4 Commits
Author SHA1 Message Date
woggioni 658e3d6ec7 Show own captured and scopa counts in game view
CI / Build and push docker image (push) Successful in 2m46s
2026-09-18 09:18:18 +08:00
woggioni 5e4e1310b4 Drive timeouts from a shared Redis deadline queue
Turn auto-play and hand-end auto-continue were process-local asyncio
tasks armed only by client connects and state broadcasts: with no
sockets connected the next turn's timer was never armed, a hand-end
timer died with its worker, and neither survived a pod restart.

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

Queue entries carry the deadline as integer epoch milliseconds, which
also serves as the revalidation token, and the score derives from the
same value.
2026-09-18 09:18:17 +08:00
woggioni db7ba30d13 Add configurable napola rule with instant win on a full denari sweep 2026-09-18 09:18:17 +08:00
woggioni f7fa8e78e5 Auto-dismiss error toasts after 10 seconds 2026-09-18 09:18:17 +08:00
30 changed files with 68 additions and 1448 deletions
-1
View File
@@ -32,7 +32,6 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
builder: multiplatform-builder
file: server/Dockerfile file: server/Dockerfile
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
-17
View File
@@ -61,23 +61,6 @@ data:
GAME_TTL_SECONDS: "86400" GAME_TTL_SECONDS: "86400"
HAND_ACK_TIMEOUT_SECONDS: "30" HAND_ACK_TIMEOUT_SECONDS: "30"
TURN_TIMEOUT_SECONDS: "30" TURN_TIMEOUT_SECONDS: "30"
# CORS (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"
# OpenTelemetry (kaya-otel): traces + metrics via OTLP/HTTP, disabled
# unless OTEL_ENABLED is set. Requires the otel extra in the image.
# OTEL_ENABLED: "true"
# OTEL_SERVICE_NAME: "tavolo"
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
# OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Bearer ..."
# OTEL_EXCLUDED_PATHS: "/api/health" # default; paths skipped by tracing
# OIDC (provider lives in another namespace). # OIDC (provider lives in another namespace).
OIDC_CLIENT_ID: tavolo OIDC_CLIENT_ID: tavolo
OIDC_POST_LOGIN_REDIRECT: / OIDC_POST_LOGIN_REDIRECT: /
-16
View File
@@ -113,22 +113,6 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30} HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30} TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
# CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX
# is set (see server/.env.example for the full list of options).
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-}
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-}
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-}
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
# OpenTelemetry (kaya-otel): disabled unless OTEL_ENABLED is set.
# Requires the otel extra in the image (see server/pyproject.toml).
OTEL_ENABLED: ${OTEL_ENABLED:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_EXCLUDED_PATHS: ${OTEL_EXCLUDED_PATHS:-}
ports: ports:
- "127.0.0.1:${APP_PORT:-8080}:8080" - "127.0.0.1:${APP_PORT:-8080}:8080"
-22
View File
@@ -39,28 +39,6 @@ TURN_TIMEOUT_SECONDS=30
# schema). Unset logs DEBUG to the console. # schema). Unset logs DEBUG to the console.
#LOGGING_CONFIG=/path/to/logging.yaml #LOGGING_CONFIG=/path/to/logging.yaml
# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's
# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set — the app serves the SPA and the API from
# the same origin, so no CORS headers are needed by default.
# Comma-separated list of origins allowed to make cross-origin requests,
# or "*" for any origin:
#CORS_ALLOW_ORIGINS=https://example.com,https://app.example.com
# Optional regex (fullmatch) allowed origins are additionally checked
# against — handy for dynamic preview URLs:
#CORS_ALLOW_ORIGIN_REGEX=https://tavolo-[a-z0-9-]+\.vercel\.app
# Comma-separated allowed methods, or "*" for all (default GET):
#CORS_ALLOW_METHODS=GET,POST
# Comma-separated allowed request headers, or "*" to mirror back whatever
# the browser requests (default: only the CORS-safelisted headers):
#CORS_ALLOW_HEADERS=Authorization,Content-Type
# Allow cookies/credentials on cross-origin requests (1/true/yes/on):
#CORS_ALLOW_CREDENTIALS=false
# Comma-separated response headers exposed to the browser:
#CORS_EXPOSE_HEADERS=
# Seconds browsers may cache the preflight response (default 600):
#CORS_MAX_AGE=600
# App server # App server
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8080 APP_PORT=8080
+6 -10
View File
@@ -48,21 +48,17 @@ RUN --mount=type=cache,target=/var/cache/apk \
WORKDIR /build WORKDIR /build
COPY server/pyproject.toml server/README.md server/requirements.txt ./
COPY server/requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt
COPY server/pyproject.toml server/README.md ./
COPY server/src/ ./src/ COPY server/src/ ./src/
RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install .
# aerich migration files are a release artifact: the db-migrate compose # aerich migration files are a release artifact: the db-migrate compose
# service runs `aerich upgrade` from this image before the app starts. # service runs `aerich upgrade` from this image before the app starts.
COPY server/migrations/ ./migrations/ COPY server/migrations/ ./migrations/
RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --upgrade pip \
&& /opt/venv/bin/pip install -r requirements.txt .
# --- Runtime --------------------------------------------------------------- # --- Runtime ---------------------------------------------------------------
FROM alpine:3.24 FROM alpine:3.24
+6 -40
View File
@@ -62,18 +62,6 @@ All configuration comes from environment variables (see `.env.example`):
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them | | `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) | | `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 | | `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 |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (requires the `otel` extra, i.e. `pip install tavolo[otel]`) |
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
| `OTEL_EXCLUDED_PATHS` | `/api/health` | Comma-separated paths excluded from tracing and metrics (exact matches) |
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address | | `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
## Logging ## Logging
@@ -138,31 +126,12 @@ loggers:
scoped per game), both teams' final scores, winner, target score, hands scoped per game), both teams' final scores, winner, target score, hands
played, start/finish timestamps. played, start/finish timestamps.
- `match_player` — one row per participant: the OIDC `sub`, display name, - `match_player` — one row per participant: the OIDC `sub`, display name,
seat, team, whether they won and the Elo change the match produced seat, team and whether they won. Unique per `(match, user_sub)`.
(`elo_delta`). Unique per `(match, user_sub)`.
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
the number of rated matches played.
When a match ends, the result is written transactionally to Postgres When a match ends, the result is written transactionally to Postgres
(once, guarded by a flag on the Redis state); the finished state stays in (once, guarded by a flag on the Redis state); the finished state stays in
Redis until its TTL expires so clients can still fetch the final board. Redis until its TTL expires so clients can still fetch the final board.
### Elo ratings
Players carry a chess-style Elo rating per game type (`tavolo.elo`):
everyone starts at 1500, a team's rating is the mean of its two members,
and the standard formula `E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))`
with `K = 32` decides how many points the match result moves — the same
delta for both members of a team, zero-sum between teams. Ratings update
in the same transaction as the match result. To recompute every rating
from the recorded match history (e.g. to backfill matches recorded before
ratings existed):
```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
.venv/bin/python -m tavolo.backfill_elo
```
## REST API ## REST API
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
@@ -174,9 +143,8 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` | | `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores and per-player Elo deltas (`?limit=&cursor=&game_type=`) | | `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type | | `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / team points per player, sorted by Elo (`?game_type=`) |
## WebSocket protocol ## WebSocket protocol
@@ -296,10 +264,8 @@ src/tavolo/
├── openapi.py # shared OpenAPI parameter fragments ├── openapi.py # shared OpenAPI parameter fragments
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket) ├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
├── aerich_config.py # aerich CLI configuration ├── aerich_config.py # aerich CLI configuration
├── models.py # Match, MatchPlayer, PlayerRating (Postgres) ├── models.py # Match, MatchPlayer (Postgres)
├── elo.py # chess-style Elo math (1500 start, K=32) ├── stats.py # finished match -> Postgres persistence
├── 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) ├── store.py # Redis / in-memory live-game store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler ├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint ├── ws.py # WebSocket live-play endpoint
@@ -310,5 +276,5 @@ src/tavolo/
└── routes/ └── routes/
├── health.py # GET /api/health ├── health.py # GET /api/health
├── games.py # lobby: create / join / snapshot ├── games.py # lobby: create / join / snapshot
└── stats.py # match history + leaderboard + Elo ratings └── stats.py # match history + leaderboard
``` ```
@@ -1,55 +0,0 @@
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="
)
-4
View File
@@ -10,7 +10,6 @@ readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"kaya-core", "kaya-core",
"kaya-cors",
"kaya-session", "kaya-session",
"kaya-session-redis", "kaya-session-redis",
"kaya-oidc", "kaya-oidc",
@@ -32,9 +31,6 @@ dev = [
"mypy", "mypy",
"httpx-ws", "httpx-ws",
] ]
otel = [
"kaya-otel>=0.0.4",
]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+6 -9
View File
@@ -50,28 +50,25 @@ idna==3.19
# httpx # httpx
iso8601==2.1.0 iso8601==2.1.0
# via tortoise-orm # via tortoise-orm
kaya-core==0.0.4 kaya-core==0.0.3
# via # via
# kaya-cors
# kaya-oidc # kaya-oidc
# kaya-openapi # kaya-openapi
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo (pyproject.toml) # tavolo (pyproject.toml)
kaya-cors==0.0.4 kaya-oidc==0.0.3
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-oidc==0.0.4 kaya-openapi==0.0.3
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-openapi==0.0.4 kaya-rsgi==0.0.3
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
kaya-rsgi==0.0.4 kaya-session==0.0.3
# via tavolo (pyproject.toml)
kaya-session==0.0.4
# via # via
# kaya-oidc # kaya-oidc
# kaya-session-redis # kaya-session-redis
# tavolo (pyproject.toml) # tavolo (pyproject.toml)
kaya-session-redis==0.0.4 kaya-session-redis==0.0.3
# via tavolo (pyproject.toml) # via tavolo (pyproject.toml)
pwo==0.1.2 pwo==0.1.2
# via # via
+4 -88
View File
@@ -11,11 +11,6 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at - :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
``/api/openapi.json`` and a Swagger UI at ``/api/docs``) ``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
``CORS_*`` environment variables (see :mod:`tavolo.config`). A
:class:`~kaya.otel.OTelMixin` (optional ``otel`` extra) is prepended when
``OTEL_ENABLED`` is set, adding OpenTelemetry traces and metrics.
Live games are kept in :data:`game_store` (Redis when configured, in-memory Live games are kept in :data:`game_store` (Redis when configured, in-memory
otherwise). Routes and the websocket handlers are registered by importing otherwise). Routes and the websocket handlers are registered by importing
their modules at the bottom; imports must happen after ``app`` is built. their modules at the bottom; imports must happen after ``app`` is built.
@@ -24,17 +19,15 @@ from __future__ import annotations
from importlib.metadata import version as _pkg_version from importlib.metadata import version as _pkg_version
from logging import getLogger from logging import getLogger
from typing import Optional
from kaya.core import KayaApp, KayaMixin from kaya.core import KayaApp
from kaya.cors import CorsMixin
from kaya.oidc import OIDCConfig, OIDCMixin from kaya.oidc import OIDCConfig, OIDCMixin
from kaya.openapi import OpenAPIMixin from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
from kaya.session.redis import RedisSessionStore from kaya.session.redis import RedisSessionStore
from redis.asyncio import Redis from redis.asyncio import Redis
from .config import Settings, settings from .config import settings
from .deadlines import DeadlineSchedulerMixin from .deadlines import DeadlineSchedulerMixin
from .logging_config import configure_logging from .logging_config import configure_logging
from .store import GameStore, InMemoryGameStore, RedisGameStore from .store import GameStore, InMemoryGameStore, RedisGameStore
@@ -43,57 +36,6 @@ from .tortoise_mixin import TortoiseMixin
configure_logging(settings.logging_config) configure_logging(settings.logging_config)
log = getLogger(__name__) log = getLogger(__name__)
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
Returns ``None`` — CORS disabled — unless at least one of
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
Settings left unset fall back to the mixin's own defaults.
"""
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
return None
return CorsMixin(
allow_origins=settings.cors_allow_origins or (),
allow_origin_regex=settings.cors_allow_origin_regex,
allow_methods=settings.cors_allow_methods or ("GET",),
allow_headers=settings.cors_allow_headers or (),
allow_credentials=settings.cors_allow_credentials,
expose_headers=settings.cors_expose_headers or (),
max_age=settings.cors_max_age,
)
def otel_mixin_from_settings(settings: Settings) -> Optional[KayaMixin]:
"""Build a :class:`~kaya.otel.OTelMixin` from the OTEL_* settings.
Returns ``None`` — telemetry disabled — unless ``OTEL_ENABLED`` is
truthy. kaya-otel is an optional dependency (the ``otel`` extra), so it
is imported lazily here: default installs and the test suite never need
the OpenTelemetry packages.
"""
if not settings.otel_enabled:
return None
try:
from kaya.otel import OTelMixin
except ImportError as exc:
raise RuntimeError(
"OTEL_ENABLED is set but kaya-otel is not installed; "
"install tavolo with the 'otel' extra"
) from exc
headers = dict(
pair.split("=", 1)
for pair in (settings.otel_exporter_headers or ())
if "=" in pair
)
return OTelMixin(
service_name=settings.otel_service_name,
endpoint=settings.otel_exporter_endpoint,
headers=headers or None,
excluded_paths=settings.otel_excluded_paths,
)
session_store: SessionStore session_store: SessionStore
if settings.redis_url is not None: if settings.redis_url is not None:
# Lazy client: no connection is opened until a session is actually # Lazy client: no connection is opened until a session is actually
@@ -135,34 +77,8 @@ tortoise_mixin = TortoiseMixin(
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}), skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
) )
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin, app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
DeadlineSchedulerMixin(game_store)] DeadlineSchedulerMixin(game_store)])
otel_mixin = otel_mixin_from_settings(settings)
if otel_mixin is not None:
# First in the list: before hooks run in registration order (after hooks
# in reverse), so the span covers session loading, OIDC handling and the
# handler itself. CORS, when enabled, is still prepended before it so
# preflight short-circuits stay untraced.
mixins.insert(0, otel_mixin)
log.info(
"OpenTelemetry enabled: service=%s endpoint=%s",
settings.otel_service_name,
settings.otel_exporter_endpoint or "(OTLP default)",
)
cors_mixin = cors_mixin_from_settings(settings)
if cors_mixin is not None:
# First in the list: preflight requests are answered before the session
# and OIDC hooks run.
mixins.insert(0, cors_mixin)
log.info(
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
settings.cors_allow_origins,
settings.cors_allow_origin_regex,
settings.cors_allow_credentials,
)
app = KayaApp(mixins=mixins)
log.debug( log.debug(
"timeouts: hand_ack=%ds turn=%ds", "timeouts: hand_ack=%ds turn=%ds",
settings.hand_ack_timeout_seconds, settings.hand_ack_timeout_seconds,
-66
View File
@@ -1,66 +0,0 @@
"""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())
+1 -58
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, Tuple from typing import Optional
from urllib.parse import quote from urllib.parse import quote
@@ -20,25 +20,6 @@ def _env(name: str, default: Optional[str] = None) -> str:
return value return value
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
"""Parse a comma-separated environment variable into a tuple of values.
Items are stripped and empty items dropped. Unset or empty variables
yield ``None``.
"""
value = os.environ.get(name)
if value is None or value.strip() == "":
return None
return tuple(part.strip() for part in value.split(",") if part.strip())
def _env_bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None or value == "":
return default
return value.strip().lower() in ("1", "true", "yes", "on")
def _database_url_from_parts(engine: str, def _database_url_from_parts(engine: str,
user: str, user: str,
password: Optional[str], password: Optional[str],
@@ -103,26 +84,6 @@ class Settings:
# Path to a YAML logging configuration file (logging.config.dictConfig # Path to a YAML logging configuration file (logging.config.dictConfig
# schema). Unset uses the built-in default: DEBUG to the console. # schema). Unset uses the built-in default: DEBUG to the console.
logging_config: Optional[str] logging_config: Optional[str]
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
# from the same origin, so no CORS headers are needed by default.
cors_allow_origins: Optional[Tuple[str, ...]]
cors_allow_origin_regex: Optional[str]
cors_allow_methods: Optional[Tuple[str, ...]]
cors_allow_headers: Optional[Tuple[str, ...]]
cors_allow_credentials: bool
cors_expose_headers: Optional[Tuple[str, ...]]
cors_max_age: int
# OpenTelemetry (kaya-otel's OTelMixin). Disabled unless OTEL_ENABLED is
# truthy; the exporter endpoint falls back to the OTLP/HTTP default
# (localhost:4318) when OTEL_EXPORTER_OTLP_ENDPOINT is unset.
otel_enabled: bool
otel_service_name: str
otel_exporter_endpoint: Optional[str]
otel_exporter_headers: Optional[Tuple[str, ...]]
# Paths excluded from tracing and metrics (exact matches). Defaults to
# the health endpoint, which k8s probes would otherwise spam.
otel_excluded_paths: Tuple[str, ...]
@staticmethod @staticmethod
def from_env() -> "Settings": def from_env() -> "Settings":
@@ -159,24 +120,6 @@ class Settings:
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")), turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")), deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
logging_config=os.environ.get("LOGGING_CONFIG"), logging_config=os.environ.get("LOGGING_CONFIG"),
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
# is set.
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
# OpenTelemetry is opt-in: set OTEL_ENABLED=1 to export traces
# and metrics via OTLP/HTTP (requires the ``otel`` extra).
otel_enabled=_env_bool("OTEL_ENABLED"),
otel_service_name=_env("OTEL_SERVICE_NAME", "tavolo"),
otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None,
# Comma-separated key=value pairs, e.g. "Authorization=Bearer x".
otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"),
otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",),
) )
+5 -11
View File
@@ -214,20 +214,14 @@ async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, An
# --- consumer lifecycle ------------------------------------------------------- # --- consumer lifecycle -------------------------------------------------------
def ensure_consumer( def ensure_consumer(store: GameStore) -> None:
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None """Start the deadline consumer on the running loop if not yet running.
) -> 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 Called lazily whenever a deadline is enqueued (the ASGI test transport
never fires the lifespan hooks, so the mixin's ``setup`` alone is not never fires the lifespan hooks, so the mixin's ``setup`` alone is not
enough) and on application startup. The explicit ``loop`` matters at enough) and on application startup.
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()
loop = asyncio.get_running_loop()
for old in list(_consumers): for old in list(_consumers):
if old.is_closed(): if old.is_closed():
_consumers.pop(old, None) _consumers.pop(old, None)
@@ -290,7 +284,7 @@ class DeadlineSchedulerMixin(KayaMixin):
pass pass
def setup(self, loop: asyncio.AbstractEventLoop) -> None: def setup(self, loop: asyncio.AbstractEventLoop) -> None:
ensure_consumer(self._store, loop) ensure_consumer(self._store)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None: def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
stop_consumer(loop) stop_consumer(loop)
-49
View File
@@ -1,49 +0,0 @@
"""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))
-26
View File
@@ -7,17 +7,12 @@ a player took part in, with the final score":
* :class:`Match` — one row per finished match with both teams' scores. * :class:`Match` — one row per finished match with both teams' scores.
* :class:`MatchPlayer` — one row per participant, linking an OIDC * :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat/team and whether they won. ``sub`` to a seat/team and whether they won.
* :class:`PlayerRating` — current chess-style Elo rating of a player for
one game type, updated transactionally with every finished match (see
:mod:`tavolo.elo`).
""" """
from __future__ import annotations from __future__ import annotations
from tortoise import fields from tortoise import fields
from tortoise.models import Model from tortoise.models import Model
from .elo import INITIAL_RATING
class Match(Model): class Match(Model):
"""A completed match of one of the registered game types.""" """A completed match of one of the registered game types."""
@@ -55,28 +50,7 @@ class MatchPlayer(Model):
seat = fields.SmallIntField() seat = fields.SmallIntField()
team = fields.CharField(max_length=1) team = fields.CharField(max_length=1)
won = fields.BooleanField() won = fields.BooleanField()
# Elo change this match produced for the player (see tavolo.elo);
# null for matches recorded before ratings existed.
elo_delta = fields.SmallIntField(null=True)
class Meta: class Meta:
table = "match_player" table = "match_player"
unique_together = (("match", "user_sub"),) unique_together = (("match", "user_sub"),)
class PlayerRating(Model):
"""Current Elo rating of one player for one game type."""
id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255)
# Which card game the rating applies to (tavolo.games.GAME_TYPES).
game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING)
matches_played = fields.IntField(default=0)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "player_rating"
unique_together = (("user_sub", "game_type"),)
indexes = (("game_type", "rating"),)
+5 -43
View File
@@ -13,10 +13,9 @@ from kaya.openapi import operation
from ..app import app, oidc_mixin from ..app import app, oidc_mixin
from ..auth import require_auth from ..auth import require_auth
from ..elo import INITIAL_RATING from ..games import get_game_type
from ..games import DEFAULT_GAME_TYPE, get_game_type
from ..http import extract_query_params, send_error, send_json from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer, PlayerRating from ..models import Match, MatchPlayer
from ..openapi import PAGINATION_PARAMETERS from ..openapi import PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params from ..pagination import CursorDecodeError, paginate, parse_cursor_params
@@ -56,9 +55,6 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
"started_at": match.started_at.isoformat(), "started_at": match.started_at.isoformat(),
"finished_at": match.finished_at.isoformat(), "finished_at": match.finished_at.isoformat(),
"you_won": any(p.user_sub == viewer and p.won for p in participants), "you_won": any(p.user_sub == viewer and p.won for p in participants),
"your_elo_delta": next(
(p.elo_delta for p in participants if p.user_sub == viewer), None
),
"players": [ "players": [
{ {
"user_sub": p.user_sub, "user_sub": p.user_sub,
@@ -66,7 +62,6 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
"seat": p.seat, "seat": p.seat,
"team": p.team, "team": p.team,
"won": p.won, "won": p.won,
"elo_delta": p.elo_delta,
} }
for p in participants for p in participants
], ],
@@ -109,11 +104,8 @@ async def my_matches(ctx: HttpContext) -> None:
@app.GET("/api/leaderboard") @app.GET("/api/leaderboard")
@operation(summary="Global leaderboard", @operation(summary="Global leaderboard",
description="Elo rating, aggregated wins, matches played and team " description="Aggregated wins, matches played and team points for every "
"points for every player with at least one finished " "player with at least one finished match. Sorted by wins.",
"match. Sorted by Elo rating (the rating for the "
"requested game_type, or the default game when the "
"filter is absent).",
tags=["stats"], tags=["stats"],
parameters=[GAME_TYPE_PARAMETER], parameters=[GAME_TYPE_PARAMETER],
responses={ responses={
@@ -129,9 +121,6 @@ async def leaderboard(ctx: HttpContext) -> None:
if game_type is not None: if game_type is not None:
queryset = queryset.filter(match__game_type=game_type) queryset = queryset.filter(match__game_type=game_type)
rows = await queryset.prefetch_related("match") rows = await queryset.prefetch_related("match")
# Ratings are per game type; without a filter show the default game's.
rating_rows = await PlayerRating.filter(game_type=game_type or DEFAULT_GAME_TYPE)
ratings = {row.user_sub: row.rating for row in rating_rows}
aggregate: Dict[str, Dict[str, Any]] = {} aggregate: Dict[str, Dict[str, Any]] = {}
for row in rows: for row in rows:
entry = aggregate.setdefault( entry = aggregate.setdefault(
@@ -142,7 +131,6 @@ async def leaderboard(ctx: HttpContext) -> None:
"matches": 0, "matches": 0,
"wins": 0, "wins": 0,
"points": 0, "points": 0,
"elo": ratings.get(row.user_sub, INITIAL_RATING),
}, },
) )
entry["matches"] += 1 entry["matches"] += 1
@@ -157,33 +145,7 @@ async def leaderboard(ctx: HttpContext) -> None:
ranking: List[Dict[str, Any]] = sorted( ranking: List[Dict[str, Any]] = sorted(
aggregate.values(), aggregate.values(),
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]), key=lambda e: (e["wins"], e["points"], -e["matches"]),
reverse=True, reverse=True,
) )
await send_json(ctx, 200, {"results": ranking}) await send_json(ctx, 200, {"results": ranking})
@app.GET("/api/me/ratings")
@operation(summary="My Elo ratings",
description="The caller's current Elo rating for every game type "
"they have played.",
tags=["stats"],
responses={
200: {"description": "The caller's ratings"},
401: {"description": "Authentication required"},
})
@require_auth
async def my_ratings(ctx: HttpContext) -> None:
user = oidc_mixin.get_user(ctx)
assert user is not None
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
await send_json(ctx, 200, {
"results": [
{
"game_type": row.game_type,
"rating": row.rating,
"matches_played": row.matches_played,
}
for row in rows
]
})
+3 -51
View File
@@ -2,19 +2,17 @@
Called once when a game reaches the finished phase (guarded by the Called once when a game reaches the finished phase (guarded by the
``stats_saved`` flag on the state). The write is transactional so a match ``stats_saved`` flag on the state). The write is transactional so a match
never appears with only some of its players. The same transaction also never appears with only some of its players.
updates the participants' Elo ratings (see :mod:`tavolo.elo`).
""" """
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from logging import getLogger from logging import getLogger
from typing import Dict, List, Optional from typing import Optional
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
from .elo import match_delta
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
log = getLogger(__name__) log = getLogger(__name__)
@@ -29,46 +27,6 @@ def _parse_timestamp(value: Optional[str]) -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
async def apply_elo(
game_type: str, winner_team: str, team_members: Dict[str, List[str]]
) -> Dict[str, int]:
"""Update the Elo ratings of ``team_members`` for ``game_type``.
``team_members`` maps a team name ("A"/"B") to its players' subs.
Ratings are read from (and written back to) the ``player_rating``
table; unrated players start at the initial rating. Returns the
per-player delta. Must be called inside a transaction.
"""
from .models import PlayerRating
ratings: Dict[str, PlayerRating] = {}
for subs in team_members.values():
for sub in subs:
rating = await PlayerRating.get_or_none(
user_sub=sub, game_type=game_type
)
if rating is None:
rating = await PlayerRating.create(
id=uuid.uuid4(), user_sub=sub, game_type=game_type
)
ratings[sub] = rating
delta_a = match_delta(
[ratings[sub].rating for sub in team_members["A"]],
[ratings[sub].rating for sub in team_members["B"]],
0 if winner_team == "A" else 1,
)
deltas: Dict[str, int] = {
**{sub: delta_a for sub in team_members["A"]},
**{sub: -delta_a for sub in team_members["B"]},
}
for sub, delta in deltas.items():
rating = ratings[sub]
rating.rating += delta
rating.matches_played += 1
await rating.save()
return deltas
async def save_match_result(state: GameState) -> None: async def save_match_result(state: GameState) -> None:
"""Persist ``state`` to Postgres if it is finished and not yet saved.""" """Persist ``state`` to Postgres if it is finished and not yet saved."""
if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None: if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None:
@@ -78,23 +36,18 @@ async def save_match_result(state: GameState) -> None:
started_at = _parse_timestamp(state.created_at) started_at = _parse_timestamp(state.created_at)
finished_at = _parse_timestamp(state.finished_at) finished_at = _parse_timestamp(state.finished_at)
winner_team = TEAM_NAMES[state.winner]
team_members: Dict[str, List[str]] = {"A": [], "B": []}
for player in state.players:
team_members[TEAM_NAMES[player.team]].append(player.sub)
async with in_transaction(): async with in_transaction():
match = await Match.create( match = await Match.create(
id=uuid.uuid4(), id=uuid.uuid4(),
game_type=state.game_type, game_type=state.game_type,
team_a_score=state.scores[0], team_a_score=state.scores[0],
team_b_score=state.scores[1], team_b_score=state.scores[1],
winner_team=winner_team, winner_team=TEAM_NAMES[state.winner],
target_score=state.target_score, target_score=state.target_score,
hands_played=state.hand_number, hands_played=state.hand_number,
started_at=started_at, started_at=started_at,
finished_at=finished_at, finished_at=finished_at,
) )
deltas = await apply_elo(state.game_type, winner_team, team_members)
for player in state.players: for player in state.players:
await MatchPlayer.create( await MatchPlayer.create(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -104,7 +57,6 @@ async def save_match_result(state: GameState) -> None:
seat=player.seat, seat=player.seat,
team=TEAM_NAMES[player.team], team=TEAM_NAMES[player.team],
won=player.team == state.winner, won=player.team == state.winner,
elo_delta=deltas[player.sub],
) )
state.stats_saved = True state.stats_saved = True
log.info( log.info(
-84
View File
@@ -81,89 +81,5 @@ class DatabaseUrlTests(unittest.TestCase):
) )
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)
class OTelSettingsTests(unittest.TestCase):
def test_otel_disabled_by_default(self):
settings = _settings({})
self.assertFalse(settings.otel_enabled)
self.assertEqual("tavolo", settings.otel_service_name)
self.assertIsNone(settings.otel_exporter_endpoint)
self.assertIsNone(settings.otel_exporter_headers)
def test_otel_enabled_parses_boolean(self):
for value in ("1", "true", "TRUE", "yes", "on"):
self.assertTrue(_settings({"OTEL_ENABLED": value}).otel_enabled)
for value in ("0", "false", "no", "off", "anything-else"):
self.assertFalse(_settings({"OTEL_ENABLED": value}).otel_enabled)
def test_otel_settings_are_passed_through(self):
settings = _settings({
"OTEL_SERVICE_NAME": "cards",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
})
self.assertEqual("cards", settings.otel_service_name)
self.assertEqual("http://collector:4318", settings.otel_exporter_endpoint)
self.assertEqual(
("Authorization=Bearer t", "X-Tenant=one"),
settings.otel_exporter_headers,
)
def test_otel_excluded_paths_defaults_to_health_endpoint(self):
self.assertEqual(("/api/health",), _settings({}).otel_excluded_paths)
def test_otel_excluded_paths_parses_comma_separated_list(self):
settings = _settings({"OTEL_EXCLUDED_PATHS": "/api/health, /metrics"})
self.assertEqual(("/api/health", "/metrics"), settings.otel_excluded_paths)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
-129
View File
@@ -1,129 +0,0 @@
"""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()
-73
View File
@@ -1,73 +0,0 @@
"""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()
-88
View File
@@ -1,88 +0,0 @@
"""Unit tests for the OpenTelemetry wiring in :mod:`tavolo.app`.
The mixin under test is kaya-otel's :class:`~kaya.otel.OTelMixin`, an
optional dependency (the ``otel`` extra); these tests only verify that
:func:`tavolo.app.otel_mixin_from_settings` maps the ``OTEL_*`` settings
onto mixin construction. The ``kaya.otel`` module is stubbed in
``sys.modules`` so the suite does not need the extra installed.
"""
from __future__ import annotations
import os
import sys
import types
import unittest
from unittest.mock import patch
from tavolo.app import otel_mixin_from_settings
from tavolo.config import Settings
def _settings(env: dict) -> Settings:
with patch.dict(os.environ, env, clear=True):
return Settings.from_env()
class _StubOTelMixin:
def __init__(self, **kwargs):
self.kwargs = kwargs
def _stub_kaya_otel():
"""Install a fake ``kaya.otel`` module and return it."""
module = types.ModuleType("kaya.otel")
module.OTelMixin = _StubOTelMixin # type: ignore[attr-defined]
return patch.dict(sys.modules, {"kaya.otel": module})
class OTelMixinFromSettingsTests(unittest.TestCase):
def test_disabled_by_default(self):
self.assertIsNone(otel_mixin_from_settings(_settings({})))
def test_enabled_by_otel_enabled(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
self.assertIsNotNone(mixin)
def test_settings_are_passed_through(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({
"OTEL_ENABLED": "true",
"OTEL_SERVICE_NAME": "cards",
"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer t, X-Tenant=one",
}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual({
"service_name": "cards",
"endpoint": "http://collector:4318",
"headers": {"Authorization": "Bearer t", "X-Tenant": "one"},
"excluded_paths": ("/api/health",),
}, mixin.kwargs)
def test_defaults_when_only_enabled(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({"OTEL_ENABLED": "on"}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual("tavolo", mixin.kwargs["service_name"])
self.assertIsNone(mixin.kwargs["endpoint"])
self.assertIsNone(mixin.kwargs["headers"])
self.assertEqual(("/api/health",), mixin.kwargs["excluded_paths"])
def test_excluded_paths_are_passed_through(self):
with _stub_kaya_otel():
mixin = otel_mixin_from_settings(_settings({
"OTEL_ENABLED": "1",
"OTEL_EXCLUDED_PATHS": "/api/health,/metrics",
}))
assert isinstance(mixin, _StubOTelMixin)
self.assertEqual(("/api/health", "/metrics"), mixin.kwargs["excluded_paths"])
def test_missing_extra_raises_runtime_error(self):
with patch.dict(sys.modules, {"kaya.otel": None}):
with self.assertRaises(RuntimeError):
otel_mixin_from_settings(_settings({"OTEL_ENABLED": "1"}))
if __name__ == "__main__":
unittest.main()
+1 -186
View File
@@ -9,10 +9,9 @@ from httpx import ASGITransport, AsyncClient
from pwo import async_test from pwo import async_test
from tavolo.app import app, tortoise_mixin from tavolo.app import app, tortoise_mixin
from tavolo.elo import INITIAL_RATING
from tavolo.game import engine from tavolo.game import engine
from tavolo.game.state import GameState from tavolo.game.state import GameState
from tavolo.models import Match, MatchPlayer, PlayerRating from tavolo.models import Match, MatchPlayer
from tavolo.stats import save_match_result from tavolo.stats import save_match_result
from tests.helpers import oidc_user from tests.helpers import oidc_user
@@ -49,28 +48,6 @@ def _finished_state() -> GameState:
return state return state
def _finished_state_reversed() -> GameState:
"""Same one-capture ending as ``_finished_state``, but team B scores it."""
state = GameState(
id="stats-game-2",
join_code="STATS2",
creator_sub="alice",
target_score=2,
phase=engine.PHASE_PLAYING,
turn=1,
table=[engine.parse_card("02C")],
)
from tavolo.game.state import PlayerState, Card
state.players = [
PlayerState(sub="alice", name="alice", seat=0),
PlayerState(sub="bob", name="bob", seat=1, hand=[Card.parse("02D")]),
PlayerState(sub="carol", name="carol", seat=2),
PlayerState(sub="dave", name="dave", seat=3),
]
return state
class SaveMatchResultTest(unittest.TestCase): class SaveMatchResultTest(unittest.TestCase):
@async_test @async_test
async def test_finished_match_is_persisted_once(self) -> None: async def test_finished_match_is_persisted_once(self) -> None:
@@ -94,58 +71,6 @@ class SaveMatchResultTest(unittest.TestCase):
winners = await MatchPlayer.filter(won=True) winners = await MatchPlayer.filter(won=True)
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners}) self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners})
@async_test
async def test_finished_match_updates_elo_ratings(self) -> None:
ctx = await _use_app_db()
state = _finished_state()
engine.play(state, "alice", "02D", ["02C"])
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: async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None:
ctx = await _use_app_db() ctx = await _use_app_db()
@@ -242,116 +167,6 @@ class StatsRouteTest(unittest.TestCase):
# Alice leads on points after tying Bob on wins. # Alice leads on points after tying Bob on wins.
self.assertEqual("alice", response.json()["results"][0]["user_sub"]) self.assertEqual("alice", response.json()["results"][0]["user_sub"])
@async_test
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
await _seed_two_matches()
ctx = await _use_app_db()
with ctx:
# Bob outranks everyone despite Alice leading on points.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="scopone_scientifico",
rating=1600,
matches_played=2,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["bob"]["elo"])
# Players without a rating row report the initial rating.
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
# Elo outranks wins/points.
self.assertEqual("bob", results[0]["user_sub"])
@async_test
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
await _seed_two_matches()
ctx = await _use_app_db()
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="scopone_scientifico",
rating=1516,
matches_played=1,
)
# Alice's rating in another game must not leak into the
# scopone leaderboard.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="other_game",
rating=1800,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard?game_type=scopone_scientifico")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1516, by_sub["alice"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
@async_test
async def test_my_matches_include_elo_delta(self) -> None:
ctx = await _use_app_db()
state = _finished_state()
engine.play(state, "alice", "02D", ["02C"])
with ctx:
await save_match_result(state)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
players = {
p["user_sub"]: p
for p in response.json()["results"][0]["players"]
}
self.assertEqual(16, players["alice"]["elo_delta"])
self.assertEqual(-16, players["bob"]["elo_delta"])
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
@async_test
async def test_my_ratings_requires_auth(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/ratings")
self.assertEqual(401, response.status_code)
@async_test
async def test_my_ratings_returns_only_own_rows(self) -> None:
ctx = await _use_app_db()
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="scopone_scientifico",
rating=1516,
matches_played=1,
)
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="scopone_scientifico",
rating=1484,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
response = await client.get("/api/me/ratings")
self.assertEqual(200, response.status_code)
self.assertEqual(
[{"game_type": "scopone_scientifico", "rating": 1516, "matches_played": 1}],
response.json()["results"],
)
class GameTypeFilterTest(unittest.TestCase): class GameTypeFilterTest(unittest.TestCase):
"""Stats endpoints scope results by the match's game type.""" """Stats endpoints scope results by the match's game type."""
-12
View File
@@ -91,18 +91,6 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
resp.json().await.map_err(|e| e.to_string()) resp.json().await.map_err(|e| e.to_string())
} }
/// Fetch the caller's Elo ratings (one row per game type played).
pub async fn my_ratings() -> Result<RatingsPage, String> {
let resp = Request::get("/api/me/ratings")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn leaderboard() -> Result<LeaderboardPage, String> { pub async fn leaderboard() -> Result<LeaderboardPage, String> {
let resp = Request::get("/api/leaderboard") let resp = Request::get("/api/leaderboard")
.send() .send()
-29
View File
@@ -184,10 +184,6 @@ pub struct MatchPlayer {
pub seat: usize, pub seat: usize,
pub team: String, pub team: String,
pub won: bool, pub won: bool,
/// Elo change this match produced for the player; absent for matches
/// recorded before ratings existed.
#[serde(default)]
pub elo_delta: Option<i32>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -205,9 +201,6 @@ pub struct MatchSummary {
pub finished_at: String, pub finished_at: String,
#[serde(default)] #[serde(default)]
pub you_won: bool, pub you_won: bool,
/// The viewer's Elo change in this match; absent when unrated.
#[serde(default)]
pub your_elo_delta: Option<i32>,
#[serde(default)] #[serde(default)]
pub players: Vec<MatchPlayer>, pub players: Vec<MatchPlayer>,
} }
@@ -220,38 +213,16 @@ pub struct MatchesPage {
pub next_cursor: Option<String>, pub next_cursor: Option<String>,
} }
fn default_elo() -> i32 {
1500
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct LeaderboardEntry { pub struct LeaderboardEntry {
pub user_sub: String, pub user_sub: String,
pub display_name: String, pub display_name: String,
/// Chess-style Elo rating for the requested game type.
#[serde(default = "default_elo")]
pub elo: i32,
pub matches: i32, pub matches: i32,
pub wins: i32, pub wins: i32,
pub points: i32, pub points: i32,
} }
/// The caller's Elo rating for one game type (GET /api/me/ratings).
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct PlayerRating {
pub game_type: String,
pub rating: i32,
pub matches_played: i32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RatingsPage {
#[serde(default)]
pub results: Vec<PlayerRating>,
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct LeaderboardPage { pub struct LeaderboardPage {
#[serde(default)] #[serde(default)]
+26 -215
View File
@@ -1,7 +1,4 @@
//! Live game page: table view over the websocket. //! Live game page: table view over the websocket.
use std::cell::Cell;
use std::rc::Rc;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::components::card::{card_back, card_img}; use crate::components::card::{card_back, card_img};
@@ -73,143 +70,6 @@ fn move_banner(mv: MoveView) -> View {
} }
} }
/// Signals shared by the websocket connection and its reconnect attempts.
#[derive(Clone, Copy)]
struct ConnCtx {
socket: Signal<Option<GameSocket>>,
game: Signal<Option<GameView>>,
over: Signal<Option<(Scores, Option<String>)>>,
error: Signal<Option<String>>,
closed: Signal<bool>,
/// Reconnect attempts exhausted; only a manual retry resumes.
gave_up: Signal<bool>,
/// The server closed the connection deliberately (auth or game gone);
/// retrying is pointless.
fatal: Signal<bool>,
attempts: Signal<u32>,
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
selected: Signal<Option<String>>,
}
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
const MAX_RECONNECT_ATTEMPTS: u32 = 10;
fn backoff_ms(attempt: u32) -> u32 {
(1000u32 << attempt.min(5)).min(30_000)
}
/// Connect the game websocket, wiring state updates and reconnects.
///
/// The server pushes a full state snapshot on connect, so a reconnect is
/// also a resync: no client-side state merging is needed.
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let on_message = {
let alive = alive.clone();
move |msg: ServerMessage| {
if !alive.get() {
// The page is unmounted; its signals are disposed.
return;
}
match msg {
ServerMessage::State { game: g } => {
ctx.capture_choice.set(None);
ctx.selected.set(None);
// A received state proves the (re)connection works.
ctx.attempts.set(0);
ctx.gave_up.set(false);
ctx.closed.set(false);
ctx.game.set(Some(g));
}
ServerMessage::GameOver { scores, winner } => {
ctx.over.set(Some((scores, winner)))
}
ServerMessage::Error { message, .. } => ctx.error.set(Some(message)),
}
}
};
let on_close = {
let id = id.clone();
let alive = alive.clone();
move |code: Option<u16>| {
if !alive.get() {
return;
}
ctx.closed.set(true);
match code {
Some(4401) => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
Some(4403) | Some(4404) => {
ctx.fatal.set(true);
ctx.error
.set(Some("This game is no longer available.".to_string()));
}
_ => schedule_retry(id.clone(), ctx, alive.clone()),
}
}
};
match ws::connect(&id, on_message, on_close) {
Some(s) => ctx.socket.set(Some(s)),
// WebSocket::open failed synchronously: treat as a transient loss.
None if alive.get() => {
ctx.closed.set(true);
schedule_retry(id, ctx, alive);
}
None => {}
}
}
/// Retry `start_connect` with exponential backoff, unless we gave up.
fn schedule_retry(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
let attempt = ctx.attempts.get();
if attempt >= MAX_RECONNECT_ATTEMPTS {
ctx.gave_up.set(true);
return;
}
ctx.attempts.set(attempt + 1);
gloo_timers::callback::Timeout::new(backoff_ms(attempt), move || {
if alive.get() {
start_connect(id, ctx, alive);
}
})
.forget();
}
/// Slim banner shown over the table while the socket is down.
fn conn_banner(
closed: bool,
gave_up: bool,
fatal: bool,
has_game: bool,
reconnect: Rc<dyn Fn()>,
) -> View {
if !closed || !has_game {
return view! {};
}
if fatal {
view! {
div(class="conn-banner") {
"Connection closed by the server. "
a(href="/") { "Back to lobby" }
}
}
} else if gave_up {
view! {
div(class="conn-banner") {
"Connection lost."
button(class="button", on:click=move |_| reconnect()) { "Retry now" }
a(href="/") { "Back to lobby" }
}
}
} else {
view! {
div(class="conn-banner") { "Connection lost — reconnecting…" }
}
}
}
#[component(inline_props)] #[component(inline_props)]
pub fn GamePage(id: String) -> View { pub fn GamePage(id: String) -> View {
let game = create_signal(Option::<GameView>::None); let game = create_signal(Option::<GameView>::None);
@@ -218,51 +78,32 @@ pub fn GamePage(id: String) -> View {
let selected = create_signal(Option::<String>::None); let selected = create_signal(Option::<String>::None);
let over = create_signal(Option::<(Scores, Option<String>)>::None); let over = create_signal(Option::<(Scores, Option<String>)>::None);
let closed = create_signal(false); let closed = create_signal(false);
let gave_up = create_signal(false);
let fatal = create_signal(false);
let attempts = create_signal(0u32);
let socket = create_signal(Option::<GameSocket>::None); let socket = create_signal(Option::<GameSocket>::None);
// Ticking clock driving the hand-end countdown display. // Ticking clock driving the hand-end countdown display.
let now = create_signal(js_sys::Date::now()); let now = create_signal(js_sys::Date::now());
let ticker = gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())); gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
// Stops the ticker and any pending reconnect once the page unmounts. {
let alive = Rc::new(Cell::new(true)); let on_message = move |msg: ServerMessage| match msg {
on_cleanup({ ServerMessage::State { game: g } => {
let alive = alive.clone(); capture_choice.set(None);
move || { selected.set(None);
alive.set(false); game.set(Some(g));
drop(ticker); }
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())),
} }
}); }
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. // Clicking a card in the player's own hand.
let on_hand_card = move |code: String| { let on_hand_card = move |code: String| {
if closed.get() {
// A dead socket would swallow the play silently.
return;
}
let Some(g) = game.get_clone() else { return }; let Some(g) = game.get_clone() else { return };
if g.your_turn != Some(true) { if g.your_turn != Some(true) {
return; return;
@@ -281,50 +122,20 @@ pub fn GamePage(id: String) -> View {
} }
}; };
let reconnect_banner = reconnect.clone();
view! { view! {
div(class="game-page") { div(class="game-page") {
(toast(error)) (toast(error))
(move || conn_banner(
closed.get(),
gave_up.get(),
fatal.get(),
game.get_clone().is_some(),
reconnect_banner.clone(),
))
(move || match game.get_clone() { (move || match game.get_clone() {
None => { None => {
if fatal.get() { let status = if closed.get() {
view! { "Connection closed."
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 { } else {
let status = if closed.get() { "Connecting to the game…"
"Connection lost — reconnecting…" };
} else { view! {
"Connecting to the game…" div(class="panel status-panel") {
}; p { (status) }
view! { p { a(href="/") { "Back to lobby" } }
div(class="panel status-panel") {
p { (status) }
p { a(href="/") { "Back to lobby" } }
}
} }
} }
} }
-8
View File
@@ -72,10 +72,6 @@ pub fn HistoryPage() -> View {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" & "); .join(" & ");
let outcome = if m.you_won { "Won" } else { "Lost" }; let outcome = if m.you_won { "Won" } else { "Lost" };
let elo_delta = m
.your_elo_delta
.map(|d| if d >= 0 { format!("+{d}") } else { d.to_string() })
.unwrap_or_else(|| "".to_string());
view! { view! {
tr { tr {
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) } td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
@@ -84,9 +80,6 @@ pub fn HistoryPage() -> View {
td { (m.team_a_score) " " (m.team_b_score) } td { (m.team_a_score) " " (m.team_b_score) }
td { "Team " (m.winner_team) } td { "Team " (m.winner_team) }
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) } td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
td(class=if m.you_won { "won" } else { "lost" }) {
(elo_delta)
}
} }
} }
}) })
@@ -101,7 +94,6 @@ pub fn HistoryPage() -> View {
th { "Score" } th { "Score" }
th { "Winner" } th { "Winner" }
th { "You" } th { "You" }
th { "Elo" }
} }
} }
tbody { (table_rows) } tbody { (table_rows) }
-2
View File
@@ -39,7 +39,6 @@ pub fn LeaderboardPage() -> View {
tr { tr {
td { (i + 1) } td { (i + 1) }
td { (e.display_name.clone()) } td { (e.display_name.clone()) }
td { (e.elo) }
td { (e.wins) } td { (e.wins) }
td { (e.matches) } td { (e.matches) }
td { (e.points) } td { (e.points) }
@@ -53,7 +52,6 @@ pub fn LeaderboardPage() -> View {
tr { tr {
th { "#" } th { "#" }
th { "Player" } th { "Player" }
th { "Elo" }
th { "Wins" } th { "Wins" }
th { "Matches" } th { "Matches" }
th { "Points" } th { "Points" }
+2 -19
View File
@@ -20,8 +20,6 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
pub fn LobbyPage() -> View { pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out. // Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None); let user = create_signal(Option::<Option<User>>::None);
// The player's Elo rating for the first rated game; None while loading.
let rating = create_signal(Option::<i32>::None);
let error = create_signal(Option::<String>::None); let error = create_signal(Option::<String>::None);
let code = create_signal(String::new()); let code = create_signal(String::new());
let game_types = create_signal(fallback_game_types()); let game_types = create_signal(fallback_game_types());
@@ -30,17 +28,7 @@ pub fn LobbyPage() -> View {
spawn_local(async move { spawn_local(async move {
match api::me().await { match api::me().await {
Ok(me) => { Ok(me) => user.set(Some(me)),
if me.is_some() {
spawn_local(async move {
if let Ok(p) = api::my_ratings().await {
// Unrated players sit at the initial 1500.
rating.set(Some(p.results.first().map(|r| r.rating).unwrap_or(1500)));
}
});
}
user.set(Some(me));
}
Err(e) => { Err(e) => {
error.set(Some(e)); error.set(Some(e));
user.set(Some(None)); user.set(Some(None));
@@ -100,12 +88,7 @@ pub fn LobbyPage() -> View {
Some(Some(me)) => view! { Some(Some(me)) => view! {
div(class="lobby-grid") { div(class="lobby-grid") {
nav(class="top-nav") { nav(class="top-nav") {
span(class="whoami") { span(class="whoami") { "Signed in as " strong { (me.name.clone()) } }
"Signed in as " strong { (me.name.clone()) }
(rating.get_clone().map(|r| view! {
span(class="rating") { " · Elo " (r) }
}))
}
a(href="/history") { "My matches" } a(href="/history") { "My matches" }
a(href="/leaderboard") { "Leaderboard" } a(href="/leaderboard") { "Leaderboard" }
a(href="/auth/logout", rel="external") { "Log out" } a(href="/auth/logout", rel="external") { "Log out" }
+3 -12
View File
@@ -4,7 +4,7 @@ use std::rc::Rc;
use futures::channel::mpsc; use futures::channel::mpsc;
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use gloo_net::websocket::{futures::WebSocket, Message, WebSocketError}; use gloo_net::websocket::{futures::WebSocket, Message};
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::model::ServerMessage; use crate::model::ServerMessage;
@@ -53,14 +53,10 @@ impl GameSocket {
/// Open the websocket for `game_id` and forward parsed server messages to /// Open the websocket for `game_id` and forward parsed server messages to
/// `on_message`. Returns the socket handle, or `None` if the connection /// `on_message`. Returns the socket handle, or `None` if the connection
/// could not be created. /// could not be created.
///
/// `on_close` fires exactly once when the connection ends; it receives the
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
pub fn connect( pub fn connect(
game_id: &str, game_id: &str,
on_message: impl Fn(ServerMessage) + 'static, on_message: impl Fn(ServerMessage) + 'static,
on_close: impl Fn(Option<u16>) + 'static, on_close: impl Fn() + 'static,
) -> Option<GameSocket> { ) -> Option<GameSocket> {
let ws = WebSocket::open(&ws_url(game_id)).ok()?; let ws = WebSocket::open(&ws_url(game_id)).ok()?;
let (mut write, mut read) = ws.split(); let (mut write, mut read) = ws.split();
@@ -76,7 +72,6 @@ pub fn connect(
}); });
spawn_local(async move { spawn_local(async move {
let mut close_code = None;
while let Some(msg) = read.next().await { while let Some(msg) = read.next().await {
match msg { match msg {
Ok(Message::Text(text)) => { Ok(Message::Text(text)) => {
@@ -85,14 +80,10 @@ pub fn connect(
} }
} }
Ok(Message::Bytes(_)) => {} Ok(Message::Bytes(_)) => {}
Err(WebSocketError::ConnectionClose(e)) => {
close_code = Some(e.code);
break;
}
Err(_) => break, Err(_) => break,
} }
} }
on_close(close_code); on_close();
}); });
Some(GameSocket { Some(GameSocket {
-25
View File
@@ -49,10 +49,6 @@ body {
margin-right: auto; margin-right: auto;
} }
.whoami .rating {
color: var(--muted, #888);
}
.panel { .panel {
background: var(--panel); background: var(--panel);
border-radius: 12px; border-radius: 12px;
@@ -422,27 +418,6 @@ table.matches td.lost {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
/* ---------- connection banner ---------- */
.conn-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
background: rgba(232, 197, 71, 0.15);
border: 1px solid var(--accent);
border-radius: 8px;
color: var(--accent);
padding: 0.4rem 1rem;
margin: 0.5rem auto 0;
width: fit-content;
}
.conn-banner a {
color: var(--accent);
text-decoration: underline;
}
/* ---------- overlays ---------- */ /* ---------- overlays ---------- */
.overlay { .overlay {