Paginate the leaderboard from materialized player ratings
CI / Build and push docker image (push) Successful in 1m19s
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:
16 files changed
+818
-167
No files matched your search
+10
-8
@@ -251,8 +251,10 @@ aggregate with `max`, not `sum`.
|
|||||||
seat, game-defined team label (nullable), whether they won, the points
|
seat, game-defined team label (nullable), whether they won, the points
|
||||||
they scored, the Elo change the match produced (`elo_delta`) and
|
they scored, the Elo change the match produced (`elo_delta`) and
|
||||||
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
game-specific extras (`details` JSON). Unique per `(match, user_sub)`.
|
||||||
- `player_rating` — current Elo rating per `(user_sub, game_type)`, with
|
- `player_rating` — current Elo rating and leaderboard totals per
|
||||||
the number of rated matches played.
|
`(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
|
When a match ends, the engine's result is written transactionally to
|
||||||
Postgres (once, guarded by a flag on the session); the finished session
|
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
|
mean of its members, and the standard formula
|
||||||
`E = 1 / (1 + 10 ** ((R_opp - R_team) / 400))` with `K = 32` decides how
|
`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
|
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
|
a team, zero-sum between the two teams. Ratings and the leaderboard
|
||||||
transaction as the match result. To recompute every rating from the
|
aggregates update in the same transaction as the match result. To
|
||||||
recorded match history (e.g. to backfill matches recorded before ratings
|
rebuild every rating and aggregate from the recorded match history (e.g.
|
||||||
existed):
|
to backfill matches recorded before ratings existed):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
.venv/bin/python -m tavolo.platform.backfill_elo \
|
.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/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/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/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
|
## WebSocket protocol
|
||||||
|
|
||||||
@@ -560,7 +562,7 @@ packages/tavolo-platform/ # game-independent platform (own pyproject + tests
|
|||||||
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
|
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
|
||||||
├── elo.py # chess-style Elo math (1500 start, K=32)
|
├── elo.py # chess-style Elo math (1500 start, K=32)
|
||||||
├── stats.py # MatchResult -> Postgres persistence + Elo update
|
├── 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)
|
├── store.py # Redis / in-memory live-session store (+ deadline queue)
|
||||||
├── deadlines.py # connection-independent timeout scheduler
|
├── deadlines.py # connection-independent timeout scheduler
|
||||||
├── ws.py # WebSocket live-play endpoint (generic envelope)
|
├── 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"
|
||||||
|
)
|
||||||
@@ -33,15 +33,18 @@ itself ships no game.
|
|||||||
revalidate the token, so stale or duplicate deliveries are harmless.
|
revalidate the token, so stale or duplicate deliveries are harmless.
|
||||||
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
|
- `models.py` — `Match` (`game_type`, timestamps, game-specific JSON
|
||||||
`result`), `MatchPlayer` (seat, team, won, points, Elo delta, 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,
|
- `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.
|
- `elo.py` — chess-style Elo math generalized to two-team matches.
|
||||||
- `backfill_elo.py` — rebuild every rating from the match history:
|
- `backfill_elo.py` — rebuild every rating and aggregate from the match
|
||||||
`python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
history: `python -m tavolo.platform.backfill_elo --database-url postgres://…`.
|
||||||
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
|
- `auth.py`, `http.py`, `pagination.py`, `openapi.py`,
|
||||||
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset pagination,
|
`tortoise_mixin.py` — OIDC helpers, JSON helpers, keyset and
|
||||||
shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
|
page-number pagination, shared OpenAPI fragments, the TortoiseORM
|
||||||
|
lifecycle mixin.
|
||||||
|
|
||||||
## Adding a game
|
## 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
|
Ratings and aggregates are deterministic given the finished matches, so
|
||||||
matches in chronological order and rewrites the ``player_rating`` table
|
this replays all matches in chronological order and rewrites the
|
||||||
and each ``match_player.elo_delta`` from scratch. Run any time ratings
|
``player_rating`` table and each ``match_player.elo_delta`` from scratch.
|
||||||
need to be rebuilt::
|
Run any time the leaderboard state needs to be rebuilt::
|
||||||
|
|
||||||
python -m tavolo.platform.backfill_elo --database-url postgres://...
|
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 tortoise.transactions import in_transaction
|
||||||
|
|
||||||
from .stats import apply_elo
|
from .stats import PlayerStats, apply_match_stats
|
||||||
from .tortoise_mixin import TortoiseMixin
|
from .tortoise_mixin import TortoiseMixin
|
||||||
|
|
||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def backfill_elo() -> int:
|
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
|
from .models import Match, MatchPlayer, PlayerRating
|
||||||
|
|
||||||
replayed = 0
|
replayed = 0
|
||||||
@@ -48,7 +48,20 @@ async def backfill_elo() -> int:
|
|||||||
),
|
),
|
||||||
0,
|
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:
|
for player in players:
|
||||||
player.elo_delta = deltas[player.user_sub]
|
player.elo_delta = deltas[player.user_sub]
|
||||||
await player.save()
|
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
|
* :class:`MatchPlayer` — one row per participant, linking an OIDC
|
||||||
``sub`` to a seat, an optional team label, whether they won, the
|
``sub`` to a seat, an optional team label, whether they won, the
|
||||||
points they scored and their Elo delta.
|
points they scored and their Elo delta.
|
||||||
* :class:`PlayerRating` — current chess-style Elo rating of a player for
|
* :class:`PlayerRating` — current chess-style Elo rating and leaderboard
|
||||||
one game type, updated transactionally with every finished match (see
|
totals (wins, points, latest display name) of a player for one game
|
||||||
:mod:`tavolo.platform.elo`).
|
type, updated transactionally with every finished match (see
|
||||||
|
:mod:`tavolo.platform.elo` and :mod:`tavolo.platform.stats`).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class MatchPlayer(Model):
|
|||||||
|
|
||||||
|
|
||||||
class PlayerRating(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)
|
id = fields.UUIDField(pk=True)
|
||||||
# OIDC subject of the player; no local users table.
|
# OIDC subject of the player; no local users table.
|
||||||
@@ -78,9 +79,17 @@ class PlayerRating(Model):
|
|||||||
game_type = fields.CharField(max_length=32)
|
game_type = fields.CharField(max_length=32)
|
||||||
rating = fields.IntField(default=INITIAL_RATING)
|
rating = fields.IntField(default=INITIAL_RATING)
|
||||||
matches_played = fields.IntField(default=0)
|
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)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "player_rating"
|
table = "player_rating"
|
||||||
unique_together = (("user_sub", "game_type"),)
|
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.",
|
"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"
|
CURSOR_PARAM = "cursor"
|
||||||
LIMIT_PARAM = "limit"
|
LIMIT_PARAM = "limit"
|
||||||
|
PAGE_PARAM = "page"
|
||||||
|
|
||||||
|
|
||||||
class CursorDecodeError(ValueError):
|
class CursorDecodeError(ValueError):
|
||||||
"""Raised when the ``cursor`` query param cannot be decoded."""
|
"""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:
|
def encode_cursor(values: Dict[str, Any]) -> str:
|
||||||
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
|
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
|
||||||
# datetime-typed sort fields (e.g. finished_at for match history).
|
# 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)
|
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
|
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
|
||||||
# keyset; the cursor encodes exactly these fields.
|
# keyset; the cursor encodes exactly these fields.
|
||||||
Sort = List[Tuple[str, str]]
|
Sort = List[Tuple[str, str]]
|
||||||
|
|||||||
@@ -16,11 +16,17 @@ from kaya.core import HttpContext, KayaApp
|
|||||||
from kaya.openapi import operation
|
from kaya.openapi import operation
|
||||||
|
|
||||||
from ..auth import current_user, require_auth
|
from ..auth import current_user, require_auth
|
||||||
from ..elo import INITIAL_RATING
|
|
||||||
from ..http import extract_query_params, send_error, send_json
|
from ..http import extract_query_params, send_error, send_json
|
||||||
from ..models import Match, MatchPlayer, PlayerRating
|
from ..models import Match, MatchPlayer, PlayerRating
|
||||||
from ..openapi import PAGINATION_PARAMETERS
|
from ..openapi import PAGE_PARAMETERS, PAGINATION_PARAMETERS
|
||||||
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
|
from ..pagination import (
|
||||||
|
CursorDecodeError,
|
||||||
|
PageParamsError,
|
||||||
|
paginate,
|
||||||
|
parse_cursor_params,
|
||||||
|
parse_page_params,
|
||||||
|
)
|
||||||
|
from tortoise.expressions import Q
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..mixin import Platform
|
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).",
|
"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(
|
def _parse_game_type(
|
||||||
platform: "Platform", query_string: str
|
platform: "Platform", query_string: str
|
||||||
@@ -50,6 +65,33 @@ def _parse_game_type(
|
|||||||
return game_type, None
|
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(
|
def _serialize_match(
|
||||||
match: Match, viewer: str, participants: List[MatchPlayer]
|
match: Match, viewer: str, participants: List[MatchPlayer]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -131,56 +173,87 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
|
|
||||||
@app.GET("/api/leaderboard")
|
@app.GET("/api/leaderboard")
|
||||||
@operation(summary="Global leaderboard",
|
@operation(summary="Global leaderboard",
|
||||||
description="Elo rating, aggregated wins, matches played and "
|
description="Elo rating, wins, matches played and points for "
|
||||||
"points for every player with at least one "
|
"every rated player of a game, sorted by Elo (the "
|
||||||
"finished match. Sorted by Elo rating (the rating "
|
"requested game_type, or the default game when "
|
||||||
"for the requested game_type, or the default game "
|
"the filter is absent). Page-number paginated; "
|
||||||
"when the filter is absent).",
|
"the response carries the caller's own rank when "
|
||||||
|
"authenticated.",
|
||||||
tags=["stats"],
|
tags=["stats"],
|
||||||
parameters=[GAME_TYPE_PARAMETER],
|
parameters=[
|
||||||
|
GAME_TYPE_PARAMETER,
|
||||||
|
*PAGE_PARAMETERS,
|
||||||
|
LEADERBOARD_FOCUS_PARAMETER,
|
||||||
|
],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "The leaderboard"},
|
200: {"description": "A page of the leaderboard"},
|
||||||
400: {"description": "Unknown game_type"},
|
400: {"description": "Unknown game_type or invalid parameters"},
|
||||||
})
|
})
|
||||||
async def leaderboard(ctx: HttpContext) -> None:
|
async def leaderboard(ctx: HttpContext) -> None:
|
||||||
game_type, error = _parse_game_type(platform, ctx.query_string)
|
game_type, error = _parse_game_type(platform, ctx.query_string)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
await send_error(ctx, 400, error)
|
await send_error(ctx, 400, error)
|
||||||
return
|
return
|
||||||
queryset = MatchPlayer.all()
|
try:
|
||||||
if game_type is not None:
|
page, limit = parse_page_params(ctx.query_string)
|
||||||
queryset = queryset.filter(match__game_type=game_type)
|
except PageParamsError as exc:
|
||||||
rows = await queryset
|
await send_error(ctx, 400, str(exc))
|
||||||
# Ratings are per game type; without a filter show the default
|
return
|
||||||
# game's.
|
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_game = game_type or platform.registry.default.id
|
||||||
rating_rows = await PlayerRating.filter(game_type=rating_game)
|
board = PlayerRating.filter(game_type=rating_game)
|
||||||
ratings = {row.user_sub: row.rating for row in rating_rows}
|
total = await board.count()
|
||||||
aggregate: Dict[str, Dict[str, Any]] = {}
|
pages = (total + limit - 1) // limit
|
||||||
for row in rows:
|
current = min(page, pages) if pages else 1
|
||||||
entry = aggregate.setdefault(
|
|
||||||
row.user_sub,
|
# 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,
|
"user_sub": row.user_sub,
|
||||||
"display_name": row.display_name,
|
"display_name": row.display_name or row.user_sub,
|
||||||
"matches": 0,
|
"matches": row.matches_played,
|
||||||
"wins": 0,
|
"wins": row.wins,
|
||||||
"points": 0,
|
"points": row.points,
|
||||||
"elo": ratings.get(row.user_sub, INITIAL_RATING),
|
"elo": row.rating,
|
||||||
},
|
}
|
||||||
)
|
for row in rows
|
||||||
entry["matches"] += 1
|
],
|
||||||
entry["wins"] += 1 if row.won else 0
|
"page": current,
|
||||||
entry["points"] += row.score
|
"pages": pages,
|
||||||
# Keep the most recent display name seen.
|
"total": total,
|
||||||
entry["display_name"] = row.display_name
|
"your_rank": your_rank,
|
||||||
|
"your_page": your_page,
|
||||||
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})
|
|
||||||
|
|
||||||
@app.GET("/api/me/ratings")
|
@app.GET("/api/me/ratings")
|
||||||
@operation(summary="My Elo ratings",
|
@operation(summary="My Elo ratings",
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
Called once when a session reaches the finished state (guarded by the
|
Called once when a session reaches the finished state (guarded by the
|
||||||
``stats_saved`` flag on the session). The write is transactional so a
|
``stats_saved`` flag on the session). The write is transactional so a
|
||||||
match never appears with only some of its players. The same transaction
|
match never appears with only some of its players. The same transaction
|
||||||
also updates the participants' Elo ratings (see
|
also updates the participants' Elo ratings and leaderboard aggregates
|
||||||
:mod:`tavolo.platform.elo`).
|
(see :mod:`tavolo.platform.elo`).
|
||||||
|
|
||||||
This module is game-independent: everything it persists comes from the
|
This module is game-independent: everything it persists comes from the
|
||||||
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
engine's :class:`~tavolo.platform.engine.MatchResult`.
|
||||||
@@ -12,9 +12,10 @@ engine's :class:`~tavolo.platform.engine.MatchResult`.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Dict, List
|
from typing import Dict, List, Sequence
|
||||||
|
|
||||||
from tortoise.transactions import in_transaction
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
@@ -24,21 +25,37 @@ from .engine import GameEngine, GameSession
|
|||||||
log = getLogger(__name__)
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def apply_elo(
|
@dataclass(frozen=True)
|
||||||
game_type: str, teams: List[List[str]], winner_team: int
|
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]:
|
) -> 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;
|
``teams`` groups the participants' subs into the two opposing sides;
|
||||||
``winner_team`` is the index of the winning side. Ratings are read
|
``winner_team`` is the index of the winning side. Ratings are read
|
||||||
from (and written back to) the ``player_rating`` table; unrated
|
from (and written back to) the ``player_rating`` table; unrated
|
||||||
players start at the initial rating. Returns the per-player delta.
|
players start at the initial rating. Alongside the Elo delta the
|
||||||
Must be called inside a transaction.
|
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:
|
if len(teams) != 2:
|
||||||
raise ValueError("Elo rating requires exactly two teams")
|
raise ValueError("Elo rating requires exactly two teams")
|
||||||
from .models import PlayerRating
|
from .models import PlayerRating
|
||||||
|
|
||||||
|
stats = {player.user_sub: player for player in players}
|
||||||
ratings: Dict[str, "PlayerRating"] = {}
|
ratings: Dict[str, "PlayerRating"] = {}
|
||||||
for subs in teams:
|
for subs in teams:
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
@@ -63,6 +80,13 @@ async def apply_elo(
|
|||||||
rating = ratings[sub]
|
rating = ratings[sub]
|
||||||
rating.rating += delta
|
rating.rating += delta
|
||||||
rating.matches_played += 1
|
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()
|
await rating.save()
|
||||||
return deltas
|
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)
|
started_at = session.created_at or datetime.now(timezone.utc)
|
||||||
finished_at = session.finished_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}
|
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():
|
async with in_transaction():
|
||||||
match = await Match.create(
|
match = await Match.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
@@ -86,7 +119,9 @@ async def save_match_result(session: GameSession, engine: GameEngine) -> None:
|
|||||||
finished_at=finished_at,
|
finished_at=finished_at,
|
||||||
result=result.summary,
|
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:
|
for player in result.players:
|
||||||
await MatchPlayer.create(
|
await MatchPlayer.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any, List
|
from typing import Any, List
|
||||||
|
|
||||||
from tavolo.platform.models import Match
|
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
|
from helpers import async_test
|
||||||
|
|
||||||
@@ -77,5 +84,22 @@ class PaginateCursorTypesTest(unittest.TestCase):
|
|||||||
self.assertEqual(match_id, id_values[0])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -78,6 +79,12 @@ class SaveMatchResultTest(unittest.TestCase):
|
|||||||
self.assertEqual(1, ratings["alice"].matches_played)
|
self.assertEqual(1, ratings["alice"].matches_played)
|
||||||
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
|
||||||
self.assertEqual(1, ratings["bob"].matches_played)
|
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.
|
# The per-match delta is recorded on each participation row.
|
||||||
deltas = {
|
deltas = {
|
||||||
@@ -136,6 +143,9 @@ async def _seed_two_matches(
|
|||||||
) -> None:
|
) -> None:
|
||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
with ctx:
|
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(
|
for index, (winner, finished) in enumerate(
|
||||||
[
|
[
|
||||||
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
|
||||||
@@ -150,6 +160,7 @@ async def _seed_two_matches(
|
|||||||
result={"winner": winner, "plays": 3 + index},
|
result={"winner": winner, "plays": 3 + index},
|
||||||
)
|
)
|
||||||
for seat, sub in enumerate(("alice", "bob")):
|
for seat, sub in enumerate(("alice", "bob")):
|
||||||
|
score = 2.0 + index if sub == winner else 1.0
|
||||||
await MatchPlayer.create(
|
await MatchPlayer.create(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
match=match,
|
match=match,
|
||||||
@@ -158,8 +169,26 @@ async def _seed_two_matches(
|
|||||||
seat=seat,
|
seat=seat,
|
||||||
team="A" if seat == 0 else "B",
|
team="A" if seat == 0 else "B",
|
||||||
won=(sub == winner),
|
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):
|
class StatsRouteTest(unittest.TestCase):
|
||||||
@@ -252,13 +281,9 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
with ctx:
|
with ctx:
|
||||||
# Alice outranks everyone despite Bob leading on points.
|
# Alice outranks everyone despite Bob leading on points.
|
||||||
await PlayerRating.create(
|
await PlayerRating.filter(
|
||||||
id=uuid.uuid4(),
|
user_sub="alice", game_type="dummy"
|
||||||
user_sub="alice",
|
).update(rating=1600)
|
||||||
game_type="dummy",
|
|
||||||
rating=1600,
|
|
||||||
matches_played=2,
|
|
||||||
)
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
response = await client.get("/api/leaderboard")
|
response = await client.get("/api/leaderboard")
|
||||||
@@ -266,7 +291,6 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
results = response.json()["results"]
|
results = response.json()["results"]
|
||||||
by_sub = {row["user_sub"]: row for row in results}
|
by_sub = {row["user_sub"]: row for row in results}
|
||||||
self.assertEqual(1600, by_sub["alice"]["elo"])
|
self.assertEqual(1600, by_sub["alice"]["elo"])
|
||||||
# Players without a rating row report the initial rating.
|
|
||||||
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
|
||||||
# Elo outranks wins/points.
|
# Elo outranks wins/points.
|
||||||
self.assertEqual("alice", results[0]["user_sub"])
|
self.assertEqual("alice", results[0]["user_sub"])
|
||||||
@@ -279,13 +303,9 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
await _seed_two_matches(tortoise_mixin)
|
await _seed_two_matches(tortoise_mixin)
|
||||||
ctx = await use_db(tortoise_mixin)
|
ctx = await use_db(tortoise_mixin)
|
||||||
with ctx:
|
with ctx:
|
||||||
await PlayerRating.create(
|
await PlayerRating.filter(
|
||||||
id=uuid.uuid4(),
|
user_sub="bob", game_type="dummy"
|
||||||
user_sub="bob",
|
).update(rating=1516)
|
||||||
game_type="dummy",
|
|
||||||
rating=1516,
|
|
||||||
matches_played=1,
|
|
||||||
)
|
|
||||||
# Bob's rating in another game must not leak into the
|
# Bob's rating in another game must not leak into the
|
||||||
# dummy leaderboard.
|
# dummy leaderboard.
|
||||||
await PlayerRating.create(
|
await PlayerRating.create(
|
||||||
@@ -305,6 +325,97 @@ class StatsRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
|
||||||
self.assertEqual("second", platform.registry.all()[1].id)
|
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_test
|
||||||
async def test_my_matches_include_elo_delta(self) -> None:
|
async def test_my_matches_include_elo_delta(self) -> None:
|
||||||
app, platform, tortoise_mixin = make_platform()
|
app, platform, tortoise_mixin = make_platform()
|
||||||
|
|||||||
@@ -190,7 +190,12 @@ class ScopaWebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual(1, game["last_move"]["seat"])
|
self.assertEqual(1, game["last_move"]["seat"])
|
||||||
self.assertEqual(card, game["last_move"]["card"])
|
self.assertEqual(card, game["last_move"]["card"])
|
||||||
if options:
|
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:
|
else:
|
||||||
self.assertEqual(5, len(game["table"]))
|
self.assertEqual(5, len(game["table"]))
|
||||||
|
|
||||||
|
|||||||
@@ -133,11 +133,25 @@ class ScoponeStatsTest(unittest.TestCase):
|
|||||||
self.assertIn("your_elo_delta", results[0])
|
self.assertIn("your_elo_delta", results[0])
|
||||||
self.assertEqual(4, len(results[0]["players"]))
|
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)
|
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.assertEqual(4, len(by_sub))
|
||||||
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
|
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:
|
async def _finished_briscola_session() -> GameSession:
|
||||||
|
|||||||
+18
-7
@@ -175,13 +175,24 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
|||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the leaderboard for one game type: Elo, wins, matches and points
|
/// Fetch one page of the leaderboard for a game. With `focus_me` the
|
||||||
/// are all scoped to that game.
|
/// server returns the page holding the caller's own ranking (page 1 for
|
||||||
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
/// anonymous or unrated callers).
|
||||||
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}"))
|
pub async fn leaderboard(
|
||||||
.send()
|
game_type: &str,
|
||||||
.await
|
page: Option<u32>,
|
||||||
.map_err(|e| e.to_string())?;
|
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)?;
|
check(&resp)?;
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -466,6 +466,10 @@ fn default_elo() -> i32 {
|
|||||||
1500
|
1500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_page() -> u32 {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct LeaderboardEntry {
|
pub struct LeaderboardEntry {
|
||||||
@@ -512,10 +516,30 @@ pub struct RatingsPage {
|
|||||||
pub results: Vec<PlayerRating>,
|
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)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct LeaderboardPage {
|
pub struct LeaderboardPage {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub results: Vec<LeaderboardEntry>,
|
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).
|
/// A card game the platform can host (GET /api/game-types).
|
||||||
@@ -1113,6 +1137,31 @@ mod tests {
|
|||||||
assert_eq!(1500, e.elo);
|
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 {
|
fn scopone_game_type() -> GameTypeInfo {
|
||||||
serde_json::from_value(json!({
|
serde_json::from_value(json!({
|
||||||
"id": "scopone_scientifico",
|
"id": "scopone_scientifico",
|
||||||
|
|||||||
+231
-62
@@ -1,14 +1,102 @@
|
|||||||
//! Per-game leaderboard page.
|
//! Per-game leaderboard page.
|
||||||
//!
|
//!
|
||||||
//! Elo ratings are stored per game type, so the board is scoped to the
|
//! Elo ratings and the leaderboard aggregates live in one
|
||||||
//! game chosen in the dropdown: Elo, wins, matches and points all come
|
//! `player_rating` row per player and game, so the board is
|
||||||
//! from that game's matches only.
|
//! 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 wasm_bindgen_futures::spawn_local;
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::toast::toast;
|
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]
|
#[component]
|
||||||
pub fn LeaderboardPage() -> View {
|
pub fn LeaderboardPage() -> View {
|
||||||
@@ -16,26 +104,33 @@ pub fn LeaderboardPage() -> View {
|
|||||||
let error = create_signal(Option::<String>::None);
|
let error = create_signal(Option::<String>::None);
|
||||||
let game_types = create_signal(fallback_game_types());
|
let game_types = create_signal(fallback_game_types());
|
||||||
let selected_game = create_signal("scopone_scientifico".to_string());
|
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
|
let load = move |target: Option<u32>, focus_me: bool| {
|
||||||
// user has already switched away from is dropped.
|
|
||||||
create_effect(move || {
|
|
||||||
let game_type = selected_game.get_clone();
|
let game_type = selected_game.get_clone();
|
||||||
page.set(None);
|
loading.set(true);
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::leaderboard(&game_type).await {
|
let response = api::leaderboard(&game_type, target, focus_me).await;
|
||||||
Ok(p) => {
|
// A response for a game the user has already switched away
|
||||||
if selected_game.get_clone() == game_type {
|
// from is dropped (and must not clear the new game's spinner).
|
||||||
page.set(Some(p));
|
if selected_game.get_clone() != game_type {
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
loading.set(false);
|
||||||
if selected_game.get_clone() == game_type {
|
match response {
|
||||||
error.set(Some(e));
|
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 {
|
spawn_local(async move {
|
||||||
@@ -80,53 +175,86 @@ pub fn LeaderboardPage() -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(toast(error))
|
(toast(error))
|
||||||
(move || match page.get_clone() {
|
(move || {
|
||||||
None => view! {
|
let busy = loading.get();
|
||||||
div(class="d-flex justify-content-center p-5") {
|
match page.get_clone() {
|
||||||
div(class="spinner-border text-warning", role="status") {
|
None => view! {
|
||||||
span(class="visually-hidden") { "Loading…" }
|
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)) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.collect::<Vec<_>>();
|
},
|
||||||
view! {
|
Some(p) if p.results.is_empty() => view! {
|
||||||
div(class="table-responsive") {
|
p(class="text-secondary text-center p-5") {
|
||||||
table(class="table table-striped table-hover align-middle") {
|
"No ratings yet — finish a match to appear here."
|
||||||
thead {
|
}
|
||||||
tr {
|
},
|
||||||
th { "#" }
|
Some(p) => {
|
||||||
th { "Player" }
|
let rows = p
|
||||||
th { "Elo" }
|
.results
|
||||||
th { "Wins" }
|
.iter()
|
||||||
th { "Matches" }
|
.cloned()
|
||||||
th { "Points" }
|
.enumerate()
|
||||||
|
.map(|(i, e)| {
|
||||||
|
let rank = (p.page.saturating_sub(1))
|
||||||
|
* LEADERBOARD_PAGE_SIZE
|
||||||
|
+ i as u32
|
||||||
|
+ 1;
|
||||||
|
let is_me = p.your_rank == Some(rank);
|
||||||
|
let cls = if is_me {
|
||||||
|
"table-active fw-semibold"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
tr(class=cls) {
|
||||||
|
td { (rank) }
|
||||||
|
td {
|
||||||
|
(e.display_name.clone())
|
||||||
|
(is_me.then(|| view! {
|
||||||
|
span(
|
||||||
|
class="badge text-bg-warning ms-2"
|
||||||
|
) {
|
||||||
|
"you"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
td { (e.elo) }
|
||||||
|
td { (e.wins) }
|
||||||
|
td { (e.matches) }
|
||||||
|
td { (fmt_points(e.points)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tbody { (rows) }
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let pager_view = pager(&p, busy, load);
|
||||||
|
let rated = if p.total == 1 {
|
||||||
|
"1 player rated".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{} players rated", p.total)
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
p(class="text-secondary small mb-2") { (rated) }
|
||||||
|
div(class="table-responsive") {
|
||||||
|
table(
|
||||||
|
class="table table-striped table-hover \
|
||||||
|
align-middle"
|
||||||
|
) {
|
||||||
|
thead {
|
||||||
|
tr {
|
||||||
|
th { "#" }
|
||||||
|
th { "Player" }
|
||||||
|
th { "Elo" }
|
||||||
|
th { "Wins" }
|
||||||
|
th { "Matches" }
|
||||||
|
th { "Points" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody { (rows) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
(pager_view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,3 +263,44 @@ pub fn LeaderboardPage() -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_window_small_boards() {
|
||||||
|
// Boards with a single page (or none) have no pager at all.
|
||||||
|
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 0));
|
||||||
|
assert_eq!(Vec::<Option<u32>>::new(), page_window(1, 1));
|
||||||
|
assert_eq!(vec![Some(1), Some(2)], page_window(1, 2));
|
||||||
|
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(1, 3));
|
||||||
|
assert_eq!(vec![Some(1), Some(2), Some(3)], page_window(3, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_window_ellipsizes_long_boards() {
|
||||||
|
assert_eq!(
|
||||||
|
vec![Some(1), Some(2), Some(3), None, Some(10)],
|
||||||
|
page_window(1, 10)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vec![
|
||||||
|
Some(1),
|
||||||
|
None,
|
||||||
|
Some(3),
|
||||||
|
Some(4),
|
||||||
|
Some(5),
|
||||||
|
Some(6),
|
||||||
|
Some(7),
|
||||||
|
None,
|
||||||
|
Some(10),
|
||||||
|
],
|
||||||
|
page_window(5, 10)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vec![Some(1), None, Some(8), Some(9), Some(10)],
|
||||||
|
page_window(10, 10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user