Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5c64218f
|
||
|
|
03a571a6a5
|
||
|
|
517ac6fb46
|
||
|
|
46f10f145a
|
||
|
|
a9eb97e6e1
|
||
|
|
e98d43e59a
|
||
|
|
1ab748a34f
|
||
|
|
ae7ab5ce60
|
||
|
|
68200bbe62
|
||
|
|
9eea293471
|
||
|
|
4ba5ae1434
|
||
|
|
0f9fb9b6bb
|
||
|
|
322ca27bf4
|
||
|
|
0674e06565
|
||
|
|
353f012906
|
||
|
|
6427afd12b
|
||
|
|
d2dae476f0
|
||
|
|
18877b8109
|
||
|
|
b821274f0b
|
||
|
|
6222e38da3
|
||
|
|
97d4b3f217
|
||
|
|
7bb6b74fdc
|
||
|
|
b5219062f2
|
||
|
|
adc3d340b1 | ||
|
|
915a294dc8 | ||
|
|
3abbbc6b68 | ||
|
|
b43e074728 | ||
|
|
e344ce2e30 | ||
|
|
e9909cb317 | ||
|
|
2910fc4600
|
||
|
|
21911caf9e
|
||
|
|
08f158ef28
|
||
|
|
834f1f26a9
|
||
|
|
bcd1371019
|
||
|
|
3cf7e2e257
|
||
|
|
2bdf247cb3
|
||
|
|
a3e8ff3206
|
||
|
|
98d4485375
|
||
|
|
5be4926885
|
||
|
|
a3566f2083
|
||
|
|
cc0b31ce6b
|
||
|
|
8f0e974842
|
@@ -7,11 +7,11 @@
|
||||
.env.example
|
||||
**/.venv/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/*.egg-info/
|
||||
**/.mypy_cache/
|
||||
**/.pytest_cache/
|
||||
web/target/
|
||||
web/dist/
|
||||
server/tests/
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
# tavolo
|
||||
|
||||
A platform for multiplayer card games. The first game is **scopone
|
||||
scientifico** — the four-player, fixed-partnership Italian card game:
|
||||
A platform for multiplayer card games. Four games are included:
|
||||
**scopone scientifico** — the four-player, fixed-partnership Italian card
|
||||
game — **scopa**, in its classic two-player and four-player partnership
|
||||
(*scopa a quattro*) variants, with the *asso pigliatutto* option turning
|
||||
either into **scopa d'assi** — **briscola**, in its classic two-player
|
||||
and four-player partnership (*briscola a quattro*) variants, and
|
||||
**briscola chiamata** — the five-player variant with a calling auction
|
||||
and a secret partner:
|
||||
|
||||
- **`server/`** — backend: Python + [kaya](https://github.com/woggioni/kaya)
|
||||
framework, OIDC login, live games in Redis, match statistics in Postgres.
|
||||
A monorepo of three distributions sharing the `tavolo` namespace: the
|
||||
game-independent `tavolo-platform`, the scopone game `tavolo-scopone`,
|
||||
and the `tavolo-app` composition root wiring them together.
|
||||
See [server/README.md](server/README.md).
|
||||
A monorepo of five distributions sharing the `tavolo` namespace: the
|
||||
game-independent `tavolo-platform`, the `tavolo-scopone`,
|
||||
`tavolo-scopa` 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
|
||||
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
|
||||
`web/theme.css`. Also an installable PWA — see [PWA](#pwa). Card images
|
||||
are the CC0 *woodcut napoletane* deck traced from a 1902 Naples print
|
||||
([SONDLecT/woodcut-napoletane](https://github.com/SONDLecT/woodcut-napoletane)).
|
||||
|
||||
## Run the whole stack
|
||||
@@ -37,34 +44,88 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||
## Between hands
|
||||
|
||||
When a hand ends but the match is not decided, the game pauses on a
|
||||
**scoring summary screen**: every player sees how each category was won
|
||||
(carte, denara, settebello, primiera, scope — plus napola when enabled)
|
||||
with the running totals and
|
||||
must click "Understood" before the next hand is dealt. If someone is away
|
||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||
(default 30s). The match-ending hand is explained on the final screen.
|
||||
**scoring summary screen**: scopone and scopa players see how each
|
||||
category was won (carte, denara, settebello, primiera, scope — plus
|
||||
napola and asso pigliatutto when enabled), briscola players see the
|
||||
deal's points and any *patta*; both see the running totals and must click
|
||||
"Understood" before the next hand is dealt. If someone is away the next
|
||||
hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` (default
|
||||
30s). The match-ending hand is explained on the final screen. Briscola
|
||||
chiamata plays a single deal per match, so it goes straight from the last
|
||||
trick to the final scoring screen; a 60–60 *patta* is replayed
|
||||
immediately.
|
||||
|
||||
## On your turn
|
||||
|
||||
Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If 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.
|
||||
Briscola chiamata arms the same countdown for its auction turns and for
|
||||
the auction winner's called-card choice: an absent bidder passes, the
|
||||
last player standing with no bid is made to bid the ace, and a timed-out
|
||||
call names a random suit of the won rank.
|
||||
|
||||
## 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. In
|
||||
briscola chiamata they also see no role labels and no side totals until
|
||||
the called card is played, so the secret partner stays secret. 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.
|
||||
|
||||
## PWA
|
||||
|
||||
The frontend is an installable PWA: `web/manifest.webmanifest` describes
|
||||
the app (standalone display, felt theme colors, PNG icons), and `web/sw.js`
|
||||
is a caching-free service worker — it deliberately caches nothing, since
|
||||
live state always comes from the server and hashed asset names change every
|
||||
build — that also handles clicks on move notifications. Browsers offer to
|
||||
install Tavolo over HTTPS (or on localhost in development).
|
||||
|
||||
Both files must be served from the site root, or the worker's scope and the
|
||||
manifest's `start_url` could not cover the app: Granian only serves
|
||||
`/static` and `/assets`, so `tavolo/static.py` answers
|
||||
`/manifest.webmanifest` and `/sw.js` (revalidated on every load) from the
|
||||
same build directory as the shell. Icons are committed under
|
||||
`web/assets/icons/`; `web/make-icons.sh` regenerates them with
|
||||
`rsvg-convert` after editing the vector sources.
|
||||
|
||||
## Move notifications
|
||||
|
||||
Players can opt into desktop notifications for the other players' card
|
||||
plays with the bell in the game HUD (the first click asks for the browser
|
||||
permission; the preference is stored per device and applies to every
|
||||
game). Notifications only appear while the game tab is in the background
|
||||
or its window is unfocused — never while the player is watching the table
|
||||
— and only for games the viewer is seated in, never for their own plays.
|
||||
They are delivered through the service worker (so installed PWAs get them
|
||||
too) under a per-game tag, which replaces the previous move instead of
|
||||
stacking one notification per play; clicking one focuses the game tab.
|
||||
The game page must stay open to receive them, since that is where the
|
||||
live-play websocket lives.
|
||||
|
||||
## Development
|
||||
|
||||
Backend (from `server/`):
|
||||
Backend (from `server/`), using [uv](https://docs.astral.sh/uv/):
|
||||
|
||||
```sh
|
||||
cd server
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]'
|
||||
.venv/bin/python -m unittest discover -s tests -t .
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
uv sync --package tavolo-app --extra otel
|
||||
uv run python -m unittest discover -s tests -t .
|
||||
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-scopa/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-briscola/tests
|
||||
```
|
||||
|
||||
The `otel` extra is optional; without it the telemetry integration tests
|
||||
skip. Development tools (`mypy`, `httpx-ws`) are installed by default from
|
||||
the workspace's `dev` dependency group.
|
||||
|
||||
Frontend (from `web/`), with the backend running on :8080:
|
||||
|
||||
```sh
|
||||
@@ -77,10 +138,16 @@ Trunk proxies `/api`, `/auth` and `/ws` to `127.0.0.1:8080` (see
|
||||
run the backend with:
|
||||
|
||||
```sh
|
||||
OIDC_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||||
OIDC_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||||
.venv/bin/granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
||||
OIDC_0_PROVIDER=default \
|
||||
OIDC_0_ISSUER=http://localhost:8180/tavolo \
|
||||
OIDC_0_CLIENT_ID=tavolo \
|
||||
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
|
||||
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
|
||||
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
|
||||
uv run granian --host 127.0.0.1 --port 8080 tavolo.app:app
|
||||
```
|
||||
|
||||
Card images are committed under `web/assets/cards/`; `web/fetch-cards.sh`
|
||||
re-downloads them if needed.
|
||||
re-downloads them if needed. PWA icons are committed under
|
||||
`web/assets/icons/`; `web/make-icons.sh` regenerates them from the SVG
|
||||
sources.
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
|
||||
# reach the app with:
|
||||
# kubectl port-forward -n tavolo svc/tavolo 8080:80
|
||||
# and set OIDC_REDIRECT_URI to match whatever URL the browser uses.
|
||||
# and set OIDC_0_REDIRECT_URI to match whatever URL the browser uses.
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
@@ -71,22 +71,32 @@ data:
|
||||
# CORS_ALLOW_CREDENTIALS: "false"
|
||||
# CORS_EXPOSE_HEADERS: ""
|
||||
# CORS_MAX_AGE: "600"
|
||||
# OpenTelemetry (kaya-otel): traces + metrics via OTLP/HTTP, disabled
|
||||
# unless OTEL_ENABLED is set. Requires the otel extra in the image.
|
||||
# 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.
|
||||
# OTEL_ENABLED: "true"
|
||||
# OTEL_SERVICE_NAME: "tavolo"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
|
||||
# OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer ..."
|
||||
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
|
||||
# OIDC (provider lives in another namespace).
|
||||
OIDC_CLIENT_ID: tavolo
|
||||
OIDC_POST_LOGIN_REDIRECT: /
|
||||
OIDC_POST_LOGOUT_REDIRECT: /
|
||||
# OTEL_METRIC_EXPORT_INTERVAL_MILLIS: "60000"
|
||||
# OTEL_METRICS_POLL_INTERVAL_SECONDS: "15"
|
||||
# OIDC (the provider lives in another namespace). Each provider is an
|
||||
# indexed block: OIDC_<n>_PROVIDER names it in the login URL
|
||||
# (/auth/<provider>/login) and indices must be contiguous from 0. Add
|
||||
# OIDC_1_*, OIDC_2_*, ... blocks for more providers; add their secrets
|
||||
# to tavolo-secrets as well.
|
||||
OIDC_0_PROVIDER: default
|
||||
OIDC_0_CLIENT_ID: tavolo
|
||||
OIDC_0_POST_LOGIN_REDIRECT: /
|
||||
OIDC_0_POST_LOGOUT_REDIRECT: /
|
||||
# Issuer URL as seen by the browser AND the pods (see notes above).
|
||||
OIDC_ISSUER: REPLACE_ME
|
||||
OIDC_0_ISSUER: REPLACE_ME
|
||||
# Public callback URL of this deployment, e.g.
|
||||
# http://<host>:<port>/auth/callback (must be allowed at the provider).
|
||||
OIDC_REDIRECT_URI: REPLACE_ME
|
||||
# http://<host>:<port>/auth/default/callback (must be allowed at the
|
||||
# provider). When unset it defaults to
|
||||
# http://localhost:8080/auth/<provider>/callback.
|
||||
OIDC_0_REDIRECT_URI: REPLACE_ME
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
@@ -103,8 +113,8 @@ stringData:
|
||||
# The only Postgres secret: the password for DATABASE_USER at
|
||||
# DATABASE_HOST (both configured in the tavolo-config ConfigMap).
|
||||
DATABASE_PASSWORD: REPLACE_ME
|
||||
# Client secret for OIDC_CLIENT_ID at the provider.
|
||||
OIDC_CLIENT_SECRET: REPLACE_ME
|
||||
# Client secret for OIDC_0_CLIENT_ID at the provider.
|
||||
OIDC_0_CLIENT_SECRET: REPLACE_ME
|
||||
|
||||
---
|
||||
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
|
||||
|
||||
@@ -103,13 +103,15 @@ services:
|
||||
DATABASE_USER: tavolo
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
|
||||
# By default the app and browsers reach the mock IdP under the same
|
||||
# name (see README /etc/hosts note); override OIDC_ISSUER and
|
||||
# OIDC_REDIRECT_URI to use a real provider or a different host port.
|
||||
OIDC_ISSUER: ${OIDC_ISSUER:-http://mockoauth:8180/tavolo}
|
||||
# name (see README /etc/hosts note); override the OIDC_0_* values to
|
||||
# use a real provider or a different host port. Additional providers
|
||||
# take OIDC_1_*, OIDC_2_*, ... blocks (indices are contiguous from 0).
|
||||
OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default}
|
||||
OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/tavolo}
|
||||
# The mock OIDC server does not validate clients: any id/secret works.
|
||||
OIDC_CLIENT_ID: tavolo
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
||||
OIDC_0_CLIENT_ID: tavolo
|
||||
OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret}
|
||||
OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/callback}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
||||
@@ -122,8 +124,11 @@ services:
|
||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
|
||||
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
||||
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
||||
# OpenTelemetry (kaya-otel): disabled unless OTEL_ENABLED is set.
|
||||
# Requires the otel extra in the image (see server/pyproject.toml).
|
||||
# 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/uv.lock).
|
||||
OTEL_ENABLED: ${OTEL_ENABLED:-}
|
||||
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
|
||||
@@ -13,12 +13,29 @@ DATABASE_OPTIONS=
|
||||
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
|
||||
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
|
||||
|
||||
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any
|
||||
# client id/secret works. For a real IdP like Keycloak, use its values here.)
|
||||
OIDC_ISSUER=http://localhost:8180/tavolo
|
||||
OIDC_CLIENT_ID=tavolo
|
||||
OIDC_CLIENT_SECRET=dev-secret
|
||||
OIDC_REDIRECT_URI=http://localhost:8080/auth/callback
|
||||
# OIDC providers. Each provider is an indexed block: OIDC_<n>_PROVIDER
|
||||
# (required; names the provider in the login URL /auth/<provider>/login and
|
||||
# in its session state), OIDC_<n>_ISSUER, OIDC_<n>_CLIENT_ID,
|
||||
# OIDC_<n>_CLIENT_SECRET (optional: leave unset for public clients),
|
||||
# OIDC_<n>_REDIRECT_URI (defaults to
|
||||
# http://localhost:8080/auth/<provider>/callback), OIDC_<n>_POST_LOGIN_REDIRECT
|
||||
# and OIDC_<n>_POST_LOGOUT_REDIRECT (default /). Indices must be contiguous
|
||||
# from 0; add a second provider with an OIDC_1_* block (and so on).
|
||||
#
|
||||
# The values below target the dev mock-oauth2-server (it does not validate
|
||||
# clients, so any client id/secret works). For a real IdP like Keycloak,
|
||||
# use its values and register the redirect URI there.
|
||||
OIDC_0_PROVIDER=default
|
||||
OIDC_0_ISSUER=http://localhost:8180/tavolo
|
||||
OIDC_0_CLIENT_ID=tavolo
|
||||
OIDC_0_CLIENT_SECRET=dev-secret
|
||||
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback
|
||||
# A second provider, e.g. Google:
|
||||
#OIDC_1_PROVIDER=google
|
||||
#OIDC_1_ISSUER=https://accounts.google.com
|
||||
#OIDC_1_CLIENT_ID=...
|
||||
#OIDC_1_CLIENT_SECRET=...
|
||||
#OIDC_1_REDIRECT_URI=http://localhost:8080/auth/google/callback
|
||||
|
||||
# Redis (session and live-game storage). Unset to fall back to in-memory
|
||||
# stores (only sensible for local development with a single worker).
|
||||
@@ -61,6 +78,23 @@ TURN_TIMEOUT_SECONDS=30
|
||||
# Seconds browsers may cache the preflight response (default 600):
|
||||
#CORS_MAX_AGE=600
|
||||
|
||||
# OpenTelemetry (via kaya-otel + the contrib instrumentation packages).
|
||||
# Off unless OTEL_ENABLED is truthy; exports traces (HTTP/WebSocket,
|
||||
# Postgres via asyncpg, Redis), metrics and logs over OTLP/HTTP (requires
|
||||
# the tavolo-app "otel" extra, bundled in the Docker image). The endpoint
|
||||
# defaults to the standard OTEL_EXPORTER_OTLP_ENDPOINT (localhost:4318)
|
||||
# when unset.
|
||||
#OTEL_ENABLED=true
|
||||
#OTEL_SERVICE_NAME=tavolo
|
||||
#OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
|
||||
#OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer token,X-Tenant=one
|
||||
# Paths excluded from tracing and metrics (default /api/health).
|
||||
#OTEL_EXCLUDED_PATHS=/api/health
|
||||
# Metric export interval (ms, default 60000) and session/game count poll
|
||||
# interval (seconds, default 15).
|
||||
#OTEL_METRIC_EXPORT_INTERVAL_MILLIS=60000
|
||||
#OTEL_METRICS_POLL_INTERVAL_SECONDS=15
|
||||
|
||||
# App server
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8080
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -7,7 +7,7 @@
|
||||
# builder alpine python -> python venv with the backend + deps
|
||||
# runtime alpine python + the venv + the compiled frontend
|
||||
#
|
||||
# apk and pip both use BuildKit cache mounts (type=cache): the package
|
||||
# apk and uv both use BuildKit cache mounts (type=cache): the package
|
||||
# caches persist in the builder's cache across builds instead of being
|
||||
# re-downloaded, and never land in the image layers.
|
||||
|
||||
@@ -30,6 +30,9 @@ WORKDIR /web
|
||||
# Card images are committed in the repository (CC0 woodcut napoletane deck);
|
||||
# web/fetch-cards.sh can regenerate them.
|
||||
COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/theme.css web/Trunk.toml ./
|
||||
# PWA manifest and service worker: trunk's copy-file entries need them at
|
||||
# the build root, and the runtime serves both from the site root (static.py).
|
||||
COPY web/manifest.webmanifest web/sw.js ./
|
||||
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.
|
||||
@@ -46,28 +49,43 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
# --- Python builder ----------------------------------------------------------
|
||||
FROM alpine:3.24 AS builder
|
||||
|
||||
# The official uv image ships a statically linked musl binary, so it works
|
||||
# on Alpine. Pin the version so image builds do not change implicitly.
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apk \
|
||||
apk add python3 py3-pip build-base python3-dev
|
||||
apk add python3 build-base python3-dev
|
||||
|
||||
# Use Alpine's Python instead of downloading a managed interpreter, install
|
||||
# the venv at the same path the runtime stage uses, and copy rather than
|
||||
# hardlink from the cache mount (cache and target are different filesystems).
|
||||
ENV UV_PYTHON_DOWNLOADS=0 \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/venv \
|
||||
UV_LINK_MODE=copy
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# uv needs the workspace root plus the member pyprojects to know which
|
||||
# package to sync. Copying only the manifests lets the third-party
|
||||
# dependency layer stay cached while application sources change.
|
||||
# server/pyproject.toml also carries [tool.aerich], read by the db-migrate
|
||||
# compose service with working_dir=/app, next to ./migrations.
|
||||
COPY server/uv.lock server/pyproject.toml ./
|
||||
COPY server/packages/tavolo-platform/pyproject.toml packages/tavolo-platform/
|
||||
COPY server/packages/tavolo-scopone/pyproject.toml packages/tavolo-scopone/
|
||||
COPY server/packages/tavolo-scopa/pyproject.toml packages/tavolo-scopa/
|
||||
COPY server/packages/tavolo-briscola/pyproject.toml packages/tavolo-briscola/
|
||||
COPY server/packages/tavolo-app/pyproject.toml packages/tavolo-app/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-install-workspace --package tavolo-app --extra otel --no-dev
|
||||
|
||||
COPY server/requirements.txt ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --upgrade pip \
|
||||
&& /opt/venv/bin/pip install -r requirements.txt
|
||||
|
||||
# The three sibling distributions, installed from the local tree (PEP 420
|
||||
# namespace packages sharing the `tavolo` namespace). --no-deps: every
|
||||
# third-party dependency is already pinned in requirements.txt.
|
||||
# The five sibling distributions are workspace members (PEP 420 namespace
|
||||
# packages sharing the `tavolo` namespace). --no-editable installs them as
|
||||
# wheels, so the runtime image does not need their source tree.
|
||||
COPY server/packages/ ./packages/
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-app
|
||||
# server/pyproject.toml only carries the [tool.aerich] section (the
|
||||
# db-migrate compose service runs `aerich upgrade` with working_dir=/app,
|
||||
# next to ./migrations).
|
||||
COPY server/pyproject.toml ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-editable --package tavolo-app --extra otel --no-dev
|
||||
|
||||
# aerich migration files are a release artifact: the db-migrate compose
|
||||
# service runs `aerich upgrade` from this image before the app starts.
|
||||
COPY server/migrations/ ./migrations/
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# tavolo (backend)
|
||||
|
||||
The backend for a multiplayer card-game platform, built on the
|
||||
[kaya](../kaya) framework. It is a monorepo of three distributions
|
||||
[kaya](../kaya) framework. It is a monorepo of five distributions
|
||||
sharing the `tavolo` namespace (see
|
||||
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md)
|
||||
and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)):
|
||||
[`packages/tavolo-platform/README.md`](packages/tavolo-platform/README.md),
|
||||
[`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md),
|
||||
[`packages/tavolo-scopa/README.md`](packages/tavolo-scopa/README.md)
|
||||
and
|
||||
[`packages/tavolo-briscola/README.md`](packages/tavolo-briscola/README.md)):
|
||||
|
||||
- **`tavolo-platform`** (`packages/tavolo-platform/`) — everything
|
||||
game-independent: the lobby API, the live-game store, the WebSocket
|
||||
@@ -13,8 +16,17 @@ and [`packages/tavolo-scopone/README.md`](packages/tavolo-scopone/README.md)):
|
||||
- **`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-scopa`** (`packages/tavolo-scopa/`) — scopa, the classic
|
||||
Italian fishing game, in its two-player (`scopa`) and four-player
|
||||
fixed-partnership (`scopa_a_quattro`) variants, with the optional asso
|
||||
pigliatutto rule (scopa d'assi): pure rules plus the platform adapters.
|
||||
- **`tavolo-briscola`** (`packages/tavolo-briscola/`) — briscola, the
|
||||
Italian trick-taking game, in its classic two-player (`briscola`),
|
||||
four-player partnership (`briscola_a_quattro`) and five-player
|
||||
called-partner (`briscola_chiamata`) variants: pure rules plus the
|
||||
platform adapter.
|
||||
- **`tavolo-app`** (`packages/tavolo-app/`) — the
|
||||
composition root wiring the platform to the scopone game, with 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
|
||||
@@ -43,12 +55,15 @@ Because the browser and the app both talk to the OIDC issuer at
|
||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
Then log in: `GET http://localhost:8080/auth/login`, type any username
|
||||
Then log in: `GET http://localhost:8080/auth/login` (a provider-agnostic
|
||||
alias that redirects to the first configured provider), type any username
|
||||
(e.g. `alice`) in the interactive login, and you are redirected back with a
|
||||
session cookie. That cookie authenticates both the REST API and the
|
||||
WebSocket endpoint.
|
||||
|
||||
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`).
|
||||
OpenAPI documentation is served at `/api/docs` (`/api/openapi.json`); every
|
||||
endpoint documents its request body and its response bodies as named
|
||||
component schemas.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -65,14 +80,17 @@ All configuration comes from environment variables (see `.env.example`):
|
||||
| `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) |
|
||||
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
|
||||
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
|
||||
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
|
||||
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
||||
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
||||
| `OIDC_<n>_PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth/<provider>/login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` |
|
||||
| `OIDC_<n>_ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL |
|
||||
| `OIDC_<n>_CLIENT_ID` | `tavolo` ¹ | OIDC client id |
|
||||
| `OIDC_<n>_CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients |
|
||||
| `OIDC_<n>_REDIRECT_URI` | `http://localhost:8080/auth/<provider>/callback` | Login callback URL; must be registered at the provider |
|
||||
| `OIDC_<n>_POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login |
|
||||
| `OIDC_<n>_POST_LOGOUT_REDIRECT` | `/` | Where the browser is sent after logout |
|
||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the `ScoponeEngine`) |
|
||||
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a scopone player has to play before the server plays a random legal card for them (wired into the `ScoponeEngine`) |
|
||||
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
|
||||
| `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 (wired into the game engines) |
|
||||
| `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 |
|
||||
| `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` |
|
||||
@@ -81,13 +99,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_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
||||
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
||||
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (requires the `otel` extra, i.e. `pip install tavolo-app[otel]`) |
|
||||
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces, metrics and logs (the Docker image bundles the `otel` extra; non-image installs need `uv sync --package tavolo-app --extra 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 |
|
||||
|
||||
¹ 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
|
||||
|
||||
The application logs through the Python stdlib `logging` module, one
|
||||
@@ -123,6 +181,37 @@ loggers:
|
||||
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
|
||||
|
||||
### Redis (live games)
|
||||
@@ -137,12 +226,15 @@ loggers:
|
||||
signals; every open WebSocket reloads the session and pushes the
|
||||
personalized view to its player.
|
||||
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
|
||||
timeouts (e.g. scopone's turn auto-plays and hand-end auto-continues).
|
||||
timeouts (e.g. the games' turn auto-plays and hand-end auto-continues).
|
||||
Every worker runs a consumer that fires due entries under the per-game
|
||||
lock by calling the engine's `fire_deadline`, so timeouts do not depend
|
||||
on any player being connected and survive the death of any worker
|
||||
(delivery is at-least-once; engines revalidate the entry's token
|
||||
against the live state before firing).
|
||||
- `tavolo:deadlines:events` — a pub/sub channel announcing newly enqueued
|
||||
deadlines; it wakes every worker's consumer immediately, while the
|
||||
heartbeat poll remains as a backstop for lost signals.
|
||||
|
||||
### Postgres (statistics, via Tortoise ORM + aerich migrations)
|
||||
|
||||
@@ -151,13 +243,20 @@ loggers:
|
||||
scoped per game), start/finish timestamps, and the game-specific outcome
|
||||
as reported by the engine (`result` JSON — for scopone: both teams'
|
||||
final scores, winner, target score, hands played and the per-hand
|
||||
audit).
|
||||
audit; for scopa: both sides' final scores, winner, target score, the
|
||||
asso pigliatutto flag, hands played and the per-hand audit; for
|
||||
briscola: the final scores, winner, target score, deals played and the
|
||||
per-deal audit; for briscola chiamata: the calling side's points
|
||||
against the defenders', the declared target, the called card and
|
||||
whether the caller played alone).
|
||||
- `match_player` — one row per participant: the OIDC `sub`, display name,
|
||||
seat, game-defined team label (nullable), whether they won, the points
|
||||
they scored, the Elo change the match produced (`elo_delta`) and
|
||||
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
||||
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
|
||||
the number of rated matches played.
|
||||
- `player_rating` — current Elo rating and leaderboard totals per
|
||||
`(user_sub, game_type)`: the rating, matches played, wins, accumulated
|
||||
points and the most recent display name. Kept in sync with the match
|
||||
transaction, so the leaderboard is a single indexed table query.
|
||||
|
||||
When a match ends, the engine's result is written transactionally to
|
||||
Postgres (once, guarded by a flag on the session); the finished session
|
||||
@@ -171,49 +270,60 @@ Players carry a chess-style Elo rating per game type
|
||||
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):
|
||||
a team, zero-sum between the two teams. Ratings and the leaderboard
|
||||
aggregates update in the same transaction as the match result. To
|
||||
rebuild every rating and aggregate from the recorded match history (e.g.
|
||||
to backfill matches recorded before ratings existed):
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m tavolo.platform.backfill_elo \
|
||||
uv run python -m tavolo.platform.backfill_elo \
|
||||
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 21, "napola": true, "asso_pigliatutto": false}}`; scopa games use `{"game_type": "scopa"}` (target default 11) or `{"game_type": "scopa_a_quattro"}` (target default 21) with an optional `{"target_score": 11, "asso_pigliatutto": false}` (the asso pigliatutto rule makes it scopa d'assi); briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
||||
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
||||
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
|
||||
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
||||
| `GET` | `/api/games/{id}` | 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/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included |
|
||||
| `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=`) |
|
||||
| `GET` | `/api/leaderboard` | Elo rating, wins / matches / points per rated player, sorted by Elo (`?game_type=`, `?page=`, `?limit=`, `?focus=me`). The response carries the requested page, the page count and — when authenticated — the caller's own rank and page; `focus=me` returns the caller's page directly |
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
||||
players are accepted; unauthenticated, unknown-game and non-player
|
||||
connections are closed with codes `4401`, `4404` and `4403` respectively.
|
||||
Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone
|
||||
who knows the game id is accepted: seated players get their personalized
|
||||
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`:
|
||||
|
||||
- `{"type": "state", "game": {...}}` — personalized game view. Your own
|
||||
hand is included; other players expose only their card counts.
|
||||
`your_turn: true` is present when it is your move.
|
||||
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": "error", "code": "illegal_move", "message": "..."}`
|
||||
|
||||
Client → server messages are JSON objects with an `action`. The
|
||||
platform handles `state` (resend the snapshot) itself; every other action
|
||||
is dispatched to the session's game engine with the rest of the message
|
||||
as its payload. For scopone scientifico:
|
||||
as its payload. Spectators may only send `state`/`sync`: any other
|
||||
action is answered with an `error` whose `code` is `forbidden`. For
|
||||
scopone scientifico and scopa (both variants):
|
||||
|
||||
```json
|
||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||
@@ -232,7 +342,39 @@ as its payload. For scopone scientifico:
|
||||
after `HAND_ACK_TIMEOUT_SECONDS`.
|
||||
- `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`.
|
||||
|
||||
For briscola chiamata the game actions are the calling auction plus play:
|
||||
|
||||
```json
|
||||
{"action": "bid", "rank": 3}
|
||||
{"action": "bid", "points": 64}
|
||||
{"action": "pass"}
|
||||
{"action": "call", "card": "03D"}
|
||||
{"action": "play", "card": "04C"}
|
||||
```
|
||||
|
||||
- `bid` with a `rank` calls a lower rank than the standing bid
|
||||
(`A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2`); with `points` it raises the
|
||||
target once the standing bid is the two.
|
||||
- `pass` leaves the auction; the last player standing must bid.
|
||||
- `call` names any card of the won rank; naming one of your own cards
|
||||
means playing alone.
|
||||
- `play` follows briscola: any card in hand is legal.
|
||||
|
||||
After every accepted move the new state is broadcast to every connected
|
||||
client, spectators included.
|
||||
|
||||
### Turn timeout
|
||||
|
||||
@@ -243,20 +385,26 @@ is required), so a disconnected or idle player cannot stall the match. The
|
||||
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||
queue (see above), not from timers tied to client connections, so the
|
||||
match keeps progressing even with every player disconnected.
|
||||
match keeps progressing even with every player disconnected. Briscola
|
||||
chiamata uses the same timeout for its auction turns (an absent bidder
|
||||
passes; the last player standing with no bid is made to bid the ace) and
|
||||
for the called-card choice (a random suit of the won rank).
|
||||
|
||||
### Hand-end summary
|
||||
|
||||
When a hand finishes but the match continues, the game enters the
|
||||
`hand_end` phase instead of dealing immediately: the state carries
|
||||
`last_hand` (a full scoring breakdown with an `award` map naming the team
|
||||
that won each category), the `acknowledged` seats and a
|
||||
`hand_end_deadline`. The frontend renders this as a screen every player
|
||||
must dismiss. A play attempted in this phase is rejected with an
|
||||
`illegal_move` error.
|
||||
`hand_end` phase instead of dealing immediately: the state carries the
|
||||
scoring breakdown of the hand just played (`last_hand` with an `award`
|
||||
map naming the side that won each scopone/scopa category; `last_deal`
|
||||
with the deal's points and running totals for briscola), the
|
||||
`acknowledged` seats and a `hand_end_deadline`. The frontend renders this
|
||||
as a screen every player must dismiss. A play attempted in this phase is
|
||||
rejected with an `illegal_move` error.
|
||||
|
||||
## Rules implemented
|
||||
|
||||
### Scopone scientifico
|
||||
|
||||
- 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)
|
||||
or a **combination of table cards whose ranks sum to its own**.
|
||||
@@ -270,26 +418,126 @@ must dismiss. A play attempted in this phase is rejected with an
|
||||
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
|
||||
A team that captures the whole denari suit (ace to king) wins the match
|
||||
instantly, regardless of the score.
|
||||
- The match ends when a team reaches the target score (default 11,
|
||||
configurable per game via the `target_score` creation option) with a
|
||||
clear lead; a tie at or above the target is broken by another hand.
|
||||
- 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 21,
|
||||
the traditional partnership target, configurable per game via the
|
||||
`target_score` creation option) with a clear lead; a tie at or above
|
||||
the target is broken by another hand.
|
||||
|
||||
### Scopa
|
||||
|
||||
- 40-card Italian deck; three cards per player and four cards face up on
|
||||
the table at the start of every hand. When every hand is empty the
|
||||
dealer deals three more cards to each player (no new table cards) until
|
||||
the deck is exhausted: two players play six rounds per hand, four
|
||||
players three.
|
||||
- A card captures a **single card of equal rank** (mandatory when present)
|
||||
or a **combination of table cards whose ranks sum to its own**; when any
|
||||
capture exists it must be taken. Remaining table cards at the end of the
|
||||
hand go to the player who made the last capture.
|
||||
- Emptying the table scores a *scopa* (+1), except on the last play of the
|
||||
hand (a sweep on the last play of a round still scores when the deck has
|
||||
cards left).
|
||||
- Hand points: `carte`, `denara`, `settebello` and `primiera` (same values
|
||||
and all-four-suits requirement as scopone), plus one point per scopa.
|
||||
Ties award nothing. There is no napola.
|
||||
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
|
||||
option, default off; scopa d'assi): 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 side reaches the target score (default 11 points
|
||||
for two-player scopa and 21 for scopa a quattro, the traditional
|
||||
partnership target; configurable per game via the `target_score`
|
||||
creation option) with a clear lead; a tie at or above the target is
|
||||
broken by another hand.
|
||||
- `scopa` is the classic two-player game; `scopa_a_quattro` seats four
|
||||
players in two fixed partnerships (seats 0/2 vs 1/3, partners
|
||||
opposite).
|
||||
|
||||
### 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).
|
||||
|
||||
### Briscola chiamata
|
||||
|
||||
- 40-card Italian deck, eight cards each for five players: the whole deck
|
||||
is dealt, so there is no stock and no face-up card.
|
||||
- The calling auction starts with the player after the dealer: each
|
||||
player either passes (leaving the auction) or bids a lower rank than
|
||||
the standing bid (*A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*); once the
|
||||
standing bid is the two, the auction continues on points (62, 64, …).
|
||||
The last player standing must bid, so every deal gets a caller.
|
||||
- The winner names any card of the won rank. If the winner holds it, the
|
||||
caller plays alone against the other four; otherwise its holder is the
|
||||
secret partner and the two form the calling side. The called card's
|
||||
suit is the trump; the partner's identity stays hidden until the
|
||||
called card is played.
|
||||
- Play and trick resolution follow briscola; eight tricks exhaust every
|
||||
hand. The calling side must reach the declared target (61 unless
|
||||
raised); with the plain target a 60–60 deal is a *patta* and is
|
||||
replayed with the next dealer. A match is a single deal, so the caller
|
||||
and partner (or the lone caller) against the other players map onto
|
||||
the platform's two-team result, Elo and history.
|
||||
- Auction turns, the called-card choice and the plays all have their own
|
||||
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
|
||||
player standing with no bid is made to bid the ace, an absent winner's
|
||||
call names a random suit of the won rank, and plays are auto-played.
|
||||
|
||||
## Development
|
||||
|
||||
The backend is a [uv](https://docs.astral.sh/uv/) workspace: `pyproject.toml`
|
||||
declares the five packages under `packages/` as members, and `uv.lock` pins
|
||||
their full dependency closure. The checked-in `.python-version` selects
|
||||
Python 3.14, matching the Docker image. Install uv, then from `server/`:
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e './packages/tavolo-app[dev]'
|
||||
uv sync --package tavolo-app --extra otel
|
||||
# app integration suite:
|
||||
.venv/bin/python -m unittest discover -s tests -t .
|
||||
uv run python -m unittest discover -s tests -t .
|
||||
# per-package suites (each package is self-contained):
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
|
||||
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
uv run python -m unittest discover -s packages/tavolo-scopa/tests
|
||||
uv run 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
|
||||
uv run mypy -p tavolo.platform
|
||||
uv run mypy -p tavolo.scopone
|
||||
uv run mypy -p tavolo.scopa
|
||||
uv run mypy -p tavolo.briscola
|
||||
```
|
||||
|
||||
`uv sync` installs the workspace's `dev` dependency group (`mypy`,
|
||||
`httpx-ws`) automatically; pass `--no-dev` for a runtime-only environment.
|
||||
The `otel` extra is optional, but without it the telemetry integration tests
|
||||
skip. The lockfile is committed: use `uv lock --upgrade` (or
|
||||
`uv lock --upgrade-package <name>`) to update dependencies deliberately.
|
||||
|
||||
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
|
||||
for Redis, a fake OIDC user patched onto the mixins, and `httpx` /
|
||||
`httpx-ws` ASGI transports for HTTP and WebSocket coverage. The platform
|
||||
@@ -304,8 +552,8 @@ To add a migration after changing
|
||||
`packages/tavolo-platform/src/tavolo/platform/models.py`:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich migrate
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo uv run aerich upgrade
|
||||
```
|
||||
|
||||
(`aerich init-db` produces sqlite-flavored DDL when pointed at sqlite;
|
||||
@@ -325,7 +573,7 @@ packages/tavolo-platform/ # game-independent platform (own pyproject + tests
|
||||
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
|
||||
├── elo.py # chess-style Elo math (1500 start, K=32)
|
||||
├── stats.py # MatchResult -> Postgres persistence + Elo update
|
||||
├── backfill_elo.py # recompute all ratings from the match history
|
||||
├── backfill_elo.py # recompute all ratings and aggregates from match history
|
||||
├── store.py # Redis / in-memory live-session store (+ deadline queue)
|
||||
├── deadlines.py # connection-independent timeout scheduler
|
||||
├── ws.py # WebSocket live-play endpoint (generic envelope)
|
||||
@@ -345,14 +593,29 @@ packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
|
||||
├── engine.py # pure scopone scientifico rules
|
||||
├── errors.py # scopone-specific rule violations
|
||||
└── plugin.py # ScoponeEngine: the GameEngine adapter
|
||||
packages/tavolo-scopa/ # scopa game (own pyproject + tests)
|
||||
src/tavolo/scopa/
|
||||
├── state.py # ScopaState / PlayerState / Card / Move, JSON (de)serialization
|
||||
├── engine.py # pure scopa rules (two- and four-player)
|
||||
├── errors.py # scopa-specific rule violations
|
||||
└── plugin.py # ScopaEngine + ScopaQuattroEngine adapters
|
||||
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)
|
||||
├── chiamata.py # pure briscola chiamata rules (5-player, auction, secret partner)
|
||||
├── errors.py # briscola-specific rule violations
|
||||
└── plugin.py # BriscolaEngine + BriscolaQuattroEngine + BriscolaChiamataEngine
|
||||
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
|
||||
pyproject.toml # uv workspace root + [tool.aerich] / [tool.mypy]
|
||||
uv.lock # locked dependency set for the whole workspace
|
||||
migrations/ # aerich migrations for the platform models
|
||||
tests/ # app integration suite (platform + scopone wired together)
|
||||
tests/ # app integration suite (platform + games wired together)
|
||||
```
|
||||
|
||||
@@ -10,7 +10,8 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
||||
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||
os.environ.setdefault("OIDC_0_PROVIDER", "default")
|
||||
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo")
|
||||
os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo")
|
||||
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
|
||||
os.environ.pop("REDIS_URL", None)
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"claims": {
|
||||
"sub": "alice",
|
||||
"preferred_username": "alice",
|
||||
"name": "Alice"
|
||||
"name": "Alice",
|
||||
"picture": "https://i.pravatar.cc/128?u=alice"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -20,7 +21,8 @@
|
||||
"claims": {
|
||||
"sub": "bob",
|
||||
"preferred_username": "bob",
|
||||
"name": "Bob"
|
||||
"name": "Bob",
|
||||
"picture": "https://i.pravatar.cc/128?u=bob"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX IF EXISTS "idx_player_rati_game_ty_3326d1";
|
||||
ALTER TABLE "player_rating" ADD "points" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "player_rating" ADD "display_name" VARCHAR(200) NOT NULL DEFAULT '';
|
||||
ALTER TABLE "player_rating" ADD "wins" INT NOT NULL DEFAULT 0;
|
||||
-- Backfill the leaderboard aggregates from the match history:
|
||||
-- existing rows keep their rating and matches, while players from
|
||||
-- matches recorded before ratings existed are inserted at the
|
||||
-- initial rating (1500) so they stay on the board.
|
||||
INSERT INTO "player_rating" (
|
||||
"id", "user_sub", "game_type", "rating", "matches_played",
|
||||
"wins", "points", "display_name", "updated_at"
|
||||
)
|
||||
SELECT gen_random_uuid(), agg.user_sub, agg.game_type, 1500,
|
||||
agg.matches, agg.wins, agg.points, agg.display_name, NOW()
|
||||
FROM (
|
||||
SELECT mp.user_sub AS user_sub,
|
||||
m.game_type AS game_type,
|
||||
COUNT(*) AS matches,
|
||||
SUM(CASE WHEN mp.won THEN 1 ELSE 0 END) AS wins,
|
||||
SUM(mp.score) AS points,
|
||||
(ARRAY_AGG(mp.display_name ORDER BY m.finished_at DESC, m.id DESC))[1]
|
||||
AS display_name
|
||||
FROM "match_player" mp
|
||||
JOIN "match" m ON m.id = mp.match_id
|
||||
GROUP BY mp.user_sub, m.game_type
|
||||
) agg
|
||||
ON CONFLICT ("user_sub", "game_type") DO UPDATE
|
||||
SET "wins" = EXCLUDED."wins",
|
||||
"points" = EXCLUDED."points",
|
||||
"display_name" = EXCLUDED."display_name";
|
||||
CREATE INDEX "idx_player_rati_game_ty_abcb75" ON "player_rating" ("game_type", "rating", "wins", "points", "matches_played", "user_sub");"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
DROP INDEX IF EXISTS "idx_player_rati_game_ty_abcb75";
|
||||
ALTER TABLE "player_rating" DROP COLUMN "points";
|
||||
ALTER TABLE "player_rating" DROP COLUMN "display_name";
|
||||
ALTER TABLE "player_rating" DROP COLUMN "wins";
|
||||
CREATE INDEX "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"eJztmm1v4jgQgP9KlE89qVcBfdnV6XRSoFTLLZQK6N1pq1VkEkOtGjubOMeiXv/72c67k/"
|
||||
"DSEgoVH7ZLxh7HeSYez4zzrM+oDbF31gPMetR/0551AmaQ/8g2nGo6cJxELAQMjHHQM+4y"
|
||||
"9pgLLMaFE4A9yEU29CwXOQxRIroamkVnDoYM2ppU0+hEowSK/9gj1Fw4RR6DLm+e8nlobO"
|
||||
"FA70yMbVOLD47I9G3D+AT98KHJ6BTyji4f7OE7FyNiw5/Qiy6dJ3OCILYzQJAtBpByUwwo"
|
||||
"ZPf3nesb2VNMcWxaFPszkvR2FuyRkri77yP7TOiItikk0AX8EVK4iI9xiDUSBTPmAub6MJ"
|
||||
"6qnQhsOAE+FtD13yc+sQRrTd5J/Ln4I5xaqptp3vZH5rA9Mk09ZyMxBYV3KLIoEfZFhAlQ"
|
||||
"zy/BuAkQKdXFDVpfjMHJ+dUvEgH12NSVjRKX/iIVAQOBqoSeUBb2CnjlYLcegVsMO6OkMO"
|
||||
"dTfg3tSLAEd0SqArZ8Tf00MSRTJhbleWMJ67+MQYC7IXFTvgCDZXkbtjRkk6CeUPYYcDkL"
|
||||
"E7A85mtOiaEZLEad1VRY26HqWfRjO+QTZ7ID9EtQjzq99nBk9O7E8DPP+4ElL2PUFi0NKV"
|
||||
"0o0pMrxSzxINrfndEXTVxq3/q3bXWtxP1G33QxJ+AzahI6N4GdZhKJI1HGzBNEkPf4Kjsr"
|
||||
"qkdD77Wh+VCCTc7Gfw77t8X2TTRU0yKLaf9pmG+gVRk1tU+NfYQZIt6ZuO17bFWCUMbKkd"
|
||||
"s86Rn/qB611e03VfOJAZrcvYq4YfKU2tOEYAyspzlwbTPTktjNwWABXS9vuGaoePN1ADGQ"
|
||||
"z5s3VTpEu5MjHe4yTKThViuB0gYtI5pvmjVmqgQQMJWPJO4t7lSArCzoTYiuCH1NJ+m5Mg"
|
||||
"K+49snspAjbRqFrb4HXQ0R+VsOmQ95N9AriHEfkiBd6JieP9a/HwPffQp8Y7tsEPemdT5e"
|
||||
"2Nu4vFwj7uW9SgNf2ZaNfG3kieVqyusNUKt6VeHeqe9VeNdq6/Cu1cp5izYl04BFsedwBj"
|
||||
"DuEFaSY8DCoFPsDQfIeCom8et549PVZ94q5yguPi1hPewZ3W7ndqSwZBDMNnlno/6velfD"
|
||||
"db83GCvOiOdBhKVEYZRiCEgx3XlhTDbmKgf4li7B2ez3u5kIudkZKVzve8324KQucfNOiE"
|
||||
"lx/gX2LOoWeN0bTEGZK4g0FMwToVIV59ruIV/375vdtnY3aLc6w06YksQppmzMwh20ja5C"
|
||||
"F2LK54wZ2NTfZhRf5XT3zVlsz+fakAGECzK08tQ6pXLMrbeSW6cNEqRcm+UkaZ1tZib767"
|
||||
"RXJCK5YoXCt8BHcz+MpuQrXEjEHT4RQKwi16weHB0m1Fw9gotdMI/T48w7xZ+ePzMMPHPL"
|
||||
"GLaM67b+Ul4AqrK0EZQtBoAFD5mrbWTaT5cVN4KyhukmXVdWN1q+60LCtDamWqCnAWJrPI"
|
||||
"iyoTum/OE0RhnXjSoYwT00h/8Thzj5usdWRiysiKST5+QASa2KPGQPlxIYc+5Zg9Ul35zT"
|
||||
"8I2AXlAOsvO1lmNx5Vhc2Z8UqpLqyv6e3n6oZDXxQlnGpRF+orC7mkr9slZVIhXG9436xa"
|
||||
"eLz+dXF3GQH0uWRfr5ID/vudfkmlfcHd9DgRttlGsijffVI0gFZBJprF0+SVSO9ZOV9ZPD"
|
||||
"OxrQq4rCdnAw4Dvi25HXfJqS1fyAX6boLs9u+gQv9PAovBz+nn+pEh/kr/llRJWJsQFdVP"
|
||||
"yNa9hyuiwZBkmfVVlwuZ23/PVp6R5amCYW7KBhrve2/LD6Ou4bd9DytPBf6Hqo6OCn3Nmn"
|
||||
"VD5gqlJJUigW1QaEw+4fkG59rX20vmQfref3UX5HBslG3/6lVHZ/QPE+9fD3+Mxv+5vZy/"
|
||||
"+g2+dS"
|
||||
)
|
||||
@@ -2,15 +2,24 @@
|
||||
|
||||
The deployable tavolo application: the composition root wiring
|
||||
[`tavolo-platform`](../tavolo-platform/README.md) to the
|
||||
[`tavolo-scopone`](../tavolo-scopone/README.md) game.
|
||||
[`tavolo-scopone`](../tavolo-scopone/README.md),
|
||||
[`tavolo-scopa`](../tavolo-scopa/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` with its configured timeouts, and exposes
|
||||
`tavolo.app:app` for granian.
|
||||
`ScoponeEngine`, `ScopaEngine`, `ScopaQuattroEngine`, `BriscolaEngine`,
|
||||
`BriscolaQuattroEngine` and `BriscolaChiamataEngine` 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`).
|
||||
@@ -20,9 +29,10 @@ The deployable tavolo application: the composition root wiring
|
||||
## Development (from `packages/tavolo-app/`)
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m mypy --namespace-packages --explicit-package-bases \
|
||||
uv run 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
|
||||
|
||||
@@ -5,12 +5,14 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "tavolo-app"
|
||||
version = "0.1.0"
|
||||
description = "Tavolo multiplayer card-game application: platform + scopone game wiring"
|
||||
description = "Tavolo multiplayer card-game application: platform + games wiring"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"tavolo-platform",
|
||||
"tavolo-scopone",
|
||||
"tavolo-scopa",
|
||||
"tavolo-briscola",
|
||||
"kaya-core",
|
||||
"kaya-cors",
|
||||
"kaya-session",
|
||||
@@ -29,13 +31,18 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.7",
|
||||
"opentelemetry-instrumentation-asyncpg>=0.66b0",
|
||||
"opentelemetry-instrumentation-redis>=0.66b0",
|
||||
"opentelemetry-instrumentation-system-metrics>=0.66b0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy",
|
||||
"httpx-ws",
|
||||
]
|
||||
otel = [
|
||||
"kaya-otel>=0.0.4",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -6,7 +6,7 @@ platform:
|
||||
- :class:`~kaya.session.SessionMixin` (sessions persisted in Redis via
|
||||
:class:`~kaya.session.redis.RedisSessionStore` when ``REDIS_URL`` is set,
|
||||
otherwise an in-memory store — e.g. for tests)
|
||||
- :class:`~kaya.oidc.OIDCMixin` (OIDC login)
|
||||
- :class:`~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)
|
||||
@@ -31,23 +31,37 @@ from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.cors import CorsMixin
|
||||
from kaya.oidc import OIDCConfig, OIDCMixin
|
||||
from kaya.oidc import OIDCConfig, OIDCProviders
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
||||
from kaya.session.redis import RedisSessionStore
|
||||
from kaya.session import SessionMixin
|
||||
from redis.asyncio import Redis
|
||||
from tavolo.briscola import (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
from tavolo.platform import GameRegistry, Platform, PlatformMixin
|
||||
from tavolo.platform.deadlines import DeadlineScheduler, DeadlineSchedulerMixin
|
||||
from tavolo.platform.openapi import SCHEMAS
|
||||
from tavolo.platform.store import GameStore, InMemoryGameStore, RedisGameStore
|
||||
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
|
||||
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__)
|
||||
@@ -73,13 +87,23 @@ def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
|
||||
)
|
||||
|
||||
|
||||
def otel_mixin_from_settings(settings: Settings) -> Optional[KayaMixin]:
|
||||
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
|
||||
@@ -90,17 +114,21 @@ def otel_mixin_from_settings(settings: Settings) -> Optional[KayaMixin]:
|
||||
"OTEL_ENABLED is set but kaya-otel is not installed; "
|
||||
"install tavolo-app with the 'otel' extra"
|
||||
) from exc
|
||||
headers = dict(
|
||||
pair.split("=", 1)
|
||||
for pair in (settings.otel_exporter_headers or ())
|
||||
if "=" in pair
|
||||
)
|
||||
return OTelMixin(
|
||||
service_name=settings.otel_service_name,
|
||||
endpoint=settings.otel_exporter_endpoint,
|
||||
headers=headers or None,
|
||||
excluded_paths=settings.otel_excluded_paths,
|
||||
)
|
||||
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()
|
||||
@@ -110,19 +138,49 @@ registry.register(
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ScopaEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
ScopaQuattroEngine(
|
||||
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,
|
||||
)
|
||||
)
|
||||
registry.register(
|
||||
BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=settings.turn_timeout_seconds,
|
||||
hand_ack_timeout_seconds=settings.hand_ack_timeout_seconds,
|
||||
)
|
||||
)
|
||||
log.info("registered games: %s", ", ".join(e.id for e in registry.all()))
|
||||
log.debug(
|
||||
"scopone timeouts: hand_ack=%ds turn=%ds",
|
||||
"game timeouts: hand_ack=%ds turn=%ds",
|
||||
settings.hand_ack_timeout_seconds,
|
||||
settings.turn_timeout_seconds,
|
||||
)
|
||||
|
||||
session_store: SessionStore
|
||||
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 = RedisSessionStore(Redis.from_url(settings.redis_url))
|
||||
session_store = CountingRedisSessionStore(Redis.from_url(settings.redis_url))
|
||||
game_store = RedisGameStore(
|
||||
Redis.from_url(settings.redis_url, decode_responses=False),
|
||||
registry,
|
||||
@@ -130,29 +188,41 @@ if settings.redis_url is not None:
|
||||
)
|
||||
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
|
||||
else:
|
||||
session_store = InMemorySessionStore()
|
||||
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_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,
|
||||
),
|
||||
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,
|
||||
# kaya-oidc defaults to openid+email+profile; the app neither
|
||||
# needs nor stores the email claim (profile covers the names).
|
||||
scopes=("openid", "profile"),
|
||||
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",
|
||||
components={"schemas": SCHEMAS},
|
||||
)
|
||||
tortoise_mixin = TortoiseMixin(
|
||||
database_url=settings.database_url,
|
||||
@@ -171,20 +241,29 @@ platform = Platform(
|
||||
registry=registry,
|
||||
game_store=game_store,
|
||||
scheduler=scheduler,
|
||||
oidc=oidc_mixin,
|
||||
oidc=oidc,
|
||||
)
|
||||
|
||||
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
||||
mixins: list[KayaMixin] = [session_mixin, oidc, tortoise_mixin, openapi_mixin,
|
||||
PlatformMixin(platform), DeadlineSchedulerMixin(scheduler)]
|
||||
otel_mixin = otel_mixin_from_settings(settings)
|
||||
if otel_mixin is not None:
|
||||
# First in the list: before hooks run in registration order (after hooks
|
||||
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.
|
||||
# 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",
|
||||
"OpenTelemetry enabled: service=%s endpoint=%s (traces, metrics, logs)",
|
||||
settings.otel_service_name,
|
||||
settings.otel_exporter_endpoint or "(OTLP default)",
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -39,6 +40,135 @@ def _env_bool(name: str, default: bool = False) -> bool:
|
||||
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],
|
||||
@@ -70,15 +200,10 @@ def _database_url_from_parts(engine: str,
|
||||
@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
|
||||
# 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]
|
||||
@@ -90,17 +215,17 @@ class Settings:
|
||||
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
|
||||
static_dir: str
|
||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||
# before dealing the next hand anyway (wired into the ScoponeEngine;
|
||||
# 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
|
||||
# ScoponeEngine; see tavolo.app).
|
||||
# game engines; see tavolo.app).
|
||||
turn_timeout_seconds: int
|
||||
# Upper bound on how long the deadline consumer sleeps between polls.
|
||||
# Locally enqueued deadlines wake the consumer immediately; the
|
||||
# heartbeat only bounds the discovery delay for deadlines enqueued by
|
||||
# other workers.
|
||||
# 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.
|
||||
@@ -125,6 +250,10 @@ class Settings:
|
||||
# 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":
|
||||
@@ -143,12 +272,7 @@ class Settings:
|
||||
# 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", "/"),
|
||||
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
|
||||
@@ -159,7 +283,7 @@ class Settings:
|
||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
||||
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
|
||||
@@ -171,14 +295,20 @@ class Settings:
|
||||
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
|
||||
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
|
||||
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
|
||||
# OpenTelemetry is opt-in: set OTEL_ENABLED=1 to export traces
|
||||
# and metrics via OTLP/HTTP (requires the ``otel`` extra).
|
||||
# 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")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Configured once at app import time (see :mod:`tavolo.app`). By default a
|
||||
single console handler at DEBUG level is installed, formatting records as
|
||||
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``.
|
||||
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``;
|
||||
``urllib3.connectionpool`` is capped at INFO so its per-request DEBUG
|
||||
chatter does not drown the log.
|
||||
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
|
||||
over the configuration entirely; the file follows the
|
||||
:data:`logging.config.dictConfig` schema, e.g.::
|
||||
@@ -55,6 +57,13 @@ DEFAULT_CONFIG = {
|
||||
"level": "DEBUG",
|
||||
"handlers": ["console"],
|
||||
},
|
||||
"loggers": {
|
||||
# Every pooled HTTP request is a DEBUG record; keep the logger's
|
||||
# INFO+ output, drop the per-connection noise.
|
||||
"urllib3.connectionpool": {
|
||||
"level": "INFO",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"""SPA shell hosting for the compiled single-page application.
|
||||
|
||||
Static assets (wasm, js, css, card images) are served by Granian itself
|
||||
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
|
||||
Dockerfile) and never reach Python. This module only serves ``index.html``
|
||||
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
|
||||
every path no API or auth route claimed, so client-side routes
|
||||
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
||||
Static assets (wasm, js, css, card images, PWA icons) are served by
|
||||
Granian itself under the ``/static`` and ``/assets`` prefixes
|
||||
(``GRANIAN_STATIC_PATH_*`` env vars; see the Dockerfile) and never reach
|
||||
Python. This module serves the few files that must live at the site root
|
||||
from ``STATIC_DIR``:
|
||||
|
||||
kaya-openapi deliberately skips glob routes, so this handler never appears
|
||||
in the API specification.
|
||||
- ``index.html`` at the site root and — via the glob catch-all — for
|
||||
every path no API or auth route claimed, so client-side routes
|
||||
(``/game/<id>`` etc.) work on direct loads and refreshes;
|
||||
- the web app manifest, whose ``start_url``/``scope`` cover the app;
|
||||
- the service worker, whose default scope is its own directory: served
|
||||
from ``/static`` it could only control ``/static``, so it is served at
|
||||
``/sw.js`` instead.
|
||||
|
||||
kaya-openapi deliberately skips glob routes, so the fallback handler
|
||||
never appears in the API specification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kaya.core import HttpContext
|
||||
|
||||
@@ -21,14 +29,30 @@ from .app import app
|
||||
from .config import settings
|
||||
|
||||
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||
index = Path(settings.static_dir) / "index.html"
|
||||
if not index.is_file():
|
||||
async def _send_from_dist(ctx: HttpContext,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
cache_control: Optional[str] = None) -> None:
|
||||
"""Serve one file from ``STATIC_DIR``, or 404 when the build lacks it.
|
||||
|
||||
Unlike the SPA fallback, a missing file is not silently replaced by
|
||||
the app shell: a manifest or worker answer with the wrong content
|
||||
type would only confuse the browser.
|
||||
"""
|
||||
file = Path(settings.static_dir) / filename
|
||||
if not file.is_file():
|
||||
await ctx.send_empty(404)
|
||||
return
|
||||
body = await asyncio.to_thread(index.read_bytes)
|
||||
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
|
||||
body = await asyncio.to_thread(file.read_bytes)
|
||||
headers = {"content-type": (content_type,)}
|
||||
if cache_control is not None:
|
||||
headers["cache-control"] = (cache_control,)
|
||||
await ctx.send_bytes(200, body, headers)
|
||||
|
||||
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||
await _send_from_dist(ctx, "index.html", "text/html; charset=utf-8")
|
||||
|
||||
|
||||
@app.GET("/")
|
||||
@@ -38,6 +62,25 @@ async def index(ctx: HttpContext) -> None:
|
||||
await _send_shell(ctx)
|
||||
|
||||
|
||||
@app.GET("/manifest.webmanifest")
|
||||
async def manifest(ctx: HttpContext) -> None:
|
||||
"""Serve the web app manifest (linked from index.html)."""
|
||||
await _send_from_dist(ctx, "manifest.webmanifest", "application/manifest+json")
|
||||
|
||||
|
||||
@app.GET("/sw.js")
|
||||
async def service_worker(ctx: HttpContext) -> None:
|
||||
"""Serve the service worker from the site root so its scope is the
|
||||
whole app. The script is revalidated on every load (no-cache) so a new
|
||||
build's worker takes over promptly."""
|
||||
await _send_from_dist(
|
||||
ctx,
|
||||
"sw.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
cache_control="no-cache",
|
||||
)
|
||||
|
||||
|
||||
@app.GET("/*", recursive=True)
|
||||
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||
"""SPA fallback: any path that matched no other route renders the app
|
||||
|
||||
@@ -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,100 @@
|
||||
# tavolo-briscola
|
||||
|
||||
Briscola — the Italian trick-taking game — as a
|
||||
[`tavolo-platform`](../tavolo-platform/README.md) game implementation.
|
||||
Three variants share one rules core:
|
||||
|
||||
- `briscola` — the classic two-player game;
|
||||
- `briscola_a_quattro` — four-player fixed-partnership briscola;
|
||||
- `briscola_chiamata` — five-player briscola with a calling auction and a
|
||||
secret partner.
|
||||
|
||||
## 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 for two- and four-player briscola:
|
||||
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.
|
||||
- `chiamata.py` — the pure rules engine for five-player briscola
|
||||
chiamata: `ChiamataState`/`ChiamataPlayer`/`Auction` (JSON
|
||||
round-trip), the 8-card deal with no stock, the descending calling
|
||||
auction with point raises, the winner's call (including calling one's
|
||||
own card, which means playing alone), the called-card reveal, single-
|
||||
deal scoring with the 60–60 patta and the auto-actions for timeouts.
|
||||
- `errors.py` — briscola-specific errors (`CardNotInHand`); every other
|
||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||
- `plugin.py` — `BriscolaEngine`, `BriscolaQuattroEngine` and
|
||||
`BriscolaChiamataEngine` (`GameEngine`): the platform-facing adapters.
|
||||
The first two 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`).
|
||||
Briscola chiamata takes no creation options.
|
||||
|
||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||
application composition root. Briscola chiamata uses the turn timeout
|
||||
for its auction turns, the called-card choice and the plays: an absent
|
||||
bidder passes, the last player standing with no bid is made to bid the
|
||||
ace, and an absent winner's call names a random suit of the won rank. It
|
||||
never enters the between-deals acknowledgement phase (a single deal ends
|
||||
the match), so the hand-ack timeout is accepted but unused.
|
||||
|
||||
## Rules
|
||||
|
||||
### Briscola (two-player and four-player)
|
||||
|
||||
- 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.
|
||||
|
||||
### Briscola chiamata (five-player)
|
||||
|
||||
- The whole 40-card deck is dealt out, eight cards each: there is no
|
||||
stock and no face-up card.
|
||||
- Starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a **lower** rank than the
|
||||
standing bid on the ladder *A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2*;
|
||||
the first bidder may bid any rank. Once the standing bid is the two,
|
||||
the auction continues on points instead (62, 64, …): the calling side
|
||||
then has to reach the raised target. The last player standing must
|
||||
bid, so a deal always gets a caller.
|
||||
- The auction winner names any card of the winning rank. If the winner
|
||||
holds it, they **play alone** against the other four; otherwise its
|
||||
holder is the **secret partner** and the two form the calling side.
|
||||
The called card's suit is the trump for the deal. The partner's
|
||||
identity is hidden until the called card is played.
|
||||
- Play follows briscola: the player after the dealer leads, the highest
|
||||
trump (or highest led-suit card) takes the trick and leads the next
|
||||
one; eight tricks exhaust every hand.
|
||||
- The calling side (caller plus partner, or the lone caller) must reach
|
||||
the declared target — 61 unless the auction raised it. With the plain
|
||||
target a 60–60 deal is a patta and is replayed with the next dealer.
|
||||
A match is a single deal, so the two sides map onto the platform's
|
||||
two-team result (Elo rating, match history).
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
uv run python -m unittest discover -s packages/tavolo-briscola/tests
|
||||
uv run mypy -p tavolo.briscola
|
||||
```
|
||||
|
||||
`test_engine.py`/`test_plugin.py` cover the two classic variants;
|
||||
`test_chiamata.py`/`test_chiamata_plugin.py` cover briscola chiamata.
|
||||
@@ -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,31 @@
|
||||
"""Briscola: tavolo's trick-taking game implementation.
|
||||
|
||||
Three 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``);
|
||||
- :class:`~tavolo.briscola.plugin.BriscolaChiamataEngine` — five-player
|
||||
briscola chiamata (``briscola_chiamata``).
|
||||
|
||||
The pure rules (:mod:`tavolo.briscola.engine`,
|
||||
:mod:`tavolo.briscola.state` and :mod:`tavolo.briscola.chiamata`) 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 (
|
||||
BriscolaChiamataEngine,
|
||||
BriscolaEngine,
|
||||
BriscolaQuattroEngine,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BriscolaChiamataEngine",
|
||||
"BriscolaEngine",
|
||||
"BriscolaQuattroEngine",
|
||||
]
|
||||
@@ -0,0 +1,953 @@
|
||||
"""Pure rules engine for briscola chiamata.
|
||||
|
||||
Briscola chiamata is the five-player variant of briscola. The 40-card
|
||||
deck is dealt out entirely (8 cards each, no stock): there is no face-up
|
||||
briscola card. Instead the players auction off the right to call a
|
||||
partner:
|
||||
|
||||
* starting with the player after the dealer, each player either passes
|
||||
(leaving the auction for good) or bids a rank lower than the standing
|
||||
bid on the ladder ``A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2``; the first
|
||||
bidder may bid any rank. Once the standing bid is the 2, further bids
|
||||
raise the points the calling side must reach instead (62, 64, ...).
|
||||
* the last remaining bidder wins and names the exact card of the winning
|
||||
rank (any suit). If the winner holds the named card they play alone
|
||||
against the other four; otherwise its holder is the **secret partner**
|
||||
and the two form the calling side. The called card's suit is the trump
|
||||
(*briscola*) for the whole deal.
|
||||
* play and scoring follow briscola. The calling side must reach the
|
||||
declared target (61 unless raised); a 60-60 deal with the plain target
|
||||
is a **patta** and is replayed.
|
||||
|
||||
A match is a single deal (patta replays aside), so the two sides map
|
||||
onto the platform's two-team result (Elo rating, match history). The
|
||||
partner's identity stays hidden until the called card is played, at
|
||||
which point the roles are revealed.
|
||||
|
||||
Like :mod:`tavolo.briscola.engine` this module is deterministic and
|
||||
I/O-free apart from logging: it mutates (or reads)
|
||||
:class:`~tavolo.briscola.chiamata.ChiamataState` and raises
|
||||
:class:`~tavolo.platform.errors.GameError` subclasses on rule
|
||||
violations. The platform-facing adapter is
|
||||
:class:`~tavolo.briscola.plugin.BriscolaChiamataEngine`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
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 .engine import TRICK_STRENGTH, card_points, shuffled_deck
|
||||
from .errors import CardNotInHand
|
||||
from .state import (
|
||||
DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
PHASE_FINISHED,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
SUITS,
|
||||
Card,
|
||||
Trick,
|
||||
TrickCard,
|
||||
parse_card,
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Five seats, the whole 40-card deck dealt out.
|
||||
PLAYER_COUNT = 5
|
||||
HAND_SIZE = 8
|
||||
|
||||
PHASE_AUCTION = "auction"
|
||||
PHASE_CALLING = "calling"
|
||||
|
||||
# The two sides of the match: the caller (plus the partner, when there is
|
||||
# one) against the other players.
|
||||
SIDE_CALLER = 0
|
||||
SIDE_DEFENDERS = 1
|
||||
SIDE_NAMES = ("Chiamante", "Difensori")
|
||||
|
||||
# Role labels carried by the personalized view once they are public.
|
||||
ROLE_CALLER = "Chiamante"
|
||||
ROLE_PARTNER = "Socio"
|
||||
ROLE_DEFENDER = "Difensori"
|
||||
|
||||
# Points the calling side must reach: 61 unless the auction raised it.
|
||||
DEFAULT_TARGET = 61
|
||||
MIN_TARGET = 61
|
||||
MAX_TARGET = 120
|
||||
|
||||
# Rank ladder of the auction, strongest first: ace, three, king, horse,
|
||||
# jack, then the plain ranks downwards.
|
||||
BID_LADDER = (1, 3, 10, 9, 8, 7, 6, 5, 4, 2)
|
||||
_BID_INDEX: Dict[int, int] = {rank: index for index, rank in enumerate(BID_LADDER)}
|
||||
|
||||
_rng = random.SystemRandom()
|
||||
|
||||
|
||||
# -- state -----------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChiamataPlayer:
|
||||
sub: str
|
||||
name: str
|
||||
seat: int
|
||||
hand: List[Card] = field(default_factory=list)
|
||||
captured: List[Card] = field(default_factory=list)
|
||||
|
||||
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]) -> "ChiamataPlayer":
|
||||
return ChiamataPlayer(
|
||||
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 Bid:
|
||||
"""One auction bid: a rank, or (at the 2) a raised points target."""
|
||||
|
||||
seat: int
|
||||
rank: int
|
||||
points: int
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {"seat": self.seat, "rank": self.rank, "points": self.points}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Bid":
|
||||
return Bid(
|
||||
seat=int(data["seat"]),
|
||||
rank=int(data["rank"]),
|
||||
points=int(data["points"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Auction:
|
||||
"""The state of the calling auction."""
|
||||
|
||||
# Seat to act; always one of ``active``.
|
||||
turn: int = 0
|
||||
active: List[int] = field(default_factory=list)
|
||||
passed: List[int] = field(default_factory=list)
|
||||
current: Optional[Bid] = None
|
||||
history: List[Bid] = field(default_factory=list)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"turn": self.turn,
|
||||
"active": list(self.active),
|
||||
"passed": list(self.passed),
|
||||
"current": self.current.to_json() if self.current else None,
|
||||
"history": [b.to_json() for b in self.history],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Auction":
|
||||
return Auction(
|
||||
turn=int(data.get("turn", 0)),
|
||||
active=[int(s) for s in data.get("active", [])],
|
||||
passed=[int(s) for s in data.get("passed", [])],
|
||||
current=(
|
||||
Bid.from_json(data["current"]) if data.get("current") else None
|
||||
),
|
||||
history=[Bid.from_json(b) for b in data.get("history", [])],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChiamataState:
|
||||
phase: str = PHASE_LOBBY
|
||||
players: List[ChiamataPlayer] = field(default_factory=list)
|
||||
dealer: int = 0
|
||||
# 1-based deal counter; grows with patta replays.
|
||||
hand_number: int = 1
|
||||
# 0 = the calling side (caller plus partner, when there is one),
|
||||
# 1 = the defenders. Only set once the match is finished.
|
||||
winner: Optional[int] = None
|
||||
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||
# The calling auction of the current deal.
|
||||
auction: Optional[Auction] = None
|
||||
# Seat that won the auction (public from then on).
|
||||
caller: Optional[int] = None
|
||||
# The card named by the caller; its suit is the trump.
|
||||
called_card: Optional[Card] = None
|
||||
briscola_suit: str = ""
|
||||
# Points target from the auction (61 unless raised).
|
||||
declared_target: int = DEFAULT_TARGET
|
||||
# True when the caller named a card of their own hand: no partner.
|
||||
solo: bool = False
|
||||
# True once the called card has been played; roles become public.
|
||||
called_played: bool = False
|
||||
# The partner's seat, recorded when the called card is played; stays
|
||||
# ``None`` for a solo call.
|
||||
partner_seat: Optional[int] = None
|
||||
# Cards of the trick in progress, in play order.
|
||||
trick: List[TrickCard] = field(default_factory=list)
|
||||
trick_leader: int = 0
|
||||
last_trick: Optional[Trick] = None
|
||||
# Seat currently to act (auction bidder, caller, or player on turn).
|
||||
turn: int = 0
|
||||
# Per-deal audit trail, patta entries included.
|
||||
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||
# While phase == "auction"/"playing": when the server acts for the
|
||||
# player on turn.
|
||||
turn_deadline: Optional[str] = None
|
||||
auction_deadline: Optional[str] = None
|
||||
# While phase == "calling": when the server names a card for the
|
||||
# auction winner.
|
||||
call_deadline: Optional[str] = None
|
||||
# Seconds a player has to act before the server acts for them.
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS
|
||||
|
||||
# -- serialization ----------------------------------------------------
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"dealer": self.dealer,
|
||||
"hand_number": self.hand_number,
|
||||
"winner": self.winner,
|
||||
"scores": list(self.scores),
|
||||
"auction": self.auction.to_json() if self.auction else None,
|
||||
"caller": self.caller,
|
||||
"called_card": (
|
||||
self.called_card.to_json() if self.called_card else None
|
||||
),
|
||||
"briscola_suit": self.briscola_suit,
|
||||
"declared_target": self.declared_target,
|
||||
"solo": self.solo,
|
||||
"called_played": self.called_played,
|
||||
"partner_seat": self.partner_seat,
|
||||
"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,
|
||||
"turn": self.turn,
|
||||
"hand_scores": list(self.hand_scores),
|
||||
"turn_deadline": self.turn_deadline,
|
||||
"auction_deadline": self.auction_deadline,
|
||||
"call_deadline": self.call_deadline,
|
||||
"turn_timeout": self.turn_timeout,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "ChiamataState":
|
||||
return ChiamataState(
|
||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||
players=[
|
||||
ChiamataPlayer.from_json(p) for p in data.get("players", [])
|
||||
],
|
||||
dealer=int(data.get("dealer", 0)),
|
||||
hand_number=int(data.get("hand_number", 1)),
|
||||
winner=data.get("winner"),
|
||||
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||
auction=(
|
||||
Auction.from_json(data["auction"]) if data.get("auction") else None
|
||||
),
|
||||
caller=data.get("caller"),
|
||||
called_card=(
|
||||
Card.from_json(data["called_card"])
|
||||
if data.get("called_card")
|
||||
else None
|
||||
),
|
||||
briscola_suit=str(data.get("briscola_suit", "")),
|
||||
declared_target=int(data.get("declared_target", DEFAULT_TARGET)),
|
||||
solo=bool(data.get("solo", False)),
|
||||
called_played=bool(data.get("called_played", False)),
|
||||
partner_seat=data.get("partner_seat"),
|
||||
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
|
||||
),
|
||||
turn=int(data.get("turn", 0)),
|
||||
hand_scores=list(data.get("hand_scores", [])),
|
||||
turn_deadline=data.get("turn_deadline"),
|
||||
auction_deadline=data.get("auction_deadline"),
|
||||
call_deadline=data.get("call_deadline"),
|
||||
turn_timeout=int(
|
||||
data.get("turn_timeout", DEFAULT_TURN_TIMEOUT_SECONDS)
|
||||
),
|
||||
)
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def player_for(self, sub: Optional[str]) -> Optional[ChiamataPlayer]:
|
||||
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
|
||||
|
||||
|
||||
# -- game setup --------------------------------------------------------------
|
||||
|
||||
|
||||
def create_game(
|
||||
creator_sub: str,
|
||||
creator_name: str,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
) -> ChiamataState:
|
||||
"""Create a lobby game with the creator seated first."""
|
||||
return ChiamataState(
|
||||
phase=PHASE_LOBBY,
|
||||
players=[ChiamataPlayer(sub=creator_sub, name=creator_name, seat=0)],
|
||||
turn_timeout=turn_timeout,
|
||||
)
|
||||
|
||||
|
||||
def join_game(state: ChiamataState, 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) >= PLAYER_COUNT:
|
||||
raise LobbyFull("game is full")
|
||||
seat = len(state.players)
|
||||
state.players.append(ChiamataPlayer(sub=sub, name=name, seat=seat))
|
||||
if len(state.players) == PLAYER_COUNT:
|
||||
start_game(state)
|
||||
|
||||
|
||||
def start_game(state: ChiamataState) -> None:
|
||||
"""Deal the first hand and open the auction."""
|
||||
if len(state.players) != PLAYER_COUNT:
|
||||
raise GameNotStarted(f"need exactly {PLAYER_COUNT} players to start")
|
||||
_deal(state)
|
||||
|
||||
|
||||
def _player_at(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise IllegalMove(f"no player in seat {seat}")
|
||||
|
||||
|
||||
def _set_deadline(seconds: int) -> str:
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=seconds)
|
||||
return deadline.isoformat()
|
||||
|
||||
|
||||
def _set_auction_deadline(state: ChiamataState) -> None:
|
||||
state.auction_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _set_call_deadline(state: ChiamataState) -> None:
|
||||
state.call_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _set_turn_deadline(state: ChiamataState) -> None:
|
||||
state.turn_deadline = _set_deadline(state.turn_timeout)
|
||||
|
||||
|
||||
def _deal(state: ChiamataState) -> None:
|
||||
"""Deal 8 cards each and open the auction."""
|
||||
deck = shuffled_deck()
|
||||
for player in state.players:
|
||||
player.hand = []
|
||||
player.captured = []
|
||||
state.trick = []
|
||||
state.last_trick = None
|
||||
state.turn_deadline = None
|
||||
state.call_deadline = None
|
||||
state.called_card = None
|
||||
state.briscola_suit = ""
|
||||
state.declared_target = DEFAULT_TARGET
|
||||
state.solo = False
|
||||
state.called_played = False
|
||||
state.partner_seat = None
|
||||
state.caller = None
|
||||
state.winner = None
|
||||
state.scores = [0, 0]
|
||||
# The whole deck is dealt, one card at a time, starting with the
|
||||
# player after the dealer.
|
||||
for _ in range(HAND_SIZE):
|
||||
for offset in range(PLAYER_COUNT):
|
||||
_player_at(
|
||||
state, (state.dealer + 1 + offset) % PLAYER_COUNT
|
||||
).hand.append(deck.pop())
|
||||
first = (state.dealer + 1) % PLAYER_COUNT
|
||||
state.turn = first
|
||||
state.trick_leader = first
|
||||
state.auction = Auction(
|
||||
turn=first,
|
||||
active=[player.seat for player in state.players],
|
||||
)
|
||||
state.phase = PHASE_AUCTION
|
||||
_set_auction_deadline(state)
|
||||
log.debug(
|
||||
"deal %d dealt (dealer seat %d), auction opens with seat %d",
|
||||
state.hand_number,
|
||||
state.dealer,
|
||||
first,
|
||||
)
|
||||
|
||||
|
||||
# -- auction -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_auction(
|
||||
state: ChiamataState, sub: str
|
||||
) -> Tuple[Auction, ChiamataPlayer]:
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = state.player_for(sub)
|
||||
if player is None:
|
||||
raise NotYourTurn("you are not seated in this game")
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - auction always exists here
|
||||
raise GameNotStarted("the auction is not open")
|
||||
if auction.turn != player.seat:
|
||||
raise NotYourTurn("it is not your turn to bid")
|
||||
return auction, player
|
||||
|
||||
|
||||
def legal_bid_ranks(state: ChiamataState) -> List[int]:
|
||||
"""The ranks the player on turn may bid, empty at the point stage."""
|
||||
auction = state.auction
|
||||
if state.phase != PHASE_AUCTION or auction is None:
|
||||
return []
|
||||
current = auction.current
|
||||
if current is None:
|
||||
return list(BID_LADDER)
|
||||
if current.rank == 2:
|
||||
return []
|
||||
index = _BID_INDEX[current.rank]
|
||||
return [rank for rank in BID_LADDER if _BID_INDEX[rank] > index]
|
||||
|
||||
|
||||
def legal_bid_points(state: ChiamataState) -> Optional[int]:
|
||||
"""The lowest legal points raise, ``None`` when there is none.
|
||||
|
||||
Points may only be raised once the standing bid is the 2; any value
|
||||
higher than the standing bid (up to :data:`MAX_TARGET`) is accepted.
|
||||
"""
|
||||
auction = state.auction
|
||||
if state.phase != PHASE_AUCTION or auction is None:
|
||||
return None
|
||||
current = auction.current
|
||||
if current is None or current.rank != 2:
|
||||
return None
|
||||
if current.points >= MAX_TARGET:
|
||||
return None
|
||||
return current.points + 2
|
||||
|
||||
|
||||
def _finish_auction(state: ChiamataState) -> None:
|
||||
"""The last active bidder wins and must name the called card."""
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
raise GameNotStarted("the auction is not open")
|
||||
if len(auction.active) != 1 or auction.current is None: # pragma: no cover
|
||||
raise IllegalMove("the auction cannot be concluded yet")
|
||||
winner = auction.active[0]
|
||||
state.caller = winner
|
||||
state.declared_target = auction.current.points
|
||||
state.turn = winner
|
||||
state.phase = PHASE_CALLING
|
||||
state.auction_deadline = None
|
||||
_set_call_deadline(state)
|
||||
log.debug(
|
||||
"deal %d: seat %d wins the auction (rank %d, target %d)",
|
||||
state.hand_number,
|
||||
winner,
|
||||
auction.current.rank,
|
||||
auction.current.points,
|
||||
)
|
||||
|
||||
|
||||
def _advance_auction(state: ChiamataState) -> None:
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
return
|
||||
for offset in range(1, PLAYER_COUNT + 1):
|
||||
seat = (auction.turn + offset) % PLAYER_COUNT
|
||||
if seat in auction.active:
|
||||
auction.turn = seat
|
||||
break
|
||||
state.turn = auction.turn
|
||||
_set_auction_deadline(state)
|
||||
|
||||
|
||||
def _apply_bid(state: ChiamataState, bid: Bid) -> None:
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by callers
|
||||
raise GameNotStarted("the auction is not open")
|
||||
auction.current = bid
|
||||
auction.history.append(bid)
|
||||
log.debug(
|
||||
"deal %d: seat %d bids rank %d at %d",
|
||||
state.hand_number,
|
||||
bid.seat,
|
||||
bid.rank,
|
||||
bid.points,
|
||||
)
|
||||
if len(auction.active) == 1:
|
||||
_finish_auction(state)
|
||||
return
|
||||
_advance_auction(state)
|
||||
|
||||
|
||||
def bid_rank(state: ChiamataState, sub: str, rank: int) -> None:
|
||||
"""Bid ``rank``; it must be weaker than the standing bid."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
if rank not in _BID_INDEX:
|
||||
raise IllegalMove(f"invalid rank: {rank!r}")
|
||||
current = auction.current
|
||||
if current is not None:
|
||||
if current.rank == 2:
|
||||
raise IllegalMove("the auction is at the 2: raise the points or pass")
|
||||
if _BID_INDEX[rank] <= _BID_INDEX[current.rank]:
|
||||
raise IllegalMove("a bid must call a lower rank than the standing bid")
|
||||
_apply_bid(state, Bid(seat=player.seat, rank=rank, points=DEFAULT_TARGET))
|
||||
|
||||
|
||||
def bid_points(state: ChiamataState, sub: str, points: int) -> None:
|
||||
"""Raise the points target; only legal once the standing bid is the 2."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
current = auction.current
|
||||
if current is None or current.rank != 2:
|
||||
raise IllegalMove("points can only be raised after a bid of the 2")
|
||||
if points <= current.points:
|
||||
raise IllegalMove(
|
||||
f"the points must be higher than the standing bid ({current.points})"
|
||||
)
|
||||
if points > MAX_TARGET:
|
||||
raise IllegalMove(f"the points must be at most {MAX_TARGET}")
|
||||
_apply_bid(state, Bid(seat=player.seat, rank=2, points=points))
|
||||
|
||||
|
||||
def pass_auction(state: ChiamataState, sub: str) -> None:
|
||||
"""Leave the auction for good."""
|
||||
auction, player = _require_auction(state, sub)
|
||||
if player.seat not in auction.active: # pragma: no cover - turn implies active
|
||||
raise IllegalMove("you already passed")
|
||||
if len(auction.active) == 1 and auction.current is None:
|
||||
# Everyone else passed before any bid: the last player must bid.
|
||||
raise IllegalMove("you must bid: everyone else has passed")
|
||||
auction.active.remove(player.seat)
|
||||
auction.passed.append(player.seat)
|
||||
log.debug("deal %d: seat %d passes", state.hand_number, player.seat)
|
||||
if len(auction.active) == 1 and auction.current is not None:
|
||||
_finish_auction(state)
|
||||
return
|
||||
_advance_auction(state)
|
||||
|
||||
|
||||
# -- calling -------------------------------------------------------------------
|
||||
|
||||
|
||||
def call(state: ChiamataState, sub: str, card_code: str) -> None:
|
||||
"""The auction winner names the called card (any suit of the bid rank).
|
||||
|
||||
If the caller holds the named card they play alone; the card's suit
|
||||
becomes the trump for the deal.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase != PHASE_CALLING:
|
||||
raise GameNotStarted("no call is waiting")
|
||||
caller = None if state.caller is None else _player_at(state, state.caller)
|
||||
player = state.player_for(sub)
|
||||
if player is None or caller is None or player.seat != caller.seat:
|
||||
raise NotYourTurn("only the auction winner calls the card")
|
||||
auction = state.auction
|
||||
if auction is None or auction.current is None: # pragma: no cover
|
||||
raise GameNotStarted("no call is waiting")
|
||||
card = parse_card(card_code)
|
||||
if card.rank != auction.current.rank:
|
||||
raise IllegalMove(
|
||||
"the called card must match the rank won in the auction"
|
||||
)
|
||||
state.called_card = card
|
||||
state.briscola_suit = card.suit
|
||||
state.solo = card in caller.hand
|
||||
state.phase = PHASE_PLAYING
|
||||
# The player after the dealer opens the play, whichever seat won the
|
||||
# auction.
|
||||
first = (state.dealer + 1) % PLAYER_COUNT
|
||||
state.turn = first
|
||||
state.trick_leader = first
|
||||
state.call_deadline = None
|
||||
_set_turn_deadline(state)
|
||||
log.debug(
|
||||
"deal %d: seat %d calls %s%s",
|
||||
state.hand_number,
|
||||
caller.seat,
|
||||
card.code,
|
||||
" (playing alone)" if state.solo else "",
|
||||
)
|
||||
|
||||
|
||||
# -- play ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def play(state: ChiamataState, 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_AUCTION:
|
||||
raise IllegalMove("the auction is still open")
|
||||
if state.phase == PHASE_CALLING:
|
||||
raise IllegalMove("the called card has not been named yet")
|
||||
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)
|
||||
if state.called_card is not None and card == state.called_card:
|
||||
state.called_played = True
|
||||
state.partner_seat = (
|
||||
None if player.seat == state.caller else player.seat
|
||||
)
|
||||
log.info(
|
||||
"deal %d: the called card %s was played by seat %d (%s)",
|
||||
state.hand_number,
|
||||
card.code,
|
||||
player.seat,
|
||||
"the caller plays alone" if state.partner_seat is None else "partner revealed",
|
||||
)
|
||||
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),
|
||||
PLAYER_COUNT,
|
||||
)
|
||||
|
||||
if len(state.trick) == PLAYER_COUNT:
|
||||
_resolve_trick(state)
|
||||
else:
|
||||
state.turn = (state.turn + 1) % PLAYER_COUNT
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _resolve_trick(state: ChiamataState) -> None:
|
||||
"""Score the completed trick, draw no cards (deck already exhausted)."""
|
||||
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
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _trick_winner(state: ChiamataState) -> 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]
|
||||
|
||||
|
||||
# -- scoring -------------------------------------------------------------------
|
||||
|
||||
|
||||
def side_points(state: ChiamataState) -> Tuple[int, int]:
|
||||
"""The points captured by the calling side and the defenders.
|
||||
|
||||
The partner's pile only joins the caller's once the partner is known
|
||||
(the called card has been played); until then this is only used to
|
||||
score a finished deal, where the reveal has always happened.
|
||||
"""
|
||||
if state.caller is None:
|
||||
return (0, 0)
|
||||
caller_seats = {state.caller}
|
||||
if state.partner_seat is not None:
|
||||
caller_seats.add(state.partner_seat)
|
||||
caller_points = 0
|
||||
defender_points = 0
|
||||
for player in state.players:
|
||||
points = sum(card_points(card) for card in player.captured)
|
||||
if player.seat in caller_seats:
|
||||
caller_points += points
|
||||
else:
|
||||
defender_points += points
|
||||
return (caller_points, defender_points)
|
||||
|
||||
|
||||
def _end_deal(state: ChiamataState) -> None:
|
||||
"""Score the deal: finish the match, or replay after a patta.
|
||||
|
||||
A 60-60 deal with the plain 61 target is a patta: its points do not
|
||||
count, the dealer rotates and the deal is replayed (a new auction).
|
||||
Otherwise the calling side wins by reaching the declared target.
|
||||
"""
|
||||
state.turn_deadline = None
|
||||
caller_points, defender_points = side_points(state)
|
||||
patta = (
|
||||
state.declared_target == DEFAULT_TARGET
|
||||
and caller_points == 60
|
||||
and defender_points == 60
|
||||
)
|
||||
caller_name = (
|
||||
_player_at(state, state.caller).name if state.caller is not None else None
|
||||
)
|
||||
caller_seats = {state.caller}
|
||||
if state.partner_seat is not None:
|
||||
caller_seats.add(state.partner_seat)
|
||||
caller_cards = sum(
|
||||
len(player.captured)
|
||||
for player in state.players
|
||||
if player.seat in caller_seats
|
||||
)
|
||||
entry: Dict[str, Any] = {
|
||||
"deal": state.hand_number,
|
||||
"patta": patta,
|
||||
"points": {"A": caller_points, "B": defender_points},
|
||||
"cards": {"A": caller_cards, "B": 40 - caller_cards},
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": caller_name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
state.hand_scores.append(entry)
|
||||
log.debug(
|
||||
"deal %d scored caller %d - defenders %d%s (target %d)",
|
||||
state.hand_number,
|
||||
caller_points,
|
||||
defender_points,
|
||||
" patta" if patta else "",
|
||||
state.declared_target,
|
||||
)
|
||||
if patta:
|
||||
state.hand_number += 1
|
||||
state.dealer = (state.dealer + 1) % PLAYER_COUNT
|
||||
_deal(state)
|
||||
return
|
||||
|
||||
state.scores = [caller_points, defender_points]
|
||||
state.winner = (
|
||||
SIDE_CALLER if caller_points >= state.declared_target else SIDE_DEFENDERS
|
||||
)
|
||||
state.phase = PHASE_FINISHED
|
||||
log.info(
|
||||
"match ended, %s win %d-%d",
|
||||
SIDE_NAMES[state.winner],
|
||||
caller_points,
|
||||
defender_points,
|
||||
)
|
||||
|
||||
|
||||
# -- server-side auto actions ---------------------------------------------------
|
||||
|
||||
|
||||
def auto_bid(state: ChiamataState) -> None:
|
||||
"""Act for the bidder on turn: always pass, unless forced to bid.
|
||||
|
||||
An absent player never outbids anyone: they leave the auction. Only
|
||||
the last player standing before any bid cannot pass — they are made
|
||||
to bid the ace, whose suit :func:`auto_call` picks at random if the
|
||||
call itself times out too.
|
||||
"""
|
||||
if state.phase != PHASE_AUCTION:
|
||||
raise GameNotStarted("the auction is not open")
|
||||
auction = state.auction
|
||||
if auction is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("the auction is not open")
|
||||
player = _player_at(state, auction.turn)
|
||||
if len(auction.active) == 1 and auction.current is None:
|
||||
bid_rank(state, player.sub, 1)
|
||||
return
|
||||
pass_auction(state, player.sub)
|
||||
|
||||
|
||||
def auto_call(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Name a random suit of the auction rank for the winner."""
|
||||
if state.phase != PHASE_CALLING:
|
||||
raise GameNotStarted("no call is waiting")
|
||||
auction = state.auction
|
||||
if auction is None or auction.current is None: # pragma: no cover
|
||||
raise GameNotStarted("no call is waiting")
|
||||
caller = _player_at(state, state.caller) if state.caller is not None else None
|
||||
if caller is None: # pragma: no cover - guarded by phase
|
||||
raise GameNotStarted("no call is waiting")
|
||||
chooser = rng or _rng
|
||||
card = Card(rank=auction.current.rank, suit=chooser.choice(SUITS))
|
||||
call(state, caller.sub, card.code)
|
||||
|
||||
|
||||
def auto_play(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Play a random card for the player currently on turn."""
|
||||
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)
|
||||
|
||||
|
||||
# -- views ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def _roles_public(state: ChiamataState) -> bool:
|
||||
return state.called_played
|
||||
|
||||
|
||||
def state_for_player(
|
||||
state: ChiamataState, sub: Optional[str]
|
||||
) -> Dict[str, object]:
|
||||
"""Serialize ``state`` hiding other players' hands and secret roles.
|
||||
|
||||
Hands are reduced to a count, except for the requesting player's own
|
||||
hand. The partner's role only appears once the called card has been
|
||||
played; before that only the caller is labeled. The calling side's
|
||||
running total is withheld until then too: it would let onlookers
|
||||
deduce the partner's identity from who captured the points.
|
||||
"""
|
||||
viewer = state.player_for(sub)
|
||||
revealed = _roles_public(state)
|
||||
players: List[Dict[str, object]] = []
|
||||
for player in state.players:
|
||||
role = ""
|
||||
if state.caller is not None and player.seat == state.caller:
|
||||
role = ROLE_CALLER
|
||||
if revealed:
|
||||
if state.partner_seat is not None and player.seat == state.partner_seat:
|
||||
role = ROLE_PARTNER
|
||||
elif player.seat != state.caller:
|
||||
role = ROLE_DEFENDER
|
||||
view: Dict[str, object] = {
|
||||
"sub": player.sub,
|
||||
"name": player.name,
|
||||
"seat": player.seat,
|
||||
"team": role,
|
||||
"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)
|
||||
|
||||
auction = state.auction
|
||||
auction_view: Optional[Dict[str, object]] = None
|
||||
if auction is not None:
|
||||
auction_view = {
|
||||
"turn": auction.turn,
|
||||
"active": list(auction.active),
|
||||
"passed": list(auction.passed),
|
||||
"current": auction.current.to_json() if auction.current else None,
|
||||
"history": [b.to_json() for b in auction.history],
|
||||
}
|
||||
|
||||
payload: Dict[str, object] = {
|
||||
"phase": state.phase,
|
||||
"player_count": PLAYER_COUNT,
|
||||
"dealer": state.dealer,
|
||||
"hand_number": state.hand_number,
|
||||
"turn": state.turn,
|
||||
"auction": auction_view,
|
||||
"caller": state.caller,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"briscola_suit": state.briscola_suit,
|
||||
"declared_target": state.declared_target,
|
||||
"called_played": state.called_played,
|
||||
"solo": state.solo if revealed else False,
|
||||
"partner": (
|
||||
state.partner_seat if revealed and state.partner_seat is not None else None
|
||||
),
|
||||
"players": players,
|
||||
"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,
|
||||
"last_deal": state.hand_scores[-1] if state.hand_scores else None,
|
||||
"auction_deadline": state.auction_deadline,
|
||||
"call_deadline": state.call_deadline,
|
||||
"turn_deadline": state.turn_deadline,
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
}
|
||||
if revealed and state.caller is not None:
|
||||
caller_points, defender_points = side_points(state)
|
||||
payload["scores"] = {"A": caller_points, "B": defender_points}
|
||||
|
||||
if viewer is not None:
|
||||
if state.phase == PHASE_AUCTION and auction is not None:
|
||||
if auction.turn == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
payload["legal_bids"] = {
|
||||
"ranks": legal_bid_ranks(state),
|
||||
"points_min": legal_bid_points(state),
|
||||
"points_max": MAX_TARGET,
|
||||
}
|
||||
elif state.phase == PHASE_CALLING and state.caller == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
current = auction.current if auction is not None else None
|
||||
if current is not None:
|
||||
payload["callable_cards"] = [
|
||||
Card(rank=current.rank, suit=suit).code for suit in SUITS
|
||||
]
|
||||
elif state.phase == PHASE_PLAYING and state.turn == viewer.seat:
|
||||
payload["your_turn"] = True
|
||||
return payload
|
||||
@@ -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,588 @@
|
||||
"""The platform-facing adapter for briscola.
|
||||
|
||||
Three :class:`~tavolo.platform.engine.GameEngine` implementations share
|
||||
one rules core (:mod:`tavolo.briscola.engine`, with the five-player
|
||||
variant in :mod:`tavolo.briscola.chiamata`): classic two-player
|
||||
:class:`BriscolaEngine`, four-player fixed-partnership
|
||||
:class:`BriscolaQuattroEngine` and five-player
|
||||
:class:`BriscolaChiamataEngine`. 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 chiamata, engine
|
||||
from .chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_NAMES,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
)
|
||||
from .engine import card_points
|
||||
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
|
||||
|
||||
|
||||
def _chiamata_player(state: ChiamataState, seat: int) -> ChiamataPlayer:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise GameError(f"no player in seat {seat}")
|
||||
|
||||
|
||||
class BriscolaChiamataEngine(GameEngine):
|
||||
"""Five-player briscola chiamata as a platform game engine.
|
||||
|
||||
One deal per match: the five players auction the call, the winner
|
||||
names a card (playing alone when it is in their own hand) and the
|
||||
calling side must reach the declared target (61 unless the auction
|
||||
raised it). A 60-60 deal with the plain target is a patta and is
|
||||
replayed. This keeps the match result a clean two-sided affair —
|
||||
caller and secret partner against the other three, or the lone
|
||||
caller against the other four — which the platform can rate,
|
||||
persist and display like any other game.
|
||||
"""
|
||||
|
||||
id = "briscola_chiamata"
|
||||
name = "Briscola chiamata"
|
||||
description = (
|
||||
"Five-player briscola with a descending auction: the winner calls "
|
||||
"a card, whose holder becomes the secret partner (or, when the "
|
||||
"caller holds it, the caller plays alone) and whose suit becomes "
|
||||
"the trump. The calling side must reach 61 points; one deal per "
|
||||
"match, replayed after a patta."
|
||||
)
|
||||
min_players = 5
|
||||
max_players = 5
|
||||
|
||||
options_schema: Mapping[str, Any] = {}
|
||||
|
||||
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
|
||||
# A chiamata match is a single deal, so there is no between-deals
|
||||
# acknowledgement phase to time out; the parameter is accepted
|
||||
# for symmetry with the other briscola engines and wired by the
|
||||
# application root.
|
||||
self._hand_ack_timeout = hand_ack_timeout_seconds
|
||||
|
||||
# -- lobby ------------------------------------------------------------
|
||||
|
||||
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
|
||||
creator = session.players[0]
|
||||
session.state = chiamata.create_game(
|
||||
creator_sub=creator.user_sub,
|
||||
creator_name=creator.display_name,
|
||||
turn_timeout=self._turn_timeout,
|
||||
)
|
||||
|
||||
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
|
||||
chiamata.join_game(session.state, user_sub, display_name)
|
||||
session.players.append(
|
||||
Seat(user_sub=user_sub, display_name=display_name)
|
||||
)
|
||||
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_LOBBY
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
state: ChiamataState = session.state
|
||||
return {
|
||||
"phase": state.phase,
|
||||
"declared_target": state.declared_target,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state: ChiamataState = session.state
|
||||
if action == "bid":
|
||||
rank = payload.get("rank")
|
||||
points = payload.get("points")
|
||||
if rank is not None:
|
||||
if isinstance(rank, bool) or not isinstance(rank, int):
|
||||
raise IllegalMove("'rank' must be an integer")
|
||||
chiamata.bid_rank(state, user_sub, rank)
|
||||
elif points is not None:
|
||||
if isinstance(points, bool) or not isinstance(points, int):
|
||||
raise IllegalMove("'points' must be an integer")
|
||||
chiamata.bid_points(state, user_sub, points)
|
||||
else:
|
||||
raise IllegalMove("a bid needs a 'rank' or a 'points' value")
|
||||
elif action == "pass":
|
||||
chiamata.pass_auction(state, user_sub)
|
||||
elif action == "call":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.call(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
elif action == "play":
|
||||
card = payload.get("card")
|
||||
if not isinstance(card, str):
|
||||
raise IllegalMove("'card' must be a card code string")
|
||||
try:
|
||||
chiamata.play(state, user_sub, card)
|
||||
except ValueError:
|
||||
raise IllegalMove("invalid card code")
|
||||
else:
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
return chiamata.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: ChiamataState = session.state
|
||||
caller = (
|
||||
_chiamata_player(state, state.caller) if state.caller is not None else None
|
||||
)
|
||||
partner = (
|
||||
_chiamata_player(state, state.partner_seat)
|
||||
if state.partner_seat is not None
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": (
|
||||
None if state.winner is None else SIDE_NAMES[state.winner]
|
||||
),
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": None if caller is None else caller.name,
|
||||
"partner": None if partner is None else partner.name,
|
||||
"solo": state.solo,
|
||||
}
|
||||
|
||||
# -- (de)serialization -------------------------------------------------
|
||||
|
||||
def state_to_json(self, state: Any) -> Dict[str, Any]:
|
||||
assert isinstance(state, ChiamataState)
|
||||
return state.to_json()
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return ChiamataState.from_json(dict(data))
|
||||
|
||||
# -- deadlines ----------------------------------------------------------
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
state: ChiamataState = session.state
|
||||
if state.phase == PHASE_AUCTION and state.auction_deadline:
|
||||
due_ms = _deadline_ms(state.auction_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="auction",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"auction:{state.hand_number}:{state.turn}:{due_ms}",
|
||||
)
|
||||
if state.phase == PHASE_CALLING and state.call_deadline:
|
||||
due_ms = _deadline_ms(state.call_deadline)
|
||||
if due_ms is None:
|
||||
return None
|
||||
return Deadline(
|
||||
kind="call",
|
||||
due_at=datetime.fromtimestamp(due_ms / 1000, tz=timezone.utc),
|
||||
token=f"call:{state.hand_number}:{due_ms}",
|
||||
)
|
||||
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}",
|
||||
)
|
||||
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 bid, call or play landed in time):
|
||||
# nothing to do.
|
||||
raise GameError("stale deadline")
|
||||
state: ChiamataState = session.state
|
||||
if kind == "auction":
|
||||
seat = state.turn
|
||||
auction = state.auction
|
||||
passed_before = len(auction.passed) if auction is not None else 0
|
||||
chiamata.auto_bid(state)
|
||||
action = (
|
||||
"auto-passed"
|
||||
if auction is not None and len(auction.passed) > passed_before
|
||||
else "forced to bid the ace"
|
||||
)
|
||||
log.info(
|
||||
"seat %s %s (auction timeout, deal %d)",
|
||||
seat,
|
||||
action,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "call":
|
||||
caller = state.caller
|
||||
chiamata.auto_call(state)
|
||||
log.info(
|
||||
"auto-called for seat %s (call timeout, deal %d)",
|
||||
caller,
|
||||
state.hand_number,
|
||||
)
|
||||
elif kind == "turn":
|
||||
seat = state.turn
|
||||
chiamata.auto_play(state)
|
||||
log.info(
|
||||
"auto-played for seat %s (turn timeout, deal %d)",
|
||||
seat,
|
||||
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: ChiamataState = session.state
|
||||
if state.winner is None:
|
||||
raise GameError("no result: the match is not finished")
|
||||
if state.caller is None: # pragma: no cover - a winner implies a caller
|
||||
raise GameError("no result: the caller is unknown")
|
||||
caller = state.caller
|
||||
partner = state.partner_seat
|
||||
caller_seats = [caller] + ([partner] if partner is not None else [])
|
||||
teams = [
|
||||
[_chiamata_player(state, seat).sub for seat in caller_seats],
|
||||
[
|
||||
player.sub
|
||||
for player in state.players
|
||||
if player.seat not in caller_seats
|
||||
],
|
||||
]
|
||||
caller_points, defender_points = chiamata.side_points(state)
|
||||
side_scores = [caller_points, defender_points]
|
||||
players = []
|
||||
for player in state.players:
|
||||
if player.seat == caller:
|
||||
role = ROLE_CALLER
|
||||
side = 0
|
||||
elif partner is not None and player.seat == partner:
|
||||
role = ROLE_PARTNER
|
||||
side = 0
|
||||
else:
|
||||
role = ROLE_DEFENDER
|
||||
side = 1
|
||||
players.append(
|
||||
PlayerResult(
|
||||
user_sub=player.sub,
|
||||
seat=player.seat,
|
||||
won=side == state.winner,
|
||||
team=role,
|
||||
score=float(side_scores[side]),
|
||||
details={
|
||||
"points": sum(card_points(card) for card in player.captured),
|
||||
"captured_cards": len(player.captured),
|
||||
},
|
||||
)
|
||||
)
|
||||
return MatchResult(
|
||||
teams=teams,
|
||||
winner_team=state.winner,
|
||||
players=players,
|
||||
summary={
|
||||
"team_a_score": caller_points,
|
||||
"team_b_score": defender_points,
|
||||
"winner_team": SIDE_NAMES[state.winner],
|
||||
"declared_target": state.declared_target,
|
||||
"called_card": (
|
||||
state.called_card.code if state.called_card is not None else None
|
||||
),
|
||||
"caller": _chiamata_player(state, caller).name,
|
||||
"partner": (
|
||||
_chiamata_player(state, partner).name
|
||||
if partner is not None
|
||||
else None
|
||||
),
|
||||
"solo": state.solo,
|
||||
"deals_played": state.hand_number,
|
||||
"deal_scores": state.hand_scores,
|
||||
},
|
||||
)
|
||||
@@ -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,667 @@
|
||||
"""Rules tests for briscola chiamata: auction, call, play, scoring, patta."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from tavolo.briscola import chiamata as rules
|
||||
from tavolo.briscola.chiamata import (
|
||||
BID_LADDER,
|
||||
Bid,
|
||||
ChiamataPlayer,
|
||||
ChiamataState,
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_FINISHED,
|
||||
PHASE_PLAYING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
SIDE_CALLER,
|
||||
SIDE_DEFENDERS,
|
||||
)
|
||||
from tavolo.briscola.errors import (
|
||||
CardNotInHand,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
NotYourTurn,
|
||||
)
|
||||
from tavolo.briscola.state import Card
|
||||
|
||||
|
||||
def card(code: str) -> Card:
|
||||
return Card.parse(code)
|
||||
|
||||
|
||||
def started_state() -> ChiamataState:
|
||||
"""A five-player game whose auction has just opened (dealer seat 0)."""
|
||||
state = rules.create_game("p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(state, f"p{i}", f"p{i}")
|
||||
return state
|
||||
|
||||
|
||||
def bid(state: ChiamataState, seat: int, rank: int) -> None:
|
||||
rules.bid_rank(state, f"p{seat}", rank)
|
||||
|
||||
|
||||
def make_state(
|
||||
hands,
|
||||
*,
|
||||
briscola_suit: str = "S",
|
||||
called_card: str | None = None,
|
||||
caller: int | None = None,
|
||||
solo: bool = False,
|
||||
called_played: bool = False,
|
||||
partner_seat: int | None = None,
|
||||
captured=None,
|
||||
turn: int = 0,
|
||||
dealer: int = 0,
|
||||
declared_target: int = rules.DEFAULT_TARGET,
|
||||
phase: str = PHASE_PLAYING,
|
||||
hand_number: int = 1,
|
||||
) -> ChiamataState:
|
||||
"""Build a controlled playing state directly (bypassing the auction)."""
|
||||
state = ChiamataState(
|
||||
phase=phase,
|
||||
dealer=dealer,
|
||||
hand_number=hand_number,
|
||||
declared_target=declared_target,
|
||||
briscola_suit=briscola_suit,
|
||||
called_card=card(called_card) if called_card else None,
|
||||
caller=caller,
|
||||
solo=solo,
|
||||
called_played=called_played,
|
||||
partner_seat=partner_seat,
|
||||
turn=turn,
|
||||
)
|
||||
for seat, hand in enumerate(hands):
|
||||
state.players.append(
|
||||
ChiamataPlayer(
|
||||
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 play_all(state: ChiamataState) -> None:
|
||||
"""Play the first card of each hand in turn order until the trick resolves."""
|
||||
for _ in range(rules.PLAYER_COUNT):
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
rules.play(state, player.sub, player.hand[0].code)
|
||||
|
||||
|
||||
class SetupTest(unittest.TestCase):
|
||||
def test_five_player_deal(self) -> None:
|
||||
state = started_state()
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
self.assertEqual(rules.PLAYER_COUNT, len(state.players))
|
||||
for player in state.players:
|
||||
self.assertEqual(rules.HAND_SIZE, len(player.hand))
|
||||
# The whole deck is dealt: no stock, no face-up card.
|
||||
dealt = [c.code for p in state.players for c in p.hand]
|
||||
self.assertEqual(40, len(dealt))
|
||||
self.assertEqual(40, len(set(dealt)))
|
||||
# The auction opens with the player after the dealer.
|
||||
assert state.auction is not None
|
||||
self.assertEqual(1, state.auction.turn)
|
||||
self.assertEqual([0, 1, 2, 3, 4], state.auction.active)
|
||||
self.assertIsNone(state.auction.current)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull
|
||||
|
||||
state = rules.create_game("p0", "p0")
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
rules.join_game(state, "p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(state, f"p{i}", f"p{i}")
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
with self.assertRaises(GameNotStarted):
|
||||
rules.join_game(state, "p5", "p5")
|
||||
|
||||
lobby = rules.create_game("p0", "p0")
|
||||
for i in range(1, 5):
|
||||
rules.join_game(lobby, f"p{i}", f"p{i}")
|
||||
# Joining a sixth player is impossible once the fifth started the
|
||||
# match; exercise LobbyFull on an artificially unfilled start.
|
||||
unfilled = rules.create_game("p0", "p0")
|
||||
unfilled.phase = rules.PHASE_LOBBY
|
||||
for i in range(1, 5):
|
||||
unfilled.players.append(
|
||||
ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i)
|
||||
)
|
||||
with self.assertRaises(LobbyFull):
|
||||
rules.join_game(unfilled, "p5", "p5")
|
||||
|
||||
|
||||
class AuctionTest(unittest.TestCase):
|
||||
def test_first_bid_accepts_any_rank(self) -> None:
|
||||
state = started_state()
|
||||
rules.bid_rank(state, "p1", 5)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current)
|
||||
|
||||
def test_bids_must_descend(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1) # ace
|
||||
with self.assertRaises(IllegalMove):
|
||||
bid(state, 2, 1) # same rank
|
||||
bid(state, 2, 3) # three, weaker than the ace
|
||||
bid(state, 3, 10) # king, weaker than the three
|
||||
with self.assertRaises(IllegalMove):
|
||||
bid(state, 4, 3) # three is stronger than the king
|
||||
bid(state, 4, 9) # horse
|
||||
|
||||
def test_bid_ladder_ordering(self) -> None:
|
||||
# A bid must be weaker than the standing bid: descending strength
|
||||
# follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2.
|
||||
state = started_state()
|
||||
bid(state, 1, 10) # king
|
||||
for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)):
|
||||
rules.bid_rank(state, f"p{opponent}", rank)
|
||||
# 3 and A are stronger than 6: rejected.
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p1", 3)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p1", 1)
|
||||
rules.bid_rank(state, "p1", 5)
|
||||
|
||||
def test_not_your_turn(self) -> None:
|
||||
state = started_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
bid(state, 2, 1)
|
||||
|
||||
def test_pass_eliminates_and_last_standing_bids(self) -> None:
|
||||
state = started_state()
|
||||
for seat in (1, 2, 3, 4):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
assert state.auction is not None
|
||||
# Only seat 0 remains and nobody has bid: it must bid.
|
||||
self.assertEqual([0], state.auction.active)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.pass_auction(state, "p0")
|
||||
rules.bid_rank(state, "p0", 2)
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(0, state.caller)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
def test_auction_ends_when_one_bidder_remains(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1) # ace
|
||||
rules.pass_auction(state, "p2")
|
||||
bid(state, 3, 10) # king
|
||||
rules.pass_auction(state, "p4")
|
||||
rules.pass_auction(state, "p0")
|
||||
rules.pass_auction(state, "p1")
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(3, state.caller)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
def test_point_raises_only_after_the_two(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 1)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p2", 62)
|
||||
bid(state, 2, 2)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_rank(state, "p3", 2) # no ranks below the 2
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p3", 61) # not higher
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.bid_points(state, "p3", 121) # above the cap
|
||||
rules.bid_points(state, "p3", 63)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current)
|
||||
|
||||
def test_legal_bids_exposed_to_the_bidder_only(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 10) # king
|
||||
view = rules.state_for_player(state, "p2")
|
||||
self.assertTrue(view["your_turn"])
|
||||
legal = view["legal_bids"]
|
||||
self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"])
|
||||
self.assertIsNone(legal["points_min"])
|
||||
# A player who is not on turn gets no legal bids.
|
||||
other = rules.state_for_player(state, "p3")
|
||||
self.assertNotIn("legal_bids", other)
|
||||
self.assertNotIn("your_turn", other)
|
||||
|
||||
def test_legal_points_exposed_at_the_two(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 2)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
self.assertEqual([], view["legal_bids"]["ranks"])
|
||||
self.assertEqual(63, view["legal_bids"]["points_min"])
|
||||
self.assertEqual(120, view["legal_bids"]["points_max"])
|
||||
|
||||
def test_auto_bid_always_passes_when_legal(self) -> None:
|
||||
state = started_state()
|
||||
assert state.auction is not None
|
||||
rules.auto_bid(state)
|
||||
# Seat 1 passes: nobody has bid and the turn moves on.
|
||||
self.assertEqual([1], state.auction.passed)
|
||||
self.assertIsNone(state.auction.current)
|
||||
self.assertEqual(2, state.auction.turn)
|
||||
# An absent bidder still passes when there is a standing bid.
|
||||
rules.bid_rank(state, "p2", 10)
|
||||
rules.auto_bid(state)
|
||||
self.assertEqual([1, 3], state.auction.passed)
|
||||
assert state.auction.current is not None
|
||||
self.assertEqual(10, state.auction.current.rank)
|
||||
|
||||
def test_auto_bid_passes_at_the_point_stage(self) -> None:
|
||||
state = started_state()
|
||||
rules.bid_rank(state, "p1", 2)
|
||||
rules.bid_points(state, "p2", 62)
|
||||
rules.auto_bid(state) # seat 3
|
||||
assert state.auction is not None
|
||||
self.assertIn(3, state.auction.passed)
|
||||
assert state.auction.current is not None
|
||||
self.assertEqual(62, state.auction.current.points)
|
||||
|
||||
def test_auto_bid_forced_to_call_the_ace(self) -> None:
|
||||
state = started_state()
|
||||
for seat in (1, 2, 3, 4):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
# Seat 0 is the last one standing and nobody has bid yet: the
|
||||
# forced auto-bid calls the ace.
|
||||
rules.auto_bid(state)
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(0, state.caller)
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
self.assertEqual(1, state.auction.current.rank)
|
||||
self.assertEqual(61, state.declared_target)
|
||||
|
||||
|
||||
class CallTest(unittest.TestCase):
|
||||
def _called_state(self, rank: int = 1) -> ChiamataState:
|
||||
state = started_state()
|
||||
bid(state, 1, rank)
|
||||
for seat in (2, 3, 4, 0):
|
||||
rules.pass_auction(state, f"p{seat}")
|
||||
return state
|
||||
|
||||
def test_call_must_match_the_winning_rank(self) -> None:
|
||||
state = self._called_state(1)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.call(state, "p1", "03D")
|
||||
# Any suit of the winning rank is fine.
|
||||
rules.call(state, "p1", "01D")
|
||||
self.assertEqual("D", state.briscola_suit)
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
|
||||
def test_only_the_winner_calls(self) -> None:
|
||||
state = self._called_state()
|
||||
with self.assertRaises(NotYourTurn):
|
||||
rules.call(state, "p2", "01D")
|
||||
|
||||
def test_call_in_hand_means_playing_alone(self) -> None:
|
||||
state = self._called_state(1)
|
||||
# Seat 1 calls a card it holds: solo.
|
||||
state.players[1].hand[0] = card("01S")
|
||||
rules.call(state, "p1", "01S")
|
||||
self.assertTrue(state.solo)
|
||||
self.assertIsNone(state.partner_seat)
|
||||
|
||||
def test_play_starts_with_the_player_after_the_dealer(self) -> None:
|
||||
state = self._called_state(1)
|
||||
rules.call(state, "p1", "01D")
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertNotEqual(state.caller, None)
|
||||
|
||||
def test_auto_call_names_the_bid_rank(self) -> None:
|
||||
state = self._called_state(10)
|
||||
rules.auto_call(state, random.Random(3))
|
||||
assert state.called_card is not None
|
||||
self.assertEqual(10, state.called_card.rank)
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
|
||||
def test_calling_phase_view(self) -> None:
|
||||
state = self._called_state(1)
|
||||
view = rules.state_for_player(state, "p1")
|
||||
self.assertEqual(PHASE_CALLING, view["phase"])
|
||||
self.assertTrue(view["your_turn"])
|
||||
self.assertEqual(
|
||||
["01D", "01C", "01S", "01B"], view["callable_cards"]
|
||||
)
|
||||
other = rules.state_for_player(state, "p2")
|
||||
self.assertNotIn("callable_cards", other)
|
||||
|
||||
|
||||
class PlayTest(unittest.TestCase):
|
||||
def test_five_player_trick_resolution(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=True,
|
||||
partner_seat=4,
|
||||
turn=0,
|
||||
)
|
||||
play_all(state)
|
||||
assert state.last_trick is not None
|
||||
self.assertEqual(3, state.last_trick.winner)
|
||||
|
||||
def test_trump_wins(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["01D"], ["04D"], ["02C"], ["03D"]],
|
||||
briscola_suit="C",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=True,
|
||||
partner_seat=4,
|
||||
turn=0,
|
||||
)
|
||||
play_all(state)
|
||||
assert state.last_trick is not None
|
||||
self.assertEqual(3, state.last_trick.winner)
|
||||
|
||||
def test_called_card_reveals_the_partner(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["01D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
called_card="01S",
|
||||
turn=1,
|
||||
)
|
||||
self.assertFalse(state.called_played)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertNotIn("scores", view)
|
||||
rules.play(state, "p1", "01D")
|
||||
# The called card has not been played yet.
|
||||
self.assertFalse(state.called_played)
|
||||
# Drive the trick until the called card appears: give it to seat 3.
|
||||
state.players[3].hand = [card("01S")]
|
||||
state.turn = 3
|
||||
rules.play(state, "p3", "01S")
|
||||
self.assertTrue(state.called_played)
|
||||
self.assertEqual(3, state.partner_seat)
|
||||
view = rules.state_for_player(state, "p2")
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertEqual(ROLE_PARTNER, roles[3])
|
||||
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||
self.assertIn("scores", view)
|
||||
|
||||
def test_called_card_played_by_the_caller_is_solo(self) -> None:
|
||||
state = make_state(
|
||||
[["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="01S",
|
||||
solo=True,
|
||||
turn=0,
|
||||
)
|
||||
rules.play(state, "p0", "01S")
|
||||
self.assertTrue(state.called_played)
|
||||
self.assertIsNone(state.partner_seat)
|
||||
view = rules.state_for_player(state, "p4")
|
||||
self.assertTrue(view["solo"])
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[0])
|
||||
self.assertEqual(ROLE_DEFENDER, roles[1])
|
||||
|
||||
def test_play_validations(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
turn=0,
|
||||
)
|
||||
with self.assertRaises(NotYourTurn):
|
||||
rules.play(state, "p1", "05D")
|
||||
with self.assertRaises(CardNotInHand):
|
||||
rules.play(state, "p0", "01D")
|
||||
state.phase = PHASE_FINISHED
|
||||
with self.assertRaises(GameFinished):
|
||||
rules.play(state, "p0", "02D")
|
||||
|
||||
def test_play_rejected_before_the_call(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
caller=0,
|
||||
turn=0,
|
||||
phase=PHASE_CALLING,
|
||||
)
|
||||
with self.assertRaises(IllegalMove):
|
||||
rules.play(state, "p0", "02D")
|
||||
|
||||
def test_auto_play_advances(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]],
|
||||
briscola_suit="S",
|
||||
caller=0,
|
||||
called_card="09B",
|
||||
turn=0,
|
||||
)
|
||||
rules.auto_play(state, random.Random(7))
|
||||
self.assertEqual(1, state.turn)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
self.assertEqual(1, len(state.players[0].hand))
|
||||
|
||||
|
||||
class ScoringTest(unittest.TestCase):
|
||||
def _piles(self):
|
||||
# 60 points in denari + coppe, 60 in spade + bastoni.
|
||||
a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [
|
||||
f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)
|
||||
]
|
||||
b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [
|
||||
f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)
|
||||
]
|
||||
return a_cards, b_cards
|
||||
|
||||
def test_side_points_split_caller_side_and_defenders(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=3,
|
||||
called_played=True,
|
||||
captured=[
|
||||
["01D"], # caller, side A: 11
|
||||
["03C", "01S"], # defender: 21
|
||||
["10D"], # defender: 4
|
||||
["01C"], # partner, side A: 11
|
||||
["03S"], # defender: 10
|
||||
],
|
||||
)
|
||||
caller_points, defender_points = rules.side_points(state)
|
||||
self.assertEqual(22, caller_points)
|
||||
self.assertEqual(35, defender_points)
|
||||
|
||||
def test_solo_side_points(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
solo=True,
|
||||
called_played=True,
|
||||
captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]],
|
||||
)
|
||||
caller_points, defender_points = rules.side_points(state)
|
||||
self.assertEqual(11, caller_points)
|
||||
self.assertEqual(35, defender_points)
|
||||
|
||||
def test_calling_side_wins_at_the_target(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
b_rest = [c for c in b_cards if c != "01S"]
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards, ["01S"], [], b_rest, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_CALLER, state.winner)
|
||||
self.assertEqual([71, 49], state.scores)
|
||||
|
||||
def test_defenders_win_below_the_declared_target(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
# Calling side only 30 points of the 61 needed.
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards[:5], [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||
self.assertEqual([30, 60], state.scores)
|
||||
|
||||
def test_patta_replays_the_deal_with_a_new_auction(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
captured=[a_cards, [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_AUCTION, state.phase)
|
||||
self.assertEqual(2, state.hand_number)
|
||||
self.assertEqual(1, state.dealer)
|
||||
assert state.auction is not None
|
||||
self.assertEqual(2, state.auction.turn)
|
||||
self.assertIsNone(state.caller)
|
||||
self.assertIsNone(state.called_card)
|
||||
# The patta entry is kept in the audit trail.
|
||||
entry = state.hand_scores[-1]
|
||||
self.assertTrue(entry["patta"])
|
||||
self.assertEqual({"A": 60, "B": 60}, entry["points"])
|
||||
|
||||
def test_raised_target_disables_patta(self) -> None:
|
||||
a_cards, b_cards = self._piles()
|
||||
state = make_state(
|
||||
[[], [], [], [], []],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
partner_seat=1,
|
||||
called_played=True,
|
||||
declared_target=62,
|
||||
captured=[a_cards, [], [], b_cards, []],
|
||||
)
|
||||
rules._end_deal(state)
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(SIDE_DEFENDERS, state.winner)
|
||||
|
||||
def test_full_deal_scores_120(self) -> None:
|
||||
state = started_state()
|
||||
rng = random.Random(2026)
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 5000:
|
||||
if state.phase == PHASE_AUCTION:
|
||||
rules.auto_bid(state)
|
||||
elif state.phase == PHASE_CALLING:
|
||||
rules.auto_call(state, rng)
|
||||
else:
|
||||
rules.auto_play(state, rng)
|
||||
moves += 1
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(120, sum(state.scores))
|
||||
self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS))
|
||||
captured = sum(len(p.captured) for p in state.players)
|
||||
self.assertEqual(40, captured)
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_state_for_player_hides_other_hands(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
called_card="01D",
|
||||
called_played=False,
|
||||
turn=0,
|
||||
)
|
||||
view = rules.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("01D", view["called_card"])
|
||||
self.assertEqual(5, view["player_count"])
|
||||
|
||||
def test_spectator_sees_public_information_only(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=2,
|
||||
called_card="01D",
|
||||
called_played=False,
|
||||
)
|
||||
view = rules.state_for_player(state, None)
|
||||
for player in view["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
self.assertNotIn("your_turn", view)
|
||||
self.assertNotIn("scores", view)
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[2])
|
||||
self.assertEqual("", roles[0])
|
||||
|
||||
def test_player_points_are_public(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
|
||||
briscola_suit="D",
|
||||
caller=0,
|
||||
captured=[["01C", "03B"], ["10D"], [], [], []],
|
||||
)
|
||||
view = rules.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"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
state = started_state()
|
||||
bid(state, 1, 10)
|
||||
bid(state, 2, 7)
|
||||
restored = ChiamataState.from_json(state.to_json())
|
||||
self.assertEqual(state.phase, restored.phase)
|
||||
self.assertEqual(state.hand_number, restored.hand_number)
|
||||
self.assertEqual(state.turn, restored.turn)
|
||||
assert restored.auction is not None and state.auction is not None
|
||||
self.assertEqual(state.auction.active, restored.auction.active)
|
||||
self.assertEqual(state.auction.passed, restored.auction.passed)
|
||||
self.assertEqual(
|
||||
state.auction.history, restored.auction.history
|
||||
)
|
||||
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 = started_state()
|
||||
data = state.to_json()
|
||||
del data["auction"]
|
||||
del data["called_card"]
|
||||
del data["declared_target"]
|
||||
restored = ChiamataState.from_json(data)
|
||||
self.assertIsNone(restored.auction)
|
||||
self.assertIsNone(restored.called_card)
|
||||
self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target)
|
||||
self.assertFalse(restored.solo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Adapter tests: the platform contract over the briscola chiamata rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from tavolo.briscola import BriscolaChiamataEngine
|
||||
from tavolo.briscola import chiamata as rules
|
||||
from tavolo.briscola.chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_FINISHED,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
ROLE_CALLER,
|
||||
ROLE_DEFENDER,
|
||||
ROLE_PARTNER,
|
||||
ChiamataState,
|
||||
)
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameError,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
)
|
||||
|
||||
|
||||
def _session(engine, **options) -> GameSession:
|
||||
session = GameSession(
|
||||
id="c1",
|
||||
game_type=engine.id,
|
||||
join_code="CODE01",
|
||||
creator_sub="p0",
|
||||
players=[Seat(user_sub="p0", display_name="P0")],
|
||||
)
|
||||
engine.create(session, options)
|
||||
return session
|
||||
|
||||
|
||||
def _started(engine, **options) -> GameSession:
|
||||
session = _session(engine, **options)
|
||||
for i in range(1, engine.max_players):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
return session
|
||||
|
||||
|
||||
def _player_on_turn(session: GameSession):
|
||||
state = session.state
|
||||
return next(p for p in state.players if p.seat == state.turn)
|
||||
|
||||
|
||||
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_AUCTION:
|
||||
player = _player_on_turn(session)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
# The last player standing must bid.
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(
|
||||
p for p in state.players if p.seat == state.caller
|
||||
)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
player = _player_on_turn(session)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session), "match did not finish"
|
||||
|
||||
|
||||
class MetadataTest(unittest.TestCase):
|
||||
def test_metadata(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
self.assertEqual("briscola_chiamata", engine.id)
|
||||
self.assertEqual("Briscola chiamata", engine.name)
|
||||
self.assertTrue(engine.description)
|
||||
self.assertEqual(5, engine.min_players)
|
||||
self.assertEqual(5, engine.max_players)
|
||||
self.assertEqual({}, engine.options_schema)
|
||||
|
||||
|
||||
class CreateJoinTest(unittest.TestCase):
|
||||
def test_create_seats_creator_without_a_team(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
self.assertIsNone(session.players[0].team)
|
||||
self.assertIsInstance(session.state, ChiamataState)
|
||||
self.assertEqual(PHASE_LOBBY, session.state.phase)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
for i in range(1, 4):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
engine.join(session, "p4", "P4")
|
||||
self.assertFalse(engine.in_lobby(session))
|
||||
self.assertEqual(PHASE_AUCTION, session.state.phase)
|
||||
self.assertEqual(5, len(session.players))
|
||||
for seat in session.players:
|
||||
self.assertIsNone(seat.team)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
engine.join(session, "p0", "P0")
|
||||
for i in range(1, 5):
|
||||
engine.join(session, f"p{i}", f"P{i}")
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.join(session, "p5", "P5")
|
||||
|
||||
def test_timeouts_come_from_the_engine(self) -> None:
|
||||
engine = BriscolaChiamataEngine(
|
||||
turn_timeout_seconds=7, hand_ack_timeout_seconds=9
|
||||
)
|
||||
session = _session(engine)
|
||||
self.assertEqual(7, session.state.turn_timeout)
|
||||
|
||||
|
||||
class ActionTest(unittest.TestCase):
|
||||
def test_unknown_action_rejected(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "dance", {})
|
||||
|
||||
def test_bid_payload_validation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"rank": "ace"})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"rank": True})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"points": False})
|
||||
# points cannot be raised before a bid of the 2
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "bid", {"points": 64})
|
||||
|
||||
def test_auction_flow_through_actions(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
engine.handle_action(session, "p2", "pass", {})
|
||||
engine.handle_action(session, "p3", "bid", {"rank": 2})
|
||||
engine.handle_action(session, "p4", "bid", {"points": 64})
|
||||
engine.handle_action(session, "p0", "pass", {})
|
||||
engine.handle_action(session, "p1", "pass", {})
|
||||
engine.handle_action(session, "p3", "pass", {})
|
||||
state = session.state
|
||||
self.assertEqual(PHASE_CALLING, state.phase)
|
||||
self.assertEqual(4, state.caller)
|
||||
self.assertEqual(64, state.declared_target)
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
self.assertEqual(2, state.auction.current.rank)
|
||||
|
||||
engine.handle_action(session, "p4", "call", {"card": "02S"})
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertEqual("S", state.briscola_suit)
|
||||
|
||||
# Play the first card of the player on turn.
|
||||
player = _player_on_turn(session)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
|
||||
def test_call_payload_validation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": 42})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": "nope"})
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "call", {"card": "01D"})
|
||||
|
||||
def test_finished_match_rejects_actions(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
_drive_to_finish(engine, session)
|
||||
with self.assertRaises(GameError):
|
||||
engine.handle_action(session, session.players[0].user_sub, "play", {})
|
||||
|
||||
|
||||
class ViewTest(unittest.TestCase):
|
||||
def test_view_hides_other_hands_and_roles(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "p0")
|
||||
players = {p["seat"]: p for p in view["players"]}
|
||||
self.assertIn("hand", players[0])
|
||||
self.assertNotIn("hand", players[1])
|
||||
self.assertNotIn("your_turn", view)
|
||||
self.assertNotIn("scores", view)
|
||||
self.assertEqual(5, view["player_count"])
|
||||
# p1 opens the auction.
|
||||
auction = view["auction"]
|
||||
self.assertEqual(1, auction["turn"])
|
||||
self.assertIsNone(auction["current"])
|
||||
|
||||
def test_view_marks_the_acting_player(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, "p1")
|
||||
self.assertTrue(view["your_turn"])
|
||||
self.assertIn("legal_bids", view)
|
||||
# The caller is labeled as soon as the auction is won.
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
view = engine.view_for(session, "p0")
|
||||
self.assertIn("callable_cards", engine.view_for(session, "p1"))
|
||||
roles = {p["seat"]: p["team"] for p in view["players"]}
|
||||
self.assertEqual(ROLE_CALLER, roles[1])
|
||||
|
||||
def test_spectator_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
view = engine.view_for(session, None)
|
||||
for player in view["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
self.assertNotIn("your_turn", view)
|
||||
|
||||
def test_lobby_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _session(engine)
|
||||
lobby = engine.lobby_view(session)
|
||||
self.assertEqual("lobby", lobby["phase"])
|
||||
self.assertEqual(61, lobby["declared_target"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 10})
|
||||
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||
self.assertIsInstance(restored, ChiamataState)
|
||||
self.assertEqual(session.state.phase, restored.phase)
|
||||
self.assertEqual(session.state.turn, restored.turn)
|
||||
assert restored.auction is not None and session.state.auction is not None
|
||||
self.assertEqual(session.state.auction.history, restored.auction.history)
|
||||
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 = BriscolaChiamataEngine()
|
||||
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||
|
||||
def test_auction_deadline_and_revalidation(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("auction", deadline.kind)
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, "auction", "auction:1:1:0")
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
# The auto-bid moved the auction on: a fresh deadline exists.
|
||||
state = session.state
|
||||
self.assertIn(
|
||||
state.phase, (PHASE_AUCTION, PHASE_CALLING, PHASE_PLAYING)
|
||||
)
|
||||
self.assertIsNotNone(engine.next_deadline(session))
|
||||
|
||||
def test_stale_auction_deadline_after_a_bid(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 1})
|
||||
with self.assertRaises(GameError):
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
|
||||
def test_call_deadline_auto_calls(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("call", deadline.kind)
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
state = session.state
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
assert state.called_card is not None
|
||||
self.assertEqual(2, state.called_card.rank)
|
||||
|
||||
def test_turn_deadline_auto_plays(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
engine.handle_action(session, "p1", "bid", {"rank": 2})
|
||||
for i in (2, 3, 4, 0):
|
||||
engine.handle_action(session, f"p{i}", "pass", {})
|
||||
engine.handle_action(session, "p1", "call", {"card": "02D"})
|
||||
deadline = engine.next_deadline(session)
|
||||
assert deadline is not None
|
||||
self.assertEqual("turn", deadline.kind)
|
||||
state = session.state
|
||||
before = state.turn
|
||||
engine.fire_deadline(session, deadline.kind, deadline.token)
|
||||
self.assertEqual(1, len(state.trick))
|
||||
self.assertNotEqual(before, state.turn)
|
||||
|
||||
|
||||
class ResultTest(unittest.TestCase):
|
||||
def test_result_of_finished_match(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
_drive_to_finish(engine, session)
|
||||
state = session.state
|
||||
result = engine.result(session)
|
||||
sizes = sorted(len(team) for team in result.teams)
|
||||
self.assertIn(sizes, ([2, 3], [1, 4]))
|
||||
self.assertEqual(5, len(result.players))
|
||||
self.assertIn(result.winner_team, (0, 1))
|
||||
for player in result.players:
|
||||
self.assertIn(player.team, (ROLE_CALLER, ROLE_PARTNER, ROLE_DEFENDER))
|
||||
self.assertEqual(
|
||||
player.won,
|
||||
(player.team in (ROLE_CALLER, ROLE_PARTNER))
|
||||
== (result.winner_team == 0),
|
||||
)
|
||||
self.assertIn("points", player.details)
|
||||
summary = result.summary
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
self.assertEqual(state.hand_number, summary["deals_played"])
|
||||
|
||||
def test_result_requires_finished_match(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
with self.assertRaises(GameError):
|
||||
engine.result(_started(engine))
|
||||
|
||||
def test_solo_result_teams(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
# Force a finished solo match: seat 0 called its own card.
|
||||
state.caller = 0
|
||||
state.solo = True
|
||||
state.partner_seat = None
|
||||
state.called_played = True
|
||||
state.called_card = Card.parse("01D")
|
||||
# Caller side captures 71 (60 + 11), defenders 49.
|
||||
state.players[0].captured = [
|
||||
Card.parse(code)
|
||||
for code in [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)]
|
||||
+ [f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)]
|
||||
+ ["01S"]
|
||||
]
|
||||
state.players[1].captured = [
|
||||
Card.parse(code)
|
||||
for code in [f"{rank:02d}S" for rank in (3, 10, 9, 8)]
|
||||
+ [f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)]
|
||||
]
|
||||
state.scores = [71, 49]
|
||||
state.winner = 0
|
||||
state.phase = PHASE_FINISHED
|
||||
result = engine.result(session)
|
||||
self.assertEqual([1, 4], sorted(len(team) for team in result.teams))
|
||||
# The lone caller won: exactly one winner.
|
||||
self.assertEqual(1, sum(1 for p in result.players if p.won))
|
||||
caller = next(p for p in result.players if p.team == ROLE_CALLER)
|
||||
self.assertTrue(caller.won)
|
||||
self.assertEqual(71.0, caller.score)
|
||||
|
||||
def test_game_over_view(self) -> None:
|
||||
engine = BriscolaChiamataEngine()
|
||||
session = _started(engine)
|
||||
state = session.state
|
||||
state.phase = PHASE_FINISHED
|
||||
state.winner = 1
|
||||
state.scores = [55, 65]
|
||||
state.caller = 2
|
||||
state.solo = False
|
||||
state.partner_seat = 4
|
||||
state.called_card = Card.parse("03C")
|
||||
over = engine.game_over_view(session, "p0")
|
||||
self.assertEqual({"A": 55, "B": 65}, over["scores"])
|
||||
self.assertEqual("Difensori", over["winner"])
|
||||
self.assertEqual("03C", over["called_card"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -33,29 +33,33 @@ itself ships no game.
|
||||
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)`).
|
||||
`details`) and `PlayerRating` (Elo and leaderboard aggregates per
|
||||
`(user_sub, game_type)`).
|
||||
- `stats.py` — `save_match_result` (engine `MatchResult` → Postgres,
|
||||
transactionally, with Elo) and `apply_elo`.
|
||||
transactionally, with Elo and the leaderboard aggregates) and
|
||||
`apply_match_stats`.
|
||||
- `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://…`.
|
||||
- `backfill_elo.py` — rebuild every rating and aggregate 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.
|
||||
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and
|
||||
page-number pagination, shared OpenAPI fragments and response schemas,
|
||||
the TortoiseORM lifecycle mixin.
|
||||
|
||||
## Adding a game
|
||||
|
||||
Implement `GameEngine` (see `engine.py` for the full contract), register
|
||||
it in a `GameRegistry`, and mount `PlatformMixin(Platform(...))` on a
|
||||
`KayaApp` — see the composition root in `tavolo.app`. The canonical
|
||||
example is
|
||||
[`tavolo-scopone`](../tavolo-scopone/README.md).
|
||||
examples are [`tavolo-scopone`](../tavolo-scopone/README.md),
|
||||
[`tavolo-scopa`](../tavolo-scopa/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
|
||||
uv run python -m unittest discover -s packages/tavolo-platform/tests
|
||||
uv run mypy -p tavolo.platform
|
||||
```
|
||||
|
||||
Tests run fully in-process against a `DummyEngine` (a two-player toy game
|
||||
|
||||
@@ -9,9 +9,9 @@ description = "Game-independent multiplayer game platform (lobby, live play, mat
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"kaya-core",
|
||||
"kaya-core>=0.0.7",
|
||||
"kaya-session",
|
||||
"kaya-oidc",
|
||||
"kaya-oidc>=0.0.7",
|
||||
"kaya-openapi",
|
||||
"tortoise-orm",
|
||||
"redis",
|
||||
|
||||
@@ -9,7 +9,29 @@ from __future__ import annotations
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
|
||||
from kaya.core import HttpContext, WebSocket
|
||||
from kaya.oidc import OIDCMixin, OIDCUser
|
||||
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]:
|
||||
@@ -21,22 +43,46 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
||||
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)
|
||||
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"):
|
||||
"""Best-effort human-readable name for a user.
|
||||
|
||||
``preferred_username`` is preferred: it is the account's stable handle,
|
||||
while ``name`` is a free-form display name a user may change (and two
|
||||
accounts can share). The ``sub`` is the last resort.
|
||||
"""
|
||||
for key in ("preferred_username", "name"):
|
||||
value = user.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return user.sub
|
||||
|
||||
|
||||
def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
def picture(user: OIDCUser) -> Optional[str]:
|
||||
"""The user's avatar URL, when the provider supplies a usable one.
|
||||
|
||||
Only absolute ``http(s)`` URLs are exported: the claim ends up in an
|
||||
``<img src>`` in the SPA, where relative or script-ish schemes have no
|
||||
business.
|
||||
"""
|
||||
value = user.picture
|
||||
if isinstance(value, str) and value.startswith(("http://", "https://")):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
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 —
|
||||
@@ -46,7 +92,7 @@ def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[...
|
||||
|
||||
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
||||
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
|
||||
if oidc.get_user(ctx) is None:
|
||||
if current_user(oidc, ctx) is None:
|
||||
await ctx.send_bytes(
|
||||
401,
|
||||
b'{"error":"unauthenticated"}',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Recompute every Elo rating from the recorded match history.
|
||||
"""Recompute every Elo rating and leaderboard aggregate from the 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::
|
||||
Ratings and aggregates 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 the leaderboard state needs to be rebuilt::
|
||||
|
||||
python -m tavolo.platform.backfill_elo --database-url postgres://...
|
||||
"""
|
||||
@@ -17,14 +17,14 @@ from typing import Dict, List
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from .stats import apply_elo
|
||||
from .stats import PlayerStats, apply_match_stats
|
||||
from .tortoise_mixin import TortoiseMixin
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
async def backfill_elo() -> int:
|
||||
"""Rebuild all ratings; returns the number of matches replayed."""
|
||||
"""Rebuild all ratings and aggregates; returns the matches replayed."""
|
||||
from .models import Match, MatchPlayer, PlayerRating
|
||||
|
||||
replayed = 0
|
||||
@@ -48,7 +48,20 @@ async def backfill_elo() -> int:
|
||||
),
|
||||
0,
|
||||
)
|
||||
deltas = await apply_elo(match.game_type, teams, winner_index)
|
||||
deltas = await apply_match_stats(
|
||||
match.game_type,
|
||||
teams,
|
||||
winner_index,
|
||||
[
|
||||
PlayerStats(
|
||||
user_sub=p.user_sub,
|
||||
won=p.won,
|
||||
score=p.score,
|
||||
display_name=p.display_name,
|
||||
)
|
||||
for p in players
|
||||
],
|
||||
)
|
||||
for player in players:
|
||||
player.elo_delta = deltas[player.user_sub]
|
||||
await player.save()
|
||||
|
||||
@@ -11,8 +11,11 @@ machinery; the *meaning* of each deadline belongs to the game engine:
|
||||
token), and the scheduler enqueues it in the store's shared deadline
|
||||
queue (a Redis sorted set in production, see
|
||||
:mod:`tavolo.platform.store`); enqueueing is idempotent;
|
||||
* a background consumer running on **every** worker polls the queue for
|
||||
due entries and hands them to the engine's
|
||||
* 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
|
||||
@@ -65,8 +68,8 @@ class DeadlineScheduler:
|
||||
"""Enqueue and fire the deadlines game engines ask for.
|
||||
|
||||
Holds the store and the registry so a single instance serves every
|
||||
game type. One consumer task (and its wake-up event) is kept per
|
||||
event loop — tests run each test on a fresh loop.
|
||||
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
|
||||
@@ -80,14 +83,19 @@ class DeadlineScheduler:
|
||||
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.
|
||||
@@ -109,6 +117,16 @@ class DeadlineScheduler:
|
||||
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()
|
||||
@@ -190,15 +208,22 @@ class DeadlineScheduler:
|
||||
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()
|
||||
|
||||
@@ -228,21 +253,48 @@ class DeadlineScheduler:
|
||||
log.exception("deadline consumer: poll failed; retrying")
|
||||
failed = True
|
||||
if failed:
|
||||
# A failed entry is still due: back off to the heartbeat
|
||||
# instead of polling it again immediately.
|
||||
delay = self._heartbeat
|
||||
# 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; coordination
|
||||
happens exclusively through the shared deadline queue and the
|
||||
per-game locks, so any worker may fire any game's deadline.
|
||||
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:
|
||||
|
||||
@@ -191,17 +191,28 @@ class GameEngine(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
"""The personalized game view for one player.
|
||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
"""The personalized game view for one viewer.
|
||||
|
||||
Must hide information the player is not entitled to (e.g. other
|
||||
players' hands). The platform merges in the envelope fields
|
||||
(``id``, ``join_code``, ``game_type``) itself.
|
||||
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)."""
|
||||
"""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:
|
||||
@@ -242,6 +253,13 @@ class GameEngine(ABC):
|
||||
``kind``/``token`` no longer match the live state.
|
||||
"""
|
||||
|
||||
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
"""Extra fields merged into the ``game_over`` websocket message."""
|
||||
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 {}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
``Platform`` bundles the collaborators every platform endpoint needs —
|
||||
the game registry, the live-game store, the deadline scheduler and the
|
||||
OIDC mixin used for authentication — so route and websocket modules
|
||||
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
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.oidc import OIDCMixin
|
||||
from kaya.oidc import OIDCProviders
|
||||
|
||||
from .deadlines import DeadlineScheduler
|
||||
from .registry import GameRegistry
|
||||
@@ -28,7 +28,7 @@ class Platform:
|
||||
registry: GameRegistry
|
||||
game_store: GameStore
|
||||
scheduler: DeadlineScheduler
|
||||
oidc: OIDCMixin
|
||||
oidc: OIDCProviders
|
||||
|
||||
|
||||
class PlatformMixin(KayaMixin):
|
||||
@@ -41,9 +41,10 @@ class PlatformMixin(KayaMixin):
|
||||
# Imported here so module import order stays acyclic: the route
|
||||
# modules reference Platform only for typing.
|
||||
from . import ws
|
||||
from .routes import games, health, me, stats
|
||||
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)
|
||||
|
||||
@@ -9,9 +9,10 @@ completed matches are written here. The schema is game-independent:
|
||||
* :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`).
|
||||
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
|
||||
totals (wins, points, latest display name) of a player for one game
|
||||
type, updated transactionally with every finished match (see
|
||||
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -69,7 +70,7 @@ class MatchPlayer(Model):
|
||||
|
||||
|
||||
class PlayerRating(Model):
|
||||
"""Current Elo rating of one player for one game type."""
|
||||
"""Current Elo rating and leaderboard totals of one player per game."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
# OIDC subject of the player; no local users table.
|
||||
@@ -78,9 +79,17 @@ class PlayerRating(Model):
|
||||
game_type = fields.CharField(max_length=32)
|
||||
rating = fields.IntField(default=INITIAL_RATING)
|
||||
matches_played = fields.IntField(default=0)
|
||||
# Leaderboard aggregates, updated with every finished match.
|
||||
wins = fields.IntField(default=0)
|
||||
points = fields.FloatField(default=0)
|
||||
# Most recent display name seen for the player.
|
||||
display_name = fields.CharField(max_length=200, default="")
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "player_rating"
|
||||
unique_together = (("user_sub", "game_type"),)
|
||||
indexes = (("game_type", "rating"),)
|
||||
# Composite index matching the leaderboard ordering within a game.
|
||||
indexes = (
|
||||
("game_type", "rating", "wins", "points", "matches_played", "user_sub"),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``."""
|
||||
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``.
|
||||
|
||||
Two kinds of fragment live here:
|
||||
|
||||
* parameter lists (``PAGINATION_PARAMETERS``, ``PAGE_PARAMETERS``), used
|
||||
directly in the route decorators, and
|
||||
* reusable response schemas, registered as ``components.schemas`` by the
|
||||
application (and the test fixtures) through ``SCHEMAS`` and referenced
|
||||
from the routes with :func:`kaya.openapi.schema_ref`.
|
||||
|
||||
Engine-defined payload parts (lobby options echo, game views, match
|
||||
results) are intentionally typed as open objects: the platform is
|
||||
game-agnostic, so their exact shape belongs to each engine, not to this
|
||||
document.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from kaya.openapi import schema_ref
|
||||
|
||||
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
|
||||
{
|
||||
@@ -19,3 +35,341 @@ PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
|
||||
"description": "Opaque pagination cursor from a previous response's next_cursor.",
|
||||
},
|
||||
]
|
||||
|
||||
# Page-number pagination (used by the leaderboard, which supports jumping
|
||||
# to a specific page).
|
||||
PAGE_PARAMETERS: List[Dict[str, Any]] = [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"description": "1-based page number (clamped to the last page).",
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
|
||||
"description": "Maximum number of results per page (clamped to [1, 100]).",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _object(
|
||||
properties: Dict[str, Any],
|
||||
required: Sequence[str] = (),
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
additional_properties: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""An object schema with the given properties (JSON Schema 2020-12)."""
|
||||
schema: Dict[str, Any] = {"type": "object", "properties": properties}
|
||||
if required:
|
||||
schema["required"] = list(required)
|
||||
if description is not None:
|
||||
schema["description"] = description
|
||||
if additional_properties:
|
||||
schema["additionalProperties"] = True
|
||||
return schema
|
||||
|
||||
|
||||
def _free_object(description: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""An open object: used for engine-specific payload parts."""
|
||||
schema: Dict[str, Any] = {"type": "object", "additionalProperties": True}
|
||||
if description is not None:
|
||||
schema["description"] = description
|
||||
return schema
|
||||
|
||||
|
||||
def _array(items: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"type": "array", "items": items}
|
||||
|
||||
|
||||
def _nullable(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""A copy of ``schema`` that also accepts ``null`` (OpenAPI 3.1 types)."""
|
||||
nullable = dict(schema)
|
||||
kind = nullable.get("type")
|
||||
if isinstance(kind, str):
|
||||
nullable["type"] = [kind, "null"]
|
||||
return nullable
|
||||
|
||||
|
||||
def json_response(description: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""A JSON response object carrying ``schema``, for ``@operation``."""
|
||||
return {
|
||||
"description": description,
|
||||
"content": {"application/json": {"schema": schema}},
|
||||
}
|
||||
|
||||
|
||||
def error_response(description: str) -> Dict[str, Any]:
|
||||
"""The standard JSON error envelope (``{"error": <message>}``)."""
|
||||
return json_response(description, schema_ref("Error"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Component schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ERROR_SCHEMA = _object({"error": {"type": "string"}}, ("error",))
|
||||
|
||||
HEALTH_SCHEMA = _object(
|
||||
{"status": {"type": "string", "enum": ["ok"]}}, ("status",)
|
||||
)
|
||||
|
||||
ME_SCHEMA = _object(
|
||||
{
|
||||
"sub": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"picture": _nullable({"type": "string"}),
|
||||
},
|
||||
("sub", "name", "picture"),
|
||||
)
|
||||
|
||||
AUTH_PROVIDER_SCHEMA = _object(
|
||||
{
|
||||
"name": {"type": "string"},
|
||||
"login_url": {"type": "string"},
|
||||
},
|
||||
("name", "login_url"),
|
||||
)
|
||||
|
||||
AUTH_PROVIDERS_PAGE_SCHEMA = _object(
|
||||
{"providers": _array(schema_ref("AuthProvider"))}, ("providers",)
|
||||
)
|
||||
|
||||
GAME_TYPE_SCHEMA = _object(
|
||||
{
|
||||
"id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"min_players": {"type": "integer"},
|
||||
"max_players": {"type": "integer"},
|
||||
"options_schema": _free_object(
|
||||
"JSON-schema fragment describing the game-specific creation "
|
||||
"options accepted by POST /api/games."
|
||||
),
|
||||
},
|
||||
("id", "name", "description", "min_players", "max_players", "options_schema"),
|
||||
)
|
||||
|
||||
GAME_TYPES_PAGE_SCHEMA = _object(
|
||||
{"results": _array(schema_ref("GameType"))}, ("results",)
|
||||
)
|
||||
|
||||
LOBBY_PLAYER_SCHEMA = _object(
|
||||
{
|
||||
"sub": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"seat": {"type": "integer"},
|
||||
"team": _nullable({
|
||||
"type": "string",
|
||||
"description": "Game-defined team label; null for games without fixed teams.",
|
||||
}),
|
||||
},
|
||||
("sub", "name", "seat", "team"),
|
||||
)
|
||||
|
||||
_LOBBY_PROPERTIES: Dict[str, Any] = {
|
||||
"id": {"type": "string"},
|
||||
"join_code": {"type": "string"},
|
||||
"game_type": {"type": "string"},
|
||||
"players": _array(schema_ref("LobbyPlayer")),
|
||||
"seats_open": {"type": "integer"},
|
||||
}
|
||||
|
||||
_LOBBY_REQUIRED = ("id", "join_code", "game_type", "players", "seats_open")
|
||||
|
||||
LOBBY_SCHEMA = _object(
|
||||
dict(_LOBBY_PROPERTIES),
|
||||
_LOBBY_REQUIRED,
|
||||
description="The lobby payload: the platform envelope plus the engine's "
|
||||
"lobby_view fields (the creation-options echo and the phase).",
|
||||
additional_properties=True,
|
||||
)
|
||||
|
||||
GAME_PREVIEW_SCHEMA = _object(
|
||||
{
|
||||
**_LOBBY_PROPERTIES,
|
||||
"game_name": {"type": "string"},
|
||||
"game_description": {"type": "string"},
|
||||
},
|
||||
(*_LOBBY_REQUIRED, "game_name", "game_description"),
|
||||
description="The join-code preview: the lobby payload plus the game's "
|
||||
"display name and description.",
|
||||
additional_properties=True,
|
||||
)
|
||||
|
||||
GAME_VIEW_SCHEMA = _object(
|
||||
{
|
||||
"id": {"type": "string"},
|
||||
"game_type": {"type": "string"},
|
||||
"spectator": {"type": "boolean"},
|
||||
"seats_open": {"type": "integer"},
|
||||
"join_code": {
|
||||
"type": "string",
|
||||
"description": "The join code is a credential: present only in a "
|
||||
"seated player's view, never to spectators.",
|
||||
},
|
||||
},
|
||||
("id", "game_type", "spectator", "seats_open"),
|
||||
description="A game snapshot: the platform envelope plus the engine's "
|
||||
"view_for fields. Seated players get their personalized view "
|
||||
"(own hand, join code); everyone else the read-only spectator "
|
||||
"view (no hands, no join code).",
|
||||
additional_properties=True,
|
||||
)
|
||||
|
||||
ONGOING_GAME_SCHEMA = _object(
|
||||
{
|
||||
"id": {"type": "string"},
|
||||
"game_type": {"type": "string"},
|
||||
"players": _array(schema_ref("LobbyPlayer")),
|
||||
"seats_open": {"type": "integer"},
|
||||
"game_name": {"type": "string"},
|
||||
"game_description": {"type": "string"},
|
||||
"your_seat": _nullable({"type": "integer"}),
|
||||
"created_at": _nullable({"type": "string", "format": "date-time"}),
|
||||
},
|
||||
(
|
||||
"id",
|
||||
"game_type",
|
||||
"players",
|
||||
"seats_open",
|
||||
"game_name",
|
||||
"game_description",
|
||||
"your_seat",
|
||||
"created_at",
|
||||
),
|
||||
description="One entry of the caller's ongoing-games list: the lobby "
|
||||
"payload without the join code, plus the game name and the "
|
||||
"caller's seat.",
|
||||
additional_properties=True,
|
||||
)
|
||||
|
||||
ONGOING_GAMES_PAGE_SCHEMA = _object(
|
||||
{"results": _array(schema_ref("OngoingGame"))}, ("results",)
|
||||
)
|
||||
|
||||
MATCH_PLAYER_SCHEMA = _object(
|
||||
{
|
||||
"user_sub": {"type": "string"},
|
||||
"display_name": {"type": "string"},
|
||||
"seat": {"type": "integer"},
|
||||
"team": _nullable({"type": "string"}),
|
||||
"won": {"type": "boolean"},
|
||||
"score": {"type": "number"},
|
||||
"elo_delta": _nullable({"type": "integer"}),
|
||||
"details": _nullable(_free_object("Game-specific extras reported by the engine.")),
|
||||
},
|
||||
(
|
||||
"user_sub",
|
||||
"display_name",
|
||||
"seat",
|
||||
"team",
|
||||
"won",
|
||||
"score",
|
||||
"elo_delta",
|
||||
"details",
|
||||
),
|
||||
)
|
||||
|
||||
MATCH_SCHEMA = _object(
|
||||
{
|
||||
"id": {"type": "string"},
|
||||
"game_type": {"type": "string"},
|
||||
"result": _nullable(_free_object(
|
||||
"The game-specific outcome reported by the engine."
|
||||
)),
|
||||
"started_at": {"type": "string", "format": "date-time"},
|
||||
"finished_at": {"type": "string", "format": "date-time"},
|
||||
"you_won": {"type": "boolean"},
|
||||
"your_elo_delta": _nullable({"type": "integer"}),
|
||||
"players": _array(schema_ref("MatchPlayer")),
|
||||
},
|
||||
(
|
||||
"id",
|
||||
"game_type",
|
||||
"result",
|
||||
"started_at",
|
||||
"finished_at",
|
||||
"you_won",
|
||||
"your_elo_delta",
|
||||
"players",
|
||||
),
|
||||
)
|
||||
|
||||
MATCHES_PAGE_SCHEMA = _object(
|
||||
{
|
||||
"results": _array(schema_ref("Match")),
|
||||
"next_cursor": _nullable({"type": "string"}),
|
||||
},
|
||||
("results", "next_cursor"),
|
||||
)
|
||||
|
||||
LEADERBOARD_ENTRY_SCHEMA = _object(
|
||||
{
|
||||
"user_sub": {"type": "string"},
|
||||
"display_name": {"type": "string"},
|
||||
"elo": {"type": "integer"},
|
||||
"matches": {"type": "integer"},
|
||||
"wins": {"type": "integer"},
|
||||
"points": {"type": "number"},
|
||||
},
|
||||
("user_sub", "display_name", "elo", "matches", "wins", "points"),
|
||||
)
|
||||
|
||||
LEADERBOARD_PAGE_SCHEMA = _object(
|
||||
{
|
||||
"results": _array(schema_ref("LeaderboardEntry")),
|
||||
"page": {"type": "integer"},
|
||||
"pages": {"type": "integer"},
|
||||
"total": {"type": "integer"},
|
||||
"your_rank": _nullable({"type": "integer"}),
|
||||
"your_page": _nullable({"type": "integer"}),
|
||||
},
|
||||
("results", "page", "pages", "total", "your_rank", "your_page"),
|
||||
)
|
||||
|
||||
RATING_SCHEMA = _object(
|
||||
{
|
||||
"game_type": {"type": "string"},
|
||||
"rating": {"type": "integer"},
|
||||
"matches_played": {"type": "integer"},
|
||||
},
|
||||
("game_type", "rating", "matches_played"),
|
||||
)
|
||||
|
||||
RATINGS_PAGE_SCHEMA = _object(
|
||||
{"results": _array(schema_ref("Rating"))}, ("results",)
|
||||
)
|
||||
|
||||
#: Component name -> schema, passed to
|
||||
#: ``OpenAPIMixin(components={"schemas": SCHEMAS})``.
|
||||
SCHEMAS: Dict[str, Dict[str, Any]] = {
|
||||
"Error": ERROR_SCHEMA,
|
||||
"Health": HEALTH_SCHEMA,
|
||||
"Me": ME_SCHEMA,
|
||||
"AuthProvider": AUTH_PROVIDER_SCHEMA,
|
||||
"AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA,
|
||||
"GameType": GAME_TYPE_SCHEMA,
|
||||
"GameTypesPage": GAME_TYPES_PAGE_SCHEMA,
|
||||
"LobbyPlayer": LOBBY_PLAYER_SCHEMA,
|
||||
"Lobby": LOBBY_SCHEMA,
|
||||
"GamePreview": GAME_PREVIEW_SCHEMA,
|
||||
"GameView": GAME_VIEW_SCHEMA,
|
||||
"OngoingGame": ONGOING_GAME_SCHEMA,
|
||||
"OngoingGamesPage": ONGOING_GAMES_PAGE_SCHEMA,
|
||||
"MatchPlayer": MATCH_PLAYER_SCHEMA,
|
||||
"Match": MATCH_SCHEMA,
|
||||
"MatchesPage": MATCHES_PAGE_SCHEMA,
|
||||
"LeaderboardEntry": LEADERBOARD_ENTRY_SCHEMA,
|
||||
"LeaderboardPage": LEADERBOARD_PAGE_SCHEMA,
|
||||
"Rating": RATING_SCHEMA,
|
||||
"RatingsPage": RATINGS_PAGE_SCHEMA,
|
||||
}
|
||||
|
||||
@@ -15,24 +15,32 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
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 .http import extract_query_params
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tortoise.models import Model
|
||||
|
||||
DEFAULT_LIMIT = 20
|
||||
MAX_LIMIT = 100
|
||||
MIN_LIMIT = 1
|
||||
|
||||
CURSOR_PARAM = "cursor"
|
||||
LIMIT_PARAM = "limit"
|
||||
PAGE_PARAM = "page"
|
||||
|
||||
|
||||
class CursorDecodeError(ValueError):
|
||||
"""Raised when the ``cursor`` query param cannot be decoded."""
|
||||
|
||||
|
||||
class PageParamsError(ValueError):
|
||||
"""Raised when the ``page``/``limit`` query params are invalid."""
|
||||
|
||||
|
||||
def encode_cursor(values: Dict[str, Any]) -> str:
|
||||
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
|
||||
# datetime-typed sort fields (e.g. finished_at for match history).
|
||||
@@ -76,11 +84,63 @@ def parse_cursor_params(query_string: str) -> Cursor:
|
||||
return Cursor(limit=limit, after=after)
|
||||
|
||||
|
||||
def parse_page_params(query_string: str) -> Tuple[int, int]:
|
||||
"""Parse the ``page``/``limit`` params: a 1-based page and a page size.
|
||||
|
||||
``limit`` is clamped to [1, 100] (default 20); ``page`` must be a
|
||||
positive integer (default 1). Clamping ``page`` to the last existing
|
||||
page is left to the endpoint, which knows how many pages there are.
|
||||
"""
|
||||
params = extract_query_params(query_string)
|
||||
limit_raw = params.get(LIMIT_PARAM)
|
||||
if limit_raw:
|
||||
try:
|
||||
limit = int(limit_raw[0])
|
||||
except ValueError as exc:
|
||||
raise PageParamsError("invalid limit") from exc
|
||||
else:
|
||||
limit = DEFAULT_LIMIT
|
||||
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
|
||||
page_raw = params.get(PAGE_PARAM)
|
||||
if page_raw:
|
||||
try:
|
||||
page = int(page_raw[0])
|
||||
except ValueError as exc:
|
||||
raise PageParamsError("invalid page") from exc
|
||||
else:
|
||||
page = 1
|
||||
if page < 1:
|
||||
raise PageParamsError("invalid page")
|
||||
return page, limit
|
||||
|
||||
|
||||
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
|
||||
# keyset; the cursor encodes exactly these fields.
|
||||
Sort = List[Tuple[str, str]]
|
||||
|
||||
|
||||
def _coerce_sort_values(
|
||||
model: "type[Model]", sort: Sort, after: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Restore the Python type of every decoded cursor value.
|
||||
|
||||
The cursor is JSON, so field values travel as strings (datetimes via
|
||||
``default=str``). asyncpg rejects strings where a ``timestamp``
|
||||
parameter is expected, and sqlite only tolerates them by accident, so
|
||||
each value is run through its model field's ``to_python_value`` before
|
||||
it reaches the keyset filter.
|
||||
"""
|
||||
fields = model._meta.fields_map
|
||||
coerced: Dict[str, Any] = {}
|
||||
for field, _ in sort:
|
||||
value = after.get(field)
|
||||
field_obj = fields.get(field)
|
||||
if value is not None and field_obj is not None:
|
||||
value = field_obj.to_python_value(value)
|
||||
coerced[field] = value
|
||||
return coerced
|
||||
|
||||
|
||||
def _keyset_where(sort: Sort, after: Dict[str, Any]):
|
||||
"""Build a Tortoise ``Q`` filter from a cursor."""
|
||||
from tortoise.queryset import Q # local to keep import edge narrow
|
||||
@@ -116,7 +176,8 @@ async def paginate(
|
||||
order_by.append(field if direction == "ASC" else f"-{field}")
|
||||
qs = queryset.order_by(*order_by)
|
||||
if cursor.after:
|
||||
qs = qs.filter(_keyset_where(sort, cursor.after))
|
||||
after = _coerce_sort_values(queryset.model, sort, cursor.after)
|
||||
qs = qs.filter(_keyset_where(sort, after))
|
||||
rows = await qs.limit(cursor.limit + 1)
|
||||
if len(rows) <= cursor.limit:
|
||||
return rows, None
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""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, schema_ref
|
||||
|
||||
from ..http import send_json
|
||||
from ..openapi import json_response
|
||||
|
||||
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: json_response("The configured providers",
|
||||
schema_ref("AuthProvidersPage"))})
|
||||
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})
|
||||
@@ -16,15 +16,23 @@ import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import TYPE_CHECKING, Any, Dict
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..auth import display_name, require_auth
|
||||
from ..auth import current_user, display_name, require_auth
|
||||
from ..engine import GameSession, Seat
|
||||
from ..errors import GameError
|
||||
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
|
||||
from ..http import (
|
||||
JsonRequestError,
|
||||
extract_query_params,
|
||||
read_json,
|
||||
read_json_optional,
|
||||
send_error,
|
||||
send_json,
|
||||
)
|
||||
from ..openapi import error_response, json_response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -64,21 +72,71 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
|
||||
}
|
||||
for index, seat in enumerate(session.players)
|
||||
],
|
||||
"seats_open": engine.max_players - len(session.players),
|
||||
"seats_open": max(0, engine.max_players - len(session.players)),
|
||||
**engine.lobby_view(session),
|
||||
}
|
||||
|
||||
|
||||
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
||||
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 {
|
||||
"id": session.id,
|
||||
"join_code": session.join_code,
|
||||
"game_type": session.game_type,
|
||||
**engine.view_for(session, sub),
|
||||
**_lobby_payload(platform, session),
|
||||
"game_name": engine.name,
|
||||
"game_description": engine.description,
|
||||
}
|
||||
|
||||
|
||||
def _ongoing_game_payload(
|
||||
platform: "Platform", session: GameSession, sub: str
|
||||
) -> Dict[str, Any]:
|
||||
"""One entry of the caller's ongoing-games list.
|
||||
|
||||
Reuses the lobby payload (players, open seats, option echo) but drops
|
||||
the join code — the Continue link only needs the id, and the game page
|
||||
shows the code to its players anyway.
|
||||
"""
|
||||
engine = platform.registry.require(session.game_type)
|
||||
payload = _lobby_payload(platform, session)
|
||||
payload.pop("join_code", None)
|
||||
payload["game_name"] = engine.name
|
||||
payload["game_description"] = engine.description
|
||||
payload["your_seat"] = next(
|
||||
(
|
||||
index
|
||||
for index, seat in enumerate(session.players)
|
||||
if seat.user_sub == sub
|
||||
),
|
||||
None,
|
||||
)
|
||||
payload["created_at"] = (
|
||||
session.created_at.isoformat() if session.created_at else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/game-types")
|
||||
@operation(summary="List available games",
|
||||
@@ -87,7 +145,8 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
"describes the per-game creation options accepted "
|
||||
"by POST /api/games.",
|
||||
tags=["games"],
|
||||
responses={200: {"description": "The available game types"}})
|
||||
responses={200: json_response("The available game types",
|
||||
schema_ref("GameTypesPage"))})
|
||||
async def list_game_types(ctx: HttpContext) -> None:
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
@@ -137,9 +196,9 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
},
|
||||
},
|
||||
responses={
|
||||
201: {"description": "The created lobby"},
|
||||
400: {"description": "Invalid game_type, options or body"},
|
||||
401: {"description": "Authentication required"},
|
||||
201: json_response("The created lobby", schema_ref("Lobby")),
|
||||
400: error_response("Invalid game_type, options or body"),
|
||||
401: error_response("Authentication required"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def create_game(ctx: HttpContext) -> None:
|
||||
@@ -164,7 +223,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
await send_error(ctx, 400, "options must be an object")
|
||||
return
|
||||
|
||||
user = platform.oidc.get_user(ctx)
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None # enforced by @require_auth
|
||||
session = GameSession(
|
||||
id=str(uuid.uuid4()),
|
||||
@@ -207,11 +266,15 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
},
|
||||
},
|
||||
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"},
|
||||
200: json_response(
|
||||
"Seated: the lobby, or the personalized game state "
|
||||
"when the join started the match",
|
||||
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]},
|
||||
),
|
||||
400: error_response("Missing code"),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("Unknown join code"),
|
||||
409: error_response("Already joined or lobby full"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def join_game(ctx: HttpContext) -> None:
|
||||
@@ -225,7 +288,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
await send_error(ctx, 400, "code is required")
|
||||
return
|
||||
|
||||
user = platform.oidc.get_user(ctx)
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None
|
||||
existing = await platform.game_store.find_by_code(code)
|
||||
if existing is None:
|
||||
@@ -262,26 +325,81 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
|
||||
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
|
||||
|
||||
@app.GET("/api/games/${game_id}")
|
||||
@operation(summary="Get a game snapshot",
|
||||
description="Only seated players may read a game; the view is "
|
||||
"personalized by the engine (e.g. hidden hands).",
|
||||
@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 personalized game state"},
|
||||
401: {"description": "Authentication required"},
|
||||
403: {"description": "Not a player in this game"},
|
||||
404: {"description": "Game not found"},
|
||||
200: json_response("The lobby preview",
|
||||
schema_ref("GamePreview")),
|
||||
400: error_response("Missing code"),
|
||||
401: error_response("Authentication required"),
|
||||
404: error_response("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: json_response(
|
||||
"The personalized (player) or read-only (spectator) game state",
|
||||
schema_ref("GameView"),
|
||||
),
|
||||
404: error_response("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 = platform.oidc.get_user(ctx)
|
||||
assert user is not None
|
||||
if not session.seated(user.sub):
|
||||
await send_error(ctx, 403, "forbidden")
|
||||
return
|
||||
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
|
||||
user = current_user(platform.oidc, ctx)
|
||||
await send_json(
|
||||
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
||||
)
|
||||
|
||||
@app.GET("/api/me/games")
|
||||
@operation(summary="List my ongoing games",
|
||||
description="Live games the caller is seated in — waiting "
|
||||
"lobbies and matches in progress or between "
|
||||
"hands — newest first, for the lobby's Continue "
|
||||
"list. Finished matches are not included.",
|
||||
tags=["games"],
|
||||
responses={
|
||||
200: json_response("The caller's ongoing games",
|
||||
schema_ref("OngoingGamesPage")),
|
||||
401: error_response("Authentication required"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def my_games(ctx: HttpContext) -> None:
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None # enforced by @require_auth
|
||||
sessions = await platform.game_store.list_ongoing()
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
_ongoing_game_payload(platform, session, user.sub)
|
||||
for session in sessions
|
||||
if session.seated(user.sub)
|
||||
]
|
||||
})
|
||||
|
||||
@@ -4,7 +4,9 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..openapi import json_response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -14,7 +16,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/health")
|
||||
@operation(summary="Health check",
|
||||
tags=["health"],
|
||||
responses={200: {"description": "The service is up"}})
|
||||
responses={200: json_response("The service is up", schema_ref("Health"))})
|
||||
async def health(ctx: HttpContext) -> None:
|
||||
await ctx.send_bytes(
|
||||
200,
|
||||
|
||||
@@ -4,10 +4,11 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
from kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..auth import display_name, require_auth
|
||||
from ..auth import current_user, display_name, picture, require_auth
|
||||
from ..http import send_json
|
||||
from ..openapi import error_response, json_response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -17,14 +18,24 @@ 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.",
|
||||
"session, including the profile picture URL when "
|
||||
"the provider supplies one; 401 when not logged "
|
||||
"in.",
|
||||
tags=["auth"],
|
||||
responses={
|
||||
200: {"description": "The current user"},
|
||||
401: {"description": "Not logged in"},
|
||||
200: json_response("The current user", schema_ref("Me")),
|
||||
401: error_response("Not logged in"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def me(ctx: HttpContext) -> None:
|
||||
user = platform.oidc.get_user(ctx)
|
||||
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)})
|
||||
await send_json(
|
||||
ctx,
|
||||
200,
|
||||
{
|
||||
"sub": user.sub,
|
||||
"name": display_name(user),
|
||||
"picture": picture(user),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,17 +8,30 @@ 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 kaya.openapi import operation, schema_ref
|
||||
|
||||
from ..auth import require_auth
|
||||
from ..elo import INITIAL_RATING
|
||||
from ..auth import current_user, require_auth
|
||||
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
|
||||
from ..openapi import (
|
||||
PAGE_PARAMETERS,
|
||||
PAGINATION_PARAMETERS,
|
||||
error_response,
|
||||
json_response,
|
||||
)
|
||||
from ..pagination import (
|
||||
CursorDecodeError,
|
||||
PageParamsError,
|
||||
paginate,
|
||||
parse_cursor_params,
|
||||
parse_page_params,
|
||||
)
|
||||
from tortoise.expressions import Q
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -31,6 +44,15 @@ GAME_TYPE_PARAMETER: Dict[str, Any] = {
|
||||
"description": "Only count matches of this game (id from GET /api/game-types).",
|
||||
}
|
||||
|
||||
LEADERBOARD_FOCUS_PARAMETER: Dict[str, Any] = {
|
||||
"name": "focus",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "string", "enum": ["me"]},
|
||||
"description": "Set to 'me' to return the page containing the caller's "
|
||||
"own ranking (page 1 for unrated or anonymous callers).",
|
||||
}
|
||||
|
||||
|
||||
def _parse_game_type(
|
||||
platform: "Platform", query_string: str
|
||||
@@ -48,8 +70,36 @@ def _parse_game_type(
|
||||
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")
|
||||
def _ranks_before(rating: PlayerRating) -> Q:
|
||||
"""Keyset condition selecting the rows ranked above ``rating``.
|
||||
|
||||
Mirrors the leaderboard ordering ``-rating, -wins, -points,
|
||||
matches_played, user_sub``: exactly the rows sorting before the given
|
||||
one.
|
||||
"""
|
||||
return (
|
||||
Q(rating__gt=rating.rating)
|
||||
| Q(rating=rating.rating, wins__gt=rating.wins)
|
||||
| Q(rating=rating.rating, wins=rating.wins, points__gt=rating.points)
|
||||
| Q(
|
||||
rating=rating.rating,
|
||||
wins=rating.wins,
|
||||
points=rating.points,
|
||||
matches_played__lt=rating.matches_played,
|
||||
)
|
||||
| Q(
|
||||
rating=rating.rating,
|
||||
wins=rating.wins,
|
||||
points=rating.points,
|
||||
matches_played=rating.matches_played,
|
||||
user_sub__lt=rating.user_sub,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _serialize_match(
|
||||
match: Match, viewer: str, participants: List[MatchPlayer]
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": str(match.id),
|
||||
"game_type": match.game_type,
|
||||
@@ -86,9 +136,10 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
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"},
|
||||
200: json_response("A page of matches",
|
||||
schema_ref("MatchesPage")),
|
||||
400: error_response("Invalid pagination cursor or game_type"),
|
||||
401: error_response("Authentication required"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def my_matches(ctx: HttpContext) -> None:
|
||||
@@ -101,7 +152,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
if error is not None:
|
||||
await send_error(ctx, 400, error)
|
||||
return
|
||||
user = platform.oidc.get_user(ctx)
|
||||
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:
|
||||
@@ -109,61 +160,107 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
matches, next_cursor = await paginate(
|
||||
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
|
||||
)
|
||||
results = [await _serialize_match(m, user.sub) for m in matches]
|
||||
# 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).",
|
||||
description="Elo rating, wins, matches played and points for "
|
||||
"every rated player of a game, sorted by Elo (the "
|
||||
"requested game_type, or the default game when "
|
||||
"the filter is absent). Page-number paginated; "
|
||||
"the response carries the caller's own rank when "
|
||||
"authenticated.",
|
||||
tags=["stats"],
|
||||
parameters=[GAME_TYPE_PARAMETER],
|
||||
parameters=[
|
||||
GAME_TYPE_PARAMETER,
|
||||
*PAGE_PARAMETERS,
|
||||
LEADERBOARD_FOCUS_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: {"description": "The leaderboard"},
|
||||
400: {"description": "Unknown game_type"},
|
||||
200: json_response("A page of the leaderboard",
|
||||
schema_ref("LeaderboardPage")),
|
||||
400: error_response("Unknown game_type or invalid parameters"),
|
||||
})
|
||||
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.
|
||||
try:
|
||||
page, limit = parse_page_params(ctx.query_string)
|
||||
except PageParamsError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
focus_values = extract_query_params(ctx.query_string).get("focus")
|
||||
focus = focus_values[0] if focus_values else None
|
||||
if focus is not None and focus != "me":
|
||||
await send_error(ctx, 400, "invalid focus")
|
||||
return
|
||||
|
||||
# The board is the requested game's, or the default game's when the
|
||||
# filter is absent (ratings are per game type).
|
||||
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,
|
||||
board = PlayerRating.filter(game_type=rating_game)
|
||||
total = await board.count()
|
||||
pages = (total + limit - 1) // limit
|
||||
current = min(page, pages) if pages else 1
|
||||
|
||||
# The caller's own ranking, when authenticated and rated: ranks
|
||||
# above theirs plus one. `focus=me` jumps to their page.
|
||||
user = current_user(platform.oidc, ctx)
|
||||
yours: Optional[PlayerRating] = None
|
||||
if user is not None:
|
||||
yours = await PlayerRating.get_or_none(
|
||||
user_sub=user.sub, game_type=rating_game
|
||||
)
|
||||
your_rank: Optional[int] = None
|
||||
your_page: Optional[int] = None
|
||||
if yours is not None:
|
||||
your_rank = 1 + await board.filter(_ranks_before(yours)).count()
|
||||
your_page = (your_rank - 1) // limit + 1
|
||||
if focus == "me":
|
||||
current = your_page
|
||||
|
||||
rows = await (
|
||||
board.order_by(
|
||||
"-rating", "-wins", "-points", "matches_played", "user_sub"
|
||||
)
|
||||
.offset((current - 1) * limit)
|
||||
.limit(limit)
|
||||
)
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
{
|
||||
"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})
|
||||
"display_name": row.display_name or row.user_sub,
|
||||
"matches": row.matches_played,
|
||||
"wins": row.wins,
|
||||
"points": row.points,
|
||||
"elo": row.rating,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"page": current,
|
||||
"pages": pages,
|
||||
"total": total,
|
||||
"your_rank": your_rank,
|
||||
"your_page": your_page,
|
||||
})
|
||||
|
||||
@app.GET("/api/me/ratings")
|
||||
@operation(summary="My Elo ratings",
|
||||
@@ -171,12 +268,13 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
"type they have played.",
|
||||
tags=["stats"],
|
||||
responses={
|
||||
200: {"description": "The caller's ratings"},
|
||||
401: {"description": "Authentication required"},
|
||||
200: json_response("The caller's ratings",
|
||||
schema_ref("RatingsPage")),
|
||||
401: error_response("Authentication required"),
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def my_ratings(ctx: HttpContext) -> None:
|
||||
user = platform.oidc.get_user(ctx)
|
||||
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, {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
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`).
|
||||
also updates the participants' Elo ratings and leaderboard aggregates
|
||||
(see :mod:`tavolo.platform.elo`).
|
||||
|
||||
This module is game-independent: everything it persists comes from the
|
||||
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||
@@ -12,9 +12,10 @@ engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Sequence
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
@@ -24,21 +25,37 @@ from .engine import GameEngine, GameSession
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
async def apply_elo(
|
||||
game_type: str, teams: List[List[str]], winner_team: int
|
||||
@dataclass(frozen=True)
|
||||
class PlayerStats:
|
||||
"""One participant's data feeding a ``player_rating`` row update."""
|
||||
|
||||
user_sub: str
|
||||
won: bool
|
||||
score: float
|
||||
display_name: str
|
||||
|
||||
|
||||
async def apply_match_stats(
|
||||
game_type: str,
|
||||
teams: List[List[str]],
|
||||
winner_team: int,
|
||||
players: Sequence[PlayerStats],
|
||||
) -> Dict[str, int]:
|
||||
"""Update the Elo ratings of ``teams`` for ``game_type``.
|
||||
"""Update the ratings and leaderboard aggregates of ``teams``.
|
||||
|
||||
``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.
|
||||
players start at the initial rating. Alongside the Elo delta the
|
||||
transaction records each participant's wins, accumulated points and
|
||||
latest display name, so the leaderboard is a single-table query.
|
||||
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
|
||||
|
||||
stats = {player.user_sub: player for player in players}
|
||||
ratings: Dict[str, "PlayerRating"] = {}
|
||||
for subs in teams:
|
||||
for sub in subs:
|
||||
@@ -63,6 +80,13 @@ async def apply_elo(
|
||||
rating = ratings[sub]
|
||||
rating.rating += delta
|
||||
rating.matches_played += 1
|
||||
player = stats.get(sub)
|
||||
if player is not None:
|
||||
if player.won:
|
||||
rating.wins += 1
|
||||
rating.points += player.score
|
||||
if player.display_name:
|
||||
rating.display_name = player.display_name
|
||||
await rating.save()
|
||||
return deltas
|
||||
|
||||
@@ -78,6 +102,15 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
||||
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}
|
||||
player_stats = [
|
||||
PlayerStats(
|
||||
user_sub=player.user_sub,
|
||||
won=player.won,
|
||||
score=player.score,
|
||||
display_name=display_names.get(player.user_sub, player.user_sub),
|
||||
)
|
||||
for player in result.players
|
||||
]
|
||||
async with in_transaction():
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
@@ -86,7 +119,9 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
||||
finished_at=finished_at,
|
||||
result=result.summary,
|
||||
)
|
||||
deltas = await apply_elo(session.game_type, result.teams, result.winner_team)
|
||||
deltas = await apply_match_stats(
|
||||
session.game_type, result.teams, result.winner_team, player_stats
|
||||
)
|
||||
for player in result.players:
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
|
||||
@@ -26,8 +26,9 @@ signal (never the state) means updated state reaches connections on
|
||||
every worker without leaking hidden information into the channel.
|
||||
|
||||
Timeouts are driven by a shared delayed-deadline queue: producers
|
||||
enqueue an opaque ``member`` string with a due timestamp, and a consumer
|
||||
on every worker polls for due entries (see
|
||||
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
|
||||
@@ -38,8 +39,9 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
||||
|
||||
@@ -54,6 +56,13 @@ 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"
|
||||
@@ -63,6 +72,15 @@ def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def _created_at_key(session: GameSession) -> datetime:
|
||||
"""Sort key for :meth:`GameStore.list_ongoing` (newest first).
|
||||
|
||||
Sessions without a creation timestamp sort last; the epoch sentinel is
|
||||
timezone-aware so it never mixes with the stored UTC datetimes.
|
||||
"""
|
||||
return session.created_at or datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _dt_from_json(value: Any) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
@@ -120,6 +138,23 @@ class GameStore(ABC):
|
||||
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
|
||||
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||
"""Return up to ``limit`` live games that have not finished yet.
|
||||
|
||||
Newest ``created_at`` first. Sessions whose key has expired are
|
||||
skipped, and so are matches that are over but still retained for
|
||||
the final-board view.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||
"""Async context manager serializing mutations of one game."""
|
||||
@@ -152,6 +187,18 @@ class GameStore(ABC):
|
||||
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"
|
||||
@@ -181,9 +228,15 @@ class RedisGameStore(GameStore):
|
||||
|
||||
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)
|
||||
|
||||
@@ -195,6 +248,38 @@ class RedisGameStore(GameStore):
|
||||
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)
|
||||
|
||||
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||
await self._redis.zremrangebyscore(
|
||||
ONGOING_GAMES_KEY, "-inf", time.time()
|
||||
)
|
||||
members = await self._redis.zrange(ONGOING_GAMES_KEY, 0, -1)
|
||||
sessions: List[GameSession] = []
|
||||
for member in members:
|
||||
game_id = (
|
||||
member.decode("utf-8") if isinstance(member, bytes) else str(member)
|
||||
)
|
||||
session = await self.load(game_id)
|
||||
if session is None:
|
||||
# The game key expired but the ongoing entry had not been
|
||||
# pruned yet: skip it.
|
||||
continue
|
||||
if self._registry.require(session.game_type).is_finished(session):
|
||||
continue
|
||||
sessions.append(session)
|
||||
sessions.sort(key=_created_at_key, reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||
pubsub = self._redis.pubsub()
|
||||
@@ -214,10 +299,17 @@ class RedisGameStore(GameStore):
|
||||
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.zrangebyscore(
|
||||
DEADLINES_KEY, "-inf", now, start=0, num=limit
|
||||
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]
|
||||
@@ -229,6 +321,20 @@ class RedisGameStore(GameStore):
|
||||
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():
|
||||
@@ -243,9 +349,11 @@ class InMemoryGameStore(GameStore):
|
||||
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)
|
||||
@@ -268,6 +376,11 @@ class InMemoryGameStore(GameStore):
|
||||
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())
|
||||
@@ -275,6 +388,21 @@ class InMemoryGameStore(GameStore):
|
||||
return None
|
||||
return await self.load(game_id)
|
||||
|
||||
async def count_ongoing(self) -> int:
|
||||
return len(self._ongoing)
|
||||
|
||||
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||
sessions: List[GameSession] = []
|
||||
for game_id in list(self._ongoing):
|
||||
session = await self.load(game_id)
|
||||
if session is None:
|
||||
continue
|
||||
if self._registry.require(session.game_type).is_finished(session):
|
||||
continue
|
||||
sessions.append(session)
|
||||
sessions.sort(key=_created_at_key, reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
@@ -306,6 +434,19 @@ class InMemoryGameStore(GameStore):
|
||||
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:
|
||||
|
||||
@@ -2,12 +2,21 @@
|
||||
|
||||
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.
|
||||
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, seat
|
||||
check, the subscription/publish fan-out and the message envelope — and
|
||||
delegates the game-specific actions to the engine registered for the
|
||||
session's ``game_type``.
|
||||
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
|
||||
--------
|
||||
@@ -27,9 +36,10 @@ Client -> server messages are JSON objects::
|
||||
|
||||
For scopone scientifico the game actions are ``play`` (with ``card``
|
||||
and optionally ``capture``) and ``ack`` — see
|
||||
:mod:`tavolo.scopone.plugin`. Because the whole message (minus
|
||||
``action``) is handed to the engine as the payload, games define their
|
||||
own fields freely.
|
||||
: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
|
||||
@@ -48,9 +58,9 @@ import asyncio
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from logging import getLogger
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from kaya.core import KayaApp, WebSocket
|
||||
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
|
||||
|
||||
from . import auth
|
||||
from .engine import GameSession
|
||||
@@ -68,17 +78,39 @@ def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
|
||||
return {"type": "error", "code": code, "message": message}
|
||||
|
||||
|
||||
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
||||
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)
|
||||
return {
|
||||
"type": "state",
|
||||
"game": {
|
||||
"id": session.id,
|
||||
"join_code": session.join_code,
|
||||
"game_type": session.game_type,
|
||||
**engine.view_for(session, sub),
|
||||
},
|
||||
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:
|
||||
@@ -86,32 +118,33 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
|
||||
@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)
|
||||
if user is None:
|
||||
log.debug("websocket %s rejected: no authenticated user", game_id)
|
||||
await ws.close(4401)
|
||||
return
|
||||
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 ws.close(4404)
|
||||
return
|
||||
if not session.seated(user.sub):
|
||||
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
||||
await ws.close(4403)
|
||||
await _reject(ws, 404)
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
log.info("%s connected to game %s", user.sub, game_id)
|
||||
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:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
try:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
except WebSocketDisconnect:
|
||||
# The peer went away without a close handshake: kaya
|
||||
# raises this from the transport, and the receive loop
|
||||
# ends momentarily.
|
||||
pass
|
||||
|
||||
await send(_state_message(platform, session, user.sub))
|
||||
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).
|
||||
@@ -119,7 +152,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
|
||||
async with platform.game_store.subscribe(game_id) as events:
|
||||
forward = asyncio.create_task(
|
||||
_forward(platform, events, game_id, user.sub, send)
|
||||
_forward(platform, events, game_id, sub, send)
|
||||
)
|
||||
try:
|
||||
async for message in ws:
|
||||
@@ -128,19 +161,19 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
if message.kind != "text" or not isinstance(message.data, str):
|
||||
await send(_error("expected a text frame with a JSON object"))
|
||||
continue
|
||||
await _handle_message(platform, send, game_id, user.sub, message.data)
|
||||
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", user.sub, game_id)
|
||||
log.debug("%s disconnected from game %s", sub or "spectator", game_id)
|
||||
|
||||
|
||||
async def _forward(
|
||||
platform: "Platform",
|
||||
events,
|
||||
game_id: str,
|
||||
sub: str,
|
||||
sub: Optional[str],
|
||||
send: Send,
|
||||
) -> None:
|
||||
async for _ in events:
|
||||
@@ -160,7 +193,7 @@ async def _forward(
|
||||
|
||||
|
||||
async def _handle_message(
|
||||
platform: "Platform", send: Send, game_id: str, sub: str, raw: str
|
||||
platform: "Platform", send: Send, game_id: str, sub: Optional[str], raw: str
|
||||
) -> None:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
@@ -189,7 +222,7 @@ async def _handle_action(
|
||||
platform: "Platform",
|
||||
send: Send,
|
||||
game_id: str,
|
||||
sub: str,
|
||||
sub: Optional[str],
|
||||
action: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
@@ -198,6 +231,13 @@ async def _handle_action(
|
||||
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:
|
||||
|
||||
@@ -8,7 +8,7 @@ without importing any real game. Its rules: the first player to reach
|
||||
``target`` plays wins the match.
|
||||
|
||||
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
|
||||
wired with in-memory stores, a dummy OIDC mixin (patched per test) and
|
||||
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.
|
||||
"""
|
||||
@@ -22,7 +22,7 @@ from functools import wraps
|
||||
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from kaya.core import KayaApp
|
||||
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCUser
|
||||
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import InMemorySessionStore, SessionMixin
|
||||
|
||||
@@ -45,6 +45,7 @@ from tavolo.platform import (
|
||||
)
|
||||
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
|
||||
from tavolo.platform.deadlines import DeadlineScheduler
|
||||
from tavolo.platform.openapi import SCHEMAS
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from tavolo.platform.tortoise_mixin import TortoiseMixin
|
||||
@@ -138,7 +139,7 @@ class DummyEngine(GameEngine):
|
||||
state["finished"] = True
|
||||
state["winner"] = user_sub
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
state = session.state
|
||||
return {
|
||||
"started": state["started"],
|
||||
@@ -146,7 +147,7 @@ class DummyEngine(GameEngine):
|
||||
"winner": state["winner"],
|
||||
"plays": len(state["plays"]),
|
||||
"target": state["target"],
|
||||
"viewer_seated": session.seated(user_sub),
|
||||
"viewer_seated": user_sub is not None and session.seated(user_sub),
|
||||
}
|
||||
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
@@ -220,20 +221,26 @@ class DummyEngine(GameEngine):
|
||||
|
||||
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_mixin = OIDCMixin(
|
||||
OIDCConfig(
|
||||
issuer="http://localhost:8180/tavolo",
|
||||
client_id="tavolo",
|
||||
client_secret=None,
|
||||
redirect_uri="http://localhost:8080/auth/callback",
|
||||
post_login_redirect="/",
|
||||
post_logout_redirect="/",
|
||||
),
|
||||
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(
|
||||
@@ -247,23 +254,24 @@ def make_platform(
|
||||
description="test app",
|
||||
spec_path="/api/openapi.json",
|
||||
docs_path="/api/docs",
|
||||
components={"schemas": SCHEMAS},
|
||||
)
|
||||
scheduler = DeadlineScheduler(
|
||||
game_store,
|
||||
registry,
|
||||
heartbeat_ms=50,
|
||||
heartbeat_ms=heartbeat_ms,
|
||||
context_binder=tortoise_mixin.ensure_context,
|
||||
)
|
||||
platform = Platform(
|
||||
registry=registry,
|
||||
game_store=game_store,
|
||||
scheduler=scheduler,
|
||||
oidc=oidc_mixin,
|
||||
oidc=oidc,
|
||||
)
|
||||
app = KayaApp(
|
||||
mixins=[
|
||||
session_mixin,
|
||||
oidc_mixin,
|
||||
oidc,
|
||||
tortoise_mixin,
|
||||
openapi_mixin,
|
||||
PlatformMixin(platform),
|
||||
@@ -319,10 +327,12 @@ def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def oidc_user(oidc: OIDCMixin, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
||||
"""Context manager: patch ``oidc.get_user`` to return this user."""
|
||||
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)
|
||||
patcher = _mock.patch.object(oidc, "get_user", return_value=user)
|
||||
provider = next(iter(oidc.providers))
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={provider: user})
|
||||
patcher.start()
|
||||
try:
|
||||
yield user
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""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, OIDCUser
|
||||
from kaya.session import InMemorySessionStore, Session, SessionMixin
|
||||
|
||||
from helpers import async_test
|
||||
from tavolo.platform.auth import (
|
||||
current_user,
|
||||
display_name,
|
||||
get_ws_user,
|
||||
picture,
|
||||
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 DisplayNameTest(unittest.TestCase):
|
||||
"""The claim priority used for the name shown at the table."""
|
||||
|
||||
def test_prefers_preferred_username_over_name(self) -> None:
|
||||
user = OIDCUser(
|
||||
{"sub": "alice", "preferred_username": "alice", "name": "Alice"}
|
||||
)
|
||||
self.assertEqual("alice", display_name(user))
|
||||
|
||||
def test_falls_back_to_name(self) -> None:
|
||||
user = OIDCUser({"sub": "alice", "name": "Alice"})
|
||||
self.assertEqual("Alice", display_name(user))
|
||||
|
||||
def test_ignores_email(self) -> None:
|
||||
# The app requests only the openid and profile scopes; an email
|
||||
# claim the provider still returns must not leak into the name.
|
||||
user = OIDCUser({"sub": "alice", "email": "alice@example.com"})
|
||||
self.assertEqual("alice", display_name(user))
|
||||
|
||||
def test_falls_back_to_sub(self) -> None:
|
||||
user = OIDCUser({"sub": "alice"})
|
||||
self.assertEqual("alice", display_name(user))
|
||||
|
||||
def test_ignores_empty_and_non_string_claims(self) -> None:
|
||||
user = OIDCUser({"sub": "alice", "preferred_username": "", "name": 42})
|
||||
self.assertEqual("alice", display_name(user))
|
||||
|
||||
|
||||
class PictureTest(unittest.TestCase):
|
||||
"""Only absolute http(s) picture claims are exported to the SPA."""
|
||||
|
||||
def test_uses_an_absolute_http_url(self) -> None:
|
||||
user = OIDCUser({"sub": "alice", "picture": "https://cdn.example.com/a.png"})
|
||||
self.assertEqual("https://cdn.example.com/a.png", picture(user))
|
||||
|
||||
def test_rejects_relative_and_script_schemes(self) -> None:
|
||||
for value in ("/relative.png", "javascript:alert(1)", "data:image/png;base64,x"):
|
||||
with self.subTest(picture=value):
|
||||
user = OIDCUser({"sub": "alice", "picture": value})
|
||||
self.assertIsNone(picture(user))
|
||||
|
||||
def test_missing_claim_is_none(self) -> None:
|
||||
self.assertIsNone(picture(OIDCUser({"sub": "alice"})))
|
||||
|
||||
|
||||
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)
|
||||
@@ -112,6 +112,38 @@ async def _finished(store, game_id: str) -> Optional[GameSession]:
|
||||
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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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,
|
||||
PageParamsError,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
paginate,
|
||||
parse_page_params,
|
||||
)
|
||||
|
||||
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])
|
||||
|
||||
|
||||
class PageParamsTest(unittest.TestCase):
|
||||
def test_defaults(self) -> None:
|
||||
self.assertEqual((1, 20), parse_page_params(""))
|
||||
|
||||
def test_page_and_limit(self) -> None:
|
||||
self.assertEqual((3, 5), parse_page_params("page=3&limit=5"))
|
||||
|
||||
def test_limit_is_clamped(self) -> None:
|
||||
self.assertEqual((1, 100), parse_page_params("limit=500"))
|
||||
self.assertEqual((1, 1), parse_page_params("limit=0"))
|
||||
|
||||
def test_invalid_values_raise(self) -> None:
|
||||
for query in ("page=0", "page=-2", "page=abc", "limit=abc"):
|
||||
with self.assertRaises(PageParamsError, msg=query):
|
||||
parse_page_params(query)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -44,10 +44,24 @@ class GamesRouteTest(unittest.TestCase):
|
||||
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"):
|
||||
forbidden = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(403, forbidden.status_code)
|
||||
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:
|
||||
@@ -69,6 +83,56 @@ class GamesRouteTest(unittest.TestCase):
|
||||
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()
|
||||
@@ -124,6 +188,63 @@ class GamesRouteTest(unittest.TestCase):
|
||||
response = await client.get("/api/games/does-not-exist")
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_my_games_lists_only_my_ongoing_games(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
anonymous = await client.get("/api/me/games")
|
||||
self.assertEqual(401, anonymous.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
mine = await client.post(
|
||||
"/api/games", json={"options": {"target": 5}}
|
||||
)
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
theirs = await client.post("/api/games", json={})
|
||||
self.assertEqual(201, mine.status_code)
|
||||
self.assertEqual(201, theirs.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
listed = await client.get("/api/me/games")
|
||||
self.assertEqual(200, listed.status_code)
|
||||
results = listed.json()["results"]
|
||||
self.assertEqual([mine.json()["id"]], [g["id"] for g in results])
|
||||
entry = results[0]
|
||||
self.assertEqual("dummy", entry["game_type"])
|
||||
self.assertEqual("Dummy game", entry["game_name"])
|
||||
self.assertTrue(entry["game_description"])
|
||||
self.assertEqual("lobby", entry["phase"])
|
||||
self.assertEqual(5, entry["target"])
|
||||
self.assertEqual(1, entry["seats_open"])
|
||||
self.assertEqual(0, entry["your_seat"])
|
||||
self.assertNotIn("join_code", entry)
|
||||
self.assertEqual("alice", entry["players"][0]["sub"])
|
||||
|
||||
# Bob's own game is the only one he sees; Alice's is not his.
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
bobs = await client.get("/api/me/games")
|
||||
self.assertEqual(
|
||||
[theirs.json()["id"]],
|
||||
[g["id"] for g in bobs.json()["results"]],
|
||||
)
|
||||
|
||||
# Once Bob joins Alice's game it appears for both, now in the
|
||||
# playing phase.
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
await client.post(
|
||||
"/api/games/join", json={"code": mine.json()["join_code"]}
|
||||
)
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
bobs = await client.get("/api/me/games")
|
||||
by_id = {g["id"]: g for g in bobs.json()["results"]}
|
||||
self.assertEqual(
|
||||
{mine.json()["id"], theirs.json()["id"]}, set(by_id)
|
||||
)
|
||||
self.assertEqual("playing", by_id[mine.json()["id"]]["phase"])
|
||||
self.assertEqual(1, by_id[mine.json()["id"]]["your_seat"])
|
||||
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
|
||||
|
||||
|
||||
class GameTypesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
@@ -189,7 +310,10 @@ class MeRouteTest(unittest.TestCase):
|
||||
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())
|
||||
self.assertEqual(
|
||||
{"sub": "alice", "name": "alice", "picture": None},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_me_unauthenticated(self) -> None:
|
||||
|
||||
@@ -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"])
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
from unittest import mock
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
@@ -77,6 +79,12 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
self.assertEqual(1, ratings["alice"].matches_played)
|
||||
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
||||
self.assertEqual(1, ratings["bob"].matches_played)
|
||||
# The leaderboard aggregates ride along in the same transaction.
|
||||
self.assertEqual(1, ratings["alice"].wins)
|
||||
self.assertEqual(0, ratings["bob"].wins)
|
||||
self.assertEqual(2.0, ratings["alice"].points)
|
||||
self.assertEqual(0.0, ratings["bob"].points)
|
||||
self.assertEqual("alice", ratings["alice"].display_name)
|
||||
|
||||
# The per-match delta is recorded on each participation row.
|
||||
deltas = {
|
||||
@@ -135,6 +143,9 @@ async def _seed_two_matches(
|
||||
) -> None:
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
# The leaderboard reads the player_rating aggregates, so the seeded
|
||||
# participations are mirrored there (as save_match_result would).
|
||||
aggregates: Dict[tuple, Dict[str, Any]] = {}
|
||||
for index, (winner, finished) in enumerate(
|
||||
[
|
||||
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||
@@ -149,6 +160,7 @@ async def _seed_two_matches(
|
||||
result={"winner": winner, "plays": 3 + index},
|
||||
)
|
||||
for seat, sub in enumerate(("alice", "bob")):
|
||||
score = 2.0 + index if sub == winner else 1.0
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
@@ -157,8 +169,26 @@ async def _seed_two_matches(
|
||||
seat=seat,
|
||||
team="A" if seat == 0 else "B",
|
||||
won=(sub == winner),
|
||||
score=2.0 + index if sub == winner else 1.0,
|
||||
score=score,
|
||||
)
|
||||
aggregate = aggregates.setdefault(
|
||||
(sub, game_types[index]),
|
||||
{"matches": 0, "wins": 0, "points": 0.0},
|
||||
)
|
||||
aggregate["matches"] += 1
|
||||
aggregate["wins"] += 1 if sub == winner else 0
|
||||
aggregate["points"] += score
|
||||
for (sub, game_type), aggregate in aggregates.items():
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub=sub,
|
||||
game_type=game_type,
|
||||
rating=INITIAL_RATING,
|
||||
matches_played=aggregate["matches"],
|
||||
wins=aggregate["wins"],
|
||||
points=aggregate["points"],
|
||||
display_name=sub,
|
||||
)
|
||||
|
||||
|
||||
class StatsRouteTest(unittest.TestCase):
|
||||
@@ -197,6 +227,28 @@ class StatsRouteTest(unittest.TestCase):
|
||||
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()
|
||||
@@ -229,13 +281,9 @@ class StatsRouteTest(unittest.TestCase):
|
||||
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,
|
||||
)
|
||||
await PlayerRating.filter(
|
||||
user_sub="alice", game_type="dummy"
|
||||
).update(rating=1600)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard")
|
||||
@@ -243,7 +291,6 @@ class StatsRouteTest(unittest.TestCase):
|
||||
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"])
|
||||
@@ -256,13 +303,9 @@ class StatsRouteTest(unittest.TestCase):
|
||||
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,
|
||||
)
|
||||
await PlayerRating.filter(
|
||||
user_sub="bob", game_type="dummy"
|
||||
).update(rating=1516)
|
||||
# Bob's rating in another game must not leak into the
|
||||
# dummy leaderboard.
|
||||
await PlayerRating.create(
|
||||
@@ -282,6 +325,97 @@ class StatsRouteTest(unittest.TestCase):
|
||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||
self.assertEqual("second", platform.registry.all()[1].id)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_pagination(self) -> None:
|
||||
app, _, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub=sub,
|
||||
game_type="dummy",
|
||||
rating=1600 + index,
|
||||
matches_played=1,
|
||||
wins=1,
|
||||
points=2.0,
|
||||
display_name=sub,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
first = await client.get("/api/leaderboard?limit=2")
|
||||
second = await client.get("/api/leaderboard?limit=2&page=2")
|
||||
third = await client.get("/api/leaderboard?limit=2&page=3")
|
||||
beyond = await client.get("/api/leaderboard?limit=2&page=99")
|
||||
body = first.json()
|
||||
self.assertEqual(1, body["page"])
|
||||
self.assertEqual(3, body["pages"])
|
||||
self.assertEqual(6, body["total"])
|
||||
self.assertEqual(2, len(body["results"]))
|
||||
self.assertEqual(
|
||||
["frank", "erin"], [row["user_sub"] for row in body["results"]]
|
||||
)
|
||||
self.assertEqual(
|
||||
["dave", "carol"],
|
||||
[row["user_sub"] for row in second.json()["results"]],
|
||||
)
|
||||
self.assertEqual(
|
||||
["bob", "alice"],
|
||||
[row["user_sub"] for row in third.json()["results"]],
|
||||
)
|
||||
# Out-of-range pages clamp to the last page.
|
||||
self.assertEqual(3, beyond.json()["page"])
|
||||
self.assertEqual(
|
||||
["bob", "alice"],
|
||||
[row["user_sub"] for row in beyond.json()["results"]],
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_focus_me(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
for index, sub in enumerate(("carol", "dave")):
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub=sub,
|
||||
game_type="dummy",
|
||||
rating=1600 + index,
|
||||
matches_played=1,
|
||||
wins=1,
|
||||
points=2.0,
|
||||
display_name=sub,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
mine = await client.get("/api/leaderboard?limit=1&focus=me")
|
||||
anonymous = await client.get("/api/leaderboard?limit=1&focus=me")
|
||||
# dave, carol, bob, alice: the caller lands on their own page.
|
||||
body = mine.json()
|
||||
self.assertEqual(4, body["page"])
|
||||
self.assertEqual(4, body["your_rank"])
|
||||
self.assertEqual(4, body["your_page"])
|
||||
self.assertEqual("alice", body["results"][0]["user_sub"])
|
||||
anonymous_body = anonymous.json()
|
||||
self.assertEqual(1, anonymous_body["page"])
|
||||
self.assertIsNone(anonymous_body["your_rank"])
|
||||
self.assertIsNone(anonymous_body["your_page"])
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_rejects_bad_page_params(self) -> None:
|
||||
app, _, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
for query in ("page=0", "page=abc", "focus=elsewhere"):
|
||||
response = await client.get(f"/api/leaderboard?{query}")
|
||||
self.assertEqual(400, response.status_code, query)
|
||||
# The limit is clamped, not rejected.
|
||||
clamped = await client.get("/api/leaderboard?limit=500")
|
||||
self.assertEqual(200, clamped.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_include_elo_delta(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from tavolo.platform.store import (
|
||||
DEADLINES_KEY,
|
||||
ONGOING_GAMES_KEY,
|
||||
InMemoryGameStore,
|
||||
RedisGameStore,
|
||||
)
|
||||
from helpers import DummyEngine, async_test
|
||||
|
||||
|
||||
@@ -27,6 +35,118 @@ def _session(game_id: str = "g1", code: str = "CODE01") -> GameSession:
|
||||
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, value))
|
||||
|
||||
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 get(self, key: str) -> object:
|
||||
return self.keys.get(key)
|
||||
|
||||
async def zremrangebyscore(
|
||||
self, key: str, minimum: object, maximum: object
|
||||
) -> int:
|
||||
return cast(int, self.apply(("zremrangebyscore", key)))
|
||||
|
||||
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]] = args[1]
|
||||
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:
|
||||
@@ -114,6 +234,50 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
["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_list_ongoing_newest_first_and_unfinished_only(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
old = _session("old", "CODE01")
|
||||
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
new = _session("new", "CODE02")
|
||||
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||
await store.save(old)
|
||||
await store.save(new)
|
||||
|
||||
self.assertEqual(
|
||||
["new", "old"], [s.id for s in await store.list_ongoing()]
|
||||
)
|
||||
self.assertEqual(
|
||||
["new"], [s.id for s in await store.list_ongoing(limit=1)]
|
||||
)
|
||||
|
||||
# A finished match retained for the final board is not listed.
|
||||
new.state["finished"] = True
|
||||
await store.save(new)
|
||||
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||
|
||||
@async_test
|
||||
async def test_deadline_queue(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
@@ -137,6 +301,92 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
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_list_ongoing_loads_unfinished_sessions(self) -> None:
|
||||
redis = _FakeRedis()
|
||||
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
|
||||
old = _session("old", "CODE01")
|
||||
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
new = _session("new", "CODE02")
|
||||
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||
await store.save(old)
|
||||
await store.save(new)
|
||||
|
||||
self.assertEqual(
|
||||
["new", "old"], [s.id for s in await store.list_ongoing()]
|
||||
)
|
||||
self.assertEqual(
|
||||
["new"], [s.id for s in await store.list_ongoing(limit=1)]
|
||||
)
|
||||
|
||||
# A finished match retained for the final board is not listed.
|
||||
new.state["finished"] = True
|
||||
await store.save(new)
|
||||
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||
|
||||
# An ongoing entry whose game key expired is skipped.
|
||||
redis.zsets[ONGOING_GAMES_KEY]["gone"] = time.time() + 100
|
||||
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||
|
||||
@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()
|
||||
|
||||
@@ -1,15 +1,92 @@
|
||||
"""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, aconnect_ws
|
||||
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."""
|
||||
@@ -39,6 +116,9 @@ class WebSocketTest(unittest.TestCase):
|
||||
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()
|
||||
@@ -52,6 +132,33 @@ class WebSocketTest(unittest.TestCase):
|
||||
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()
|
||||
@@ -87,6 +194,38 @@ class WebSocketTest(unittest.TestCase):
|
||||
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()
|
||||
@@ -121,13 +260,13 @@ class WebSocketTest(unittest.TestCase):
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
||||
pass
|
||||
self.assertEqual(4404, caught.exception.code)
|
||||
self.assertEqual(404, caught.exception.code)
|
||||
|
||||
@async_test
|
||||
async def test_non_player_is_closed(self) -> None:
|
||||
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:
|
||||
@@ -137,21 +276,62 @@ class WebSocketTest(unittest.TestCase):
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("mallory")]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||
pass
|
||||
self.assertEqual(4403, caught.exception.code)
|
||||
async 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_unauthenticated_is_closed(self) -> None:
|
||||
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()
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||
pass
|
||||
self.assertEqual(4401, caught.exception.code)
|
||||
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__":
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# tavolo-scopa
|
||||
|
||||
Scopa — the classic Italian fishing game — in its two-player form and in
|
||||
the four-player fixed-partnership variant (*scopa a quattro*), as
|
||||
[`tavolo-platform`](../tavolo-platform/README.md) game implementations.
|
||||
The optional *asso pigliatutto* creation option turns either variant into
|
||||
**scopa d'assi**.
|
||||
|
||||
## Contents
|
||||
|
||||
- `state.py` — `ScopaState` (pure game data: phases, players, hands,
|
||||
table, the deck persisting between rounds, 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, round re-dealing, hand scoring (carte, denara, settebello,
|
||||
primiera, scope), 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` — scopa-specific errors (`CardNotInHand`); every other
|
||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||
- `plugin.py` — `ScopaEngine` (two players) and `ScopaQuattroEngine`
|
||||
(four players in fixed partnerships): the platform-facing adapters.
|
||||
They translate create/join/websocket actions/deadlines into
|
||||
rules-engine calls and back, validate the
|
||||
`target_score`/`asso_pigliatutto` creation options (the match target
|
||||
defaults to the traditional 11 points for two-player scopa and 21 for
|
||||
scopa a quattro), 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.
|
||||
|
||||
## Development (from `server/`)
|
||||
|
||||
```sh
|
||||
uv run python -m unittest discover -s packages/tavolo-scopa/tests
|
||||
uv run mypy -p tavolo.scopa
|
||||
```
|
||||
|
||||
`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-scopa"
|
||||
version = "0.1.0"
|
||||
description = "Scopa 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,15 @@
|
||||
"""Scopa: the classic Italian fishing game.
|
||||
|
||||
The pure rules (:mod:`tavolo.scopa.engine`, :mod:`tavolo.scopa.state`)
|
||||
know nothing about HTTP, Redis or Postgres; :mod:`tavolo.scopa.plugin`
|
||||
adapts them to the platform's
|
||||
:class:`~tavolo.platform.engine.GameEngine` contract so the
|
||||
game-independent platform can host them. Two variants are provided:
|
||||
:class:`ScopaEngine` (two players) and :class:`ScopaQuattroEngine` (four
|
||||
players in fixed partnerships).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .plugin import ScopaEngine, ScopaQuattroEngine
|
||||
|
||||
__all__ = ["ScopaEngine", "ScopaQuattroEngine"]
|
||||
@@ -0,0 +1,595 @@
|
||||
"""Pure rules engine for scopa.
|
||||
|
||||
Every function here is deterministic and I/O-free (the only side effect is
|
||||
debug logging): it mutates (or reads) :class:`~tavolo.scopa.state.ScopaState`
|
||||
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.scopa.plugin.ScopaEngine`.
|
||||
|
||||
Rules implemented
|
||||
-----------------
|
||||
* 40-card Italian deck; three cards per player and four cards face up on
|
||||
the table at the start of every hand; the rest of the deck stays out of
|
||||
play until the next round of the hand.
|
||||
* When every hand is empty the dealer deals three more cards to each
|
||||
player (no new table cards) and play resumes; this repeats until the
|
||||
deck is exhausted. Two players play six rounds per hand, four players
|
||||
three.
|
||||
* A card captures either a **single card of equal rank** or a **combination
|
||||
of cards whose ranks sum to its own**. When an equal-ranked card is on the
|
||||
table that capture is mandatory; the player may not take an alternative
|
||||
combination instead. When any capture exists it must be taken.
|
||||
* Emptying the table with a capture is a **scopa** (+1), except on the very
|
||||
last play of a hand — a sweep on the last play of a round still scores
|
||||
when the deck has cards left.
|
||||
* At the end of a hand the remaining table cards go to the player who made
|
||||
the last capture.
|
||||
* Hand points: ``carte`` (most captured cards), ``denara`` (most diamond
|
||||
cards), ``settebello`` (the 7 of diamonds), ``primiera`` (best
|
||||
seven/five/four/three card of each suit, all four suits required), plus
|
||||
one point per ``scopa``. Ties on carte/denara/primiera award nothing.
|
||||
With four players the partners' piles and scope are pooled.
|
||||
* Optional ``asso_pigliatutto`` rule (disabled by default; scopa d'assi):
|
||||
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 or in the
|
||||
deck, 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 side reaches the target score with a clear lead; a
|
||||
tie at or above the target is broken by playing another hand.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from itertools import combinations
|
||||
from logging import getLogger
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from tavolo.platform.errors import (
|
||||
AlreadyJoined,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
LobbyFull,
|
||||
NotYourTurn,
|
||||
)
|
||||
|
||||
from .errors import CardNotInHand
|
||||
|
||||
from .state import (
|
||||
DEFAULT_PLAYER_COUNT,
|
||||
DEFAULT_TARGET_SCORES,
|
||||
PHASE_FINISHED,
|
||||
PHASE_HAND_END,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
PLAYER_COUNTS,
|
||||
SUITS,
|
||||
TEAM_NAMES,
|
||||
Card,
|
||||
Move,
|
||||
PlayerState,
|
||||
ScopaState,
|
||||
parse_card,
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Number of cards dealt to each player at a time.
|
||||
HAND_SIZE = 3
|
||||
# Cards dealt face up on the table at the start of every hand.
|
||||
TABLE_CARDS = 4
|
||||
# Default seconds the hand-end summary waits before dealing anyway. Games
|
||||
# carry their own copy in ``ScopaState.hand_ack_timeout``.
|
||||
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
||||
|
||||
# Default seconds a player has to play before the server plays a random
|
||||
# legal card for them. Games carry their own copy in
|
||||
# ``ScopaState.turn_timeout``.
|
||||
DEFAULT_TURN_TIMEOUT_SECONDS = 30
|
||||
|
||||
# Primiera card values: sevens are best, then sixes, then aces, then the
|
||||
# remaining ranks in descending order. All of 8/9/10 are worth 10.
|
||||
PRIMIERA_VALUES: Dict[int, int] = {
|
||||
7: 21,
|
||||
6: 18,
|
||||
1: 16,
|
||||
5: 15,
|
||||
4: 14,
|
||||
3: 13,
|
||||
2: 12,
|
||||
8: 10,
|
||||
9: 10,
|
||||
10: 10,
|
||||
}
|
||||
|
||||
_rng = random.SystemRandom()
|
||||
|
||||
|
||||
def full_deck() -> List[Card]:
|
||||
"""Return the 40 cards of the Italian deck in canonical order."""
|
||||
return [Card(rank=rank, suit=suit) for suit in SUITS for rank in range(1, 11)]
|
||||
|
||||
|
||||
def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
|
||||
"""Return a shuffled deck. Pass ``rng`` for deterministic tests."""
|
||||
deck = full_deck()
|
||||
(rng or _rng).shuffle(deck)
|
||||
return deck
|
||||
|
||||
|
||||
def legal_captures(
|
||||
table: Sequence[Card], card: Card, *, asso_pigliatutto: bool = False
|
||||
) -> List[List[Card]]:
|
||||
"""Return every legal capture (a list of card sets) for ``card``.
|
||||
|
||||
If an equal-ranked card is on the table, only those single-card
|
||||
captures are returned (the rule forbids taking a combination instead).
|
||||
Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
|
||||
the only capture is the whole table — or an empty capture when the
|
||||
table is bare, the ace taking itself. Without the option every subset
|
||||
of the table whose ranks sum to ``card.rank`` is returned.
|
||||
"""
|
||||
equal = [c for c in table if c.rank == card.rank]
|
||||
if equal:
|
||||
return [[c] for c in equal]
|
||||
if asso_pigliatutto and card.rank == 1:
|
||||
return [list(table)]
|
||||
|
||||
candidates = [c for c in table if c.rank <= card.rank]
|
||||
captures: List[List[Card]] = []
|
||||
# A sum-equal capture needs at least two cards (single non-equal cards
|
||||
# cannot sum to the played card).
|
||||
for size in range(2, len(candidates) + 1):
|
||||
for combo in combinations(candidates, size):
|
||||
if sum(c.rank for c in combo) == card.rank:
|
||||
captures.append(list(combo))
|
||||
return captures
|
||||
|
||||
|
||||
def create_game(
|
||||
creator_sub: str,
|
||||
creator_name: str,
|
||||
player_count: int = DEFAULT_PLAYER_COUNT,
|
||||
target_score: Optional[int] = None,
|
||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
asso_pigliatutto: bool = False,
|
||||
) -> ScopaState:
|
||||
"""Create a lobby game with the creator seated first.
|
||||
|
||||
``target_score`` defaults to the variant's traditional target: 11
|
||||
points for two-player scopa, 21 for the four-player partnership game.
|
||||
"""
|
||||
if player_count not in PLAYER_COUNTS:
|
||||
raise IllegalMove(
|
||||
"player_count must be one of: "
|
||||
+ ", ".join(str(count) for count in PLAYER_COUNTS)
|
||||
)
|
||||
if target_score is None:
|
||||
target_score = DEFAULT_TARGET_SCORES[player_count]
|
||||
if target_score < 1 or target_score > 100:
|
||||
raise IllegalMove("target_score must be between 1 and 100")
|
||||
return ScopaState(
|
||||
player_count=player_count,
|
||||
target_score=target_score,
|
||||
asso_pigliatutto=asso_pigliatutto,
|
||||
phase=PHASE_LOBBY,
|
||||
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||
hand_ack_timeout=hand_ack_timeout,
|
||||
turn_timeout=turn_timeout,
|
||||
)
|
||||
|
||||
|
||||
def join_game(state: ScopaState, 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: ScopaState) -> 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_hand(state)
|
||||
|
||||
|
||||
def _set_turn_deadline(state: ScopaState) -> 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 _player_at(state: ScopaState, seat: int) -> PlayerState:
|
||||
for player in state.players:
|
||||
if player.seat == seat:
|
||||
return player
|
||||
raise IllegalMove(f"no player in seat {seat}")
|
||||
|
||||
|
||||
def _deal_hand(state: ScopaState) -> None:
|
||||
"""Start a new hand: shuffle, lay the table, deal the first round."""
|
||||
deck = shuffled_deck()
|
||||
for player in state.players:
|
||||
player.hand = []
|
||||
player.captured = []
|
||||
player.scope = 0
|
||||
state.table = []
|
||||
state.last_taker = None
|
||||
for _ in range(TABLE_CARDS):
|
||||
state.table.append(deck.pop())
|
||||
state.deck = deck
|
||||
_deal_round(state)
|
||||
log.debug(
|
||||
"hand %d dealt (dealer seat %d, %d players, %d on the table)",
|
||||
state.hand_number,
|
||||
state.dealer,
|
||||
state.player_count,
|
||||
len(state.table),
|
||||
)
|
||||
|
||||
|
||||
def _deal_round(state: ScopaState) -> None:
|
||||
"""Deal three cards to every player and give the lead to the player
|
||||
after the dealer."""
|
||||
for player in state.players:
|
||||
player.hand = []
|
||||
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(state.deck.pop())
|
||||
state.turn = (state.dealer + 1) % state.player_count
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def play(
|
||||
state: ScopaState,
|
||||
sub: str,
|
||||
card_code: str,
|
||||
capture_codes: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
"""Apply one move by the player identified by ``sub``.
|
||||
|
||||
``capture_codes`` selects which table cards to capture; it must be a
|
||||
legal capture (see :func:`legal_captures`) when one exists and empty
|
||||
otherwise. Raises a :class:`~tavolo.platform.errors.GameError` subclass
|
||||
on any violation.
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
raise GameFinished("the match is over")
|
||||
if state.phase == PHASE_HAND_END:
|
||||
raise IllegalMove("the hand is over; acknowledge the summary to continue")
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
|
||||
player = state.player_for(sub)
|
||||
if player is None or player.seat != state.turn:
|
||||
raise NotYourTurn("it is not your turn")
|
||||
|
||||
card = parse_card(card_code)
|
||||
if card not in player.hand:
|
||||
raise CardNotInHand(f"card {card.code} is not in your hand")
|
||||
# Remove the card now so the scopa check below can tell whether this
|
||||
# was the last play of the hand.
|
||||
player.hand.remove(card)
|
||||
|
||||
requested = [parse_card(c) for c in (capture_codes or [])]
|
||||
table_before = list(state.table)
|
||||
options = legal_captures(
|
||||
table_before, card, asso_pigliatutto=state.asso_pigliatutto
|
||||
)
|
||||
# An asso pigliatutto capture: the ace takes the whole table because no
|
||||
# equal rank was on it, or takes itself when the table is bare. Such a
|
||||
# capture never scores a scopa.
|
||||
pigliatutto = (
|
||||
state.asso_pigliatutto
|
||||
and card.rank == 1
|
||||
and all(c.rank != card.rank for c in table_before)
|
||||
)
|
||||
|
||||
taken: List[Card] = []
|
||||
scopa = False
|
||||
if not options:
|
||||
if requested:
|
||||
raise IllegalMove("no capture is possible with that card")
|
||||
state.table.append(card)
|
||||
else:
|
||||
chosen = _match_option(options, requested)
|
||||
if chosen is None:
|
||||
raise IllegalMove("the requested capture is not legal")
|
||||
taken = chosen
|
||||
for captured in chosen:
|
||||
state.table.remove(captured)
|
||||
player.captured.append(captured)
|
||||
player.captured.append(card)
|
||||
state.last_taker = player.seat
|
||||
# A scopa scores only if cards remain to be played this hand: a
|
||||
# sweep on the last play of a round still scores when the deck has
|
||||
# cards left to deal.
|
||||
cards_left = bool(state.deck) or any(p.hand for p in state.players)
|
||||
if not state.table and cards_left and not pigliatutto:
|
||||
player.scope += 1
|
||||
scopa = True
|
||||
|
||||
state.last_move = Move(
|
||||
seat=player.seat,
|
||||
name=player.name,
|
||||
card=card.code,
|
||||
captured=[c.code for c in taken],
|
||||
scopa=scopa,
|
||||
pigliatutto=pigliatutto,
|
||||
)
|
||||
|
||||
if all(not p.hand for p in state.players):
|
||||
if state.deck:
|
||||
# Same hand continues with a fresh round of three cards each.
|
||||
_deal_round(state)
|
||||
else:
|
||||
_end_hand(state)
|
||||
else:
|
||||
state.turn = (state.turn + 1) % state.player_count
|
||||
_set_turn_deadline(state)
|
||||
|
||||
|
||||
def _match_option(
|
||||
options: Sequence[Sequence[Card]], requested: Sequence[Card]
|
||||
) -> Optional[List[Card]]:
|
||||
"""Return the option matching ``requested`` exactly, if any.
|
||||
|
||||
An empty request matches only an empty option — the asso pigliatutto
|
||||
self-capture — so requesting nothing never satisfies a real capture.
|
||||
"""
|
||||
wanted = sorted(c.code for c in requested)
|
||||
for option in options:
|
||||
if sorted(c.code for c in option) == wanted:
|
||||
return list(option)
|
||||
return None
|
||||
|
||||
|
||||
def auto_play(state: ScopaState, rng: Optional[random.Random] = None) -> None:
|
||||
"""Play a random legal move for the player currently on turn.
|
||||
|
||||
A card is drawn at random from that player's hand; if it can capture,
|
||||
one of the legal captures is chosen at random (the rules require a
|
||||
capture when one exists). Delegates to :func:`play`, so the move is
|
||||
fully validated and can end the hand or the match. Pass ``rng`` for
|
||||
deterministic tests.
|
||||
"""
|
||||
if state.phase != PHASE_PLAYING:
|
||||
raise GameNotStarted("the game has not started yet")
|
||||
player = _player_at(state, state.turn)
|
||||
if not player.hand:
|
||||
raise IllegalMove("the player on turn has no cards")
|
||||
chooser = rng or _rng
|
||||
card = chooser.choice(player.hand)
|
||||
options = legal_captures(
|
||||
state.table, card, asso_pigliatutto=state.asso_pigliatutto
|
||||
)
|
||||
capture = chooser.choice(options) if options else None
|
||||
play(
|
||||
state,
|
||||
player.sub,
|
||||
card.code,
|
||||
[c.code for c in capture] if capture is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _end_hand(state: ScopaState) -> None:
|
||||
"""Sweep the table and score the hand.
|
||||
|
||||
If the match continues, the game pauses in the ``hand_end`` phase so
|
||||
every player can read the scoring summary; the next hand is dealt by
|
||||
:func:`acknowledge_hand` once every player has acknowledged (or by the
|
||||
hand-end timeout fired by the platform's deadline scheduler). If the
|
||||
match is over the game goes to ``finished`` immediately.
|
||||
"""
|
||||
state.turn_deadline = None
|
||||
if state.table and state.last_taker is not None:
|
||||
taker = _player_at(state, state.last_taker)
|
||||
taker.captured.extend(state.table)
|
||||
state.table = []
|
||||
|
||||
points, details = hand_points(state)
|
||||
for team in (0, 1):
|
||||
state.scores[team] += points[team]
|
||||
details["hand"] = state.hand_number
|
||||
details["team_a_points"] = points[0]
|
||||
details["team_b_points"] = points[1]
|
||||
state.hand_scores.append(details)
|
||||
|
||||
a, b = state.scores
|
||||
log.debug(
|
||||
"hand %d scored A+%d B+%d (totals %d-%d)",
|
||||
state.hand_number,
|
||||
points[0],
|
||||
points[1],
|
||||
a,
|
||||
b,
|
||||
)
|
||||
reached = max(a, b) >= state.target_score
|
||||
if reached and a != b:
|
||||
state.phase = PHASE_FINISHED
|
||||
state.winner = 0 if a > b else 1
|
||||
log.debug("match ended, team %s wins", TEAM_NAMES[state.winner])
|
||||
return
|
||||
|
||||
# Pause for the scoring summary instead of dealing immediately.
|
||||
state.phase = PHASE_HAND_END
|
||||
state.acked = []
|
||||
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
|
||||
state.hand_end_deadline = deadline.isoformat()
|
||||
|
||||
|
||||
def acknowledge_hand(state: ScopaState, sub: str) -> None:
|
||||
"""Record that ``sub`` has read the hand-end summary.
|
||||
|
||||
When every player has acknowledged, the next hand is dealt. Acknowledging
|
||||
twice is a no-op; acknowledging outside the ``hand_end`` phase raises an
|
||||
error.
|
||||
"""
|
||||
if state.phase != PHASE_HAND_END:
|
||||
raise IllegalMove("no hand summary is waiting for acknowledgement")
|
||||
player = state.player_for(sub)
|
||||
if player is None:
|
||||
raise NotYourTurn("you are not seated in this game")
|
||||
if player.seat in state.acked:
|
||||
return
|
||||
state.acked.append(player.seat)
|
||||
if len(state.acked) < state.player_count:
|
||||
return
|
||||
|
||||
state.hand_number += 1
|
||||
state.dealer = (state.dealer + 1) % state.player_count
|
||||
state.acked = []
|
||||
state.hand_end_deadline = None
|
||||
state.last_move = None
|
||||
state.phase = PHASE_PLAYING
|
||||
_deal_hand(state)
|
||||
|
||||
|
||||
def primiera_score(captured: Sequence[Card]) -> int:
|
||||
"""Return the primiera value of a capture pile (0 if a suit is absent)."""
|
||||
best: Dict[str, int] = {}
|
||||
for card in captured:
|
||||
value = PRIMIERA_VALUES[card.rank]
|
||||
if card.suit not in best or value > best[card.suit]:
|
||||
best[card.suit] = value
|
||||
if len(best) < len(SUITS):
|
||||
return 0
|
||||
return sum(best.values())
|
||||
|
||||
|
||||
def hand_points(state: ScopaState) -> Tuple[List[int], Dict[str, object]]:
|
||||
"""Compute the hand points for both sides (index 0 = team A).
|
||||
|
||||
With four players the partners' piles and scopa counts are pooled; with
|
||||
two each player is their own side.
|
||||
"""
|
||||
piles: List[List[Card]] = [[], []]
|
||||
scope: List[int] = [0, 0]
|
||||
for player in state.players:
|
||||
piles[player.team].extend(player.captured)
|
||||
scope[player.team] += player.scope
|
||||
|
||||
points = [0, 0]
|
||||
award: Dict[str, Optional[str]] = {}
|
||||
# Carte: most captured cards. Ties award nothing.
|
||||
cards = [len(piles[0]), len(piles[1])]
|
||||
if cards[0] != cards[1]:
|
||||
winner = 0 if cards[0] > cards[1] else 1
|
||||
points[winner] += 1
|
||||
award["carte"] = TEAM_NAMES[winner]
|
||||
else:
|
||||
award["carte"] = None
|
||||
# Denara: most diamond cards. Ties award nothing.
|
||||
coins = [
|
||||
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
|
||||
]
|
||||
if coins[0] != coins[1]:
|
||||
winner = 0 if coins[0] > coins[1] else 1
|
||||
points[winner] += 1
|
||||
award["denara"] = TEAM_NAMES[winner]
|
||||
else:
|
||||
award["denara"] = None
|
||||
# Settebello: the 7 of diamonds always belongs to someone.
|
||||
settebello = [
|
||||
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1)
|
||||
]
|
||||
if settebello[0] != settebello[1]:
|
||||
winner = 0 if settebello[0] else 1
|
||||
points[winner] += 1
|
||||
award["settebello"] = TEAM_NAMES[winner]
|
||||
# Primiera: highest value, only if the side holds all four suits.
|
||||
primiera = [primiera_score(piles[t]) for t in (0, 1)]
|
||||
if primiera[0] != primiera[1]:
|
||||
winner = 0 if primiera[0] > primiera[1] else 1
|
||||
points[winner] += 1
|
||||
award["primiera"] = TEAM_NAMES[winner]
|
||||
else:
|
||||
award["primiera"] = None
|
||||
# Scope: one point each.
|
||||
points[0] += scope[0]
|
||||
points[1] += scope[1]
|
||||
|
||||
return points, {
|
||||
"cards": {"A": cards[0], "B": cards[1]},
|
||||
"denara": {"A": coins[0], "B": coins[1]},
|
||||
"settebello": {"A": settebello[0], "B": settebello[1]},
|
||||
"primiera": {"A": primiera[0], "B": primiera[1]},
|
||||
"scope": {"A": scope[0], "B": scope[1]},
|
||||
"award": award,
|
||||
}
|
||||
|
||||
|
||||
def state_for_player(state: ScopaState, 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),
|
||||
"scope": player.scope,
|
||||
}
|
||||
if viewer is not None and viewer.seat == player.seat:
|
||||
view["hand"] = [c.code for c in player.hand]
|
||||
players.append(view)
|
||||
|
||||
payload: Dict[str, object] = {
|
||||
"phase": state.phase,
|
||||
"player_count": state.player_count,
|
||||
"target_score": state.target_score,
|
||||
"asso_pigliatutto": state.asso_pigliatutto,
|
||||
"hand_number": state.hand_number,
|
||||
"dealer": state.dealer,
|
||||
"turn": state.turn,
|
||||
"deck_count": len(state.deck),
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
"winner": None if state.winner is None else TEAM_NAMES[state.winner],
|
||||
"table": [c.code for c in state.table],
|
||||
"players": players,
|
||||
"last_hand": state.hand_scores[-1] if state.hand_scores else None,
|
||||
"last_move": state.last_move.to_json() if state.last_move else None,
|
||||
"acknowledged": list(state.acked),
|
||||
"hand_end_deadline": state.hand_end_deadline,
|
||||
"turn_deadline": state.turn_deadline,
|
||||
}
|
||||
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
|
||||
payload["your_turn"] = True
|
||||
# Only the player on turn receives their legal captures, so all the
|
||||
# rule logic stays server-side.
|
||||
legal_moves: Dict[str, List[List[str]]] = {}
|
||||
for hand_card in viewer.hand:
|
||||
options = legal_captures(
|
||||
state.table, hand_card, asso_pigliatutto=state.asso_pigliatutto
|
||||
)
|
||||
if options:
|
||||
legal_moves[hand_card.code] = [
|
||||
[c.code for c in option] for option in options
|
||||
]
|
||||
payload["legal_moves"] = legal_moves
|
||||
return payload
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Scopa errors.
|
||||
|
||||
Most failure modes are game-independent and live in
|
||||
:mod:`tavolo.platform.errors`; only genuinely scopa-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,305 @@
|
||||
"""The platform-facing adapter for scopa.
|
||||
|
||||
:class:`ScopaEngine` (classic two-player) and
|
||||
:class:`ScopaQuattroEngine` (four-player fixed partnerships) implement
|
||||
:class:`~tavolo.platform.engine.GameEngine` on top of the pure rules in
|
||||
:mod:`tavolo.scopa.engine`: they translate platform calls (create, join,
|
||||
websocket actions, deadlines) into rules-engine calls and back, keeping
|
||||
all scopa knowledge inside this package. Nothing outside ``tavolo.scopa``
|
||||
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,
|
||||
DEFAULT_TARGET_SCORE_QUATTRO,
|
||||
PHASE_FINISHED,
|
||||
PHASE_HAND_END,
|
||||
PHASE_LOBBY,
|
||||
PHASE_PLAYING,
|
||||
TEAM_NAMES,
|
||||
ScopaState,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _options_schema(default_target: int) -> Dict[str, Any]:
|
||||
"""The creation options schema with this variant's target default."""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_score": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": default_target,
|
||||
"description": "Match points the winning side must reach.",
|
||||
},
|
||||
"asso_pigliatutto": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Scopa d'assi: an ace sweeps the whole "
|
||||
"table (no scopa)",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _ScopaBase(GameEngine):
|
||||
"""Shared platform adapter for the two-player and four-player games."""
|
||||
|
||||
player_count: int
|
||||
#: Traditional match target of the variant; also the schema default.
|
||||
default_target_score: int
|
||||
|
||||
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", self.default_target_score)
|
||||
if isinstance(target_score, bool) or not isinstance(target_score, int):
|
||||
raise IllegalMove("target_score must be an integer")
|
||||
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,
|
||||
player_count=self.player_count,
|
||||
target_score=target_score,
|
||||
hand_ack_timeout=self._hand_ack_timeout,
|
||||
turn_timeout=self._turn_timeout,
|
||||
asso_pigliatutto=asso_pigliatutto,
|
||||
)
|
||||
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: ScopaState = session.state
|
||||
return {
|
||||
"phase": state.phase,
|
||||
"target_score": state.target_score,
|
||||
"asso_pigliatutto": state.asso_pigliatutto,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
|
||||
def handle_action(
|
||||
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
|
||||
) -> None:
|
||||
state: ScopaState = 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: ScopaState = 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, ScopaState)
|
||||
return state.to_json()
|
||||
|
||||
def state_from_json(self, data: Mapping[str, Any]) -> Any:
|
||||
return ScopaState.from_json(dict(data))
|
||||
|
||||
# -- deadlines ----------------------------------------------------------
|
||||
|
||||
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
|
||||
state: ScopaState = 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: ScopaState = 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: ScopaState = 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,
|
||||
"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,
|
||||
"asso_pigliatutto": state.asso_pigliatutto,
|
||||
"hands_played": state.hand_number,
|
||||
"hand_scores": state.hand_scores,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ScopaEngine(_ScopaBase):
|
||||
"""Classic two-player scopa as a platform game engine."""
|
||||
|
||||
id = "scopa"
|
||||
name = "Scopa"
|
||||
description = (
|
||||
"The classic two-player Italian fishing game: three cards each, "
|
||||
"four on the table, capture by rank or sum and sweep for scope. "
|
||||
"First to the target score wins."
|
||||
)
|
||||
min_players = 2
|
||||
max_players = 2
|
||||
player_count = 2
|
||||
default_target_score = DEFAULT_TARGET_SCORE
|
||||
options_schema = _options_schema(DEFAULT_TARGET_SCORE)
|
||||
|
||||
|
||||
class ScopaQuattroEngine(_ScopaBase):
|
||||
"""Four-player partnership scopa as a platform game engine."""
|
||||
|
||||
id = "scopa_a_quattro"
|
||||
name = "Scopa a quattro"
|
||||
description = (
|
||||
"Scopa for four players in fixed partnerships: three cards each, "
|
||||
"four on the table, capture by rank or sum and sweep for scope. "
|
||||
"First side to the target score wins."
|
||||
)
|
||||
min_players = 4
|
||||
max_players = 4
|
||||
player_count = 4
|
||||
default_target_score = DEFAULT_TARGET_SCORE_QUATTRO
|
||||
options_schema = _options_schema(DEFAULT_TARGET_SCORE_QUATTRO)
|
||||
@@ -0,0 +1,265 @@
|
||||
"""In-memory representation of a scopa game.
|
||||
|
||||
The whole mutable game lives in :class:`ScopaState`, 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 same state serves the classic two-player game and the four-player
|
||||
partnership variant (``player_count``): with two players the two seats
|
||||
are individual sides, with four they are fixed partnerships (0/2 vs 1/3),
|
||||
and either way the side is the seat's parity. Within a hand the deck is
|
||||
dealt in rounds of three cards per player and persists across rounds in
|
||||
:attr:`ScopaState.deck`.
|
||||
|
||||
The state contains only game data: the platform owns the session envelope
|
||||
(id, join code, seats, timestamps, stats persistence) — see
|
||||
:class:`tavolo.platform.engine.GameSession`.
|
||||
|
||||
Deck convention: a 40-card Italian deck. Suits are ``D`` (denari),
|
||||
``C`` (coppe), ``S`` (spade) and ``B`` (bastoni); ranks are ``1``..``10``.
|
||||
A card is rendered as ``RRSUIT`` (e.g. ``07D`` is the settebello).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
SUITS = ("D", "C", "S", "B")
|
||||
RANKS = tuple(range(1, 11))
|
||||
|
||||
# Teams are derived from the seat parity: seats 0 and 2 form team A
|
||||
# (index 0), seats 1 and 3 form team B (index 1). In the two-player game
|
||||
# that makes the two players their own sides (seats 0 and 1); in the
|
||||
# four-player game partners sit opposite each other.
|
||||
TEAM_A = 0
|
||||
TEAM_B = 1
|
||||
TEAM_NAMES = ("A", "B")
|
||||
|
||||
PHASE_LOBBY = "lobby"
|
||||
PHASE_PLAYING = "playing"
|
||||
# Between hands of an unfinished match: scoring summary shown to every
|
||||
# player; the next hand is dealt once everyone acknowledges (or the
|
||||
# hand-end timeout elapses).
|
||||
PHASE_HAND_END = "hand_end"
|
||||
PHASE_FINISHED = "finished"
|
||||
|
||||
# Traditional match targets: two-player scopa is played to 11 points,
|
||||
# while the four-player partnership game (like scopone) goes to 21.
|
||||
DEFAULT_TARGET_SCORE = 11
|
||||
DEFAULT_TARGET_SCORE_QUATTRO = 21
|
||||
DEFAULT_TARGET_SCORES = {2: DEFAULT_TARGET_SCORE, 4: DEFAULT_TARGET_SCORE_QUATTRO}
|
||||
DEFAULT_PLAYER_COUNT = 2
|
||||
PLAYER_COUNTS = (2, 4)
|
||||
|
||||
|
||||
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 Move:
|
||||
"""Record of a single play, broadcast so every client can show who
|
||||
played which card and what it captured."""
|
||||
|
||||
seat: int
|
||||
name: str
|
||||
card: str
|
||||
captured: List[str] = field(default_factory=list)
|
||||
scopa: bool = False
|
||||
# The play was an asso pigliatutto capture (the whole table, or the
|
||||
# ace itself when the table was empty).
|
||||
pigliatutto: bool = False
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"seat": self.seat,
|
||||
"name": self.name,
|
||||
"card": self.card,
|
||||
"captured": list(self.captured),
|
||||
"scopa": self.scopa,
|
||||
"pigliatutto": self.pigliatutto,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "Move":
|
||||
return Move(
|
||||
seat=int(data["seat"]),
|
||||
name=str(data["name"]),
|
||||
card=str(data["card"]),
|
||||
captured=[str(c) for c in data.get("captured", [])],
|
||||
scopa=bool(data.get("scopa", False)),
|
||||
pigliatutto=bool(data.get("pigliatutto", False)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerState:
|
||||
sub: str
|
||||
name: str
|
||||
seat: int
|
||||
hand: List[Card] = field(default_factory=list)
|
||||
captured: List[Card] = field(default_factory=list)
|
||||
scope: int = 0
|
||||
|
||||
@property
|
||||
def team(self) -> int:
|
||||
return team_of(self.seat)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"sub": self.sub,
|
||||
"name": self.name,
|
||||
"seat": self.seat,
|
||||
"hand": [c.to_json() for c in self.hand],
|
||||
"captured": [c.to_json() for c in self.captured],
|
||||
"scope": self.scope,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "PlayerState":
|
||||
return PlayerState(
|
||||
sub=str(data["sub"]),
|
||||
name=str(data["name"]),
|
||||
seat=int(data["seat"]),
|
||||
hand=[Card.from_json(c) for c in data.get("hand", [])],
|
||||
captured=[Card.from_json(c) for c in data.get("captured", [])],
|
||||
scope=int(data.get("scope", 0)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScopaState:
|
||||
# 2 (individual) or 4 (fixed partnerships). Fixed for the session.
|
||||
player_count: int = DEFAULT_PLAYER_COUNT
|
||||
target_score: int = DEFAULT_TARGET_SCORE
|
||||
# Whether the asso pigliatutto rule is scored (scopa d'assi: an ace
|
||||
# sweeps the whole table when no equal rank is on it, or captures
|
||||
# itself when the table is empty; the capture never scores a scopa).
|
||||
# Default off, also for states serialized before the option existed.
|
||||
asso_pigliatutto: bool = False
|
||||
phase: str = PHASE_LOBBY
|
||||
players: List[PlayerState] = field(default_factory=list)
|
||||
table: List[Card] = field(default_factory=list)
|
||||
# Cards of the current hand not dealt yet: scopa deals three cards per
|
||||
# player at a time, and re-deals when every hand is empty until the
|
||||
# deck is exhausted.
|
||||
deck: List[Card] = field(default_factory=list)
|
||||
dealer: int = 0
|
||||
turn: int = 0
|
||||
hand_number: int = 1
|
||||
scores: List[int] = field(default_factory=lambda: [0, 0])
|
||||
winner: Optional[int] = None
|
||||
last_taker: Optional[int] = None
|
||||
# Per-hand points awarded, for a compact audit trail in the API.
|
||||
hand_scores: List[Dict[str, Any]] = field(default_factory=list)
|
||||
# The most recent play in the current hand, for move announcements.
|
||||
last_move: Optional[Move] = None
|
||||
# While phase == "hand_end": seats that acknowledged the summary, and
|
||||
# when the auto-continue timeout fires.
|
||||
acked: List[int] = field(default_factory=list)
|
||||
hand_end_deadline: Optional[str] = None
|
||||
# Seconds the hand-end summary waits before dealing anyway.
|
||||
hand_ack_timeout: int = 30
|
||||
# While phase == "playing": when the server plays a random legal card
|
||||
# for the player on turn.
|
||||
turn_deadline: Optional[str] = None
|
||||
turn_timeout: int = 30
|
||||
|
||||
# -- serialization ----------------------------------------------------
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"player_count": self.player_count,
|
||||
"target_score": self.target_score,
|
||||
"asso_pigliatutto": self.asso_pigliatutto,
|
||||
"phase": self.phase,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"table": [c.to_json() for c in self.table],
|
||||
"deck": [c.to_json() for c in self.deck],
|
||||
"dealer": self.dealer,
|
||||
"turn": self.turn,
|
||||
"hand_number": self.hand_number,
|
||||
"scores": list(self.scores),
|
||||
"winner": self.winner,
|
||||
"last_taker": self.last_taker,
|
||||
"hand_scores": list(self.hand_scores),
|
||||
"last_move": self.last_move.to_json() if self.last_move else None,
|
||||
"acked": list(self.acked),
|
||||
"hand_end_deadline": self.hand_end_deadline,
|
||||
"hand_ack_timeout": self.hand_ack_timeout,
|
||||
"turn_deadline": self.turn_deadline,
|
||||
"turn_timeout": self.turn_timeout,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(data: Dict[str, Any]) -> "ScopaState":
|
||||
return ScopaState(
|
||||
player_count=int(data.get("player_count", DEFAULT_PLAYER_COUNT)),
|
||||
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
|
||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||
table=[Card.from_json(c) for c in data.get("table", [])],
|
||||
deck=[Card.from_json(c) for c in data.get("deck", [])],
|
||||
dealer=int(data.get("dealer", 0)),
|
||||
turn=int(data.get("turn", 0)),
|
||||
hand_number=int(data.get("hand_number", 1)),
|
||||
scores=[int(x) for x in data.get("scores", [0, 0])],
|
||||
winner=data.get("winner"),
|
||||
last_taker=data.get("last_taker"),
|
||||
hand_scores=list(data.get("hand_scores", [])),
|
||||
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
||||
acked=[int(s) for s in data.get("acked", [])],
|
||||
hand_end_deadline=data.get("hand_end_deadline"),
|
||||
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
|
||||
turn_deadline=data.get("turn_deadline"),
|
||||
turn_timeout=int(data.get("turn_timeout", 30)),
|
||||
)
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def player_for(self, sub: 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,625 @@
|
||||
"""Rule engine tests: deals, captures, scope, scoring and full matches."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import unittest
|
||||
|
||||
from tavolo.scopa import engine
|
||||
from tavolo.scopa.errors import (
|
||||
AlreadyJoined,
|
||||
CardNotInHand,
|
||||
GameFinished,
|
||||
GameNotStarted,
|
||||
IllegalMove,
|
||||
NotYourTurn,
|
||||
)
|
||||
from tavolo.scopa.state import (
|
||||
PHASE_FINISHED,
|
||||
PHASE_HAND_END,
|
||||
PHASE_PLAYING,
|
||||
Card,
|
||||
PlayerState,
|
||||
ScopaState,
|
||||
)
|
||||
|
||||
|
||||
def card(code: str) -> Card:
|
||||
return Card.parse(code)
|
||||
|
||||
|
||||
def make_state(
|
||||
hands,
|
||||
table,
|
||||
turn: int = 0,
|
||||
*,
|
||||
dealer: int = 0,
|
||||
captured=None,
|
||||
scope=None,
|
||||
target: int = 11,
|
||||
last_taker=None,
|
||||
deck=None,
|
||||
asso_pigliatutto: bool = False,
|
||||
) -> ScopaState:
|
||||
"""Build a controlled game state directly (bypassing the deal)."""
|
||||
state = ScopaState(
|
||||
player_count=len(hands),
|
||||
target_score=target,
|
||||
asso_pigliatutto=asso_pigliatutto,
|
||||
phase=PHASE_PLAYING,
|
||||
dealer=dealer,
|
||||
turn=turn,
|
||||
last_taker=last_taker,
|
||||
)
|
||||
for seat, hand in enumerate(hands):
|
||||
state.players.append(
|
||||
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
|
||||
hand=[card(c) for c in hand])
|
||||
)
|
||||
if captured is not None:
|
||||
for player, codes in zip(state.players, captured):
|
||||
player.captured = [card(c) for c in codes]
|
||||
if scope is not None:
|
||||
for player, value in zip(state.players, scope):
|
||||
player.scope = value
|
||||
state.table = [card(c) for c in table]
|
||||
state.deck = [card(c) for c in (deck or [])]
|
||||
return state
|
||||
|
||||
|
||||
class DeckTest(unittest.TestCase):
|
||||
def test_full_deck_has_40_unique_cards(self) -> None:
|
||||
deck = engine.full_deck()
|
||||
self.assertEqual(40, len(deck))
|
||||
self.assertEqual(40, len({c.code for c in deck}))
|
||||
self.assertEqual(4, len({c.suit for c in deck}))
|
||||
self.assertEqual(4, sum(1 for c in deck if c.rank == 7))
|
||||
|
||||
def test_shuffled_deck_is_permutation(self) -> None:
|
||||
deck = engine.shuffled_deck()
|
||||
self.assertEqual(
|
||||
sorted(c.code for c in engine.full_deck()),
|
||||
sorted(c.code for c in deck),
|
||||
)
|
||||
|
||||
|
||||
class CaptureTest(unittest.TestCase):
|
||||
def test_equal_card_is_mandatory(self) -> None:
|
||||
table = [card("05C"), card("02D"), card("03S")]
|
||||
options = engine.legal_captures(table, card("05D"))
|
||||
self.assertEqual([["05C"]], [[c.code for c in o] for o in options])
|
||||
|
||||
def test_sum_combination(self) -> None:
|
||||
table = [card("01C"), card("03C"), card("02S")]
|
||||
options = engine.legal_captures(table, card("04D"))
|
||||
self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options])
|
||||
|
||||
def test_multiple_equal_cards_each_a_separate_option(self) -> None:
|
||||
table = [card("05C"), card("05S")]
|
||||
options = engine.legal_captures(table, card("05D"))
|
||||
self.assertEqual(
|
||||
[["05C"], ["05S"]], sorted([[c.code for c in o] for o in options])
|
||||
)
|
||||
|
||||
def test_no_capture(self) -> None:
|
||||
table = [card("09C"), card("08S")]
|
||||
self.assertEqual([], engine.legal_captures(table, card("02D")))
|
||||
|
||||
def test_play_without_capture_places_card_on_table(self) -> None:
|
||||
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
|
||||
engine.play(state, "p0", "02D")
|
||||
self.assertIn("02D", [c.code for c in state.table])
|
||||
self.assertNotIn("02D", [c.code for c in state.players[0].hand])
|
||||
self.assertEqual(1, state.turn)
|
||||
|
||||
def test_play_capture_and_scopa(self) -> None:
|
||||
state = make_state([["02D", "09C"], ["01C"]], table=["02C"])
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
self.assertEqual(1, state.players[0].scope)
|
||||
self.assertEqual([], state.table)
|
||||
self.assertEqual(
|
||||
["02C", "02D"], [c.code for c in state.players[0].captured]
|
||||
)
|
||||
# The move is recorded for the "who played what" announcement.
|
||||
assert state.last_move is not None
|
||||
self.assertEqual(0, state.last_move.seat)
|
||||
self.assertEqual("p0", state.last_move.name)
|
||||
self.assertEqual("02D", state.last_move.card)
|
||||
self.assertEqual(["02C"], state.last_move.captured)
|
||||
self.assertTrue(state.last_move.scopa)
|
||||
|
||||
def test_play_without_capture_records_move(self) -> None:
|
||||
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
|
||||
engine.play(state, "p0", "02D")
|
||||
assert state.last_move is not None
|
||||
self.assertEqual("02D", state.last_move.card)
|
||||
self.assertEqual([], state.last_move.captured)
|
||||
self.assertFalse(state.last_move.scopa)
|
||||
|
||||
def test_illegal_combination_when_equal_card_present(self) -> None:
|
||||
state = make_state([["05D"], ["01C"]],
|
||||
table=["05C", "02D", "03S"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "05D", ["02D", "03S"])
|
||||
|
||||
def test_illegal_capture_rejected(self) -> None:
|
||||
state = make_state([["04D"], ["01C"]], table=["02C", "03S"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "04D", ["02C"])
|
||||
|
||||
def test_no_capture_requested_when_capture_possible(self) -> None:
|
||||
state = make_state([["05D"], ["01C"]], table=["05C"])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "05D")
|
||||
|
||||
def test_not_your_turn(self) -> None:
|
||||
state = make_state([["02D"], ["03D"]], table=[], turn=1)
|
||||
with self.assertRaises(NotYourTurn):
|
||||
engine.play(state, "p0", "02D")
|
||||
|
||||
def test_card_not_in_hand(self) -> None:
|
||||
state = make_state([["02D"], ["03D"]], table=[])
|
||||
with self.assertRaises(CardNotInHand):
|
||||
engine.play(state, "p0", "07D")
|
||||
|
||||
def test_finished_game_rejects_moves(self) -> None:
|
||||
state = make_state([["02D"], ["03D"]], table=[])
|
||||
state.phase = PHASE_FINISHED
|
||||
with self.assertRaises(GameFinished):
|
||||
engine.play(state, "p0", "02D")
|
||||
|
||||
|
||||
class DealTest(unittest.TestCase):
|
||||
def test_create_game_defaults(self) -> None:
|
||||
state = engine.create_game("p0", "p0")
|
||||
self.assertEqual(2, state.player_count)
|
||||
# Two-player scopa is traditionally played to 11 points.
|
||||
self.assertEqual(11, state.target_score)
|
||||
self.assertFalse(state.asso_pigliatutto)
|
||||
self.assertEqual("p0", state.players[0].sub)
|
||||
self.assertEqual("lobby", state.phase)
|
||||
|
||||
def test_create_game_four_player_default_target(self) -> None:
|
||||
# The four-player partnership game (like scopone) goes to 21.
|
||||
state = engine.create_game("p0", "p0", player_count=4)
|
||||
self.assertEqual(21, state.target_score)
|
||||
# An explicit target still wins over the default.
|
||||
explicit = engine.create_game("p0", "p0", player_count=4, target_score=16)
|
||||
self.assertEqual(16, explicit.target_score)
|
||||
|
||||
def test_create_game_player_count_validation(self) -> None:
|
||||
for bad in (1, 3, 5, 0):
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.create_game("p0", "p0", player_count=bad)
|
||||
|
||||
def test_join_starts_two_player_game(self) -> None:
|
||||
state = engine.create_game("p0", "p0")
|
||||
engine.join_game(state, "p1", "p1")
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertEqual(4, len(state.table))
|
||||
self.assertEqual(4, len({c.code for c in state.table}))
|
||||
for player in state.players:
|
||||
self.assertEqual(3, len(player.hand))
|
||||
# 40 - 4 on the table - 6 in hand.
|
||||
self.assertEqual(30, len(state.deck))
|
||||
self.assertEqual(1, state.turn)
|
||||
|
||||
def test_join_starts_four_player_game(self) -> None:
|
||||
state = engine.create_game("p0", "p0", player_count=4)
|
||||
for seat in range(1, 4):
|
||||
engine.join_game(state, f"p{seat}", f"p{seat}")
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
self.assertEqual(4, len(state.table))
|
||||
for player in state.players:
|
||||
self.assertEqual(3, len(player.hand))
|
||||
# 40 - 4 on the table - 12 in hand.
|
||||
self.assertEqual(24, len(state.deck))
|
||||
self.assertEqual(1, state.turn)
|
||||
for seat in range(4):
|
||||
self.assertEqual(seat % 2, engine._player_at(state, seat).team)
|
||||
|
||||
def test_join_errors(self) -> None:
|
||||
state = engine.create_game("p0", "p0")
|
||||
with self.assertRaises(AlreadyJoined):
|
||||
engine.join_game(state, "p0", "p0")
|
||||
engine.join_game(state, "p1", "p1")
|
||||
# The lobby filled up and the match started.
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.join_game(state, "p2", "p2")
|
||||
|
||||
def test_start_requires_full_table(self) -> None:
|
||||
state = engine.create_game("p0", "p0", player_count=4)
|
||||
engine.join_game(state, "p1", "p1")
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.start_game(state)
|
||||
|
||||
|
||||
class RoundTest(unittest.TestCase):
|
||||
def test_redeal_when_hands_empty_and_deck_has_cards(self) -> None:
|
||||
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
|
||||
for suit in ("D", "C")]
|
||||
state = make_state([["02D"], ["03D"]], table=["09C"],
|
||||
deck=deck, turn=0, dealer=0)
|
||||
engine.play(state, "p0", "02D")
|
||||
self.assertEqual(1, state.turn)
|
||||
# The last card of the round: the next three cards are dealt.
|
||||
engine.play(state, "p1", "03D")
|
||||
self.assertEqual(PHASE_PLAYING, state.phase)
|
||||
for player in state.players:
|
||||
self.assertEqual(3, len(player.hand))
|
||||
self.assertEqual([], state.deck)
|
||||
# The table is never topped up.
|
||||
self.assertEqual(
|
||||
{"09C", "02D", "03D"}, {c.code for c in state.table}
|
||||
)
|
||||
# The player after the dealer leads every round again.
|
||||
self.assertEqual(1, state.turn)
|
||||
|
||||
def test_redeal_gives_the_lead_back_to_the_non_dealer(self) -> None:
|
||||
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
|
||||
for suit in ("D", "C")]
|
||||
state = make_state([["02D"], ["03D"]], table=[], deck=deck,
|
||||
turn=0, dealer=1)
|
||||
engine.play(state, "p0", "02D")
|
||||
engine.play(state, "p1", "03D")
|
||||
# Dealer seat 1: the lead is seat 0 again.
|
||||
self.assertEqual(0, state.turn)
|
||||
|
||||
def test_hand_ends_when_deck_is_exhausted(self) -> None:
|
||||
state = make_state([["02D"], ["03D"]], table=[], deck=[], target=100)
|
||||
engine.play(state, "p0", "02D")
|
||||
engine.play(state, "p1", "03D")
|
||||
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||
|
||||
|
||||
class ScopaTest(unittest.TestCase):
|
||||
def test_scopa_on_the_last_play_of_a_round_with_deck_left(self) -> None:
|
||||
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
|
||||
for suit in ("D", "C")]
|
||||
state = make_state([["02D"], []], table=["02C"], deck=deck, turn=0)
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
# Cards remain in the deck: the sweep is a scopa, and the round is
|
||||
# re-dealt immediately.
|
||||
self.assertEqual(1, state.players[0].scope)
|
||||
assert state.last_move is not None
|
||||
self.assertTrue(state.last_move.scopa)
|
||||
for player in state.players:
|
||||
self.assertEqual(3, len(player.hand))
|
||||
|
||||
def test_no_scopa_on_the_last_play_of_the_hand(self) -> None:
|
||||
state = make_state([["02D"], []], table=["02C"], deck=[],
|
||||
turn=0, target=100)
|
||||
engine.play(state, "p0", "02D", ["02C"])
|
||||
self.assertEqual(0, state.players[0].scope)
|
||||
assert state.last_move is not None
|
||||
self.assertFalse(state.last_move.scopa)
|
||||
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
|
||||
|
||||
|
||||
class AssoPigliatuttoTest(unittest.TestCase):
|
||||
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
|
||||
# p1 still holds cards, so emptying the table would normally score
|
||||
# a scopa: the sweep must suppress it.
|
||||
state = make_state(
|
||||
[["01D", "09C"], ["04D"]],
|
||||
table=["05C", "02D", "03S"],
|
||||
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:
|
||||
state = make_state(
|
||||
[["01D"], ["04D"]],
|
||||
table=["01C", "02D"],
|
||||
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"]],
|
||||
table=["01C", "02D"],
|
||||
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"]], 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"]], table=[])
|
||||
engine.play(state, "p0", "01D")
|
||||
self.assertEqual(["01D"], [c.code for c in state.table])
|
||||
self.assertEqual([], state.players[0].captured)
|
||||
|
||||
def test_ace_on_empty_table_captures_itself(self) -> None:
|
||||
# The self-capture makes p0 the last taker and scores no scopa.
|
||||
state = make_state(
|
||||
[["01D", "09C"], ["04D"]],
|
||||
table=[],
|
||||
last_taker=1,
|
||||
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"]], table=[], 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)
|
||||
|
||||
|
||||
class HandEndTest(unittest.TestCase):
|
||||
def test_table_swept_to_last_taker(self) -> None:
|
||||
state = make_state([["02D"], []], table=["05C", "04D"],
|
||||
last_taker=1, deck=[], target=100)
|
||||
engine.play(state, "p0", "02D")
|
||||
captured = {c.code for c in state.players[1].captured}
|
||||
self.assertEqual({"05C", "04D", "02D"}, captured)
|
||||
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||
|
||||
def test_match_ends_at_target_with_clear_lead(self) -> None:
|
||||
state = make_state([["02D"], []], table=[], deck=[], turn=0,
|
||||
target=1, captured=[["07D"], []])
|
||||
engine.play(state, "p0", "02D")
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(0, state.winner)
|
||||
|
||||
def test_tie_at_target_plays_another_hand(self) -> None:
|
||||
state = make_state(
|
||||
[["02D"], []],
|
||||
table=[],
|
||||
deck=[],
|
||||
target=1,
|
||||
captured=[["01D", "02C"], ["02D", "01C"]],
|
||||
)
|
||||
state.scores = [1, 1]
|
||||
engine.play(state, "p0", "02D")
|
||||
# A 0-0 hand at the target is not a win: another hand is played.
|
||||
self.assertEqual(PHASE_HAND_END, state.phase)
|
||||
self.assertIsNone(state.winner)
|
||||
|
||||
def test_acknowledge_deals_next_hand(self) -> None:
|
||||
state = engine.create_game("p0", "p0")
|
||||
engine.join_game(state, "p1", "p1")
|
||||
state.phase = PHASE_HAND_END
|
||||
engine.acknowledge_hand(state, "p0")
|
||||
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(1, state.dealer)
|
||||
self.assertEqual(4, len(state.table))
|
||||
for player in state.players:
|
||||
self.assertEqual(3, len(player.hand))
|
||||
|
||||
def test_acknowledge_rejects_outside_hand_end(self) -> None:
|
||||
state = engine.create_game("p0", "p0")
|
||||
engine.join_game(state, "p1", "p1")
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.acknowledge_hand(state, "p0")
|
||||
|
||||
def test_hand_summary_has_no_napola(self) -> None:
|
||||
state = make_state([["02D"], []], table=[], deck=[], target=1,
|
||||
captured=[["07D"], []])
|
||||
engine.play(state, "p0", "02D")
|
||||
self.assertNotIn("napola", state.hand_scores[-1])
|
||||
|
||||
|
||||
class ScoringTest(unittest.TestCase):
|
||||
def test_primiera_values_and_all_suits_requirement(self) -> None:
|
||||
self.assertEqual(70, engine.primiera_score(
|
||||
[card(c) for c in ["07D", "06C", "01S", "05B"]]))
|
||||
self.assertEqual(0, engine.primiera_score(
|
||||
[card(c) for c in ["07D", "06C", "01S"]]))
|
||||
self.assertEqual(40, engine.primiera_score(
|
||||
[card(c) for c in ["08D", "09C", "10S", "10B"]]))
|
||||
|
||||
def test_two_player_hand_points(self) -> None:
|
||||
state = make_state(
|
||||
[[], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["07D", "06C", "01S", "05B"],
|
||||
["03D", "04C", "07S", "02B"],
|
||||
],
|
||||
scope=[1, 0],
|
||||
)
|
||||
points, details = engine.hand_points(state)
|
||||
# Carte and denara tie; A takes settebello and primiera, plus one
|
||||
# scopa: A+3.
|
||||
self.assertEqual({"A": 4, "B": 4}, details["cards"])
|
||||
self.assertEqual({"A": 1, "B": 1}, details["denara"])
|
||||
self.assertEqual({"A": True, "B": False}, details["settebello"])
|
||||
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
|
||||
self.assertEqual({"A": 1, "B": 0}, details["scope"])
|
||||
self.assertEqual([3, 0], points)
|
||||
|
||||
def test_ties_award_nothing(self) -> None:
|
||||
state = make_state(
|
||||
[[], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["06C", "01S", "05B", "02D"],
|
||||
["06S", "01B", "05D", "02C"],
|
||||
],
|
||||
scope=[0, 0],
|
||||
)
|
||||
points, _ = engine.hand_points(state)
|
||||
# Equal cards, equal denara, equal primiera and no settebello:
|
||||
# everything ties, so no points at all.
|
||||
self.assertEqual([0, 0], points)
|
||||
|
||||
def test_four_player_pools_the_partners(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["07D", "06C", "01S", "05B"], # seat 0, team A
|
||||
["03D", "04C", "07S", "02B"], # seat 1, team B
|
||||
["02D"], # seat 2, team A
|
||||
["10D", "10C", "10S", "10B"], # seat 3, team B
|
||||
],
|
||||
scope=[1, 0, 0, 2],
|
||||
)
|
||||
points, details = engine.hand_points(state)
|
||||
self.assertEqual([3, 3], points)
|
||||
self.assertEqual({"A": 5, "B": 8}, details["cards"])
|
||||
self.assertEqual({"A": 2, "B": 2}, details["denara"])
|
||||
self.assertEqual({"A": True, "B": False}, details["settebello"])
|
||||
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
|
||||
self.assertEqual({"A": 1, "B": 2}, details["scope"])
|
||||
self.assertNotIn("napola", details)
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
state = engine.create_game(
|
||||
"p0", "p0", player_count=4, target_score=16,
|
||||
asso_pigliatutto=True,
|
||||
)
|
||||
for seat in range(1, 4):
|
||||
engine.join_game(state, f"p{seat}", f"p{seat}")
|
||||
restored = ScopaState.from_json(state.to_json())
|
||||
self.assertEqual(4, restored.player_count)
|
||||
self.assertEqual(16, restored.target_score)
|
||||
self.assertTrue(restored.asso_pigliatutto)
|
||||
self.assertEqual(
|
||||
[c.code for c in state.deck], [c.code for c in restored.deck]
|
||||
)
|
||||
self.assertEqual(
|
||||
[c.code for c in state.table], [c.code for c in restored.table]
|
||||
)
|
||||
self.assertEqual(state.phase, restored.phase)
|
||||
self.assertEqual(state.turn, restored.turn)
|
||||
self.assertEqual(
|
||||
[p.sub for p in state.players], [p.sub for p in restored.players]
|
||||
)
|
||||
|
||||
def test_asso_pigliatutto_defaults_off(self) -> None:
|
||||
state = make_state([["02D"], ["03D"]], table=[])
|
||||
data = state.to_json()
|
||||
del data["asso_pigliatutto"]
|
||||
self.assertFalse(ScopaState.from_json(data).asso_pigliatutto)
|
||||
|
||||
def test_player_count_defaults_to_two(self) -> None:
|
||||
data = ScopaState().to_json()
|
||||
del data["player_count"]
|
||||
self.assertEqual(2, ScopaState.from_json(data).player_count)
|
||||
|
||||
|
||||
class AutoPlayTest(unittest.TestCase):
|
||||
def test_auto_play_applies_a_legal_move(self) -> None:
|
||||
state = engine.create_game("p0", "p0")
|
||||
engine.join_game(state, "p1", "p1")
|
||||
rng = random.Random(7)
|
||||
player = engine._player_at(state, state.turn)
|
||||
hand_size = len(player.hand)
|
||||
engine.auto_play(state, rng)
|
||||
self.assertEqual(hand_size - 1, len(player.hand))
|
||||
assert state.last_move is not None
|
||||
self.assertEqual(player.seat, state.last_move.seat)
|
||||
|
||||
|
||||
class FullMatchTest(unittest.TestCase):
|
||||
def _play_out(self, player_count: int, target: int) -> ScopaState:
|
||||
state = engine.create_game("p0", "p0", player_count=player_count,
|
||||
target_score=target)
|
||||
for seat in range(1, player_count):
|
||||
engine.join_game(state, f"p{seat}", f"p{seat}")
|
||||
rng = random.Random(1234 + player_count)
|
||||
steps = 0
|
||||
while state.phase != PHASE_FINISHED and steps < 100_000:
|
||||
if state.phase == PHASE_HAND_END:
|
||||
for player in state.players:
|
||||
engine.acknowledge_hand(state, player.sub)
|
||||
continue
|
||||
engine.auto_play(state, rng)
|
||||
steps += 1
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertIsNotNone(state.winner)
|
||||
self.assertGreaterEqual(max(state.scores), target)
|
||||
self.assertNotEqual(state.scores[0], state.scores[1])
|
||||
return state
|
||||
|
||||
def test_random_two_player_match_reaches_a_winner(self) -> None:
|
||||
state = self._play_out(2, 5)
|
||||
for hand in state.hand_scores:
|
||||
self.assertIn("cards", hand)
|
||||
self.assertIn("scope", hand)
|
||||
|
||||
def test_random_four_player_match_reaches_a_winner(self) -> None:
|
||||
state = self._play_out(4, 5)
|
||||
self.assertEqual(4, len(state.players))
|
||||
|
||||
def test_all_cards_are_captured_by_the_end_of_a_hand(self) -> None:
|
||||
state = engine.create_game("p0", "p0")
|
||||
engine.join_game(state, "p1", "p1")
|
||||
rng = random.Random(99)
|
||||
# Finish exactly one hand (target 100 keeps the match alive).
|
||||
state.target_score = 100
|
||||
while state.phase != PHASE_HAND_END:
|
||||
engine.auto_play(state, rng)
|
||||
self.assertIsNotNone(state.last_taker)
|
||||
self.assertEqual(
|
||||
40, sum(len(p.captured) for p in state.players)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Adapter tests: the platform contract over the pure scopa 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.scopa import ScopaEngine, ScopaQuattroEngine
|
||||
from tavolo.scopa import engine as rules
|
||||
from tavolo.scopa.state import (
|
||||
PHASE_FINISHED,
|
||||
PHASE_HAND_END,
|
||||
PHASE_PLAYING,
|
||||
ScopaState,
|
||||
)
|
||||
|
||||
|
||||
def _session(engine, **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, **options) -> GameSession:
|
||||
session = _session(engine, **options)
|
||||
for seat in range(1, engine.max_players):
|
||||
engine.join(session, f"p{seat}", f"P{seat}")
|
||||
return session
|
||||
|
||||
|
||||
def _drive_to_end(engine, session: GameSession) -> None:
|
||||
"""Play the match out with the first legal move each turn."""
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200_000:
|
||||
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)
|
||||
played = player.hand[0]
|
||||
options = rules.legal_captures(
|
||||
state.table, played, asso_pigliatutto=state.asso_pigliatutto
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class CreateTest(unittest.TestCase):
|
||||
def test_create_seats_creator_on_side_a(self) -> None:
|
||||
for engine_type, target in ((ScopaEngine, 11), (ScopaQuattroEngine, 21)):
|
||||
engine = engine_type()
|
||||
session = _session(engine)
|
||||
self.assertEqual("A", session.players[0].team)
|
||||
self.assertIsInstance(session.state, ScopaState)
|
||||
# Two-player scopa is played to 11, the partnership game to 21.
|
||||
self.assertEqual(target, session.state.target_score)
|
||||
self.assertFalse(session.state.asso_pigliatutto)
|
||||
|
||||
def test_schema_defaults_follow_the_variant(self) -> None:
|
||||
self.assertEqual(
|
||||
11, ScopaEngine().options_schema["properties"]["target_score"]["default"]
|
||||
)
|
||||
self.assertEqual(
|
||||
21,
|
||||
ScopaQuattroEngine().options_schema["properties"]["target_score"][
|
||||
"default"
|
||||
],
|
||||
)
|
||||
|
||||
def test_create_options(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
session = _session(engine, target_score=21, asso_pigliatutto=True)
|
||||
self.assertEqual(21, session.state.target_score)
|
||||
self.assertTrue(session.state.asso_pigliatutto)
|
||||
|
||||
def test_create_rejects_bad_options(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, target_score=0)
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, target_score=101)
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, target_score="eleven")
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, target_score=True)
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, asso_pigliatutto="yes")
|
||||
|
||||
def test_timeouts_come_from_the_engine(self) -> None:
|
||||
engine = ScopaEngine(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_starts_two_player_game(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
session = _session(engine)
|
||||
self.assertTrue(engine.in_lobby(session))
|
||||
engine.join(session, "bob", "Bob")
|
||||
self.assertFalse(engine.in_lobby(session))
|
||||
self.assertEqual(PHASE_PLAYING, session.state.phase)
|
||||
self.assertEqual(["A", "B"], [p.team for p in session.players])
|
||||
|
||||
def test_join_starts_four_player_game_with_teams(self) -> None:
|
||||
engine = ScopaQuattroEngine()
|
||||
session = _session(engine)
|
||||
for seat, team in ((1, "B"), (2, "A"), (3, "B")):
|
||||
engine.join(session, f"p{seat}", f"P{seat}")
|
||||
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:
|
||||
engine = ScopaEngine()
|
||||
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")
|
||||
with self.assertRaises(GameNotStarted):
|
||||
engine.join(session, "alice", "Alice")
|
||||
|
||||
|
||||
class ActionTest(unittest.TestCase):
|
||||
def test_unknown_action_rejected(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "alice", "dance", {})
|
||||
|
||||
def test_play_validates_payload(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
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": "01D", "capture": "02C"}
|
||||
)
|
||||
|
||||
def test_invalid_card_code_is_illegal_move(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
session = _started(engine)
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.handle_action(session, "p1", "play", {"card": "nope"})
|
||||
|
||||
def test_finished_match_rejects_actions(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
session = _started(engine, target_score=1)
|
||||
_drive_to_end(engine, session)
|
||||
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_hides_other_hands_and_reports_the_deck(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
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])
|
||||
self.assertEqual(2, view["player_count"])
|
||||
self.assertEqual(30, view["deck_count"])
|
||||
self.assertEqual(4, len(view["table"]))
|
||||
# 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 = ScopaQuattroEngine()
|
||||
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(4, view["player_count"])
|
||||
|
||||
def test_lobby_view(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
session = _session(engine, target_score=16, asso_pigliatutto=True)
|
||||
lobby = engine.lobby_view(session)
|
||||
self.assertEqual("lobby", lobby["phase"])
|
||||
self.assertEqual(16, lobby["target_score"])
|
||||
self.assertTrue(lobby["asso_pigliatutto"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
def test_state_roundtrip(self) -> None:
|
||||
engine = ScopaQuattroEngine()
|
||||
session = _started(engine)
|
||||
restored = engine.state_from_json(engine.state_to_json(session.state))
|
||||
self.assertIsInstance(restored, ScopaState)
|
||||
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],
|
||||
)
|
||||
self.assertEqual(
|
||||
[c.code for c in session.state.deck],
|
||||
[c.code for c in restored.deck],
|
||||
)
|
||||
|
||||
|
||||
class DeadlineTest(unittest.TestCase):
|
||||
def test_no_deadline_in_lobby(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
self.assertIsNone(engine.next_deadline(_session(engine)))
|
||||
|
||||
def test_turn_deadline_and_revalidation(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
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:
|
||||
engine = ScopaEngine()
|
||||
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)
|
||||
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 = ScopaEngine()
|
||||
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_two_player_match(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
session = _started(engine, target_score=1)
|
||||
_drive_to_end(engine, session)
|
||||
result = engine.result(session)
|
||||
self.assertEqual(2, len(result.teams))
|
||||
self.assertEqual(1, len(result.teams[0]))
|
||||
self.assertEqual(1, len(result.teams[1]))
|
||||
self.assertIn(result.winner_team, (0, 1))
|
||||
self.assertEqual(2, 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)
|
||||
self.assertIn("asso_pigliatutto", result.summary)
|
||||
|
||||
def test_result_of_finished_four_player_match(self) -> None:
|
||||
engine = ScopaQuattroEngine()
|
||||
session = _started(engine, target_score=1)
|
||||
_drive_to_end(engine, session)
|
||||
result = engine.result(session)
|
||||
self.assertEqual([2, 2], [len(team) for team in result.teams])
|
||||
self.assertEqual(4, len(result.players))
|
||||
|
||||
def test_result_requires_finished_match(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
with self.assertRaises(GameError):
|
||||
engine.result(_started(engine))
|
||||
|
||||
def test_game_over_view(self) -> None:
|
||||
engine = ScopaEngine()
|
||||
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:
|
||||
two = ScopaEngine()
|
||||
self.assertEqual("scopa", two.id)
|
||||
self.assertEqual("Scopa", two.name)
|
||||
self.assertEqual(2, two.min_players)
|
||||
self.assertEqual(2, two.max_players)
|
||||
four = ScopaQuattroEngine()
|
||||
self.assertEqual("scopa_a_quattro", four.id)
|
||||
self.assertEqual("Scopa a quattro", four.name)
|
||||
self.assertEqual(4, four.min_players)
|
||||
self.assertEqual(4, four.max_players)
|
||||
for engine in (two, four):
|
||||
self.assertIn("target_score", engine.options_schema["properties"])
|
||||
self.assertIn(
|
||||
"asso_pigliatutto", engine.options_schema["properties"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,15 +11,18 @@ implementation.
|
||||
(de)serialization. No session envelope, no transport, no I/O.
|
||||
- `engine.py` — the pure rules engine: deck, legal captures, plays,
|
||||
auto-play, hand scoring (carte, denara, settebello, primiera, scope,
|
||||
napola), hand-end acknowledgements. Deterministic and I/O-free apart
|
||||
from logging, so the whole rule set is unit-testable.
|
||||
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` creation options,
|
||||
and extracts the `MatchResult` (teams, winner, per-player scores and the
|
||||
match summary persisted as the match's JSON `result`).
|
||||
calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
|
||||
creation options (the match target defaults to the traditional 21
|
||||
points), 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
|
||||
@@ -28,8 +31,8 @@ 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
|
||||
uv run python -m unittest discover -s packages/tavolo-scopone/tests
|
||||
uv run mypy -p tavolo.scopone
|
||||
```
|
||||
|
||||
`test_engine.py` covers the pure rules; `test_plugin.py` covers the
|
||||
|
||||
@@ -28,6 +28,12 @@ Rules implemented
|
||||
consecutive denari starting from the ace scores one point per card when
|
||||
it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
|
||||
capturing the whole denari suit (ace to king) wins the match instantly.
|
||||
* Optional ``asso_pigliatutto`` rule (disabled by default): an ace played
|
||||
when no equal rank is on the table sweeps the whole table — or captures
|
||||
itself, leaving the table empty, when the table is bare. The sweep never
|
||||
scores a scopa, even when cards remain in hand, but it counts as a
|
||||
capture for the last-taker rule. When another ace is on the table the
|
||||
mandatory equal-rank capture still applies.
|
||||
* The match ends when a team reaches the target score with a clear lead; a
|
||||
tie at or above the target is broken by playing another hand.
|
||||
"""
|
||||
@@ -110,17 +116,23 @@ def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
|
||||
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``.
|
||||
|
||||
If an equal-ranked card is on the table, only those single-card
|
||||
captures are returned (the rule forbids taking a combination instead).
|
||||
Otherwise every subset of the table whose ranks sum to ``card.rank`` is
|
||||
returned.
|
||||
Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
|
||||
the only capture is the whole table — or an empty capture when the
|
||||
table is bare, the ace taking itself. Without the option every subset
|
||||
of the table whose ranks sum to ``card.rank`` is returned.
|
||||
"""
|
||||
equal = [c for c in table if c.rank == card.rank]
|
||||
if equal:
|
||||
return [[c] for c in equal]
|
||||
if asso_pigliatutto and card.rank == 1:
|
||||
return [list(table)]
|
||||
|
||||
candidates = [c for c in table if c.rank <= card.rank]
|
||||
captures: List[List[Card]] = []
|
||||
@@ -140,6 +152,7 @@ def create_game(
|
||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
napola: bool = True,
|
||||
asso_pigliatutto: bool = False,
|
||||
) -> ScoponeState:
|
||||
"""Create a lobby game with the creator seated first."""
|
||||
if target_score < 1 or target_score > 100:
|
||||
@@ -147,6 +160,7 @@ def create_game(
|
||||
return ScoponeState(
|
||||
target_score=target_score,
|
||||
napola=napola,
|
||||
asso_pigliatutto=asso_pigliatutto,
|
||||
phase=PHASE_LOBBY,
|
||||
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||
hand_ack_timeout=hand_ack_timeout,
|
||||
@@ -240,7 +254,18 @@ def play(
|
||||
player.hand.remove(card)
|
||||
|
||||
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] = []
|
||||
scopa = False
|
||||
@@ -260,7 +285,7 @@ def play(
|
||||
state.last_taker = player.seat
|
||||
# A scopa scores only if cards remain to be played this hand.
|
||||
hands_empty = all(not p.hand for p in state.players)
|
||||
if not state.table and not hands_empty:
|
||||
if not state.table and not hands_empty and not pigliatutto:
|
||||
player.scope += 1
|
||||
scopa = True
|
||||
|
||||
@@ -270,6 +295,7 @@ def play(
|
||||
card=card.code,
|
||||
captured=[c.code for c in taken],
|
||||
scopa=scopa,
|
||||
pigliatutto=pigliatutto,
|
||||
)
|
||||
|
||||
if all(not p.hand for p in state.players):
|
||||
@@ -282,10 +308,12 @@ def play(
|
||||
def _match_option(
|
||||
options: Sequence[Sequence[Card]], requested: Sequence[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)
|
||||
if not wanted:
|
||||
return None
|
||||
for option in options:
|
||||
if sorted(c.code for c in option) == wanted:
|
||||
return list(option)
|
||||
@@ -308,13 +336,15 @@ def auto_play(state: ScoponeState, rng: Optional[random.Random] = None) -> None:
|
||||
raise IllegalMove("the player on turn has no cards")
|
||||
chooser = rng or _rng
|
||||
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
|
||||
play(
|
||||
state,
|
||||
player.sub,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -498,13 +528,14 @@ def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
|
||||
return points, details
|
||||
|
||||
|
||||
def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
|
||||
def state_for_player(state: ScoponeState, 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 simply see no hand at all. The platform
|
||||
merges the session envelope (``id``, ``join_code``, ``game_type``)
|
||||
into the view itself.
|
||||
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]] = []
|
||||
@@ -526,6 +557,7 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
|
||||
"phase": state.phase,
|
||||
"target_score": state.target_score,
|
||||
"napola": state.napola,
|
||||
"asso_pigliatutto": state.asso_pigliatutto,
|
||||
"hand_number": state.hand_number,
|
||||
"dealer": state.dealer,
|
||||
"turn": state.turn,
|
||||
@@ -545,7 +577,9 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
|
||||
# rule logic stays server-side.
|
||||
legal_moves: Dict[str, List[List[str]]] = {}
|
||||
for hand_card in viewer.hand:
|
||||
options = legal_captures(state.table, hand_card)
|
||||
options = legal_captures(
|
||||
state.table, hand_card, asso_pigliatutto=state.asso_pigliatutto
|
||||
)
|
||||
if options:
|
||||
legal_moves[hand_card.code] = [
|
||||
[c.code for c in option] for option in options
|
||||
|
||||
@@ -75,6 +75,12 @@ class ScoponeEngine(GameEngine):
|
||||
"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)",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -95,6 +101,9 @@ class ScoponeEngine(GameEngine):
|
||||
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,
|
||||
@@ -103,6 +112,7 @@ class ScoponeEngine(GameEngine):
|
||||
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(
|
||||
@@ -133,6 +143,7 @@ class ScoponeEngine(GameEngine):
|
||||
"phase": state.phase,
|
||||
"target_score": state.target_score,
|
||||
"napola": state.napola,
|
||||
"asso_pigliatutto": state.asso_pigliatutto,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
@@ -160,13 +171,13 @@ class ScoponeEngine(GameEngine):
|
||||
else:
|
||||
raise IllegalMove(f"unknown action: {action!r}")
|
||||
|
||||
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
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: str) -> Dict[str, Any]:
|
||||
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]},
|
||||
|
||||
@@ -37,7 +37,9 @@ PHASE_PLAYING = "playing"
|
||||
PHASE_HAND_END = "hand_end"
|
||||
PHASE_FINISHED = "finished"
|
||||
|
||||
DEFAULT_TARGET_SCORE = 11
|
||||
# The traditional match target for scopone scientifico: the partnership
|
||||
# game is played to 21 points.
|
||||
DEFAULT_TARGET_SCORE = 21
|
||||
|
||||
|
||||
def team_of(seat: int) -> int:
|
||||
@@ -92,6 +94,9 @@ class Move:
|
||||
card: str
|
||||
captured: List[str] = field(default_factory=list)
|
||||
scopa: bool = False
|
||||
# The play was an asso pigliatutto capture (the whole table, or the
|
||||
# ace itself when the table was empty).
|
||||
pigliatutto: bool = False
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -100,6 +105,7 @@ class Move:
|
||||
"card": self.card,
|
||||
"captured": list(self.captured),
|
||||
"scopa": self.scopa,
|
||||
"pigliatutto": self.pigliatutto,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -110,6 +116,7 @@ class Move:
|
||||
card=str(data["card"]),
|
||||
captured=[str(c) for c in data.get("captured", [])],
|
||||
scopa=bool(data.get("scopa", False)),
|
||||
pigliatutto=bool(data.get("pigliatutto", False)),
|
||||
)
|
||||
|
||||
|
||||
@@ -154,6 +161,11 @@ class ScoponeState:
|
||||
# Whether the napola rule is scored (denari run from the ace; a full
|
||||
# suit wins the match instantly). Default on.
|
||||
napola: bool = True
|
||||
# Whether the asso pigliatutto rule is scored (an ace sweeps the whole
|
||||
# table when no equal rank is on it, or captures itself when the table
|
||||
# is empty; the capture never scores a scopa). Default off, also for
|
||||
# states serialized before the option existed.
|
||||
asso_pigliatutto: bool = False
|
||||
phase: str = PHASE_LOBBY
|
||||
players: List[PlayerState] = field(default_factory=list)
|
||||
table: List[Card] = field(default_factory=list)
|
||||
@@ -184,6 +196,7 @@ class ScoponeState:
|
||||
return {
|
||||
"target_score": self.target_score,
|
||||
"napola": self.napola,
|
||||
"asso_pigliatutto": self.asso_pigliatutto,
|
||||
"phase": self.phase,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"table": [c.to_json() for c in self.table],
|
||||
@@ -207,6 +220,7 @@ class ScoponeState:
|
||||
return ScoponeState(
|
||||
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||
napola=bool(data.get("napola", True)),
|
||||
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
|
||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||
table=[Card.from_json(c) for c in data.get("table", [])],
|
||||
@@ -227,7 +241,7 @@ class ScoponeState:
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def player_for(self, sub: str) -> Optional[PlayerState]:
|
||||
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
|
||||
for player in self.players:
|
||||
if player.sub == sub:
|
||||
return player
|
||||
|
||||
@@ -325,6 +325,221 @@ class NapolaTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class AssoPigliatuttoTest(unittest.TestCase):
|
||||
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
|
||||
# The other players still hold cards, so emptying the table would
|
||||
# normally score a scopa: the sweep must suppress it.
|
||||
state = make_state(
|
||||
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["05C", "02D", "03S"],
|
||||
)
|
||||
state.asso_pigliatutto = True
|
||||
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
|
||||
self.assertEqual([], state.table)
|
||||
self.assertEqual(
|
||||
{"05C", "02D", "03S", "01D"},
|
||||
{c.code for c in state.players[0].captured},
|
||||
)
|
||||
self.assertEqual(0, state.players[0].scope)
|
||||
assert state.last_move is not None
|
||||
self.assertTrue(state.last_move.pigliatutto)
|
||||
self.assertFalse(state.last_move.scopa)
|
||||
|
||||
def test_legal_captures_offer_only_the_whole_table(self) -> None:
|
||||
table = [card("05C"), card("02D"), card("03S")]
|
||||
options = engine.legal_captures(
|
||||
table, card("01D"), asso_pigliatutto=True
|
||||
)
|
||||
self.assertEqual(
|
||||
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
|
||||
)
|
||||
# A bare table leaves only the self-capture option; without the
|
||||
# rule the ace stays on the table.
|
||||
self.assertEqual(
|
||||
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
|
||||
)
|
||||
self.assertEqual(
|
||||
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
|
||||
)
|
||||
# Non-ace cards keep the normal rules.
|
||||
self.assertEqual(
|
||||
[],
|
||||
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
|
||||
)
|
||||
|
||||
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
|
||||
table = ["01C", "02D"]
|
||||
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
|
||||
state.asso_pigliatutto = True
|
||||
options = engine.legal_captures(
|
||||
state.table, card("01D"), asso_pigliatutto=True
|
||||
)
|
||||
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
|
||||
with self.assertRaises(IllegalMove):
|
||||
engine.play(state, "p0", "01D", ["01C", "02D"])
|
||||
# A rejected play consumes the card, so retry on a fresh state.
|
||||
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
|
||||
state.asso_pigliatutto = True
|
||||
engine.play(state, "p0", "01D", ["01C"])
|
||||
self.assertEqual(["02D"], [c.code for c in state.table])
|
||||
assert state.last_move is not None
|
||||
self.assertFalse(state.last_move.pigliatutto)
|
||||
|
||||
def test_disabled_by_default(self) -> None:
|
||||
state = make_state(
|
||||
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["05C", "02D"],
|
||||
)
|
||||
engine.play(state, "p0", "01D")
|
||||
# Without the rule an ace captures nothing here and stays on the
|
||||
# table.
|
||||
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
|
||||
assert state.last_move is not None
|
||||
self.assertFalse(state.last_move.pigliatutto)
|
||||
# ... and on a bare table it stays there too.
|
||||
state = make_state(
|
||||
[["01D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
|
||||
)
|
||||
engine.play(state, "p0", "01D")
|
||||
self.assertEqual(["01D"], [c.code for c in state.table])
|
||||
self.assertEqual([], state.players[0].captured)
|
||||
self.assertEqual(
|
||||
[],
|
||||
engine.legal_captures(
|
||||
[card("05C")], card("01D"), asso_pigliatutto=False
|
||||
),
|
||||
)
|
||||
|
||||
def test_ace_on_empty_table_captures_itself(self) -> None:
|
||||
# Other players still hold cards, so the bare table would trigger a
|
||||
# (nonsensical) scopa without the pigliatutto suppression. The
|
||||
# self-capture makes p0 the last taker, replacing seat 1.
|
||||
state = make_state(
|
||||
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=[],
|
||||
last_taker=1,
|
||||
)
|
||||
state.asso_pigliatutto = True
|
||||
engine.play(state, "p0", "01D")
|
||||
self.assertEqual([], state.table)
|
||||
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
|
||||
self.assertEqual(0, state.players[0].scope)
|
||||
self.assertEqual(0, state.last_taker)
|
||||
assert state.last_move is not None
|
||||
self.assertTrue(state.last_move.pigliatutto)
|
||||
self.assertEqual([], state.last_move.captured)
|
||||
self.assertFalse(state.last_move.scopa)
|
||||
|
||||
def test_non_ace_on_empty_table_stays(self) -> None:
|
||||
state = make_state(
|
||||
[["02D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
|
||||
)
|
||||
state.asso_pigliatutto = True
|
||||
engine.play(state, "p0", "02D")
|
||||
self.assertEqual(["02D"], [c.code for c in state.table])
|
||||
self.assertEqual([], state.players[0].captured)
|
||||
assert state.last_move is not None
|
||||
self.assertFalse(state.last_move.pigliatutto)
|
||||
|
||||
def test_move_pigliatutto_serialization(self) -> None:
|
||||
move = engine.Move(
|
||||
seat=0, name="p0", card="01D", captured=["05C"], pigliatutto=True
|
||||
)
|
||||
restored = engine.Move.from_json(move.to_json())
|
||||
self.assertTrue(restored.pigliatutto)
|
||||
legacy = move.to_json()
|
||||
del legacy["pigliatutto"]
|
||||
self.assertFalse(engine.Move.from_json(legacy).pigliatutto)
|
||||
|
||||
def test_state_serialization_roundtrip(self) -> None:
|
||||
state = make_state([["02D"], [], [], []], table=[])
|
||||
self.assertFalse(state.asso_pigliatutto)
|
||||
state.asso_pigliatutto = True
|
||||
self.assertTrue(
|
||||
ScoponeState.from_json(state.to_json()).asso_pigliatutto
|
||||
)
|
||||
# States serialized before the option existed default to disabled.
|
||||
data = state.to_json()
|
||||
del data["asso_pigliatutto"]
|
||||
self.assertFalse(ScoponeState.from_json(data).asso_pigliatutto)
|
||||
|
||||
def test_create_game_default_and_override(self) -> None:
|
||||
self.assertFalse(engine.create_game("p0", "p0").asso_pigliatutto)
|
||||
self.assertTrue(
|
||||
engine.create_game("p0", "p0", asso_pigliatutto=True).asso_pigliatutto
|
||||
)
|
||||
|
||||
def test_state_for_player_exposes_rule_and_legal_moves(self) -> None:
|
||||
state = make_state(
|
||||
[["01D"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["05C", "02D", "03S"],
|
||||
)
|
||||
state.asso_pigliatutto = True
|
||||
view = engine.state_for_player(state, "p0")
|
||||
self.assertTrue(view["asso_pigliatutto"])
|
||||
self.assertEqual(
|
||||
{"01D": [["05C", "02D", "03S"]]}, view["legal_moves"]
|
||||
)
|
||||
# On a bare table the ace advertises the self-capture.
|
||||
state = make_state(
|
||||
[["01D"], ["04D"], ["05D"], ["06D"]], table=[]
|
||||
)
|
||||
state.asso_pigliatutto = True
|
||||
view = engine.state_for_player(state, "p0")
|
||||
self.assertEqual({"01D": [[]]}, view["legal_moves"])
|
||||
|
||||
def test_auto_play_sweeps_with_the_rule(self) -> None:
|
||||
state = make_state(
|
||||
[["01D"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["05C", "02D"],
|
||||
)
|
||||
state.asso_pigliatutto = True
|
||||
engine.auto_play(state)
|
||||
assert state.last_move is not None
|
||||
self.assertTrue(state.last_move.pigliatutto)
|
||||
self.assertEqual(["05C", "02D"], state.last_move.captured)
|
||||
|
||||
def test_auto_play_self_captures_on_bare_table(self) -> None:
|
||||
state = make_state(
|
||||
[["01D"], ["04D"], ["05D"], ["06D"]], table=[]
|
||||
)
|
||||
state.asso_pigliatutto = True
|
||||
engine.auto_play(state)
|
||||
assert state.last_move is not None
|
||||
self.assertTrue(state.last_move.pigliatutto)
|
||||
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
|
||||
self.assertEqual([], state.table)
|
||||
|
||||
def test_full_random_match_with_rule_keeps_all_cards(self) -> None:
|
||||
state = engine.create_game(
|
||||
"p0", "p0", target_score=11, asso_pigliatutto=True
|
||||
)
|
||||
for i in range(1, 4):
|
||||
engine.join_game(state, f"p{i}", f"p{i}")
|
||||
|
||||
moves = 0
|
||||
while state.phase != PHASE_FINISHED and moves < 200000:
|
||||
if state.phase == "hand_end":
|
||||
for p in state.players:
|
||||
engine.acknowledge_hand(state, p.sub)
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = engine.legal_captures(
|
||||
state.table, played, asso_pigliatutto=state.asso_pigliatutto
|
||||
)
|
||||
capture = [c.code for c in options[0]] if options else None
|
||||
engine.play(state, player.sub, played.code, capture)
|
||||
moves += 1
|
||||
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertIn(state.winner, (0, 1))
|
||||
# Self-captures included, all 40 cards end up in capture piles.
|
||||
self.assertEqual([], state.table)
|
||||
self.assertTrue(all(not p.hand for p in state.players))
|
||||
self.assertEqual(40, sum(len(p.captured) for p in state.players))
|
||||
|
||||
|
||||
class MatchFlowTest(unittest.TestCase):
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
state = engine.create_game("p0", "p0", target_score=11)
|
||||
@@ -359,6 +574,27 @@ class MatchFlowTest(unittest.TestCase):
|
||||
self.assertEqual(["07C"], view["table"])
|
||||
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:
|
||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||
table=["07C"])
|
||||
|
||||
@@ -35,7 +35,8 @@ class CreateTest(unittest.TestCase):
|
||||
session = _session(engine)
|
||||
self.assertEqual("A", session.players[0].team)
|
||||
self.assertIsInstance(session.state, ScoponeState)
|
||||
self.assertEqual(11, session.state.target_score)
|
||||
# The traditional partnership target.
|
||||
self.assertEqual(21, session.state.target_score)
|
||||
self.assertTrue(session.state.napola)
|
||||
|
||||
def test_create_options(self) -> None:
|
||||
@@ -43,6 +44,12 @@ class CreateTest(unittest.TestCase):
|
||||
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()
|
||||
@@ -52,6 +59,8 @@ class CreateTest(unittest.TestCase):
|
||||
_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)
|
||||
@@ -152,6 +161,17 @@ class ViewTest(unittest.TestCase):
|
||||
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)
|
||||
@@ -159,6 +179,7 @@ class ViewTest(unittest.TestCase):
|
||||
self.assertEqual("lobby", lobby["phase"])
|
||||
self.assertEqual(16, lobby["target_score"])
|
||||
self.assertTrue(lobby["napola"])
|
||||
self.assertFalse(lobby["asso_pigliatutto"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
@@ -283,6 +304,7 @@ class ResultTest(unittest.TestCase):
|
||||
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__":
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
# NOTE: the tavolo-app distribution lives in packages/tavolo-app/ (same
|
||||
# kaya-style layout as tavolo-platform and tavolo-scopone). This file
|
||||
# exists only for tooling that reads configuration from the working
|
||||
# directory:
|
||||
# NOTE: the five tavolo distributions live in packages/ (same kaya-style
|
||||
# layout as tavolo-platform and tavolo-scopone). This file is the root of
|
||||
# the uv workspace and also holds tooling configuration that is read from
|
||||
# the working directory:
|
||||
# - uv locks and syncs the whole workspace from here;
|
||||
# - aerich reads [tool.aerich] from here (server/ locally, /app in the
|
||||
# db-migrate compose service), next to ./migrations;
|
||||
# - mypy reads [tool.mypy] from here when run from server/.
|
||||
# See the Migrations section in README.md.
|
||||
|
||||
[tool.uv]
|
||||
# The old pip-compile setup used --index-url for the private Gitea index
|
||||
# plus --extra-index-url for PyPI, so the best version across both indexes
|
||||
# won. Match that behavior with uv.
|
||||
index-strategy = "unsafe-best-match"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "gitea"
|
||||
url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple"
|
||||
default = true
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["packages/*"]
|
||||
|
||||
[tool.uv.sources]
|
||||
tavolo-platform = { workspace = true }
|
||||
tavolo-scopone = { workspace = true }
|
||||
tavolo-scopa = { workspace = true }
|
||||
tavolo-briscola = { workspace = true }
|
||||
|
||||
[tool.aerich]
|
||||
tortoise_orm = "tavolo.aerich_config.TORTOISE_ORM"
|
||||
location = "./migrations"
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.14
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=requirements.txt pyproject.toml
|
||||
#
|
||||
# NOTE: the tavolo-platform, tavolo-scopone and tavolo-app sibling packages
|
||||
# are NOT pinned here: they are installed from ./packages in the Dockerfile
|
||||
# and in development (pip install -e ./packages/tavolo-platform
|
||||
# -e ./packages/tavolo-scopone -e ./packages/tavolo-app). All of their
|
||||
# third-party dependencies are direct dependencies of tavolo-app, so this
|
||||
# lock covers the full closure.
|
||||
#
|
||||
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
|
||||
--extra-index-url https://pypi.org/simple
|
||||
|
||||
aerich==0.10.1
|
||||
# via tavolo-app (pyproject.toml)
|
||||
aiosqlite==0.22.1
|
||||
# via tortoise-orm
|
||||
anyio==4.15.1
|
||||
# via
|
||||
# aerich
|
||||
# httpx
|
||||
# tortoise-orm
|
||||
asyncclick==8.4.2.1
|
||||
# via aerich
|
||||
asyncpg==0.31.0
|
||||
# via tavolo-app (pyproject.toml)
|
||||
certifi==2026.7.22
|
||||
# via
|
||||
# httpcore
|
||||
# httpx
|
||||
cffi==2.1.1
|
||||
# via cryptography
|
||||
click==8.5.0
|
||||
# via granian
|
||||
cryptography==50.0.1
|
||||
# via pyjwt
|
||||
dictdiffer==0.10.0
|
||||
# via aerich
|
||||
granian==2.8.3
|
||||
# via
|
||||
# kaya-rsgi
|
||||
# tavolo-app (pyproject.toml)
|
||||
h11==0.16.0
|
||||
# via httpcore
|
||||
httpcore==1.0.9
|
||||
# via httpx
|
||||
httpx==0.28.1
|
||||
# via
|
||||
# kaya-oidc
|
||||
# tavolo-app (pyproject.toml)
|
||||
idna==3.20
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
iso8601==2.1.0
|
||||
# via tortoise-orm
|
||||
kaya-core==0.0.4
|
||||
# via
|
||||
# kaya-cors
|
||||
# kaya-oidc
|
||||
# kaya-openapi
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# tavolo-app (pyproject.toml)
|
||||
kaya-cors==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-oidc==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-openapi==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-rsgi==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-session==0.0.4
|
||||
# via
|
||||
# kaya-oidc
|
||||
# kaya-session-redis
|
||||
# tavolo-app (pyproject.toml)
|
||||
kaya-session-redis==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
pwo==0.1.2
|
||||
# via
|
||||
# kaya-core
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# tavolo-app (pyproject.toml)
|
||||
pycparser==3.0
|
||||
# via cffi
|
||||
pyjwt[crypto]==2.14.0
|
||||
# via
|
||||
# kaya-oidc
|
||||
# tavolo-app (pyproject.toml)
|
||||
pypika-tortoise==0.6.5
|
||||
# via tortoise-orm
|
||||
pyyaml==6.0.3
|
||||
# via tavolo-app (pyproject.toml)
|
||||
redis==8.1.0
|
||||
# via
|
||||
# kaya-session-redis
|
||||
# tavolo-app (pyproject.toml)
|
||||
tortoise-orm==1.1.8
|
||||
# via aerich
|
||||
typing-extensions==4.16.0
|
||||
# via
|
||||
# anyio
|
||||
# kaya-core
|
||||
# pwo
|
||||
@@ -10,8 +10,9 @@ from __future__ import annotations
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://:memory:")
|
||||
os.environ.setdefault("OIDC_ISSUER", "http://localhost:8180/tavolo")
|
||||
os.environ.setdefault("OIDC_CLIENT_ID", "tavolo")
|
||||
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost:8080/auth/callback")
|
||||
os.environ.setdefault("OIDC_0_PROVIDER", "default")
|
||||
os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/tavolo")
|
||||
os.environ.setdefault("OIDC_0_CLIENT_ID", "tavolo")
|
||||
os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback")
|
||||
# Unset REDIS_URL: sessions and live games use the in-memory fallbacks.
|
||||
os.environ.pop("REDIS_URL", None)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Helpers for faking the OIDC authenticated user during tests.
|
||||
|
||||
HTTP handlers funnel through ``oidc_mixin.get_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.
|
||||
HTTP handlers funnel through ``OIDCProviders.get_users`` (wrapped by
|
||||
``tavolo.platform.auth.current_user``); websocket handlers through
|
||||
:func:`tavolo.platform.auth.get_ws_user`. Patching those two entry points
|
||||
lets route and websocket tests run entirely in-process with no IdP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,19 +15,44 @@ from kaya.oidc import OIDCUser
|
||||
|
||||
# Import the app first: it pulls in the route modules, which import
|
||||
# ``tavolo.platform.auth`` themselves.
|
||||
from tavolo.app import oidc_mixin
|
||||
from tavolo.app import oidc
|
||||
from tavolo.platform import auth
|
||||
|
||||
# The app's first configured provider (the test environment configures one
|
||||
# named "default").
|
||||
_PROVIDER = next(iter(oidc.providers))
|
||||
|
||||
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
|
||||
return OIDCUser({"sub": sub, "preferred_username": name or sub})
|
||||
|
||||
def make_user(
|
||||
sub: str,
|
||||
name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
picture: Optional[str] = None,
|
||||
) -> OIDCUser:
|
||||
claims = {"sub": sub, "preferred_username": name or sub}
|
||||
if email:
|
||||
claims["email"] = email
|
||||
if picture:
|
||||
claims["picture"] = picture
|
||||
return OIDCUser(claims)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def oidc_user(sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
|
||||
"""Context manager: patch ``oidc_mixin.get_user`` to return this user."""
|
||||
user = make_user(sub, name)
|
||||
patcher = _mock.patch.object(oidc_mixin, "get_user", return_value=user)
|
||||
def oidc_user(
|
||||
sub: str,
|
||||
name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
picture: Optional[str] = None,
|
||||
) -> Iterator[OIDCUser]:
|
||||
"""Context manager: make ``oidc.get_users`` return this user.
|
||||
|
||||
``email`` is optional so tests can simulate an IdP that still returns
|
||||
the claim: the app requests only the ``openid`` and ``profile`` scopes
|
||||
and ignores email where present. ``picture`` simulates the profile
|
||||
scope's avatar claim.
|
||||
"""
|
||||
user = make_user(sub, name, email, picture)
|
||||
patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user})
|
||||
patcher.start()
|
||||
try:
|
||||
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()
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Briscola chiamata integration tests: lobby, auction, websocket play, timeouts.
|
||||
|
||||
The per-package suite covers the rules and the engine contract; these
|
||||
tests wire the game through the full platform stack (REST, WebSocket,
|
||||
deadline scheduler).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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.chiamata import (
|
||||
PHASE_AUCTION,
|
||||
PHASE_CALLING,
|
||||
PHASE_PLAYING,
|
||||
Auction,
|
||||
Bid,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave", "erin")
|
||||
|
||||
|
||||
async def _create_and_join(client: AsyncClient) -> dict:
|
||||
"""Create a chiamata game and seat all five players; return the view."""
|
||||
with oidc_user(PLAYERS[0]):
|
||||
created = await client.post("/api/games", json={"game_type": "briscola_chiamata"})
|
||||
assert created.status_code == 201, created.text
|
||||
code = created.json()["join_code"]
|
||||
body = created.json()
|
||||
for player in PLAYERS[1:]:
|
||||
with oidc_user(player):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
assert joined.status_code == 200, joined.text
|
||||
body = joined.json()
|
||||
return body
|
||||
|
||||
|
||||
class ChiamataRoutesTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_create_and_seat_five_players(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user(PLAYERS[0]):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "briscola_chiamata"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("briscola_chiamata", body["game_type"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(4, body["seats_open"])
|
||||
self.assertIsNone(body["players"][0]["team"])
|
||||
code = body["join_code"]
|
||||
|
||||
for player in PLAYERS[1:4]:
|
||||
with oidc_user(player):
|
||||
joined = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual("lobby", joined.json()["phase"])
|
||||
self.assertEqual(
|
||||
4 - (PLAYERS.index(player)), joined.json()["seats_open"]
|
||||
)
|
||||
|
||||
with oidc_user(PLAYERS[4]):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
state = started.json()
|
||||
self.assertEqual(PHASE_AUCTION, state["phase"])
|
||||
self.assertEqual(0, state["seats_open"])
|
||||
self.assertEqual(5, len(state["players"]))
|
||||
for seat in state["players"]:
|
||||
# Roles are hidden until the called card is played.
|
||||
self.assertEqual("", seat["team"])
|
||||
self.assertEqual(8, seat["cards_left"])
|
||||
# The auction opens with the player after the dealer (seat 0).
|
||||
self.assertEqual(1, state["auction"]["turn"])
|
||||
self.assertEqual([0, 1, 2, 3], state["auction"]["active"][0:4])
|
||||
self.assertIsNone(state["auction"]["current"])
|
||||
self.assertIsNotNone(state["auction_deadline"])
|
||||
# The last joiner's personalized view carries their own hand.
|
||||
self.assertIn("hand", state["players"][4])
|
||||
self.assertEqual(8, len(state["players"][4]["hand"]))
|
||||
self.assertNotIn("hand", state["players"][0])
|
||||
# There is no stock in this variant.
|
||||
self.assertNotIn("stock_count", state)
|
||||
|
||||
# A non-player watches without hands or the join code.
|
||||
with oidc_user("mallory"):
|
||||
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 seat in spectator["players"]:
|
||||
self.assertNotIn("hand", seat)
|
||||
|
||||
@async_test
|
||||
async def test_preview_lists_chiamata(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_chiamata"}
|
||||
)
|
||||
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_chiamata", body["game_type"])
|
||||
self.assertEqual("Briscola chiamata", body["game_name"])
|
||||
self.assertTrue(body["game_description"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(4, body["seats_open"])
|
||||
|
||||
|
||||
class ChiamataWebSocketTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_auction_call_and_play_over_the_socket(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await _create_and_join(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("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(PHASE_AUCTION, first["game"]["phase"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
self.assertIn("legal_bids", first["game"])
|
||||
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||
await alice_ws.receive_json()
|
||||
# Bob (seat 1) opens the bidding with the two.
|
||||
await bob_ws.send_json({"action": "bid", "rank": 2})
|
||||
update = await bob_ws.receive_json()
|
||||
self.assertEqual("state", update["type"])
|
||||
current = update["game"]["auction"]["current"]
|
||||
self.assertEqual(
|
||||
{"seat": 1, "rank": 2, "points": 61}, current
|
||||
)
|
||||
self.assertEqual(2, update["game"]["auction"]["turn"])
|
||||
# Alice receives the broadcast of Bob's bid.
|
||||
broadcast = await alice_ws.receive_json()
|
||||
self.assertEqual("state", broadcast["type"])
|
||||
# Alice (seat 0) is not on turn: her bid is an error.
|
||||
await alice_ws.send_json({"action": "bid", "rank": 3})
|
||||
error = await alice_ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
|
||||
# The remaining players pass; Bob wins the auction and is
|
||||
# asked to name the card.
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = await game_store.load(game_id)
|
||||
assert session is not None
|
||||
for sub in ("carol", "dave", "erin", "alice"):
|
||||
engine.handle_action(session, sub, "pass", {})
|
||||
await game_store.save(session)
|
||||
self.assertEqual(PHASE_CALLING, session.state.phase)
|
||||
self.assertEqual(1, session.state.caller)
|
||||
|
||||
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 ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
self.assertEqual(
|
||||
["02D", "02C", "02S", "02B"],
|
||||
first["game"]["callable_cards"],
|
||||
)
|
||||
await ws.send_json(
|
||||
{"action": "call", "card": "02D"}
|
||||
)
|
||||
update = await ws.receive_json()
|
||||
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
|
||||
self.assertEqual("D", update["game"]["briscola_suit"])
|
||||
self.assertEqual("02D", update["game"]["called_card"])
|
||||
self.assertEqual(1, update["game"]["turn"])
|
||||
|
||||
# Bob is first to play (player after the dealer).
|
||||
bob = next(
|
||||
p for p in update["game"]["players"] if p["seat"] == 1
|
||||
)
|
||||
card = bob["hand"][0]
|
||||
await ws.send_json({"action": "play", "card": card})
|
||||
update = await ws.receive_json()
|
||||
self.assertEqual(
|
||||
[{"seat": 1, "card": card}], update["game"]["trick"]
|
||||
)
|
||||
|
||||
@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 _create_and_join(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 seat in first["game"]["players"]:
|
||||
self.assertNotIn("hand", seat)
|
||||
await ws.send_json({"action": "bid", "rank": 1})
|
||||
error = await ws.receive_json()
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
|
||||
class ChiamataTimeoutTest(unittest.TestCase):
|
||||
async def _seed(self, game_id: str, join_code: str, state_mutator) -> str:
|
||||
from tavolo.briscola import chiamata as rules
|
||||
|
||||
session = GameSession(
|
||||
id=game_id,
|
||||
game_type="briscola_chiamata",
|
||||
join_code=join_code,
|
||||
creator_sub=PLAYERS[0],
|
||||
players=[
|
||||
Seat(user_sub=name, display_name=name.capitalize())
|
||||
for name in PLAYERS
|
||||
],
|
||||
)
|
||||
session.state = rules.create_game(PLAYERS[0], "Alice", turn_timeout=1)
|
||||
for name in PLAYERS[1:]:
|
||||
rules.join_game(session.state, name, name.capitalize())
|
||||
state_mutator(session.state)
|
||||
await game_store.save(session)
|
||||
return session.id
|
||||
|
||||
@async_test
|
||||
async def test_auction_timeout_auto_passes(self) -> None:
|
||||
game_id = await self._seed(
|
||||
"chiamata-auction-timeout-1", "CH0001", lambda state: None
|
||||
)
|
||||
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 ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
|
||||
self.assertIsNotNone(first["game"]["auction_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 1 in update["game"]["auction"]["passed"]
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(update)
|
||||
assert update is not None
|
||||
# The absent seat 1 passed: nobody bid and the turn
|
||||
# moved to seat 2.
|
||||
auction = update["game"]["auction"]
|
||||
self.assertIn(1, auction["passed"])
|
||||
self.assertIsNone(auction["current"])
|
||||
self.assertEqual(2, auction["turn"])
|
||||
|
||||
@async_test
|
||||
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
|
||||
def seed(state) -> None:
|
||||
state.phase = PHASE_AUCTION
|
||||
state.auction = Auction(
|
||||
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
|
||||
)
|
||||
state.turn = 0
|
||||
state.auction_deadline = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
).isoformat()
|
||||
state.call_deadline = None
|
||||
|
||||
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
|
||||
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(PHASE_AUCTION, first["game"]["phase"])
|
||||
|
||||
# The auction times out: seat 0 is forced to bid the
|
||||
# ace; its call then times out too and a random ace
|
||||
# suit is named.
|
||||
played = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
played = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
played.get("type") == "state"
|
||||
and played["game"]["phase"] == PHASE_PLAYING
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(played)
|
||||
assert played is not None
|
||||
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
|
||||
self.assertEqual(0, played["game"]["caller"])
|
||||
card = played["game"]["called_card"]
|
||||
self.assertIsNotNone(card)
|
||||
self.assertTrue(card.startswith("01"))
|
||||
|
||||
@async_test
|
||||
async def test_call_and_turn_timeouts(self) -> None:
|
||||
def seed(state) -> None:
|
||||
state.phase = PHASE_CALLING
|
||||
state.auction = Auction(
|
||||
turn=1,
|
||||
active=[1],
|
||||
passed=[0, 2, 3, 4],
|
||||
current=Bid(seat=1, rank=2, points=61),
|
||||
)
|
||||
state.caller = 1
|
||||
state.turn = 1
|
||||
state.auction_deadline = None
|
||||
state.call_deadline = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
).isoformat()
|
||||
|
||||
game_id = await self._seed(
|
||||
"chiamata-call-timeout-1", "CH0002", seed
|
||||
)
|
||||
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 ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(PHASE_CALLING, first["game"]["phase"])
|
||||
|
||||
called = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
called = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
called.get("type") == "state"
|
||||
and called["game"]["phase"] == PHASE_PLAYING
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(called)
|
||||
assert called is not None
|
||||
self.assertEqual(PHASE_PLAYING, called["game"]["phase"])
|
||||
card = called["game"]["called_card"]
|
||||
self.assertIsNotNone(card)
|
||||
self.assertTrue(card.startswith("02"))
|
||||
|
||||
played = None
|
||||
for _ in range(20):
|
||||
try:
|
||||
played = await asyncio.wait_for(
|
||||
ws.receive_json(), timeout=2
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
if (
|
||||
played.get("type") == "state"
|
||||
and len(played["game"]["trick"]) == 1
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(played)
|
||||
assert played is not None
|
||||
# Seat 1 was on turn and was auto-played for.
|
||||
self.assertEqual(1, played["game"]["trick"][0]["seat"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -164,6 +164,154 @@ class OTelSettingsTests(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -18,12 +18,15 @@ class LoggingConfigTest(unittest.TestCase):
|
||||
self._root_level = root.level
|
||||
tavolo = logging.getLogger("tavolo")
|
||||
self._tavolo_level = tavolo.level
|
||||
pool = logging.getLogger("urllib3.connectionpool")
|
||||
self._pool_level = pool.level
|
||||
|
||||
def tearDown(self) -> None:
|
||||
root = logging.getLogger()
|
||||
root.handlers = self._root_handlers
|
||||
root.level = self._root_level
|
||||
logging.getLogger("tavolo").level = self._tavolo_level
|
||||
logging.getLogger("urllib3.connectionpool").level = self._pool_level
|
||||
|
||||
def test_default_config_when_unset(self) -> None:
|
||||
configure_logging(None)
|
||||
@@ -33,6 +36,12 @@ class LoggingConfigTest(unittest.TestCase):
|
||||
any(isinstance(h, logging.StreamHandler) for h in root.handlers),
|
||||
"default config installs a console stream handler",
|
||||
)
|
||||
# The noisy per-request HTTP logger is capped at INFO while the
|
||||
# root stays at DEBUG.
|
||||
self.assertEqual(
|
||||
logging.INFO,
|
||||
logging.getLogger("urllib3.connectionpool").getEffectiveLevel(),
|
||||
)
|
||||
|
||||
def test_yaml_config_is_applied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""OpenAPI document tests: components, response schemas and Swagger UI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from kaya.openapi import schema_ref
|
||||
|
||||
from tavolo.app import app
|
||||
from tavolo.platform.openapi import SCHEMAS
|
||||
from tests.helpers import async_test
|
||||
|
||||
#: Methods documented for the application's own routes.
|
||||
HTTP_METHODS = ("get", "post", "put", "patch", "delete", "options", "head")
|
||||
|
||||
|
||||
class OpenApiDocumentTest(unittest.TestCase):
|
||||
async def _get(self, path: str) -> Any:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get(path)
|
||||
self.assertEqual(200, response.status_code)
|
||||
return response
|
||||
|
||||
async def _spec(self) -> Dict[str, Any]:
|
||||
response = await self._get("/api/openapi.json")
|
||||
body: Dict[str, Any] = response.json()
|
||||
return body
|
||||
|
||||
async def _schema(
|
||||
self, spec: Dict[str, Any], path: str, method: str, status: str
|
||||
) -> Dict[str, Any]:
|
||||
schema: Dict[str, Any] = spec["paths"][path][method]["responses"][status][
|
||||
"content"
|
||||
]["application/json"]["schema"]
|
||||
return schema
|
||||
|
||||
@async_test
|
||||
async def test_components_registered(self) -> None:
|
||||
spec = await self._spec()
|
||||
self.assertEqual(SCHEMAS, spec["components"]["schemas"])
|
||||
|
||||
@async_test
|
||||
async def test_every_api_response_documents_a_schema(self) -> None:
|
||||
spec = await self._spec()
|
||||
checked = 0
|
||||
for path, item in spec["paths"].items():
|
||||
if not path.startswith("/api/"):
|
||||
continue
|
||||
for method, operation in item.items():
|
||||
if method not in HTTP_METHODS:
|
||||
continue
|
||||
for status, response in operation["responses"].items():
|
||||
with self.subTest(path=path, method=method, status=status):
|
||||
if status == "204":
|
||||
# No content, so no schema either.
|
||||
self.assertNotIn("content", response)
|
||||
continue
|
||||
self.assertIn("application/json", response["content"])
|
||||
self.assertTrue(
|
||||
response["content"]["application/json"]["schema"]
|
||||
)
|
||||
checked += 1
|
||||
self.assertGreater(checked, 0)
|
||||
|
||||
@async_test
|
||||
async def test_response_schemas_reference_components(self) -> None:
|
||||
spec = await self._spec()
|
||||
cases: Tuple[Tuple[str, str, str, Dict[str, Any]], ...] = (
|
||||
("/api/health", "get", "200", schema_ref("Health")),
|
||||
("/api/me", "get", "200", schema_ref("Me")),
|
||||
("/api/me", "get", "401", schema_ref("Error")),
|
||||
("/api/auth/providers", "get", "200", schema_ref("AuthProvidersPage")),
|
||||
("/api/game-types", "get", "200", schema_ref("GameTypesPage")),
|
||||
("/api/games", "post", "201", schema_ref("Lobby")),
|
||||
("/api/games/preview", "get", "200", schema_ref("GamePreview")),
|
||||
("/api/games/{game_id}", "get", "200", schema_ref("GameView")),
|
||||
("/api/games/{game_id}", "get", "404", schema_ref("Error")),
|
||||
("/api/me/games", "get", "200", schema_ref("OngoingGamesPage")),
|
||||
("/api/me/matches", "get", "200", schema_ref("MatchesPage")),
|
||||
("/api/leaderboard", "get", "200", schema_ref("LeaderboardPage")),
|
||||
("/api/me/ratings", "get", "200", schema_ref("RatingsPage")),
|
||||
)
|
||||
for path, method, status, expected in cases:
|
||||
with self.subTest(path=path, method=method, status=status):
|
||||
self.assertEqual(
|
||||
expected, await self._schema(spec, path, method, status)
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_join_response_is_lobby_or_game_view(self) -> None:
|
||||
spec = await self._spec()
|
||||
schema = await self._schema(spec, "/api/games/join", "post", "200")
|
||||
self.assertEqual(
|
||||
{"anyOf": [schema_ref("Lobby"), schema_ref("GameView")]}, schema
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_component_nesting_and_nullability(self) -> None:
|
||||
spec = await self._spec()
|
||||
schemas = spec["components"]["schemas"]
|
||||
|
||||
matches = schemas["MatchesPage"]["properties"]
|
||||
self.assertEqual(schema_ref("Match"), matches["results"]["items"])
|
||||
self.assertEqual(["string", "null"], matches["next_cursor"]["type"])
|
||||
|
||||
match = schemas["Match"]["properties"]
|
||||
self.assertEqual(schema_ref("MatchPlayer"), match["players"]["items"])
|
||||
self.assertEqual(["integer", "null"], match["your_elo_delta"]["type"])
|
||||
|
||||
lobby = schemas["Lobby"]
|
||||
self.assertEqual(schema_ref("LobbyPlayer"), lobby["properties"]["players"]["items"])
|
||||
self.assertIs(True, lobby["additionalProperties"])
|
||||
self.assertEqual(
|
||||
["string", "null"],
|
||||
schemas["LobbyPlayer"]["properties"]["team"]["type"],
|
||||
)
|
||||
|
||||
game_view = schemas["GameView"]
|
||||
self.assertEqual("boolean", game_view["properties"]["spectator"]["type"])
|
||||
self.assertIs(True, game_view["additionalProperties"])
|
||||
# The join code is optional (players only), so it is not required.
|
||||
self.assertNotIn("join_code", game_view["required"])
|
||||
|
||||
board = schemas["LeaderboardPage"]
|
||||
self.assertEqual(["integer", "null"], board["properties"]["your_rank"]["type"])
|
||||
self.assertEqual(
|
||||
["integer", "null"], board["properties"]["your_page"]["type"]
|
||||
)
|
||||
self.assertEqual(schema_ref("LeaderboardEntry"),
|
||||
board["properties"]["results"]["items"])
|
||||
|
||||
@async_test
|
||||
async def test_docs_serve_swagger_ui(self) -> None:
|
||||
response = await self._get("/api/docs")
|
||||
self.assertIn("text/html", response.headers["content-type"])
|
||||
self.assertIn("swagger-ui", response.text)
|
||||
@@ -16,6 +16,7 @@ from unittest.mock import patch
|
||||
|
||||
from tavolo.app import otel_mixin_from_settings
|
||||
from tavolo.config import Settings
|
||||
from tavolo.telemetry import session_tag_hook
|
||||
|
||||
|
||||
def _settings(env: dict) -> Settings:
|
||||
@@ -58,8 +59,17 @@ class OTelMixinFromSettingsTests(unittest.TestCase):
|
||||
"endpoint": "http://collector:4318",
|
||||
"headers": {"Authorization": "Bearer t", "X-Tenant": "one"},
|
||||
"excluded_paths": ("/api/health",),
|
||||
"server_response_hook": session_tag_hook,
|
||||
"websocket_close_hook": session_tag_hook,
|
||||
}, mixin.kwargs)
|
||||
|
||||
def test_session_tag_hooks_are_wired(self):
|
||||
with _stub_kaya_otel():
|
||||
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertIs(session_tag_hook, mixin.kwargs["server_response_hook"])
|
||||
self.assertIs(session_tag_hook, mixin.kwargs["websocket_close_hook"])
|
||||
|
||||
def test_defaults_when_only_enabled(self):
|
||||
with _stub_kaya_otel():
|
||||
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "on"}))
|
||||
@@ -69,6 +79,24 @@ class OTelMixinFromSettingsTests(unittest.TestCase):
|
||||
self.assertIsNone(mixin.kwargs["headers"])
|
||||
self.assertEqual(("/api/health",), mixin.kwargs["excluded_paths"])
|
||||
|
||||
def test_meter_provider_is_passed_through(self):
|
||||
with _stub_kaya_otel():
|
||||
provider = object()
|
||||
mixin = otel_mixin_from_settings(
|
||||
_settings({"OTEL_ENABLED": "1"}), meter_provider=provider
|
||||
)
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertIs(provider, mixin.kwargs["meter_provider"])
|
||||
|
||||
def test_tracer_provider_is_passed_through(self):
|
||||
with _stub_kaya_otel():
|
||||
provider = object()
|
||||
mixin = otel_mixin_from_settings(
|
||||
_settings({"OTEL_ENABLED": "1"}), tracer_provider=provider
|
||||
)
|
||||
assert isinstance(mixin, _StubOTelMixin)
|
||||
self.assertIs(provider, mixin.kwargs["tracer_provider"])
|
||||
|
||||
def test_excluded_paths_are_passed_through(self):
|
||||
with _stub_kaya_otel():
|
||||
mixin = otel_mixin_from_settings(_settings({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Auth routes on the fully wired application.
|
||||
|
||||
The integration suite's app is built from the indexed test environment
|
||||
(one provider named ``default``); these tests pin the public surface the
|
||||
SPA and bookmarks rely on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app, oidc
|
||||
from tests.helpers import async_test
|
||||
|
||||
|
||||
class AuthRoutesTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_providers_endpoint_reports_the_configured_provider(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/auth/providers")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
{"providers": [{"name": "default", "login_url": "/auth/default/login"}]},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_generic_login_redirects_to_the_provider(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/auth/login", follow_redirects=False)
|
||||
self.assertEqual(302, response.status_code)
|
||||
self.assertEqual("/auth/default/login", response.headers["location"])
|
||||
|
||||
def test_authorization_scopes_exclude_email(self) -> None:
|
||||
# Only the scopes the app uses are requested: openid (sub) and
|
||||
# profile (preferred_username/name). kaya-oidc's default would also
|
||||
# request email, which the app neither needs nor stores.
|
||||
mixin = oidc.provider("default")
|
||||
self.assertEqual(("openid", "profile"), tuple(mixin._config.scopes))
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Game lobby route tests via kaya's ASGI transport (scopone game)."""
|
||||
"""Game lobby route tests via kaya's ASGI transport (all registered games)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
@@ -39,9 +39,19 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(200, snapshot.status_code)
|
||||
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
|
||||
|
||||
# A logged-in non-player is a read-only spectator: no join
|
||||
# code, and the view is marked as a spectator view.
|
||||
with oidc_user("mallory"):
|
||||
forbidden = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(403, forbidden.status_code)
|
||||
watched = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, watched.status_code)
|
||||
self.assertTrue(watched.json()["spectator"])
|
||||
self.assertNotIn("join_code", watched.json())
|
||||
|
||||
# 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_fills_seats_and_starts_game(self) -> None:
|
||||
@@ -61,6 +71,7 @@ class GamesRouteTest(unittest.TestCase):
|
||||
started = await client.post("/api/games/join", json={"code": code})
|
||||
self.assertEqual(200, started.status_code)
|
||||
state = started.json()
|
||||
game_id = state["id"]
|
||||
self.assertEqual("playing", state["phase"])
|
||||
self.assertEqual(4, len(state["players"]))
|
||||
self.assertEqual([], state["table"])
|
||||
@@ -72,6 +83,21 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertNotIn("hand", state["players"][0])
|
||||
self.assertEqual(1, state["turn"])
|
||||
|
||||
# A spectator sees the table, the score and each player's
|
||||
# captured count and scope — but no hand at all.
|
||||
watched = await client.get(f"/api/games/{game_id}")
|
||||
self.assertEqual(200, watched.status_code)
|
||||
spect = watched.json()
|
||||
self.assertTrue(spect["spectator"])
|
||||
self.assertNotIn("join_code", spect)
|
||||
self.assertEqual([], spect["table"])
|
||||
self.assertEqual({"A": 0, "B": 0}, spect["scores"])
|
||||
for participant in spect["players"]:
|
||||
self.assertNotIn("hand", participant)
|
||||
self.assertEqual(10, participant["cards_left"])
|
||||
self.assertEqual(0, participant["captured_count"])
|
||||
self.assertEqual(0, participant["scope"])
|
||||
|
||||
@async_test
|
||||
async def test_create_napola_option(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -94,6 +120,77 @@ class GamesRouteTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(400, invalid.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_create_asso_pigliatutto_option(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
default = await client.post("/api/games", json={})
|
||||
self.assertEqual(201, default.status_code)
|
||||
self.assertFalse(default.json()["asso_pigliatutto"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
enabled = await client.post(
|
||||
"/api/games", json={"options": {"asso_pigliatutto": True}}
|
||||
)
|
||||
self.assertEqual(201, enabled.status_code)
|
||||
self.assertTrue(enabled.json()["asso_pigliatutto"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
invalid = await client.post(
|
||||
"/api/games", json={"options": {"asso_pigliatutto": "yes"}}
|
||||
)
|
||||
self.assertEqual(400, invalid.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_preview_join_code(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games",
|
||||
json={
|
||||
"options": {
|
||||
"target_score": 16,
|
||||
"napola": False,
|
||||
"asso_pigliatutto": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
code = created.json()["join_code"]
|
||||
|
||||
response = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
with oidc_user("bob"):
|
||||
missing = await client.get("/api/games/preview")
|
||||
unknown = await client.get("/api/games/preview?code=ZZZZZZ")
|
||||
self.assertEqual(400, missing.status_code)
|
||||
self.assertEqual(404, unknown.status_code)
|
||||
|
||||
with oidc_user("bob"):
|
||||
preview = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, preview.status_code)
|
||||
body = preview.json()
|
||||
self.assertEqual("scopone_scientifico", body["game_type"])
|
||||
self.assertEqual("Scopone scientifico", body["game_name"])
|
||||
self.assertTrue(body["game_description"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(16, body["target_score"])
|
||||
self.assertFalse(body["napola"])
|
||||
self.assertTrue(body["asso_pigliatutto"])
|
||||
self.assertEqual(3, body["seats_open"])
|
||||
self.assertEqual(["alice"], [p["sub"] for p in body["players"]])
|
||||
|
||||
for player in ("bob", "carol", "dave"):
|
||||
with oidc_user(player):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
with oidc_user("bob"):
|
||||
started = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, started.status_code)
|
||||
self.assertEqual("playing", started.json()["phase"])
|
||||
self.assertEqual(0, started.json()["seats_open"])
|
||||
|
||||
@async_test
|
||||
async def test_join_errors(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -139,6 +236,52 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(400, text.status_code)
|
||||
self.assertEqual(400, huge.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_my_games_lists_my_ongoing_games(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"}
|
||||
)
|
||||
game_id = created.json()["id"]
|
||||
|
||||
with oidc_user("alice"):
|
||||
listed = await client.get("/api/me/games")
|
||||
self.assertEqual(200, listed.status_code)
|
||||
# The app suite shares one in-memory store, so other tests'
|
||||
# games may be listed too: pick ours by id.
|
||||
by_id = {g["id"]: g for g in listed.json()["results"]}
|
||||
self.assertIn(game_id, by_id)
|
||||
entry = by_id[game_id]
|
||||
self.assertEqual("briscola", entry["game_type"])
|
||||
self.assertEqual("Briscola", entry["game_name"])
|
||||
self.assertTrue(entry["game_description"])
|
||||
self.assertEqual("lobby", entry["phase"])
|
||||
self.assertEqual(1, entry["seats_open"])
|
||||
self.assertEqual(0, entry["your_seat"])
|
||||
self.assertNotIn("join_code", entry)
|
||||
|
||||
# A logged-in non-player does not see the game.
|
||||
with oidc_user("mallory"):
|
||||
others = await client.get("/api/me/games")
|
||||
self.assertNotIn(
|
||||
game_id, [g["id"] for g in others.json()["results"]]
|
||||
)
|
||||
|
||||
# Once the match starts it stays listed, for both players.
|
||||
code = created.json()["join_code"]
|
||||
with oidc_user("bob"):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
with oidc_user("bob"):
|
||||
started = await client.get("/api/me/games")
|
||||
bob_entry = next(
|
||||
g for g in started.json()["results"] if g["id"] == game_id
|
||||
)
|
||||
self.assertEqual("playing", bob_entry["phase"])
|
||||
self.assertEqual(1, bob_entry["your_seat"])
|
||||
self.assertEqual(0, bob_entry["seats_open"])
|
||||
|
||||
@async_test
|
||||
async def test_get_unknown_game(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -156,13 +299,78 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
response = await client.get("/api/game-types")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
|
||||
self.assertEqual("Scopone scientifico", results[0]["name"])
|
||||
self.assertTrue(results[0]["description"])
|
||||
self.assertEqual(4, results[0]["min_players"])
|
||||
self.assertEqual(4, results[0]["max_players"])
|
||||
self.assertIn("target_score", results[0]["options_schema"]["properties"])
|
||||
self.assertIn("napola", results[0]["options_schema"]["properties"])
|
||||
self.assertEqual(
|
||||
[
|
||||
"scopone_scientifico",
|
||||
"scopa",
|
||||
"scopa_a_quattro",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
],
|
||||
[g["id"] for g in results],
|
||||
)
|
||||
scopone = results[0]
|
||||
self.assertEqual("Scopone scientifico", scopone["name"])
|
||||
self.assertTrue(scopone["description"])
|
||||
self.assertEqual(4, scopone["min_players"])
|
||||
self.assertEqual(4, scopone["max_players"])
|
||||
self.assertIn("target_score", scopone["options_schema"]["properties"])
|
||||
self.assertEqual(
|
||||
21,
|
||||
scopone["options_schema"]["properties"]["target_score"]["default"],
|
||||
)
|
||||
self.assertIn("napola", scopone["options_schema"]["properties"])
|
||||
self.assertIn(
|
||||
"asso_pigliatutto", scopone["options_schema"]["properties"]
|
||||
)
|
||||
scopa = results[1]
|
||||
self.assertEqual("Scopa", scopa["name"])
|
||||
self.assertTrue(scopa["description"])
|
||||
self.assertEqual(2, scopa["min_players"])
|
||||
self.assertEqual(2, scopa["max_players"])
|
||||
self.assertIn("target_score", scopa["options_schema"]["properties"])
|
||||
self.assertEqual(
|
||||
11, scopa["options_schema"]["properties"]["target_score"]["default"]
|
||||
)
|
||||
self.assertIn(
|
||||
"asso_pigliatutto", scopa["options_schema"]["properties"]
|
||||
)
|
||||
scopa_quattro = results[2]
|
||||
self.assertEqual("Scopa a quattro", scopa_quattro["name"])
|
||||
self.assertTrue(scopa_quattro["description"])
|
||||
self.assertEqual(4, scopa_quattro["min_players"])
|
||||
self.assertEqual(4, scopa_quattro["max_players"])
|
||||
self.assertIn(
|
||||
"target_score", scopa_quattro["options_schema"]["properties"]
|
||||
)
|
||||
self.assertEqual(
|
||||
21,
|
||||
scopa_quattro["options_schema"]["properties"]["target_score"][
|
||||
"default"
|
||||
],
|
||||
)
|
||||
self.assertIn(
|
||||
"asso_pigliatutto", scopa_quattro["options_schema"]["properties"]
|
||||
)
|
||||
briscola = results[3]
|
||||
self.assertEqual("Briscola", briscola["name"])
|
||||
self.assertTrue(briscola["description"])
|
||||
self.assertEqual(2, briscola["min_players"])
|
||||
self.assertEqual(2, briscola["max_players"])
|
||||
self.assertIn("target_score", briscola["options_schema"]["properties"])
|
||||
quattro = results[4]
|
||||
self.assertEqual("Briscola a quattro", quattro["name"])
|
||||
self.assertTrue(quattro["description"])
|
||||
self.assertEqual(4, quattro["min_players"])
|
||||
self.assertEqual(4, quattro["max_players"])
|
||||
self.assertIn("target_score", quattro["options_schema"]["properties"])
|
||||
chiamata = results[5]
|
||||
self.assertEqual("Briscola chiamata", chiamata["name"])
|
||||
self.assertTrue(chiamata["description"])
|
||||
self.assertEqual(5, chiamata["min_players"])
|
||||
self.assertEqual(5, chiamata["max_players"])
|
||||
self.assertEqual({}, chiamata["options_schema"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(self) -> None:
|
||||
@@ -194,7 +402,7 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
unknown = await client.post("/api/games", json={"game_type": "briscola"})
|
||||
unknown = await client.post("/api/games", json={"game_type": "tresette"})
|
||||
non_string = await client.post("/api/games", json={"game_type": 42})
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
self.assertEqual(400, non_string.status_code)
|
||||
|
||||
@@ -19,10 +19,36 @@ class MeRouteTest(unittest.TestCase):
|
||||
async def test_me_authenticated(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
with oidc_user("alice", email="alice@example.com"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
|
||||
# The email claim the IdP returns is neither stored nor exposed;
|
||||
# no picture claim in this request.
|
||||
self.assertEqual(
|
||||
{"sub": "alice", "name": "alice", "picture": None},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_me_includes_the_profile_picture(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice", picture="https://cdn.example.com/alice.png"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
"https://cdn.example.com/alice.png",
|
||||
response.json()["picture"],
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_me_rejects_non_http_picture_claims(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice", picture="javascript:alert(1)"):
|
||||
response = await client.get("/api/me")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertIsNone(response.json()["picture"])
|
||||
|
||||
@async_test
|
||||
async def test_me_unauthenticated(self) -> None:
|
||||
@@ -55,6 +81,46 @@ class StaticRouteTest(unittest.TestCase):
|
||||
self.assertEqual(200, fallback.status_code)
|
||||
self.assertIn(b"spa", fallback.content)
|
||||
|
||||
@async_test
|
||||
async def test_serves_pwa_manifest_and_worker(self) -> None:
|
||||
"""The manifest and the service worker are served from the site
|
||||
root, with the content types browsers require, instead of falling
|
||||
through to the app shell."""
|
||||
with tempfile.TemporaryDirectory() as dist:
|
||||
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||
(Path(dist) / "manifest.webmanifest").write_text('{"name": "Tavolo"}')
|
||||
(Path(dist) / "sw.js").write_text("self.addEventListener('fetch', () => {});")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
with mock.patch("tavolo.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
manifest = await client.get("/manifest.webmanifest")
|
||||
self.assertEqual(200, manifest.status_code)
|
||||
self.assertEqual("application/manifest+json", manifest.headers["content-type"])
|
||||
self.assertEqual('{"name": "Tavolo"}', manifest.text)
|
||||
|
||||
worker = await client.get("/sw.js")
|
||||
self.assertEqual(200, worker.status_code)
|
||||
self.assertEqual("text/javascript; charset=utf-8", worker.headers["content-type"])
|
||||
# The worker script itself must never be cached stale.
|
||||
self.assertEqual("no-cache", worker.headers["cache-control"])
|
||||
self.assertIn(b"addEventListener", worker.content)
|
||||
|
||||
@async_test
|
||||
async def test_missing_pwa_files_return_404(self) -> None:
|
||||
"""A build without the PWA files must 404 those exact paths rather
|
||||
than answer them with the shell (the wrong content type)."""
|
||||
with tempfile.TemporaryDirectory() as dist:
|
||||
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
with mock.patch("tavolo.static.settings", patched):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
self.assertEqual(404, (await client.get("/manifest.webmanifest")).status_code)
|
||||
self.assertEqual(404, (await client.get("/sw.js")).status_code)
|
||||
|
||||
@async_test
|
||||
async def test_missing_dist_returns_404(self) -> None:
|
||||
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Scopa integration tests: lobby, websocket play, timeouts and summaries.
|
||||
|
||||
The per-package suites cover the rules and the engine contract; these
|
||||
tests wire both scopa 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.scopa.state import (
|
||||
PHASE_HAND_END,
|
||||
PHASE_PLAYING,
|
||||
Card,
|
||||
PlayerState,
|
||||
ScopaState,
|
||||
)
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tests.helpers import async_test, make_user, oidc_user, ws_users
|
||||
|
||||
|
||||
class ScopaRoutesTest(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": "scopa",
|
||||
"options": {"target_score": 21, "asso_pigliatutto": True},
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("scopa", body["game_type"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
self.assertEqual(21, body["target_score"])
|
||||
self.assertTrue(body["asso_pigliatutto"])
|
||||
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(4, len(state["table"]))
|
||||
self.assertEqual(30, state["deck_count"])
|
||||
self.assertEqual(1, state["turn"])
|
||||
self.assertTrue(state["asso_pigliatutto"])
|
||||
# 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": "scopa_a_quattro"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("scopa_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"]])
|
||||
# Partnership scopa defaults to the traditional 21 points.
|
||||
self.assertEqual(21, state["target_score"])
|
||||
for participant in state["players"]:
|
||||
self.assertEqual(3, participant["cards_left"])
|
||||
self.assertEqual(4, len(state["table"]))
|
||||
self.assertEqual(24, state["deck_count"])
|
||||
|
||||
@async_test
|
||||
async def test_preview_shows_scopa_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": "scopa",
|
||||
"options": {"target_score": 21, "asso_pigliatutto": True},
|
||||
},
|
||||
)
|
||||
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("scopa", body["game_type"])
|
||||
self.assertEqual("Scopa", body["game_name"])
|
||||
self.assertTrue(body["game_description"])
|
||||
self.assertEqual(21, body["target_score"])
|
||||
self.assertTrue(body["asso_pigliatutto"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_bad_options(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
for bad in (0, 101, "eleven", True):
|
||||
with oidc_user("alice"):
|
||||
response = await client.post(
|
||||
"/api/games",
|
||||
json={"game_type": "scopa", "options": {"target_score": bad}},
|
||||
)
|
||||
self.assertEqual(400, response.status_code, bad)
|
||||
with oidc_user("alice"):
|
||||
response = await client.post(
|
||||
"/api/games",
|
||||
json={"game_type": "scopa", "options": {"asso_pigliatutto": "yes"}},
|
||||
)
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
|
||||
class ScopaWebSocketTest(unittest.TestCase):
|
||||
async def _started_two_player(self, client: AsyncClient) -> dict:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={"game_type": "scopa"})
|
||||
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_play_trails_or_captures(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("bob")]):
|
||||
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"])
|
||||
game = first["game"]
|
||||
self.assertEqual(1, game["turn"])
|
||||
self.assertTrue(game.get("your_turn"))
|
||||
self.assertEqual(4, len(game["table"]))
|
||||
card = game["players"][1]["hand"][0]
|
||||
options = (game.get("legal_moves") or {}).get(card)
|
||||
payload = {"action": "play", "card": card}
|
||||
if options:
|
||||
payload["capture"] = options[0]
|
||||
await bob_ws.send_json(payload)
|
||||
|
||||
update = await bob_ws.receive_json()
|
||||
self.assertEqual("state", update["type"])
|
||||
game = update["game"]
|
||||
self.assertEqual(0, game["turn"])
|
||||
self.assertIsNotNone(game["last_move"])
|
||||
self.assertEqual(1, game["last_move"]["seat"])
|
||||
self.assertEqual(card, game["last_move"]["card"])
|
||||
if options:
|
||||
# The pile grows by the captured table cards
|
||||
# plus the card just played.
|
||||
self.assertEqual(
|
||||
len(options[0]) + 1,
|
||||
game["players"][1]["captured_count"],
|
||||
)
|
||||
else:
|
||||
self.assertEqual(5, len(game["table"]))
|
||||
|
||||
@async_test
|
||||
async def test_illegal_move_returns_error(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_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"])
|
||||
|
||||
@async_test
|
||||
async def test_asso_pigliatutto_sweeps_the_table(self) -> None:
|
||||
# A controlled scopa d'assi position: Alice's ace sweeps the whole
|
||||
# table, scores no scopa and counts as the last capture.
|
||||
state = ScopaState(
|
||||
player_count=2,
|
||||
target_score=100,
|
||||
asso_pigliatutto=True,
|
||||
phase=PHASE_PLAYING,
|
||||
dealer=0,
|
||||
turn=0,
|
||||
players=[
|
||||
PlayerState(
|
||||
sub="alice", name="Alice", seat=0,
|
||||
hand=[Card.parse("01D"), Card.parse("09C")],
|
||||
),
|
||||
PlayerState(
|
||||
sub="bob", name="Bob", seat=1,
|
||||
hand=[Card.parse("04D")],
|
||||
),
|
||||
],
|
||||
table=[Card.parse("05C"), Card.parse("02D")],
|
||||
)
|
||||
engine = platform.registry.require("scopa")
|
||||
session = GameSession(
|
||||
id="scopa-asso-pigliatutto-1",
|
||||
game_type="scopa",
|
||||
join_code="SA0001",
|
||||
creator_sub="alice",
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = state
|
||||
await game_store.save(session)
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("alice")]):
|
||||
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
|
||||
first = await ws.receive_json()
|
||||
self.assertEqual(0, first["game"]["turn"])
|
||||
self.assertTrue(first["game"].get("your_turn"))
|
||||
await ws.send_json(
|
||||
{"action": "play", "card": "01D", "capture": ["05C", "02D"]}
|
||||
)
|
||||
update = await ws.receive_json()
|
||||
game = update["game"]
|
||||
self.assertEqual([], game["table"])
|
||||
self.assertEqual(3, game["players"][0]["captured_count"])
|
||||
self.assertEqual(0, game["players"][0]["scope"])
|
||||
self.assertEqual(0, game["scores"]["A"])
|
||||
self.assertTrue(game["last_move"]["pigliatutto"])
|
||||
self.assertFalse(game["last_move"]["scopa"])
|
||||
|
||||
@async_test
|
||||
async def test_hand_end_ack_flow(self) -> None:
|
||||
# A one-play hand with target 100 so the match continues to the
|
||||
# scoring summary.
|
||||
state = ScopaState(
|
||||
player_count=2,
|
||||
target_score=100,
|
||||
phase=PHASE_PLAYING,
|
||||
dealer=0,
|
||||
turn=0,
|
||||
players=[
|
||||
PlayerState(
|
||||
sub="alice", name="Alice", seat=0,
|
||||
hand=[Card.parse("02D")],
|
||||
),
|
||||
PlayerState(sub="bob", name="Bob", seat=1),
|
||||
],
|
||||
table=[Card.parse("02C")],
|
||||
)
|
||||
session = GameSession(
|
||||
id="scopa-hand-end-1",
|
||||
game_type="scopa",
|
||||
join_code="SH0001",
|
||||
creator_sub="alice",
|
||||
players=[
|
||||
Seat(user_sub="alice", display_name="Alice", team="A"),
|
||||
Seat(user_sub="bob", display_name="Bob", team="B"),
|
||||
],
|
||||
)
|
||||
session.state = state
|
||||
await game_store.save(session)
|
||||
|
||||
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/{session.id}", ws_client)
|
||||
)
|
||||
for _ in range(2)
|
||||
]
|
||||
for ws in sockets:
|
||||
await ws.receive_json()
|
||||
|
||||
# Alice captures the last table card: the hand ends.
|
||||
await sockets[0].send_json(
|
||||
{"action": "play", "card": "02D", "capture": ["02C"]}
|
||||
)
|
||||
summaries = [await ws.receive_json() for ws in sockets]
|
||||
for summary in summaries:
|
||||
self.assertEqual("state", summary["type"])
|
||||
self.assertEqual(PHASE_HAND_END, summary["game"]["phase"])
|
||||
self.assertEqual([], summary["game"]["acknowledged"])
|
||||
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
|
||||
hand = summary["game"]["last_hand"]
|
||||
self.assertEqual({"A": 2, "B": 0}, hand["cards"])
|
||||
self.assertEqual("A", hand["award"]["carte"])
|
||||
|
||||
# Both acknowledge; the second ack deals the next hand.
|
||||
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"])
|
||||
|
||||
|
||||
class ScopaTurnTimeoutTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_turn_timeout_auto_plays_a_card(self) -> None:
|
||||
engine = platform.registry.require("scopa")
|
||||
session = GameSession(
|
||||
id="scopa-turn-timeout-1",
|
||||
game_type=engine.id,
|
||||
join_code="ST0001",
|
||||
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_move"] is not None
|
||||
):
|
||||
break
|
||||
self.assertIsNotNone(update)
|
||||
assert update is not None
|
||||
# The absent player was auto-played: the move is on the
|
||||
# board and the turn advanced.
|
||||
self.assertEqual(1, update["game"]["last_move"]["seat"])
|
||||
self.assertEqual(0, update["game"]["turn"])
|
||||
players = {p["seat"]: p for p in update["game"]["players"]}
|
||||
self.assertEqual(2, players[1]["cards_left"])
|
||||
self.assertEqual(3, players[0]["cards_left"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -12,10 +12,14 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app, platform, tortoise_mixin
|
||||
from tavolo.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.errors import IllegalMove
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from tavolo.scopa import engine as scopa_rules
|
||||
from tavolo.scopone import engine as rules
|
||||
from tests.helpers import async_test, oidc_user
|
||||
|
||||
@@ -129,11 +133,331 @@ class ScoponeStatsTest(unittest.TestCase):
|
||||
self.assertIn("your_elo_delta", results[0])
|
||||
self.assertEqual(4, len(results[0]["players"]))
|
||||
|
||||
board = await client.get("/api/leaderboard")
|
||||
with oidc_user("alice"):
|
||||
board = await client.get("/api/leaderboard")
|
||||
self.assertEqual(200, board.status_code)
|
||||
by_sub = {r["user_sub"]: r for r in board.json()["results"]}
|
||||
body = board.json()
|
||||
by_sub = {r["user_sub"]: r for r in body["results"]}
|
||||
self.assertEqual(4, len(by_sub))
|
||||
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
|
||||
self.assertEqual(1, body["page"])
|
||||
self.assertEqual(1, body["pages"])
|
||||
self.assertEqual(4, body["total"])
|
||||
# The caller's own rank rides along on the response.
|
||||
self.assertIsNotNone(body["your_rank"])
|
||||
self.assertEqual(1, body["your_page"])
|
||||
|
||||
paged = await client.get("/api/leaderboard?limit=1&page=2")
|
||||
self.assertEqual(200, paged.status_code)
|
||||
self.assertEqual(4, paged.json()["pages"])
|
||||
self.assertEqual(2, paged.json()["page"])
|
||||
self.assertEqual(1, len(paged.json()["results"]))
|
||||
|
||||
|
||||
async def _finished_briscola_session() -> GameSession:
|
||||
engine = platform.registry.require("briscola")
|
||||
session = GameSession(
|
||||
id="stats-briscola-1",
|
||||
game_type=engine.id,
|
||||
join_code="BS0001",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
)
|
||||
engine.create(session, {})
|
||||
engine.join(session, "bob", "Bob")
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == "hand_end":
|
||||
for player in state.players:
|
||||
engine.handle_action(session, player.sub, "ack", {})
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
|
||||
class BriscolaStatsTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_briscola_match_persisted_with_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("briscola")
|
||||
session = await _finished_briscola_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(2, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("briscola", match.game_type)
|
||||
summary = match.result
|
||||
self.assertIn("team_a_score", summary)
|
||||
self.assertIn("team_b_score", summary)
|
||||
self.assertIn("winner_team", summary)
|
||||
self.assertIn("target_score", summary)
|
||||
self.assertIn("deals_played", summary)
|
||||
self.assertIn("deal_scores", summary)
|
||||
|
||||
winner_team = summary["winner_team"]
|
||||
players = {p.user_sub: p for p in await MatchPlayer.all()}
|
||||
for row in players.values():
|
||||
if row.won:
|
||||
self.assertEqual(winner_team, row.team)
|
||||
else:
|
||||
self.assertNotEqual(winner_team, row.team)
|
||||
# A two-player match moves Elo by 16 points each.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(2, len(ratings))
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_history_exposes_briscola_result(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("briscola")
|
||||
session = await _finished_briscola_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
history = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, history.status_code)
|
||||
results = history.json()["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual("briscola", results[0]["game_type"])
|
||||
self.assertIn("team_a_score", results[0]["result"])
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
|
||||
|
||||
async def _finished_chiamata_session() -> GameSession:
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = GameSession(
|
||||
id="stats-chiamata-1",
|
||||
game_type=engine.id,
|
||||
join_code="BC0001",
|
||||
creator_sub="alice",
|
||||
players=[
|
||||
Seat(user_sub=name, display_name=name.capitalize())
|
||||
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
for name in ("bob", "carol", "dave", "erin"):
|
||||
engine.join(session, name, name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == PHASE_AUCTION:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(p for p in state.players if p.seat == state.caller)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
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
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
|
||||
class ChiamataStatsTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = await _finished_chiamata_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(5, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("briscola_chiamata", match.game_type)
|
||||
summary = match.result
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
|
||||
players = await MatchPlayer.all()
|
||||
roles = {p.team for p in players}
|
||||
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
|
||||
# Roles match the sides: exactly one caller, the rest defenders
|
||||
# plus at most one partner.
|
||||
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
|
||||
winner_team = summary["winner_team"]
|
||||
for row in players:
|
||||
on_caller_side = row.team in ("Chiamante", "Socio")
|
||||
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
|
||||
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(5, len(ratings))
|
||||
winners = {p.user_sub for p in players if p.won}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||
)
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING - 16},
|
||||
{ratings[sub] for sub in ratings if sub not in winners},
|
||||
)
|
||||
|
||||
|
||||
class ScopaStatsTest(unittest.TestCase):
|
||||
async def _finished_session(
|
||||
self, player_count: int = 2, asso_pigliatutto: bool = False
|
||||
) -> GameSession:
|
||||
engine = platform.registry.require(
|
||||
"scopa" if player_count == 2 else "scopa_a_quattro"
|
||||
)
|
||||
session = GameSession(
|
||||
id=f"stats-scopa-{player_count}",
|
||||
game_type=engine.id,
|
||||
join_code="SC0001",
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="Alice")],
|
||||
)
|
||||
engine.create(
|
||||
session,
|
||||
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
|
||||
)
|
||||
for name in PLAYERS[1:player_count]:
|
||||
engine.join(session, name, name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == "hand_end":
|
||||
for player in state.players:
|
||||
engine.handle_action(session, player.sub, "ack", {})
|
||||
continue
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
played = player.hand[0]
|
||||
options = scopa_rules.legal_captures(
|
||||
state.table, played,
|
||||
asso_pigliatutto=state.asso_pigliatutto,
|
||||
)
|
||||
payload = {"card": played.code}
|
||||
if options:
|
||||
payload["capture"] = [c.code for c in options[0]]
|
||||
engine.handle_action(session, player.sub, "play", payload)
|
||||
moves += 1
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
@async_test
|
||||
async def test_finished_two_player_match_persisted_with_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopa")
|
||||
session = await self._finished_session(2, asso_pigliatutto=True)
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(2, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("scopa", match.game_type)
|
||||
summary = match.result
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"target_score",
|
||||
"asso_pigliatutto",
|
||||
"hands_played",
|
||||
"hand_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertTrue(summary["asso_pigliatutto"])
|
||||
|
||||
winner_team = summary["winner_team"]
|
||||
players = {p.user_sub: p for p in await MatchPlayer.all()}
|
||||
for row in players.values():
|
||||
self.assertEqual(row.won, row.team == winner_team)
|
||||
# A two-player match moves Elo by 16 points each.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_finished_four_player_match_persisted_with_teams(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopa_a_quattro")
|
||||
session = await self._finished_session(4)
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(4, await MatchPlayer.all().count())
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("scopa_a_quattro", match.game_type)
|
||||
players = await MatchPlayer.all()
|
||||
self.assertEqual({"A", "B"}, {p.team for p in players})
|
||||
# Partnership Elo: the winning side gains 16 each.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
winners = {p.user_sub for p in players if p.won}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||
)
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING - 16},
|
||||
{ratings[sub] for sub in ratings if sub not in winners},
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_history_exposes_scopa_result(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("scopa")
|
||||
session = await self._finished_session(2)
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
history = await client.get("/api/me/matches")
|
||||
self.assertEqual(200, history.status_code)
|
||||
results = history.json()["results"]
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertEqual("scopa", results[0]["game_type"])
|
||||
self.assertIn("team_a_score", results[0]["result"])
|
||||
self.assertIn("your_elo_delta", results[0])
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Telemetry unit tests: app gauges, process metrics, log export and counts.
|
||||
|
||||
The OpenTelemetry packages are an optional extra (``tavolo-app[otel]``),
|
||||
so the provider-dependent tests skip when they are not installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
from kaya.session import Session
|
||||
from tavolo.telemetry import (
|
||||
CountingInMemorySessionStore,
|
||||
CountingRedisSessionStore,
|
||||
TelemetryMixin,
|
||||
_NoOtelLogs,
|
||||
session_tag_hook,
|
||||
telemetry_from_settings,
|
||||
)
|
||||
from tavolo.config import Settings
|
||||
|
||||
from tests.helpers import async_test
|
||||
|
||||
try:
|
||||
from opentelemetry.sdk._logs import LoggingHandler, LoggerProvider
|
||||
from opentelemetry.sdk._logs.export import (
|
||||
InMemoryLogRecordExporter,
|
||||
SimpleLogRecordProcessor,
|
||||
)
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
|
||||
InMemorySpanExporter,
|
||||
)
|
||||
|
||||
HAVE_OTEL = True
|
||||
except ImportError: # pragma: no cover - installs without the otel extra
|
||||
HAVE_OTEL = False
|
||||
|
||||
|
||||
def _settings(env: dict) -> Settings:
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
return Settings.from_env()
|
||||
|
||||
|
||||
class _FakeSessions:
|
||||
def __init__(self, count: int = 0) -> None:
|
||||
self.value: Optional[int] = count
|
||||
self.error: Optional[Exception] = None
|
||||
|
||||
async def count(self) -> int:
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
assert self.value is not None
|
||||
return self.value
|
||||
|
||||
|
||||
class _FakeGames:
|
||||
def __init__(self, count: int = 0) -> None:
|
||||
self.value: Optional[int] = count
|
||||
|
||||
async def count_ongoing(self) -> int:
|
||||
assert self.value is not None
|
||||
return self.value
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
"""Minimal async Redis double covering the session-count scan."""
|
||||
|
||||
def __init__(self, keys: list) -> None:
|
||||
self.keys = keys
|
||||
|
||||
async def scan_iter(self, match: str = "", count: int = 0):
|
||||
prefix = match.rstrip("*")
|
||||
for key in self.keys:
|
||||
if key.startswith(prefix):
|
||||
yield key
|
||||
|
||||
|
||||
class TelemetrySetupTest(unittest.TestCase):
|
||||
def test_disabled_returns_none(self) -> None:
|
||||
self.assertIsNone(
|
||||
telemetry_from_settings(
|
||||
_settings({}), _FakeSessions(), _FakeGames()
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_otel_dependencies_raise(self) -> None:
|
||||
settings = _settings({"OTEL_ENABLED": "true"})
|
||||
missing = "opentelemetry.exporter.otlp.proto.http._log_exporter"
|
||||
with patch.dict(sys.modules, {missing: None}):
|
||||
with self.assertRaises(RuntimeError):
|
||||
telemetry_from_settings(settings, _FakeSessions(), _FakeGames())
|
||||
|
||||
|
||||
class SessionCountTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_in_memory_counts_live_sessions(self) -> None:
|
||||
now = [1000.0]
|
||||
store = CountingInMemorySessionStore(clock=lambda: now[0])
|
||||
await store.save("a", Session("a", {"user": "alice"}), max_age=10)
|
||||
await store.save("b", Session("b", {"user": "bob"}), max_age=10)
|
||||
await store.save("c", Session("c", {"user": "carol"})) # no expiry
|
||||
self.assertEqual(3, await store.count())
|
||||
|
||||
now[0] += 11 # a and b have expired
|
||||
self.assertEqual(1, await store.count())
|
||||
await store.delete("c")
|
||||
self.assertEqual(0, await store.count())
|
||||
|
||||
@async_test
|
||||
async def test_redis_counts_only_session_keys(self) -> None:
|
||||
store = CountingRedisSessionStore(
|
||||
_FakeRedis(["kaya:session:a", "kaya:session:b", "tavolo:code:X"])
|
||||
)
|
||||
self.assertEqual(2, await store.count())
|
||||
|
||||
|
||||
class NoOtelLogsFilterTest(unittest.TestCase):
|
||||
def test_filters_opentelemetry_records_only(self) -> None:
|
||||
filter_ = _NoOtelLogs()
|
||||
self.assertFalse(
|
||||
filter_.filter(logging.makeLogRecord({"name": "opentelemetry.sdk"}))
|
||||
)
|
||||
self.assertTrue(
|
||||
filter_.filter(logging.makeLogRecord({"name": "tavolo.app"}))
|
||||
)
|
||||
self.assertTrue(
|
||||
filter_.filter(logging.makeLogRecord({"name": "granian.server"}))
|
||||
)
|
||||
|
||||
|
||||
class _RecordedSpan:
|
||||
def __init__(self) -> None:
|
||||
self.attributes: dict = {}
|
||||
|
||||
def set_attribute(self, key: str, value) -> None:
|
||||
self.attributes[key] = value
|
||||
|
||||
|
||||
class SessionTagHookTest(unittest.TestCase):
|
||||
def test_tags_authenticated_session(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
carrier = SimpleNamespace(
|
||||
session=Session("sid", {"oidc:default:user": {"sub": "alice"}})
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(b"sid").hexdigest()[:16],
|
||||
span.attributes["session.id"],
|
||||
)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
|
||||
def test_tags_user_of_any_provider(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
carrier = SimpleNamespace(
|
||||
session=Session("sid", {"oidc:google:user": {"sub": "alice"}})
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
|
||||
def test_ignores_legacy_unprefixed_user_key(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
carrier = SimpleNamespace(
|
||||
session=Session("sid", {"oidc_user": {"sub": "alice"}})
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertIn("session.id", span.attributes)
|
||||
self.assertNotIn("user.id", span.attributes)
|
||||
|
||||
def test_tags_session_without_user(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
session_tag_hook(span, SimpleNamespace(session=Session("sid", {})))
|
||||
self.assertIn("session.id", span.attributes)
|
||||
self.assertNotIn("user.id", span.attributes)
|
||||
|
||||
def test_tags_user_when_session_id_not_yet_minted(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
carrier = SimpleNamespace(
|
||||
session=Session(None, {"oidc:default:user": {"sub": "alice"}})
|
||||
)
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertNotIn("session.id", span.attributes)
|
||||
self.assertEqual("alice", span.attributes["user.id"])
|
||||
|
||||
def test_missing_session_is_a_noop(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
session_tag_hook(span, SimpleNamespace())
|
||||
self.assertEqual({}, span.attributes)
|
||||
|
||||
def test_malformed_user_is_ignored(self) -> None:
|
||||
span = _RecordedSpan()
|
||||
carrier = SimpleNamespace(session=Session("sid", {"oidc:default:user": "alice"}))
|
||||
session_tag_hook(span, carrier)
|
||||
self.assertIn("session.id", span.attributes)
|
||||
self.assertNotIn("user.id", span.attributes)
|
||||
|
||||
|
||||
@unittest.skipUnless(HAVE_OTEL, "tavolo-app[otel] not installed")
|
||||
class SessionTagIntegrationTest(unittest.TestCase):
|
||||
"""The hook is wired as kaya-otel's response hook, so it sees the
|
||||
session after the session mixin persisted and minted it."""
|
||||
|
||||
@async_test
|
||||
async def test_spans_carry_hashed_session_and_oidc_sub(self) -> None:
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from kaya.otel import OTelMixin
|
||||
from kaya.session import InMemorySessionStore, SessionMixin
|
||||
|
||||
span_exporter = InMemorySpanExporter()
|
||||
tracer_provider = TracerProvider(
|
||||
resource=Resource.create({"service.name": "test"})
|
||||
)
|
||||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||||
app = KayaApp(
|
||||
mixins=[
|
||||
OTelMixin(
|
||||
service_name="test",
|
||||
tracer_provider=tracer_provider,
|
||||
meter_provider=MeterProvider(
|
||||
metric_readers=[InMemoryMetricReader()]
|
||||
),
|
||||
server_response_hook=session_tag_hook,
|
||||
websocket_close_hook=session_tag_hook,
|
||||
),
|
||||
SessionMixin(InMemorySessionStore()),
|
||||
]
|
||||
)
|
||||
|
||||
@app.GET("/login")
|
||||
async def login(ctx: HttpContext) -> None:
|
||||
session = ctx.session
|
||||
assert session is not None
|
||||
session["oidc:default:user"] = {"sub": "alice"}
|
||||
await ctx.send_str(200, "ok")
|
||||
|
||||
@app.GET("/me")
|
||||
async def me(ctx: HttpContext) -> None:
|
||||
await ctx.send_str(200, "ok")
|
||||
|
||||
transport = ASGITransport(app=app) # type: ignore[arg-type]
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://127.0.0.1"
|
||||
) as client:
|
||||
first = await client.get("/login")
|
||||
second = await client.get("/me")
|
||||
self.assertEqual(200, first.status_code)
|
||||
self.assertEqual(200, second.status_code)
|
||||
# The cookie value is the session id; only its digest is exported.
|
||||
cookie = first.cookies["session_id"]
|
||||
digest = hashlib.sha256(cookie.encode()).hexdigest()[:16]
|
||||
|
||||
spans = {span.name: span for span in span_exporter.get_finished_spans()}
|
||||
self.assertEqual({"GET /login", "GET /me"}, set(spans))
|
||||
for name, span in spans.items():
|
||||
attributes = span.attributes
|
||||
assert attributes is not None
|
||||
self.assertEqual(digest, attributes["session.id"], name)
|
||||
self.assertEqual("alice", attributes["user.id"], name)
|
||||
|
||||
|
||||
def _metric_points(reader) -> dict:
|
||||
"""Map metric name -> list of (attributes, value) data points."""
|
||||
data = reader.get_metrics_data()
|
||||
assert data is not None
|
||||
points = {}
|
||||
for resource_metrics in data.resource_metrics:
|
||||
for scope_metrics in resource_metrics.scope_metrics:
|
||||
for metric in scope_metrics.metrics:
|
||||
points[metric.name] = [
|
||||
(dict(point.attributes), point.value)
|
||||
for point in metric.data.data_points
|
||||
]
|
||||
return points
|
||||
|
||||
|
||||
@unittest.skipUnless(HAVE_OTEL, "tavolo-app[otel] not installed")
|
||||
class MetricExportTest(unittest.TestCase):
|
||||
# The instrumentation packages are process-wide singletons whose
|
||||
# callbacks cannot be re-registered, so the providers and mixin are
|
||||
# built once for the whole class (as the application does).
|
||||
sessions = _FakeSessions(0)
|
||||
games = _FakeGames(0)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
resource = Resource.create({"service.name": "test"})
|
||||
cls.reader = InMemoryMetricReader()
|
||||
cls.provider = MeterProvider(resource=resource, metric_readers=[cls.reader])
|
||||
cls.span_exporter = InMemorySpanExporter()
|
||||
cls.tracer_provider = TracerProvider(resource=resource)
|
||||
cls.tracer_provider.add_span_processor(
|
||||
SimpleSpanProcessor(cls.span_exporter)
|
||||
)
|
||||
cls.mixin = TelemetryMixin(
|
||||
meter_provider=cls.provider,
|
||||
tracer_provider=cls.tracer_provider,
|
||||
logger_provider=None,
|
||||
log_handler=None,
|
||||
session_store=cls.sessions,
|
||||
game_store=cls.games,
|
||||
poll_interval_seconds=1,
|
||||
)
|
||||
cls.mixin.apply(None) # type: ignore[arg-type]
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.mixin.shutdown(None) # type: ignore[arg-type]
|
||||
|
||||
@async_test
|
||||
async def test_app_gauges_report_polled_counts(self) -> None:
|
||||
self.sessions.value = 3
|
||||
self.sessions.error = None
|
||||
self.games.value = 2
|
||||
await self.mixin.refresh_counts()
|
||||
points = _metric_points(self.reader)
|
||||
self.assertEqual([({}, 3)], points["tavolo.active_users"])
|
||||
self.assertEqual([({}, 2)], points["tavolo.ongoing_games"])
|
||||
|
||||
@async_test
|
||||
async def test_failed_refresh_keeps_last_value(self) -> None:
|
||||
self.sessions.value = 5
|
||||
self.sessions.error = None
|
||||
await self.mixin.refresh_counts()
|
||||
self.sessions.error = RuntimeError("redis down")
|
||||
await self.mixin.refresh_counts()
|
||||
points = _metric_points(self.reader)
|
||||
self.assertEqual([({}, 5)], points["tavolo.active_users"])
|
||||
|
||||
@async_test
|
||||
async def test_process_and_gc_metrics_are_exported(self) -> None:
|
||||
await self.mixin.refresh_counts()
|
||||
points = _metric_points(self.reader)
|
||||
for name in (
|
||||
"process.cpu.time",
|
||||
"process.cpu.utilization",
|
||||
"process.memory.usage",
|
||||
"process.memory.virtual",
|
||||
"cpython.gc.collections",
|
||||
"cpython.gc.collected_objects",
|
||||
"cpython.gc.uncollectable_objects",
|
||||
):
|
||||
self.assertIn(name, points)
|
||||
self.assertGreater(points["process.memory.usage"][0][1], 0)
|
||||
modes = {attrs["type"] for attrs, _ in points["process.cpu.time"]}
|
||||
self.assertEqual({"user", "system"}, modes)
|
||||
generations = {
|
||||
attrs["cpython.gc.generation"]
|
||||
for attrs, _ in points["cpython.gc.collections"]
|
||||
}
|
||||
self.assertEqual({0, 1, 2}, generations)
|
||||
# Host-level metrics are deliberately not configured.
|
||||
self.assertNotIn("system.cpu.time", points)
|
||||
|
||||
@async_test
|
||||
async def test_database_clients_are_instrumented(self) -> None:
|
||||
import asyncpg.connection
|
||||
import redis.asyncio
|
||||
|
||||
self.assertTrue(hasattr(redis.asyncio.Redis.execute_command, "__wrapped__"))
|
||||
self.assertTrue(hasattr(asyncpg.connection.Connection.fetch, "__wrapped__"))
|
||||
|
||||
# Exercise each wrapper through stubs: the downstream call fails (no
|
||||
# server), but the span is recorded before it does.
|
||||
client = object.__new__(redis.asyncio.Redis)
|
||||
client.connection_pool = SimpleNamespace(
|
||||
connection_kwargs={"host": "127.0.0.1", "port": 6379, "db": 0}
|
||||
)
|
||||
with self.assertRaises(AttributeError):
|
||||
await client.execute_command("GET", "key")
|
||||
|
||||
connection = object.__new__(asyncpg.connection.Connection)
|
||||
connection._aborted = True # keep __del__ quiet on the stub
|
||||
with self.assertRaises(Exception):
|
||||
await connection.fetch("SELECT 1")
|
||||
|
||||
spans = {
|
||||
span.name: span for span in self.span_exporter.get_finished_spans()
|
||||
}
|
||||
self.assertEqual("GET ?", spans["GET"].attributes["db.statement"])
|
||||
self.assertEqual("redis", spans["GET"].attributes["db.system"])
|
||||
self.assertEqual("SELECT 1", spans["SELECT"].attributes["db.statement"])
|
||||
self.assertEqual("postgresql", spans["SELECT"].attributes["db.system"])
|
||||
|
||||
|
||||
@unittest.skipUnless(HAVE_OTEL, "tavolo-app[otel] not installed")
|
||||
class LogExportTest(unittest.TestCase):
|
||||
def test_logs_are_exported_and_otel_records_filtered(self) -> None:
|
||||
exporter = InMemoryLogRecordExporter()
|
||||
resource = Resource.create({"service.name": "test"})
|
||||
provider = LoggerProvider(resource=resource)
|
||||
provider.add_log_record_processor(SimpleLogRecordProcessor(exporter))
|
||||
handler = LoggingHandler(level=logging.NOTSET, logger_provider=provider)
|
||||
handler.addFilter(_NoOtelLogs())
|
||||
root = logging.getLogger()
|
||||
previous_level = root.level
|
||||
root.addHandler(handler)
|
||||
root.setLevel(logging.DEBUG)
|
||||
try:
|
||||
logging.getLogger("tavolo.test").info("exported message")
|
||||
logging.getLogger("opentelemetry.test").info("filtered message")
|
||||
bodies = [r.log_record.body for r in exporter.get_finished_logs()]
|
||||
self.assertIn("exported message", bodies)
|
||||
self.assertNotIn("filtered message", bodies)
|
||||
records = exporter.get_finished_logs()
|
||||
self.assertEqual(
|
||||
"test", records[0].resource.attributes["service.name"]
|
||||
)
|
||||
finally:
|
||||
root.removeHandler(handler)
|
||||
handler.close()
|
||||
root.setLevel(previous_level)
|
||||
provider.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -131,10 +131,10 @@ class WebSocketTest(unittest.TestCase):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/no-such-game", ws_client):
|
||||
pass
|
||||
self.assertEqual(4404, caught.exception.code)
|
||||
self.assertEqual(404, caught.exception.code)
|
||||
|
||||
@async_test
|
||||
async def test_non_player_is_closed(self) -> None:
|
||||
async def test_non_player_watches_as_spectator(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client)
|
||||
@@ -143,20 +143,42 @@ class WebSocketTest(unittest.TestCase):
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([make_user("mallory")]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||
pass
|
||||
self.assertEqual(4403, caught.exception.code)
|
||||
async 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"])
|
||||
self.assertIn("table", first["game"])
|
||||
self.assertEqual({"A": 0, "B": 0}, first["game"]["scores"])
|
||||
for player in first["game"]["players"]:
|
||||
self.assertNotIn("hand", player)
|
||||
# Spectators may not act.
|
||||
await ws.send_json({"action": "play", "card": "01D"})
|
||||
error = await ws.receive_json()
|
||||
self.assertEqual("error", error["type"])
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
@async_test
|
||||
async def test_unauthenticated_is_closed(self) -> None:
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([]):
|
||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||
pass
|
||||
self.assertEqual(4401, caught.exception.code)
|
||||
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
||||
api_transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||
state = await self._started_game(client)
|
||||
game_id = state["id"]
|
||||
|
||||
ws_transport = ASGIWebSocketTransport(app=app)
|
||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||
with ws_users([]):
|
||||
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"])
|
||||
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("error", error["type"])
|
||||
self.assertEqual("forbidden", error["code"])
|
||||
|
||||
|
||||
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||
|
||||
@@ -13,7 +13,7 @@ serde_json = "1"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
futures = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage", "ServiceWorkerContainer", "ServiceWorkerRegistration", "Notification", "NotificationPermission", "NotificationOptions", "Document"] }
|
||||
js-sys = "0.3"
|
||||
gloo-timers = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
|
||||
<!-- Seamless felt grain: fractal noise mapped to semi-transparent black
|
||||
speckles, tiled as a CSS background over the green table gradient.
|
||||
stitchTiles + a filter region equal to the tile keep the seams
|
||||
invisible. -->
|
||||
<filter id="felt" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" result="noise"/>
|
||||
<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.3 0.3 0 -0.3"/>
|
||||
</filter>
|
||||
<rect width="240" height="240" filter="url(#felt)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 716 B |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<!-- Maskable variant: the felt background bleeds to the edges and the
|
||||
spade shrinks inside the 80% safe zone, so platform masks (circles,
|
||||
squircles, rounded squares) never clip the glyph. Also rasterized
|
||||
into the 180x180 apple-touch-icon, where iOS applies its own mask. -->
|
||||
<defs>
|
||||
<radialGradient id="felt" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0" stop-color="#1d5c38"/>
|
||||
<stop offset="1" stop-color="#14341f"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" fill="url(#felt)"/>
|
||||
<path fill="#e8c547" transform="translate(128 134) scale(16)" d="M7.184 11.246A3.5 3.5 0 0 1 1 9c0-1.602 1.14-2.633 2.66-4.008C4.986 3.792 6.184 2.602 8 0c1.818 2.602 3.016 3.793 4.34 4.992C13.86 6.367 15 7.398 15 9a3.5 3.5 0 0 1-6.184 2.246 20 20 0 0 0 1.06 3.09c.42.897-.155 1.664-1.876 1.664s-2.296-.767-1.876-1.664a20 20 0 0 0 1.06-3.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 984 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<!-- Tavolo app icon: the gold spade from the navbar on dark felt.
|
||||
The spade glyph is Bootstrap Icons' "suit-spade-fill" (MIT), the
|
||||
same license as the vendored Bootstrap CSS. Rasterized into the
|
||||
PNG sizes by make-icons.sh (rsvg-convert); not used directly at
|
||||
runtime except as the SVG favicon. -->
|
||||
<defs>
|
||||
<radialGradient id="felt" cx="50%" cy="35%" r="80%">
|
||||
<stop offset="0" stop-color="#1d5c38"/>
|
||||
<stop offset="1" stop-color="#14341f"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="url(#felt)"/>
|
||||
<path fill="#e8c547" transform="translate(96 104) scale(20)" d="M7.184 11.246A3.5 3.5 0 0 1 1 9c0-1.602 1.14-2.633 2.66-4.008C4.986 3.792 6.184 2.602 8 0c1.818 2.602 3.016 3.793 4.34 4.992C13.86 6.367 15 7.398 15 9a3.5 3.5 0 0 1-6.184 2.246 20 20 0 0 0 1.06 3.09c.42.897-.155 1.664-1.876 1.664s-2.296-.767-1.876-1.664a20 20 0 0 0 1.06-3.09Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1022 B |