Author SHA1 Message Date
woggioni a606f14550 Add chess-style Elo ratings for players
Each player's rating starts at 1500 and updates transactionally with
every finished match: a team's rating is the mean of its two members
and the standard K=32 formula decides the zero-sum delta applied to
both members of a team. Ratings are per game type in a new
player_rating table; match_player records each match's elo_delta.

- GET /api/leaderboard exposes elo and sorts by it
- GET /api/me/matches includes per-player elo deltas
- new GET /api/me/ratings returns the caller's rating per game type
- frontend: Elo column on the leaderboard, per-match delta in the
  history page, current rating in the lobby
- python -m tavolo.backfill_elo recomputes all ratings from the
  recorded match history (one-off backfill for existing matches)
2026-09-18 13:36:17 +00:00
woggioni 3da0c463de Merge branch 'backup' into opencode
# Conflicts:
#	server/src/tavolo/app.py
2026-09-18 11:00:52 +00:00
woggioni 26464bce43 Configure CORS headers from environment variables 2026-09-18 08:41:03 +00:00
woggioni 8a2941eccc updated Docker builder 2026-09-18 13:55:11 +08:00
woggioni 57c6ffb032 Reconnect the game websocket after connectivity loss
CI / Build and push docker image (push) Successful in 2m59s
The socket had no recovery path: a mid-game drop left the table showing
stale state with no indication, and plays were silently swallowed by the
dead channel. Surface the server close code from ws::connect and add a
reconnect driver in the game page: transient losses retry with
exponential backoff (capped, then a manual Retry), while deliberate
closes (session expired, game gone) stop retrying. Reconnects are free
resyncs because the server pushes a full state snapshot on connect.

Also gate card clicks while disconnected, show a connection banner, and
drop the ticking interval and pending retries on unmount.
2026-09-18 13:48:34 +08:00
woggioni c10d45523a Fix deadline consumer startup under granian RSGI
The mixin received the event loop from Kaya but ignored it, calling
asyncio.get_running_loop() instead. Under granian RSGI __rsgi_init__ runs
before the loop starts, so that raised RuntimeError and killed the
worker. Use the loop passed to setup(), falling back to the running loop
for the lazy calls from sync_deadline().
2026-09-18 13:48:34 +08:00
woggioni bbd12ce674 Reconnect the game websocket after connectivity loss
The socket had no recovery path: a mid-game drop left the table showing
stale state with no indication, and plays were silently swallowed by the
dead channel. Surface the server close code from ws::connect and add a
reconnect driver in the game page: transient losses retry with
exponential backoff (capped, then a manual Retry), while deliberate
closes (session expired, game gone) stop retrying. Reconnects are free
resyncs because the server pushes a full state snapshot on connect.

Also gate card clicks while disconnected, show a connection banner, and
drop the ticking interval and pending retries on unmount.
2026-09-18 05:47:35 +00:00
woggioni c40ebd84d8 Fix deadline consumer startup under granian RSGI
The mixin received the event loop from Kaya but ignored it, calling
asyncio.get_running_loop() instead. Under granian RSGI __rsgi_init__ runs
before the loop starts, so that raised RuntimeError and killed the
worker. Use the loop passed to setup(), falling back to the running loop
for the lazy calls from sync_deadline().
2026-09-18 02:33:00 +00:00
woggioni 658e3d6ec7 Show own captured and scopa counts in game view
CI / Build and push docker image (push) Successful in 2m46s
2026-09-18 09:18:18 +08:00
woggioni 5e4e1310b4 Drive timeouts from a shared Redis deadline queue
Turn auto-play and hand-end auto-continue were process-local asyncio
tasks armed only by client connects and state broadcasts: with no
sockets connected the next turn's timer was never armed, a hand-end
timer died with its worker, and neither survived a pod restart.

Deadlines are now driven by the absolute timestamps persisted on the
game state and enqueued in a shared Redis sorted set. Every worker runs
a consumer that fires due entries under the per-game lock after
revalidating them against the live state, so timeouts no longer depend
on any player being connected and survive the death of any worker.
Delivery is at-least-once: entries are removed only after processing,
and revalidation makes duplicate deliveries no-ops.

Queue entries carry the deadline as integer epoch milliseconds, which
also serves as the revalidation token, and the score derives from the
same value.
2026-09-18 09:18:17 +08:00
woggioni db7ba30d13 Add configurable napola rule with instant win on a full denari sweep 2026-09-18 09:18:17 +08:00
woggioni f7fa8e78e5 Auto-dismiss error toasts after 10 seconds 2026-09-18 09:18:17 +08:00
woggioni 5b9e998739 Show own captured and scopa counts in game view 2026-09-18 01:17:26 +00:00
woggioni 5dc3f4cb17 Drive timeouts from a shared Redis deadline queue
Turn auto-play and hand-end auto-continue were process-local asyncio
tasks armed only by client connects and state broadcasts: with no
sockets connected the next turn's timer was never armed, a hand-end
timer died with its worker, and neither survived a pod restart.

Deadlines are now driven by the absolute timestamps persisted on the
game state and enqueued in a shared Redis sorted set. Every worker runs
a consumer that fires due entries under the per-game lock after
revalidating them against the live state, so timeouts no longer depend
on any player being connected and survive the death of any worker.
Delivery is at-least-once: entries are removed only after processing,
and revalidation makes duplicate deliveries no-ops.

Queue entries carry the deadline as integer epoch milliseconds, which
also serves as the revalidation token, and the score derives from the
same value.
2026-09-18 00:59:54 +00:00
woggioni 22ef689092 Add configurable napola rule with instant win on a full denari sweep 2026-09-17 22:50:59 +00:00
woggioni aa1fecae43 Auto-dismiss error toasts after 10 seconds 2026-09-17 21:30:36 +00:00
woggioni 294a93912d Split database config into DATABASE_* components
CI / Build and push docker image (push) Successful in 2m56s
Assemble the Postgres DSN from DATABASE_ENGINE/HOST/PORT/NAME/USER/
PASSWORD/OPTIONS so only the password needs to live in a secret; the
rest can go in a ConfigMap. DATABASE_URL remains a full-DSN override
(used by the sqlite test suite). Credentials are percent-encoded, the
port and options are omitted when empty, and the k8s migrate
initContainer now also reads the config ConfigMap.
2026-09-17 19:46:19 +08:00
woggioni ab4130a4ca Add preliminary support for multiple card games
CI / Build and push docker image (push) Successful in 3m5s
A game-type registry (server/src/tavolo/games.py) is now the single
source of truth for the games the platform can host; only scopone
scientifico is registered so far. GET /api/game-types exposes it for the
lobby's new game dropdown, and POST /api/games accepts a validated
game_type (default scopone_scientifico) which is carried on the live
GameState and onto each finished Match row (new indexed column,
migration 1_20260916235833_update), so statistics can be scoped per
game: /api/me/matches and /api/leaderboard take an optional game_type
filter and every serialized match includes its game_type.

Game states serialized before this change still load with the default
game type.
2026-09-17 08:26:46 +08:00
woggioni 876b4abd8b Serve static assets with Granian and add YAML-configurable logging
Granian serves the compiled SPA assets directly in Rust: hashed js/wasm/css
under /static (the release build uses --public-url /static/) and the card
images under /assets, configured with the GRANIAN_STATIC_PATH_ROUTE/MOUNT/
DIR_TO_FILE env vars in the Dockerfile. The Python catch-all now only serves
the SPA shell (index.html) at / and for client-side routes.

Every module logs through getLogger(__name__): lifecycle and business events
at INFO, per-move and store detail at DEBUG. The built-in default writes
DEBUG to the console; LOGGING_CONFIG points at a YAML file in the
logging.config.dictConfig schema to take over the configuration. PyYAML
becomes a direct dependency.
2026-09-17 08:26:45 +08:00
woggioni f6239d2637 Add Kubernetes manifest for the tavolo namespace
Single-file deployment under deploy/k8s/tavolo.yaml:

- Namespace, ConfigMap and Secret (placeholders) for the app
- Ephemeral Redis Deployment + Service (sessions and live games are
  disposable, mirroring the docker-compose no-volume choice)
- App Deployment with an aerich-migrate initContainer so the schema is
  upgraded before rollout, /api/health probes and a hardened pod
  security context
- ClusterIP Service; Postgres and the OIDC provider stay in their own
  namespaces and are referenced by DNS
