Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a606f14550 | ||
|
|
3da0c463de | ||
|
|
26464bce43 | ||
|
|
8a2941eccc
|
||
|
|
bbd12ce674 | ||
|
|
c40ebd84d8 | ||
|
|
5b9e998739 | ||
|
|
5dc3f4cb17 | ||
|
|
22ef689092 | ||
|
|
aa1fecae43 |
@@ -32,6 +32,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
builder: multiplatform-builder
|
||||||
file: server/Dockerfile
|
file: server/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ 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"
|
||||||
# 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: /
|
||||||
|
|||||||
@@ -113,6 +113,15 @@ services:
|
|||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||||
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
||||||
|
# CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX
|
||||||
|
# is set (see server/.env.example for the full list of options).
|
||||||
|
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-}
|
||||||
|
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
|
||||||
|
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-}
|
||||||
|
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-}
|
||||||
|
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
|
||||||
|
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
||||||
|
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,28 @@ 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
|
||||||
|
|||||||
+35
-6
@@ -62,6 +62,13 @@ 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 |
|
||||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
@@ -126,12 +133,31 @@ 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 and whether they won. Unique per `(match, user_sub)`.
|
seat, team, whether they won and the Elo change the match produced
|
||||||
|
(`elo_delta`). Unique per `(match, user_sub)`.
|
||||||
|
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
|
||||||
|
the number of rated matches played.
|
||||||
|
|
||||||
When a match ends, the result is written transactionally to Postgres
|
When a match ends, the result is written transactionally to Postgres
|
||||||
(once, guarded by a flag on the Redis state); the finished state stays in
|
(once, guarded by a flag on the Redis state); the finished state stays in
|
||||||
Redis until its TTL expires so clients can still fetch the final board.
|
Redis until its TTL expires so clients can still fetch the final board.
|
||||||
|
|
||||||
|
### Elo ratings
|
||||||
|
|
||||||
|
Players carry a chess-style Elo rating per game type (`tavolo.elo`):
|
||||||
|
everyone starts at 1500, a team's rating is the mean of its two members,
|
||||||
|
and the standard formula `E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))`
|
||||||
|
with `K = 32` decides how many points the match result moves — the same
|
||||||
|
delta for both members of a team, zero-sum between teams. Ratings update
|
||||||
|
in the same transaction as the match result. To recompute every rating
|
||||||
|
from the recorded match history (e.g. to backfill matches recorded before
|
||||||
|
ratings existed):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
|
||||||
|
.venv/bin/python -m tavolo.backfill_elo
|
||||||
|
```
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||||
@@ -143,8 +169,9 @@ 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 (`?limit=&cursor=&game_type=`) |
|
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||||
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?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
|
## WebSocket protocol
|
||||||
|
|
||||||
@@ -264,8 +291,10 @@ src/tavolo/
|
|||||||
├── openapi.py # shared OpenAPI parameter fragments
|
├── openapi.py # shared OpenAPI parameter fragments
|
||||||
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
|
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
|
||||||
├── aerich_config.py # aerich CLI configuration
|
├── aerich_config.py # aerich CLI configuration
|
||||||
├── models.py # Match, MatchPlayer (Postgres)
|
├── models.py # Match, MatchPlayer, PlayerRating (Postgres)
|
||||||
├── stats.py # finished match -> Postgres persistence
|
├── elo.py # chess-style Elo math (1500 start, K=32)
|
||||||
|
├── 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
|
||||||
@@ -276,5 +305,5 @@ src/tavolo/
|
|||||||
└── routes/
|
└── routes/
|
||||||
├── health.py # GET /api/health
|
├── health.py # GET /api/health
|
||||||
├── games.py # lobby: create / join / snapshot
|
├── games.py # lobby: create / join / snapshot
|
||||||
└── stats.py # match history + leaderboard
|
└── stats.py # match history + leaderboard + Elo ratings
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,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"
|
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",
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ iso8601==2.1.0
|
|||||||
# via tortoise-orm
|
# via tortoise-orm
|
||||||
kaya-core==0.0.3
|
kaya-core==0.0.3
|
||||||
# via
|
# via
|
||||||
|
# kaya-cors
|
||||||
# kaya-oidc
|
# kaya-oidc
|
||||||
# kaya-openapi
|
# kaya-openapi
|
||||||
# kaya-rsgi
|
# kaya-rsgi
|
||||||
# kaya-session
|
# kaya-session
|
||||||
# tavolo (pyproject.toml)
|
# tavolo (pyproject.toml)
|
||||||
|
kaya-cors==0.0.3
|
||||||
|
# via tavolo (pyproject.toml)
|
||||||
kaya-oidc==0.0.3
|
kaya-oidc==0.0.3
|
||||||
# via tavolo (pyproject.toml)
|
# via tavolo (pyproject.toml)
|
||||||
kaya-openapi==0.0.3
|
kaya-openapi==0.0.3
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
|
|||||||
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
||||||
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
||||||
|
|
||||||
|
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
|
||||||
|
``CORS_*`` environment variables (see :mod:`tavolo.config`).
|
||||||
|
|
||||||
Live games are kept in :data:`game_store` (Redis when configured, in-memory
|
Live games are kept in :data:`game_store` (Redis when configured, in-memory
|
||||||
otherwise). Routes and the websocket handlers are registered by importing
|
otherwise). Routes and the websocket handlers are registered by importing
|
||||||
their modules at the bottom; imports must happen after ``app`` is built.
|
their modules at the bottom; imports must happen after ``app`` is built.
|
||||||
@@ -19,15 +22,17 @@ 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
|
from kaya.core import KayaApp, KayaMixin
|
||||||
|
from kaya.cors import CorsMixin
|
||||||
from kaya.oidc import OIDCConfig, OIDCMixin
|
from kaya.oidc import OIDCConfig, OIDCMixin
|
||||||
from kaya.openapi import OpenAPIMixin
|
from kaya.openapi import OpenAPIMixin
|
||||||
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
||||||
from kaya.session.redis import RedisSessionStore
|
from kaya.session.redis import RedisSessionStore
|
||||||
from redis.asyncio import Redis
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
from .config import settings
|
from .config import Settings, settings
|
||||||
from .deadlines import DeadlineSchedulerMixin
|
from .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
|
||||||
@@ -36,6 +41,27 @@ 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -77,8 +103,21 @@ tortoise_mixin = TortoiseMixin(
|
|||||||
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
||||||
DeadlineSchedulerMixin(game_store)])
|
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(
|
log.debug(
|
||||||
"timeouts: hand_ack=%ds turn=%ds",
|
"timeouts: hand_ack=%ds turn=%ds",
|
||||||
settings.hand_ack_timeout_seconds,
|
settings.hand_ack_timeout_seconds,
|
||||||
|
|||||||
@@ -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,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
|
||||||
@@ -20,6 +20,25 @@ 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],
|
||||||
@@ -84,6 +103,16 @@ 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
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "Settings":
|
def from_env() -> "Settings":
|
||||||
@@ -120,6 +149,16 @@ 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")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -7,12 +7,17 @@ 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."""
|
||||||
@@ -50,7 +55,28 @@ class MatchPlayer(Model):
|
|||||||
seat = fields.SmallIntField()
|
seat = fields.SmallIntField()
|
||||||
team = fields.CharField(max_length=1)
|
team = fields.CharField(max_length=1)
|
||||||
won = fields.BooleanField()
|
won = fields.BooleanField()
|
||||||
|
# Elo change this match produced for the player (see tavolo.elo);
|
||||||
|
# null for matches recorded before ratings existed.
|
||||||
|
elo_delta = fields.SmallIntField(null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "match_player"
|
table = "match_player"
|
||||||
unique_together = (("match", "user_sub"),)
|
unique_together = (("match", "user_sub"),)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerRating(Model):
|
||||||
|
"""Current Elo rating of one player for one game type."""
|
||||||
|
|
||||||
|
id = fields.UUIDField(pk=True)
|
||||||
|
# OIDC subject of the player; no local users table.
|
||||||
|
user_sub = fields.CharField(max_length=255)
|
||||||
|
# Which card game the rating applies to (tavolo.games.GAME_TYPES).
|
||||||
|
game_type = fields.CharField(max_length=32)
|
||||||
|
rating = fields.IntField(default=INITIAL_RATING)
|
||||||
|
matches_played = fields.IntField(default=0)
|
||||||
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "player_rating"
|
||||||
|
unique_together = (("user_sub", "game_type"),)
|
||||||
|
indexes = (("game_type", "rating"),)
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ 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 ..games import get_game_type
|
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 ..http import extract_query_params, send_error, send_json
|
||||||
from ..models import Match, MatchPlayer
|
from ..models import Match, MatchPlayer, PlayerRating
|
||||||
from ..openapi import PAGINATION_PARAMETERS
|
from ..openapi import PAGINATION_PARAMETERS
|
||||||
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
||||||
|
|
||||||
@@ -55,6 +56,9 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
|||||||
"started_at": match.started_at.isoformat(),
|
"started_at": match.started_at.isoformat(),
|
||||||
"finished_at": match.finished_at.isoformat(),
|
"finished_at": match.finished_at.isoformat(),
|
||||||
"you_won": any(p.user_sub == viewer and p.won for p in participants),
|
"you_won": any(p.user_sub == viewer and p.won for p in participants),
|
||||||
|
"your_elo_delta": next(
|
||||||
|
(p.elo_delta for p in participants if p.user_sub == viewer), None
|
||||||
|
),
|
||||||
"players": [
|
"players": [
|
||||||
{
|
{
|
||||||
"user_sub": p.user_sub,
|
"user_sub": p.user_sub,
|
||||||
@@ -62,6 +66,7 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
|
|||||||
"seat": p.seat,
|
"seat": p.seat,
|
||||||
"team": p.team,
|
"team": p.team,
|
||||||
"won": p.won,
|
"won": p.won,
|
||||||
|
"elo_delta": p.elo_delta,
|
||||||
}
|
}
|
||||||
for p in participants
|
for p in participants
|
||||||
],
|
],
|
||||||
@@ -104,8 +109,11 @@ async def my_matches(ctx: HttpContext) -> None:
|
|||||||
|
|
||||||
@app.GET("/api/leaderboard")
|
@app.GET("/api/leaderboard")
|
||||||
@operation(summary="Global leaderboard",
|
@operation(summary="Global leaderboard",
|
||||||
description="Aggregated wins, matches played and team points for every "
|
description="Elo rating, aggregated wins, matches played and team "
|
||||||
"player with at least one finished match. Sorted by wins.",
|
"points for every player with at least one finished "
|
||||||
|
"match. Sorted by Elo rating (the rating for the "
|
||||||
|
"requested game_type, or the default game when the "
|
||||||
|
"filter is absent).",
|
||||||
tags=["stats"],
|
tags=["stats"],
|
||||||
parameters=[GAME_TYPE_PARAMETER],
|
parameters=[GAME_TYPE_PARAMETER],
|
||||||
responses={
|
responses={
|
||||||
@@ -121,6 +129,9 @@ 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(
|
||||||
@@ -131,6 +142,7 @@ async def leaderboard(ctx: HttpContext) -> None:
|
|||||||
"matches": 0,
|
"matches": 0,
|
||||||
"wins": 0,
|
"wins": 0,
|
||||||
"points": 0,
|
"points": 0,
|
||||||
|
"elo": ratings.get(row.user_sub, INITIAL_RATING),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
entry["matches"] += 1
|
entry["matches"] += 1
|
||||||
@@ -145,7 +157,33 @@ async def leaderboard(ctx: HttpContext) -> None:
|
|||||||
|
|
||||||
ranking: List[Dict[str, Any]] = sorted(
|
ranking: List[Dict[str, Any]] = sorted(
|
||||||
aggregate.values(),
|
aggregate.values(),
|
||||||
key=lambda e: (e["wins"], e["points"], -e["matches"]),
|
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
await send_json(ctx, 200, {"results": ranking})
|
await send_json(ctx, 200, {"results": ranking})
|
||||||
|
|
||||||
|
|
||||||
|
@app.GET("/api/me/ratings")
|
||||||
|
@operation(summary="My Elo ratings",
|
||||||
|
description="The caller's current Elo rating for every game type "
|
||||||
|
"they have played.",
|
||||||
|
tags=["stats"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The caller's ratings"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
})
|
||||||
|
@require_auth
|
||||||
|
async def my_ratings(ctx: HttpContext) -> None:
|
||||||
|
user = oidc_mixin.get_user(ctx)
|
||||||
|
assert user is not None
|
||||||
|
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
|
||||||
|
await send_json(ctx, 200, {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"game_type": row.game_type,
|
||||||
|
"rating": row.rating,
|
||||||
|
"matches_played": row.matches_played,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,17 +2,19 @@
|
|||||||
|
|
||||||
Called once when a game reaches the finished phase (guarded by the
|
Called once when a game reaches the finished phase (guarded by the
|
||||||
``stats_saved`` flag on the state). The write is transactional so a match
|
``stats_saved`` flag on the state). The write is transactional so a match
|
||||||
never appears with only some of its players.
|
never appears with only some of its players. The same transaction also
|
||||||
|
updates the participants' Elo ratings (see :mod:`tavolo.elo`).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from tortoise.transactions import in_transaction
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
|
from .elo import match_delta
|
||||||
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
|
from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState
|
||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
@@ -27,6 +29,46 @@ def _parse_timestamp(value: Optional[str]) -> datetime:
|
|||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_elo(
|
||||||
|
game_type: str, winner_team: str, team_members: Dict[str, List[str]]
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""Update the Elo ratings of ``team_members`` for ``game_type``.
|
||||||
|
|
||||||
|
``team_members`` maps a team name ("A"/"B") to its players' subs.
|
||||||
|
Ratings are read from (and written back to) the ``player_rating``
|
||||||
|
table; unrated players start at the initial rating. Returns the
|
||||||
|
per-player delta. Must be called inside a transaction.
|
||||||
|
"""
|
||||||
|
from .models import PlayerRating
|
||||||
|
|
||||||
|
ratings: Dict[str, PlayerRating] = {}
|
||||||
|
for subs in team_members.values():
|
||||||
|
for sub in subs:
|
||||||
|
rating = await PlayerRating.get_or_none(
|
||||||
|
user_sub=sub, game_type=game_type
|
||||||
|
)
|
||||||
|
if rating is None:
|
||||||
|
rating = await PlayerRating.create(
|
||||||
|
id=uuid.uuid4(), user_sub=sub, game_type=game_type
|
||||||
|
)
|
||||||
|
ratings[sub] = rating
|
||||||
|
delta_a = match_delta(
|
||||||
|
[ratings[sub].rating for sub in team_members["A"]],
|
||||||
|
[ratings[sub].rating for sub in team_members["B"]],
|
||||||
|
0 if winner_team == "A" else 1,
|
||||||
|
)
|
||||||
|
deltas: Dict[str, int] = {
|
||||||
|
**{sub: delta_a for sub in team_members["A"]},
|
||||||
|
**{sub: -delta_a for sub in team_members["B"]},
|
||||||
|
}
|
||||||
|
for sub, delta in deltas.items():
|
||||||
|
rating = ratings[sub]
|
||||||
|
rating.rating += delta
|
||||||
|
rating.matches_played += 1
|
||||||
|
await rating.save()
|
||||||
|
return deltas
|
||||||
|
|
||||||
|
|
||||||
async def save_match_result(state: GameState) -> None:
|
async def save_match_result(state: GameState) -> None:
|
||||||
"""Persist ``state`` to Postgres if it is finished and not yet saved."""
|
"""Persist ``state`` to Postgres if it is finished and not yet saved."""
|
||||||
if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None:
|
if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None:
|
||||||
@@ -36,18 +78,23 @@ async def save_match_result(state: GameState) -> None:
|
|||||||
|
|
||||||
started_at = _parse_timestamp(state.created_at)
|
started_at = _parse_timestamp(state.created_at)
|
||||||
finished_at = _parse_timestamp(state.finished_at)
|
finished_at = _parse_timestamp(state.finished_at)
|
||||||
|
winner_team = TEAM_NAMES[state.winner]
|
||||||
|
team_members: Dict[str, List[str]] = {"A": [], "B": []}
|
||||||
|
for player in state.players:
|
||||||
|
team_members[TEAM_NAMES[player.team]].append(player.sub)
|
||||||
async with in_transaction():
|
async with in_transaction():
|
||||||
match = await Match.create(
|
match = await Match.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
game_type=state.game_type,
|
game_type=state.game_type,
|
||||||
team_a_score=state.scores[0],
|
team_a_score=state.scores[0],
|
||||||
team_b_score=state.scores[1],
|
team_b_score=state.scores[1],
|
||||||
winner_team=TEAM_NAMES[state.winner],
|
winner_team=winner_team,
|
||||||
target_score=state.target_score,
|
target_score=state.target_score,
|
||||||
hands_played=state.hand_number,
|
hands_played=state.hand_number,
|
||||||
started_at=started_at,
|
started_at=started_at,
|
||||||
finished_at=finished_at,
|
finished_at=finished_at,
|
||||||
)
|
)
|
||||||
|
deltas = await apply_elo(state.game_type, winner_team, team_members)
|
||||||
for player in state.players:
|
for player in state.players:
|
||||||
await MatchPlayer.create(
|
await MatchPlayer.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -57,6 +104,7 @@ 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(
|
||||||
|
|||||||
@@ -81,5 +81,54 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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,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()
|
||||||
+186
-1
@@ -9,9 +9,10 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
from pwo import async_test
|
from pwo import async_test
|
||||||
|
|
||||||
from tavolo.app import app, tortoise_mixin
|
from tavolo.app import app, tortoise_mixin
|
||||||
|
from tavolo.elo import INITIAL_RATING
|
||||||
from tavolo.game import engine
|
from tavolo.game import engine
|
||||||
from tavolo.game.state import GameState
|
from tavolo.game.state import GameState
|
||||||
from tavolo.models import Match, MatchPlayer
|
from tavolo.models import Match, MatchPlayer, PlayerRating
|
||||||
from tavolo.stats import save_match_result
|
from tavolo.stats import save_match_result
|
||||||
from tests.helpers import oidc_user
|
from tests.helpers import oidc_user
|
||||||
|
|
||||||
@@ -48,6 +49,28 @@ def _finished_state() -> GameState:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _finished_state_reversed() -> GameState:
|
||||||
|
"""Same one-capture ending as ``_finished_state``, but team B scores it."""
|
||||||
|
state = GameState(
|
||||||
|
id="stats-game-2",
|
||||||
|
join_code="STATS2",
|
||||||
|
creator_sub="alice",
|
||||||
|
target_score=2,
|
||||||
|
phase=engine.PHASE_PLAYING,
|
||||||
|
turn=1,
|
||||||
|
table=[engine.parse_card("02C")],
|
||||||
|
)
|
||||||
|
from tavolo.game.state import PlayerState, Card
|
||||||
|
|
||||||
|
state.players = [
|
||||||
|
PlayerState(sub="alice", name="alice", seat=0),
|
||||||
|
PlayerState(sub="bob", name="bob", seat=1, hand=[Card.parse("02D")]),
|
||||||
|
PlayerState(sub="carol", name="carol", seat=2),
|
||||||
|
PlayerState(sub="dave", name="dave", seat=3),
|
||||||
|
]
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
class SaveMatchResultTest(unittest.TestCase):
|
class SaveMatchResultTest(unittest.TestCase):
|
||||||
@async_test
|
@async_test
|
||||||
async def test_finished_match_is_persisted_once(self) -> None:
|
async def test_finished_match_is_persisted_once(self) -> None:
|
||||||
@@ -71,6 +94,58 @@ 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()
|
||||||
@@ -167,6 +242,116 @@ 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."""
|
||||||
|
|||||||
@@ -91,6 +91,18 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
|||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the caller's Elo ratings (one row per game type played).
|
||||||
|
pub async fn my_ratings() -> Result<RatingsPage, String> {
|
||||||
|
let resp = Request::get("/api/me/ratings")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
||||||
let resp = Request::get("/api/leaderboard")
|
let resp = Request::get("/api/leaderboard")
|
||||||
.send()
|
.send()
|
||||||
|
|||||||
@@ -184,6 +184,10 @@ 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)]
|
||||||
@@ -201,6 +205,9 @@ pub struct MatchSummary {
|
|||||||
pub finished_at: String,
|
pub finished_at: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub you_won: bool,
|
pub you_won: bool,
|
||||||
|
/// The viewer's Elo change in this match; absent when unrated.
|
||||||
|
#[serde(default)]
|
||||||
|
pub your_elo_delta: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub players: Vec<MatchPlayer>,
|
pub players: Vec<MatchPlayer>,
|
||||||
}
|
}
|
||||||
@@ -213,16 +220,38 @@ 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)]
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ pub fn HistoryPage() -> View {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" & ");
|
.join(" & ");
|
||||||
let outcome = if m.you_won { "Won" } else { "Lost" };
|
let outcome = if m.you_won { "Won" } else { "Lost" };
|
||||||
|
let elo_delta = m
|
||||||
|
.your_elo_delta
|
||||||
|
.map(|d| if d >= 0 { format!("+{d}") } else { d.to_string() })
|
||||||
|
.unwrap_or_else(|| "—".to_string());
|
||||||
view! {
|
view! {
|
||||||
tr {
|
tr {
|
||||||
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
|
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
|
||||||
@@ -80,6 +84,9 @@ pub fn HistoryPage() -> View {
|
|||||||
td { (m.team_a_score) " – " (m.team_b_score) }
|
td { (m.team_a_score) " – " (m.team_b_score) }
|
||||||
td { "Team " (m.winner_team) }
|
td { "Team " (m.winner_team) }
|
||||||
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
|
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
|
||||||
|
td(class=if m.you_won { "won" } else { "lost" }) {
|
||||||
|
(elo_delta)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -94,6 +101,7 @@ pub fn HistoryPage() -> View {
|
|||||||
th { "Score" }
|
th { "Score" }
|
||||||
th { "Winner" }
|
th { "Winner" }
|
||||||
th { "You" }
|
th { "You" }
|
||||||
|
th { "Elo" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tbody { (table_rows) }
|
tbody { (table_rows) }
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub fn LeaderboardPage() -> View {
|
|||||||
tr {
|
tr {
|
||||||
td { (i + 1) }
|
td { (i + 1) }
|
||||||
td { (e.display_name.clone()) }
|
td { (e.display_name.clone()) }
|
||||||
|
td { (e.elo) }
|
||||||
td { (e.wins) }
|
td { (e.wins) }
|
||||||
td { (e.matches) }
|
td { (e.matches) }
|
||||||
td { (e.points) }
|
td { (e.points) }
|
||||||
@@ -52,6 +53,7 @@ pub fn LeaderboardPage() -> View {
|
|||||||
tr {
|
tr {
|
||||||
th { "#" }
|
th { "#" }
|
||||||
th { "Player" }
|
th { "Player" }
|
||||||
|
th { "Elo" }
|
||||||
th { "Wins" }
|
th { "Wins" }
|
||||||
th { "Matches" }
|
th { "Matches" }
|
||||||
th { "Points" }
|
th { "Points" }
|
||||||
|
|||||||
+19
-2
@@ -20,6 +20,8 @@ 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());
|
||||||
@@ -28,7 +30,17 @@ pub fn LobbyPage() -> View {
|
|||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::me().await {
|
match api::me().await {
|
||||||
Ok(me) => user.set(Some(me)),
|
Ok(me) => {
|
||||||
|
if me.is_some() {
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Ok(p) = api::my_ratings().await {
|
||||||
|
// Unrated players sit at the initial 1500.
|
||||||
|
rating.set(Some(p.results.first().map(|r| r.rating).unwrap_or(1500)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
user.set(Some(me));
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error.set(Some(e));
|
error.set(Some(e));
|
||||||
user.set(Some(None));
|
user.set(Some(None));
|
||||||
@@ -88,7 +100,12 @@ pub fn LobbyPage() -> View {
|
|||||||
Some(Some(me)) => view! {
|
Some(Some(me)) => view! {
|
||||||
div(class="lobby-grid") {
|
div(class="lobby-grid") {
|
||||||
nav(class="top-nav") {
|
nav(class="top-nav") {
|
||||||
span(class="whoami") { "Signed in as " strong { (me.name.clone()) } }
|
span(class="whoami") {
|
||||||
|
"Signed in as " strong { (me.name.clone()) }
|
||||||
|
(rating.get_clone().map(|r| view! {
|
||||||
|
span(class="rating") { " · Elo " (r) }
|
||||||
|
}))
|
||||||
|
}
|
||||||
a(href="/history") { "My matches" }
|
a(href="/history") { "My matches" }
|
||||||
a(href="/leaderboard") { "Leaderboard" }
|
a(href="/leaderboard") { "Leaderboard" }
|
||||||
a(href="/auth/logout", rel="external") { "Log out" }
|
a(href="/auth/logout", rel="external") { "Log out" }
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ body {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whoami .rating {
|
||||||
|
color: var(--muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user