Paginate the leaderboard from materialized player ratings
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:
+10
-8
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
"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"
|
||||
"+g2+dS"
|
||||
)
|
||||
@@ -33,15 +33,18 @@ itself ships no game.
|
||||
revalidate the token, so stale or duplicate deliveries are harmless.
|
||||
- `models.py` — `Match` (`game_type`, timestamps, game-specific 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,
|
||||
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.
|
||||
- `backfill_elo.py` — rebuild every rating from the match history:
|
||||
`python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
||||
- `backfill_elo.py` — rebuild every rating and aggregate from the match
|
||||
history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
||||
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
|
||||
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination,
|
||||
shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
|
||||
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and
|
||||
page-number pagination, shared OpenAPI fragments, the TortoiseORM
|
||||
lifecycle mixin.
|
||||
|
||||
## 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
|
||||
matches in chronological order and rewrites the ``player_rating`` table
|
||||
and each ``match_player.elo_delta`` from scratch. Run any time ratings
|
||||
need to be rebuilt::
|
||||
Ratings and aggregates 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 any time the leaderboard state needs to be rebuilt::
|
||||
|
||||
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 .stats import apply_elo
|
||||
from .stats import PlayerStats, apply_match_stats
|
||||
from .tortoise_mixin import TortoiseMixin
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
replayed = 0
|
||||
@@ -48,7 +48,20 @@ async def backfill_elo() -> int:
|
||||
),
|
||||
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:
|
||||
player.elo_delta = deltas[player.user_sub]
|
||||
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
|
||||
``sub`` to a seat, an optional team label, whether they won, the
|
||||
points they scored and their Elo delta.
|
||||
* :class:`PlayerRating` — current chess-style Elo rating of a player for
|
||||
one game type, updated transactionally with every finished match (see
|
||||
:mod:`tavolo.platform.elo`).
|
||||
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
|
||||
totals (wins, points, latest display name) of a player for one game
|
||||
type, updated transactionally with every finished match (see
|
||||
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -69,7 +70,7 @@ class MatchPlayer(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)
|
||||
# OIDC subject of the player; no local users table.
|
||||
@@ -78,9 +79,17 @@ class PlayerRating(Model):
|
||||
game_type = fields.CharField(max_length=32)
|
||||
rating = fields.IntField(default=INITIAL_RATING)
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
table = "player_rating"
|
||||
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.",
|
||||
},
|
||||
]
|
||||
|
||||
# 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"
|
||||
LIMIT_PARAM = "limit"
|
||||
PAGE_PARAM = "page"
|
||||
|
||||
|
||||
class CursorDecodeError(ValueError):
|
||||
"""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:
|
||||
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
|
||||
# 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)
|
||||
|
||||
|
||||
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
|
||||
# keyset; the cursor encodes exactly these fields.
|
||||
Sort = List[Tuple[str, str]]
|
||||
|
||||
@@ -16,11 +16,17 @@ from kaya.core import HttpContext, KayaApp
|
||||
from kaya.openapi import operation
|
||||
|
||||
from ..auth import current_user, require_auth
|
||||
from ..elo import INITIAL_RATING
|
||||
from ..http import extract_query_params, send_error, send_json
|
||||
from ..models import Match, MatchPlayer, PlayerRating
|
||||
from ..openapi import PAGINATION_PARAMETERS
|
||||
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
||||
from ..openapi import PAGE_PARAMETERS, PAGINATION_PARAMETERS
|
||||
from ..pagination import (
|
||||
CursorDecodeError,
|
||||
PageParamsError,
|
||||
paginate,
|
||||
parse_cursor_params,
|
||||
parse_page_params,
|
||||
)
|
||||
from tortoise.expressions import Q
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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).",
|
||||
}
|
||||
|
||||
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(
|
||||
platform: "Platform", query_string: str
|
||||
@@ -50,6 +65,33 @@ def _parse_game_type(
|
||||
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(
|
||||
match: Match, viewer: str, participants: List[MatchPlayer]
|
||||
) -> Dict[str, Any]:
|
||||
@@ -131,56 +173,87 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
|
||||
@app.GET("/api/leaderboard")
|
||||
@operation(summary="Global leaderboard",
|
||||
description="Elo rating, aggregated wins, matches played and "
|
||||
"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).",
|
||||
description="Elo rating, wins, matches played and points for "
|
||||
"every rated player of a game, sorted by Elo (the "
|
||||
"requested game_type, or the default game when "
|
||||
"the filter is absent). Page-number paginated; "
|
||||
"the response carries the caller's own rank when "
|
||||
"authenticated.",
|
||||
tags=["stats"],
|
||||
parameters=[GAME_TYPE_PARAMETER],
|
||||
parameters=[
|
||||
GAME_TYPE_PARAMETER,
|
||||
*PAGE_PARAMETERS,
|
||||
LEADERBOARD_FOCUS_PARAMETER,
|
||||
],
|
||||
responses={
|
||||
200: {"description": "The leaderboard"},
|
||||
400: {"description": "Unknown game_type"},
|
||||
200: {"description": "A page of the leaderboard"},
|
||||
400: {"description": "Unknown game_type or invalid parameters"},
|
||||
})
|
||||
async def leaderboard(ctx: HttpContext) -> None:
|
||||
game_type, error = _parse_game_type(platform, ctx.query_string)
|
||||
if error is not None:
|
||||
await send_error(ctx, 400, error)
|
||||
return
|
||||
queryset = MatchPlayer.all()
|
||||
if game_type is not None:
|
||||
queryset = queryset.filter(match__game_type=game_type)
|
||||
rows = await queryset
|
||||
# Ratings are per game type; without a filter show the default
|
||||
# game's.
|
||||
try:
|
||||
page, limit = parse_page_params(ctx.query_string)
|
||||
except PageParamsError as exc:
|
||||
await send_error(ctx, 400, str(exc))
|
||||
return
|
||||
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_rows = await PlayerRating.filter(game_type=rating_game)
|
||||
ratings = {row.user_sub: row.rating for row in rating_rows}
|
||||
aggregate: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
entry = aggregate.setdefault(
|
||||
row.user_sub,
|
||||
board = PlayerRating.filter(game_type=rating_game)
|
||||
total = await board.count()
|
||||
pages = (total + limit - 1) // limit
|
||||
current = min(page, pages) if pages else 1
|
||||
|
||||
# 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,
|
||||
"display_name": row.display_name,
|
||||
"matches": 0,
|
||||
"wins": 0,
|
||||
"points": 0,
|
||||
"elo": ratings.get(row.user_sub, INITIAL_RATING),
|
||||
},
|
||||
)
|
||||
entry["matches"] += 1
|
||||
entry["wins"] += 1 if row.won else 0
|
||||
entry["points"] += row.score
|
||||
# Keep the most recent display name seen.
|
||||
entry["display_name"] = row.display_name
|
||||
|
||||
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})
|
||||
"display_name": row.display_name or row.user_sub,
|
||||
"matches": row.matches_played,
|
||||
"wins": row.wins,
|
||||
"points": row.points,
|
||||
"elo": row.rating,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"page": current,
|
||||
"pages": pages,
|
||||
"total": total,
|
||||
"your_rank": your_rank,
|
||||
"your_page": your_page,
|
||||
})
|
||||
|
||||
@app.GET("/api/me/ratings")
|
||||
@operation(summary="My Elo ratings",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Called once when a session reaches the finished state (guarded by the
|
||||
``stats_saved`` flag on the session). The write is transactional so a
|
||||
match never appears with only some of its players. The same transaction
|
||||
also updates the participants' Elo ratings (see
|
||||
:mod:`tavolo.platform.elo`).
|
||||
also updates the participants' Elo ratings and leaderboard aggregates
|
||||
(see :mod:`tavolo.platform.elo`).
|
||||
|
||||
This module is game-independent: everything it persists comes from the
|
||||
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||
@@ -12,9 +12,10 @@ engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Sequence
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
@@ -24,21 +25,37 @@ from .engine import GameEngine, GameSession
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
async def apply_elo(
|
||||
game_type: str, teams: List[List[str]], winner_team: int
|
||||
@dataclass(frozen=True)
|
||||
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]:
|
||||
"""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;
|
||||
``winner_team`` is the index of the winning side. 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.
|
||||
players start at the initial rating. Alongside the Elo delta the
|
||||
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:
|
||||
raise ValueError("Elo rating requires exactly two teams")
|
||||
from .models import PlayerRating
|
||||
|
||||
stats = {player.user_sub: player for player in players}
|
||||
ratings: Dict[str, "PlayerRating"] = {}
|
||||
for subs in teams:
|
||||
for sub in subs:
|
||||
@@ -63,6 +80,13 @@ async def apply_elo(
|
||||
rating = ratings[sub]
|
||||
rating.rating += delta
|
||||
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()
|
||||
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)
|
||||
finished_at = session.finished_at or datetime.now(timezone.utc)
|
||||
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():
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
@@ -86,7 +119,9 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
||||
finished_at=finished_at,
|
||||
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:
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
|
||||
@@ -14,7 +14,14 @@ from datetime import datetime, timezone
|
||||
from typing import Any, List
|
||||
|
||||
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
|
||||
|
||||
@@ -77,5 +84,22 @@ class PaginateCursorTypesTest(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict
|
||||
from unittest import mock
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -78,6 +79,12 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
self.assertEqual(1, ratings["alice"].matches_played)
|
||||
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
||||
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.
|
||||
deltas = {
|
||||
@@ -136,6 +143,9 @@ async def _seed_two_matches(
|
||||
) -> None:
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
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(
|
||||
[
|
||||
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||
@@ -150,6 +160,7 @@ async def _seed_two_matches(
|
||||
result={"winner": winner, "plays": 3 + index},
|
||||
)
|
||||
for seat, sub in enumerate(("alice", "bob")):
|
||||
score = 2.0 + index if sub == winner else 1.0
|
||||
await MatchPlayer.create(
|
||||
id=uuid.uuid4(),
|
||||
match=match,
|
||||
@@ -158,8 +169,26 @@ async def _seed_two_matches(
|
||||
seat=seat,
|
||||
team="A" if seat == 0 else "B",
|
||||
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):
|
||||
@@ -252,13 +281,9 @@ class StatsRouteTest(unittest.TestCase):
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
# Alice outranks everyone despite Bob leading on points.
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="alice",
|
||||
game_type="dummy",
|
||||
rating=1600,
|
||||
matches_played=2,
|
||||
)
|
||||
await PlayerRating.filter(
|
||||
user_sub="alice", game_type="dummy"
|
||||
).update(rating=1600)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/leaderboard")
|
||||
@@ -266,7 +291,6 @@ class StatsRouteTest(unittest.TestCase):
|
||||
results = response.json()["results"]
|
||||
by_sub = {row["user_sub"]: row for row in results}
|
||||
self.assertEqual(1600, by_sub["alice"]["elo"])
|
||||
# Players without a rating row report the initial rating.
|
||||
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||
# Elo outranks wins/points.
|
||||
self.assertEqual("alice", results[0]["user_sub"])
|
||||
@@ -279,13 +303,9 @@ class StatsRouteTest(unittest.TestCase):
|
||||
await _seed_two_matches(tortoise_mixin)
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
with ctx:
|
||||
await PlayerRating.create(
|
||||
id=uuid.uuid4(),
|
||||
user_sub="bob",
|
||||
game_type="dummy",
|
||||
rating=1516,
|
||||
matches_played=1,
|
||||
)
|
||||
await PlayerRating.filter(
|
||||
user_sub="bob", game_type="dummy"
|
||||
).update(rating=1516)
|
||||
# Bob's rating in another game must not leak into the
|
||||
# dummy leaderboard.
|
||||
await PlayerRating.create(
|
||||
@@ -305,6 +325,97 @@ class StatsRouteTest(unittest.TestCase):
|
||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||
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 def test_my_matches_include_elo_delta(self) -> None:
|
||||
app, platform, tortoise_mixin = make_platform()
|
||||
|
||||
@@ -190,7 +190,12 @@ class ScopaWebSocketTest(unittest.TestCase):
|
||||
self.assertEqual(1, game["last_move"]["seat"])
|
||||
self.assertEqual(card, game["last_move"]["card"])
|
||||
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:
|
||||
self.assertEqual(5, len(game["table"]))
|
||||
|
||||
|
||||
@@ -133,11 +133,25 @@ class ScoponeStatsTest(unittest.TestCase):
|
||||
self.assertIn("your_elo_delta", results[0])
|
||||
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)
|
||||
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.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:
|
||||
|
||||
+18
-7
@@ -175,13 +175,24 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch the leaderboard for one game type: Elo, wins, matches and points
|
||||
/// are all scoped to that game.
|
||||
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
||||
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
/// Fetch one page of the leaderboard for a game. With `focus_me` the
|
||||
/// server returns the page holding the caller's own ranking (page 1 for
|
||||
/// anonymous or unrated callers).
|
||||
pub async fn leaderboard(
|
||||
game_type: &str,
|
||||
page: Option<u32>,
|
||||
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)?;
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -466,6 +466,10 @@ fn default_elo() -> i32 {
|
||||
1500
|
||||
}
|
||||
|
||||
fn default_page() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LeaderboardEntry {
|
||||
@@ -512,10 +516,30 @@ pub struct RatingsPage {
|
||||
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)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LeaderboardPage {
|
||||
#[serde(default)]
|
||||
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).
|
||||
@@ -1113,6 +1137,31 @@ mod tests {
|
||||
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 {
|
||||
serde_json::from_value(json!({
|
||||
"id": "scopone_scientifico",
|
||||
|
||||
+231
-62
@@ -1,14 +1,102 @@
|
||||
//! Per-game leaderboard page.
|
||||
//!
|
||||
//! Elo ratings are stored per game type, so the board is scoped to the
|
||||
//! game chosen in the dropdown: Elo, wins, matches and points all come
|
||||
//! from that game's matches only.
|
||||
//! Elo ratings and the leaderboard aggregates live in one
|
||||
//! `player_rating` row per player and game, so the board is
|
||||
//! 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 sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
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]
|
||||
pub fn LeaderboardPage() -> View {
|
||||
@@ -16,26 +104,33 @@ pub fn LeaderboardPage() -> View {
|
||||
let error = create_signal(Option::<String>::None);
|
||||
let game_types = create_signal(fallback_game_types());
|
||||
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
|
||||
// user has already switched away from is dropped.
|
||||
create_effect(move || {
|
||||
let load = move |target: Option<u32>, focus_me: bool| {
|
||||
let game_type = selected_game.get_clone();
|
||||
page.set(None);
|
||||
loading.set(true);
|
||||
spawn_local(async move {
|
||||
match api::leaderboard(&game_type).await {
|
||||
Ok(p) => {
|
||||
if selected_game.get_clone() == game_type {
|
||||
page.set(Some(p));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if selected_game.get_clone() == game_type {
|
||||
error.set(Some(e));
|
||||
}
|
||||
}
|
||||
let response = api::leaderboard(&game_type, target, focus_me).await;
|
||||
// A response for a game the user has already switched away
|
||||
// from is dropped (and must not clear the new game's spinner).
|
||||
if selected_game.get_clone() != game_type {
|
||||
return;
|
||||
}
|
||||
loading.set(false);
|
||||
match response {
|
||||
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 {
|
||||
@@ -80,53 +175,86 @@ pub fn LeaderboardPage() -> View {
|
||||
}
|
||||
}
|
||||
(toast(error))
|
||||
(move || match page.get_clone() {
|
||||
None => view! {
|
||||
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)) }
|
||||
}
|
||||
(move || {
|
||||
let busy = loading.get();
|
||||
match page.get_clone() {
|
||||
None => view! {
|
||||
div(class="d-flex justify-content-center p-5") {
|
||||
div(class="spinner-border text-warning", role="status") {
|
||||
span(class="visually-hidden") { "Loading…" }
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
view! {
|
||||
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" }
|
||||
}
|
||||
},
|
||||
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)| {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user