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:
woggioni committed 2026-10-09 08:35:03 +00:00
1 parent bf613eb1c4
commit 551c4c9c28
16 files changed
+818 -167

No files matched your search

+9 -6
View File
@@ -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()