Add chess-style Elo ratings for players

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

- GET /api/leaderboard exposes elo and sorts by it
- GET /api/me/matches includes per-player elo deltas
- new GET /api/me/ratings returns the caller's rating per game type
- frontend: Elo column on the leaderboard, per-match delta in the
  history page, current rating in the lobby
- python -m tavolo.backfill_elo recomputes all ratings from the
  recorded match history (one-off backfill for existing matches)
This commit is contained in:
2026-09-18 13:36:17 +00:00
parent 3da0c463de
commit a606f14550
15 changed files with 651 additions and 17 deletions
+28 -6
View File
@@ -133,12 +133,31 @@ loggers:
scoped per game), both teams' final scores, winner, target score, hands
played, start/finish timestamps.
- `match_player` — one row per participant: the OIDC `sub`, display name,
seat, team and whether they won. Unique per `(match, user_sub)`.
seat, team, whether they won and the Elo change the match produced
(`elo_delta`). Unique per `(match, user_sub)`.
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
the number of rated matches played.
When a match ends, the result is written transactionally to Postgres
(once, guarded by a flag on the Redis state); the finished state stays in
Redis until its TTL expires so clients can still fetch the final board.
### Elo ratings
Players carry a chess-style Elo rating per game type (`tavolo.elo`):
everyone starts at 1500, a team's rating is the mean of its two members,
and the standard formula `E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))`
with `K = 32` decides how many points the match result moves — the same
delta for both members of a team, zero-sum between teams. Ratings update
in the same transaction as the match result. To recompute every rating
from the recorded match history (e.g. to backfill matches recorded before
ratings existed):
```sh
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
.venv/bin/python -m tavolo.backfill_elo
```
## REST API
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
@@ -150,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/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / team points per player, sorted by Elo (`?game_type=`) |
## WebSocket protocol
@@ -271,8 +291,10 @@ src/tavolo/
├── openapi.py # shared OpenAPI parameter fragments
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
├── aerich_config.py # aerich CLI configuration
├── models.py # Match, MatchPlayer (Postgres)
├── stats.py # finished match -> Postgres persistence
├── models.py # Match, MatchPlayer, PlayerRating (Postgres)
├── elo.py # chess-style Elo math (1500 start, K=32)
├── stats.py # finished match -> Postgres persistence + Elo update
├── backfill_elo.py # recompute all ratings from the match history
├── store.py # Redis / in-memory live-game store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint
@@ -283,5 +305,5 @@ src/tavolo/
└── routes/
├── health.py # GET /api/health
├── games.py # lobby: create / join / snapshot
└── stats.py # match history + leaderboard
└── stats.py # match history + leaderboard + Elo ratings
```