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:
1 parent
bf613eb1c4
commit
551c4c9c28
16 files changed
+818
-167
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user