Paginate the leaderboard from materialized player ratings
CI / Build and push docker image (push) Successful in 1m19s

Store each player's leaderboard totals (wins, accumulated points and the
latest display name) on player_rating alongside the Elo rating, updated
in the same transaction as the match, so the board is a single indexed
table query instead of aggregating every participation on each request.

GET /api/leaderboard is now page-number paginated (page/limit) and
returns the page, page count and total, plus the caller's own rank and
page when authenticated; focus=me returns the caller's page directly.
The migration adds the columns, backfills them from the match history
(inserting rows for players from pre-Elo matches at the initial rating)
and replaces the index. backfill_elo rebuilds the aggregates too.

The web page opens on the page containing the signed-in player, marks
their row and adds a numbered footer pager.
This commit is contained in:
2026-10-09 16:42:47 +08:00
parent ae7ab5ce60
commit 1ab748a34f
16 changed files with 818 additions and 167 deletions
+10 -8
View File
@@ -251,8 +251,10 @@ aggregate with `max`, not `sum`.
seat, game-defined team label (nullable), whether they won, the points
they scored, the Elo change the match produced (`elo_delta`) and
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
the number of rated matches played.
- `player_rating` — current Elo rating and leaderboard totals per
`(user_sub, game_type)`: the rating, matches played, wins, accumulated
points and the most recent display name. Kept in sync with the match
transaction, so the leaderboard is a single indexed table query.
When a match ends, the engine's result is written transactionally to
Postgres (once, guarded by a flag on the session); the finished session
@@ -266,10 +268,10 @@ Players carry a chess-style Elo rating per game type
mean of its 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 every member of
a team, zero-sum between the two 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):
a team, zero-sum between the two teams. Ratings and the leaderboard
aggregates update in the same transaction as the match result. To
rebuild every rating and aggregate from the recorded match history (e.g.
to backfill matches recorded before ratings existed):
```sh
.venv/bin/python -m tavolo.platform.backfill_elo \
@@ -292,7 +294,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included |
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` 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 / points per player, sorted by Elo (`?game_type=`) |
| `GET` | `/api/leaderboard` | Elo rating, wins / matches / points per rated player, sorted by Elo (`?game_type=`, `?page=`, `?limit=`, `?focus=me`). The response carries the requested page, the page count and — when authenticated — the caller's own rank and page; `focus=me` returns the caller's page directly |
## WebSocket protocol
@@ -560,7 +562,7 @@ packages/tavolo-platform/ # game-independent platform (own pyproject + tests
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
├── elo.py # chess-style Elo math (1500 start, K=32)
├── stats.py # MatchResult -> Postgres persistence + Elo update
├── backfill_elo.py # recompute all ratings from the match history
├── backfill_elo.py # recompute all ratings and aggregates from match history
├── store.py # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint (generic envelope)