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
@@ -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