2026-09-17 08:26:42 +08:00
49 changed files with 3258 additions and 291 deletions
+1
View File
@@ -32,6 +32,7 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
builder: multiplatform-builder
file: server/Dockerfile file: server/Dockerfile
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
+2 -1
View File
@@ -32,7 +32,8 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
When a hand ends but the match is not decided, the game pauses on a When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: every player sees how each category was won **scoring summary screen**: every player sees how each category was won
(carte, denara, settebello, primiera, scope) with the running totals and (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 must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
(default 30s). The match-ending hand is explained on the final screen. (default 30s). The match-ending hand is explained on the final screen.
+301
View File
@@ -0,0 +1,301 @@
# Tavolo — Kubernetes deployment (namespace: tavolo)
#
# Single apply:
# kubectl apply -f deploy/k8s/tavolo.yaml
#
# Replace every value marked REPLACE_ME before applying.
#
# Scope:
# - Postgres and the OIDC provider are NOT part of this file; they are
# deployed in other namespaces and referenced by DNS name.
# - Redis IS included (ephemeral: sessions and live games are disposable,
# mirroring docker-compose's no-volume choice).
#
# Reachability notes:
# - The OIDC issuer URL must resolve from the browser AND from the pods
# (kaya does discovery lazily on the first login, so startup succeeds
# even when the issuer is unreachable, but logins then fail). If the
# provider is internal-only, you need split DNS or a public issuer URL.
# - The Postgres NetworkPolicy (if any) must allow ingress from the
# tavolo namespace.
# - This file deploys a ClusterIP Service only. Until you add an Ingress,
# reach the app with:
# kubectl port-forward -n tavolo svc/tavolo 8080:80
# and set OIDC_REDIRECT_URI to match whatever URL the browser uses.
---
apiVersion: v1
kind: Namespace
metadata:
name: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/part-of: tavolo
---
apiVersion: v1
kind: ConfigMap
metadata:
name: tavolo-config
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
data:
# In-cluster Redis deployed by this file.
REDIS_URL: redis://redis.tavolo.svc.cluster.local:6379/0
# Postgres (external, lives in another namespace): everything except the
# password, which is the only entry in the tavolo-secrets Secret.
# Use its Service DNS name, e.g. postgres.<namespace>.svc.cluster.local.
DATABASE_ENGINE: postgres
DATABASE_HOST: REPLACE_ME
DATABASE_PORT: "5432"
DATABASE_NAME: REPLACE_ME
DATABASE_USER: REPLACE_ME
# Extra DSN query parameters appended to the URL (e.g. ssl=require).
# Empty means none.
DATABASE_OPTIONS: ""
# The image bakes STATIC_DIR=/app/web/dist; repeat it here for clarity.
STATIC_DIR: /app/web/dist
GAME_TTL_SECONDS: "86400"
HAND_ACK_TIMEOUT_SECONDS: "30"
TURN_TIMEOUT_SECONDS: "30"
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are
# served from the same origin. See server/.env.example for details.
# CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*"
# CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app'
# CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all
# CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request
# CORS_ALLOW_CREDENTIALS: "false"
# CORS_EXPOSE_HEADERS: ""
# CORS_MAX_AGE: "600"
# OIDC (provider lives in another namespace).
OIDC_CLIENT_ID: tavolo
OIDC_POST_LOGIN_REDIRECT: /
OIDC_POST_LOGOUT_REDIRECT: /
# Issuer URL as seen by the browser AND the pods (see notes above).
OIDC_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
---
apiVersion: v1
kind: Secret
metadata:
name: tavolo-secrets
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
type: Opaque
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
---
# Redis: sessions + live game state. Ephemeral by design (emptyDir): worst
# case after a restart users log in again and games in progress expire.
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
replicas: 1
# Redis is a singleton here; avoid a window with two masters on rollout.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
securityContext:
runAsNonRoot: true
runAsUser: 999 # redis user in the official image
fsGroup: 999
containers:
- name: redis
image: redis:8-alpine
# Snapshots would only live on an emptyDir anyway: skip them.
args: ["--save", "", "--appendonly", "no"]
ports:
- name: redis
containerPort: 6379
volumeMounts:
- name: data
mountPath: /data
readinessProbe:
exec:
command: ["redis-cli", "ping"]
periodSeconds: 5
livenessProbe:
exec:
command: ["redis-cli", "ping"]
periodSeconds: 10
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
volumes:
- name: data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
app.kubernetes.io/part-of: tavolo
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: redis
ports:
- name: redis
port: 6379
targetPort: redis
---
# The app (backend + compiled SPA + WebSocket endpoint, one image).
# The "migrate" initContainer runs `aerich upgrade` before the app starts,
# so this single file can be applied safely in any order. Migrations are
# idempotent, but if you ever scale above 1 replica, move this to a Job to
# avoid concurrent upgrades.
apiVersion: apps/v1
kind: Deployment
metadata:
name: tavolo
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
template:
metadata:
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
# Let WebSocket connections drain on rollout.
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
initContainers:
- name: migrate
image: gitea.woggioni.net/woggioni/tavolo:latest
# aerich reads [tool.aerich] from pyproject.toml in /app.
command: ["aerich", "upgrade"]
workingDir: /app
envFrom:
# Migrations need the non-secret DATABASE_* parts too.
- configMapRef:
name: tavolo-config
- secretRef:
name: tavolo-secrets
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
containers:
- name: tavolo
# Pin a release/* tag for production instead of :latest.
image: gitea.woggioni.net/woggioni/tavolo:latest
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: tavolo-config
- secretRef:
name: tavolo-secrets
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
startupProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: tavolo
namespace: tavolo
labels:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
app.kubernetes.io/part-of: tavolo
spec:
type: ClusterIP
# No session affinity needed: sessions and live games live in Redis.
selector:
app.kubernetes.io/name: tavolo
app.kubernetes.io/component: app
ports:
- name: http
port: 80
targetPort: http
+23 -3
View File
@@ -4,7 +4,8 @@ services:
environment: environment:
POSTGRES_DB: tavolo POSTGRES_DB: tavolo
POSTGRES_USER: tavolo POSTGRES_USER: tavolo
POSTGRES_PASSWORD: tavolo # Override via the DATABASE_PASSWORD env var (shell or root .env).
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
@@ -71,7 +72,12 @@ services:
working_dir: /app working_dir: /app
command: ["aerich", "upgrade"] command: ["aerich", "upgrade"]
environment: environment:
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: tavolo
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -90,7 +96,12 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgres://tavolo:tavolo@postgres:5432/tavolo DATABASE_ENGINE: postgres
DATABASE_HOST: postgres
DATABASE_PORT: "5432"
DATABASE_NAME: tavolo
DATABASE_USER: tavolo
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
# By default the app and browsers reach the mock IdP under the same # By default the app and browsers reach the mock IdP under the same
# name (see README /etc/hosts note); override OIDC_ISSUER and # name (see README /etc/hosts note); override OIDC_ISSUER and
# OIDC_REDIRECT_URI to use a real provider or a different host port. # OIDC_REDIRECT_URI to use a real provider or a different host port.
@@ -102,6 +113,15 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30} TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
# CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX
# is set (see server/.env.example for the full list of options).
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-}
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-}
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-}
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
+40 -7
View File
@@ -1,10 +1,17 @@
# Postgres # Database (match statistics). The app assembles the DSN from these
POSTGRES_HOST=localhost # parts; DATABASE_PORT may be left unset to use the driver default
POSTGRES_PORT=5432 # (5432 for Postgres). DATABASE_OPTIONS is a raw query string appended
POSTGRES_DB=tavolo # to the URL (e.g. ssl=require); leave empty for none.
POSTGRES_USER=tavolo DATABASE_ENGINE=postgres
POSTGRES_PASSWORD=tavolo DATABASE_HOST=localhost
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo DATABASE_PORT=5432
DATABASE_NAME=tavolo
DATABASE_USER=tavolo
DATABASE_PASSWORD=password
DATABASE_OPTIONS=
# Full-DSN override: when set, the parts above are ignored. Used by the
# test suite (sqlite://:memory:) and handy for managed-DB URLs.
#DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo
# OIDC (mock-oauth2-server in dev; it does not validate clients, so any # 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.) # client id/secret works. For a real IdP like Keycloak, use its values here.)
@@ -28,6 +35,32 @@ HAND_ACK_TIMEOUT_SECONDS=30
# for them (covers disconnects and idle players). # for them (covers disconnects and idle players).
TURN_TIMEOUT_SECONDS=30 TURN_TIMEOUT_SECONDS=30
# Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset logs DEBUG to the console.
#LOGGING_CONFIG=/path/to/logging.yaml
# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's
# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set — the app serves the SPA and the API from
# the same origin, so no CORS headers are needed by default.
# Comma-separated list of origins allowed to make cross-origin requests,
# or "*" for any origin:
#CORS_ALLOW_ORIGINS=https://example.com,https://app.example.com
# Optional regex (fullmatch) allowed origins are additionally checked
# against — handy for dynamic preview URLs:
#CORS_ALLOW_ORIGIN_REGEX=https://tavolo-[a-z0-9-]+\.vercel\.app
# Comma-separated allowed methods, or "*" for all (default GET):
#CORS_ALLOW_METHODS=GET,POST
# Comma-separated allowed request headers, or "*" to mirror back whatever
# the browser requests (default: only the CORS-safelisted headers):
#CORS_ALLOW_HEADERS=Authorization,Content-Type
# Allow cookies/credentials on cross-origin requests (1/true/yes/on):
#CORS_ALLOW_CREDENTIALS=false
# Comma-separated response headers exposed to the browser:
#CORS_EXPOSE_HEADERS=
# Seconds browsers may cache the preflight response (default 600):
#CORS_MAX_AGE=600
# App server # App server
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
+13 -2
View File
@@ -33,9 +33,12 @@ COPY web/Cargo.toml web/Cargo.lock web/index.html web/style.css web/Trunk.toml .
COPY web/assets ./assets COPY web/assets ./assets
COPY web/src ./src COPY web/src ./src
# --public-url makes trunk emit asset URLs under /static, the prefix Granian
# serves in the runtime image (see GRANIAN_STATIC_PATH_* below). Dev builds
# (trunk serve) keep the default "/" public URL.
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/web/target \ --mount=type=cache,target=/web/target \
trunk build --release trunk build --release --public-url /static/
# --- Python builder ---------------------------------------------------------- # --- Python builder ----------------------------------------------------------
FROM alpine:3.24 AS builder FROM alpine:3.24 AS builder
@@ -68,7 +71,12 @@ COPY --from=builder /build/migrations /app/migrations
# aerich reads [tool.aerich] from pyproject.toml (its default config file); # aerich reads [tool.aerich] from pyproject.toml (its default config file);
# the db-migrate compose service runs `aerich upgrade` with working_dir=/app. # the db-migrate compose service runs `aerich upgrade` with working_dir=/app.
COPY --from=builder /build/pyproject.toml /app/pyproject.toml COPY --from=builder /build/pyproject.toml /app/pyproject.toml
# The compiled single-page application, served by the backend itself. # The compiled single-page application. Granian serves the assets directly
# in Rust — hashed js/wasm/css under /static/* and the card images under
# /assets/* (see the GRANIAN_STATIC_PATH_* env vars below; click splits
# multi-value env vars on whitespace for routes and ':' for paths). The
# Python app only serves the SPA shell (index.html) at / and for
# client-side routes (STATIC_DIR).
COPY --from=web-builder /web/dist /app/web/dist COPY --from=web-builder /web/dist /app/web/dist
ENV PATH="/opt/venv/bin:$PATH" \ ENV PATH="/opt/venv/bin:$PATH" \
@@ -77,6 +85,9 @@ ENV PATH="/opt/venv/bin:$PATH" \
GRANIAN_HOST=0.0.0.0 \ GRANIAN_HOST=0.0.0.0 \
GRANIAN_PORT=8080 \ GRANIAN_PORT=8080 \
GRANIAN_INTERFACE=rsgi \ GRANIAN_INTERFACE=rsgi \
GRANIAN_STATIC_PATH_ROUTE="/static /assets" \
GRANIAN_STATIC_PATH_MOUNT="/app/web/dist:/app/web/dist/assets" \
GRANIAN_STATIC_PATH_DIR_TO_FILE=index.html \
STATIC_DIR=/app/web/dist STATIC_DIR=/app/web/dist
USER app USER app
+104 -14
View File
@@ -44,7 +44,14 @@ All configuration comes from environment variables (see `.env.example`):
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `DATABASE_URL` | `postgres://tavolo:tavolo@localhost:5432/tavolo` | Postgres DSN for match statistics | | `DATABASE_ENGINE` | `postgres` | Database DSN scheme/driver |
| `DATABASE_HOST` | `localhost` | Postgres host |
| `DATABASE_PORT` | unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
| `DATABASE_NAME` | `tavolo` | Postgres database name |
| `DATABASE_USER` | `tavolo` | Postgres user |
| `DATABASE_PASSWORD` | `password` | Postgres password |
| `DATABASE_OPTIONS` | unset | Extra DSN query parameters, e.g. `ssl=require` |
| `DATABASE_URL` | unset | Full-DSN override; when set, the `DATABASE_*` parts above are ignored (used for sqlite in tests and for managed-DB URLs) |
| `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores | | `REDIS_URL` | unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL | | `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
| `OIDC_CLIENT_ID` | `tavolo` | OIDC client id | | `OIDC_CLIENT_ID` | `tavolo` | OIDC client id |
@@ -53,8 +60,52 @@ All configuration comes from environment variables (see `.env.example`):
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis | | `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements | | `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them | | `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
| `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all |
| `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed |
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Logging
The application logs through the Python stdlib `logging` module, one
`getLogger(__name__)` per module: lifecycle and business events at INFO
(game created/joined, websocket connections, match results, auto-plays),
per-move and store detail at DEBUG.
By default everything at DEBUG level goes to the console. Set
`LOGGING_CONFIG` to the path of a YAML file to take over the
configuration; the file follows the
[`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema)
schema. Keep `disable_existing_loggers: false` — Granian configures its own
loggers before importing the app, and disabling them would silence the
server. Example for quieter production logs (WARNING for third parties,
INFO for the application):
```yaml
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
```
## Data model ## Data model
### Redis (live games) ### Redis (live games)
@@ -68,30 +119,59 @@ All configuration comes from environment variables (see `.env.example`):
- `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed" - `tavolo:game:<uuid>:events` — a pub/sub channel carrying "state changed"
signals; every open WebSocket reloads the state and pushes the signals; every open WebSocket reloads the state and pushes the
personalized view to its player. personalized view to its player.
- `tavolo:deadlines` — a sorted set (score = due timestamp) of pending
timeouts: turn auto-plays and hand-end auto-continues. Every worker runs
a consumer that fires due entries under the per-game lock, so timeouts
do not depend on any player being connected and survive the death of
any worker (delivery is at-least-once; entries are revalidated against
the live state before firing).
### Postgres (statistics, via Tortoise ORM + aerich migrations) ### Postgres (statistics, via Tortoise ORM + aerich migrations)
- `match` — one row per finished match: both teams' final scores, winner, - `match` — one row per finished match: the game played (`game_type`, one
target score, hands played, start/finish timestamps. of the ids from `GET /api/game-types`, indexed so statistics can be
scoped per game), both teams' final scores, winner, target score, hands
played, start/finish timestamps.
- `match_player` — one row per participant: the OIDC `sub`, display name, - `match_player` — one row per participant: the OIDC `sub`, display name,
seat, team and whether they won. Unique per `(match, user_sub)`. seat, team, whether they won and the Elo change the match produced
(`elo_delta`). Unique per `(match, user_sub)`.
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
the number of rated matches played.
When a match ends, the result is written transactionally to Postgres When a match ends, the result is written transactionally to Postgres
(once, guarded by a flag on the Redis state); the finished state stays in (once, guarded by a flag on the Redis state); the finished state stays in
Redis until its TTL expires so clients can still fetch the final board. Redis until its TTL expires so clients can still fetch the final board.
### Elo ratings
Players carry a chess-style Elo rating per game type (`tavolo.elo`):
everyone starts at 1500, a team's rating is the mean of its two 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 both members of a team, zero-sum between teams. Ratings update
in the same transaction as the match result. To recompute every rating
from the recorded match history (e.g. to backfill matches recorded before
ratings existed):
```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
.venv/bin/python -m tavolo.backfill_elo
```
## REST API ## REST API
All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json` All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
require authentication. `/api/game-types` and `/api/leaderboard` require authentication.
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `POST` | `/api/games` | Create a lobby game. Optional body `{"target_score": 11}`. Returns `{id, join_code}` | | `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=`) | | `GET` | `/api/me/matches` | Cursor-paginated match history with final scores and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player | | `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / team points per player, sorted by Elo (`?game_type=`) |
## WebSocket protocol ## WebSocket protocol
@@ -135,7 +215,9 @@ player on turn does not move before it, the server plays a random legal
card for them (picking one of the legal captures at random when a capture card for them (picking one of the legal captures at random when a capture
is required), so a disconnected or idle player cannot stall the match. The is required), so a disconnected or idle player cannot stall the match. The
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other. 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.
### Hand-end summary ### Hand-end summary
@@ -157,6 +239,11 @@ must dismiss. A play attempted in this phase is rejected with an
- Hand points: `carte` (most captured cards), `denara` (most diamonds), - Hand points: `carte` (most captured cards), `denara` (most diamonds),
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits `settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
required), plus one point per scopa. Ties award nothing. required), plus one point per scopa. Ties award nothing.
- Optional *napola* rule (per-game `napola` flag on `POST /api/games`,
default on): the longest run of consecutive denari starting from the
ace scores one point per card once it reaches three cards (A-2-3 = 3,
A-2-3-4 = 4, …). 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, - The match ends when a team reaches the target score (default 11,
configurable per game) with a clear lead; a tie at or above the target is configurable per game) with a clear lead; a tie at or above the target is
broken by another hand. broken by another hand.
@@ -204,9 +291,12 @@ src/tavolo/
├── openapi.py # shared OpenAPI parameter fragments ├── openapi.py # shared OpenAPI parameter fragments
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket) ├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
├── aerich_config.py # aerich CLI configuration ├── aerich_config.py # aerich CLI configuration
├── models.py # Match, MatchPlayer (Postgres) ├── models.py # Match, MatchPlayer, PlayerRating (Postgres)
├── stats.py # finished match -> Postgres persistence ├── elo.py # chess-style Elo math (1500 start, K=32)
├── store.py # Redis / in-memory live-game store ├── stats.py # finished match -> Postgres persistence + Elo update
├── backfill_elo.py # recompute all ratings from the match history
├── store.py # Redis / in-memory live-game store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint ├── ws.py # WebSocket live-play endpoint
├── game/ ├── game/
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization │ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
@@ -215,5 +305,5 @@ src/tavolo/
└── routes/ └── routes/
├── health.py # GET /api/health ├── health.py # GET /api/health
├── games.py # lobby: create / join / snapshot ├── games.py # lobby: create / join / snapshot
└── stats.py # match history + leaderboard └── stats.py # match history + leaderboard + Elo ratings
``` ```
@@ -0,0 +1,42 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match" ADD "game_type" VARCHAR(32) NOT NULL DEFAULT 'scopone_scientifico';
CREATE INDEX IF NOT EXISTS "idx_match_game_ty_7d519c" ON "match" ("game_type");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_match_game_ty_7d519c";
ALTER TABLE "match" DROP COLUMN "game_type";"""
MODELS_STATE = (
"eJztmW1P4zgQgP9KlE+ctIdoYGF1Op2UlqLtLW0RLXenXa0sN3FTi8TO2s51K47/frbTNI"
"3zQgsUAeoXaGY8jv2MX2Ymd3ZEfRTywz4U3sz+zbqzCYyQ/FFUfLBsGMe5WAkEnIRpy1WT"
"CRcMekIKpzDkSIp8xD2GY4EpUU1dy6NRHCKBfEubWXRqUYLUPzFDFkMB5gIxqQ7kOCyxiB"
"E/VH371JOdYxI8rZuE4B8JAoIGSDZksrNv36UYEx/9RDx7jG/BFKPQLwDBvupAy4HqUMlu"
"bnrnF7qlGuIEeDRMIpK3jhdiRsmqeZJg/1DZKF2ACGJQTmENF0nCcIk1E6UjlgLBErQaqp"
"8LfDSFSaig279PE+Ip1pZ+k/pz8sdyaGvNABgMx2DUHQNgl3ykhmDwXoo8SpR/MREK1N19"
"2m8OREtt9YLOZ/f64Pj0F42AchEwrdS47HttCAVMTTX0nLLyV8qrBLszg6wadsHIYC6H/B"
"jamaAJN/doLFcd4B5GROAp9uiuYMtN9hOEiARC7dJjpwH+X+51yt/R/Knckek+HSw1jlYp"
"N+TYBYIRgHImlFWQH0UwDHtEVNM3bQ0HyCk8jwPyMyWDm+HbBfBADeLXY+fs9JPU6jGqh7"
"MG8qO+e3nZG4yr2E6ewHayZ1vHdo6JnDpQmLY5MAyzXR0ZL0u1cES0NjghWrUHRKt0PkAm"
"b8xHrmHDdr+Gi2xnkPgcxCFcoIoQo5mtabtnW2TL5eKT8wZQlMmeSyICR6iabNHS4OovTQ"
"+zH2+QcgPMca/fHY3d/pXqPuL8R6h5ueOu0jhaujCkB6fGabLqxPq7N/5sqUfr63DQNYPC"
"VbvxV1uNCSaCAkLnAPrrTDJxJiq4eYoJ5rNH+dkw3Tv61TlaZWTT27VsQQkm0LudQ+aDgi"
"ZfEfpIZLy8GtpLw4sv1yiEmmXZ7evJ75Xu6e36PZfaaRKjgVKH1hEtqyInMiWQwEBPSb1b"
"vakCWV05ISf6QFEhvdjYZrWFK3leYw/H2qdZQSDhiFmY6N+6y3IxYQu7iurBt7z8oWwATy"
"b2931J4TWVFFZ+2SJBWLd5sYLCiyUHzsePG6QHslVtgqB1xVDLx1xtV6Cft0Bt2r3DZMw5"
"OtqE99FRPW+lM0JbVBXsNKcLmc0+TSiXaLZZs/vCQc06LRUO5ml8ZcRglIYIkpraTGVENp"
"Emb5BtA8z2cHhZCLvbvbGB9abf7ma0ZSMstLi8fNNgabtoYt3mOWOK1wv8gRCilGYYfMtw"
"LyhDOCBf0EIj7smBQOJVXWLmx7S3CbWUSUgxg/NVYFtYU3L2cs4oXbIdd9Rxz7v2fX3qts"
"ukxEUMV3/eXGo+NKUiMG/zUBJST/SZPzzW3vCVG7rifl+672nZwe4vd6d1cnby6fj0ZHXD"
"ryRN13x2RNYnBf8ixnHV3VR/6a+ZvMN7fyc5gdpUWxBeNn+HdFsbZQCthgygVc4A5BsFIh"
"VJwJ+j4aCa8JqJWenEnrD+s0LM32Iu0ABXwSjEWRnTg777j4m7czlsm/GB6qBdFSC85GV2"
"/z8H5XI/"
)
@@ -0,0 +1,55 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "player_rating" (
"id" UUID NOT NULL PRIMARY KEY,
"user_sub" VARCHAR(255) NOT NULL,
"game_type" VARCHAR(32) NOT NULL,
"rating" INT NOT NULL,
"matches_played" INT NOT NULL,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "uid_player_rati_user_su_655b53" UNIQUE ("user_sub", "game_type")
);
CREATE INDEX IF NOT EXISTS "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");
COMMENT ON TABLE "player_rating" IS 'Current Elo rating of one player for one game type.';
ALTER TABLE "match_player" ADD "elo_delta" SMALLINT;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "match_player" DROP COLUMN "elo_delta";
DROP TABLE IF EXISTS "player_rating";"""
MODELS_STATE = (
"eJztmltv4jgUgP9KlKeuNFsBvY1Wq5WAUg07pVSF7q6mqiyTGLAa7IztbAd1+9/Xdm7EuR"
"RoYUrFyww59nHsz87xufTJnlEXefywB4UztX+znmwCZ0j+yDZ8smzo+6lYCQQceWHPpMuI"
"CwYdIYVj6HEkRS7iDsO+wJSork3LoTPfQwK5llaz6NiiBKn/xBRZDE0wF4jJ5omchyXmPu"
"KHamyXOnJwTCavGyYg+HuAgKATJDsyOdjdvRRj4qIfiMeP/gMYY+S5GSDYVQNoOVADKtnt"
"bff8QvdUUxwBh3rBjKS9/bmYUpJ0DwLsHiod1TZBBDEol7CAiwSeF2GNReGMpUCwACVTdV"
"OBi8Yw8BR0+/dxQBzF2tJvUv8c/xFNbaEbAFf9IRh0hgDYuT1SUzB4RyKHErW/mAgF6uk5"
"HDcFoqW2ekH7S/Pm4Oj0F42AcjFhulHjsp+1IhQwVNXQU8pqv0JeOdjtKWTFsDNKBnM55X"
"Vox4Iq3Nyhvjx1gDsYEYHH2KGbgi0/sh/AQ2Qi1Fd61KiA/1fzJuTf0Pyp/CLD7/Qqamno"
"JrUNKXaB4AxAuRLKCsgPZtDzukQU0zd1jQ2QS3ibDUhtSgw3xrcJ4BM1iV+PGmenn2Wrnq"
"N6OKsgP+g1Ly+7V8MitqNXsB3t2ZaxfcRELh0oTKsYDENtUyZju1QzJqK+hIWolxqIes4+"
"QCZvzDXPsKG7P8NZtlNIXA58D85RgYtRzdbU3bPNsuXy8Ml1AyjyZM8lEYFnqJhsVtPg6k"
"aqh/GPHaRcAXPY7XUGw2bvWg0/4/y7p3k1hx3V0tDSuSE9ODWsSTKI9Xd3+MVSj9a3/lXH"
"dAqTfsNvtpoTDAQFhD4C6C4yicWxKLPNY0wwn661z4bqfqPf3UariGz8sBAtKMEIOg+PkL"
"kg05KeCG0SGc+fhlakePH1BnlQs8xv+2Lwe61H2t19T6V2GMRooLRBy4jmm2aNmSmBBE70"
"ktS71ZsKkJWlE1KiLyQVwouNLZdbuJb2GjvY13saJwQCjpiFif6th8wnE1bQK8ge3KXpD6"
"UDeDCy7/cphfeUUkj2ZYUAYVFnawmFrQUHjZOTJcID2as0QNBtWVfLxVx9rkA/r4Da1PuA"
"wVijVluGd61Wzlu1Ga4tKnJ2qsOFWGcfJuRTNKuc2X3ioOSc5hIHj6F/ZfhglHoIkpLcTK"
"FHNpIqO8i2Amar37/MuN2t7tDAettrdWLashMWWpw/vsijcuqegKvag4ziWkYhusTeC+83"
"tAmhB7qai7ao85aO2vs9xS/4ZbnYzeCbh3tBGcIT8hXNNeKunAgkTpFnYFYodxNqLjyTYg"
"Yfk2ghc6bk6uWaUWgH2s1Bu3nesZ/L4+FNRnphFHcjwya9yFyol2n/VBXrhVEeYGnXF4O9"
"dsAYIsLqeNQK9eLILRzMGlOmH5P6bz7wW3OMwiBwMV5Iq5FmIHiXrVRGC77fx4f7+PBnW6"
"MtBIjvt7T/E0m/fSk/NaNZxqVOYKqwvbCwflKrbdYFbNSPz44/H50eJ35gIqlyBkv8QFRe"
"oCvlmlfcHt9dgRv4qrCyTt0mq/kByzY2Q9DtE28e3bu7W8ZJig5LVnE26bU2EcPFf+kYtV"
"R6qjDt85KLWr7Pb/w3iKX2p9AfLLA5kVP3Okdw8zH9K21Ouf/3L2IcF6Wpyr2SBZUP6JNs"
"xPtTH9UKhKPuH5BufaliQL2iGFDPFwPkGwUiBZfon4P+VTHhBRXz9sSOsP6zPMx3sSxQAV"
"fByFyRMdODXvMfE3f7st8y7z41QKsorbXNy+z5fy/uTSo="
)
+2
View File
@@ -10,6 +10,7 @@ readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"kaya-core", "kaya-core",
"kaya-cors",
"kaya-session", "kaya-session",
"kaya-session-redis", "kaya-session-redis",
"kaya-oidc", "kaya-oidc",
@@ -22,6 +23,7 @@ dependencies = [
"httpx", "httpx",
"PyJWT[crypto]", "PyJWT[crypto]",
"pwo", "pwo",
"PyYAML",
"redis", "redis",
] ]
+5
View File
@@ -52,11 +52,14 @@ iso8601==2.1.0
# via tortoise-orm # via tortoise-orm
kaya-core==0.0.3 kaya-core==0.0.3
# via # via
# kaya-cors
# kaya-oidc # kaya-oidc
# kaya-openapi # kaya-openapi
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo (pyproject.toml) # tavolo (pyproject.toml)
kaya-cors==0.0.3
# via tavolo (pyproject.toml)
kaya-oidc==0.0.3 kaya-oidc==0.0.3
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-openapi==0.0.3 kaya-openapi==0.0.3
@@ -84,6 +87,8 @@ pyjwt[crypto]==2.14.0
# tavolo (pyproject.toml) # tavolo (pyproject.toml)
pypika-tortoise==0.6.5 pypika-tortoise==0.6.5
# via tortoise-orm # via tortoise-orm
pyyaml==6.0.3
# via tavolo (pyproject.toml)
redis==8.1.0 redis==8.1.0
# via # via
# kaya-session-redis # kaya-session-redis
+59 -5
View File
@@ -11,6 +11,9 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at - :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
``/api/openapi.json`` and a Swagger UI at ``/api/docs``) ``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
``CORS_*`` environment variables (see :mod:`tavolo.config`).
Live games are kept in :data:`game_store` (Redis when configured, in-memory Live games are kept in :data:`game_store` (Redis when configured, in-memory
otherwise). Routes and the websocket handlers are registered by importing otherwise). Routes and the websocket handlers are registered by importing
their modules at the bottom; imports must happen after ``app`` is built. their modules at the bottom; imports must happen after ``app`` is built.
@@ -18,18 +21,47 @@ their modules at the bottom; imports must happen after ``app`` is built.
from __future__ import annotations from __future__ import annotations
from importlib.metadata import version as _pkg_version from importlib.metadata import version as _pkg_version
from logging import getLogger
from typing import Optional
from kaya.core import KayaApp from kaya.core import KayaApp, KayaMixin
from kaya.cors import CorsMixin
from kaya.oidc import OIDCConfig, OIDCMixin from kaya.oidc import OIDCConfig, OIDCMixin
from kaya.openapi import OpenAPIMixin from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
from kaya.session.redis import RedisSessionStore from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis from redis.asyncio import Redis
from .config import settings from .config import Settings, settings
from .deadlines import DeadlineSchedulerMixin
from .logging_config import configure_logging
from .store import GameStore, InMemoryGameStore, RedisGameStore from .store import GameStore, InMemoryGameStore, RedisGameStore
from .tortoise_mixin import TortoiseMixin from .tortoise_mixin import TortoiseMixin
configure_logging(settings.logging_config)
log = getLogger(__name__)
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
Returns ``None`` — CORS disabled — unless at least one of
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
Settings left unset fall back to the mixin's own defaults.
"""
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
return None
return CorsMixin(
allow_origins=settings.cors_allow_origins or (),
allow_origin_regex=settings.cors_allow_origin_regex,
allow_methods=settings.cors_allow_methods or ("GET",),
allow_headers=settings.cors_allow_headers or (),
allow_credentials=settings.cors_allow_credentials,
expose_headers=settings.cors_expose_headers or (),
max_age=settings.cors_max_age,
)
session_store: SessionStore session_store: SessionStore
if settings.redis_url is not None: if settings.redis_url is not None:
# Lazy client: no connection is opened until a session is actually # Lazy client: no connection is opened until a session is actually
@@ -39,9 +71,11 @@ if settings.redis_url is not None:
Redis.from_url(settings.redis_url, decode_responses=False), Redis.from_url(settings.redis_url, decode_responses=False),
ttl_seconds=settings.game_ttl_seconds, ttl_seconds=settings.game_ttl_seconds,
) )
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
else: else:
session_store = InMemorySessionStore() session_store = InMemorySessionStore()
game_store = InMemoryGameStore() game_store = InMemoryGameStore()
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
session_mixin = SessionMixin(session_store) session_mixin = SessionMixin(session_store)
oidc_mixin = OIDCMixin( oidc_mixin = OIDCMixin(
@@ -69,12 +103,32 @@ tortoise_mixin = TortoiseMixin(
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}), skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
) )
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin]) mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
DeadlineSchedulerMixin(game_store)]
cors_mixin = cors_mixin_from_settings(settings)
if cors_mixin is not None:
# First in the list: preflight requests are answered before the session
# and OIDC hooks run.
mixins.insert(0, cors_mixin)
log.info(
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
settings.cors_allow_origins,
settings.cors_allow_origin_regex,
settings.cors_allow_credentials,
)
app = KayaApp(mixins=mixins)
log.debug(
"timeouts: hand_ack=%ds turn=%ds",
settings.hand_ack_timeout_seconds,
settings.turn_timeout_seconds,
)
# Register routes by importing modules. Order does not matter; each module # Register routes by importing modules. Order does not matter; each module
# pulls ``app`` from here and decorates its handlers at import time. The # pulls ``app`` from here and decorates its handlers at import time. The
# static SPA catch-all is registered last and only matches paths no other # static SPA-shell catch-all is registered last and only matches paths no
# route claimed. # other route claimed (asset files under /static are served by Granian
# itself and never reach the app).
from .routes import games, health, me, stats # noqa: E402,F401 from .routes import games, health, me, stats # noqa: E402,F401
from . import ws # noqa: E402,F401 from . import ws # noqa: E402,F401
from .routes import static # noqa: E402,F401 from .routes import static # noqa: E402,F401
+66
View File
@@ -0,0 +1,66 @@
"""Recompute every Elo rating from the recorded match history.
Ratings are deterministic given the finished matches, so this replays all
matches in chronological order and rewrites the ``player_rating`` table
and each ``match_player.elo_delta`` from scratch. Run once after
deploying the ratings feature to backfill pre-existing matches, or any
time ratings need to be rebuilt::
python -m tavolo.backfill_elo
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from logging import getLogger
from typing import Dict, List
from tortoise.transactions import in_transaction
from .config import settings
from .stats import apply_elo
from .tortoise_mixin import TortoiseMixin
log = getLogger(__name__)
async def backfill_elo() -> int:
"""Rebuild all ratings; returns the number of matches replayed."""
from .models import Match, MatchPlayer, PlayerRating
replayed = 0
async with in_transaction():
await PlayerRating.all().delete()
matches = await Match.all().order_by("finished_at", "id")
for match in matches:
players = await MatchPlayer.filter(match_id=match.id)
team_members: Dict[str, List[str]] = defaultdict(list)
for player in players:
team_members[player.team].append(player.user_sub)
deltas = await apply_elo(
match.game_type, match.winner_team, team_members
)
for player in players:
player.elo_delta = deltas[player.user_sub]
await player.save()
replayed += 1
return replayed
async def _main() -> None:
mixin = TortoiseMixin(
database_url=settings.database_url,
models_modules=["tavolo.models"],
)
await mixin._bind()
try:
replayed = await backfill_elo()
log.info("elo backfill complete: %d matches replayed", replayed)
print(f"Recomputed ratings from {replayed} matches.")
finally:
if mixin._ctx is not None:
await mixin._ctx.close_connections()
if __name__ == "__main__":
asyncio.run(_main())
+96 -4
View File
@@ -7,7 +7,8 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional, Tuple
from urllib.parse import quote
def _env(name: str, default: Optional[str] = None) -> str: def _env(name: str, default: Optional[str] = None) -> str:
@@ -19,6 +20,53 @@ def _env(name: str, default: Optional[str] = None) -> str:
return value return value
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
"""Parse a comma-separated environment variable into a tuple of values.
Items are stripped and empty items dropped. Unset or empty variables
yield ``None``.
"""
value = os.environ.get(name)
if value is None or value.strip() == "":
return None
return tuple(part.strip() for part in value.split(",") if part.strip())
def _env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None or value == "":
return default
return value.strip().lower() in ("1", "true", "yes", "on")
def _database_url_from_parts(engine: str,
user: str,
password: Optional[str],
host: str,
port: str,
name: str,
options: str) -> str:
"""Assemble a database DSN from individual components.
``user`` and ``password`` are percent-encoded so credentials containing
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
DSN. ``port`` and ``options`` are omitted when empty: a missing port
lets the driver pick its default (5432 for asyncpg). ``options`` is a
raw query string (e.g. ``ssl=require``) appended after a ``?``.
"""
netloc = quote(user, safe="")
if password:
netloc += ":" + quote(password, safe="")
netloc += "@" + host
if port:
netloc += ":" + port
url = f"{engine}://{netloc}/{name}"
options = options.lstrip("?")
if options:
url += "?" + options
return url
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
database_url: str database_url: str
@@ -37,8 +85,9 @@ class Settings:
# How long a live game (and its join-code index) survives in Redis # How long a live game (and its join-code index) survives in Redis
# without activity, in seconds. Defaults to 24h. # without activity, in seconds. Defaults to 24h.
game_ttl_seconds: int game_ttl_seconds: int
# Directory holding the compiled frontend (trunk's dist output), # Directory holding the compiled frontend (trunk's dist output). Only
# served for every path that is not under /api or /auth. # used to locate index.html for the SPA shell; the assets themselves
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
static_dir: str static_dir: str
# Seconds the between-hands scoring summary waits for acknowledgements # Seconds the between-hands scoring summary waits for acknowledgements
# before dealing the next hand anyway. # before dealing the next hand anyway.
@@ -46,11 +95,42 @@ class Settings:
# Seconds a player has to play before the server plays a random legal # Seconds a player has to play before the server plays a random legal
# card for them (covering disconnects and idle players). # card for them (covering disconnects and idle players).
turn_timeout_seconds: int turn_timeout_seconds: int
# Upper bound on how long the deadline consumer sleeps between polls.
# Locally enqueued deadlines wake the consumer immediately; the
# heartbeat only bounds the discovery delay for deadlines enqueued by
# other workers.
deadline_heartbeat_ms: int
# Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset uses the built-in default: DEBUG to the console.
logging_config: Optional[str]
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
# from the same origin, so no CORS headers are needed by default.
cors_allow_origins: Optional[Tuple[str, ...]]
cors_allow_origin_regex: Optional[str]
cors_allow_methods: Optional[Tuple[str, ...]]
cors_allow_headers: Optional[Tuple[str, ...]]
cors_allow_credentials: bool
cors_expose_headers: Optional[Tuple[str, ...]]
cors_max_age: int
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
return Settings( return Settings(
database_url=_env("DATABASE_URL", "postgres://tavolo:tavolo@localhost:5432/tavolo"), # DATABASE_URL, when set, is used verbatim and the DATABASE_*
# parts below are ignored (sqlite in tests, managed-DB DSNs).
database_url=os.environ.get("DATABASE_URL") or _database_url_from_parts(
engine=_env("DATABASE_ENGINE", "postgres"),
user=_env("DATABASE_USER", "tavolo"),
password=_env("DATABASE_PASSWORD", "password"),
host=_env("DATABASE_HOST", "localhost"),
# Unset: the port segment is omitted and the driver default
# (5432 for asyncpg) applies.
port=os.environ.get("DATABASE_PORT", ""),
name=_env("DATABASE_NAME", "tavolo"),
# Raw DSN query string (e.g. "ssl=require"); empty = none.
options=os.environ.get("DATABASE_OPTIONS", ""),
),
oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"), oidc_issuer=_env("OIDC_ISSUER", "http://localhost:8180/tavolo"),
oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"), oidc_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"), oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
@@ -67,6 +147,18 @@ class Settings:
static_dir=_env("STATIC_DIR", "web/dist"), static_dir=_env("STATIC_DIR", "web/dist"),
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")), hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")), turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
logging_config=os.environ.get("LOGGING_CONFIG"),
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
# is set.
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
) )
+296
View File
@@ -0,0 +1,296 @@
"""Deadline-driven timeouts, independent of player connections.
Both in-match timeouts — the per-turn auto-play (``turn_deadline``) and
the hand-end summary auto-continue (``hand_end_deadline``) — are driven by
the absolute deadlines persisted on the game state, never by which players
(or whether any players) are connected.
Every mutation that sets a deadline enqueues an entry in the store's
shared deadline queue (a Redis sorted set in production, see
:mod:`tavolo.store`), and a background consumer running on **every**
worker polls the queue for due entries. An entry records the phase, hand,
turn and deadline (as integer epoch milliseconds) it was enqueued for;
before acting, the consumer revalidates all of it against the live state
under the per-game lock, so entries that were overtaken by events (a play
landed in time, the hand was acknowledged, the deadline moved) are simply
discarded.
Delivery is at-least-once: an entry is removed from the queue only after
it has been processed. If a worker dies mid-processing, the entry stays in
Redis and another worker's consumer picks it up — the lock plus
revalidation make the duplicate delivery a no-op. Entries whose game has
expired are dropped the first time they fire, so the queue is
self-cleaning.
"""
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from .config import settings
from .game import engine
from .game.errors import GameError
from .game.state import PHASE_HAND_END, PHASE_PLAYING, PHASE_FINISHED, GameState
from .stats import save_match_result
from .store import GameStore
log = getLogger(__name__)
# Entry kinds enqueued in the deadline queue.
KIND_TURN = "turn"
KIND_HAND_END = "hand_end"
# One consumer task and its wake-up event per event loop (tests run each
# test on a fresh loop).
_consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
_wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
def encode(entry: Dict[str, Any]) -> str:
"""Canonical queue-member encoding for a deadline entry."""
return json.dumps(entry, sort_keys=True)
def _decode(member: Any) -> Optional[Dict[str, Any]]:
if isinstance(member, bytes):
member = member.decode("utf-8")
if not isinstance(member, str):
return None
try:
entry = json.loads(member)
except ValueError:
return None
return entry if isinstance(entry, dict) else None
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
or unparseable. Queue entries carry this integer (never the ISO
string) as their revalidation token."""
if not iso:
return None
try:
return int(datetime.fromisoformat(iso).timestamp() * 1000)
except ValueError:
return None
async def sync_deadline(store: GameStore, state: GameState) -> None:
"""Enqueue the deadline the current state carries, if any.
Called after every mutation that can set a deadline (plays, acks, game
start) and as a backstop when a client connects. Enqueueing is
idempotent: an identical entry is already queued with the same due
time, so re-adding it changes nothing.
"""
entry: Optional[Dict[str, Any]] = None
due_ms: Optional[int] = None
if state.phase == PHASE_PLAYING and state.turn_deadline:
due_ms = _deadline_ms(state.turn_deadline)
entry = {
"game_id": state.id,
"kind": KIND_TURN,
"hand": state.hand_number,
"turn": state.turn,
"deadline": due_ms,
}
elif state.phase == PHASE_HAND_END and state.hand_end_deadline:
due_ms = _deadline_ms(state.hand_end_deadline)
entry = {
"game_id": state.id,
"kind": KIND_HAND_END,
"hand": state.hand_number,
"deadline": due_ms,
}
if entry is None or due_ms is None:
if entry is not None:
log.warning("game %s: unparseable deadline", state.id)
return
ensure_consumer(store)
# The score derives from the same value carried in the member, so the
# two can never disagree.
await store.add_deadline(encode(entry), due_ms / 1000)
wake = _wake_events.get(asyncio.get_running_loop())
if wake is not None:
wake.set()
async def finalize_mutation(store: GameStore, state: GameState) -> None:
"""Persist a successful mutation, notify subscribers and enqueue the
next deadline.
Callers must hold the per-game lock. Handles the terminal transition:
the match result is written to Postgres once (guarded by
``stats_saved``).
"""
if state.phase == PHASE_FINISHED:
await save_match_result(state)
log.info(
"game %s finished: team %s wins %d-%d",
state.id,
"A" if state.winner == 0 else "B",
state.scores[0],
state.scores[1],
)
await store.save(state)
await store.publish(state.id)
await sync_deadline(store, state)
async def process_due(store: GameStore, member: Any) -> None:
"""Fire a single due deadline entry.
Revalidates the entry against the live state under the per-game lock;
stale or foreign entries are discarded without effect. The entry is
removed from the queue once handled (including "nothing to do"); if
handling fails (e.g. the lock cannot be acquired), the entry is left
in the queue so another consumer retries it.
"""
entry = _decode(member)
if entry is None:
log.warning("deadline consumer: dropping malformed entry %r", member)
await store.remove_deadline(member)
return
game_id = entry.get("game_id")
kind = entry.get("kind")
if not isinstance(game_id, str):
await store.remove_deadline(member)
return
async with store.lock(game_id):
state = await store.load(game_id)
if state is not None:
if kind == KIND_TURN:
await _fire_turn(store, state, entry)
elif kind == KIND_HAND_END:
await _fire_hand_end(store, state, entry)
await store.remove_deadline(member)
async def _fire_turn(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
if (
state.phase != PHASE_PLAYING
or state.hand_number != entry.get("hand")
or state.turn != entry.get("turn")
or _deadline_ms(state.turn_deadline) != entry.get("deadline")
):
return
seat = state.turn
try:
engine.auto_play(state)
except GameError:
return
log.info(
"game %s: auto-played for %s (turn timeout, hand %d)",
state.id,
state.players[seat].sub if seat < len(state.players) else "?",
entry.get("hand"),
)
await finalize_mutation(store, state)
async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
if (
state.phase != PHASE_HAND_END
or state.hand_number != entry.get("hand")
or _deadline_ms(state.hand_end_deadline) != entry.get("deadline")
):
return
for player in state.players:
engine.acknowledge_hand(state, player.sub)
log.info(
"game %s: hand %d auto-advanced after the acknowledgement timeout",
state.id,
entry.get("hand"),
)
await finalize_mutation(store, state)
# --- consumer lifecycle -------------------------------------------------------
def ensure_consumer(
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
"""Start the deadline consumer on the given (or running) loop if not
yet running.
Called lazily whenever a deadline is enqueued (the ASGI test transport
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
enough) and on application startup. The explicit ``loop`` matters at
startup: under RSGI granian calls ``setup`` before the loop runs, so
``asyncio.get_running_loop()`` would fail there.
"""
if loop is None:
loop = asyncio.get_running_loop()
for old in list(_consumers):
if old.is_closed():
_consumers.pop(old, None)
_wake_events.pop(old, None)
task = _consumers.get(loop)
if task is None or task.done():
_wake_events[loop] = asyncio.Event()
_consumers[loop] = loop.create_task(_run(store, loop))
log.debug("deadline consumer started")
def stop_consumer(loop: asyncio.AbstractEventLoop) -> None:
task = _consumers.pop(loop, None)
_wake_events.pop(loop, None)
if task is not None:
task.cancel()
async def _run(store: GameStore, loop: asyncio.AbstractEventLoop) -> None:
wake = _wake_events[loop]
heartbeat = settings.deadline_heartbeat_ms / 1000
while True:
# Clear before polling so an enqueue racing the poll re-wakes us.
wake.clear()
delay = heartbeat
try:
for member in await store.due_deadlines(time.time()):
try:
await process_due(store, member)
except asyncio.CancelledError:
raise
except Exception:
# Left in the queue; retried on the next pass.
log.exception("deadline consumer: failed to process %r", member)
next_due = await store.next_deadline()
if next_due is not None:
delay = max(0.0, min(heartbeat, next_due - time.time()))
except asyncio.CancelledError:
raise
except Exception:
log.exception("deadline consumer: poll failed; retrying")
try:
await asyncio.wait_for(wake.wait(), timeout=delay)
except asyncio.TimeoutError:
pass
class DeadlineSchedulerMixin(KayaMixin):
"""Run the deadline consumer for the whole app lifetime.
Every worker (and every pod) runs the same consumer; coordination
happens exclusively through the shared deadline queue and the per-game
locks, so any worker may fire any game's deadline.
"""
def __init__(self, store: GameStore) -> None:
self._store = store
def apply(self, app: KayaApp) -> None:
pass
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
ensure_consumer(self._store, loop)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
stop_consumer(loop)
+49
View File
@@ -0,0 +1,49 @@
"""Chess-style Elo ratings, generalized to two-team matches.
Every player starts at :data:`INITIAL_RATING`. A team's rating is the mean
of its members' current ratings, so the usual chess formula applies
unchanged between the two teams:
* expected score ``E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))``
* actual score ``S`` is 1 for a win and 0 for a loss (matches never draw)
* every member of a team gains/loses the same ``round(K * (S - E))``
Deltas are rounded to integers and ratings are stored as integers, so the
system is exactly zero-sum: what the winners gain the losers lose.
"""
from __future__ import annotations
from typing import Sequence
INITIAL_RATING = 1500
K_FACTOR = 32
def expected_score(rating: float, opponent_rating: float) -> float:
"""Expected score (0..1) of a side rated ``rating`` against
``opponent_rating``."""
return 1.0 / (1.0 + 10.0 ** ((opponent_rating - rating) / 400.0))
def team_rating(ratings: Sequence[float]) -> float:
"""A team's rating is the mean of its members' ratings."""
if not ratings:
raise ValueError("a team needs at least one rating")
return sum(ratings) / len(ratings)
def match_delta(
team_a_ratings: Sequence[float],
team_b_ratings: Sequence[float],
winner_team: int,
) -> int:
"""Rating change applied to each member of team A.
``winner_team`` is 0 when team A won, 1 when team B won. Team B
members change by the negation of the returned value (zero-sum).
"""
rating_a = team_rating(team_a_ratings)
rating_b = team_rating(team_b_ratings)
expected = expected_score(rating_a, rating_b)
score = 1.0 if winner_team == 0 else 0.0
return round(K_FACTOR * (score - expected))
+63 -1
View File
@@ -1,6 +1,7 @@
"""Pure rules engine for scopone scientifico. """Pure rules engine for scopone scientifico.
Every function here is deterministic and I/O-free: it mutates (or reads) Every function here is deterministic and I/O-free (the only side effect is
debug logging): it mutates (or reads)
:class:`~tavolo.game.state.GameState` and raises :class:`~tavolo.game.state.GameState` and raises
:class:`~tavolo.game.errors.GameError` subclasses on rule violations. This :class:`~tavolo.game.errors.GameError` subclasses on rule violations. This
makes the whole rule set unit-testable without Redis, Postgres or HTTP. makes the whole rule set unit-testable without Redis, Postgres or HTTP.
@@ -21,6 +22,10 @@ Rules implemented
cards), ``settebello`` (the 7 of diamonds), ``primiera`` (best cards), ``settebello`` (the 7 of diamonds), ``primiera`` (best
seven/five/four/three card of each suit, all four suits required), plus seven/five/four/three card of each suit, all four suits required), plus
one point per ``scopa``. Ties on carte/denara/primiera award nothing. one point per ``scopa``. Ties on carte/denara/primiera award nothing.
* Optional ``napola`` rule (enabled by default): the longest run of
consecutive denari starting from the ace scores one point per card when
it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
capturing the whole denari suit (ace to king) wins the match instantly.
* The match ends when a team reaches the target score with a clear lead; a * The match ends when a team reaches the target score with a clear lead; a
tie at or above the target is broken by playing another hand. tie at or above the target is broken by playing another hand.
""" """
@@ -29,6 +34,7 @@ from __future__ import annotations
import random import random
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from itertools import combinations from itertools import combinations
from logging import getLogger
from typing import Dict, List, Optional, Sequence, Tuple from typing import Dict, List, Optional, Sequence, Tuple
from .errors import ( from .errors import (
@@ -55,6 +61,8 @@ from .state import (
parse_card, parse_card,
) )
log = getLogger(__name__)
# Number of cards dealt to each player at the start of a hand. # Number of cards dealt to each player at the start of a hand.
HAND_SIZE = 10 HAND_SIZE = 10
PLAYERS = 4 PLAYERS = 4
@@ -130,6 +138,8 @@ def create_game(
target_score: int = DEFAULT_TARGET_SCORE, target_score: int = DEFAULT_TARGET_SCORE,
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
game_type: str = "scopone_scientifico",
napola: bool = True,
) -> GameState: ) -> GameState:
"""Create a lobby game with the creator seated first.""" """Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100: if target_score < 1 or target_score > 100:
@@ -138,7 +148,9 @@ def create_game(
id=game_id, id=game_id,
join_code=join_code, join_code=join_code,
creator_sub=creator_sub, creator_sub=creator_sub,
game_type=game_type,
target_score=target_score, target_score=target_score,
napola=napola,
phase=PHASE_LOBBY, phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout, hand_ack_timeout=hand_ack_timeout,
@@ -191,6 +203,7 @@ def _deal_hand(state: GameState) -> None:
for seat in range(PLAYERS): for seat in range(PLAYERS):
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS) player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
player.hand.append(deck.pop()) player.hand.append(deck.pop())
log.debug("game %s: hand %d dealt (dealer seat %d)", state.id, state.hand_number, state.dealer)
def _player_at(state: GameState, seat: int) -> PlayerState: def _player_at(state: GameState, seat: int) -> PlayerState:
@@ -334,11 +347,32 @@ def _end_hand(state: GameState) -> None:
state.hand_scores.append(details) state.hand_scores.append(details)
a, b = state.scores a, b = state.scores
log.debug(
"game %s: hand %d scored A+%d B+%d (totals %d-%d)",
state.id,
state.hand_number,
points[0],
points[1],
a,
b,
)
# A full napola (the whole denari suit) wins the match outright,
# regardless of the score.
napola = details.get("napola")
if isinstance(napola, dict):
for team, name in enumerate(TEAM_NAMES):
if napola.get(name) == 10:
state.phase = PHASE_FINISHED
state.winner = team
state.finished_at = datetime.now(timezone.utc).isoformat()
log.info("game %s: team %s swept the denari (napola) and wins", state.id, name)
return
reached = max(a, b) >= state.target_score reached = max(a, b) >= state.target_score
if reached and a != b: if reached and a != b:
state.phase = PHASE_FINISHED state.phase = PHASE_FINISHED
state.winner = 0 if a > b else 1 state.winner = 0 if a > b else 1
state.finished_at = datetime.now(timezone.utc).isoformat() state.finished_at = datetime.now(timezone.utc).isoformat()
log.debug("game %s: match ended, team %s wins", state.id, "A" if state.winner == 0 else "B")
return return
# Pause for the scoring summary instead of dealing immediately. # Pause for the scoring summary instead of dealing immediately.
@@ -387,6 +421,22 @@ def primiera_score(captured: Sequence[Card]) -> int:
return sum(best.values()) return sum(best.values())
def napola_score(captured: Sequence[Card]) -> int:
"""Return the napola value of a capture pile.
The longest run of consecutive denari starting from the ace scores one
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4,
...), so the whole suit (ace to king) is worth 10. Shorter runs score
nothing. Only one team can score a napola: the ace of denari belongs
to exactly one capture pile.
"""
ranks = {card.rank for card in captured if card.suit == "D"}
run = 0
while run + 1 in ranks:
run += 1
return run if run >= 3 else 0
def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]: def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
"""Compute the hand points for both teams (index 0 = team A).""" """Compute the hand points for both teams (index 0 = team A)."""
piles: List[List[Card]] = [[], []] piles: List[List[Card]] = [[], []]
@@ -443,6 +493,16 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
"scope": {"A": scope[0], "B": scope[1]}, "scope": {"A": scope[0], "B": scope[1]},
"award": award, "award": award,
} }
# Napola (optional rule): consecutive denari from the ace. A run of 10
# means the team swept the whole suit and wins the match instantly.
if state.napola:
napola = [napola_score(piles[t]) for t in (0, 1)]
for team in (0, 1):
points[team] += napola[team]
award["napola"] = next(
(TEAM_NAMES[t] for t in (0, 1) if napola[t] > 0), None
)
details["napola"] = {"A": napola[0], "B": napola[1]}
return points, details return points, details
@@ -473,8 +533,10 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
payload: Dict[str, object] = { payload: Dict[str, object] = {
"id": state.id, "id": state.id,
"join_code": state.join_code, "join_code": state.join_code,
"game_type": state.game_type,
"phase": state.phase, "phase": state.phase,
"target_score": state.target_score, "target_score": state.target_score,
"napola": state.napola,
"hand_number": state.hand_number, "hand_number": state.hand_number,
"dealer": state.dealer, "dealer": state.dealer,
"turn": state.turn, "turn": state.turn,
+11
View File
@@ -148,7 +148,14 @@ class GameState:
id: str id: str
join_code: str join_code: str
creator_sub: str creator_sub: str
# Which card game this state belongs to (see tavolo.games.GAME_TYPES).
# Defaults so states serialized before game types existed still load.
game_type: str = "scopone_scientifico"
target_score: int = DEFAULT_TARGET_SCORE target_score: int = DEFAULT_TARGET_SCORE
# Whether the napola rule is scored (denari run from the ace; a full
# suit wins the match instantly). Default on, also for states
# serialized before the option existed.
napola: bool = True
phase: str = PHASE_LOBBY phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list) players: List[PlayerState] = field(default_factory=list)
table: List[Card] = field(default_factory=list) table: List[Card] = field(default_factory=list)
@@ -184,7 +191,9 @@ class GameState:
"id": self.id, "id": self.id,
"join_code": self.join_code, "join_code": self.join_code,
"creator_sub": self.creator_sub, "creator_sub": self.creator_sub,
"game_type": self.game_type,
"target_score": self.target_score, "target_score": self.target_score,
"napola": self.napola,
"phase": self.phase, "phase": self.phase,
"players": [p.to_json() for p in self.players], "players": [p.to_json() for p in self.players],
"table": [c.to_json() for c in self.table], "table": [c.to_json() for c in self.table],
@@ -212,7 +221,9 @@ class GameState:
id=str(data["id"]), id=str(data["id"]),
join_code=str(data["join_code"]), join_code=str(data["join_code"]),
creator_sub=str(data.get("creator_sub", "")), creator_sub=str(data.get("creator_sub", "")),
game_type=str(data.get("game_type", "scopone_scientifico")),
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
napola=bool(data.get("napola", True)),
phase=str(data.get("phase", PHASE_LOBBY)), phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])], players=[PlayerState.from_json(p) for p in data.get("players", [])],
table=[Card.from_json(c) for c in data.get("table", [])], table=[Card.from_json(c) for c in data.get("table", [])],
+42
View File
@@ -0,0 +1,42 @@
"""Registry of the card games the platform can host.
Only *scopone scientifico* is implemented for now; adding a game means a
new entry here plus its engine. The registry is the single source of truth
for the ``game_type`` carried by every live game (:mod:`tavolo.game.state`)
and persisted on each finished match (:mod:`tavolo.models`), which is what
makes match statistics game-scoped.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Optional
@dataclass(frozen=True)
class GameType:
"""Metadata describing one playable card game."""
id: str
name: str
description: str
SCOPONE_SCIENTIFICO = "scopone_scientifico"
GAME_TYPES: Dict[str, GameType] = {
SCOPONE_SCIENTIFICO: GameType(
id=SCOPONE_SCIENTIFICO,
name="Scopone scientifico",
description=(
"Four players in fixed partnerships, ten cards each and an empty "
"table. First team to the target score wins."
),
),
}
DEFAULT_GAME_TYPE = SCOPONE_SCIENTIFICO
def get_game_type(game_type_id: str) -> Optional[GameType]:
"""Return the registered game type with id ``game_type_id``, if any."""
return GAME_TYPES.get(game_type_id)
+74
View File
@@ -0,0 +1,74 @@
"""Logging setup for the tavolo application.
Configured once at app import time (see :mod:`tavolo.app`). By default a
single console handler at DEBUG level is installed, formatting records as
``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``.
Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take
over the configuration entirely; the file follows the
:data:`logging.config.dictConfig` schema, e.g.::
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
``disable_existing_loggers`` should stay ``false``: Granian configures its
own loggers before importing the application, and disabling them would
silence the server and Tortoise.
"""
from __future__ import annotations
from logging.config import dictConfig
from typing import Optional
import yaml
DEFAULT_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"level": "DEBUG",
},
},
"root": {
"level": "DEBUG",
"handlers": ["console"],
},
}
def configure_logging(config_path: Optional[str]) -> None:
"""Apply the YAML logging configuration at ``config_path``, or the
built-in default when unset."""
if config_path is None:
dictConfig(DEFAULT_CONFIG)
return
try:
with open(config_path, "rb") as handle:
config = yaml.safe_load(handle)
except OSError as exc:
raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc
if not isinstance(config, dict):
raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}")
dictConfig(config)
+30 -1
View File
@@ -7,17 +7,25 @@ a player took part in, with the final score":
* :class:`Match` — one row per finished match with both teams' scores. * :class:`Match` — one row per finished match with both teams' scores.
* :class:`MatchPlayer` — one row per participant, linking an OIDC * :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat/team and whether they won. ``sub`` to a seat/team and whether they won.
* :class:`PlayerRating` — current chess-style Elo rating of a player for
one game type, updated transactionally with every finished match (see
:mod:`tavolo.elo`).
""" """
from __future__ import annotations from __future__ import annotations
from tortoise import fields from tortoise import fields
from tortoise.models import Model from tortoise.models import Model
from .elo import INITIAL_RATING
class Match(Model): class Match(Model):
"""A completed scopone scientifico match.""" """A completed match of one of the registered game types."""
id = fields.UUIDField(pk=True) id = fields.UUIDField(pk=True)
# Which card game was played (tavolo.games.GAME_TYPES); the default
# backfills matches recorded before game types existed.
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
team_a_score = fields.SmallIntField() team_a_score = fields.SmallIntField()
team_b_score = fields.SmallIntField() team_b_score = fields.SmallIntField()
# "A" or "B". # "A" or "B".
@@ -47,7 +55,28 @@ class MatchPlayer(Model):
seat = fields.SmallIntField() seat = fields.SmallIntField()
team = fields.CharField(max_length=1) team = fields.CharField(max_length=1)
won = fields.BooleanField() won = fields.BooleanField()
# Elo change this match produced for the player (see tavolo.elo);
# null for matches recorded before ratings existed.
elo_delta = fields.SmallIntField(null=True)
class Meta: class Meta:
table = "match_player" table = "match_player"
unique_together = (("match", "user_sub"),) unique_together = (("match", "user_sub"),)
class PlayerRating(Model):
"""Current Elo rating of one player for one game type."""
id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255)
# Which card game the rating applies to (tavolo.games.GAME_TYPES).
game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING)
matches_played = fields.IntField(default=0)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "player_rating"
unique_together = (("user_sub", "game_type"),)
indexes = (("game_type", "rating"),)
+63 -3
View File
@@ -10,20 +10,24 @@ from __future__ import annotations
import secrets import secrets
import uuid import uuid
from logging import getLogger
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from kaya.core import HttpContext from kaya.core import HttpContext
from kaya.openapi import operation from kaya.openapi import operation
from .. import auth from .. import auth, deadlines
from ..app import app, game_store, oidc_mixin from ..app import app, game_store, oidc_mixin
from ..auth import require_auth from ..auth import require_auth
from ..config import settings from ..config import settings
from ..game import engine from ..game import engine
from ..game.errors import GameError from ..game.errors import GameError
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
from ..games import GAME_TYPES, get_game_type
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
log = getLogger(__name__)
# Join codes avoid characters that are easy to confuse when read aloud. # Join codes avoid characters that are easy to confuse when read aloud.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" _CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6 _CODE_LENGTH = 6
@@ -46,7 +50,9 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
return { return {
"id": state.id, "id": state.id,
"join_code": state.join_code, "join_code": state.join_code,
"game_type": state.game_type,
"target_score": state.target_score, "target_score": state.target_score,
"napola": state.napola,
"phase": state.phase, "phase": state.phase,
"players": [ "players": [
{"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"} {"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"}
@@ -56,6 +62,21 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
} }
@app.GET("/api/game-types")
@operation(summary="List available games",
description="Every card game the platform can host, for the "
"match-creation dropdown.",
tags=["games"],
responses={200: {"description": "The available game types"}})
async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"results": [
{"id": g.id, "name": g.name, "description": g.description}
for g in GAME_TYPES.values()
]
})
@app.POST("/api/games") @app.POST("/api/games")
@operation(summary="Create a game", @operation(summary="Create a game",
description="Creates a lobby game and seats the caller in seat 0. " description="Creates a lobby game and seats the caller in seat 0. "
@@ -68,7 +89,18 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
"game_type": {
"type": "string",
"default": "scopone_scientifico",
"description": "One of the ids from GET /api/game-types",
},
"target_score": {"type": "integer", "minimum": 1, "maximum": 100}, "target_score": {"type": "integer", "minimum": 1, "maximum": 100},
"napola": {
"type": "boolean",
"default": True,
"description": "Score the napola rule; a full "
"denari sweep wins the match",
},
}, },
} }
} }
@@ -76,7 +108,7 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
}, },
responses={ responses={
201: {"description": "The created lobby"}, 201: {"description": "The created lobby"},
400: {"description": "Invalid target_score or body"}, 400: {"description": "Invalid game_type, target_score or body"},
401: {"description": "Authentication required"}, 401: {"description": "Authentication required"},
}) })
@require_auth @require_auth
@@ -93,6 +125,16 @@ async def create_game(ctx: HttpContext) -> None:
await send_error(ctx, 400, "target_score must be an integer") await send_error(ctx, 400, "target_score must be an integer")
return return
game_type: Any = body.get("game_type", "scopone_scientifico")
if not isinstance(game_type, str) or get_game_type(game_type) is None:
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
return
napola: Any = body.get("napola", True)
if not isinstance(napola, bool):
await send_error(ctx, 400, "napola must be a boolean")
return
user = oidc_mixin.get_user(ctx) user = oidc_mixin.get_user(ctx)
assert user is not None # enforced by @require_auth assert user is not None # enforced by @require_auth
game_id = str(uuid.uuid4()) game_id = str(uuid.uuid4())
@@ -106,11 +148,20 @@ async def create_game(ctx: HttpContext) -> None:
target_score=target_score, target_score=target_score,
hand_ack_timeout=settings.hand_ack_timeout_seconds, hand_ack_timeout=settings.hand_ack_timeout_seconds,
turn_timeout=settings.turn_timeout_seconds, turn_timeout=settings.turn_timeout_seconds,
game_type=game_type,
napola=napola,
) )
except GameError as exc: except GameError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
return return
await game_store.save(state) await game_store.save(state)
log.info(
"game %s created by %s (%s, target score %d)",
game_id,
user.sub,
game_type,
target_score,
)
await send_json(ctx, 201, _lobby_payload(state)) await send_json(ctx, 201, _lobby_payload(state))
@@ -154,6 +205,7 @@ async def join_game(ctx: HttpContext) -> None:
assert user is not None assert user is not None
existing = await game_store.find_by_code(code) existing = await game_store.find_by_code(code)
if existing is None: if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
await send_error(ctx, 404, "unknown join code") await send_error(ctx, 404, "unknown join code")
return return
@@ -165,14 +217,22 @@ async def join_game(ctx: HttpContext) -> None:
try: try:
engine.join_game(state, user.sub, auth.display_name(user)) engine.join_game(state, user.sub, auth.display_name(user))
except GameError as exc: except GameError as exc:
log.debug("join rejected for %s in game %s: %s", user.sub, state.id, exc)
await send_error(ctx, 409, str(exc)) await send_error(ctx, 409, str(exc))
return return
await game_store.save(state) await game_store.save(state)
await game_store.publish(state.id) await game_store.publish(state.id)
# When the fourth join started the match, the first turn deadline
# was armed; queue it so it fires even if nobody ever connects.
await deadlines.sync_deadline(game_store, state)
seat = next(p.seat for p in state.players if p.sub == user.sub)
if state.phase == PHASE_LOBBY: if state.phase == PHASE_LOBBY:
await send_json(ctx, 200, _lobby_payload(state)) log.info("%s joined game %s (seat %d, %d/4 players)", user.sub, state.id, seat, len(state.players))
else: else:
log.info("%s joined game %s (seat %d); match started", user.sub, state.id, seat)
await send_json(ctx, 200, engine.state_for_player(state, user.sub)) await send_json(ctx, 200, engine.state_for_player(state, user.sub))
return
await send_json(ctx, 200, _lobby_payload(state))
@app.GET("/api/games/${game_id}") @app.GET("/api/games/${game_id}")
+17 -50
View File
@@ -1,10 +1,10 @@
"""Static hosting for the compiled single-page application. """SPA shell hosting for the compiled single-page application.
In production the kaya backend itself serves the WASM frontend built into Static assets (wasm, js, css, card images) are served by Granian itself
``STATIC_DIR`` (the ``web/dist`` output of ``trunk build --release``; see under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
the Docker image). A glob catch-all (``/*``) handles every path that did Dockerfile) and never reach Python. This module only serves ``index.html``
not match an API or auth route: real files are served with their content from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
type, anything else falls back to ``index.html`` so client-side routes every path no API or auth route claimed, so client-side routes
(``/game/<id>`` etc.) work on direct loads and refreshes. (``/game/<id>`` etc.) work on direct loads and refreshes.
kaya-openapi deliberately skips glob routes, so this handler never appears kaya-openapi deliberately skips glob routes, so this handler never appears
@@ -14,66 +14,33 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Mapping
from kaya.core import HttpContext from kaya.core import HttpContext
from ..app import app from ..app import app
from ..config import settings from ..config import settings
# Explicit content types: wasm-pack/trunk outputs (.wasm, .js) are not
# consistently covered by the system mime database in slim containers.
_CONTENT_TYPES: Mapping[str, str] = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".wasm": "application/wasm",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".json": "application/json",
".webmanifest": "application/manifest+json",
".woff2": "font/woff2",
}
async def _send_shell(ctx: HttpContext) -> None:
def _static_root() -> Path: """Serve the SPA shell, or 404 when the frontend build is missing."""
return Path(settings.static_dir).resolve() index = Path(settings.static_dir) / "index.html"
if not index.is_file():
async def _send_path(ctx: HttpContext, target: Path) -> None:
"""Serve a static file, or 404 when it does not exist.
Uses ``send_bytes`` rather than kaya's ``send_file`` (unimplemented by
the ASGI adapter); frontend artifacts are small enough to buffer.
"""
if not target.is_file():
await ctx.send_empty(404) await ctx.send_empty(404)
return return
content_type = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream") body = await asyncio.to_thread(index.read_bytes)
body = await asyncio.to_thread(target.read_bytes) await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
await ctx.send_bytes(200, body, {"content-type": (content_type,)})
@app.GET("/") @app.GET("/")
async def index(ctx: HttpContext) -> None: async def index(ctx: HttpContext) -> None:
"""Serve the SPA shell at the site root (the glob below cannot match """Serve the SPA shell at the site root (the glob below cannot match
an empty path).""" an empty path)."""
await _send_path(ctx, _static_root() / "index.html") await _send_shell(ctx)
@app.GET("/*", recursive=True) @app.GET("/*", recursive=True)
async def spa(ctx: HttpContext, _matched: object = None) -> None: async def spa(ctx: HttpContext, _matched: object = None) -> None:
root = _static_root() """SPA fallback: any path that matched no other route renders the app
relative = ctx.path.lstrip("/") shell. Requests under ``/static`` are answered by Granian before the
target = (root / relative).resolve() if relative else root app is ever called, so they never arrive here."""
# Path-traversal guard: the resolved target must stay inside the dist await _send_shell(ctx)
# directory.
if root != target and root not in target.parents:
await ctx.send_empty(404)
return
if not target.is_file():
# SPA fallback: unknown paths render the app shell.
target = root / "index.html"
await _send_path(ctx, target)
+89 -10
View File
@@ -6,23 +6,48 @@ leaderboard aggregated from the same two tables.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List from typing import Any, Dict, List, Optional, Tuple
from kaya.core import HttpContext from kaya.core import HttpContext
from kaya.openapi import operation from kaya.openapi import operation
from ..app import app, oidc_mixin from ..app import app, oidc_mixin
from ..auth import require_auth from ..auth import require_auth
from ..http import send_error, send_json from ..elo import INITIAL_RATING
from ..models import Match, MatchPlayer from ..games import DEFAULT_GAME_TYPE, get_game_type
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer, PlayerRating
from ..openapi import PAGINATION_PARAMETERS from ..openapi import PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params from ..pagination import CursorDecodeError, paginate, parse_cursor_params
GAME_TYPE_PARAMETER: Dict[str, Any] = {
"name": "game_type",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Only count matches of this game (id from GET /api/game-types).",
}
def _parse_game_type(query_string: str) -> Tuple[Optional[str], Optional[str]]:
"""Parse the ``game_type`` query parameter.
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
when valid, ``(None, message)`` when it names no registered game."""
values = extract_query_params(query_string).get("game_type")
if not values:
return None, None
game_type = values[0]
if get_game_type(game_type) is None:
return None, f"unknown game_type: {game_type!r}"
return game_type, None
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]: async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat") participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
return { return {
"id": str(match.id), "id": str(match.id),
"game_type": match.game_type,
"team_a_score": match.team_a_score, "team_a_score": match.team_a_score,
"team_b_score": match.team_b_score, "team_b_score": match.team_b_score,
"winner_team": match.winner_team, "winner_team": match.winner_team,
@@ -31,6 +56,9 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
"started_at": match.started_at.isoformat(), "started_at": match.started_at.isoformat(),
"finished_at": match.finished_at.isoformat(), "finished_at": match.finished_at.isoformat(),
"you_won": any(p.user_sub == viewer and p.won for p in participants), "you_won": any(p.user_sub == viewer and p.won for p in participants),
"your_elo_delta": next(
(p.elo_delta for p in participants if p.user_sub == viewer), None
),
"players": [ "players": [
{ {
"user_sub": p.user_sub, "user_sub": p.user_sub,
@@ -38,6 +66,7 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
"seat": p.seat, "seat": p.seat,
"team": p.team, "team": p.team,
"won": p.won, "won": p.won,
"elo_delta": p.elo_delta,
} }
for p in participants for p in participants
], ],
@@ -49,10 +78,10 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
description="Cursor-paginated history of finished matches the caller " description="Cursor-paginated history of finished matches the caller "
"played, newest first, with the final score.", "played, newest first, with the final score.",
tags=["stats"], tags=["stats"],
parameters=PAGINATION_PARAMETERS, parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={ responses={
200: {"description": "A page of matches"}, 200: {"description": "A page of matches"},
400: {"description": "Invalid pagination cursor"}, 400: {"description": "Invalid pagination cursor or game_type"},
401: {"description": "Authentication required"}, 401: {"description": "Authentication required"},
}) })
@require_auth @require_auth
@@ -62,9 +91,15 @@ async def my_matches(ctx: HttpContext) -> None:
except CursorDecodeError as exc: except CursorDecodeError as exc:
await send_error(ctx, 400, str(exc)) await send_error(ctx, 400, str(exc))
return return
game_type, error = _parse_game_type(ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
user = oidc_mixin.get_user(ctx) user = oidc_mixin.get_user(ctx)
assert user is not None assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct() queryset = Match.filter(players__user_sub=user.sub).distinct()
if game_type is not None:
queryset = queryset.filter(game_type=game_type)
matches, next_cursor = await paginate( matches, next_cursor = await paginate(
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
) )
@@ -74,12 +109,29 @@ async def my_matches(ctx: HttpContext) -> None:
@app.GET("/api/leaderboard") @app.GET("/api/leaderboard")
@operation(summary="Global leaderboard", @operation(summary="Global leaderboard",
description="Aggregated wins, matches played and team points for every " description="Elo rating, aggregated wins, matches played and team "
"player with at least one finished match. Sorted by wins.", "points for every player with at least one finished "
"match. Sorted by Elo rating (the rating for the "
"requested game_type, or the default game when the "
"filter is absent).",
tags=["stats"], tags=["stats"],
responses={200: {"description": "The leaderboard"}}) parameters=[GAME_TYPE_PARAMETER],
responses={
200: {"description": "The leaderboard"},
400: {"description": "Unknown game_type"},
})
async def leaderboard(ctx: HttpContext) -> None: async def leaderboard(ctx: HttpContext) -> None:
rows = await MatchPlayer.all().prefetch_related("match") game_type, error = _parse_game_type(ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
queryset = MatchPlayer.all()
if game_type is not None:
queryset = queryset.filter(match__game_type=game_type)
rows = await queryset.prefetch_related("match")
# Ratings are per game type; without a filter show the default game's.
rating_rows = await PlayerRating.filter(game_type=game_type or DEFAULT_GAME_TYPE)
ratings = {row.user_sub: row.rating for row in rating_rows}
aggregate: Dict[str, Dict[str, Any]] = {} aggregate: Dict[str, Dict[str, Any]] = {}
for row in rows: for row in rows:
entry = aggregate.setdefault( entry = aggregate.setdefault(
@@ -90,6 +142,7 @@ async def leaderboard(ctx: HttpContext) -> None:
"matches": 0, "matches": 0,
"wins": 0, "wins": 0,
"points": 0, "points": 0,
"elo": ratings.get(row.user_sub, INITIAL_RATING),
}, },
) )
entry["matches"] += 1 entry["matches"] += 1
@@ -104,7 +157,33 @@ async def leaderboard(ctx: HttpContext) -> None:
ranking: List[Dict[str, Any]] = sorted( ranking: List[Dict[str, Any]] = sorted(
aggregate.values(), aggregate.values(),
key=lambda e: (e["wins"], e["points"], -e["matches"]), key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
reverse=True, reverse=True,
) )
await send_json(ctx, 200, {"results": ranking}) await send_json(ctx, 200, {"results": ranking})
@app.GET("/api/me/ratings")
@operation(summary="My Elo ratings",
description="The caller's current Elo rating for every game type "
"they have played.",
tags=["stats"],
responses={
200: {"description": "The caller's ratings"},
401: {"description": "Authentication required"},
})
@require_auth
async def my_ratings(ctx: HttpContext) -> None:
user = oidc_mixin.get_user(ctx)
assert user is not None
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
await send_json(ctx, 200, {
"results": [
{
"game_type": row.game_type,
"rating": row.rating,
"matches_played": row.matches_played,
}
for row in rows
]
})
+64 -3
View File
@@ -2,18 +2,23 @@
Called once when a game reaches the finished phase (guarded by the Called once when a game reaches the finished phase (guarded by the
``stats_saved`` flag on the state). The write is transactional so a match ``stats_saved`` flag on the state). The write is transactional so a match
never appears with only some of its players. never appears with only some of its players. The same transaction also
updates the participants' Elo ratings (see :mod:`tavolo.elo`).
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from logging import getLogger
from typing import Dict, List, Optional
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
from .elo import match_delta
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
log = getLogger(__name__)
def _parse_timestamp(value: Optional[str]) -> datetime: def _parse_timestamp(value: Optional[str]) -> datetime:
if value: if value:
@@ -24,6 +29,46 @@ def _parse_timestamp(value: Optional[str]) -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
async def apply_elo(
game_type: str, winner_team: str, team_members: Dict[str, List[str]]
) -> Dict[str, int]:
"""Update the Elo ratings of ``team_members`` for ``game_type``.
``team_members`` maps a team name ("A"/"B") to its players' subs.
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.
"""
from .models import PlayerRating
ratings: Dict[str, PlayerRating] = {}
for subs in team_members.values():
for sub in subs:
rating = await PlayerRating.get_or_none(
user_sub=sub, game_type=game_type
)
if rating is None:
rating = await PlayerRating.create(
id=uuid.uuid4(), user_sub=sub, game_type=game_type
)
ratings[sub] = rating
delta_a = match_delta(
[ratings[sub].rating for sub in team_members["A"]],
[ratings[sub].rating for sub in team_members["B"]],
0 if winner_team == "A" else 1,
)
deltas: Dict[str, int] = {
**{sub: delta_a for sub in team_members["A"]},
**{sub: -delta_a for sub in team_members["B"]},
}
for sub, delta in deltas.items():
rating = ratings[sub]
rating.rating += delta
rating.matches_played += 1
await rating.save()
return deltas
async def save_match_result(state: GameState) -> None: async def save_match_result(state: GameState) -> None:
"""Persist ``state`` to Postgres if it is finished and not yet saved.""" """Persist ``state`` to Postgres if it is finished and not yet saved."""
if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None: if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None:
@@ -33,17 +78,23 @@ async def save_match_result(state: GameState) -> None:
started_at = _parse_timestamp(state.created_at) started_at = _parse_timestamp(state.created_at)
finished_at = _parse_timestamp(state.finished_at) finished_at = _parse_timestamp(state.finished_at)
winner_team = TEAM_NAMES[state.winner]
team_members: Dict[str, List[str]] = {"A": [], "B": []}
for player in state.players:
team_members[TEAM_NAMES[player.team]].append(player.sub)
async with in_transaction(): async with in_transaction():
match = await Match.create( match = await Match.create(
id=uuid.uuid4(), id=uuid.uuid4(),
game_type=state.game_type,
team_a_score=state.scores[0], team_a_score=state.scores[0],
team_b_score=state.scores[1], team_b_score=state.scores[1],
winner_team=TEAM_NAMES[state.winner], winner_team=winner_team,
target_score=state.target_score, target_score=state.target_score,
hands_played=state.hand_number, hands_played=state.hand_number,
started_at=started_at, started_at=started_at,
finished_at=finished_at, finished_at=finished_at,
) )
deltas = await apply_elo(state.game_type, winner_team, team_members)
for player in state.players: for player in state.players:
await MatchPlayer.create( await MatchPlayer.create(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -53,5 +104,15 @@ async def save_match_result(state: GameState) -> None:
seat=player.seat, seat=player.seat,
team=TEAM_NAMES[player.team], team=TEAM_NAMES[player.team],
won=player.team == state.winner, won=player.team == state.winner,
elo_delta=deltas[player.sub],
) )
state.stats_saved = True state.stats_saved = True
log.info(
"match result persisted: game %s (%s), team %s won %d-%d over %d hands",
state.id,
state.game_type,
TEAM_NAMES[state.winner],
state.scores[0],
state.scores[1],
state.hand_number,
)
+71 -1
View File
@@ -18,6 +18,14 @@ channel as a simple "something changed" signal; every open websocket
reloads the state and renders the personalized view. Publishing only a reloads the state and renders the personalized view. Publishing only a
signal (never the state) means updated state reaches connections on every signal (never the state) means updated state reaches connections on every
worker without leaking hidden hands into the channel. worker without leaking hidden hands into the channel.
Timeouts (turn auto-play, hand-end auto-continue) are driven by a shared
delayed-deadline queue: producers enqueue an opaque ``member`` string with
a due timestamp, and a consumer on every worker polls for due entries.
Delivery is at-least-once — entries are removed only after they are
processed — so a worker dying mid-processing cannot lose a deadline;
consumers revalidate entries against the live state under the per-game
lock, which makes duplicate deliveries harmless.
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,15 +33,19 @@ import asyncio
import contextlib import contextlib
import json import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import AsyncContextManager, AsyncIterator, Dict, Optional, Set from logging import getLogger
from typing import AsyncContextManager, AsyncIterator, Dict, List, Optional, Set, cast
from redis.asyncio import Redis from redis.asyncio import Redis
from .game.state import GameState from .game.state import GameState
log = getLogger(__name__)
GAME_KEY_PREFIX = "tavolo:game:" GAME_KEY_PREFIX = "tavolo:game:"
CODE_KEY_PREFIX = "tavolo:code:" CODE_KEY_PREFIX = "tavolo:code:"
CHANNEL_PREFIX = "tavolo:game:" CHANNEL_PREFIX = "tavolo:game:"
DEADLINES_KEY = "tavolo:deadlines"
# Sentinel pushed into in-memory subscriber queues to signal a change. # Sentinel pushed into in-memory subscriber queues to signal a change.
_BUMP = b"update" _BUMP = b"update"
@@ -66,6 +78,26 @@ class GameStore(ABC):
async def publish(self, game_id: str) -> None: async def publish(self, game_id: str) -> None:
"""Signal that the state of ``game_id`` changed.""" """Signal that the state of ``game_id`` changed."""
@abstractmethod
async def add_deadline(self, member: str, due_at: float) -> None:
"""Enqueue ``member`` to fire at ``due_at`` (epoch seconds).
Idempotent for identical members: re-adding an existing member only
updates its due time.
"""
@abstractmethod
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
"""Return up to ``limit`` enqueued members due at or before ``now``."""
@abstractmethod
async def next_deadline(self) -> Optional[float]:
"""Return the earliest pending due time (epoch seconds), if any."""
@abstractmethod
async def remove_deadline(self, member: str) -> None:
"""Remove ``member`` from the queue; a no-op when absent."""
def _channel(game_id: str) -> str: def _channel(game_id: str) -> str:
return f"{CHANNEL_PREFIX}{game_id}:events" return f"{CHANNEL_PREFIX}{game_id}:events"
@@ -86,9 +118,11 @@ class RedisGameStore(GameStore):
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}") raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
if raw is None: if raw is None:
log.debug("redis load %s: miss", game_id)
return None return None
if isinstance(raw, bytes): if isinstance(raw, bytes):
raw = raw.decode("utf-8") raw = raw.decode("utf-8")
log.debug("redis load %s: hit", game_id)
return GameState.from_json(json.loads(raw)) return GameState.from_json(json.loads(raw))
async def save(self, state: GameState) -> None: async def save(self, state: GameState) -> None:
@@ -98,6 +132,7 @@ class RedisGameStore(GameStore):
pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl) pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl)
pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl) pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl)
await pipe.execute() await pipe.execute()
log.debug("redis save %s (phase %s, ttl %ds)", state.id, state.phase, self._ttl)
async def find_by_code(self, code: str) -> Optional[GameState]: async def find_by_code(self, code: str) -> Optional[GameState]:
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}") game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
@@ -120,6 +155,26 @@ class RedisGameStore(GameStore):
async def publish(self, game_id: str) -> None: async def publish(self, game_id: str) -> None:
await self._redis.publish(_channel(game_id), "update") await self._redis.publish(_channel(game_id), "update")
log.debug("redis publish %s", game_id)
async def add_deadline(self, member: str, due_at: float) -> None:
await self._redis.zadd(DEADLINES_KEY, {member: due_at})
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
members = cast(
list,
await self._redis.zrangebyscore(
DEADLINES_KEY, "-inf", now, start=0, num=limit
),
)
return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
async def next_deadline(self) -> Optional[float]:
earliest = await self._redis.zrange(DEADLINES_KEY, 0, 0, withscores=True)
return float(earliest[0][1]) if earliest else None
async def remove_deadline(self, member: str) -> None:
await self._redis.zrem(DEADLINES_KEY, member)
async def _redis_events(pubsub) -> AsyncIterator[None]: async def _redis_events(pubsub) -> AsyncIterator[None]:
@@ -136,6 +191,7 @@ class InMemoryGameStore(GameStore):
self._codes: Dict[str, str] = {} self._codes: Dict[str, str] = {}
self._locks: Dict[str, asyncio.Lock] = {} self._locks: Dict[str, asyncio.Lock] = {}
self._subscribers: Dict[str, Set[asyncio.Queue]] = {} self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
self._deadlines: Dict[str, float] = {}
def _lock_for(self, game_id: str) -> asyncio.Lock: def _lock_for(self, game_id: str) -> asyncio.Lock:
lock = self._locks.get(game_id) lock = self._locks.get(game_id)
@@ -180,6 +236,20 @@ class InMemoryGameStore(GameStore):
for queue in list(self._subscribers.get(game_id, ())): for queue in list(self._subscribers.get(game_id, ())):
queue.put_nowait(_BUMP) queue.put_nowait(_BUMP)
async def add_deadline(self, member: str, due_at: float) -> None:
self._deadlines[member] = due_at
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
due = [m for m, due_at in self._deadlines.items() if due_at <= now]
due.sort(key=self._deadlines.__getitem__)
return due[:limit]
async def next_deadline(self) -> Optional[float]:
return min(self._deadlines.values(), default=None)
async def remove_deadline(self, member: str) -> None:
self._deadlines.pop(member, None)
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]: async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
while True: while True:
+18
View File
@@ -35,6 +35,7 @@ from __future__ import annotations
from asyncio import AbstractEventLoop, get_running_loop from asyncio import AbstractEventLoop, get_running_loop
from logging import getLogger from logging import getLogger
from typing import AbstractSet, Optional, Sequence from typing import AbstractSet, Optional, Sequence
from urllib.parse import urlsplit, urlunsplit
from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket
from tortoise.context import TortoiseContext, _current_context from tortoise.context import TortoiseContext, _current_context
@@ -42,6 +43,19 @@ from tortoise.context import TortoiseContext, _current_context
log = getLogger(__name__) log = getLogger(__name__)
def _safe_url(database_url: str) -> str:
"""The database URL with any credentials stripped, for logging."""
parts = urlsplit(database_url)
host = parts.hostname or ""
try:
if parts.port:
host = f"{host}:{parts.port}"
except ValueError:
# Non-numeric netloc (e.g. sqlite://:memory:): keep the host only.
pass
return urlunsplit((parts.scheme, host, parts.path, "", ""))
class TortoiseMixin(KayaMixin): class TortoiseMixin(KayaMixin):
"""Initialize and tear down a per-loop :class:`TortoiseContext`.""" """Initialize and tear down a per-loop :class:`TortoiseContext`."""
@@ -64,6 +78,7 @@ class TortoiseMixin(KayaMixin):
def shutdown(self, loop: AbstractEventLoop) -> None: def shutdown(self, loop: AbstractEventLoop) -> None:
if self._init_loop is loop and self._ctx is not None: if self._init_loop is loop and self._ctx is not None:
log.info("closing database connections")
loop.create_task(self._ctx.close_connections()) loop.create_task(self._ctx.close_connections())
self._ctx = None self._ctx = None
self._init_loop = None self._init_loop = None
@@ -75,11 +90,13 @@ class TortoiseMixin(KayaMixin):
db_url=self._database_url, db_url=self._database_url,
modules={"models": self._models_modules}, modules={"models": self._models_modules},
) )
log.info("database context initialized (%s)", _safe_url(self._database_url))
# Schema creation is only done for sqlite (in-memory test # Schema creation is only done for sqlite (in-memory test
# databases). Postgres schemas are managed by aerich migrations # databases). Postgres schemas are managed by aerich migrations
# (applied by the db-migrate compose service / `aerich upgrade`). # (applied by the db-migrate compose service / `aerich upgrade`).
if self._database_url.startswith("sqlite"): if self._database_url.startswith("sqlite"):
await ctx.generate_schemas() await ctx.generate_schemas()
log.info("sqlite schemas generated")
return ctx return ctx
async def _bind(self) -> None: async def _bind(self) -> None:
@@ -88,6 +105,7 @@ class TortoiseMixin(KayaMixin):
if self._ctx is not None: if self._ctx is not None:
# A previous test loop went away; drop its context. # A previous test loop went away; drop its context.
self._ctx = None self._ctx = None
log.debug("building a Tortoise context for a new event loop")
self._ctx = await self._build_context() self._ctx = await self._build_context()
self._init_loop = loop self._init_loop = loop
assert self._ctx is not None assert self._ctx is not None
+31 -115
View File
@@ -30,28 +30,30 @@ state is saved to Redis and a change signal is published. Every connected
websocket is subscribed to that signal and re-renders the state, so all websocket is subscribed to that signal and re-renders the state, so all
players see the move immediately (and consistently across workers). players see the move immediately (and consistently across workers).
If a player does not move before the per-game ``turn_timeout``, the server Timeouts do not depend on anyone being connected: both the per-turn
plays a random card (with a random legal capture when one is required) for auto-play and the hand-end auto-continue are driven by the absolute
them, so a disconnected or idle player cannot stall the match. The timer is deadlines persisted on the game state, via the shared deadline queue
re-armed by every client connection and state broadcast, and fires drained by a consumer on every worker (see :mod:`tavolo.deadlines`). A
immediately when a reconnect finds the deadline already past. disconnected or idle player therefore cannot stall the match, and a
worker dying cannot either.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json import json
from contextlib import suppress from contextlib import suppress
from datetime import datetime, timezone from logging import getLogger
from typing import Any, Awaitable, Callable, Dict, Optional from typing import Any, Awaitable, Callable, Dict
from kaya.core import WebSocket from kaya.core import WebSocket
from . import auth from . import auth, deadlines
from .app import app, game_store from .app import app, game_store
from .game import engine from .game import engine
from .game.errors import GameError from .game.errors import GameError
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState from .game.state import PHASE_FINISHED, GameState
from .stats import save_match_result
log = getLogger(__name__)
Send = Callable[[Dict[str, Any]], Awaitable[None]] Send = Callable[[Dict[str, Any]], Awaitable[None]]
@@ -68,18 +70,22 @@ def _state_message(state: GameState, sub: str) -> Dict[str, Any]:
async def game_socket(ws: WebSocket, game_id: str) -> None: async def game_socket(ws: WebSocket, game_id: str) -> None:
user = auth.get_ws_user(ws) user = auth.get_ws_user(ws)
if user is None: if user is None:
log.debug("websocket %s rejected: no authenticated user", game_id)
await ws.close(4401) await ws.close(4401)
return return
state = await game_store.load(game_id) state = await game_store.load(game_id)
if state is None: if state is None:
log.debug("websocket rejected: unknown game %s", game_id)
await ws.close(4404) await ws.close(4404)
return return
if not state.seated(user.sub): if not state.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await ws.close(4403) await ws.close(4403)
return return
await ws.accept() await ws.accept()
log.info("%s connected to game %s", user.sub, game_id)
send_lock = asyncio.Lock() send_lock = asyncio.Lock()
@@ -88,7 +94,10 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
await ws.send_text(json.dumps(payload)) await ws.send_text(json.dumps(payload))
await send(_state_message(state, user.sub)) await send(_state_message(state, user.sub))
schedule_turn_timer(game_id, state) # Backstop: make sure the current phase's deadline is queued even if
# its entry was lost (e.g. the queue was flushed while the game lived
# on thanks to its sliding TTL).
await deadlines.sync_deadline(game_store, state)
async with game_store.subscribe(game_id) as events: async with game_store.subscribe(game_id) as events:
forward = asyncio.create_task( forward = asyncio.create_task(
@@ -106,6 +115,7 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
forward.cancel() forward.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await forward await forward
log.debug("%s disconnected from game %s", user.sub, game_id)
async def _forward( async def _forward(
@@ -118,7 +128,6 @@ async def _forward(
state = await game_store.load(game_id) state = await game_store.load(game_id)
if state is None: if state is None:
return return
schedule_turn_timer(game_id, state)
await send(_state_message(state, sub)) await send(_state_message(state, sub))
if state.phase == PHASE_FINISHED: if state.phase == PHASE_FINISHED:
await send( await send(
@@ -135,9 +144,11 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
try: try:
data = json.loads(raw) data = json.loads(raw)
except (ValueError, TypeError): except (ValueError, TypeError):
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
await send(_error("invalid JSON")) await send(_error("invalid JSON"))
return return
if not isinstance(data, dict): if not isinstance(data, dict):
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
await send(_error("message must be a JSON object")) await send(_error("message must be a JSON object"))
return return
@@ -156,10 +167,6 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
# --- hand-end acknowledgement ------------------------------------------------ # --- hand-end acknowledgement ------------------------------------------------
# Running auto-continue timers, keyed by (game_id, hand_number), so a hand's
# timeout is scheduled only once even when several clients are connected.
_hand_end_timers: Dict[tuple, asyncio.Task] = {}
async def _handle_ack(send: Send, game_id: str, sub: str) -> None: async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
async with game_store.lock(game_id): async with game_store.lock(game_id):
@@ -172,106 +179,12 @@ async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
except GameError as exc: except GameError as exc:
await send(_error(str(exc), code="illegal_move")) await send(_error(str(exc), code="illegal_move"))
return return
log.debug("game %s: %s acknowledged hand %d", game_id, sub, state.hand_number)
await game_store.save(state) await game_store.save(state)
await game_store.publish(game_id) await game_store.publish(game_id)
# The fourth ack deals the next hand, which arms a new turn
# deadline; earlier acks change nothing and this is a no-op.
def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> None: await deadlines.sync_deadline(game_store, state)
"""Deal the next hand after the acknowledgement timeout, even if not
everyone has clicked. Fizzles if the hand already advanced."""
key = (game_id, hand_number)
if key in _hand_end_timers:
return
async def _auto_advance() -> None:
try:
await asyncio.sleep(timeout)
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if (
state is None
or state.phase != engine.PHASE_HAND_END
or state.hand_number != hand_number
):
return
for player in state.players:
engine.acknowledge_hand(state, player.sub)
await game_store.save(state)
await game_store.publish(game_id)
finally:
_hand_end_timers.pop(key, None)
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
# --- auto-play on turn timeout ------------------------------------------------
# Running turn timers, keyed by (game_id, hand_number, turn, deadline), so a
# turn's timeout is scheduled only once even when several clients are
# connected. Including the deadline means a re-arm after a reconnect cannot
# duplicate a timer for a turn that was already auto-played.
_turn_timers: Dict[tuple, asyncio.Task] = {}
def schedule_turn_timer(game_id: str, state: GameState) -> None:
"""Auto-play a random legal card if the player on turn misses the
deadline. Fizzles if the turn already advanced."""
if state.phase != PHASE_PLAYING or not state.turn_deadline:
return
key = (game_id, state.hand_number, state.turn, state.turn_deadline)
if key in _turn_timers:
return
hand_number = state.hand_number
turn = state.turn
deadline_raw = state.turn_deadline
try:
deadline = datetime.fromisoformat(deadline_raw)
except ValueError:
return
async def _auto_play() -> None:
try:
delay = (deadline - datetime.now(timezone.utc)).total_seconds()
await asyncio.sleep(max(delay, 0))
async with game_store.lock(game_id):
state = await game_store.load(game_id)
if (
state is None
or state.phase != PHASE_PLAYING
or state.hand_number != hand_number
or state.turn != turn
or state.turn_deadline != deadline_raw
):
# The turn moved on (or the game ended) without this
# timer firing: make sure the current turn is armed.
if state is not None:
schedule_turn_timer(game_id, state)
return
try:
engine.auto_play(state)
except GameError:
return
await _after_play(state, game_id)
finally:
_turn_timers.pop(key, None)
_turn_timers[key] = asyncio.create_task(_auto_play())
async def _after_play(state: GameState, game_id: str) -> None:
"""Persist a successful move and notify every connected player.
Callers must hold the per-game lock. Handles the two terminal
transitions: the match result is written to Postgres once, and a
hand-end summary schedules the auto-continue timeout.
"""
if state.phase == PHASE_FINISHED:
await save_match_result(state)
elif state.phase == PHASE_HAND_END:
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
await game_store.save(state)
await game_store.publish(game_id)
async def _handle_play( async def _handle_play(
@@ -297,10 +210,13 @@ async def _handle_play(
try: try:
engine.play(state, sub, card, capture) engine.play(state, sub, card, capture)
except GameError as exc: except GameError as exc:
log.debug("game %s: illegal move by %s: %s", game_id, sub, exc)
await send(_error(str(exc), code="illegal_move")) await send(_error(str(exc), code="illegal_move"))
return return
except ValueError: except ValueError:
log.debug("game %s: invalid card code from %s: %r", game_id, sub, card)
await send(_error("invalid card code", code="illegal_move")) await send(_error("invalid card code", code="illegal_move"))
return return
await _after_play(state, game_id) log.debug("game %s: %s played %s (capture: %s)", game_id, sub, card, capture or "-")
await deadlines.finalize_mutation(game_store, state)
+134
View File
@@ -0,0 +1,134 @@
"""Unit tests for the database DSN assembly in :mod:`tavolo.config`.
``Settings.from_env`` is called directly with a fully replaced
``os.environ`` so no test leaks its ``DATABASE_*`` overrides into the
suite (``tests/__init__.py`` sets ``DATABASE_URL=sqlite://:memory:``
globally for the application tests).
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from tavolo.config import Settings
def _settings(env: dict) -> Settings:
with patch.dict(os.environ, env, clear=True):
return Settings.from_env()
class DatabaseUrlTests(unittest.TestCase):
def test_defaults_assemble_from_parts(self):
settings = _settings({})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo",
)
def test_components_override_defaults(self):
settings = _settings({
"DATABASE_ENGINE": "postgres",
"DATABASE_HOST": "db.internal",
"DATABASE_PORT": "5433",
"DATABASE_NAME": "cards",
"DATABASE_USER": "scopa",
"DATABASE_PASSWORD": "s3cret",
})
self.assertEqual(
settings.database_url,
"postgres://scopa:s3cret@db.internal:5433/cards",
)
def test_options_are_appended_as_query_string(self):
settings = _settings({"DATABASE_OPTIONS": "ssl=require"})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo?ssl=require",
)
def test_options_leading_question_mark_is_stripped(self):
settings = _settings({"DATABASE_OPTIONS": "?ssl=require"})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo?ssl=require",
)
def test_credentials_are_percent_encoded(self):
settings = _settings({
"DATABASE_USER": "u@x",
"DATABASE_PASSWORD": "p@ss/word:1",
})
self.assertEqual(
settings.database_url,
"postgres://u%40x:p%40ss%2Fword%3A1@localhost/tavolo",
)
def test_database_url_takes_precedence_over_parts(self):
settings = _settings({
"DATABASE_URL": "sqlite://:memory:",
"DATABASE_HOST": "db.internal",
"DATABASE_PASSWORD": "ignored",
})
self.assertEqual(settings.database_url, "sqlite://:memory:")
def test_empty_database_url_falls_back_to_parts(self):
settings = _settings({"DATABASE_URL": ""})
self.assertEqual(
settings.database_url,
"postgres://tavolo:password@localhost/tavolo",
)
class CorsSettingsTests(unittest.TestCase):
def test_cors_disabled_by_default(self):
settings = _settings({})
self.assertIsNone(settings.cors_allow_origins)
self.assertIsNone(settings.cors_allow_origin_regex)
self.assertIsNone(settings.cors_allow_methods)
self.assertIsNone(settings.cors_allow_headers)
self.assertFalse(settings.cors_allow_credentials)
self.assertIsNone(settings.cors_expose_headers)
self.assertEqual(600, settings.cors_max_age)
def test_allow_origins_parses_comma_separated_list(self):
settings = _settings({
"CORS_ALLOW_ORIGINS": "https://a.example, https://b.example ,,https://c.example",
})
self.assertEqual(
("https://a.example", "https://b.example", "https://c.example"),
settings.cors_allow_origins,
)
def test_allow_origins_star_is_passed_through(self):
settings = _settings({"CORS_ALLOW_ORIGINS": "*"})
self.assertEqual(("*",), settings.cors_allow_origins)
def test_allow_origin_regex_is_passed_through(self):
settings = _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})
self.assertEqual(r"https://.*\.example\.com", settings.cors_allow_origin_regex)
def test_allow_methods_and_headers_parse_as_lists(self):
settings = _settings({
"CORS_ALLOW_METHODS": "GET,POST",
"CORS_ALLOW_HEADERS": "Authorization, X-Custom-Header",
"CORS_EXPOSE_HEADERS": "X-Total-Count",
})
self.assertEqual(("GET", "POST"), settings.cors_allow_methods)
self.assertEqual(("Authorization", "X-Custom-Header"), settings.cors_allow_headers)
self.assertEqual(("X-Total-Count",), settings.cors_expose_headers)
def test_allow_credentials_parses_boolean(self):
for value in ("1", "true", "TRUE", "yes", "on"):
self.assertTrue(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
for value in ("0", "false", "no", "off", "anything-else"):
self.assertFalse(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
def test_max_age_parses_int(self):
settings = _settings({"CORS_MAX_AGE": "3600"})
self.assertEqual(3600, settings.cors_max_age)
if __name__ == "__main__":
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Integration tests for the CORS configuration in :mod:`tavolo.app`.
The mixin under test is kaya-cors' :class:`~kaya.cors.CorsMixin`; these
tests only verify that :func:`tavolo.app.cors_mixin_from_settings` maps the
environment-driven :class:`~tavolo.config.Settings` onto it correctly. A
minimal ``KayaApp`` is used instead of the global ``app`` so the tests do
not depend on the environment the suite was imported with.
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from httpx import ASGITransport, AsyncClient
from kaya.core import HttpContext, KayaApp
from pwo import async_test
from tavolo.app import cors_mixin_from_settings
from tavolo.config import Settings
ORIGIN = "https://cards.example"
def _settings(env: dict) -> Settings:
with patch.dict(os.environ, env, clear=True):
return Settings.from_env()
def _app(settings: Settings) -> KayaApp:
mixin = cors_mixin_from_settings(settings)
assert mixin is not None
app = KayaApp(mixins=[mixin])
@app.GET("/api/health")
async def health(ctx: HttpContext) -> None:
await ctx.send_str(200, "ok")
return app
class CorsMixinFromSettingsTests(unittest.TestCase):
def test_disabled_when_unconfigured(self):
self.assertIsNone(cors_mixin_from_settings(_settings({})))
def test_enabled_by_allow_origins(self):
self.assertIsNotNone(cors_mixin_from_settings(
_settings({"CORS_ALLOW_ORIGINS": ORIGIN})))
def test_enabled_by_allow_origin_regex_alone(self):
self.assertIsNotNone(cors_mixin_from_settings(
_settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})))
class CorsBehaviorTests(unittest.TestCase):
@async_test
async def test_request_without_origin_is_untouched(self) -> None:
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.get("/api/health")
self.assertEqual(200, response.status_code)
self.assertNotIn("access-control-allow-origin", response.headers)
@async_test
async def test_simple_request_with_allowed_origin(self) -> None:
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.get("/api/health", headers={"Origin": ORIGIN})
self.assertEqual(200, response.status_code)
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
@async_test
async def test_simple_request_with_disallowed_origin(self) -> None:
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.get(
"/api/health", headers={"Origin": "https://mallory.example"})
self.assertEqual(200, response.status_code)
self.assertNotIn("access-control-allow-origin", response.headers)
@async_test
async def test_preflight_allowed(self) -> None:
app = _app(_settings({
"CORS_ALLOW_ORIGINS": ORIGIN,
"CORS_ALLOW_METHODS": "GET,POST",
"CORS_MAX_AGE": "3600",
}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.options("/api/health", headers={
"Origin": ORIGIN,
"Access-Control-Request-Method": "POST",
})
self.assertEqual(200, response.status_code)
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
self.assertEqual("GET, POST", response.headers["access-control-allow-methods"])
self.assertEqual("3600", response.headers["access-control-max-age"])
@async_test
async def test_preflight_disallowed_origin(self) -> None:
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.options("/api/health", headers={
"Origin": "https://mallory.example",
"Access-Control-Request-Method": "GET",
})
self.assertEqual(400, response.status_code)
self.assertIn("Disallowed CORS", response.text)
@async_test
async def test_credentials_echo_origin_and_set_flag(self) -> None:
app = _app(_settings({
"CORS_ALLOW_ORIGINS": "*",
"CORS_ALLOW_CREDENTIALS": "true",
}))
async with AsyncClient(transport=ASGITransport(app=app),
base_url="http://127.0.0.1") as client:
response = await client.get("/api/health", headers={"Origin": ORIGIN})
self.assertEqual(200, response.status_code)
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
self.assertEqual("true", response.headers["access-control-allow-credentials"])
if __name__ == "__main__":
unittest.main()
+191
View File
@@ -0,0 +1,191 @@
"""Deadline-queue timeout tests.
Timeouts must be driven by the persisted deadlines and the shared queue,
not by connected sockets: these tests seed games, queue their deadlines
and let the background consumer fire them without a single websocket.
"""
from __future__ import annotations
import asyncio
import unittest
from datetime import datetime, timedelta, timezone
from typing import Optional
from pwo import async_test
from tavolo import deadlines
from tavolo.app import game_store
from tavolo.game import engine
from tavolo.game.state import GameState, PlayerState
PLAYERS = ("alice", "bob", "carol", "dave")
def _ms(iso: str) -> int:
"""Epoch milliseconds for an ISO-8601 timestamp (the queue-entry form)."""
return int(datetime.fromisoformat(iso).timestamp() * 1000)
def _hand_end_state(game_id: str, deadline: str) -> GameState:
"""A game paused on the hand-end summary, waiting for acks."""
state = GameState(
id=game_id,
join_code="DLhend",
creator_sub="alice",
target_score=11,
phase="hand_end",
# Long turn timeout: the next hand's auto-play must not interfere
# with later tests sharing this store.
turn_timeout=3600,
)
state.players = [
PlayerState(sub=name, name=name.capitalize(), seat=i)
for i, name in enumerate(PLAYERS)
]
state.hand_end_deadline = deadline
return state
async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]:
"""Poll the store until ``predicate`` holds for the loaded state."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
state = await predicate()
if state is not None:
return state
await asyncio.sleep(0.05)
return None
class ConnectionIndependenceTest(unittest.TestCase):
@async_test
async def test_turn_timeout_fires_with_no_connections(self) -> None:
state = engine.create_game(
"dl-turn-1", "DLT001", "alice", "Alice",
target_score=11, turn_timeout=1,
)
for name in PLAYERS[1:]:
engine.join_game(state, name, name.capitalize())
assert state.turn_deadline is not None
await game_store.save(state)
await deadlines.sync_deadline(game_store, state)
# Nobody ever connects: the consumer must still auto-play for Bob
# (seat 1, first to act).
result = await _wait_for(
lambda: _turn_is(state.id, 2),
)
self.assertIsNotNone(result, "turn deadline never fired")
assert result is not None
self.assertEqual(1, result.last_move.seat if result.last_move else None)
# Defuse the follow-on turn deadlines so this game cannot keep
# auto-playing while later tests run.
result.turn_timeout = 3600
await game_store.save(result)
@async_test
async def test_hand_end_timeout_fires_with_no_connections(self) -> None:
deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat()
state = _hand_end_state("dl-handend-1", deadline)
await game_store.save(state)
await deadlines.sync_deadline(game_store, state)
# Nobody acks (nobody is even connected): the deadline must deal
# the next hand.
result = await _wait_for(
lambda: _phase_is("dl-handend-1", "playing"),
)
self.assertIsNotNone(result, "hand-end deadline never fired")
assert result is not None
self.assertEqual(2, result.hand_number)
self.assertEqual([], result.acked)
async def _turn_is(game_id: str, turn: int) -> Optional[GameState]:
state = await game_store.load(game_id)
return state if state is not None and state.turn == turn else None
async def _phase_is(game_id: str, phase: str) -> Optional[GameState]:
state = await game_store.load(game_id)
return state if state is not None and state.phase == phase else None
class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: revalidation and idempotency."""
@async_test
async def test_processing_twice_is_a_no_op(self) -> None:
# Simulates a worker dying after firing but before removing the
# entry: another worker re-delivers the same entry.
deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
state = _hand_end_state("dl-idem-1", deadline)
await game_store.save(state)
member = deadlines.encode({
"game_id": state.id,
"kind": deadlines.KIND_HAND_END,
"hand": state.hand_number,
"deadline": _ms(deadline),
})
await deadlines.process_due(game_store, member)
await deadlines.process_due(game_store, member)
result = await game_store.load(state.id)
assert result is not None
# Advanced exactly once: hand 2, not hand 3.
self.assertEqual("playing", result.phase)
self.assertEqual(2, result.hand_number)
@async_test
async def test_stale_entry_is_discarded(self) -> None:
# A turn entry enqueued before a play landed in time: the state's
# deadline has moved, so the entry must not fire.
state = engine.create_game(
"dl-stale-1", "DLS001", "alice", "Alice",
target_score=11, turn_timeout=3600,
)
for name in PLAYERS[1:]:
engine.join_game(state, name, name.capitalize())
await game_store.save(state)
member = deadlines.encode({
"game_id": state.id,
"kind": deadlines.KIND_TURN,
"hand": state.hand_number,
"turn": state.turn,
# Not the live deadline (epoch milliseconds).
"deadline": 946684800000,
})
await game_store.add_deadline(member, due_at=0.0)
await deadlines.process_due(game_store, member)
result = await game_store.load(state.id)
assert result is not None
self.assertEqual(state.turn, result.turn)
# The entry was removed after processing.
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
@async_test
async def test_entry_for_expired_game_is_dropped(self) -> None:
member = deadlines.encode({
"game_id": "dl-gone",
"kind": deadlines.KIND_TURN,
"hand": 1,
"turn": 0,
"deadline": 946684800000,
})
await game_store.add_deadline(member, due_at=0.0)
await deadlines.process_due(game_store, member)
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
@async_test
async def test_malformed_entry_is_dropped(self) -> None:
await game_store.add_deadline("not json", due_at=0.0)
await deadlines.process_due(game_store, "not json")
self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
"""Unit tests for the chess-style Elo math in :mod:`tavolo.elo`."""
from __future__ import annotations
import unittest
from tavolo.elo import (
INITIAL_RATING,
K_FACTOR,
expected_score,
match_delta,
team_rating,
)
class ExpectedScoreTest(unittest.TestCase):
def test_equal_ratings_give_even_odds(self) -> None:
self.assertAlmostEqual(0.5, expected_score(1500, 1500))
def test_higher_rating_is_favoured(self) -> None:
self.assertGreater(expected_score(1700, 1500), 0.5)
self.assertLess(expected_score(1500, 1700), 0.5)
def test_scores_sum_to_one(self) -> None:
self.assertAlmostEqual(
1.0, expected_score(1600, 1400) + expected_score(1400, 1600)
)
def test_four_hundred_points_is_ten_to_one(self) -> None:
self.assertAlmostEqual(10 / 11, expected_score(1900, 1500))
class TeamRatingTest(unittest.TestCase):
def test_mean_of_members(self) -> None:
self.assertEqual(1600, team_rating([1500, 1700]))
def test_empty_team_rejected(self) -> None:
with self.assertRaises(ValueError):
team_rating([])
class MatchDeltaTest(unittest.TestCase):
def test_equal_teams_exchange_half_k(self) -> None:
delta = match_delta([1500, 1500], [1500, 1500], winner_team=0)
self.assertEqual(K_FACTOR // 2, delta)
def test_favourite_gains_less_than_underdog(self) -> None:
favourite = match_delta([1700, 1700], [1500, 1500], winner_team=0)
underdog = match_delta([1500, 1500], [1700, 1700], winner_team=0)
self.assertGreater(underdog, favourite)
self.assertGreater(favourite, 0)
def test_losing_side_loses_the_winners_gain(self) -> None:
# Zero-sum: the losers' delta is the negation of the winners'.
win = match_delta([1600, 1500], [1400, 1500], winner_team=0)
loss = match_delta([1600, 1500], [1400, 1500], winner_team=1)
self.assertEqual(-win, -abs(win)) # winner gains
# Losing the same pairing costs K * E, winning gains K * (1 - E);
# both are computed from the same expectation, so loss = win - K.
self.assertEqual(win - K_FACTOR, loss)
def test_team_average_decides_not_individual_ratings(self) -> None:
# [1700, 1300] averages 1500, same as [1500, 1500].
mixed = match_delta([1700, 1300], [1500, 1500], winner_team=0)
even = match_delta([1500, 1500], [1500, 1500], winner_team=0)
self.assertEqual(even, mixed)
def test_initial_rating_constant(self) -> None:
self.assertEqual(1500, INITIAL_RATING)
self.assertEqual(32, K_FACTOR)
if __name__ == "__main__":
unittest.main()
+89
View File
@@ -239,6 +239,95 @@ class ScoringTest(unittest.TestCase):
self.assertEqual([0, 0], points) self.assertEqual([0, 0], points)
class NapolaTest(unittest.TestCase):
def test_napola_score_runs(self) -> None:
self.assertEqual(0, engine.napola_score(
[card(c) for c in ["02D", "03D", "04D"]])) # no ace
self.assertEqual(0, engine.napola_score(
[card(c) for c in ["01D", "02D"]])) # too short
self.assertEqual(3, engine.napola_score(
[card(c) for c in ["03D", "01D", "02D"]])) # order-independent
self.assertEqual(4, engine.napola_score(
[card(c) for c in ["01D", "02D", "03D", "04D", "07C"]]))
self.assertEqual(3, engine.napola_score(
[card(c) for c in ["01D", "02D", "03D", "05D"]])) # broken run
self.assertEqual(10, engine.napola_score(
[card(f"{rank:02d}D") for rank in range(1, 11)]))
def test_hand_points_napola(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["01D", "02D", "03D", "04C"], # seat 0, team A
["05D", "06D", "07D", "08D"], # seat 1, team B
["09D", "10D", "01C", "02C"], # seat 2, team A
["03C", "05C", "06C", "07C"], # seat 3, team B
],
)
points, details = engine.hand_points(state)
# Team A has the ace-led run 01D-03D (3 points); team B's denari
# start at the 5, so no napola. Carte tie (8 each), denara to A
# (5 vs 4), settebello to B, primiere tied at 0 (missing suits).
self.assertEqual({"A": 3, "B": 0}, details["napola"])
self.assertEqual("A", details["award"]["napola"])
self.assertEqual([4, 1], points)
def test_napola_disabled(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["01D", "02D", "03D", "04C"],
["05D", "06D", "07D", "08D"],
["09D", "10D", "01C", "02C"],
["03C", "05C", "06C", "07C"],
],
)
state.napola = False
points, details = engine.hand_points(state)
self.assertNotIn("napola", details)
self.assertEqual([1, 1], points)
def test_full_denari_sweep_wins_match_instantly(self) -> None:
# Team A already captured the whole denari suit; the last play of
# the hand cannot capture. Team B leads 50-0, yet the napola ends
# the match in team A's favour, well below the target of 100.
state = make_state(
[["02C"], [], [], []],
table=[],
target=100,
captured=[
[f"{rank:02d}D" for rank in range(1, 11)],
[],
[],
[],
],
)
state.scores = [0, 50]
engine.play(state, "p0", "02C")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
self.assertLess(state.scores[0], 100)
self.assertEqual(10, state.hand_scores[-1]["napola"]["A"])
def test_napola_serialization_roundtrip(self) -> None:
state = make_state([["02D"], [], [], []], table=[])
self.assertTrue(state.napola)
state.napola = False
self.assertFalse(GameState.from_json(state.to_json()).napola)
# States serialized before the option existed default to enabled.
data = state.to_json()
del data["napola"]
self.assertTrue(GameState.from_json(data).napola)
def test_create_game_napola_default_and_override(self) -> None:
self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola)
self.assertFalse(
engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola
)
class MatchFlowTest(unittest.TestCase): class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None: def test_join_starts_when_full(self) -> None:
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11) state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11)
+66
View File
@@ -0,0 +1,66 @@
"""Tests for the logging configuration entry point."""
from __future__ import annotations
import logging
import tempfile
import unittest
from pathlib import Path
from tavolo.logging_config import configure_logging
class LoggingConfigTest(unittest.TestCase):
def setUp(self) -> None:
# configure_logging mutates the global logging state; snapshot and
# restore it so the rest of the suite is unaffected.
root = logging.getLogger()
self._root_handlers = root.handlers[:]
self._root_level = root.level
tavolo = logging.getLogger("tavolo")
self._tavolo_level = tavolo.level
def tearDown(self) -> None:
root = logging.getLogger()
root.handlers = self._root_handlers
root.level = self._root_level
logging.getLogger("tavolo").level = self._tavolo_level
def test_default_config_when_unset(self) -> None:
configure_logging(None)
root = logging.getLogger()
self.assertEqual(logging.DEBUG, root.level)
self.assertTrue(
any(isinstance(h, logging.StreamHandler) for h in root.handlers),
"default config installs a console stream handler",
)
def test_yaml_config_is_applied(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "logging.yaml"
config.write_text(
"version: 1\n"
"disable_existing_loggers: false\n"
"root:\n"
" level: WARNING\n"
"loggers:\n"
" tavolo:\n"
" level: DEBUG\n"
)
configure_logging(str(config))
self.assertEqual(logging.DEBUG, logging.getLogger("tavolo").getEffectiveLevel())
self.assertEqual(logging.WARNING, logging.getLogger().getEffectiveLevel())
def test_missing_file_raises(self) -> None:
with self.assertRaises(RuntimeError):
configure_logging("/nonexistent/logging.yaml")
def test_non_mapping_yaml_raises(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "logging.yaml"
config.write_text("- just\n- a\n- list\n")
with self.assertRaises(RuntimeError):
configure_logging(str(config))
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -71,6 +71,24 @@ class GamesRouteTest(unittest.TestCase):
self.assertNotIn("hand", state["players"][0]) self.assertNotIn("hand", state["players"][0])
self.assertEqual(1, state["turn"]) self.assertEqual(1, state["turn"])
@async_test
async def test_create_napola_option(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
default = await client.post("/api/games", json={})
self.assertEqual(201, default.status_code)
self.assertTrue(default.json()["napola"])
with oidc_user("alice"):
disabled = await client.post("/api/games", json={"napola": False})
self.assertEqual(201, disabled.status_code)
self.assertFalse(disabled.json()["napola"])
with oidc_user("alice"):
invalid = await client.post("/api/games", json={"napola": "yes"})
self.assertEqual(400, invalid.status_code)
@async_test @async_test
async def test_join_errors(self) -> None: async def test_join_errors(self) -> None:
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
@@ -119,5 +137,53 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(404, response.status_code) self.assertEqual(404, response.status_code)
class GameTypesRouteTest(unittest.TestCase):
@async_test
async def test_lists_available_game_types(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/game-types")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
self.assertEqual("Scopone scientifico", results[0]["name"])
self.assertTrue(results[0]["description"])
@async_test
async def test_create_defaults_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post("/api/games", json={})
self.assertEqual(201, created.status_code)
self.assertEqual("scopone_scientifico", created.json()["game_type"])
@async_test
async def test_create_with_explicit_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games", json={"game_type": "scopone_scientifico"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("scopone_scientifico", body["game_type"])
with oidc_user("alice"):
snapshot = await client.get(f"/api/games/{body['id']}")
self.assertEqual("scopone_scientifico", snapshot.json()["game_type"])
@async_test
async def test_create_rejects_unknown_game_type(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
unknown = await client.post("/api/games", json={"game_type": "briscola"})
non_string = await client.post("/api/games", json={"game_type": 42})
self.assertEqual(400, unknown.status_code)
self.assertEqual(400, non_string.status_code)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+6 -21
View File
@@ -34,15 +34,13 @@ class MeRouteTest(unittest.TestCase):
class StaticRouteTest(unittest.TestCase): class StaticRouteTest(unittest.TestCase):
"""The app only serves the SPA shell; asset files under /static are
served by Granian and are not reachable through the ASGI transport."""
@async_test @async_test
async def test_serves_files_and_spa_fallback(self) -> None: async def test_serves_shell_and_spa_fallback(self) -> None:
with tempfile.TemporaryDirectory() as dist: with tempfile.TemporaryDirectory() as dist:
root = Path(dist) (Path(dist) / "index.html").write_text("<html>spa</html>")
(root / "index.html").write_text("<html>spa</html>")
(root / "app.js").write_text("console.log(1)")
cards = root / "assets" / "cards"
cards.mkdir(parents=True)
(cards / "07D.svg").write_text("<svg/>")
patched = dataclasses.replace(settings, static_dir=dist) patched = dataclasses.replace(settings, static_dir=dist)
with mock.patch("tavolo.routes.static.settings", patched): with mock.patch("tavolo.routes.static.settings", patched):
@@ -50,27 +48,14 @@ class StaticRouteTest(unittest.TestCase):
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
index = await client.get("/") index = await client.get("/")
self.assertEqual(200, index.status_code) self.assertEqual(200, index.status_code)
self.assertEqual("text/html; charset=utf-8", index.headers["content-type"])
self.assertIn(b"spa", index.content) self.assertIn(b"spa", index.content)
js = await client.get("/app.js")
self.assertEqual(200, js.status_code)
self.assertEqual("text/javascript; charset=utf-8", js.headers["content-type"])
svg = await client.get("/assets/cards/07D.svg")
self.assertEqual(200, svg.status_code)
self.assertEqual("image/svg+xml", svg.headers["content-type"])
# Unknown client-side route falls back to the app shell. # Unknown client-side route falls back to the app shell.
fallback = await client.get("/game/some-id") fallback = await client.get("/game/some-id")
self.assertEqual(200, fallback.status_code) self.assertEqual(200, fallback.status_code)
self.assertIn(b"spa", fallback.content) self.assertIn(b"spa", fallback.content)
# Traversal attempts never escape the dist directory.
traversal = await client.get("/..%2F..%2Fetc%2Fpasswd")
self.assertIn(traversal.status_code, (200, 404))
if traversal.status_code == 200:
self.assertIn(b"spa", traversal.content)
@async_test @async_test
async def test_missing_dist_returns_404(self) -> None: async def test_missing_dist_returns_404(self) -> None:
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist") patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
+230 -2
View File
@@ -9,9 +9,10 @@ from httpx import ASGITransport, AsyncClient
from pwo import async_test from pwo import async_test
from tavolo.app import app, tortoise_mixin from tavolo.app import app, tortoise_mixin
from tavolo.elo import INITIAL_RATING
from tavolo.game import engine from tavolo.game import engine
from tavolo.game.state import GameState from tavolo.game.state import GameState
from tavolo.models import Match, MatchPlayer from tavolo.models import Match, MatchPlayer, PlayerRating
from tavolo.stats import save_match_result from tavolo.stats import save_match_result
from tests.helpers import oidc_user from tests.helpers import oidc_user
@@ -48,6 +49,28 @@ def _finished_state() -> GameState:
return state return state
def _finished_state_reversed() -> GameState:
"""Same one-capture ending as ``_finished_state``, but team B scores it."""
state = GameState(
id="stats-game-2",
join_code="STATS2",
creator_sub="alice",
target_score=2,
phase=engine.PHASE_PLAYING,
turn=1,
table=[engine.parse_card("02C")],
)
from tavolo.game.state import PlayerState, Card
state.players = [
PlayerState(sub="alice", name="alice", seat=0),
PlayerState(sub="bob", name="bob", seat=1, hand=[Card.parse("02D")]),
PlayerState(sub="carol", name="carol", seat=2),
PlayerState(sub="dave", name="dave", seat=3),
]
return state
class SaveMatchResultTest(unittest.TestCase): class SaveMatchResultTest(unittest.TestCase):
@async_test @async_test
async def test_finished_match_is_persisted_once(self) -> None: async def test_finished_match_is_persisted_once(self) -> None:
@@ -66,11 +89,65 @@ class SaveMatchResultTest(unittest.TestCase):
assert match is not None assert match is not None
self.assertEqual(state.scores[0], match.team_a_score) self.assertEqual(state.scores[0], match.team_a_score)
self.assertEqual("A", match.winner_team) self.assertEqual("A", match.winner_team)
# The game type travels from the live state onto the row.
self.assertEqual("scopone_scientifico", match.game_type)
winners = await MatchPlayer.filter(won=True) winners = await MatchPlayer.filter(won=True)
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners}) self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners})
@async_test
async def test_finished_match_updates_elo_ratings(self) -> None:
ctx = await _use_app_db()
state = _finished_state()
engine.play(state, "alice", "02D", ["02C"])
async def _seed_two_matches() -> None: with ctx:
await save_match_result(state)
ratings = {
row.user_sub: row for row in await PlayerRating.all()
}
self.assertEqual(4, len(ratings))
# Four players at 1500: winners gain K/2, losers lose it.
for winner in ("alice", "carol"):
self.assertEqual(INITIAL_RATING + 16, ratings[winner].rating)
self.assertEqual(1, ratings[winner].matches_played)
for loser in ("bob", "dave"):
self.assertEqual(INITIAL_RATING - 16, ratings[loser].rating)
self.assertEqual(1, ratings[loser].matches_played)
# The per-match delta is recorded on each participation row.
deltas = {
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
}
self.assertEqual(
{"alice": 16, "carol": 16, "bob": -16, "dave": -16}, deltas
)
@async_test
async def test_elo_ratings_accumulate_across_matches(self) -> None:
ctx = await _use_app_db()
state = _finished_state()
engine.play(state, "alice", "02D", ["02C"])
reversed_state = _finished_state_reversed()
engine.play(reversed_state, "bob", "02D", ["02C"])
with ctx:
await save_match_result(state)
# A second match between the same players, won by team B.
await save_match_result(reversed_state)
ratings = {
row.user_sub: row.rating for row in await PlayerRating.all()
}
# Match 1: even teams, team A wins (+16/-16). Match 2: team A
# is now the favourite (1516 vs 1484), so losing costs 17.
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
bob = await PlayerRating.get(user_sub="bob")
self.assertEqual(2, bob.matches_played)
async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None:
ctx = await _use_app_db() ctx = await _use_app_db()
with ctx: with ctx:
for index, (a_score, b_score, winner, finished) in enumerate( for index, (a_score, b_score, winner, finished) in enumerate(
@@ -81,6 +158,7 @@ async def _seed_two_matches() -> None:
): ):
match = await Match.create( match = await Match.create(
id=uuid.uuid4(), id=uuid.uuid4(),
game_type=game_types[index],
team_a_score=a_score, team_a_score=a_score,
team_b_score=b_score, team_b_score=b_score,
winner_team=winner, winner_team=winner,
@@ -164,6 +242,156 @@ class StatsRouteTest(unittest.TestCase):
# Alice leads on points after tying Bob on wins. # Alice leads on points after tying Bob on wins.
self.assertEqual("alice", response.json()["results"][0]["user_sub"]) self.assertEqual("alice", response.json()["results"][0]["user_sub"])
@async_test
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
await _seed_two_matches()
ctx = await _use_app_db()
with ctx:
# Bob outranks everyone despite Alice leading on points.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="scopone_scientifico",
rating=1600,
matches_played=2,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["bob"]["elo"])
# Players without a rating row report the initial rating.
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
# Elo outranks wins/points.
self.assertEqual("bob", results[0]["user_sub"])
@async_test
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
await _seed_two_matches()
ctx = await _use_app_db()
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="scopone_scientifico",
rating=1516,
matches_played=1,
)
# Alice's rating in another game must not leak into the
# scopone leaderboard.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="other_game",
rating=1800,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard?game_type=scopone_scientifico")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1516, by_sub["alice"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
@async_test
async def test_my_matches_include_elo_delta(self) -> None:
ctx = await _use_app_db()
state = _finished_state()
engine.play(state, "alice", "02D", ["02C"])
with ctx:
await save_match_result(state)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
players = {
p["user_sub"]: p
for p in response.json()["results"][0]["players"]
}
self.assertEqual(16, players["alice"]["elo_delta"])
self.assertEqual(-16, players["bob"]["elo_delta"])
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
@async_test
async def test_my_ratings_requires_auth(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/ratings")
self.assertEqual(401, response.status_code)
@async_test
async def test_my_ratings_returns_only_own_rows(self) -> None:
ctx = await _use_app_db()
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="scopone_scientifico",
rating=1516,
matches_played=1,
)
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="scopone_scientifico",
rating=1484,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
response = await client.get("/api/me/ratings")
self.assertEqual(200, response.status_code)
self.assertEqual(
[{"game_type": "scopone_scientifico", "rating": 1516, "matches_played": 1}],
response.json()["results"],
)
class GameTypeFilterTest(unittest.TestCase):
"""Stats endpoints scope results by the match's game type."""
@async_test
async def test_my_matches_filter_by_game_type(self) -> None:
# The second seed names a game the registry does not know; rows are
# written directly, so this only exercises the SQL filter.
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
all_matches = await client.get("/api/me/matches")
scoped = await client.get("/api/me/matches?game_type=scopone_scientifico")
unknown = await client.get("/api/me/matches?game_type=briscola")
self.assertEqual(2, len(all_matches.json()["results"]))
self.assertEqual(
{"scopone_scientifico", "other_game"},
{m["game_type"] for m in all_matches.json()["results"]},
)
scoped_results = scoped.json()["results"]
self.assertEqual(1, len(scoped_results))
self.assertEqual("scopone_scientifico", scoped_results[0]["game_type"])
self.assertEqual(400, unknown.status_code)
@async_test
async def test_leaderboard_filter_by_game_type(self) -> None:
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
scoped = await client.get("/api/leaderboard?game_type=scopone_scientifico")
unknown = await client.get("/api/leaderboard?game_type=briscola")
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, team A won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(400, unknown.status_code)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+41
View File
@@ -25,6 +25,24 @@ class InMemoryGameStoreTest(unittest.TestCase):
self.assertEqual(16, loaded.target_score) self.assertEqual(16, loaded.target_score)
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players]) self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players])
@async_test
async def test_game_type_roundtrip_and_default(self) -> None:
store = InMemoryGameStore()
state = engine.create_game(
"g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico"
)
await store.save(state)
loaded = await store.load("g1b")
assert loaded is not None
self.assertEqual("scopone_scientifico", loaded.game_type)
# States serialized before game types existed load with the default.
legacy = state.to_json()
del legacy["game_type"]
from tavolo.game.state import GameState
self.assertEqual("scopone_scientifico", GameState.from_json(legacy).game_type)
@async_test @async_test
async def test_load_missing_returns_none(self) -> None: async def test_load_missing_returns_none(self) -> None:
store = InMemoryGameStore() store = InMemoryGameStore()
@@ -90,6 +108,29 @@ class InMemoryGameStoreTest(unittest.TestCase):
["holder-enter", "holder-exit", "contender"], order ["holder-enter", "holder-exit", "contender"], order
) )
@async_test
async def test_deadline_queue(self) -> None:
store = InMemoryGameStore()
self.assertIsNone(await store.next_deadline())
self.assertEqual([], await store.due_deadlines(now=100.0))
await store.add_deadline("b", due_at=50.0)
await store.add_deadline("a", due_at=10.0)
await store.add_deadline("c", due_at=200.0)
# Re-adding an existing member only updates its due time.
await store.add_deadline("b", due_at=60.0)
self.assertEqual(10.0, await store.next_deadline())
self.assertEqual(["a"], await store.due_deadlines(now=10.0))
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
# Due entries come out in due-time order and stay queued until removed.
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
await store.remove_deadline("a")
await store.remove_deadline("a") # removing twice is a no-op
self.assertEqual(60.0, await store.next_deadline())
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+27 -2
View File
@@ -22,9 +22,22 @@ pub async fn me() -> Result<Option<User>, String> {
resp.json().await.map(Some).map_err(|e| e.to_string()) resp.json().await.map(Some).map_err(|e| e.to_string())
} }
pub async fn create_game(target_score: i32) -> Result<GameView, String> { /// Fetch the card games the platform can host (for the creation dropdown).
pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
let resp = Request::get("/api/game-types")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
pub async fn create_game(game_type: &str, target_score: i32, napola: bool) -> Result<GameView, String> {
let resp = Request::post("/api/games") let resp = Request::post("/api/games")
.json(&serde_json::json!({ "target_score": target_score })) .json(&serde_json::json!({ "game_type": game_type, "target_score": target_score, "napola": napola }))
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.send() .send()
.await .await
@@ -78,6 +91,18 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
resp.json().await.map_err(|e| e.to_string()) resp.json().await.map_err(|e| e.to_string())
} }
/// Fetch the caller's Elo ratings (one row per game type played).
pub async fn my_ratings() -> Result<RatingsPage, String> {
let resp = Request::get("/api/me/ratings")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn leaderboard() -> Result<LeaderboardPage, String> { pub async fn leaderboard() -> Result<LeaderboardPage, String> {
let resp = Request::get("/api/leaderboard") let resp = Request::get("/api/leaderboard")
.send() .send()
+1
View File
@@ -1,2 +1,3 @@
pub mod card; pub mod card;
pub mod summary; pub mod summary;
pub mod toast;
+39
View File
@@ -113,6 +113,45 @@ pub fn summary_rows(summary: HandSummary) -> View {
summary.award.primiera.clone(), summary.award.primiera.clone(),
"+1", "+1",
), ),
// Napola (only when the rule is enabled for this match)
match summary.napola {
None => view! {},
Some(napola) => {
let n = match summary.award.napola.as_deref() {
Some("A") => napola.a,
Some("B") => napola.b,
_ => 0,
};
let text = match (&summary.award.napola, n) {
(Some(t), 10) => format!(
"Team {t} swept the whole denari suit — napola! Instant match win"
),
(Some(t), n) => format!(
"Team {t} captured {n} consecutive denari from the ace"
),
(None, _) => "No napola this hand".to_string(),
};
let chip = match &summary.award.napola {
Some(t) => format!("Team {t} +{n}"),
None => "tie".to_string(),
};
let cls = match summary.award.napola.as_deref() {
Some("A") => "score-row team-a",
Some("B") => "score-row team-b",
_ => "score-row tie",
};
view! {
div(class=cls) {
div(class="score-icon") { (card_img("01D".to_string(), "score-mini")) }
div(class="score-body") {
div(class="score-title") { "Napola" }
div(class="score-text") { (text) }
}
div(class="score-points") { (chip) }
}
}
}
},
// Scope // Scope
{ {
let a = summary.scope.a; let a = summary.scope.a;
+20
View File
@@ -0,0 +1,20 @@
//! Auto-dismissing error toast.
use gloo_timers::callback::Timeout;
use sycamore::prelude::*;
const TOAST_MS: u32 = 10_000;
/// Renders the error from `error` as a toast; hides it after `TOAST_MS`.
/// A new error replaces the message and restarts the timer.
pub fn toast(error: Signal<Option<String>>) -> View {
create_effect(move || {
if error.get_clone().is_some() {
// Held until cleanup; dropped (cancelled) when the effect re-runs.
let timeout = Timeout::new(TOAST_MS, move || error.set(None));
on_cleanup(move || drop(timeout));
}
});
view! {
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
}
}
+63
View File
@@ -2,6 +2,10 @@
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct User { pub struct User {
@@ -75,6 +79,8 @@ pub struct Award {
pub settebello: Option<String>, pub settebello: Option<String>,
#[serde(default)] #[serde(default)]
pub primiera: Option<String>, pub primiera: Option<String>,
#[serde(default)]
pub napola: Option<String>,
} }
/// The scoring breakdown of one completed hand. /// The scoring breakdown of one completed hand.
@@ -86,6 +92,10 @@ pub struct HandSummary {
pub settebello: TeamBools, pub settebello: TeamBools,
pub primiera: TeamCounts, pub primiera: TeamCounts,
pub scope: TeamCounts, pub scope: TeamCounts,
/// Napola run lengths per team; absent when the rule is disabled (or
/// the summary predates the option).
#[serde(default)]
pub napola: Option<TeamCounts>,
pub award: Award, pub award: Award,
#[serde(default)] #[serde(default)]
pub hand: i32, pub hand: i32,
@@ -101,6 +111,12 @@ pub struct GameView {
pub id: String, pub id: String,
#[serde(default)] #[serde(default)]
pub join_code: String, pub join_code: String,
/// Which card game this match is (id from /api/game-types).
#[serde(default)]
pub game_type: String,
/// Whether the napola rule is scored in this match.
#[serde(default = "default_true")]
pub napola: bool,
pub phase: String, pub phase: String,
#[serde(default)] #[serde(default)]
pub target_score: i32, pub target_score: i32,
@@ -168,12 +184,18 @@ pub struct MatchPlayer {
pub seat: usize, pub seat: usize,
pub team: String, pub team: String,
pub won: bool, pub won: bool,
/// Elo change this match produced for the player; absent for matches
/// recorded before ratings existed.
#[serde(default)]
pub elo_delta: Option<i32>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct MatchSummary { pub struct MatchSummary {
pub id: String, pub id: String,
#[serde(default)]
pub game_type: String,
pub team_a_score: i32, pub team_a_score: i32,
pub team_b_score: i32, pub team_b_score: i32,
pub winner_team: String, pub winner_team: String,
@@ -183,6 +205,9 @@ pub struct MatchSummary {
pub finished_at: String, pub finished_at: String,
#[serde(default)] #[serde(default)]
pub you_won: bool, pub you_won: bool,
/// The viewer's Elo change in this match; absent when unrated.
#[serde(default)]
pub your_elo_delta: Option<i32>,
#[serde(default)] #[serde(default)]
pub players: Vec<MatchPlayer>, pub players: Vec<MatchPlayer>,
} }
@@ -195,22 +220,60 @@ pub struct MatchesPage {
pub next_cursor: Option<String>, pub next_cursor: Option<String>,
} }
fn default_elo() -> i32 {
1500
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct LeaderboardEntry { pub struct LeaderboardEntry {
pub user_sub: String, pub user_sub: String,
pub display_name: String, pub display_name: String,
/// Chess-style Elo rating for the requested game type.
#[serde(default = "default_elo")]
pub elo: i32,
pub matches: i32, pub matches: i32,
pub wins: i32, pub wins: i32,
pub points: i32, pub points: i32,
} }
/// The caller's Elo rating for one game type (GET /api/me/ratings).
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct PlayerRating {
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RatingsPage {
#[serde(default)]
pub results: Vec<PlayerRating>,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct LeaderboardPage { pub struct LeaderboardPage {
#[serde(default)] #[serde(default)]
pub results: Vec<LeaderboardEntry>, pub results: Vec<LeaderboardEntry>,
} }
/// A card game the platform can host (GET /api/game-types).
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[allow(dead_code)]
pub struct GameTypeInfo {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GameTypesPage {
#[serde(default)]
pub results: Vec<GameTypeInfo>,
}
/// Map a card code (e.g. `07D`) to its asset path. /// Map a card code (e.g. `07D`) to its asset path.
pub fn card_asset(code: &str) -> String { pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg") format!("/assets/cards/{code}.svg")
+216 -22
View File
@@ -1,8 +1,12 @@
//! Live game page: table view over the websocket. //! Live game page: table view over the websocket.
use std::cell::Cell;
use std::rc::Rc;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::components::card::{card_back, card_img}; use crate::components::card::{card_back, card_img};
use crate::components::summary::{hand_summary_modal, summary_rows}; use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage}; use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::ws::{self, GameSocket}; use crate::ws::{self, GameSocket};
@@ -69,6 +73,143 @@ fn move_banner(mv: MoveView) -> View {
} }
} }
/// Signals shared by the websocket connection and its reconnect attempts.
#[derive(Clone, Copy)]
struct ConnCtx {
socket: Signal<Option<GameSocket>>,
game: Signal<Option<GameView>>,
over: Signal<Option<(Scores, Option<String>)>>,
error: Signal<Option<String>>,
closed: Signal<bool>,
/// Reconnect attempts exhausted; only a manual retry resumes.
gave_up: Signal<bool>,
/// The server closed the connection deliberately (auth or game gone);
/// retrying is pointless.
fatal: Signal<bool>,
attempts: Signal<u32>,
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
selected: Signal<Option<String>>,
}
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
const MAX_RECONNECT_ATTEMPTS: u32 = 10;
fn backoff_ms(attempt: u32) -> u32 {
(1000u32 << attempt.min(5)).min(30_000)
}
/// Connect the game websocket, wiring state updates and reconnects.
///
/// The server pushes a full state snapshot on connect, so a reconnect is
/// also a resync: no client-side state merging is needed.
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let on_message = {
let alive = alive.clone();
move |msg: ServerMessage| {
if !alive.get() {
// The page is unmounted; its signals are disposed.
return;
}
match msg {
ServerMessage::State { game: g } => {
ctx.capture_choice.set(None);
ctx.selected.set(None);
// A received state proves the (re)connection works.
ctx.attempts.set(0);
ctx.gave_up.set(false);
ctx.closed.set(false);
ctx.game.set(Some(g));
}
ServerMessage::GameOver { scores, winner } => {
ctx.over.set(Some((scores, winner)))
}
ServerMessage::Error { message, .. } => ctx.error.set(Some(message)),
}
}
};
let on_close = {
let id = id.clone();
let alive = alive.clone();
move |code: Option<u16>| {
if !alive.get() {
return;
}
ctx.closed.set(true);
match code {
Some(4401) => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
Some(4403) | Some(4404) => {
ctx.fatal.set(true);
ctx.error
.set(Some("This game is no longer available.".to_string()));
}
_ => schedule_retry(id.clone(), ctx, alive.clone()),
}
}
};
match ws::connect(&id, on_message, on_close) {
Some(s) => ctx.socket.set(Some(s)),
// WebSocket::open failed synchronously: treat as a transient loss.
None if alive.get() => {
ctx.closed.set(true);
schedule_retry(id, ctx, alive);
}
None => {}
}
}
/// Retry `start_connect` with exponential backoff, unless we gave up.
fn schedule_retry(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let attempt = ctx.attempts.get();
if attempt >= MAX_RECONNECT_ATTEMPTS {
ctx.gave_up.set(true);
return;
}
ctx.attempts.set(attempt + 1);
gloo_timers::callback::Timeout::new(backoff_ms(attempt), move || {
if alive.get() {
start_connect(id, ctx, alive);
}
})
.forget();
}
/// Slim banner shown over the table while the socket is down.
fn conn_banner(
closed: bool,
gave_up: bool,
fatal: bool,
has_game: bool,
reconnect: Rc<dyn Fn()>,
) -> View {
if !closed || !has_game {
return view! {};
}
if fatal {
view! {
div(class="conn-banner") {
"Connection closed by the server. "
a(href="/") { "Back to lobby" }
}
}
} else if gave_up {
view! {
div(class="conn-banner") {
"Connection lost."
button(class="button", on:click=move |_| reconnect()) { "Retry now" }
a(href="/") { "Back to lobby" }
}
}
} else {
view! {
div(class="conn-banner") { "Connection lost — reconnecting…" }
}
}
}
#[component(inline_props)] #[component(inline_props)]
pub fn GamePage(id: String) -> View { pub fn GamePage(id: String) -> View {
let game = create_signal(Option::<GameView>::None); let game = create_signal(Option::<GameView>::None);
@@ -77,32 +218,51 @@ pub fn GamePage(id: String) -> View {
let selected = create_signal(Option::<String>::None); let selected = create_signal(Option::<String>::None);
let over = create_signal(Option::<(Scores, Option<String>)>::None); let over = create_signal(Option::<(Scores, Option<String>)>::None);
let closed = create_signal(false); let closed = create_signal(false);
let gave_up = create_signal(false);
let fatal = create_signal(false);
let attempts = create_signal(0u32);
let socket = create_signal(Option::<GameSocket>::None); let socket = create_signal(Option::<GameSocket>::None);
// Ticking clock driving the hand-end countdown display. // Ticking clock driving the hand-end countdown display.
let now = create_signal(js_sys::Date::now()); let now = create_signal(js_sys::Date::now());
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget(); let ticker = gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now()));
{ // Stops the ticker and any pending reconnect once the page unmounts.
let on_message = move |msg: ServerMessage| match msg { let alive = Rc::new(Cell::new(true));
ServerMessage::State { game: g } => { on_cleanup({
capture_choice.set(None); let alive = alive.clone();
selected.set(None); move || {
game.set(Some(g)); alive.set(false);
drop(ticker);
} }
ServerMessage::GameOver { scores, winner } => { });
over.set(Some((scores, winner)));
} let id = Rc::new(id);
ServerMessage::Error { message, .. } => error.set(Some(message)), let ctx = ConnCtx {
socket,
game,
over,
error,
closed,
gave_up,
fatal,
attempts,
capture_choice,
selected,
}; };
let on_close = move || closed.set(true); start_connect(id.clone(), ctx, alive.clone());
match ws::connect(&id, on_message, on_close) { let reconnect: Rc<dyn Fn()> = Rc::new(move || {
Some(s) => socket.set(Some(s)), ctx.attempts.set(0);
None => error.set(Some("Could not connect to the game".to_string())), ctx.gave_up.set(false);
} ctx.closed.set(false);
} start_connect(id.clone(), ctx, alive.clone());
});
// Clicking a card in the player's own hand. // Clicking a card in the player's own hand.
let on_hand_card = move |code: String| { let on_hand_card = move |code: String| {
if closed.get() {
// A dead socket would swallow the play silently.
return;
}
let Some(g) = game.get_clone() else { return }; let Some(g) = game.get_clone() else { return };
if g.your_turn != Some(true) { if g.your_turn != Some(true) {
return; return;
@@ -121,13 +281,42 @@ pub fn GamePage(id: String) -> View {
} }
}; };
let reconnect_banner = reconnect.clone();
view! { view! {
div(class="game-page") { div(class="game-page") {
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } })) (toast(error))
(move || conn_banner(
closed.get(),
gave_up.get(),
fatal.get(),
game.get_clone().is_some(),
reconnect_banner.clone(),
))
(move || match game.get_clone() { (move || match game.get_clone() {
None => { None => {
if fatal.get() {
view! {
div(class="panel status-panel") {
p { "Connection closed." }
p { a(href="/") { "Back to lobby" } }
}
}
} else if gave_up.get() {
let reconnect = reconnect.clone();
view! {
div(class="panel status-panel") {
p { "Connection lost." }
p {
button(class="button primary", on:click=move |_| reconnect()) {
"Retry now"
}
}
p { a(href="/") { "Back to lobby" } }
}
}
} else {
let status = if closed.get() { let status = if closed.get() {
"Connection closed." "Connection lost — reconnecting…"
} else { } else {
"Connecting to the game…" "Connecting to the game…"
}; };
@@ -138,6 +327,7 @@ pub fn GamePage(id: String) -> View {
} }
} }
} }
}
Some(g) if g.phase == "lobby" => lobby_view(g), Some(g) if g.phase == "lobby" => lobby_view(g),
Some(g) => table_view(g, on_hand_card, selected, now), Some(g) => table_view(g, on_hand_card, selected, now),
}) })
@@ -246,9 +436,10 @@ fn table_view(
let hand_number = game.hand_number; let hand_number = game.hand_number;
let target_score = game.target_score; let target_score = game.target_score;
let hand = player_for_seat(&game, viewer_seat) let viewer = player_for_seat(&game, viewer_seat);
.and_then(|p| p.hand) let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
.unwrap_or_default(); let my_scope = viewer.as_ref().map(|p| p.scope).unwrap_or(0);
let hand = viewer.and_then(|p| p.hand).unwrap_or_default();
let current_selection = selected.get_clone(); let current_selection = selected.get_clone();
let hand_cards = hand let hand_cards = hand
.into_iter() .into_iter()
@@ -300,6 +491,9 @@ fn table_view(
} }
(right) (right)
div(class="seat-bottom") { div(class="seat-bottom") {
div(class="seat-stats own-stats") {
(my_captured) " captured · " (my_scope) " scope"
}
div(class="hand") { (hand_cards) } div(class="hand") { (hand_cards) }
(hint) (hint)
} }
+10 -1
View File
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::api; use crate::api;
use crate::components::toast::toast;
use crate::model::MatchesPage; use crate::model::MatchesPage;
#[component] #[component]
@@ -45,7 +46,7 @@ pub fn HistoryPage() -> View {
a(href="/leaderboard") { "Leaderboard" } a(href="/leaderboard") { "Leaderboard" }
} }
h1 { "My matches" } h1 { "My matches" }
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } })) (toast(error))
(move || match page.get_clone() { (move || match page.get_clone() {
None => view! { p(class="status") { "Loading…" } }, None => view! { p(class="status") { "Loading…" } },
Some(_) if rows.get_clone().is_empty() => view! { Some(_) if rows.get_clone().is_empty() => view! {
@@ -71,6 +72,10 @@ pub fn HistoryPage() -> View {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" & "); .join(" & ");
let outcome = if m.you_won { "Won" } else { "Lost" }; let outcome = if m.you_won { "Won" } else { "Lost" };
let elo_delta = m
.your_elo_delta
.map(|d| if d >= 0 { format!("+{d}") } else { d.to_string() })
.unwrap_or_else(|| "".to_string());
view! { view! {
tr { tr {
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) } td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
@@ -79,6 +84,9 @@ pub fn HistoryPage() -> View {
td { (m.team_a_score) " " (m.team_b_score) } td { (m.team_a_score) " " (m.team_b_score) }
td { "Team " (m.winner_team) } td { "Team " (m.winner_team) }
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) } td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
td(class=if m.you_won { "won" } else { "lost" }) {
(elo_delta)
}
} }
} }
}) })
@@ -93,6 +101,7 @@ pub fn HistoryPage() -> View {
th { "Score" } th { "Score" }
th { "Winner" } th { "Winner" }
th { "You" } th { "You" }
th { "Elo" }
} }
} }
tbody { (table_rows) } tbody { (table_rows) }
+4 -1
View File
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::api; use crate::api;
use crate::components::toast::toast;
use crate::model::LeaderboardPage; use crate::model::LeaderboardPage;
#[component] #[component]
@@ -24,7 +25,7 @@ pub fn LeaderboardPage() -> View {
a(href="/history") { "My matches" } a(href="/history") { "My matches" }
} }
h1 { "Leaderboard" } h1 { "Leaderboard" }
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } })) (toast(error))
(move || match page.get_clone() { (move || match page.get_clone() {
None => view! { p(class="status") { "Loading…" } }, None => view! { p(class="status") { "Loading…" } },
Some(p) => { Some(p) => {
@@ -38,6 +39,7 @@ pub fn LeaderboardPage() -> View {
tr { tr {
td { (i + 1) } td { (i + 1) }
td { (e.display_name.clone()) } td { (e.display_name.clone()) }
td { (e.elo) }
td { (e.wins) } td { (e.wins) }
td { (e.matches) } td { (e.matches) }
td { (e.points) } td { (e.points) }
@@ -51,6 +53,7 @@ pub fn LeaderboardPage() -> View {
tr { tr {
th { "#" } th { "#" }
th { "Player" } th { "Player" }
th { "Elo" }
th { "Wins" } th { "Wins" }
th { "Matches" } th { "Matches" }
th { "Points" } th { "Points" }
+65 -6
View File
@@ -4,18 +4,43 @@ use sycamore::prelude::*;
use sycamore_router::navigate; use sycamore_router::navigate;
use crate::api; use crate::api;
use crate::model::User; use crate::components::toast::toast;
use crate::model::{GameTypeInfo, User};
/// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> {
vec![GameTypeInfo {
id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(),
description: String::new(),
}]
}
#[component] #[component]
pub fn LobbyPage() -> View { pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out. // Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None); let user = create_signal(Option::<Option<User>>::None);
// The player's Elo rating for the first rated game; None while loading.
let rating = create_signal(Option::<i32>::None);
let error = create_signal(Option::<String>::None); let error = create_signal(Option::<String>::None);
let code = create_signal(String::new()); let code = create_signal(String::new());
let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string());
let napola = create_signal(true);
spawn_local(async move { spawn_local(async move {
match api::me().await { match api::me().await {
Ok(me) => user.set(Some(me)), Ok(me) => {
if me.is_some() {
spawn_local(async move {
if let Ok(p) = api::my_ratings().await {
// Unrated players sit at the initial 1500.
rating.set(Some(p.results.first().map(|r| r.rating).unwrap_or(1500)));
}
});
}
user.set(Some(me));
}
Err(e) => { Err(e) => {
error.set(Some(e)); error.set(Some(e));
user.set(Some(None)); user.set(Some(None));
@@ -23,9 +48,21 @@ pub fn LobbyPage() -> View {
} }
}); });
let on_create = move |target: i32| {
spawn_local(async move { spawn_local(async move {
match api::create_game(target).await { match api::game_types().await {
Ok(types) if !types.is_empty() => {
selected_game.set(types[0].id.clone());
game_types.set(types);
}
_ => {} // keep the scopone fallback
}
});
let on_create = move |target: i32| {
let game_type = selected_game.get_clone();
let napola = napola.get();
spawn_local(async move {
match api::create_game(&game_type, target, napola).await {
Ok(game) => navigate(&format!("/game/{}", game.id)), Ok(game) => navigate(&format!("/game/{}", game.id)),
Err(e) => error.set(Some(e)), Err(e) => error.set(Some(e)),
} }
@@ -48,7 +85,7 @@ pub fn LobbyPage() -> View {
view! { view! {
div(class="lobby") { div(class="lobby") {
h1 { "Scopone scientifico" } h1 { "Scopone scientifico" }
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } })) (toast(error))
(move || match user.get_clone() { (move || match user.get_clone() {
None => view! { p(class="status") { "Loading…" } }, None => view! { p(class="status") { "Loading…" } },
Some(None) => view! { Some(None) => view! {
@@ -63,13 +100,35 @@ pub fn LobbyPage() -> View {
Some(Some(me)) => view! { Some(Some(me)) => view! {
div(class="lobby-grid") { div(class="lobby-grid") {
nav(class="top-nav") { nav(class="top-nav") {
span(class="whoami") { "Signed in as " strong { (me.name.clone()) } } span(class="whoami") {
"Signed in as " strong { (me.name.clone()) }
(rating.get_clone().map(|r| view! {
span(class="rating") { " · Elo " (r) }
}))
}
a(href="/history") { "My matches" } a(href="/history") { "My matches" }
a(href="/leaderboard") { "Leaderboard" } a(href="/leaderboard") { "Leaderboard" }
a(href="/auth/logout", rel="external") { "Log out" } a(href="/auth/logout", rel="external") { "Log out" }
} }
div(class="panel") { div(class="panel") {
h2 { "New match" } h2 { "New match" }
label(r#for="game-type") { "Game" }
select(id="game-type", bind:value=selected_game) {
Keyed(
list=game_types,
view=|g| view! { option(value=g.id.clone()) { (g.name) } },
key=|g| g.id.clone(),
)
}
label(class="check", r#for="napola") {
input(id="napola", r#type="checkbox", bind:checked=napola)
" Napola"
}
p(class="hint") {
"A-2-3 of denari scores 3 points, plus 1 per extra "
"consecutive denari card; sweeping the whole suit "
"wins the match instantly."
}
p { "First team to reach the target score wins." } p { "First team to reach the target score wins." }
div(class="target-buttons") { div(class="target-buttons") {
button(class="button", on:click=move |_| on_create(11)) { "Target 11" } button(class="button", on:click=move |_| on_create(11)) { "Target 11" }
+12 -3
View File
@@ -4,7 +4,7 @@ use std::rc::Rc;
use futures::channel::mpsc; use futures::channel::mpsc;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use gloo_net::websocket::{futures::WebSocket, Message}; use gloo_net::websocket::{futures::WebSocket, Message, WebSocketError};
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::model::ServerMessage; use crate::model::ServerMessage;
@@ -53,10 +53,14 @@ impl GameSocket {
/// Open the websocket for `game_id` and forward parsed server messages to /// Open the websocket for `game_id` and forward parsed server messages to
/// `on_message`. Returns the socket handle, or `None` if the connection /// `on_message`. Returns the socket handle, or `None` if the connection
/// could not be created. /// could not be created.
///
/// `on_close` fires exactly once when the connection ends; it receives the
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
pub fn connect( pub fn connect(
game_id: &str, game_id: &str,
on_message: impl Fn(ServerMessage) + 'static, on_message: impl Fn(ServerMessage) + 'static,
on_close: impl Fn() + 'static, on_close: impl Fn(Option<u16>) + 'static,
) -> Option<GameSocket> { ) -> Option<GameSocket> {
let ws = WebSocket::open(&ws_url(game_id)).ok()?; let ws = WebSocket::open(&ws_url(game_id)).ok()?;
let (mut write, mut read) = ws.split(); let (mut write, mut read) = ws.split();
@@ -72,6 +76,7 @@ pub fn connect(
}); });
spawn_local(async move { spawn_local(async move {
let mut close_code = None;
while let Some(msg) = read.next().await { while let Some(msg) = read.next().await {
match msg { match msg {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
@@ -80,10 +85,14 @@ pub fn connect(
} }
} }
Ok(Message::Bytes(_)) => {} Ok(Message::Bytes(_)) => {}
Err(WebSocketError::ConnectionClose(e)) => {
close_code = Some(e.code);
break;
}
Err(_) => break, Err(_) => break,
} }
} }
on_close(); on_close(close_code);
}); });
Some(GameSocket { Some(GameSocket {
+36
View File
@@ -49,6 +49,10 @@ body {
margin-right: auto; margin-right: auto;
} }
.whoami .rating {
color: var(--muted, #888);
}
.panel { .panel {
background: var(--panel); background: var(--panel);
border-radius: 12px; border-radius: 12px;
@@ -123,6 +127,12 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
.check {
display: flex;
align-items: center;
gap: 0.4rem;
}
.join-form { .join-form {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -304,6 +314,11 @@ table.matches td.lost {
margin-top: auto; margin-top: auto;
} }
/* The viewer's own stats sit above the hand; no flex context here. */
.seat-bottom .seat-stats {
margin-bottom: 0.35rem;
}
.center { .center {
grid-area: center; grid-area: center;
display: flex; display: flex;
@@ -407,6 +422,27 @@ table.matches td.lost {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
/* ---------- connection banner ---------- */
.conn-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
background: rgba(232, 197, 71, 0.15);
border: 1px solid var(--accent);
border-radius: 8px;
color: var(--accent);
padding: 0.4rem 1rem;
margin: 0.5rem auto 0;
width: fit-content;
}
.conn-banner a {
color: var(--accent);
text-decoration: underline;
}
/* ---------- overlays ---------- */ /* ---------- overlays ---------- */
.overlay { .overlay {