Paginate the leaderboard from materialized player ratings
CI / Build and push docker image (push) Successful in 1m19s

Store each player's leaderboard totals (wins, accumulated points and the
latest display name) on player_rating alongside the Elo rating, updated
in the same transaction as the match, so the board is a single indexed
table query instead of aggregating every participation on each request.

GET /api/leaderboard is now page-number paginated (page/limit) and
returns the page, page count and total, plus the caller's own rank and
page when authenticated; focus=me returns the caller's page directly.
The migration adds the columns, backfills them from the match history
(inserting rows for players from pre-Elo matches at the initial rating)
and replaces the index. backfill_elo rebuilds the aggregates too.

The web page opens on the page containing the signed-in player, marks
their row and adds a numbered footer pager.
This commit is contained in:
2026-10-09 16:42:47 +08:00
parent ae7ab5ce60
commit 1ab748a34f
16 changed files with 818 additions and 167 deletions
+10 -8
View File
@@ -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 = (
"eJztmm1v4jgQgP9KlE89qVcBfdnV6XRSoFTLLZQK6N1pq1VkEkOtGjubOMeiXv/72c67k/"
"DSEgoVH7ZLxh7HeSYez4zzrM+oDbF31gPMetR/0551AmaQ/8g2nGo6cJxELAQMjHHQM+4y"
"9pgLLMaFE4A9yEU29CwXOQxRIroamkVnDoYM2ppU0+hEowSK/9gj1Fw4RR6DLm+e8nlobO"
"FA70yMbVOLD47I9G3D+AT98KHJ6BTyji4f7OE7FyNiw5/Qiy6dJ3OCILYzQJAtBpByUwwo"
"ZPf3nesb2VNMcWxaFPszkvR2FuyRkri77yP7TOiItikk0AX8EVK4iI9xiDUSBTPmAub6MJ"
"6qnQhsOAE+FtD13yc+sQRrTd5J/Ln4I5xaqptp3vZH5rA9Mk09ZyMxBYV3KLIoEfZFhAlQ"
"zy/BuAkQKdXFDVpfjMHJ+dUvEgH12NSVjRKX/iIVAQOBqoSeUBb2CnjlYLcegVsMO6OkMO"
"dTfg3tSLAEd0SqArZ8Tf00MSRTJhbleWMJ67+MQYC7IXFTvgCDZXkbtjRkk6CeUPYYcDkL"
"E7A85mtOiaEZLEad1VRY26HqWfRjO+QTZ7ID9EtQjzq99nBk9O7E8DPP+4ElL2PUFi0NKV"
"0o0pMrxSzxINrfndEXTVxq3/q3bXWtxP1G33QxJ+AzahI6N4GdZhKJI1HGzBNEkPf4Kjsr"
"qkdD77Wh+VCCTc7Gfw77t8X2TTRU0yKLaf9pmG+gVRk1tU+NfYQZIt6ZuO17bFWCUMbKkd"
"s86Rn/qB611e03VfOJAZrcvYq4YfKU2tOEYAyspzlwbTPTktjNwWABXS9vuGaoePN1ADGQ"
"z5s3VTpEu5MjHe4yTKThViuB0gYtI5pvmjVmqgQQMJWPJO4t7lSArCzoTYiuCH1NJ+m5Mg"
"K+49snspAjbRqFrb4HXQ0R+VsOmQ95N9AriHEfkiBd6JieP9a/HwPffQp8Y7tsEPemdT5e"
"2Nu4vFwj7uW9SgNf2ZaNfG3kieVqyusNUKt6VeHeqe9VeNdq6/Cu1cp5izYl04BFsedwBj"
"DuEFaSY8DCoFPsDQfIeCom8et549PVZ94q5yguPi1hPewZ3W7ndqSwZBDMNnlno/6velfD"
"db83GCvOiOdBhKVEYZRiCEgx3XlhTDbmKgf4li7B2ez3u5kIudkZKVzve8324KQucfNOiE"
"lx/gX2LOoWeN0bTEGZK4g0FMwToVIV59ruIV/375vdtnY3aLc6w06YksQppmzMwh20ja5C"
"F2LK54wZ2NTfZhRf5XT3zVlsz+fakAGECzK08tQ6pXLMrbeSW6cNEqRcm+UkaZ1tZib767"
"RXJCK5YoXCt8BHcz+MpuQrXEjEHT4RQKwi16weHB0m1Fw9gotdMI/T48w7xZ+ePzMMPHPL"
"GLaM67b+Ul4AqrK0EZQtBoAFD5mrbWTaT5cVN4KyhukmXVdWN1q+60LCtDamWqCnAWJrPI"
"iyoTum/OE0RhnXjSoYwT00h/8Thzj5usdWRiysiKST5+QASa2KPGQPlxIYc+5Zg9Ul35zT"
"8I2AXlAOsvO1lmNx5Vhc2Z8UqpLqyv6e3n6oZDXxQlnGpRF+orC7mkr9slZVIhXG9436xa"
"eLz+dXF3GQH0uWRfr5ID/vudfkmlfcHd9DgRttlGsijffVI0gFZBJprF0+SVSO9ZOV9ZPD"
"OxrQq4rCdnAw4Dvi25HXfJqS1fyAX6boLs9u+gQv9PAovBz+nn+pEh/kr/llRJWJsQFdVP"
"yNa9hyuiwZBkmfVVlwuZ23/PVp6R5amCYW7KBhrve2/LD6Ou4bd9DytPBf6Hqo6OCn3Nmn"
"VD5gqlJJUigW1QaEw+4fkG59rX20vmQfref3UX5HBslG3/6lVHZ/QPE+9fD3+Mxv+5vZy/"
"+g2+dS"
)
+9 -6
View File
@@ -33,15 +33,18 @@ itself ships no game.
revalidate the token, so stale or duplicate deliveries are harmless.
- `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()
+6 -1
View File
@@ -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"]))
+16 -2
View File
@@ -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
View File
@@ -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())
}
+49
View File
@@ -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
View File
@@ -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)
);
}
}