Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a606f14550 | ||
|
|
3da0c463de | ||
|
|
26464bce43 | ||
|
|
8a2941eccc
|
||
|
|
57c6ffb032
|
||
|
|
c10d45523a
|
||
|
|
bbd12ce674 | ||
|
|
c40ebd84d8 | ||
|
|
658e3d6ec7
|
||
|
|
5e4e1310b4
|
||
|
|
db7ba30d13
|
||
|
|
f7fa8e78e5
|
||
|
|
5b9e998739 | ||
|
|
5dc3f4cb17 | ||
|
|
22ef689092 | ||
|
|
aa1fecae43 | ||
|
|
294a93912d
|
||
|
|
ab4130a4ca
|
||
|
|
876b4abd8b
|
||
|
|
f6239d2637
|
@@ -32,6 +32,7 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
builder: multiplatform-builder
|
||||
file: server/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
|
||||
@@ -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
|
||||
**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
|
||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||
(default 30s). The match-ending hand is explained on the final screen.
|
||||
|
||||
@@ -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
@@ -4,7 +4,8 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: tavolo
|
||||
POSTGRES_USER: tavolo
|
||||
POSTGRES_PASSWORD: tavolo
|
||||
# Override via the DATABASE_PASSWORD env var (shell or root .env).
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
@@ -71,7 +72,12 @@ services:
|
||||
working_dir: /app
|
||||
command: ["aerich", "upgrade"]
|
||||
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:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -90,7 +96,12 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
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
|
||||
# name (see README /etc/hosts note); override OIDC_ISSUER and
|
||||
# OIDC_REDIRECT_URI to use a real provider or a different host port.
|
||||
@@ -102,6 +113,15 @@ services:
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_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:
|
||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||
|
||||
|
||||
+40
-7
@@ -1,10 +1,17 @@
|
||||
# Postgres
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=tavolo
|
||||
POSTGRES_USER=tavolo
|
||||
POSTGRES_PASSWORD=tavolo
|
||||
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo
|
||||
# Database (match statistics). The app assembles the DSN from these
|
||||
# parts; DATABASE_PORT may be left unset to use the driver default
|
||||
# (5432 for Postgres). DATABASE_OPTIONS is a raw query string appended
|
||||
# to the URL (e.g. ssl=require); leave empty for none.
|
||||
DATABASE_ENGINE=postgres
|
||||
DATABASE_HOST=localhost
|
||||
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
|
||||
# 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).
|
||||
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_HOST=0.0.0.0
|
||||
APP_PORT=8080
|
||||
|
||||
+13
-2
@@ -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/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 \
|
||||
--mount=type=cache,target=/web/target \
|
||||
trunk build --release
|
||||
trunk build --release --public-url /static/
|
||||
|
||||
# --- Python 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);
|
||||
# the db-migrate compose service runs `aerich upgrade` with working_dir=/app.
|
||||
COPY --from=builder /build/pyproject.toml /app/pyproject.toml
|
||||
# The compiled single-page application, 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
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH" \
|
||||
@@ -77,6 +85,9 @@ ENV PATH="/opt/venv/bin:$PATH" \
|
||||
GRANIAN_HOST=0.0.0.0 \
|
||||
GRANIAN_PORT=8080 \
|
||||
GRANIAN_INTERFACE=rsgi \
|
||||
GRANIAN_STATIC_PATH_ROUTE="/static /assets" \
|
||||
GRANIAN_STATIC_PATH_MOUNT="/app/web/dist:/app/web/dist/assets" \
|
||||
GRANIAN_STATIC_PATH_DIR_TO_FILE=index.html \
|
||||
STATIC_DIR=/app/web/dist
|
||||
|
||||
USER app
|
||||
|
||||
+104
-14
@@ -44,7 +44,14 @@ All configuration comes from environment variables (see `.env.example`):
|
||||
|
||||
| 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 |
|
||||
| `OIDC_ISSUER` | `http://localhost:8180/tavolo` | OIDC issuer URL |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
## 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
|
||||
|
||||
### 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"
|
||||
signals; every open WebSocket reloads the state and pushes the
|
||||
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)
|
||||
|
||||
- `match` — one row per finished match: both teams' final scores, winner,
|
||||
target score, hands played, start/finish timestamps.
|
||||
- `match` — one row per finished match: the game played (`game_type`, one
|
||||
of the ids from `GET /api/game-types`, indexed so statistics can be
|
||||
scoped per game), both teams' final scores, winner, target score, hands
|
||||
played, start/finish timestamps.
|
||||
- `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
|
||||
(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.
|
||||
|
||||
### 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
|
||||
|
||||
All endpoints except `/api/health`, `/api/docs` and `/api/openapi.json`
|
||||
require authentication.
|
||||
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||
`/api/game-types` and `/api/leaderboard` require authentication.
|
||||
|
||||
| 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 |
|
||||
| `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/leaderboard` | Aggregated wins / matches / team points per player |
|
||||
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
||||
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / team points per player, sorted by Elo (`?game_type=`) |
|
||||
|
||||
## 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
|
||||
is required), so a disconnected or idle player cannot stall the match. The
|
||||
timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
|
||||
broadcast like any other.
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
`settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits
|
||||
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,
|
||||
configurable per game) with a clear lead; a tie at or above the target is
|
||||
broken by another hand.
|
||||
@@ -204,9 +291,12 @@ src/tavolo/
|
||||
├── openapi.py # shared OpenAPI parameter fragments
|
||||
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
|
||||
├── aerich_config.py # aerich CLI configuration
|
||||
├── models.py # Match, MatchPlayer (Postgres)
|
||||
├── stats.py # finished match -> Postgres persistence
|
||||
├── store.py # Redis / in-memory live-game store
|
||||
├── models.py # Match, MatchPlayer, PlayerRating (Postgres)
|
||||
├── elo.py # chess-style Elo math (1500 start, K=32)
|
||||
├── 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
|
||||
├── game/
|
||||
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
|
||||
@@ -215,5 +305,5 @@ src/tavolo/
|
||||
└── routes/
|
||||
├── health.py # GET /api/health
|
||||
├── 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="
|
||||
)
|
||||
@@ -10,6 +10,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"kaya-core",
|
||||
"kaya-cors",
|
||||
"kaya-session",
|
||||
"kaya-session-redis",
|
||||
"kaya-oidc",
|
||||
@@ -22,6 +23,7 @@ dependencies = [
|
||||
"httpx",
|
||||
"PyJWT[crypto]",
|
||||
"pwo",
|
||||
"PyYAML",
|
||||
"redis",
|
||||
]
|
||||
|
||||
|
||||
@@ -52,11 +52,14 @@ iso8601==2.1.0
|
||||
# via tortoise-orm
|
||||
kaya-core==0.0.3
|
||||
# via
|
||||
# kaya-cors
|
||||
# kaya-oidc
|
||||
# kaya-openapi
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# tavolo (pyproject.toml)
|
||||
kaya-cors==0.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-oidc==0.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-openapi==0.0.3
|
||||
@@ -84,6 +87,8 @@ pyjwt[crypto]==2.14.0
|
||||
# tavolo (pyproject.toml)
|
||||
pypika-tortoise==0.6.5
|
||||
# via tortoise-orm
|
||||
pyyaml==6.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
redis==8.1.0
|
||||
# via
|
||||
# kaya-session-redis
|
||||
|
||||
@@ -11,6 +11,9 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
|
||||
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
||||
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
||||
|
||||
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
|
||||
``CORS_*`` environment variables (see :mod:`tavolo.config`).
|
||||
|
||||
Live games are kept in :data:`game_store` (Redis when configured, in-memory
|
||||
otherwise). Routes and the websocket handlers are registered by importing
|
||||
their modules at the bottom; imports must happen after ``app`` is built.
|
||||
@@ -18,18 +21,47 @@ their modules at the bottom; imports must happen after ``app`` is built.
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from kaya.core import KayaApp
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.cors import CorsMixin
|
||||
from kaya.oidc import OIDCConfig, OIDCMixin
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
||||
from kaya.session.redis import RedisSessionStore
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from .config import settings
|
||||
from .config import Settings, settings
|
||||
from .deadlines import DeadlineSchedulerMixin
|
||||
from .logging_config import configure_logging
|
||||
from .store import GameStore, InMemoryGameStore, RedisGameStore
|
||||
from .tortoise_mixin import TortoiseMixin
|
||||
|
||||
configure_logging(settings.logging_config)
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
|
||||
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
|
||||
|
||||
Returns ``None`` — CORS disabled — unless at least one of
|
||||
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
|
||||
Settings left unset fall back to the mixin's own defaults.
|
||||
"""
|
||||
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
|
||||
return None
|
||||
return CorsMixin(
|
||||
allow_origins=settings.cors_allow_origins or (),
|
||||
allow_origin_regex=settings.cors_allow_origin_regex,
|
||||
allow_methods=settings.cors_allow_methods or ("GET",),
|
||||
allow_headers=settings.cors_allow_headers or (),
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
expose_headers=settings.cors_expose_headers or (),
|
||||
max_age=settings.cors_max_age,
|
||||
)
|
||||
|
||||
|
||||
session_store: SessionStore
|
||||
if settings.redis_url is not None:
|
||||
# Lazy client: no connection is opened until a session is actually
|
||||
@@ -39,9 +71,11 @@ if settings.redis_url is not None:
|
||||
Redis.from_url(settings.redis_url, decode_responses=False),
|
||||
ttl_seconds=settings.game_ttl_seconds,
|
||||
)
|
||||
log.info("using Redis stores (sessions + live games, game TTL %ds)", settings.game_ttl_seconds)
|
||||
else:
|
||||
session_store = InMemorySessionStore()
|
||||
game_store = InMemoryGameStore()
|
||||
log.info("REDIS_URL unset: using in-memory stores (sessions + live games)")
|
||||
|
||||
session_mixin = SessionMixin(session_store)
|
||||
oidc_mixin = OIDCMixin(
|
||||
@@ -69,12 +103,32 @@ tortoise_mixin = TortoiseMixin(
|
||||
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
|
||||
# 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
|
||||
# route claimed.
|
||||
# static SPA-shell catch-all is registered last and only matches paths no
|
||||
# other route claimed (asset files under /static are served by Granian
|
||||
# itself and never reach the app).
|
||||
from .routes import games, health, me, stats # noqa: E402,F401
|
||||
from . import ws # noqa: E402,F401
|
||||
from .routes import static # noqa: E402,F401
|
||||
|
||||
@@ -0,0 +1,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())
|
||||
@@ -7,7 +7,8 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
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:
|
||||
@@ -19,6 +20,53 @@ def _env(name: str, default: Optional[str] = None) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
|
||||
"""Parse a comma-separated environment variable into a tuple of values.
|
||||
|
||||
Items are stripped and empty items dropped. Unset or empty variables
|
||||
yield ``None``.
|
||||
"""
|
||||
value = os.environ.get(name)
|
||||
if value is None or value.strip() == "":
|
||||
return None
|
||||
return tuple(part.strip() for part in value.split(",") if part.strip())
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _database_url_from_parts(engine: str,
|
||||
user: str,
|
||||
password: Optional[str],
|
||||
host: str,
|
||||
port: str,
|
||||
name: str,
|
||||
options: str) -> str:
|
||||
"""Assemble a database DSN from individual components.
|
||||
|
||||
``user`` and ``password`` are percent-encoded so credentials containing
|
||||
URL-reserved characters (``@``, ``:``, ``/``, ...) do not corrupt the
|
||||
DSN. ``port`` and ``options`` are omitted when empty: a missing port
|
||||
lets the driver pick its default (5432 for asyncpg). ``options`` is a
|
||||
raw query string (e.g. ``ssl=require``) appended after a ``?``.
|
||||
"""
|
||||
netloc = quote(user, safe="")
|
||||
if password:
|
||||
netloc += ":" + quote(password, safe="")
|
||||
netloc += "@" + host
|
||||
if port:
|
||||
netloc += ":" + port
|
||||
url = f"{engine}://{netloc}/{name}"
|
||||
options = options.lstrip("?")
|
||||
if options:
|
||||
url += "?" + options
|
||||
return url
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
database_url: str
|
||||
@@ -37,8 +85,9 @@ class Settings:
|
||||
# How long a live game (and its join-code index) survives in Redis
|
||||
# without activity, in seconds. Defaults to 24h.
|
||||
game_ttl_seconds: int
|
||||
# Directory holding the compiled frontend (trunk's dist output),
|
||||
# served for every path that is not under /api or /auth.
|
||||
# Directory holding the compiled frontend (trunk's dist output). Only
|
||||
# used to locate index.html for the SPA shell; the assets themselves
|
||||
# are served by Granian under /static (GRANIAN_STATIC_PATH_* env vars).
|
||||
static_dir: str
|
||||
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||
# before dealing the next hand anyway.
|
||||
@@ -46,11 +95,42 @@ class Settings:
|
||||
# Seconds a player has to play before the server plays a random legal
|
||||
# card for them (covering disconnects and idle players).
|
||||
turn_timeout_seconds: int
|
||||
# Upper bound on how long the deadline consumer sleeps between polls.
|
||||
# Locally enqueued deadlines wake the consumer immediately; the
|
||||
# heartbeat only bounds the discovery delay for deadlines enqueued by
|
||||
# other workers.
|
||||
deadline_heartbeat_ms: int
|
||||
# Path to a YAML logging configuration file (logging.config.dictConfig
|
||||
# schema). Unset uses the built-in default: DEBUG to the console.
|
||||
logging_config: Optional[str]
|
||||
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
||||
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
|
||||
# from the same origin, so no CORS headers are needed by default.
|
||||
cors_allow_origins: Optional[Tuple[str, ...]]
|
||||
cors_allow_origin_regex: Optional[str]
|
||||
cors_allow_methods: Optional[Tuple[str, ...]]
|
||||
cors_allow_headers: Optional[Tuple[str, ...]]
|
||||
cors_allow_credentials: bool
|
||||
cors_expose_headers: Optional[Tuple[str, ...]]
|
||||
cors_max_age: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
return Settings(
|
||||
database_url=_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_client_id=_env("OIDC_CLIENT_ID", "tavolo"),
|
||||
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET"),
|
||||
@@ -67,6 +147,18 @@ class Settings:
|
||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
||||
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")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Deadline-driven timeouts, independent of player connections.
|
||||
|
||||
Both in-match timeouts — the per-turn auto-play (``turn_deadline``) and
|
||||
the hand-end summary auto-continue (``hand_end_deadline``) — are driven by
|
||||
the absolute deadlines persisted on the game state, never by which players
|
||||
(or whether any players) are connected.
|
||||
|
||||
Every mutation that sets a deadline enqueues an entry in the store's
|
||||
shared deadline queue (a Redis sorted set in production, see
|
||||
:mod:`tavolo.store`), and a background consumer running on **every**
|
||||
worker polls the queue for due entries. An entry records the phase, hand,
|
||||
turn and deadline (as integer epoch milliseconds) it was enqueued for;
|
||||
before acting, the consumer revalidates all of it against the live state
|
||||
under the per-game lock, so entries that were overtaken by events (a play
|
||||
landed in time, the hand was acknowledged, the deadline moved) are simply
|
||||
discarded.
|
||||
|
||||
Delivery is at-least-once: an entry is removed from the queue only after
|
||||
it has been processed. If a worker dies mid-processing, the entry stays in
|
||||
Redis and another worker's consumer picks it up — the lock plus
|
||||
revalidation make the duplicate delivery a no-op. Entries whose game has
|
||||
expired are dropped the first time they fire, so the queue is
|
||||
self-cleaning.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
|
||||
from .config import settings
|
||||
from .game import engine
|
||||
from .game.errors import GameError
|
||||
from .game.state import PHASE_HAND_END, PHASE_PLAYING, PHASE_FINISHED, GameState
|
||||
from .stats import save_match_result
|
||||
from .store import GameStore
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Entry kinds enqueued in the deadline queue.
|
||||
KIND_TURN = "turn"
|
||||
KIND_HAND_END = "hand_end"
|
||||
|
||||
# One consumer task and its wake-up event per event loop (tests run each
|
||||
# test on a fresh loop).
|
||||
_consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
|
||||
_wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
|
||||
|
||||
|
||||
def encode(entry: Dict[str, Any]) -> str:
|
||||
"""Canonical queue-member encoding for a deadline entry."""
|
||||
return json.dumps(entry, sort_keys=True)
|
||||
|
||||
|
||||
def _decode(member: Any) -> Optional[Dict[str, Any]]:
|
||||
if isinstance(member, bytes):
|
||||
member = member.decode("utf-8")
|
||||
if not isinstance(member, str):
|
||||
return None
|
||||
try:
|
||||
entry = json.loads(member)
|
||||
except ValueError:
|
||||
return None
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def _deadline_ms(iso: Optional[str]) -> Optional[int]:
|
||||
"""Epoch milliseconds for an ISO-8601 deadline, ``None`` when absent
|
||||
or unparseable. Queue entries carry this integer (never the ISO
|
||||
string) as their revalidation token."""
|
||||
if not iso:
|
||||
return None
|
||||
try:
|
||||
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def sync_deadline(store: GameStore, state: GameState) -> None:
|
||||
"""Enqueue the deadline the current state carries, if any.
|
||||
|
||||
Called after every mutation that can set a deadline (plays, acks, game
|
||||
start) and as a backstop when a client connects. Enqueueing is
|
||||
idempotent: an identical entry is already queued with the same due
|
||||
time, so re-adding it changes nothing.
|
||||
"""
|
||||
entry: Optional[Dict[str, Any]] = None
|
||||
due_ms: Optional[int] = None
|
||||
if state.phase == PHASE_PLAYING and state.turn_deadline:
|
||||
due_ms = _deadline_ms(state.turn_deadline)
|
||||
entry = {
|
||||
"game_id": state.id,
|
||||
"kind": KIND_TURN,
|
||||
"hand": state.hand_number,
|
||||
"turn": state.turn,
|
||||
"deadline": due_ms,
|
||||
}
|
||||
elif state.phase == PHASE_HAND_END and state.hand_end_deadline:
|
||||
due_ms = _deadline_ms(state.hand_end_deadline)
|
||||
entry = {
|
||||
"game_id": state.id,
|
||||
"kind": KIND_HAND_END,
|
||||
"hand": state.hand_number,
|
||||
"deadline": due_ms,
|
||||
}
|
||||
if entry is None or due_ms is None:
|
||||
if entry is not None:
|
||||
log.warning("game %s: unparseable deadline", state.id)
|
||||
return
|
||||
ensure_consumer(store)
|
||||
# The score derives from the same value carried in the member, so the
|
||||
# two can never disagree.
|
||||
await store.add_deadline(encode(entry), due_ms / 1000)
|
||||
wake = _wake_events.get(asyncio.get_running_loop())
|
||||
if wake is not None:
|
||||
wake.set()
|
||||
|
||||
|
||||
async def finalize_mutation(store: GameStore, state: GameState) -> None:
|
||||
"""Persist a successful mutation, notify subscribers and enqueue the
|
||||
next deadline.
|
||||
|
||||
Callers must hold the per-game lock. Handles the terminal transition:
|
||||
the match result is written to Postgres once (guarded by
|
||||
``stats_saved``).
|
||||
"""
|
||||
if state.phase == PHASE_FINISHED:
|
||||
await save_match_result(state)
|
||||
log.info(
|
||||
"game %s finished: team %s wins %d-%d",
|
||||
state.id,
|
||||
"A" if state.winner == 0 else "B",
|
||||
state.scores[0],
|
||||
state.scores[1],
|
||||
)
|
||||
await store.save(state)
|
||||
await store.publish(state.id)
|
||||
await sync_deadline(store, state)
|
||||
|
||||
|
||||
async def process_due(store: GameStore, member: Any) -> None:
|
||||
"""Fire a single due deadline entry.
|
||||
|
||||
Revalidates the entry against the live state under the per-game lock;
|
||||
stale or foreign entries are discarded without effect. The entry is
|
||||
removed from the queue once handled (including "nothing to do"); if
|
||||
handling fails (e.g. the lock cannot be acquired), the entry is left
|
||||
in the queue so another consumer retries it.
|
||||
"""
|
||||
entry = _decode(member)
|
||||
if entry is None:
|
||||
log.warning("deadline consumer: dropping malformed entry %r", member)
|
||||
await store.remove_deadline(member)
|
||||
return
|
||||
game_id = entry.get("game_id")
|
||||
kind = entry.get("kind")
|
||||
if not isinstance(game_id, str):
|
||||
await store.remove_deadline(member)
|
||||
return
|
||||
async with store.lock(game_id):
|
||||
state = await store.load(game_id)
|
||||
if state is not None:
|
||||
if kind == KIND_TURN:
|
||||
await _fire_turn(store, state, entry)
|
||||
elif kind == KIND_HAND_END:
|
||||
await _fire_hand_end(store, state, entry)
|
||||
await store.remove_deadline(member)
|
||||
|
||||
|
||||
async def _fire_turn(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
|
||||
if (
|
||||
state.phase != PHASE_PLAYING
|
||||
or state.hand_number != entry.get("hand")
|
||||
or state.turn != entry.get("turn")
|
||||
or _deadline_ms(state.turn_deadline) != entry.get("deadline")
|
||||
):
|
||||
return
|
||||
seat = state.turn
|
||||
try:
|
||||
engine.auto_play(state)
|
||||
except GameError:
|
||||
return
|
||||
log.info(
|
||||
"game %s: auto-played for %s (turn timeout, hand %d)",
|
||||
state.id,
|
||||
state.players[seat].sub if seat < len(state.players) else "?",
|
||||
entry.get("hand"),
|
||||
)
|
||||
await finalize_mutation(store, state)
|
||||
|
||||
|
||||
async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, Any]) -> None:
|
||||
if (
|
||||
state.phase != PHASE_HAND_END
|
||||
or state.hand_number != entry.get("hand")
|
||||
or _deadline_ms(state.hand_end_deadline) != entry.get("deadline")
|
||||
):
|
||||
return
|
||||
for player in state.players:
|
||||
engine.acknowledge_hand(state, player.sub)
|
||||
log.info(
|
||||
"game %s: hand %d auto-advanced after the acknowledgement timeout",
|
||||
state.id,
|
||||
entry.get("hand"),
|
||||
)
|
||||
await finalize_mutation(store, state)
|
||||
|
||||
|
||||
# --- consumer lifecycle -------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_consumer(
|
||||
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
) -> None:
|
||||
"""Start the deadline consumer on the given (or running) loop if not
|
||||
yet running.
|
||||
|
||||
Called lazily whenever a deadline is enqueued (the ASGI test transport
|
||||
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
|
||||
enough) and on application startup. The explicit ``loop`` matters at
|
||||
startup: under RSGI granian calls ``setup`` before the loop runs, so
|
||||
``asyncio.get_running_loop()`` would fail there.
|
||||
"""
|
||||
if loop is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
for old in list(_consumers):
|
||||
if old.is_closed():
|
||||
_consumers.pop(old, None)
|
||||
_wake_events.pop(old, None)
|
||||
task = _consumers.get(loop)
|
||||
if task is None or task.done():
|
||||
_wake_events[loop] = asyncio.Event()
|
||||
_consumers[loop] = loop.create_task(_run(store, loop))
|
||||
log.debug("deadline consumer started")
|
||||
|
||||
|
||||
def stop_consumer(loop: asyncio.AbstractEventLoop) -> None:
|
||||
task = _consumers.pop(loop, None)
|
||||
_wake_events.pop(loop, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def _run(store: GameStore, loop: asyncio.AbstractEventLoop) -> None:
|
||||
wake = _wake_events[loop]
|
||||
heartbeat = settings.deadline_heartbeat_ms / 1000
|
||||
while True:
|
||||
# Clear before polling so an enqueue racing the poll re-wakes us.
|
||||
wake.clear()
|
||||
delay = heartbeat
|
||||
try:
|
||||
for member in await store.due_deadlines(time.time()):
|
||||
try:
|
||||
await process_due(store, member)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
# Left in the queue; retried on the next pass.
|
||||
log.exception("deadline consumer: failed to process %r", member)
|
||||
next_due = await store.next_deadline()
|
||||
if next_due is not None:
|
||||
delay = max(0.0, min(heartbeat, next_due - time.time()))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("deadline consumer: poll failed; retrying")
|
||||
try:
|
||||
await asyncio.wait_for(wake.wait(), timeout=delay)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
class DeadlineSchedulerMixin(KayaMixin):
|
||||
"""Run the deadline consumer for the whole app lifetime.
|
||||
|
||||
Every worker (and every pod) runs the same consumer; coordination
|
||||
happens exclusively through the shared deadline queue and the per-game
|
||||
locks, so any worker may fire any game's deadline.
|
||||
"""
|
||||
|
||||
def __init__(self, store: GameStore) -> None:
|
||||
self._store = store
|
||||
|
||||
def apply(self, app: KayaApp) -> None:
|
||||
pass
|
||||
|
||||
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
ensure_consumer(self._store, loop)
|
||||
|
||||
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
stop_consumer(loop)
|
||||
@@ -0,0 +1,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))
|
||||
@@ -1,6 +1,7 @@
|
||||
"""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.errors.GameError` subclasses on rule violations. This
|
||||
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
|
||||
seven/five/four/three card of each suit, all four suits required), plus
|
||||
one point per ``scopa``. Ties on carte/denara/primiera award nothing.
|
||||
* Optional ``napola`` rule (enabled by default): the longest run of
|
||||
consecutive denari starting from the ace scores one point per card when
|
||||
it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
|
||||
capturing the whole denari suit (ace to king) wins the match instantly.
|
||||
* The match ends when a team reaches the target score with a clear lead; a
|
||||
tie at or above the target is broken by playing another hand.
|
||||
"""
|
||||
@@ -29,6 +34,7 @@ from __future__ import annotations
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from itertools import combinations
|
||||
from logging import getLogger
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from .errors import (
|
||||
@@ -55,6 +61,8 @@ from .state import (
|
||||
parse_card,
|
||||
)
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Number of cards dealt to each player at the start of a hand.
|
||||
HAND_SIZE = 10
|
||||
PLAYERS = 4
|
||||
@@ -130,6 +138,8 @@ def create_game(
|
||||
target_score: int = DEFAULT_TARGET_SCORE,
|
||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
game_type: str = "scopone_scientifico",
|
||||
napola: bool = True,
|
||||
) -> GameState:
|
||||
"""Create a lobby game with the creator seated first."""
|
||||
if target_score < 1 or target_score > 100:
|
||||
@@ -138,7 +148,9 @@ def create_game(
|
||||
id=game_id,
|
||||
join_code=join_code,
|
||||
creator_sub=creator_sub,
|
||||
game_type=game_type,
|
||||
target_score=target_score,
|
||||
napola=napola,
|
||||
phase=PHASE_LOBBY,
|
||||
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||
hand_ack_timeout=hand_ack_timeout,
|
||||
@@ -191,6 +203,7 @@ def _deal_hand(state: GameState) -> None:
|
||||
for seat in range(PLAYERS):
|
||||
player = _player_at(state, (state.dealer + 1 + seat) % PLAYERS)
|
||||
player.hand.append(deck.pop())
|
||||
log.debug("game %s: hand %d dealt (dealer seat %d)", state.id, state.hand_number, state.dealer)
|
||||
|
||||
|
||||
def _player_at(state: GameState, seat: int) -> PlayerState:
|
||||
@@ -334,11 +347,32 @@ def _end_hand(state: GameState) -> None:
|
||||
state.hand_scores.append(details)
|
||||
|
||||
a, b = state.scores
|
||||
log.debug(
|
||||
"game %s: hand %d scored A+%d B+%d (totals %d-%d)",
|
||||
state.id,
|
||||
state.hand_number,
|
||||
points[0],
|
||||
points[1],
|
||||
a,
|
||||
b,
|
||||
)
|
||||
# A full napola (the whole denari suit) wins the match outright,
|
||||
# regardless of the score.
|
||||
napola = details.get("napola")
|
||||
if isinstance(napola, dict):
|
||||
for team, name in enumerate(TEAM_NAMES):
|
||||
if napola.get(name) == 10:
|
||||
state.phase = PHASE_FINISHED
|
||||
state.winner = team
|
||||
state.finished_at = datetime.now(timezone.utc).isoformat()
|
||||
log.info("game %s: team %s swept the denari (napola) and wins", state.id, name)
|
||||
return
|
||||
reached = max(a, b) >= state.target_score
|
||||
if reached and a != b:
|
||||
state.phase = PHASE_FINISHED
|
||||
state.winner = 0 if a > b else 1
|
||||
state.finished_at = datetime.now(timezone.utc).isoformat()
|
||||
log.debug("game %s: match ended, team %s wins", state.id, "A" if state.winner == 0 else "B")
|
||||
return
|
||||
|
||||
# Pause for the scoring summary instead of dealing immediately.
|
||||
@@ -387,6 +421,22 @@ def primiera_score(captured: Sequence[Card]) -> int:
|
||||
return sum(best.values())
|
||||
|
||||
|
||||
def napola_score(captured: Sequence[Card]) -> int:
|
||||
"""Return the napola value of a capture pile.
|
||||
|
||||
The longest run of consecutive denari starting from the ace scores one
|
||||
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4,
|
||||
...), so the whole suit (ace to king) is worth 10. Shorter runs score
|
||||
nothing. Only one team can score a napola: the ace of denari belongs
|
||||
to exactly one capture pile.
|
||||
"""
|
||||
ranks = {card.rank for card in captured if card.suit == "D"}
|
||||
run = 0
|
||||
while run + 1 in ranks:
|
||||
run += 1
|
||||
return run if run >= 3 else 0
|
||||
|
||||
|
||||
def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
||||
"""Compute the hand points for both teams (index 0 = team A)."""
|
||||
piles: List[List[Card]] = [[], []]
|
||||
@@ -443,6 +493,16 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
||||
"scope": {"A": scope[0], "B": scope[1]},
|
||||
"award": award,
|
||||
}
|
||||
# Napola (optional rule): consecutive denari from the ace. A run of 10
|
||||
# means the team swept the whole suit and wins the match instantly.
|
||||
if state.napola:
|
||||
napola = [napola_score(piles[t]) for t in (0, 1)]
|
||||
for team in (0, 1):
|
||||
points[team] += napola[team]
|
||||
award["napola"] = next(
|
||||
(TEAM_NAMES[t] for t in (0, 1) if napola[t] > 0), None
|
||||
)
|
||||
details["napola"] = {"A": napola[0], "B": napola[1]}
|
||||
return points, details
|
||||
|
||||
|
||||
@@ -473,8 +533,10 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
||||
payload: Dict[str, object] = {
|
||||
"id": state.id,
|
||||
"join_code": state.join_code,
|
||||
"game_type": state.game_type,
|
||||
"phase": state.phase,
|
||||
"target_score": state.target_score,
|
||||
"napola": state.napola,
|
||||
"hand_number": state.hand_number,
|
||||
"dealer": state.dealer,
|
||||
"turn": state.turn,
|
||||
|
||||
@@ -148,7 +148,14 @@ class GameState:
|
||||
id: str
|
||||
join_code: str
|
||||
creator_sub: str
|
||||
# Which card game this state belongs to (see tavolo.games.GAME_TYPES).
|
||||
# Defaults so states serialized before game types existed still load.
|
||||
game_type: str = "scopone_scientifico"
|
||||
target_score: int = DEFAULT_TARGET_SCORE
|
||||
# Whether the napola rule is scored (denari run from the ace; a full
|
||||
# suit wins the match instantly). Default on, also for states
|
||||
# serialized before the option existed.
|
||||
napola: bool = True
|
||||
phase: str = PHASE_LOBBY
|
||||
players: List[PlayerState] = field(default_factory=list)
|
||||
table: List[Card] = field(default_factory=list)
|
||||
@@ -184,7 +191,9 @@ class GameState:
|
||||
"id": self.id,
|
||||
"join_code": self.join_code,
|
||||
"creator_sub": self.creator_sub,
|
||||
"game_type": self.game_type,
|
||||
"target_score": self.target_score,
|
||||
"napola": self.napola,
|
||||
"phase": self.phase,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"table": [c.to_json() for c in self.table],
|
||||
@@ -212,7 +221,9 @@ class GameState:
|
||||
id=str(data["id"]),
|
||||
join_code=str(data["join_code"]),
|
||||
creator_sub=str(data.get("creator_sub", "")),
|
||||
game_type=str(data.get("game_type", "scopone_scientifico")),
|
||||
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||
napola=bool(data.get("napola", True)),
|
||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||
table=[Card.from_json(c) for c in data.get("table", [])],
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Registry of the card games the platform can host.
|
||||
|
||||
Only *scopone scientifico* is implemented for now; adding a game means a
|
||||
new entry here plus its engine. The registry is the single source of truth
|
||||
for the ``game_type`` carried by every live game (:mod:`tavolo.game.state`)
|
||||
and persisted on each finished match (:mod:`tavolo.models`), which is what
|
||||
makes match statistics game-scoped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GameType:
|
||||
"""Metadata describing one playable card game."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
SCOPONE_SCIENTIFICO = "scopone_scientifico"
|
||||
|
||||
GAME_TYPES: Dict[str, GameType] = {
|
||||
SCOPONE_SCIENTIFICO: GameType(
|
||||
id=SCOPONE_SCIENTIFICO,
|
||||
name="Scopone scientifico",
|
||||
description=(
|
||||
"Four players in fixed partnerships, ten cards each and an empty "
|
||||
"table. First team to the target score wins."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_GAME_TYPE = SCOPONE_SCIENTIFICO
|
||||
|
||||
|
||||
def get_game_type(game_type_id: str) -> Optional[GameType]:
|
||||
"""Return the registered game type with id ``game_type_id``, if any."""
|
||||
return GAME_TYPES.get(game_type_id)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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:`MatchPlayer` — one row per participant, linking an OIDC
|
||||
``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 tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
from .elo import INITIAL_RATING
|
||||
|
||||
|
||||
class Match(Model):
|
||||
"""A completed scopone scientifico match."""
|
||||
"""A completed match of one of the registered game types."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
# Which card game was played (tavolo.games.GAME_TYPES); the default
|
||||
# backfills matches recorded before game types existed.
|
||||
game_type = fields.CharField(max_length=32, db_index=True, default="scopone_scientifico")
|
||||
team_a_score = fields.SmallIntField()
|
||||
team_b_score = fields.SmallIntField()
|
||||
# "A" or "B".
|
||||
@@ -47,7 +55,28 @@ class MatchPlayer(Model):
|
||||
seat = fields.SmallIntField()
|
||||
team = fields.CharField(max_length=1)
|
||||
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:
|
||||
table = "match_player"
|
||||
unique_together = (("match", "user_sub"),)
|
||||
|
||||
|
||||
class PlayerRating(Model):
|
||||
"""Current Elo rating of one player for one game type."""
|
||||
|
||||
id = fields.UUIDField(pk=True)
|
||||
# OIDC subject of the player; no local users table.
|
||||
user_sub = fields.CharField(max_length=255)
|
||||
# Which 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"),)
|
||||
|
||||
@@ -10,20 +10,24 @@ from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from kaya.core import HttpContext
|
||||
from kaya.openapi import operation
|
||||
|
||||
from .. import auth
|
||||
from .. import auth, deadlines
|
||||
from ..app import app, game_store, oidc_mixin
|
||||
from ..auth import require_auth
|
||||
from ..config import settings
|
||||
from ..game import engine
|
||||
from ..game.errors import GameError
|
||||
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
|
||||
from ..games import GAME_TYPES, get_game_type
|
||||
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
# Join codes avoid characters that are easy to confuse when read aloud.
|
||||
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
_CODE_LENGTH = 6
|
||||
@@ -46,7 +50,9 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": state.id,
|
||||
"join_code": state.join_code,
|
||||
"game_type": state.game_type,
|
||||
"target_score": state.target_score,
|
||||
"napola": state.napola,
|
||||
"phase": state.phase,
|
||||
"players": [
|
||||
{"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"}
|
||||
@@ -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")
|
||||
@operation(summary="Create a game",
|
||||
description="Creates a lobby game and seats the caller in seat 0. "
|
||||
@@ -65,18 +86,29 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]:
|
||||
"required": False,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||
},
|
||||
}
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"game_type": {
|
||||
"type": "string",
|
||||
"default": "scopone_scientifico",
|
||||
"description": "One of the ids from GET /api/game-types",
|
||||
},
|
||||
"target_score": {"type": "integer", "minimum": 1, "maximum": 100},
|
||||
"napola": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Score the napola rule; a full "
|
||||
"denari sweep wins the match",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
responses={
|
||||
201: {"description": "The created lobby"},
|
||||
400: {"description": "Invalid target_score or body"},
|
||||
400: {"description": "Invalid game_type, target_score or body"},
|
||||
401: {"description": "Authentication required"},
|
||||
})
|
||||
@require_auth
|
||||
@@ -93,6 +125,16 @@ async def create_game(ctx: HttpContext) -> None:
|
||||
await send_error(ctx, 400, "target_score must be an integer")
|
||||
return
|
||||
|
||||
game_type: Any = body.get("game_type", "scopone_scientifico")
|
||||
if not isinstance(game_type, str) or get_game_type(game_type) is None:
|
||||
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
|
||||
return
|
||||
|
||||
napola: Any = body.get("napola", True)
|
||||
if not isinstance(napola, bool):
|
||||
await send_error(ctx, 400, "napola must be a boolean")
|
||||
return
|
||||
|
||||
user = oidc_mixin.get_user(ctx)
|
||||
assert user is not None # enforced by @require_auth
|
||||
game_id = str(uuid.uuid4())
|
||||
@@ -106,11 +148,20 @@ async def create_game(ctx: HttpContext) -> None:
|
||||
target_score=target_score,
|
||||
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
||||
turn_timeout=settings.turn_timeout_seconds,
|
||||
game_type=game_type,
|
||||
napola=napola,
|
||||
)
|
||||
except GameError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
await game_store.save(state)
|
||||
log.info(
|
||||
"game %s created by %s (%s, target score %d)",
|
||||
game_id,
|
||||
user.sub,
|
||||
game_type,
|
||||
target_score,
|
||||
)
|
||||
await send_json(ctx, 201, _lobby_payload(state))
|
||||
|
||||
|
||||
@@ -154,6 +205,7 @@ async def join_game(ctx: HttpContext) -> None:
|
||||
assert user is not None
|
||||
existing = await game_store.find_by_code(code)
|
||||
if existing is None:
|
||||
log.debug("join rejected for %s: unknown code %r", user.sub, code)
|
||||
await send_error(ctx, 404, "unknown join code")
|
||||
return
|
||||
|
||||
@@ -165,14 +217,22 @@ async def join_game(ctx: HttpContext) -> None:
|
||||
try:
|
||||
engine.join_game(state, user.sub, auth.display_name(user))
|
||||
except GameError as exc:
|
||||
log.debug("join rejected for %s in game %s: %s", user.sub, state.id, exc)
|
||||
await send_error(ctx, 409, str(exc))
|
||||
return
|
||||
await game_store.save(state)
|
||||
await game_store.publish(state.id)
|
||||
# When the fourth join started the match, the first turn deadline
|
||||
# was armed; queue it so it fires even if nobody ever connects.
|
||||
await deadlines.sync_deadline(game_store, state)
|
||||
seat = next(p.seat for p in state.players if p.sub == user.sub)
|
||||
if state.phase == PHASE_LOBBY:
|
||||
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:
|
||||
log.info("%s joined game %s (seat %d); match started", user.sub, state.id, seat)
|
||||
await send_json(ctx, 200, engine.state_for_player(state, user.sub))
|
||||
return
|
||||
await send_json(ctx, 200, _lobby_payload(state))
|
||||
|
||||
|
||||
@app.GET("/api/games/${game_id}")
|
||||
|
||||
@@ -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_DIR`` (the ``web/dist`` output of ``trunk build --release``; see
|
||||
the Docker image). A glob catch-all (``/*``) handles every path that did
|
||||
not match an API or auth route: real files are served with their content
|
||||
type, anything else falls back to ``index.html`` so client-side routes
|
||||
Static assets (wasm, js, css, card images) are served by Granian itself
|
||||
under the ``/static`` prefix (``GRANIAN_STATIC_PATH_*`` env vars; see the
|
||||
Dockerfile) and never reach Python. This module only serves ``index.html``
|
||||
from ``STATIC_DIR``: at the site root and — via the glob catch-all — for
|
||||
every path no API or auth route claimed, so client-side routes
|
||||
(``/game/<id>`` etc.) work on direct loads and refreshes.
|
||||
|
||||
kaya-openapi deliberately skips glob routes, so this handler never appears
|
||||
@@ -14,66 +14,33 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from kaya.core import HttpContext
|
||||
|
||||
from ..app import app
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
def _static_root() -> Path:
|
||||
return Path(settings.static_dir).resolve()
|
||||
|
||||
|
||||
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():
|
||||
async def _send_shell(ctx: HttpContext) -> None:
|
||||
"""Serve the SPA shell, or 404 when the frontend build is missing."""
|
||||
index = Path(settings.static_dir) / "index.html"
|
||||
if not index.is_file():
|
||||
await ctx.send_empty(404)
|
||||
return
|
||||
content_type = _CONTENT_TYPES.get(target.suffix.lower(), "application/octet-stream")
|
||||
body = await asyncio.to_thread(target.read_bytes)
|
||||
await ctx.send_bytes(200, body, {"content-type": (content_type,)})
|
||||
body = await asyncio.to_thread(index.read_bytes)
|
||||
await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)})
|
||||
|
||||
|
||||
@app.GET("/")
|
||||
async def index(ctx: HttpContext) -> None:
|
||||
"""Serve the SPA shell at the site root (the glob below cannot match
|
||||
an empty path)."""
|
||||
await _send_path(ctx, _static_root() / "index.html")
|
||||
await _send_shell(ctx)
|
||||
|
||||
|
||||
@app.GET("/*", recursive=True)
|
||||
async def spa(ctx: HttpContext, _matched: object = None) -> None:
|
||||
root = _static_root()
|
||||
relative = ctx.path.lstrip("/")
|
||||
target = (root / relative).resolve() if relative else root
|
||||
# Path-traversal guard: the resolved target must stay inside the dist
|
||||
# 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)
|
||||
"""SPA fallback: any path that matched no other route renders the app
|
||||
shell. Requests under ``/static`` are answered by Granian before the
|
||||
app is ever called, so they never arrive here."""
|
||||
await _send_shell(ctx)
|
||||
|
||||
@@ -6,23 +6,48 @@ leaderboard aggregated from the same two tables.
|
||||
"""
|
||||
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.openapi import operation
|
||||
|
||||
from ..app import app, oidc_mixin
|
||||
from ..auth import require_auth
|
||||
from ..http import send_error, send_json
|
||||
from ..models import Match, MatchPlayer
|
||||
from ..elo import INITIAL_RATING
|
||||
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 ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
||||
|
||||
GAME_TYPE_PARAMETER: Dict[str, Any] = {
|
||||
"name": "game_type",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "string"},
|
||||
"description": "Only count matches of this game (id from GET /api/game-types).",
|
||||
}
|
||||
|
||||
|
||||
def _parse_game_type(query_string: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Parse the ``game_type`` query parameter.
|
||||
|
||||
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
|
||||
when valid, ``(None, message)`` when it names no registered game."""
|
||||
values = extract_query_params(query_string).get("game_type")
|
||||
if not values:
|
||||
return None, None
|
||||
game_type = values[0]
|
||||
if get_game_type(game_type) is None:
|
||||
return None, f"unknown game_type: {game_type!r}"
|
||||
return game_type, None
|
||||
|
||||
|
||||
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
||||
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
|
||||
return {
|
||||
"id": str(match.id),
|
||||
"game_type": match.game_type,
|
||||
"team_a_score": match.team_a_score,
|
||||
"team_b_score": match.team_b_score,
|
||||
"winner_team": match.winner_team,
|
||||
@@ -31,6 +56,9 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
||||
"started_at": match.started_at.isoformat(),
|
||||
"finished_at": match.finished_at.isoformat(),
|
||||
"you_won": any(p.user_sub == viewer and p.won for p in participants),
|
||||
"your_elo_delta": next(
|
||||
(p.elo_delta for p in participants if p.user_sub == viewer), None
|
||||
),
|
||||
"players": [
|
||||
{
|
||||
"user_sub": p.user_sub,
|
||||
@@ -38,6 +66,7 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
||||
"seat": p.seat,
|
||||
"team": p.team,
|
||||
"won": p.won,
|
||||
"elo_delta": p.elo_delta,
|
||||
}
|
||||
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 "
|
||||
"played, newest first, with the final score.",
|
||||
tags=["stats"],
|
||||
parameters=PAGINATION_PARAMETERS,
|
||||
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
|
||||
responses={
|
||||
200: {"description": "A page of matches"},
|
||||
400: {"description": "Invalid pagination cursor"},
|
||||
400: {"description": "Invalid pagination cursor or game_type"},
|
||||
401: {"description": "Authentication required"},
|
||||
})
|
||||
@require_auth
|
||||
@@ -62,9 +91,15 @@ async def my_matches(ctx: HttpContext) -> None:
|
||||
except CursorDecodeError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
game_type, error = _parse_game_type(ctx.query_string)
|
||||
if error is not None:
|
||||
await send_error(ctx, 400, error)
|
||||
return
|
||||
user = oidc_mixin.get_user(ctx)
|
||||
assert user is not None
|
||||
queryset = Match.filter(players__user_sub=user.sub).distinct()
|
||||
if game_type is not None:
|
||||
queryset = queryset.filter(game_type=game_type)
|
||||
matches, next_cursor = await paginate(
|
||||
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
|
||||
)
|
||||
@@ -74,12 +109,29 @@ async def my_matches(ctx: HttpContext) -> None:
|
||||
|
||||
@app.GET("/api/leaderboard")
|
||||
@operation(summary="Global leaderboard",
|
||||
description="Aggregated wins, matches played and team points for every "
|
||||
"player with at least one finished match. Sorted by wins.",
|
||||
description="Elo rating, aggregated wins, matches played and team "
|
||||
"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"],
|
||||
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:
|
||||
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]] = {}
|
||||
for row in rows:
|
||||
entry = aggregate.setdefault(
|
||||
@@ -90,6 +142,7 @@ async def leaderboard(ctx: HttpContext) -> None:
|
||||
"matches": 0,
|
||||
"wins": 0,
|
||||
"points": 0,
|
||||
"elo": ratings.get(row.user_sub, INITIAL_RATING),
|
||||
},
|
||||
)
|
||||
entry["matches"] += 1
|
||||
@@ -104,7 +157,33 @@ async def leaderboard(ctx: HttpContext) -> None:
|
||||
|
||||
ranking: List[Dict[str, Any]] = sorted(
|
||||
aggregate.values(),
|
||||
key=lambda e: (e["wins"], e["points"], -e["matches"]),
|
||||
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
|
||||
reverse=True,
|
||||
)
|
||||
await send_json(ctx, 200, {"results": ranking})
|
||||
|
||||
|
||||
@app.GET("/api/me/ratings")
|
||||
@operation(summary="My Elo ratings",
|
||||
description="The caller's current Elo rating for every game type "
|
||||
"they have played.",
|
||||
tags=["stats"],
|
||||
responses={
|
||||
200: {"description": "The caller's ratings"},
|
||||
401: {"description": "Authentication required"},
|
||||
})
|
||||
@require_auth
|
||||
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
|
||||
]
|
||||
})
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
|
||||
Called once when a game reaches the finished phase (guarded by the
|
||||
``stats_saved`` flag on the state). The write is transactional so a match
|
||||
never appears with only some of its players.
|
||||
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
|
||||
|
||||
import uuid
|
||||
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 .elo import match_delta
|
||||
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_timestamp(value: Optional[str]) -> datetime:
|
||||
if value:
|
||||
@@ -24,6 +29,46 @@ def _parse_timestamp(value: Optional[str]) -> datetime:
|
||||
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:
|
||||
"""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:
|
||||
@@ -33,17 +78,23 @@ async def save_match_result(state: GameState) -> None:
|
||||
|
||||
started_at = _parse_timestamp(state.created_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():
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
game_type=state.game_type,
|
||||
team_a_score=state.scores[0],
|
||||
team_b_score=state.scores[1],
|
||||
winner_team=TEAM_NAMES[state.winner],
|
||||
winner_team=winner_team,
|
||||
target_score=state.target_score,
|
||||
hands_played=state.hand_number,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
deltas = await apply_elo(state.game_type, winner_team, team_members)
|
||||
for player in state.players:
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
@@ -53,5 +104,15 @@ async def save_match_result(state: GameState) -> None:
|
||||
seat=player.seat,
|
||||
team=TEAM_NAMES[player.team],
|
||||
won=player.team == state.winner,
|
||||
elo_delta=deltas[player.sub],
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
signal (never the state) means updated state reaches connections on every
|
||||
worker without leaking hidden hands into the channel.
|
||||
|
||||
Timeouts (turn auto-play, hand-end auto-continue) are driven by a shared
|
||||
delayed-deadline queue: producers enqueue an opaque ``member`` string with
|
||||
a due timestamp, and a consumer on every worker polls for due entries.
|
||||
Delivery is at-least-once — entries are removed only after they are
|
||||
processed — so a worker dying mid-processing cannot lose a deadline;
|
||||
consumers revalidate entries against the live state under the per-game
|
||||
lock, which makes duplicate deliveries harmless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,15 +33,19 @@ import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
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 .game.state import GameState
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
GAME_KEY_PREFIX = "tavolo:game:"
|
||||
CODE_KEY_PREFIX = "tavolo:code:"
|
||||
CHANNEL_PREFIX = "tavolo:game:"
|
||||
DEADLINES_KEY = "tavolo:deadlines"
|
||||
|
||||
# Sentinel pushed into in-memory subscriber queues to signal a change.
|
||||
_BUMP = b"update"
|
||||
@@ -66,6 +78,26 @@ class GameStore(ABC):
|
||||
async def publish(self, game_id: str) -> None:
|
||||
"""Signal that the state of ``game_id`` changed."""
|
||||
|
||||
@abstractmethod
|
||||
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||
"""Enqueue ``member`` to fire at ``due_at`` (epoch seconds).
|
||||
|
||||
Idempotent for identical members: re-adding an existing member only
|
||||
updates its due time.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||
"""Return up to ``limit`` enqueued members due at or before ``now``."""
|
||||
|
||||
@abstractmethod
|
||||
async def next_deadline(self) -> Optional[float]:
|
||||
"""Return the earliest pending due time (epoch seconds), if any."""
|
||||
|
||||
@abstractmethod
|
||||
async def remove_deadline(self, member: str) -> None:
|
||||
"""Remove ``member`` from the queue; a no-op when absent."""
|
||||
|
||||
|
||||
def _channel(game_id: str) -> str:
|
||||
return f"{CHANNEL_PREFIX}{game_id}:events"
|
||||
@@ -86,9 +118,11 @@ class RedisGameStore(GameStore):
|
||||
|
||||
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
|
||||
if raw is None:
|
||||
log.debug("redis load %s: miss", game_id)
|
||||
return None
|
||||
if isinstance(raw, bytes):
|
||||
raw = raw.decode("utf-8")
|
||||
log.debug("redis load %s: hit", game_id)
|
||||
return GameState.from_json(json.loads(raw))
|
||||
|
||||
async def save(self, state: GameState) -> None:
|
||||
@@ -98,6 +132,7 @@ class RedisGameStore(GameStore):
|
||||
pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl)
|
||||
pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl)
|
||||
await pipe.execute()
|
||||
log.debug("redis save %s (phase %s, ttl %ds)", state.id, state.phase, self._ttl)
|
||||
|
||||
async def find_by_code(self, code: str) -> Optional[GameState]:
|
||||
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
|
||||
@@ -120,6 +155,26 @@ class RedisGameStore(GameStore):
|
||||
|
||||
async def publish(self, game_id: str) -> None:
|
||||
await self._redis.publish(_channel(game_id), "update")
|
||||
log.debug("redis publish %s", game_id)
|
||||
|
||||
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||
await self._redis.zadd(DEADLINES_KEY, {member: due_at})
|
||||
|
||||
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||
members = cast(
|
||||
list,
|
||||
await self._redis.zrangebyscore(
|
||||
DEADLINES_KEY, "-inf", now, start=0, num=limit
|
||||
),
|
||||
)
|
||||
return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
|
||||
|
||||
async def next_deadline(self) -> Optional[float]:
|
||||
earliest = await self._redis.zrange(DEADLINES_KEY, 0, 0, withscores=True)
|
||||
return float(earliest[0][1]) if earliest else None
|
||||
|
||||
async def remove_deadline(self, member: str) -> None:
|
||||
await self._redis.zrem(DEADLINES_KEY, member)
|
||||
|
||||
|
||||
async def _redis_events(pubsub) -> AsyncIterator[None]:
|
||||
@@ -136,6 +191,7 @@ class InMemoryGameStore(GameStore):
|
||||
self._codes: Dict[str, str] = {}
|
||||
self._locks: Dict[str, asyncio.Lock] = {}
|
||||
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
|
||||
self._deadlines: Dict[str, float] = {}
|
||||
|
||||
def _lock_for(self, game_id: str) -> asyncio.Lock:
|
||||
lock = self._locks.get(game_id)
|
||||
@@ -180,6 +236,20 @@ class InMemoryGameStore(GameStore):
|
||||
for queue in list(self._subscribers.get(game_id, ())):
|
||||
queue.put_nowait(_BUMP)
|
||||
|
||||
async def add_deadline(self, member: str, due_at: float) -> None:
|
||||
self._deadlines[member] = due_at
|
||||
|
||||
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
|
||||
due = [m for m, due_at in self._deadlines.items() if due_at <= now]
|
||||
due.sort(key=self._deadlines.__getitem__)
|
||||
return due[:limit]
|
||||
|
||||
async def next_deadline(self) -> Optional[float]:
|
||||
return min(self._deadlines.values(), default=None)
|
||||
|
||||
async def remove_deadline(self, member: str) -> None:
|
||||
self._deadlines.pop(member, None)
|
||||
|
||||
|
||||
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
|
||||
while True:
|
||||
|
||||
@@ -35,6 +35,7 @@ from __future__ import annotations
|
||||
from asyncio import AbstractEventLoop, get_running_loop
|
||||
from logging import getLogger
|
||||
from typing import AbstractSet, Optional, Sequence
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket
|
||||
from tortoise.context import TortoiseContext, _current_context
|
||||
@@ -42,6 +43,19 @@ from tortoise.context import TortoiseContext, _current_context
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_url(database_url: str) -> str:
|
||||
"""The database URL with any credentials stripped, for logging."""
|
||||
parts = urlsplit(database_url)
|
||||
host = parts.hostname or ""
|
||||
try:
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
except ValueError:
|
||||
# Non-numeric netloc (e.g. sqlite://:memory:): keep the host only.
|
||||
pass
|
||||
return urlunsplit((parts.scheme, host, parts.path, "", ""))
|
||||
|
||||
|
||||
class TortoiseMixin(KayaMixin):
|
||||
"""Initialize and tear down a per-loop :class:`TortoiseContext`."""
|
||||
|
||||
@@ -64,6 +78,7 @@ class TortoiseMixin(KayaMixin):
|
||||
|
||||
def shutdown(self, loop: AbstractEventLoop) -> None:
|
||||
if self._init_loop is loop and self._ctx is not None:
|
||||
log.info("closing database connections")
|
||||
loop.create_task(self._ctx.close_connections())
|
||||
self._ctx = None
|
||||
self._init_loop = None
|
||||
@@ -75,11 +90,13 @@ class TortoiseMixin(KayaMixin):
|
||||
db_url=self._database_url,
|
||||
modules={"models": self._models_modules},
|
||||
)
|
||||
log.info("database context initialized (%s)", _safe_url(self._database_url))
|
||||
# Schema creation is only done for sqlite (in-memory test
|
||||
# databases). Postgres schemas are managed by aerich migrations
|
||||
# (applied by the db-migrate compose service / `aerich upgrade`).
|
||||
if self._database_url.startswith("sqlite"):
|
||||
await ctx.generate_schemas()
|
||||
log.info("sqlite schemas generated")
|
||||
return ctx
|
||||
|
||||
async def _bind(self) -> None:
|
||||
@@ -88,6 +105,7 @@ class TortoiseMixin(KayaMixin):
|
||||
if self._ctx is not None:
|
||||
# A previous test loop went away; drop its context.
|
||||
self._ctx = None
|
||||
log.debug("building a Tortoise context for a new event loop")
|
||||
self._ctx = await self._build_context()
|
||||
self._init_loop = loop
|
||||
assert self._ctx is not None
|
||||
|
||||
+31
-115
@@ -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
|
||||
players see the move immediately (and consistently across workers).
|
||||
|
||||
If a player does not move before the per-game ``turn_timeout``, the server
|
||||
plays a random card (with a random legal capture when one is required) for
|
||||
them, so a disconnected or idle player cannot stall the match. The timer is
|
||||
re-armed by every client connection and state broadcast, and fires
|
||||
immediately when a reconnect finds the deadline already past.
|
||||
Timeouts do not depend on anyone being connected: both the per-turn
|
||||
auto-play and the hand-end auto-continue are driven by the absolute
|
||||
deadlines persisted on the game state, via the shared deadline queue
|
||||
drained by a consumer on every worker (see :mod:`tavolo.deadlines`). A
|
||||
disconnected or idle player therefore cannot stall the match, and a
|
||||
worker dying cannot either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
from logging import getLogger
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
|
||||
from kaya.core import WebSocket
|
||||
|
||||
from . import auth
|
||||
from . import auth, deadlines
|
||||
from .app import app, game_store
|
||||
from .game import engine
|
||||
from .game.errors import GameError
|
||||
from .game.state import PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, GameState
|
||||
from .stats import save_match_result
|
||||
from .game.state import PHASE_FINISHED, GameState
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
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:
|
||||
user = auth.get_ws_user(ws)
|
||||
if user is None:
|
||||
log.debug("websocket %s rejected: no authenticated user", game_id)
|
||||
await ws.close(4401)
|
||||
return
|
||||
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
log.debug("websocket rejected: unknown game %s", game_id)
|
||||
await ws.close(4404)
|
||||
return
|
||||
if not state.seated(user.sub):
|
||||
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
|
||||
await ws.close(4403)
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
log.info("%s connected to game %s", user.sub, game_id)
|
||||
|
||||
send_lock = asyncio.Lock()
|
||||
|
||||
@@ -88,7 +94,10 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
|
||||
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:
|
||||
forward = asyncio.create_task(
|
||||
@@ -106,6 +115,7 @@ async def game_socket(ws: WebSocket, game_id: str) -> None:
|
||||
forward.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await forward
|
||||
log.debug("%s disconnected from game %s", user.sub, game_id)
|
||||
|
||||
|
||||
async def _forward(
|
||||
@@ -118,7 +128,6 @@ async def _forward(
|
||||
state = await game_store.load(game_id)
|
||||
if state is None:
|
||||
return
|
||||
schedule_turn_timer(game_id, state)
|
||||
await send(_state_message(state, sub))
|
||||
if state.phase == PHASE_FINISHED:
|
||||
await send(
|
||||
@@ -135,9 +144,11 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
|
||||
await send(_error("invalid JSON"))
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
|
||||
await send(_error("message must be a JSON object"))
|
||||
return
|
||||
|
||||
@@ -156,10 +167,6 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
||||
|
||||
# --- 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 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:
|
||||
await send(_error(str(exc), code="illegal_move"))
|
||||
return
|
||||
log.debug("game %s: %s acknowledged hand %d", game_id, sub, state.hand_number)
|
||||
await game_store.save(state)
|
||||
await game_store.publish(game_id)
|
||||
|
||||
|
||||
def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> None:
|
||||
"""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)
|
||||
# The fourth ack deals the next hand, which arms a new turn
|
||||
# deadline; earlier acks change nothing and this is a no-op.
|
||||
await deadlines.sync_deadline(game_store, state)
|
||||
|
||||
|
||||
async def _handle_play(
|
||||
@@ -297,10 +210,13 @@ async def _handle_play(
|
||||
try:
|
||||
engine.play(state, sub, card, capture)
|
||||
except GameError as exc:
|
||||
log.debug("game %s: illegal move by %s: %s", game_id, sub, exc)
|
||||
await send(_error(str(exc), code="illegal_move"))
|
||||
return
|
||||
except ValueError:
|
||||
log.debug("game %s: invalid card code from %s: %r", game_id, sub, card)
|
||||
await send(_error("invalid card code", code="illegal_move"))
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Deadline-queue timeout tests.
|
||||
|
||||
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||
not by connected sockets: these tests seed games, queue their deadlines
|
||||
and let the background consumer fire them without a single websocket.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pwo import async_test
|
||||
|
||||
from tavolo import deadlines
|
||||
from tavolo.app import game_store
|
||||
from tavolo.game import engine
|
||||
from tavolo.game.state import GameState, PlayerState
|
||||
|
||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||
|
||||
|
||||
def _ms(iso: str) -> int:
|
||||
"""Epoch milliseconds for an ISO-8601 timestamp (the queue-entry form)."""
|
||||
return int(datetime.fromisoformat(iso).timestamp() * 1000)
|
||||
|
||||
|
||||
def _hand_end_state(game_id: str, deadline: str) -> GameState:
|
||||
"""A game paused on the hand-end summary, waiting for acks."""
|
||||
state = GameState(
|
||||
id=game_id,
|
||||
join_code="DLhend",
|
||||
creator_sub="alice",
|
||||
target_score=11,
|
||||
phase="hand_end",
|
||||
# Long turn timeout: the next hand's auto-play must not interfere
|
||||
# with later tests sharing this store.
|
||||
turn_timeout=3600,
|
||||
)
|
||||
state.players = [
|
||||
PlayerState(sub=name, name=name.capitalize(), seat=i)
|
||||
for i, name in enumerate(PLAYERS)
|
||||
]
|
||||
state.hand_end_deadline = deadline
|
||||
return state
|
||||
|
||||
|
||||
async def _wait_for(predicate, timeout: float = 5.0) -> Optional[GameState]:
|
||||
"""Poll the store until ``predicate`` holds for the loaded state."""
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
state = await predicate()
|
||||
if state is not None:
|
||||
return state
|
||||
await asyncio.sleep(0.05)
|
||||
return None
|
||||
|
||||
|
||||
class ConnectionIndependenceTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_turn_timeout_fires_with_no_connections(self) -> None:
|
||||
state = engine.create_game(
|
||||
"dl-turn-1", "DLT001", "alice", "Alice",
|
||||
target_score=11, turn_timeout=1,
|
||||
)
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join_game(state, name, name.capitalize())
|
||||
assert state.turn_deadline is not None
|
||||
await game_store.save(state)
|
||||
await deadlines.sync_deadline(game_store, state)
|
||||
|
||||
# Nobody ever connects: the consumer must still auto-play for Bob
|
||||
# (seat 1, first to act).
|
||||
result = await _wait_for(
|
||||
lambda: _turn_is(state.id, 2),
|
||||
)
|
||||
self.assertIsNotNone(result, "turn deadline never fired")
|
||||
assert result is not None
|
||||
self.assertEqual(1, result.last_move.seat if result.last_move else None)
|
||||
|
||||
# Defuse the follow-on turn deadlines so this game cannot keep
|
||||
# auto-playing while later tests run.
|
||||
result.turn_timeout = 3600
|
||||
await game_store.save(result)
|
||||
|
||||
@async_test
|
||||
async def test_hand_end_timeout_fires_with_no_connections(self) -> None:
|
||||
deadline = (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat()
|
||||
state = _hand_end_state("dl-handend-1", deadline)
|
||||
await game_store.save(state)
|
||||
await deadlines.sync_deadline(game_store, state)
|
||||
|
||||
# Nobody acks (nobody is even connected): the deadline must deal
|
||||
# the next hand.
|
||||
result = await _wait_for(
|
||||
lambda: _phase_is("dl-handend-1", "playing"),
|
||||
)
|
||||
self.assertIsNotNone(result, "hand-end deadline never fired")
|
||||
assert result is not None
|
||||
self.assertEqual(2, result.hand_number)
|
||||
self.assertEqual([], result.acked)
|
||||
|
||||
|
||||
async def _turn_is(game_id: str, turn: int) -> Optional[GameState]:
|
||||
state = await game_store.load(game_id)
|
||||
return state if state is not None and state.turn == turn else None
|
||||
|
||||
|
||||
async def _phase_is(game_id: str, phase: str) -> Optional[GameState]:
|
||||
state = await game_store.load(game_id)
|
||||
return state if state is not None and state.phase == phase else None
|
||||
|
||||
|
||||
class ProcessDueTest(unittest.TestCase):
|
||||
"""Direct ``process_due`` behaviour: revalidation and idempotency."""
|
||||
|
||||
@async_test
|
||||
async def test_processing_twice_is_a_no_op(self) -> None:
|
||||
# Simulates a worker dying after firing but before removing the
|
||||
# entry: another worker re-delivers the same entry.
|
||||
deadline = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
|
||||
state = _hand_end_state("dl-idem-1", deadline)
|
||||
await game_store.save(state)
|
||||
member = deadlines.encode({
|
||||
"game_id": state.id,
|
||||
"kind": deadlines.KIND_HAND_END,
|
||||
"hand": state.hand_number,
|
||||
"deadline": _ms(deadline),
|
||||
})
|
||||
|
||||
await deadlines.process_due(game_store, member)
|
||||
await deadlines.process_due(game_store, member)
|
||||
|
||||
result = await game_store.load(state.id)
|
||||
assert result is not None
|
||||
# Advanced exactly once: hand 2, not hand 3.
|
||||
self.assertEqual("playing", result.phase)
|
||||
self.assertEqual(2, result.hand_number)
|
||||
|
||||
@async_test
|
||||
async def test_stale_entry_is_discarded(self) -> None:
|
||||
# A turn entry enqueued before a play landed in time: the state's
|
||||
# deadline has moved, so the entry must not fire.
|
||||
state = engine.create_game(
|
||||
"dl-stale-1", "DLS001", "alice", "Alice",
|
||||
target_score=11, turn_timeout=3600,
|
||||
)
|
||||
for name in PLAYERS[1:]:
|
||||
engine.join_game(state, name, name.capitalize())
|
||||
await game_store.save(state)
|
||||
member = deadlines.encode({
|
||||
"game_id": state.id,
|
||||
"kind": deadlines.KIND_TURN,
|
||||
"hand": state.hand_number,
|
||||
"turn": state.turn,
|
||||
# Not the live deadline (epoch milliseconds).
|
||||
"deadline": 946684800000,
|
||||
})
|
||||
await game_store.add_deadline(member, due_at=0.0)
|
||||
|
||||
await deadlines.process_due(game_store, member)
|
||||
|
||||
result = await game_store.load(state.id)
|
||||
assert result is not None
|
||||
self.assertEqual(state.turn, result.turn)
|
||||
# The entry was removed after processing.
|
||||
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_entry_for_expired_game_is_dropped(self) -> None:
|
||||
member = deadlines.encode({
|
||||
"game_id": "dl-gone",
|
||||
"kind": deadlines.KIND_TURN,
|
||||
"hand": 1,
|
||||
"turn": 0,
|
||||
"deadline": 946684800000,
|
||||
})
|
||||
await game_store.add_deadline(member, due_at=0.0)
|
||||
await deadlines.process_due(game_store, member)
|
||||
self.assertNotIn(member, await game_store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_malformed_entry_is_dropped(self) -> None:
|
||||
await game_store.add_deadline("not json", due_at=0.0)
|
||||
await deadlines.process_due(game_store, "not json")
|
||||
self.assertNotIn("not json", await game_store.due_deadlines(float("inf")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -239,6 +239,95 @@ class ScoringTest(unittest.TestCase):
|
||||
self.assertEqual([0, 0], points)
|
||||
|
||||
|
||||
class NapolaTest(unittest.TestCase):
|
||||
def test_napola_score_runs(self) -> None:
|
||||
self.assertEqual(0, engine.napola_score(
|
||||
[card(c) for c in ["02D", "03D", "04D"]])) # no ace
|
||||
self.assertEqual(0, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D"]])) # too short
|
||||
self.assertEqual(3, engine.napola_score(
|
||||
[card(c) for c in ["03D", "01D", "02D"]])) # order-independent
|
||||
self.assertEqual(4, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D", "03D", "04D", "07C"]]))
|
||||
self.assertEqual(3, engine.napola_score(
|
||||
[card(c) for c in ["01D", "02D", "03D", "05D"]])) # broken run
|
||||
self.assertEqual(10, engine.napola_score(
|
||||
[card(f"{rank:02d}D") for rank in range(1, 11)]))
|
||||
|
||||
def test_hand_points_napola(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["01D", "02D", "03D", "04C"], # seat 0, team A
|
||||
["05D", "06D", "07D", "08D"], # seat 1, team B
|
||||
["09D", "10D", "01C", "02C"], # seat 2, team A
|
||||
["03C", "05C", "06C", "07C"], # seat 3, team B
|
||||
],
|
||||
)
|
||||
points, details = engine.hand_points(state)
|
||||
# Team A has the ace-led run 01D-03D (3 points); team B's denari
|
||||
# start at the 5, so no napola. Carte tie (8 each), denara to A
|
||||
# (5 vs 4), settebello to B, primiere tied at 0 (missing suits).
|
||||
self.assertEqual({"A": 3, "B": 0}, details["napola"])
|
||||
self.assertEqual("A", details["award"]["napola"])
|
||||
self.assertEqual([4, 1], points)
|
||||
|
||||
def test_napola_disabled(self) -> None:
|
||||
state = make_state(
|
||||
[[], [], [], []],
|
||||
table=[],
|
||||
captured=[
|
||||
["01D", "02D", "03D", "04C"],
|
||||
["05D", "06D", "07D", "08D"],
|
||||
["09D", "10D", "01C", "02C"],
|
||||
["03C", "05C", "06C", "07C"],
|
||||
],
|
||||
)
|
||||
state.napola = False
|
||||
points, details = engine.hand_points(state)
|
||||
self.assertNotIn("napola", details)
|
||||
self.assertEqual([1, 1], points)
|
||||
|
||||
def test_full_denari_sweep_wins_match_instantly(self) -> None:
|
||||
# Team A already captured the whole denari suit; the last play of
|
||||
# the hand cannot capture. Team B leads 50-0, yet the napola ends
|
||||
# the match in team A's favour, well below the target of 100.
|
||||
state = make_state(
|
||||
[["02C"], [], [], []],
|
||||
table=[],
|
||||
target=100,
|
||||
captured=[
|
||||
[f"{rank:02d}D" for rank in range(1, 11)],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
)
|
||||
state.scores = [0, 50]
|
||||
engine.play(state, "p0", "02C")
|
||||
self.assertEqual(PHASE_FINISHED, state.phase)
|
||||
self.assertEqual(0, state.winner)
|
||||
self.assertLess(state.scores[0], 100)
|
||||
self.assertEqual(10, state.hand_scores[-1]["napola"]["A"])
|
||||
|
||||
def test_napola_serialization_roundtrip(self) -> None:
|
||||
state = make_state([["02D"], [], [], []], table=[])
|
||||
self.assertTrue(state.napola)
|
||||
state.napola = False
|
||||
self.assertFalse(GameState.from_json(state.to_json()).napola)
|
||||
# States serialized before the option existed default to enabled.
|
||||
data = state.to_json()
|
||||
del data["napola"]
|
||||
self.assertTrue(GameState.from_json(data).napola)
|
||||
|
||||
def test_create_game_napola_default_and_override(self) -> None:
|
||||
self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola)
|
||||
self.assertFalse(
|
||||
engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola
|
||||
)
|
||||
|
||||
|
||||
class MatchFlowTest(unittest.TestCase):
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11)
|
||||
|
||||
@@ -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()
|
||||
@@ -71,6 +71,24 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertNotIn("hand", state["players"][0])
|
||||
self.assertEqual(1, state["turn"])
|
||||
|
||||
@async_test
|
||||
async def test_create_napola_option(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
default = await client.post("/api/games", json={})
|
||||
self.assertEqual(201, default.status_code)
|
||||
self.assertTrue(default.json()["napola"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
disabled = await client.post("/api/games", json={"napola": False})
|
||||
self.assertEqual(201, disabled.status_code)
|
||||
self.assertFalse(disabled.json()["napola"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
invalid = await client.post("/api/games", json={"napola": "yes"})
|
||||
self.assertEqual(400, invalid.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_join_errors(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -119,5 +137,53 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
|
||||
class GameTypesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_lists_available_game_types(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/game-types")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
|
||||
self.assertEqual("Scopone scientifico", results[0]["name"])
|
||||
self.assertTrue(results[0]["description"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post("/api/games", json={})
|
||||
self.assertEqual(201, created.status_code)
|
||||
self.assertEqual("scopone_scientifico", created.json()["game_type"])
|
||||
|
||||
@async_test
|
||||
async def test_create_with_explicit_game_type(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games", json={"game_type": "scopone_scientifico"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("scopone_scientifico", body["game_type"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
snapshot = await client.get(f"/api/games/{body['id']}")
|
||||
self.assertEqual("scopone_scientifico", snapshot.json()["game_type"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_unknown_game_type(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
unknown = await client.post("/api/games", json={"game_type": "briscola"})
|
||||
non_string = await client.post("/api/games", json={"game_type": 42})
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
self.assertEqual(400, non_string.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -34,15 +34,13 @@ class MeRouteTest(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 def test_serves_files_and_spa_fallback(self) -> None:
|
||||
async def test_serves_shell_and_spa_fallback(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as dist:
|
||||
root = Path(dist)
|
||||
(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/>")
|
||||
(Path(dist) / "index.html").write_text("<html>spa</html>")
|
||||
|
||||
patched = dataclasses.replace(settings, static_dir=dist)
|
||||
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:
|
||||
index = await client.get("/")
|
||||
self.assertEqual(200, index.status_code)
|
||||
self.assertEqual("text/html; charset=utf-8", index.headers["content-type"])
|
||||
self.assertIn(b"spa", index.content)
|
||||
|
||||
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.
|
||||
fallback = await client.get("/game/some-id")
|
||||
self.assertEqual(200, fallback.status_code)
|
||||
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 def test_missing_dist_returns_404(self) -> None:
|
||||
patched = dataclasses.replace(settings, static_dir="/nonexistent-dist")
|
||||
|
||||
+230
-2
@@ -9,9 +9,10 @@ from httpx import ASGITransport, AsyncClient
|
||||
from pwo import async_test
|
||||
|
||||
from tavolo.app import app, tortoise_mixin
|
||||
from tavolo.elo import INITIAL_RATING
|
||||
from tavolo.game import engine
|
||||
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 tests.helpers import oidc_user
|
||||
|
||||
@@ -48,6 +49,28 @@ def _finished_state() -> GameState:
|
||||
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):
|
||||
@async_test
|
||||
async def test_finished_match_is_persisted_once(self) -> None:
|
||||
@@ -66,11 +89,65 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
assert match is not None
|
||||
self.assertEqual(state.scores[0], match.team_a_score)
|
||||
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)
|
||||
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()
|
||||
with ctx:
|
||||
for index, (a_score, b_score, winner, finished) in enumerate(
|
||||
@@ -81,6 +158,7 @@ async def _seed_two_matches() -> None:
|
||||
):
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
game_type=game_types[index],
|
||||
team_a_score=a_score,
|
||||
team_b_score=b_score,
|
||||
winner_team=winner,
|
||||
@@ -164,6 +242,156 @@ class StatsRouteTest(unittest.TestCase):
|
||||
# Alice leads on points after tying Bob on wins.
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -25,6 +25,24 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
self.assertEqual(16, loaded.target_score)
|
||||
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 def test_load_missing_returns_none(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
@@ -90,6 +108,29 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
["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__":
|
||||
unittest.main()
|
||||
|
||||
+27
-2
@@ -22,9 +22,22 @@ pub async fn me() -> Result<Option<User>, 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")
|
||||
.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())?
|
||||
.send()
|
||||
.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())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let resp = Request::get("/api/leaderboard")
|
||||
.send()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod card;
|
||||
pub mod summary;
|
||||
pub mod toast;
|
||||
|
||||
@@ -113,6 +113,45 @@ pub fn summary_rows(summary: HandSummary) -> View {
|
||||
summary.award.primiera.clone(),
|
||||
"+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
|
||||
{
|
||||
let a = summary.scope.a;
|
||||
|
||||
@@ -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) } }))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct User {
|
||||
@@ -75,6 +79,8 @@ pub struct Award {
|
||||
pub settebello: Option<String>,
|
||||
#[serde(default)]
|
||||
pub primiera: Option<String>,
|
||||
#[serde(default)]
|
||||
pub napola: Option<String>,
|
||||
}
|
||||
|
||||
/// The scoring breakdown of one completed hand.
|
||||
@@ -86,6 +92,10 @@ pub struct HandSummary {
|
||||
pub settebello: TeamBools,
|
||||
pub primiera: 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,
|
||||
#[serde(default)]
|
||||
pub hand: i32,
|
||||
@@ -101,6 +111,12 @@ pub struct GameView {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub target_score: i32,
|
||||
@@ -168,12 +184,18 @@ pub struct MatchPlayer {
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
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)]
|
||||
#[allow(dead_code)]
|
||||
pub struct MatchSummary {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub game_type: String,
|
||||
pub team_a_score: i32,
|
||||
pub team_b_score: i32,
|
||||
pub winner_team: String,
|
||||
@@ -183,6 +205,9 @@ pub struct MatchSummary {
|
||||
pub finished_at: String,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub players: Vec<MatchPlayer>,
|
||||
}
|
||||
@@ -195,22 +220,60 @@ pub struct MatchesPage {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn default_elo() -> i32 {
|
||||
1500
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LeaderboardEntry {
|
||||
pub user_sub: 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 wins: 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)]
|
||||
pub struct LeaderboardPage {
|
||||
#[serde(default)]
|
||||
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.
|
||||
pub fn card_asset(code: &str) -> String {
|
||||
format!("/assets/cards/{code}.svg")
|
||||
|
||||
+224
-30
@@ -1,8 +1,12 @@
|
||||
//! Live game page: table view over the websocket.
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
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::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)]
|
||||
pub fn GamePage(id: String) -> View {
|
||||
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 over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||
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);
|
||||
// Ticking clock driving the hand-end countdown display.
|
||||
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()));
|
||||
|
||||
{
|
||||
let on_message = move |msg: ServerMessage| match msg {
|
||||
ServerMessage::State { game: g } => {
|
||||
capture_choice.set(None);
|
||||
selected.set(None);
|
||||
game.set(Some(g));
|
||||
}
|
||||
ServerMessage::GameOver { scores, winner } => {
|
||||
over.set(Some((scores, winner)));
|
||||
}
|
||||
ServerMessage::Error { message, .. } => error.set(Some(message)),
|
||||
};
|
||||
let on_close = move || closed.set(true);
|
||||
match ws::connect(&id, on_message, on_close) {
|
||||
Some(s) => socket.set(Some(s)),
|
||||
None => error.set(Some("Could not connect to the game".to_string())),
|
||||
// Stops the ticker and any pending reconnect once the page unmounts.
|
||||
let alive = Rc::new(Cell::new(true));
|
||||
on_cleanup({
|
||||
let alive = alive.clone();
|
||||
move || {
|
||||
alive.set(false);
|
||||
drop(ticker);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let id = Rc::new(id);
|
||||
let ctx = ConnCtx {
|
||||
socket,
|
||||
game,
|
||||
over,
|
||||
error,
|
||||
closed,
|
||||
gave_up,
|
||||
fatal,
|
||||
attempts,
|
||||
capture_choice,
|
||||
selected,
|
||||
};
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
||||
ctx.attempts.set(0);
|
||||
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.
|
||||
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 };
|
||||
if g.your_turn != Some(true) {
|
||||
return;
|
||||
@@ -121,20 +281,50 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
};
|
||||
|
||||
let reconnect_banner = reconnect.clone();
|
||||
view! {
|
||||
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() {
|
||||
None => {
|
||||
let status = if closed.get() {
|
||||
"Connection closed."
|
||||
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 {
|
||||
"Connecting to the game…"
|
||||
};
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { (status) }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
let status = if closed.get() {
|
||||
"Connection lost — reconnecting…"
|
||||
} else {
|
||||
"Connecting to the game…"
|
||||
};
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { (status) }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,9 +436,10 @@ fn table_view(
|
||||
let hand_number = game.hand_number;
|
||||
let target_score = game.target_score;
|
||||
|
||||
let hand = player_for_seat(&game, viewer_seat)
|
||||
.and_then(|p| p.hand)
|
||||
.unwrap_or_default();
|
||||
let viewer = player_for_seat(&game, viewer_seat);
|
||||
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
|
||||
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 hand_cards = hand
|
||||
.into_iter()
|
||||
@@ -300,6 +491,9 @@ fn table_view(
|
||||
}
|
||||
(right)
|
||||
div(class="seat-bottom") {
|
||||
div(class="seat-stats own-stats") {
|
||||
(my_captured) " captured · " (my_scope) " scope"
|
||||
}
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::MatchesPage;
|
||||
|
||||
#[component]
|
||||
@@ -45,7 +46,7 @@ pub fn HistoryPage() -> View {
|
||||
a(href="/leaderboard") { "Leaderboard" }
|
||||
}
|
||||
h1 { "My matches" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(toast(error))
|
||||
(move || match page.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(_) if rows.get_clone().is_empty() => view! {
|
||||
@@ -71,6 +72,10 @@ pub fn HistoryPage() -> View {
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
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! {
|
||||
tr {
|
||||
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 { "Team " (m.winner_team) }
|
||||
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 { "Winner" }
|
||||
th { "You" }
|
||||
th { "Elo" }
|
||||
}
|
||||
}
|
||||
tbody { (table_rows) }
|
||||
|
||||
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::LeaderboardPage;
|
||||
|
||||
#[component]
|
||||
@@ -24,7 +25,7 @@ pub fn LeaderboardPage() -> View {
|
||||
a(href="/history") { "My matches" }
|
||||
}
|
||||
h1 { "Leaderboard" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(toast(error))
|
||||
(move || match page.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(p) => {
|
||||
@@ -38,6 +39,7 @@ pub fn LeaderboardPage() -> View {
|
||||
tr {
|
||||
td { (i + 1) }
|
||||
td { (e.display_name.clone()) }
|
||||
td { (e.elo) }
|
||||
td { (e.wins) }
|
||||
td { (e.matches) }
|
||||
td { (e.points) }
|
||||
@@ -51,6 +53,7 @@ pub fn LeaderboardPage() -> View {
|
||||
tr {
|
||||
th { "#" }
|
||||
th { "Player" }
|
||||
th { "Elo" }
|
||||
th { "Wins" }
|
||||
th { "Matches" }
|
||||
th { "Points" }
|
||||
|
||||
+64
-5
@@ -4,18 +4,43 @@ use sycamore::prelude::*;
|
||||
use sycamore_router::navigate;
|
||||
|
||||
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]
|
||||
pub fn LobbyPage() -> View {
|
||||
// Outer None = still loading; Some(None) = logged out.
|
||||
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 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 {
|
||||
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) => {
|
||||
error.set(Some(e));
|
||||
user.set(Some(None));
|
||||
@@ -23,9 +48,21 @@ pub fn LobbyPage() -> View {
|
||||
}
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
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(target).await {
|
||||
match api::create_game(&game_type, target, napola).await {
|
||||
Ok(game) => navigate(&format!("/game/{}", game.id)),
|
||||
Err(e) => error.set(Some(e)),
|
||||
}
|
||||
@@ -48,7 +85,7 @@ pub fn LobbyPage() -> View {
|
||||
view! {
|
||||
div(class="lobby") {
|
||||
h1 { "Scopone scientifico" }
|
||||
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
|
||||
(toast(error))
|
||||
(move || match user.get_clone() {
|
||||
None => view! { p(class="status") { "Loading…" } },
|
||||
Some(None) => view! {
|
||||
@@ -63,13 +100,35 @@ pub fn LobbyPage() -> View {
|
||||
Some(Some(me)) => view! {
|
||||
div(class="lobby-grid") {
|
||||
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="/leaderboard") { "Leaderboard" }
|
||||
a(href="/auth/logout", rel="external") { "Log out" }
|
||||
}
|
||||
div(class="panel") {
|
||||
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." }
|
||||
div(class="target-buttons") {
|
||||
button(class="button", on:click=move |_| on_create(11)) { "Target 11" }
|
||||
|
||||
+12
-3
@@ -4,7 +4,7 @@ use std::rc::Rc;
|
||||
|
||||
use futures::channel::mpsc;
|
||||
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 crate::model::ServerMessage;
|
||||
@@ -53,10 +53,14 @@ impl GameSocket {
|
||||
/// Open the websocket for `game_id` and forward parsed server messages to
|
||||
/// `on_message`. Returns the socket handle, or `None` if the connection
|
||||
/// 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(
|
||||
game_id: &str,
|
||||
on_message: impl Fn(ServerMessage) + 'static,
|
||||
on_close: impl Fn() + 'static,
|
||||
on_close: impl Fn(Option<u16>) + 'static,
|
||||
) -> Option<GameSocket> {
|
||||
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
||||
let (mut write, mut read) = ws.split();
|
||||
@@ -72,6 +76,7 @@ pub fn connect(
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let mut close_code = None;
|
||||
while let Some(msg) = read.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
@@ -80,10 +85,14 @@ pub fn connect(
|
||||
}
|
||||
}
|
||||
Ok(Message::Bytes(_)) => {}
|
||||
Err(WebSocketError::ConnectionClose(e)) => {
|
||||
close_code = Some(e.code);
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
on_close();
|
||||
on_close(close_code);
|
||||
});
|
||||
|
||||
Some(GameSocket {
|
||||
|
||||
@@ -49,6 +49,10 @@ body {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.whoami .rating {
|
||||
color: var(--muted, #888);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
@@ -123,6 +127,12 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.join-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -304,6 +314,11 @@ table.matches td.lost {
|
||||
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 {
|
||||
grid-area: center;
|
||||
display: flex;
|
||||
@@ -407,6 +422,27 @@ table.matches td.lost {
|
||||
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 ---------- */
|
||||
|
||||
.overlay {
|
||||
|
||||
Reference in New Issue
Block a user