diff --git a/server/README.md b/server/README.md index 39c783c..338862d 100644 --- a/server/README.md +++ b/server/README.md @@ -133,12 +133,31 @@ loggers: scoped per game), both teams' final scores, winner, target score, hands played, start/finish timestamps. - `match_player` — one row per participant: the OIDC `sub`, display name, - seat, team and whether they won. Unique per `(match, user_sub)`. + seat, team, whether they won and the Elo change the match produced + (`elo_delta`). Unique per `(match, user_sub)`. +- `player_rating` — current Elo rating per `(user_sub, game_type)`, with + the number of rated matches played. When a match ends, the result is written transactionally to Postgres (once, guarded by a flag on the Redis state); the finished state stays in Redis until its TTL expires so clients can still fetch the final board. +### Elo ratings + +Players carry a chess-style Elo rating per game type (`tavolo.elo`): +everyone starts at 1500, a team's rating is the mean of its two 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 both members of a team, zero-sum between 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): + +```sh +DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \ + .venv/bin/python -m tavolo.backfill_elo +``` + ## REST API All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, @@ -150,8 +169,9 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, | `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | -| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) | -| `GET` | `/api/leaderboard` | Aggregated wins / matches / team points per player (`?game_type=`) | +| `GET` | `/api/me/matches` | Cursor-paginated match history with final scores 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 / team points per player, sorted by Elo (`?game_type=`) | ## WebSocket protocol @@ -271,8 +291,10 @@ src/tavolo/ ├── openapi.py # shared OpenAPI parameter fragments ├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket) ├── aerich_config.py # aerich CLI configuration -├── models.py # Match, MatchPlayer (Postgres) -├── stats.py # finished match -> Postgres persistence +├── models.py # Match, MatchPlayer, PlayerRating (Postgres) +├── elo.py # chess-style Elo math (1500 start, K=32) +├── stats.py # finished match -> Postgres persistence + Elo update +├── backfill_elo.py # recompute all ratings from the match history ├── store.py # Redis / in-memory live-game store (+ deadline queue) ├── deadlines.py # connection-independent timeout scheduler ├── ws.py # WebSocket live-play endpoint @@ -283,5 +305,5 @@ src/tavolo/ └── routes/ ├── health.py # GET /api/health ├── games.py # lobby: create / join / snapshot - └── stats.py # match history + leaderboard + └── stats.py # match history + leaderboard + Elo ratings ``` diff --git a/server/migrations/models/2_20260918120620_elo.py b/server/migrations/models/2_20260918120620_elo.py new file mode 100644 index 0000000..6d71634 --- /dev/null +++ b/server/migrations/models/2_20260918120620_elo.py @@ -0,0 +1,55 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "player_rating" ( + "id" UUID NOT NULL PRIMARY KEY, + "user_sub" VARCHAR(255) NOT NULL, + "game_type" VARCHAR(32) NOT NULL, + "rating" INT NOT NULL, + "matches_played" INT NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL, + CONSTRAINT "uid_player_rati_user_su_655b53" UNIQUE ("user_sub", "game_type") +); +CREATE INDEX IF NOT EXISTS "idx_player_rati_game_ty_3326d1" ON "player_rating" ("game_type", "rating"); +COMMENT ON TABLE "player_rating" IS 'Current Elo rating of one player for one game type.'; + ALTER TABLE "match_player" ADD "elo_delta" SMALLINT;""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "match_player" DROP COLUMN "elo_delta"; + DROP TABLE IF EXISTS "player_rating";""" + + +MODELS_STATE = ( + "eJztmltv4jgUgP9KlKeuNFsBvY1Wq5WAUg07pVSF7q6mqiyTGLAa7IztbAd1+9/Xdm7EuR" + "RoYUrFyww59nHsz87xufTJnlEXefywB4UztX+znmwCZ0j+yDZ8smzo+6lYCQQceWHPpMuI" + "CwYdIYVj6HEkRS7iDsO+wJSork3LoTPfQwK5llaz6NiiBKn/xBRZDE0wF4jJ5omchyXmPu" + "KHamyXOnJwTCavGyYg+HuAgKATJDsyOdjdvRRj4qIfiMeP/gMYY+S5GSDYVQNoOVADKtnt" + "bff8QvdUUxwBh3rBjKS9/bmYUpJ0DwLsHiod1TZBBDEol7CAiwSeF2GNReGMpUCwACVTdV" + "OBi8Yw8BR0+/dxQBzF2tJvUv8c/xFNbaEbAFf9IRh0hgDYuT1SUzB4RyKHErW/mAgF6uk5" + "HDcFoqW2ekH7S/Pm4Oj0F42AcjFhulHjsp+1IhQwVNXQU8pqv0JeOdjtKWTFsDNKBnM55X" + "Vox4Iq3Nyhvjx1gDsYEYHH2KGbgi0/sh/AQ2Qi1Fd61KiA/1fzJuTf0Pyp/CLD7/Qqamno" + "JrUNKXaB4AxAuRLKCsgPZtDzukQU0zd1jQ2QS3ibDUhtSgw3xrcJ4BM1iV+PGmenn2Wrnq" + "N6OKsgP+g1Ly+7V8MitqNXsB3t2ZaxfcRELh0oTKsYDENtUyZju1QzJqK+hIWolxqIes4+" + "QCZvzDXPsKG7P8NZtlNIXA58D85RgYtRzdbU3bPNsuXy8Ml1AyjyZM8lEYFnqJhsVtPg6k" + "aqh/GPHaRcAXPY7XUGw2bvWg0/4/y7p3k1hx3V0tDSuSE9ODWsSTKI9Xd3+MVSj9a3/lXH" + "dAqTfsNvtpoTDAQFhD4C6C4yicWxKLPNY0wwn661z4bqfqPf3UariGz8sBAtKMEIOg+PkL" + "kg05KeCG0SGc+fhlakePH1BnlQs8xv+2Lwe61H2t19T6V2GMRooLRBy4jmm2aNmSmBBE70" + "ktS71ZsKkJWlE1KiLyQVwouNLZdbuJb2GjvY13saJwQCjpiFif6th8wnE1bQK8ge3KXpD6" + "UDeDCy7/cphfeUUkj2ZYUAYVFnawmFrQUHjZOTJcID2as0QNBtWVfLxVx9rkA/r4Da1PuA" + "wVijVluGd61Wzlu1Ga4tKnJ2qsOFWGcfJuRTNKuc2X3ioOSc5hIHj6F/ZfhglHoIkpLcTK" + "FHNpIqO8i2Amar37/MuN2t7tDAettrdWLashMWWpw/vsijcuqegKvag4ziWkYhusTeC+83" + "tAmhB7qai7ao85aO2vs9xS/4ZbnYzeCbh3tBGcIT8hXNNeKunAgkTpFnYFYodxNqLjyTYg" + "Yfk2ghc6bk6uWaUWgH2s1Bu3nesZ/L4+FNRnphFHcjwya9yFyol2n/VBXrhVEeYGnXF4O9" + "dsAYIsLqeNQK9eLILRzMGlOmH5P6bz7wW3OMwiBwMV5Iq5FmIHiXrVRGC77fx4f7+PBnW6" + "MtBIjvt7T/E0m/fSk/NaNZxqVOYKqwvbCwflKrbdYFbNSPz44/H50eJ35gIqlyBkv8QFRe" + "oCvlmlfcHt9dgRv4qrCyTt0mq/kByzY2Q9DtE28e3bu7W8ZJig5LVnE26bU2EcPFf+kYtV" + "R6qjDt85KLWr7Pb/w3iKX2p9AfLLA5kVP3Okdw8zH9K21Ouf/3L2IcF6Wpyr2SBZUP6JNs" + "xPtTH9UKhKPuH5BufaliQL2iGFDPFwPkGwUiBZfon4P+VTHhBRXz9sSOsP6zPMx3sSxQAV" + "fByFyRMdODXvMfE3f7st8y7z41QKsorbXNy+z5fy/uTSo=" +) diff --git a/server/src/tavolo/backfill_elo.py b/server/src/tavolo/backfill_elo.py new file mode 100644 index 0000000..4db9432 --- /dev/null +++ b/server/src/tavolo/backfill_elo.py @@ -0,0 +1,66 @@ +"""Recompute every Elo rating from the recorded match 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 once after +deploying the ratings feature to backfill pre-existing matches, or any +time ratings need to be rebuilt:: + + python -m tavolo.backfill_elo +""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from logging import getLogger +from typing import Dict, List + +from tortoise.transactions import in_transaction + +from .config import settings +from .stats import apply_elo +from .tortoise_mixin import TortoiseMixin + +log = getLogger(__name__) + + +async def backfill_elo() -> int: + """Rebuild all ratings; returns the number of matches replayed.""" + from .models import Match, MatchPlayer, PlayerRating + + replayed = 0 + async with in_transaction(): + await PlayerRating.all().delete() + matches = await Match.all().order_by("finished_at", "id") + for match in matches: + players = await MatchPlayer.filter(match_id=match.id) + team_members: Dict[str, List[str]] = defaultdict(list) + for player in players: + team_members[player.team].append(player.user_sub) + deltas = await apply_elo( + match.game_type, match.winner_team, team_members + ) + for player in players: + player.elo_delta = deltas[player.user_sub] + await player.save() + replayed += 1 + return replayed + + +async def _main() -> None: + mixin = TortoiseMixin( + database_url=settings.database_url, + models_modules=["tavolo.models"], + ) + await mixin._bind() + try: + replayed = await backfill_elo() + log.info("elo backfill complete: %d matches replayed", replayed) + print(f"Recomputed ratings from {replayed} matches.") + finally: + if mixin._ctx is not None: + await mixin._ctx.close_connections() + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/server/src/tavolo/elo.py b/server/src/tavolo/elo.py new file mode 100644 index 0000000..9fbc9a2 --- /dev/null +++ b/server/src/tavolo/elo.py @@ -0,0 +1,49 @@ +"""Chess-style Elo ratings, generalized to two-team matches. + +Every player starts at :data:`INITIAL_RATING`. A team's rating is the mean +of its members' current ratings, so the usual chess formula applies +unchanged between the two teams: + +* expected score ``E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))`` +* actual score ``S`` is 1 for a win and 0 for a loss (matches never draw) +* every member of a team gains/loses the same ``round(K * (S - E))`` + +Deltas are rounded to integers and ratings are stored as integers, so the +system is exactly zero-sum: what the winners gain the losers lose. +""" +from __future__ import annotations + +from typing import Sequence + +INITIAL_RATING = 1500 +K_FACTOR = 32 + + +def expected_score(rating: float, opponent_rating: float) -> float: + """Expected score (0..1) of a side rated ``rating`` against + ``opponent_rating``.""" + return 1.0 / (1.0 + 10.0 ** ((opponent_rating - rating) / 400.0)) + + +def team_rating(ratings: Sequence[float]) -> float: + """A team's rating is the mean of its members' ratings.""" + if not ratings: + raise ValueError("a team needs at least one rating") + return sum(ratings) / len(ratings) + + +def match_delta( + team_a_ratings: Sequence[float], + team_b_ratings: Sequence[float], + winner_team: int, +) -> int: + """Rating change applied to each member of team A. + + ``winner_team`` is 0 when team A won, 1 when team B won. Team B + members change by the negation of the returned value (zero-sum). + """ + rating_a = team_rating(team_a_ratings) + rating_b = team_rating(team_b_ratings) + expected = expected_score(rating_a, rating_b) + score = 1.0 if winner_team == 0 else 0.0 + return round(K_FACTOR * (score - expected)) diff --git a/server/src/tavolo/models.py b/server/src/tavolo/models.py index e6e2007..9a67891 100644 --- a/server/src/tavolo/models.py +++ b/server/src/tavolo/models.py @@ -7,12 +7,17 @@ a player took part in, with the final score": * :class:`Match` — one row per finished match with both teams' scores. * :class:`MatchPlayer` — one row per participant, linking an OIDC ``sub`` to a seat/team and whether they won. +* :class:`PlayerRating` — current chess-style Elo rating of a player for + one game type, updated transactionally with every finished match (see + :mod:`tavolo.elo`). """ from __future__ import annotations from tortoise import fields from tortoise.models import Model +from .elo import INITIAL_RATING + class Match(Model): """A completed match of one of the registered game types.""" @@ -50,7 +55,28 @@ class MatchPlayer(Model): seat = fields.SmallIntField() team = fields.CharField(max_length=1) won = fields.BooleanField() + # Elo change this match produced for the player (see tavolo.elo); + # null for matches recorded before ratings existed. + elo_delta = fields.SmallIntField(null=True) class Meta: table = "match_player" unique_together = (("match", "user_sub"),) + + +class PlayerRating(Model): + """Current Elo rating of one player for one game type.""" + + id = fields.UUIDField(pk=True) + # OIDC subject of the player; no local users table. + user_sub = fields.CharField(max_length=255) + # Which card game the rating applies to (tavolo.games.GAME_TYPES). + game_type = fields.CharField(max_length=32) + rating = fields.IntField(default=INITIAL_RATING) + matches_played = fields.IntField(default=0) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "player_rating" + unique_together = (("user_sub", "game_type"),) + indexes = (("game_type", "rating"),) diff --git a/server/src/tavolo/routes/stats.py b/server/src/tavolo/routes/stats.py index 736394e..03ffe7d 100644 --- a/server/src/tavolo/routes/stats.py +++ b/server/src/tavolo/routes/stats.py @@ -13,9 +13,10 @@ from kaya.openapi import operation from ..app import app, oidc_mixin from ..auth import require_auth -from ..games import get_game_type +from ..elo import INITIAL_RATING +from ..games import DEFAULT_GAME_TYPE, get_game_type from ..http import extract_query_params, send_error, send_json -from ..models import Match, MatchPlayer +from ..models import Match, MatchPlayer, PlayerRating from ..openapi import PAGINATION_PARAMETERS from ..pagination import CursorDecodeError, paginate, parse_cursor_params @@ -55,6 +56,9 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]: "started_at": match.started_at.isoformat(), "finished_at": match.finished_at.isoformat(), "you_won": any(p.user_sub == viewer and p.won for p in participants), + "your_elo_delta": next( + (p.elo_delta for p in participants if p.user_sub == viewer), None + ), "players": [ { "user_sub": p.user_sub, @@ -62,6 +66,7 @@ async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]: "seat": p.seat, "team": p.team, "won": p.won, + "elo_delta": p.elo_delta, } for p in participants ], @@ -104,8 +109,11 @@ async def my_matches(ctx: HttpContext) -> None: @app.GET("/api/leaderboard") @operation(summary="Global leaderboard", - description="Aggregated wins, matches played and team points for every " - "player with at least one finished match. Sorted by wins.", + description="Elo rating, aggregated wins, matches played and team " + "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).", tags=["stats"], parameters=[GAME_TYPE_PARAMETER], responses={ @@ -121,6 +129,9 @@ async def leaderboard(ctx: HttpContext) -> None: if game_type is not None: queryset = queryset.filter(match__game_type=game_type) rows = await queryset.prefetch_related("match") + # Ratings are per game type; without a filter show the default game's. + rating_rows = await PlayerRating.filter(game_type=game_type or DEFAULT_GAME_TYPE) + ratings = {row.user_sub: row.rating for row in rating_rows} aggregate: Dict[str, Dict[str, Any]] = {} for row in rows: entry = aggregate.setdefault( @@ -131,6 +142,7 @@ async def leaderboard(ctx: HttpContext) -> None: "matches": 0, "wins": 0, "points": 0, + "elo": ratings.get(row.user_sub, INITIAL_RATING), }, ) entry["matches"] += 1 @@ -145,7 +157,33 @@ async def leaderboard(ctx: HttpContext) -> None: ranking: List[Dict[str, Any]] = sorted( aggregate.values(), - key=lambda e: (e["wins"], e["points"], -e["matches"]), + 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") +@operation(summary="My Elo ratings", + description="The caller's current Elo rating for every game type " + "they have played.", + tags=["stats"], + responses={ + 200: {"description": "The caller's ratings"}, + 401: {"description": "Authentication required"}, + }) +@require_auth +async def my_ratings(ctx: HttpContext) -> None: + user = oidc_mixin.get_user(ctx) + assert user is not None + rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type") + await send_json(ctx, 200, { + "results": [ + { + "game_type": row.game_type, + "rating": row.rating, + "matches_played": row.matches_played, + } + for row in rows + ] + }) diff --git a/server/src/tavolo/stats.py b/server/src/tavolo/stats.py index 2518d12..3839cb1 100644 --- a/server/src/tavolo/stats.py +++ b/server/src/tavolo/stats.py @@ -2,17 +2,19 @@ Called once when a game reaches the finished phase (guarded by the ``stats_saved`` flag on the state). The write is transactional so a match -never appears with only some of its players. +never appears with only some of its players. The same transaction also +updates the participants' Elo ratings (see :mod:`tavolo.elo`). """ from __future__ import annotations import uuid from datetime import datetime, timezone from logging import getLogger -from typing import Optional +from typing import Dict, List, Optional from tortoise.transactions import in_transaction +from .elo import match_delta from .game.state import PHASE_FINISHED, TEAM_NAMES, GameState log = getLogger(__name__) @@ -27,6 +29,46 @@ def _parse_timestamp(value: Optional[str]) -> datetime: return datetime.now(timezone.utc) +async def apply_elo( + game_type: str, winner_team: str, team_members: Dict[str, List[str]] +) -> Dict[str, int]: + """Update the Elo ratings of ``team_members`` for ``game_type``. + + ``team_members`` maps a team name ("A"/"B") to its players' subs. + 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. + """ + from .models import PlayerRating + + ratings: Dict[str, PlayerRating] = {} + for subs in team_members.values(): + for sub in subs: + rating = await PlayerRating.get_or_none( + user_sub=sub, game_type=game_type + ) + if rating is None: + rating = await PlayerRating.create( + id=uuid.uuid4(), user_sub=sub, game_type=game_type + ) + ratings[sub] = rating + delta_a = match_delta( + [ratings[sub].rating for sub in team_members["A"]], + [ratings[sub].rating for sub in team_members["B"]], + 0 if winner_team == "A" else 1, + ) + deltas: Dict[str, int] = { + **{sub: delta_a for sub in team_members["A"]}, + **{sub: -delta_a for sub in team_members["B"]}, + } + for sub, delta in deltas.items(): + rating = ratings[sub] + rating.rating += delta + rating.matches_played += 1 + await rating.save() + return deltas + + async def save_match_result(state: GameState) -> None: """Persist ``state`` to Postgres if it is finished and not yet saved.""" if state.stats_saved or state.phase != PHASE_FINISHED or state.winner is None: @@ -36,18 +78,23 @@ async def save_match_result(state: GameState) -> None: started_at = _parse_timestamp(state.created_at) finished_at = _parse_timestamp(state.finished_at) + winner_team = TEAM_NAMES[state.winner] + team_members: Dict[str, List[str]] = {"A": [], "B": []} + for player in state.players: + team_members[TEAM_NAMES[player.team]].append(player.sub) async with in_transaction(): match = await Match.create( id=uuid.uuid4(), game_type=state.game_type, team_a_score=state.scores[0], team_b_score=state.scores[1], - winner_team=TEAM_NAMES[state.winner], + winner_team=winner_team, target_score=state.target_score, hands_played=state.hand_number, started_at=started_at, finished_at=finished_at, ) + deltas = await apply_elo(state.game_type, winner_team, team_members) for player in state.players: await MatchPlayer.create( id=uuid.uuid4(), @@ -57,6 +104,7 @@ async def save_match_result(state: GameState) -> None: seat=player.seat, team=TEAM_NAMES[player.team], won=player.team == state.winner, + elo_delta=deltas[player.sub], ) state.stats_saved = True log.info( diff --git a/server/tests/test_elo.py b/server/tests/test_elo.py new file mode 100644 index 0000000..17a6c47 --- /dev/null +++ b/server/tests/test_elo.py @@ -0,0 +1,73 @@ +"""Unit tests for the chess-style Elo math in :mod:`tavolo.elo`.""" +from __future__ import annotations + +import unittest + +from tavolo.elo import ( + INITIAL_RATING, + K_FACTOR, + expected_score, + match_delta, + team_rating, +) + + +class ExpectedScoreTest(unittest.TestCase): + def test_equal_ratings_give_even_odds(self) -> None: + self.assertAlmostEqual(0.5, expected_score(1500, 1500)) + + def test_higher_rating_is_favoured(self) -> None: + self.assertGreater(expected_score(1700, 1500), 0.5) + self.assertLess(expected_score(1500, 1700), 0.5) + + def test_scores_sum_to_one(self) -> None: + self.assertAlmostEqual( + 1.0, expected_score(1600, 1400) + expected_score(1400, 1600) + ) + + def test_four_hundred_points_is_ten_to_one(self) -> None: + self.assertAlmostEqual(10 / 11, expected_score(1900, 1500)) + + +class TeamRatingTest(unittest.TestCase): + def test_mean_of_members(self) -> None: + self.assertEqual(1600, team_rating([1500, 1700])) + + def test_empty_team_rejected(self) -> None: + with self.assertRaises(ValueError): + team_rating([]) + + +class MatchDeltaTest(unittest.TestCase): + def test_equal_teams_exchange_half_k(self) -> None: + delta = match_delta([1500, 1500], [1500, 1500], winner_team=0) + self.assertEqual(K_FACTOR // 2, delta) + + def test_favourite_gains_less_than_underdog(self) -> None: + favourite = match_delta([1700, 1700], [1500, 1500], winner_team=0) + underdog = match_delta([1500, 1500], [1700, 1700], winner_team=0) + self.assertGreater(underdog, favourite) + self.assertGreater(favourite, 0) + + def test_losing_side_loses_the_winners_gain(self) -> None: + # Zero-sum: the losers' delta is the negation of the winners'. + win = match_delta([1600, 1500], [1400, 1500], winner_team=0) + loss = match_delta([1600, 1500], [1400, 1500], winner_team=1) + self.assertEqual(-win, -abs(win)) # winner gains + # Losing the same pairing costs K * E, winning gains K * (1 - E); + # both are computed from the same expectation, so loss = win - K. + self.assertEqual(win - K_FACTOR, loss) + + def test_team_average_decides_not_individual_ratings(self) -> None: + # [1700, 1300] averages 1500, same as [1500, 1500]. + mixed = match_delta([1700, 1300], [1500, 1500], winner_team=0) + even = match_delta([1500, 1500], [1500, 1500], winner_team=0) + self.assertEqual(even, mixed) + + def test_initial_rating_constant(self) -> None: + self.assertEqual(1500, INITIAL_RATING) + self.assertEqual(32, K_FACTOR) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/tests/test_stats.py b/server/tests/test_stats.py index b9298b7..95ebfb1 100644 --- a/server/tests/test_stats.py +++ b/server/tests/test_stats.py @@ -9,9 +9,10 @@ from httpx import ASGITransport, AsyncClient from pwo import async_test from tavolo.app import app, tortoise_mixin +from tavolo.elo import INITIAL_RATING from tavolo.game import engine from tavolo.game.state import GameState -from tavolo.models import Match, MatchPlayer +from tavolo.models import Match, MatchPlayer, PlayerRating from tavolo.stats import save_match_result from tests.helpers import oidc_user @@ -48,6 +49,28 @@ def _finished_state() -> GameState: return state +def _finished_state_reversed() -> GameState: + """Same one-capture ending as ``_finished_state``, but team B scores it.""" + state = GameState( + id="stats-game-2", + join_code="STATS2", + creator_sub="alice", + target_score=2, + phase=engine.PHASE_PLAYING, + turn=1, + table=[engine.parse_card("02C")], + ) + from tavolo.game.state import PlayerState, Card + + state.players = [ + PlayerState(sub="alice", name="alice", seat=0), + PlayerState(sub="bob", name="bob", seat=1, hand=[Card.parse("02D")]), + PlayerState(sub="carol", name="carol", seat=2), + PlayerState(sub="dave", name="dave", seat=3), + ] + return state + + class SaveMatchResultTest(unittest.TestCase): @async_test async def test_finished_match_is_persisted_once(self) -> None: @@ -71,6 +94,58 @@ class SaveMatchResultTest(unittest.TestCase): winners = await MatchPlayer.filter(won=True) self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners}) + @async_test + async def test_finished_match_updates_elo_ratings(self) -> None: + ctx = await _use_app_db() + state = _finished_state() + engine.play(state, "alice", "02D", ["02C"]) + + with ctx: + await save_match_result(state) + + ratings = { + row.user_sub: row for row in await PlayerRating.all() + } + self.assertEqual(4, len(ratings)) + # Four players at 1500: winners gain K/2, losers lose it. + for winner in ("alice", "carol"): + self.assertEqual(INITIAL_RATING + 16, ratings[winner].rating) + self.assertEqual(1, ratings[winner].matches_played) + for loser in ("bob", "dave"): + self.assertEqual(INITIAL_RATING - 16, ratings[loser].rating) + self.assertEqual(1, ratings[loser].matches_played) + + # The per-match delta is recorded on each participation row. + deltas = { + p.user_sub: p.elo_delta for p in await MatchPlayer.all() + } + self.assertEqual( + {"alice": 16, "carol": 16, "bob": -16, "dave": -16}, deltas + ) + + @async_test + async def test_elo_ratings_accumulate_across_matches(self) -> None: + ctx = await _use_app_db() + state = _finished_state() + engine.play(state, "alice", "02D", ["02C"]) + reversed_state = _finished_state_reversed() + engine.play(reversed_state, "bob", "02D", ["02C"]) + + with ctx: + await save_match_result(state) + # A second match between the same players, won by team B. + await save_match_result(reversed_state) + + ratings = { + row.user_sub: row.rating for row in await PlayerRating.all() + } + # Match 1: even teams, team A wins (+16/-16). Match 2: team A + # is now the favourite (1516 vs 1484), so losing costs 17. + self.assertEqual(INITIAL_RATING - 1, ratings["alice"]) + self.assertEqual(INITIAL_RATING + 1, ratings["bob"]) + bob = await PlayerRating.get(user_sub="bob") + self.assertEqual(2, bob.matches_played) + async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None: ctx = await _use_app_db() @@ -167,6 +242,116 @@ class StatsRouteTest(unittest.TestCase): # Alice leads on points after tying Bob on wins. self.assertEqual("alice", response.json()["results"][0]["user_sub"]) + @async_test + async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None: + await _seed_two_matches() + ctx = await _use_app_db() + with ctx: + # Bob outranks everyone despite Alice leading on points. + await PlayerRating.create( + id=uuid.uuid4(), + user_sub="bob", + game_type="scopone_scientifico", + rating=1600, + matches_played=2, + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + response = await client.get("/api/leaderboard") + self.assertEqual(200, response.status_code) + results = response.json()["results"] + by_sub = {row["user_sub"]: row for row in results} + self.assertEqual(1600, by_sub["bob"]["elo"]) + # Players without a rating row report the initial rating. + self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"]) + # Elo outranks wins/points. + self.assertEqual("bob", results[0]["user_sub"]) + + @async_test + async def test_leaderboard_elo_scoped_by_game_type(self) -> None: + await _seed_two_matches() + ctx = await _use_app_db() + with ctx: + await PlayerRating.create( + id=uuid.uuid4(), + user_sub="alice", + game_type="scopone_scientifico", + rating=1516, + matches_played=1, + ) + # Alice's rating in another game must not leak into the + # scopone leaderboard. + await PlayerRating.create( + id=uuid.uuid4(), + user_sub="alice", + game_type="other_game", + rating=1800, + matches_played=1, + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + response = await client.get("/api/leaderboard?game_type=scopone_scientifico") + self.assertEqual(200, response.status_code) + results = response.json()["results"] + by_sub = {row["user_sub"]: row for row in results} + self.assertEqual(1516, by_sub["alice"]["elo"]) + self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"]) + + @async_test + async def test_my_matches_include_elo_delta(self) -> None: + ctx = await _use_app_db() + state = _finished_state() + engine.play(state, "alice", "02D", ["02C"]) + with ctx: + await save_match_result(state) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + response = await client.get("/api/me/matches") + self.assertEqual(200, response.status_code) + players = { + p["user_sub"]: p + for p in response.json()["results"][0]["players"] + } + self.assertEqual(16, players["alice"]["elo_delta"]) + self.assertEqual(-16, players["bob"]["elo_delta"]) + self.assertEqual(16, response.json()["results"][0]["your_elo_delta"]) + + @async_test + async def test_my_ratings_requires_auth(self) -> None: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + response = await client.get("/api/me/ratings") + self.assertEqual(401, response.status_code) + + @async_test + async def test_my_ratings_returns_only_own_rows(self) -> None: + ctx = await _use_app_db() + with ctx: + await PlayerRating.create( + id=uuid.uuid4(), + user_sub="alice", + game_type="scopone_scientifico", + rating=1516, + matches_played=1, + ) + await PlayerRating.create( + id=uuid.uuid4(), + user_sub="bob", + game_type="scopone_scientifico", + rating=1484, + matches_played=1, + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + with oidc_user("alice"): + response = await client.get("/api/me/ratings") + self.assertEqual(200, response.status_code) + self.assertEqual( + [{"game_type": "scopone_scientifico", "rating": 1516, "matches_played": 1}], + response.json()["results"], + ) + class GameTypeFilterTest(unittest.TestCase): """Stats endpoints scope results by the match's game type.""" diff --git a/web/src/api.rs b/web/src/api.rs index 93c1f54..fb51af5 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -91,6 +91,18 @@ pub async fn my_matches(cursor: Option<&str>) -> Result { resp.json().await.map_err(|e| e.to_string()) } +/// Fetch the caller's Elo ratings (one row per game type played). +pub async fn my_ratings() -> Result { + let resp = Request::get("/api/me/ratings") + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.ok() { + return Err(server_error(resp.status())); + } + resp.json().await.map_err(|e| e.to_string()) +} + pub async fn leaderboard() -> Result { let resp = Request::get("/api/leaderboard") .send() diff --git a/web/src/model.rs b/web/src/model.rs index 949004e..2d394ff 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -184,6 +184,10 @@ pub struct MatchPlayer { pub seat: usize, pub team: String, pub won: bool, + /// Elo change this match produced for the player; absent for matches + /// recorded before ratings existed. + #[serde(default)] + pub elo_delta: Option, } #[derive(Debug, Clone, Deserialize)] @@ -201,6 +205,9 @@ pub struct MatchSummary { pub finished_at: String, #[serde(default)] pub you_won: bool, + /// The viewer's Elo change in this match; absent when unrated. + #[serde(default)] + pub your_elo_delta: Option, #[serde(default)] pub players: Vec, } @@ -213,16 +220,38 @@ pub struct MatchesPage { pub next_cursor: Option, } +fn default_elo() -> i32 { + 1500 +} + #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct LeaderboardEntry { pub user_sub: String, pub display_name: String, + /// Chess-style Elo rating for the requested game type. + #[serde(default = "default_elo")] + pub elo: i32, pub matches: i32, pub wins: i32, pub points: i32, } +/// The caller's Elo rating for one game type (GET /api/me/ratings). +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct PlayerRating { + pub game_type: String, + pub rating: i32, + pub matches_played: i32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RatingsPage { + #[serde(default)] + pub results: Vec, +} + #[derive(Debug, Clone, Deserialize)] pub struct LeaderboardPage { #[serde(default)] diff --git a/web/src/pages/history.rs b/web/src/pages/history.rs index 37a9862..9ece686 100644 --- a/web/src/pages/history.rs +++ b/web/src/pages/history.rs @@ -72,6 +72,10 @@ pub fn HistoryPage() -> View { .collect::>() .join(" & "); let outcome = if m.you_won { "Won" } else { "Lost" }; + let elo_delta = m + .your_elo_delta + .map(|d| if d >= 0 { format!("+{d}") } else { d.to_string() }) + .unwrap_or_else(|| "—".to_string()); view! { tr { td { (m.finished_at.replace('T', " ").chars().take(16).collect::()) } @@ -80,6 +84,9 @@ pub fn HistoryPage() -> View { td { (m.team_a_score) " – " (m.team_b_score) } td { "Team " (m.winner_team) } td(class=if m.you_won { "won" } else { "lost" }) { (outcome) } + td(class=if m.you_won { "won" } else { "lost" }) { + (elo_delta) + } } } }) @@ -94,6 +101,7 @@ pub fn HistoryPage() -> View { th { "Score" } th { "Winner" } th { "You" } + th { "Elo" } } } tbody { (table_rows) } diff --git a/web/src/pages/leaderboard.rs b/web/src/pages/leaderboard.rs index d832b8a..a91621c 100644 --- a/web/src/pages/leaderboard.rs +++ b/web/src/pages/leaderboard.rs @@ -39,6 +39,7 @@ pub fn LeaderboardPage() -> View { tr { td { (i + 1) } td { (e.display_name.clone()) } + td { (e.elo) } td { (e.wins) } td { (e.matches) } td { (e.points) } @@ -52,6 +53,7 @@ pub fn LeaderboardPage() -> View { tr { th { "#" } th { "Player" } + th { "Elo" } th { "Wins" } th { "Matches" } th { "Points" } diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index 4849704..41a4548 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -20,6 +20,8 @@ fn fallback_game_types() -> Vec { pub fn LobbyPage() -> View { // Outer None = still loading; Some(None) = logged out. let user = create_signal(Option::>::None); + // The player's Elo rating for the first rated game; None while loading. + let rating = create_signal(Option::::None); let error = create_signal(Option::::None); let code = create_signal(String::new()); let game_types = create_signal(fallback_game_types()); @@ -28,7 +30,17 @@ pub fn LobbyPage() -> View { spawn_local(async move { match api::me().await { - Ok(me) => user.set(Some(me)), + Ok(me) => { + if me.is_some() { + spawn_local(async move { + if let Ok(p) = api::my_ratings().await { + // Unrated players sit at the initial 1500. + rating.set(Some(p.results.first().map(|r| r.rating).unwrap_or(1500))); + } + }); + } + user.set(Some(me)); + } Err(e) => { error.set(Some(e)); user.set(Some(None)); @@ -88,7 +100,12 @@ pub fn LobbyPage() -> View { Some(Some(me)) => view! { div(class="lobby-grid") { nav(class="top-nav") { - span(class="whoami") { "Signed in as " strong { (me.name.clone()) } } + span(class="whoami") { + "Signed in as " strong { (me.name.clone()) } + (rating.get_clone().map(|r| view! { + span(class="rating") { " · Elo " (r) } + })) + } a(href="/history") { "My matches" } a(href="/leaderboard") { "Leaderboard" } a(href="/auth/logout", rel="external") { "Log out" } diff --git a/web/style.css b/web/style.css index 2c7d151..8266fa1 100644 --- a/web/style.css +++ b/web/style.css @@ -49,6 +49,10 @@ body { margin-right: auto; } +.whoami .rating { + color: var(--muted, #888); +} + .panel { background: var(--panel); border-radius: 12px;