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:
woggioni committed 2026-10-09 16:42:47 +08:00
1 parent ae7ab5ce60
commit 1ab748a34f
16 files changed
+818 -167

No files matched your search

+10 -8
View File
@@ -251,8 +251,10 @@ aggregate with `max`, not `sum`.
seat, game-defined team label (nullable), whether they won, the points seat, game-defined team label (nullable), whether they won, the points
they scored, the Elo change the match produced (`elo_delta`) and they scored, the Elo change the match produced (`elo_delta`) and
game-specific extras (`details` JSON). Unique per `(match, user_sub)`. game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with - `player_rating` — current Elo rating and leaderboard totals per
the number of rated matches played. `(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 When a match ends, the engine's result is written transactionally to
Postgres (once, guarded by a flag on the session); the finished session 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 mean of its members, and the standard formula
`E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how `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 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 a team, zero-sum between the two teams. Ratings and the leaderboard
transaction as the match result. To recompute every rating from the aggregates update in the same transaction as the match result. To
recorded match history (e.g. to backfill matches recorded before ratings rebuild every rating and aggregate from the recorded match history (e.g.
existed): to backfill matches recorded before ratings existed):
```sh ```sh
.venv/bin/python -m tavolo.platform.backfill_elo \ .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/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/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/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 ## WebSocket protocol
@@ -560,7 +562,7 @@ packages/tavolo-platform/ # game-independent platform (own pyproject + tests
├── models.py # Match / MatchPlayer / PlayerRating (Postgres) ├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
├── elo.py # chess-style Elo math (1500 start, K=32) ├── elo.py # chess-style Elo math (1500 start, K=32)
├── stats.py # MatchResult -> Postgres persistence + Elo update ├── 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) ├── store.py # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler ├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint (generic envelope) ├── ws.py # WebSocket live-play endpoint (generic envelope)
@@ -0,0 +1,79 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_player_rati_game_ty_3326d1";
ALTER TABLE "player_rating" ADD "points" DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE "player_rating" ADD "display_name" VARCHAR(200) NOT NULL DEFAULT '';
ALTER TABLE "player_rating" ADD "wins" INT NOT NULL DEFAULT 0;
-- Backfill the leaderboard aggregates from the match history:
-- existing rows keep their rating and matches, while players from
-- matches recorded before ratings existed are inserted at the
-- initial rating (1500) so they stay on the board.
INSERT INTO "player_rating" (
"id", "user_sub", "game_type", "rating", "matches_played",
"wins", "points", "display_name", "updated_at"
)
SELECT gen_random_uuid(), agg.user_sub, agg.game_type, 1500,
agg.matches, agg.wins, agg.points, agg.display_name, NOW()
FROM (
SELECT mp.user_sub AS user_sub,
m.game_type AS game_type,
COUNT(*) AS matches,
SUM(CASE WHEN mp.won THEN 1 ELSE 0 END) AS wins,
SUM(mp.score) AS points,
(ARRAY_AGG(mp.display_name ORDER BY m.finished_at DESC, m.id DESC))[1]
AS display_name
FROM "match_player" mp
JOIN "match" m ON m.id = mp.match_id
GROUP BY mp.user_sub, m.game_type
) agg
ON CONFLICT ("user_sub", "game_type") DO UPDATE
SET "wins" = EXCLUDED."wins",
"points" = EXCLUDED."points",
"display_name" = EXCLUDED."display_name";
CREATE INDEX "idx_player_rati_game_ty_abcb75" ON "player_rating" ("game_type", "rating", "wins", "points", "matches_played", "user_sub");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_player_rati_game_ty_abcb75";
ALTER TABLE "player_rating" DROP COLUMN "points";
ALTER TABLE "player_rating" DROP COLUMN "display_name";
ALTER TABLE "player_rating" DROP COLUMN "wins";
CREATE INDEX "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating");"""
MODELS_STATE = (
"eJztmm1v4jgQgP9KlE89qVcBfdnV6XRSoFTLLZQK6N1pq1VkEkOtGjubOMeiXv/72c67k/"
"DSEgoVH7ZLxh7HeSYez4zzrM+oDbF31gPMetR/0551AmaQ/8g2nGo6cJxELAQMjHHQM+4y"
"9pgLLMaFE4A9yEU29CwXOQxRIroamkVnDoYM2ppU0+hEowSK/9gj1Fw4RR6DLm+e8nlobO"
"FA70yMbVOLD47I9G3D+AT98KHJ6BTyji4f7OE7FyNiw5/Qiy6dJ3OCILYzQJAtBpByUwwo"
"ZPf3nesb2VNMcWxaFPszkvR2FuyRkri77yP7TOiItikk0AX8EVK4iI9xiDUSBTPmAub6MJ"
"6qnQhsOAE+FtD13yc+sQRrTd5J/Ln4I5xaqptp3vZH5rA9Mk09ZyMxBYV3KLIoEfZFhAlQ"
"zy/BuAkQKdXFDVpfjMHJ+dUvEgH12NSVjRKX/iIVAQOBqoSeUBb2CnjlYLcegVsMO6OkMO"
"dTfg3tSLAEd0SqArZ8Tf00MSRTJhbleWMJ67+MQYC7IXFTvgCDZXkbtjRkk6CeUPYYcDkL"
"E7A85mtOiaEZLEad1VRY26HqWfRjO+QTZ7ID9EtQjzq99nBk9O7E8DPP+4ElL2PUFi0NKV"
"0o0pMrxSzxINrfndEXTVxq3/q3bXWtxP1G33QxJ+AzahI6N4GdZhKJI1HGzBNEkPf4Kjsr"
"qkdD77Wh+VCCTc7Gfw77t8X2TTRU0yKLaf9pmG+gVRk1tU+NfYQZIt6ZuO17bFWCUMbKkd"
"s86Rn/qB611e03VfOJAZrcvYq4YfKU2tOEYAyspzlwbTPTktjNwWABXS9vuGaoePN1ADGQ"
"z5s3VTpEu5MjHe4yTKThViuB0gYtI5pvmjVmqgQQMJWPJO4t7lSArCzoTYiuCH1NJ+m5Mg"
"K+49snspAjbRqFrb4HXQ0R+VsOmQ95N9AriHEfkiBd6JieP9a/HwPffQp8Y7tsEPemdT5e"
"2Nu4vFwj7uW9SgNf2ZaNfG3kieVqyusNUKt6VeHeqe9VeNdq6/Cu1cp5izYl04BFsedwBj"
"DuEFaSY8DCoFPsDQfIeCom8et549PVZ94q5yguPi1hPewZ3W7ndqSwZBDMNnlno/6velfD"
"db83GCvOiOdBhKVEYZRiCEgx3XlhTDbmKgf4li7B2ez3u5kIudkZKVzve8324KQucfNOiE"
"lx/gX2LOoWeN0bTEGZK4g0FMwToVIV59ruIV/375vdtnY3aLc6w06YksQppmzMwh20ja5C"
"F2LK54wZ2NTfZhRf5XT3zVlsz+fakAGECzK08tQ6pXLMrbeSW6cNEqRcm+UkaZ1tZib767"
"RXJCK5YoXCt8BHcz+MpuQrXEjEHT4RQKwi16weHB0m1Fw9gotdMI/T48w7xZ+ePzMMPHPL"
"GLaM67b+Ul4AqrK0EZQtBoAFD5mrbWTaT5cVN4KyhukmXVdWN1q+60LCtDamWqCnAWJrPI"
"iyoTum/OE0RhnXjSoYwT00h/8Thzj5usdWRiysiKST5+QASa2KPGQPlxIYc+5Zg9Ul35zT"
"8I2AXlAOsvO1lmNx5Vhc2Z8UqpLqyv6e3n6oZDXxQlnGpRF+orC7mkr9slZVIhXG9436xa"
"eLz+dXF3GQH0uWRfr5ID/vudfkmlfcHd9DgRttlGsijffVI0gFZBJprF0+SVSO9ZOV9ZPD"
"OxrQq4rCdnAw4Dvi25HXfJqS1fyAX6boLs9u+gQv9PAovBz+nn+pEh/kr/llRJWJsQFdVP"
"yNa9hyuiwZBkmfVVlwuZ23/PVp6R5amCYW7KBhrve2/LD6Ou4bd9DytPBf6Hqo6OCn3Nmn"
"VD5gqlJJUigW1QaEw+4fkG59rX20vmQfref3UX5HBslG3/6lVHZ/QPE+9fD3+Mxv+5vZy/"
"+g2+dS"
)
+9 -6
View File
@@ -33,15 +33,18 @@ itself ships no game.
revalidate the token, so stale or duplicate deliveries are harmless. revalidate the token, so stale or duplicate deliveries are harmless.
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON - `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
`result`), `MatchPlayer` (seat, team, won, points, Elo delta, JSON `result`), `MatchPlayer` (seat, team, won, points, Elo delta, JSON
`details`) and `PlayerRating` (Elo per `(user_sub, game_type)`). `details`) and `PlayerRating` (Elo and leaderboard aggregates per
`(user_sub, game_type)`).
- `stats.py` — `save_match_result` (engine `MatchResult` → Postgres, - `stats.py` — `save_match_result` (engine `MatchResult` → Postgres,
transactionally, with Elo) and `apply_elo`. transactionally, with Elo and the leaderboard aggregates) and
`apply_match_stats`.
- `elo.py` — chess-style Elo math generalized to two-team matches. - `elo.py` — chess-style Elo math generalized to two-team matches.
- `backfill_elo.py` — rebuild every rating from the match history: - `backfill_elo.py` — rebuild every rating and aggregate from the match
`python -m tavolo.platform.backfill_elo --database-url postgres://…`. history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`.
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`, - `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination, `tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and
shared OpenAPI fragments, the TortoiseORM lifecycle mixin. page-number pagination, shared OpenAPI fragments, the TortoiseORM
lifecycle mixin.
## Adding a game ## Adding a game
@@ -1,9 +1,9 @@
"""Recompute every Elo rating from the recorded match history. """Recompute every Elo rating and leaderboard aggregate from the history.
Ratings are deterministic given the finished matches, so this replays all Ratings and aggregates are deterministic given the finished matches, so
matches in chronological order and rewrites the ``player_rating`` table this replays all matches in chronological order and rewrites the
and each ``match_player.elo_delta`` from scratch. Run any time ratings ``player_rating`` table and each ``match_player.elo_delta`` from scratch.
need to be rebuilt:: Run any time the leaderboard state needs to be rebuilt::
python -m tavolo.platform.backfill_elo --database-url postgres://... python -m tavolo.platform.backfill_elo --database-url postgres://...
""" """
@@ -17,14 +17,14 @@ from typing import Dict, List
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
from .stats import apply_elo from .stats import PlayerStats, apply_match_stats
from .tortoise_mixin import TortoiseMixin from .tortoise_mixin import TortoiseMixin
log = getLogger(__name__) log = getLogger(__name__)
async def backfill_elo() -> int: async def backfill_elo() -> int:
"""Rebuild all ratings; returns the number of matches replayed.""" """Rebuild all ratings and aggregates; returns the matches replayed."""
from .models import Match, MatchPlayer, PlayerRating from .models import Match, MatchPlayer, PlayerRating
replayed = 0 replayed = 0
@@ -48,7 +48,20 @@ async def backfill_elo() -> int:
), ),
0, 0,
) )
deltas = await apply_elo(match.game_type, teams, winner_index) deltas = await apply_match_stats(
match.game_type,
teams,
winner_index,
[
PlayerStats(
user_sub=p.user_sub,
won=p.won,
score=p.score,
display_name=p.display_name,
)
for p in players
],
)
for player in players: for player in players:
player.elo_delta = deltas[player.user_sub] player.elo_delta = deltas[player.user_sub]
await player.save() await player.save()
@@ -9,9 +9,10 @@ completed matches are written here. The schema is game-independent:
* :class:`MatchPlayer` — one row per participant, linking an OIDC * :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat, an optional team label, whether they won, the ``sub`` to a seat, an optional team label, whether they won, the
points they scored and their Elo delta. points they scored and their Elo delta.
* :class:`PlayerRating` — current chess-style Elo rating of a player for * :class:`PlayerRating` — current chess-style Elo rating and leaderboard
one game type, updated transactionally with every finished match (see totals (wins, points, latest display name) of a player for one game
:mod:`tavolo.platform.elo`). type, updated transactionally with every finished match (see
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
""" """
from __future__ import annotations from __future__ import annotations
@@ -69,7 +70,7 @@ class MatchPlayer(Model):
class PlayerRating(Model): class PlayerRating(Model):
"""Current Elo rating of one player for one game type.""" """Current Elo rating and leaderboard totals of one player per game."""
id = fields.UUIDField(pk=True) id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table. # OIDC subject of the player; no local users table.
@@ -78,9 +79,17 @@ class PlayerRating(Model):
game_type = fields.CharField(max_length=32) game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING) rating = fields.IntField(default=INITIAL_RATING)
matches_played = fields.IntField(default=0) matches_played = fields.IntField(default=0)
# Leaderboard aggregates, updated with every finished match.
wins = fields.IntField(default=0)
points = fields.FloatField(default=0)
# Most recent display name seen for the player.
display_name = fields.CharField(max_length=200, default="")
updated_at = fields.DatetimeField(auto_now=True) updated_at = fields.DatetimeField(auto_now=True)
class Meta: class Meta:
table = "player_rating" table = "player_rating"
unique_together = (("user_sub", "game_type"),) unique_together = (("user_sub", "game_type"),)
indexes = (("game_type", "rating"),) # Composite index matching the leaderboard ordering within a game.
indexes = (
("game_type", "rating", "wins", "points", "matches_played", "user_sub"),
)
@@ -19,3 +19,22 @@ PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
"description": "Opaque pagination cursor from a previous response's next_cursor.", "description": "Opaque pagination cursor from a previous response's next_cursor.",
}, },
] ]
# Page-number pagination (used by the leaderboard, which supports jumping
# to a specific page).
PAGE_PARAMETERS: List[Dict[str, Any]] = [
{
"name": "page",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "default": 1},
"description": "1-based page number (clamped to the last page).",
},
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
"description": "Maximum number of results per page (clamped to [1, 100]).",
},
]
@@ -30,12 +30,17 @@ MIN_LIMIT = 1
CURSOR_PARAM = "cursor" CURSOR_PARAM = "cursor"
LIMIT_PARAM = "limit" LIMIT_PARAM = "limit"
PAGE_PARAM = "page"
class CursorDecodeError(ValueError): class CursorDecodeError(ValueError):
"""Raised when the ``cursor`` query param cannot be decoded.""" """Raised when the ``cursor`` query param cannot be decoded."""
class PageParamsError(ValueError):
"""Raised when the ``page``/``limit`` query params are invalid."""
def encode_cursor(values: Dict[str, Any]) -> str: def encode_cursor(values: Dict[str, Any]) -> str:
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry # ``default=str`` handles datetimes (ISO) so keyset cursors can carry
# datetime-typed sort fields (e.g. finished_at for match history). # datetime-typed sort fields (e.g. finished_at for match history).
@@ -79,6 +84,36 @@ def parse_cursor_params(query_string: str) -> Cursor:
return Cursor(limit=limit, after=after) return Cursor(limit=limit, after=after)
def parse_page_params(query_string: str) -> Tuple[int, int]:
"""Parse the ``page``/``limit`` params: a 1-based page and a page size.
``limit`` is clamped to [1, 100] (default 20); ``page`` must be a
positive integer (default 1). Clamping ``page`` to the last existing
page is left to the endpoint, which knows how many pages there are.
"""
params = extract_query_params(query_string)
limit_raw = params.get(LIMIT_PARAM)
if limit_raw:
try:
limit = int(limit_raw[0])
except ValueError as exc:
raise PageParamsError("invalid limit") from exc
else:
limit = DEFAULT_LIMIT
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
page_raw = params.get(PAGE_PARAM)
if page_raw:
try:
page = int(page_raw[0])
except ValueError as exc:
raise PageParamsError("invalid page") from exc
else:
page = 1
if page < 1:
raise PageParamsError("invalid page")
return page, limit
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full # A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
# keyset; the cursor encodes exactly these fields. # keyset; the cursor encodes exactly these fields.
Sort = List[Tuple[str, str]] Sort = List[Tuple[str, str]]
@@ -16,11 +16,17 @@ from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation from kaya.openapi import operation
from ..auth import current_user, require_auth from ..auth import current_user, require_auth
from ..elo import INITIAL_RATING
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, PlayerRating
from ..openapi import PAGINATION_PARAMETERS from ..openapi import PAGE_PARAMETERS, PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params from ..pagination import (
CursorDecodeError,
PageParamsError,
paginate,
parse_cursor_params,
parse_page_params,
)
from tortoise.expressions import Q
if TYPE_CHECKING: if TYPE_CHECKING:
from ..mixin import Platform from ..mixin import Platform
@@ -33,6 +39,15 @@ GAME_TYPE_PARAMETER: Dict[str, Any] = {
"description": "Only count matches of this game (id from GET /api/game-types).", "description": "Only count matches of this game (id from GET /api/game-types).",
} }
LEADERBOARD_FOCUS_PARAMETER: Dict[str, Any] = {
"name": "focus",
"in": "query",
"required": False,
"schema": {"type": "string", "enum": ["me"]},
"description": "Set to 'me' to return the page containing the caller's "
"own ranking (page 1 for unrated or anonymous callers).",
}
def _parse_game_type( def _parse_game_type(
platform: "Platform", query_string: str platform: "Platform", query_string: str
@@ -50,6 +65,33 @@ def _parse_game_type(
return game_type, None return game_type, None
def _ranks_before(rating: PlayerRating) -> Q:
"""Keyset condition selecting the rows ranked above ``rating``.
Mirrors the leaderboard ordering ``-rating, -wins, -points,
matches_played, user_sub``: exactly the rows sorting before the given
one.
"""
return (
Q(rating__gt=rating.rating)
| Q(rating=rating.rating, wins__gt=rating.wins)
| Q(rating=rating.rating, wins=rating.wins, points__gt=rating.points)
| Q(
rating=rating.rating,
wins=rating.wins,
points=rating.points,
matches_played__lt=rating.matches_played,
)
| Q(
rating=rating.rating,
wins=rating.wins,
points=rating.points,
matches_played=rating.matches_played,
user_sub__lt=rating.user_sub,
)
)
def _serialize_match( def _serialize_match(
match: Match, viewer: str, participants: List[MatchPlayer] match: Match, viewer: str, participants: List[MatchPlayer]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -131,56 +173,87 @@ def register(app: KayaApp, platform: "Platform") -> 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 " description="Elo rating, wins, matches played and points for "
"points for every player with at least one " "every rated player of a game, sorted by Elo (the "
"finished match. Sorted by Elo rating (the rating " "requested game_type, or the default game when "
"for the requested game_type, or the default game " "the filter is absent). Page-number paginated; "
"when the filter is absent).", "the response carries the caller's own rank when "
"authenticated.",
tags=["stats"], tags=["stats"],
parameters=[GAME_TYPE_PARAMETER], parameters=[
GAME_TYPE_PARAMETER,
*PAGE_PARAMETERS,
LEADERBOARD_FOCUS_PARAMETER,
],
responses={ responses={
200: {"description": "The leaderboard"}, 200: {"description": "A page of the leaderboard"},
400: {"description": "Unknown game_type"}, 400: {"description": "Unknown game_type or invalid parameters"},
}) })
async def leaderboard(ctx: HttpContext) -> None: async def leaderboard(ctx: HttpContext) -> None:
game_type, error = _parse_game_type(platform, ctx.query_string) game_type, error = _parse_game_type(platform, ctx.query_string)
if error is not None: if error is not None:
await send_error(ctx, 400, error) await send_error(ctx, 400, error)
return return
queryset = MatchPlayer.all() try:
if game_type is not None: page, limit = parse_page_params(ctx.query_string)
queryset = queryset.filter(match__game_type=game_type) except PageParamsError as exc:
rows = await queryset await send_error(ctx, 400, str(exc))
# Ratings are per game type; without a filter show the default return
# game's. focus_values = extract_query_params(ctx.query_string).get("focus")
focus = focus_values[0] if focus_values else None
if focus is not None and focus != "me":
await send_error(ctx, 400, "invalid focus")
return
# The board is the requested game's, or the default game's when the
# filter is absent (ratings are per game type).
rating_game = game_type or platform.registry.default.id rating_game = game_type or platform.registry.default.id
rating_rows = await PlayerRating.filter(game_type=rating_game) board = PlayerRating.filter(game_type=rating_game)
ratings = {row.user_sub: row.rating for row in rating_rows} total = await board.count()
aggregate: Dict[str, Dict[str, Any]] = {} pages = (total + limit - 1) // limit
for row in rows: current = min(page, pages) if pages else 1
entry = aggregate.setdefault(
row.user_sub, # The caller's own ranking, when authenticated and rated: ranks
# above theirs plus one. `focus=me` jumps to their page.
user = current_user(platform.oidc, ctx)
yours: Optional[PlayerRating] = None
if user is not None:
yours = await PlayerRating.get_or_none(
user_sub=user.sub, game_type=rating_game
)
your_rank: Optional[int] = None
your_page: Optional[int] = None
if yours is not None:
your_rank = 1 + await board.filter(_ranks_before(yours)).count()
your_page = (your_rank - 1) // limit + 1
if focus == "me":
current = your_page
rows = await (
board.order_by(
"-rating", "-wins", "-points", "matches_played", "user_sub"
)
.offset((current - 1) * limit)
.limit(limit)
)
await send_json(ctx, 200, {
"results": [
{ {
"user_sub": row.user_sub, "user_sub": row.user_sub,
"display_name": row.display_name, "display_name": row.display_name or row.user_sub,
"matches": 0, "matches": row.matches_played,
"wins": 0, "wins": row.wins,
"points": 0, "points": row.points,
"elo": ratings.get(row.user_sub, INITIAL_RATING), "elo": row.rating,
}, }
) for row in rows
entry["matches"] += 1 ],
entry["wins"] += 1 if row.won else 0 "page": current,
entry["points"] += row.score "pages": pages,
# Keep the most recent display name seen. "total": total,
entry["display_name"] = row.display_name "your_rank": your_rank,
"your_page": your_page,
ranking: List[Dict[str, Any]] = sorted( })
aggregate.values(),
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
reverse=True,
)
await send_json(ctx, 200, {"results": ranking})
@app.GET("/api/me/ratings") @app.GET("/api/me/ratings")
@operation(summary="My Elo ratings", @operation(summary="My Elo ratings",
@@ -3,8 +3,8 @@
Called once when a session reaches the finished state (guarded by the Called once when a session reaches the finished state (guarded by the
``stats_saved`` flag on the session). The write is transactional so a ``stats_saved`` flag on the session). The write is transactional so a
match never appears with only some of its players. The same transaction match never appears with only some of its players. The same transaction
also updates the participants' Elo ratings (see also updates the participants' Elo ratings and leaderboard aggregates
:mod:`tavolo.platform.elo`). (see :mod:`tavolo.platform.elo`).
This module is game-independent: everything it persists comes from the This module is game-independent: everything it persists comes from the
engine's :class:`~tavolo.platform.engine.MatchResult`. engine's :class:`~tavolo.platform.engine.MatchResult`.
@@ -12,9 +12,10 @@ engine's :class:`~tavolo.platform.engine.MatchResult`.
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from logging import getLogger from logging import getLogger
from typing import Dict, List from typing import Dict, List, Sequence
from tortoise.transactions import in_transaction from tortoise.transactions import in_transaction
@@ -24,21 +25,37 @@ from .engine import GameEngine, GameSession
log = getLogger(__name__) log = getLogger(__name__)
async def apply_elo( @dataclass(frozen=True)
game_type: str, teams: List[List[str]], winner_team: int class PlayerStats:
"""One participant's data feeding a ``player_rating`` row update."""
user_sub: str
won: bool
score: float
display_name: str
async def apply_match_stats(
game_type: str,
teams: List[List[str]],
winner_team: int,
players: Sequence[PlayerStats],
) -> Dict[str, int]: ) -> Dict[str, int]:
"""Update the Elo ratings of ``teams`` for ``game_type``. """Update the ratings and leaderboard aggregates of ``teams``.
``teams`` groups the participants' subs into the two opposing sides; ``teams`` groups the participants' subs into the two opposing sides;
``winner_team`` is the index of the winning side. Ratings are read ``winner_team`` is the index of the winning side. Ratings are read
from (and written back to) the ``player_rating`` table; unrated from (and written back to) the ``player_rating`` table; unrated
players start at the initial rating. Returns the per-player delta. players start at the initial rating. Alongside the Elo delta the
Must be called inside a transaction. transaction records each participant's wins, accumulated points and
latest display name, so the leaderboard is a single-table query.
Returns the per-player delta. Must be called inside a transaction.
""" """
if len(teams) != 2: if len(teams) != 2:
raise ValueError("Elo rating requires exactly two teams") raise ValueError("Elo rating requires exactly two teams")
from .models import PlayerRating from .models import PlayerRating
stats = {player.user_sub: player for player in players}
ratings: Dict[str, "PlayerRating"] = {} ratings: Dict[str, "PlayerRating"] = {}
for subs in teams: for subs in teams:
for sub in subs: for sub in subs:
@@ -63,6 +80,13 @@ async def apply_elo(
rating = ratings[sub] rating = ratings[sub]
rating.rating += delta rating.rating += delta
rating.matches_played += 1 rating.matches_played += 1
player = stats.get(sub)
if player is not None:
if player.won:
rating.wins += 1
rating.points += player.score
if player.display_name:
rating.display_name = player.display_name
await rating.save() await rating.save()
return deltas return deltas
@@ -78,6 +102,15 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
started_at = session.created_at or datetime.now(timezone.utc) started_at = session.created_at or datetime.now(timezone.utc)
finished_at = session.finished_at or datetime.now(timezone.utc) finished_at = session.finished_at or datetime.now(timezone.utc)
display_names = {seat.user_sub: seat.display_name for seat in session.players} display_names = {seat.user_sub: seat.display_name for seat in session.players}
player_stats = [
PlayerStats(
user_sub=player.user_sub,
won=player.won,
score=player.score,
display_name=display_names.get(player.user_sub, player.user_sub),
)
for player in result.players
]
async with in_transaction(): async with in_transaction():
match = await Match.create( match = await Match.create(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -86,7 +119,9 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
finished_at=finished_at, finished_at=finished_at,
result=result.summary, result=result.summary,
) )
deltas = await apply_elo(session.game_type, result.teams, result.winner_team) deltas = await apply_match_stats(
session.game_type, result.teams, result.winner_team, player_stats
)
for player in result.players: for player in result.players:
await MatchPlayer.create( await MatchPlayer.create(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -14,7 +14,14 @@ from datetime import datetime, timezone
from typing import Any, List from typing import Any, List
from tavolo.platform.models import Match from tavolo.platform.models import Match
from tavolo.platform.pagination import Cursor, decode_cursor, encode_cursor, paginate from tavolo.platform.pagination import (
Cursor,
PageParamsError,
decode_cursor,
encode_cursor,
paginate,
parse_page_params,
)
from helpers import async_test from helpers import async_test
@@ -77,5 +84,22 @@ class PaginateCursorTypesTest(unittest.TestCase):
self.assertEqual(match_id, id_values[0]) self.assertEqual(match_id, id_values[0])
class PageParamsTest(unittest.TestCase):
def test_defaults(self) -> None:
self.assertEqual((1, 20), parse_page_params(""))
def test_page_and_limit(self) -> None:
self.assertEqual((3, 5), parse_page_params("page=3&limit=5"))
def test_limit_is_clamped(self) -> None:
self.assertEqual((1, 100), parse_page_params("limit=500"))
self.assertEqual((1, 1), parse_page_params("limit=0"))
def test_invalid_values_raise(self) -> None:
for query in ("page=0", "page=-2", "page=abc", "limit=abc"):
with self.assertRaises(PageParamsError, msg=query):
parse_page_params(query)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -4,6 +4,7 @@ from __future__ import annotations
import unittest import unittest
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict
from unittest import mock from unittest import mock
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
@@ -78,6 +79,12 @@ class SaveMatchResultTest(unittest.TestCase):
self.assertEqual(1, ratings["alice"].matches_played) self.assertEqual(1, ratings["alice"].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating) self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
self.assertEqual(1, ratings["bob"].matches_played) self.assertEqual(1, ratings["bob"].matches_played)
# The leaderboard aggregates ride along in the same transaction.
self.assertEqual(1, ratings["alice"].wins)
self.assertEqual(0, ratings["bob"].wins)
self.assertEqual(2.0, ratings["alice"].points)
self.assertEqual(0.0, ratings["bob"].points)
self.assertEqual("alice", ratings["alice"].display_name)
# The per-match delta is recorded on each participation row. # The per-match delta is recorded on each participation row.
deltas = { deltas = {
@@ -136,6 +143,9 @@ async def _seed_two_matches(
) -> None: ) -> None:
ctx = await use_db(tortoise_mixin) ctx = await use_db(tortoise_mixin)
with ctx: with ctx:
# The leaderboard reads the player_rating aggregates, so the seeded
# participations are mirrored there (as save_match_result would).
aggregates: Dict[tuple, Dict[str, Any]] = {}
for index, (winner, finished) in enumerate( for index, (winner, finished) in enumerate(
[ [
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)), ("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
@@ -150,6 +160,7 @@ async def _seed_two_matches(
result={"winner": winner, "plays": 3 + index}, result={"winner": winner, "plays": 3 + index},
) )
for seat, sub in enumerate(("alice", "bob")): for seat, sub in enumerate(("alice", "bob")):
score = 2.0 + index if sub == winner else 1.0
await MatchPlayer.create( await MatchPlayer.create(
id=uuid.uuid4(), id=uuid.uuid4(),
match=match, match=match,
@@ -158,8 +169,26 @@ async def _seed_two_matches(
seat=seat, seat=seat,
team="A" if seat == 0 else "B", team="A" if seat == 0 else "B",
won=(sub == winner), won=(sub == winner),
score=2.0 + index if sub == winner else 1.0, score=score,
) )
aggregate = aggregates.setdefault(
(sub, game_types[index]),
{"matches": 0, "wins": 0, "points": 0.0},
)
aggregate["matches"] += 1
aggregate["wins"] += 1 if sub == winner else 0
aggregate["points"] += score
for (sub, game_type), aggregate in aggregates.items():
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type=game_type,
rating=INITIAL_RATING,
matches_played=aggregate["matches"],
wins=aggregate["wins"],
points=aggregate["points"],
display_name=sub,
)
class StatsRouteTest(unittest.TestCase): class StatsRouteTest(unittest.TestCase):
@@ -252,13 +281,9 @@ class StatsRouteTest(unittest.TestCase):
ctx = await use_db(tortoise_mixin) ctx = await use_db(tortoise_mixin)
with ctx: with ctx:
# Alice outranks everyone despite Bob leading on points. # Alice outranks everyone despite Bob leading on points.
await PlayerRating.create( await PlayerRating.filter(
id=uuid.uuid4(), user_sub="alice", game_type="dummy"
user_sub="alice", ).update(rating=1600)
game_type="dummy",
rating=1600,
matches_played=2,
)
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard") response = await client.get("/api/leaderboard")
@@ -266,7 +291,6 @@ class StatsRouteTest(unittest.TestCase):
results = response.json()["results"] results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results} by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["alice"]["elo"]) self.assertEqual(1600, by_sub["alice"]["elo"])
# Players without a rating row report the initial rating.
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"]) self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
# Elo outranks wins/points. # Elo outranks wins/points.
self.assertEqual("alice", results[0]["user_sub"]) self.assertEqual("alice", results[0]["user_sub"])
@@ -279,13 +303,9 @@ class StatsRouteTest(unittest.TestCase):
await _seed_two_matches(tortoise_mixin) await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin) ctx = await use_db(tortoise_mixin)
with ctx: with ctx:
await PlayerRating.create( await PlayerRating.filter(
id=uuid.uuid4(), user_sub="bob", game_type="dummy"
user_sub="bob", ).update(rating=1516)
game_type="dummy",
rating=1516,
matches_played=1,
)
# Bob's rating in another game must not leak into the # Bob's rating in another game must not leak into the
# dummy leaderboard. # dummy leaderboard.
await PlayerRating.create( await PlayerRating.create(
@@ -305,6 +325,97 @@ class StatsRouteTest(unittest.TestCase):
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"]) self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
self.assertEqual("second", platform.registry.all()[1].id) self.assertEqual("second", platform.registry.all()[1].id)
@async_test
async def test_leaderboard_pagination(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
for index, sub in enumerate(("carol", "dave", "erin", "frank")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type="dummy",
rating=1600 + index,
matches_played=1,
wins=1,
points=2.0,
display_name=sub,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
first = await client.get("/api/leaderboard?limit=2")
second = await client.get("/api/leaderboard?limit=2&page=2")
third = await client.get("/api/leaderboard?limit=2&page=3")
beyond = await client.get("/api/leaderboard?limit=2&page=99")
body = first.json()
self.assertEqual(1, body["page"])
self.assertEqual(3, body["pages"])
self.assertEqual(6, body["total"])
self.assertEqual(2, len(body["results"]))
self.assertEqual(
["frank", "erin"], [row["user_sub"] for row in body["results"]]
)
self.assertEqual(
["dave", "carol"],
[row["user_sub"] for row in second.json()["results"]],
)
self.assertEqual(
["bob", "alice"],
[row["user_sub"] for row in third.json()["results"]],
)
# Out-of-range pages clamp to the last page.
self.assertEqual(3, beyond.json()["page"])
self.assertEqual(
["bob", "alice"],
[row["user_sub"] for row in beyond.json()["results"]],
)
@async_test
async def test_leaderboard_focus_me(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
for index, sub in enumerate(("carol", "dave")):
await PlayerRating.create(
id=uuid.uuid4(),
user_sub=sub,
game_type="dummy",
rating=1600 + index,
matches_played=1,
wins=1,
points=2.0,
display_name=sub,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
mine = await client.get("/api/leaderboard?limit=1&focus=me")
anonymous = await client.get("/api/leaderboard?limit=1&focus=me")
# dave, carol, bob, alice: the caller lands on their own page.
body = mine.json()
self.assertEqual(4, body["page"])
self.assertEqual(4, body["your_rank"])
self.assertEqual(4, body["your_page"])
self.assertEqual("alice", body["results"][0]["user_sub"])
anonymous_body = anonymous.json()
self.assertEqual(1, anonymous_body["page"])
self.assertIsNone(anonymous_body["your_rank"])
self.assertIsNone(anonymous_body["your_page"])
@async_test
async def test_leaderboard_rejects_bad_page_params(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
for query in ("page=0", "page=abc", "focus=elsewhere"):
response = await client.get(f"/api/leaderboard?{query}")
self.assertEqual(400, response.status_code, query)
# The limit is clamped, not rejected.
clamped = await client.get("/api/leaderboard?limit=500")
self.assertEqual(200, clamped.status_code)
@async_test @async_test
async def test_my_matches_include_elo_delta(self) -> None: async def test_my_matches_include_elo_delta(self) -> None:
app, platform, tortoise_mixin = make_platform() app, platform, tortoise_mixin = make_platform()
+6 -1
View File
@@ -190,7 +190,12 @@ class ScopaWebSocketTest(unittest.TestCase):
self.assertEqual(1, game["last_move"]["seat"]) self.assertEqual(1, game["last_move"]["seat"])
self.assertEqual(card, game["last_move"]["card"]) self.assertEqual(card, game["last_move"]["card"])
if options: if options:
self.assertEqual(2, game["players"][1]["captured_count"]) # The pile grows by the captured table cards
# plus the card just played.
self.assertEqual(
len(options[0]) + 1,
game["players"][1]["captured_count"],
)
else: else:
self.assertEqual(5, len(game["table"])) self.assertEqual(5, len(game["table"]))
+16 -2
View File
@@ -133,11 +133,25 @@ class ScoponeStatsTest(unittest.TestCase):
self.assertIn("your_elo_delta", results[0]) self.assertIn("your_elo_delta", results[0])
self.assertEqual(4, len(results[0]["players"])) self.assertEqual(4, len(results[0]["players"]))
board = await client.get("/api/leaderboard") with oidc_user("alice"):
board = await client.get("/api/leaderboard")
self.assertEqual(200, board.status_code) self.assertEqual(200, board.status_code)
by_sub = {r["user_sub"]: r for r in board.json()["results"]} body = board.json()
by_sub = {r["user_sub"]: r for r in body["results"]}
self.assertEqual(4, len(by_sub)) self.assertEqual(4, len(by_sub))
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values())) self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
self.assertEqual(1, body["page"])
self.assertEqual(1, body["pages"])
self.assertEqual(4, body["total"])
# The caller's own rank rides along on the response.
self.assertIsNotNone(body["your_rank"])
self.assertEqual(1, body["your_page"])
paged = await client.get("/api/leaderboard?limit=1&page=2")
self.assertEqual(200, paged.status_code)
self.assertEqual(4, paged.json()["pages"])
self.assertEqual(2, paged.json()["page"])
self.assertEqual(1, len(paged.json()["results"]))
async def _finished_briscola_session() -> GameSession: async def _finished_briscola_session() -> GameSession:
+18 -7
View File
@@ -175,13 +175,24 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
resp.json().await.map_err(|e| e.to_string()) resp.json().await.map_err(|e| e.to_string())
} }
/// Fetch the leaderboard for one game type: Elo, wins, matches and points /// Fetch one page of the leaderboard for a game. With `focus_me` the
/// are all scoped to that game. /// server returns the page holding the caller's own ranking (page 1 for
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> { /// anonymous or unrated callers).
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}")) pub async fn leaderboard(
.send() game_type: &str,
.await page: Option<u32>,
.map_err(|e| e.to_string())?; focus_me: bool,
) -> Result<LeaderboardPage, String> {
let mut url = format!(
"/api/leaderboard?game_type={game_type}&limit={LEADERBOARD_PAGE_SIZE}"
);
if focus_me {
url.push_str("&focus=me");
}
if let Some(page) = page {
url.push_str(&format!("&page={page}"));
}
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
check(&resp)?; check(&resp)?;
resp.json().await.map_err(|e| e.to_string()) resp.json().await.map_err(|e| e.to_string())
} }
+49
View File
@@ -466,6 +466,10 @@ fn default_elo() -> i32 {
1500 1500
} }
fn default_page() -> u32 {
1
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct LeaderboardEntry { pub struct LeaderboardEntry {
@@ -512,10 +516,30 @@ pub struct RatingsPage {
pub results: Vec<PlayerRating>, pub results: Vec<PlayerRating>,
} }
/// Page size requested by the leaderboard page. Also used to turn a rank
/// into the row number on a page (and back).
pub const LEADERBOARD_PAGE_SIZE: u32 = 20;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardPage { pub struct LeaderboardPage {
#[serde(default)] #[serde(default)]
pub results: Vec<LeaderboardEntry>, pub results: Vec<LeaderboardEntry>,
/// 1-based page actually returned (clamped to the last page).
#[serde(default = "default_page")]
pub page: u32,
/// Total number of pages; 0 when the board has no rated players.
#[serde(default)]
pub pages: u32,
/// Total number of rated players.
#[serde(default)]
pub total: u32,
/// The viewer's own rank, when authenticated and rated for this game.
#[serde(default)]
pub your_rank: Option<u32>,
/// The page holding the viewer's row.
#[serde(default)]
pub your_page: Option<u32>,
} }
/// A card game the platform can host (GET /api/game-types). /// A card game the platform can host (GET /api/game-types).
@@ -1113,6 +1137,31 @@ mod tests {
assert_eq!(1500, e.elo); assert_eq!(1500, e.elo);
} }
#[test]
fn leaderboard_page_parses_pagination_fields() {
let p: LeaderboardPage = serde_json::from_value(json!({
"results": [],
"page": 3,
"pages": 12,
"total": 234,
"your_rank": 47,
"your_page": 3,
}))
.unwrap();
assert_eq!(3, p.page);
assert_eq!(12, p.pages);
assert_eq!(234, p.total);
assert_eq!(Some(47), p.your_rank);
assert_eq!(Some(3), p.your_page);
// The pagination fields are optional for older payloads.
let legacy: LeaderboardPage =
serde_json::from_value(json!({"results": []})).unwrap();
assert_eq!(1, legacy.page);
assert_eq!(0, legacy.pages);
assert_eq!(None, legacy.your_rank);
assert_eq!(None, legacy.your_page);
}
fn scopone_game_type() -> GameTypeInfo { fn scopone_game_type() -> GameTypeInfo {
serde_json::from_value(json!({ serde_json::from_value(json!({
"id": "scopone_scientifico", "id": "scopone_scientifico",
+231 -62
View File
@@ -1,14 +1,102 @@
//! Per-game leaderboard page. //! Per-game leaderboard page.
//! //!
//! Elo ratings are stored per game type, so the board is scoped to the //! Elo ratings and the leaderboard aggregates live in one
//! game chosen in the dropdown: Elo, wins, matches and points all come //! `player_rating` row per player and game, so the board is
//! from that game's matches only. //! page-number paginated: the first load lands on the page holding the
//! signed-in player's own ranking (the server resolves it with
//! `focus=me`) and a footer pager changes pages.
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::api; use crate::api;
use crate::components::toast::toast; use crate::components::toast::toast;
use crate::model::{fallback_game_types, fmt_points, LeaderboardPage}; use crate::model::{
fallback_game_types, fmt_points, LeaderboardPage, LEADERBOARD_PAGE_SIZE,
};
/// Page numbers to show in the pager: the first and last page, the
/// current page and its neighbours, with `None` marking an ellipsis.
fn page_window(page: u32, pages: u32) -> Vec<Option<u32>> {
if pages <= 1 {
return Vec::new();
}
let mut window = vec![Some(1)];
let start = page.saturating_sub(2).max(2);
let end = (page + 2).min(pages.saturating_sub(1));
if start > 2 {
window.push(None);
}
for number in start..=end {
window.push(Some(number));
}
if end + 1 < pages {
window.push(None);
}
window.push(Some(pages));
window
}
/// The footer pager: first/previous, page numbers, next/last. Rendered
/// only when the board has more than one page.
fn pager(
page: &LeaderboardPage,
busy: bool,
load: impl Fn(Option<u32>, bool) + Copy + 'static,
) -> View {
let current = page.page;
let pages = page.pages;
if pages <= 1 {
return view! {};
}
let button = move |label: &'static str, target: u32, enabled: bool| {
view! {
li(class=if enabled { "page-item" } else { "page-item disabled" }) {
button(
class="page-link",
disabled=!enabled || busy,
on:click=move |_| if enabled && !busy {
load(Some(target), false);
},
) { (label) }
}
}
};
let items = page_window(current, pages)
.into_iter()
.map(|entry| match entry {
None => view! {
li(class="page-item disabled") {
span(class="page-link") { "…" }
}
},
Some(number) => {
let active = number == current;
view! {
li(class=if active { "page-item active" } else { "page-item" }) {
button(
class="page-link",
disabled=active || busy,
on:click=move |_| if !active && !busy {
load(Some(number), false);
},
) { (number) }
}
}
}
})
.collect::<Vec<_>>();
view! {
nav(aria-label="Leaderboard pages") {
ul(class="pagination justify-content-center mt-4") {
(button("«", 1, current > 1))
(button("‹", current.saturating_sub(1).max(1), current > 1))
(items)
(button("›", (current + 1).min(pages), current < pages))
(button("»", pages, current < pages))
}
}
}
}
#[component] #[component]
pub fn LeaderboardPage() -> View { pub fn LeaderboardPage() -> View {
@@ -16,26 +104,33 @@ pub fn LeaderboardPage() -> View {
let error = create_signal(Option::<String>::None); let error = create_signal(Option::<String>::None);
let game_types = create_signal(fallback_game_types()); let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string()); let selected_game = create_signal("scopone_scientifico".to_string());
// A leaderboard request is in flight; the pager is disabled meanwhile.
let loading = create_signal(false);
// Reload whenever the selection changes; a response for a game the let load = move |target: Option<u32>, focus_me: bool| {
// user has already switched away from is dropped.
create_effect(move || {
let game_type = selected_game.get_clone(); let game_type = selected_game.get_clone();
page.set(None); loading.set(true);
spawn_local(async move { spawn_local(async move {
match api::leaderboard(&game_type).await { let response = api::leaderboard(&game_type, target, focus_me).await;
Ok(p) => { // A response for a game the user has already switched away
if selected_game.get_clone() == game_type { // from is dropped (and must not clear the new game's spinner).
page.set(Some(p)); if selected_game.get_clone() != game_type {
} return;
} }
Err(e) => { loading.set(false);
if selected_game.get_clone() == game_type { match response {
error.set(Some(e)); Ok(p) => page.set(Some(p)),
} Err(e) => error.set(Some(e)),
}
} }
}); });
};
// Reload whenever the selection changes; the first load focuses the
// viewer's own page so they see their name right away.
create_effect(move || {
let _game = selected_game.get_clone();
page.set(None);
load(None, true);
}); });
spawn_local(async move { spawn_local(async move {
@@ -80,53 +175,86 @@ pub fn LeaderboardPage() -> View {
} }
} }
(toast(error)) (toast(error))
(move || match page.get_clone() { (move || {
None => view! { let busy = loading.get();
div(class="d-flex justify-content-center p-5") { match page.get_clone() {
div(class="spinner-border text-warning", role="status") { None => view! {
span(class="visually-hidden") { "Loading…" } div(class="d-flex justify-content-center p-5") {
} div(class="spinner-border text-warning", role="status") {
} span(class="visually-hidden") { "Loading…" }
},
Some(p) if p.results.is_empty() => view! {
p(class="text-secondary text-center p-5") {
"No ratings yet — finish a match to appear here."
}
},
Some(p) => {
let rows = p
.results
.iter()
.cloned()
.enumerate()
.map(|(i, e)| {
view! {
tr {
td { (i + 1) }
td { (e.display_name.clone()) }
td { (e.elo) }
td { (e.wins) }
td { (e.matches) }
td { (fmt_points(e.points)) }
}
} }
}) }
.collect::<Vec<_>>(); },
view! { Some(p) if p.results.is_empty() => view! {
div(class="table-responsive") { p(class="text-secondary text-center p-5") {
table(class="table table-striped table-hover align-middle") { "No ratings yet — finish a match to appear here."
thead { }
tr { },
th { "#" } Some(p) => {
th { "Player" } let rows = p
th { "Elo" } .results
th { "Wins" } .iter()
th { "Matches" } .cloned()
th { "Points" } .enumerate()
.map(|(i, e)| {
let rank = (p.page.saturating_sub(1))
* LEADERBOARD_PAGE_SIZE
+ i as u32
+ 1;
let is_me = p.your_rank == Some(rank);
let cls = if is_me {
"table-active fw-semibold"
} else {
""
};
view! {
tr(class=cls) {
td { (rank) }
td {
(e.display_name.clone())
(is_me.then(|| view! {
span(
class="badge text-bg-warning ms-2"
) {
"you"
}
}))
}
td { (e.elo) }
td { (e.wins) }
td { (e.matches) }
td { (fmt_points(e.points)) }
} }
} }
tbody { (rows) } })
.collect::<Vec<_>>();
let pager_view = pager(&p, busy, load);
let rated = if p.total == 1 {
"1 player rated".to_string()
} else {
format!("{} players rated", p.total)
};
view! {
p(class="text-secondary small mb-2") { (rated) }
div(class="table-responsive") {
table(
class="table table-striped table-hover \
align-middle"
) {
thead {
tr {
th { "#" }
th { "Player" }
th { "Elo" }
th { "Wins" }
th { "Matches" }
th { "Points" }
}
}
tbody { (rows) }
}
} }
(pager_view)
} }
} }
} }
@@ -135,3 +263,44 @@ pub fn LeaderboardPage() -> View {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn page_window_small_boards() {
// Boards with a single page (or none) have no pager at all.
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 0));
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 1));
assert_eq!(vec![Some(1), Some(2)], page_window(1, 2));
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(1, 3));
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(3, 3));
}
#[test]
fn page_window_ellipsizes_long_boards() {
assert_eq!(
vec![Some(1), Some(2), Some(3), None, Some(10)],
page_window(1, 10)
);
assert_eq!(
vec![
Some(1),
None,
Some(3),
Some(4),
Some(5),
Some(6),
Some(7),
None,
Some(10),
],
page_window(5, 10)
);
assert_eq!(
vec![Some(1), None, Some(8), Some(9), Some(10)],
page_window(10, 10)
);
}
}