Files
woggioni a606f14550 Add chess-style Elo ratings for players
Each player's rating starts at 1500 and updates transactionally with
every finished match: a team's rating is the mean of its two members
and the standard K=32 formula decides the zero-sum delta applied to
both members of a team. Ratings are per game type in a new
player_rating table; match_player records each match's elo_delta.

- GET /api/leaderboard exposes elo and sorts by it
- GET /api/me/matches includes per-player elo deltas
- new GET /api/me/ratings returns the caller's rating per game type
- frontend: Elo column on the leaderboard, per-match delta in the
  history page, current rating in the lobby
- python -m tavolo.backfill_elo recomputes all ratings from the
  recorded match history (one-off backfill for existing matches)
2026-09-18 13:36:17 +00:00

398 lines
16 KiB
Python

"""Match-statistics tests: Postgres persistence and the stats endpoints."""
from __future__ import annotations
import unittest
import uuid
from datetime import datetime, timezone
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, PlayerRating
from tavolo.stats import save_match_result
from tests.helpers import oidc_user
async def _use_app_db():
"""Bind the same Tortoise context the app uses for this event loop and
return it, so tests can seed rows the route handlers will see."""
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
return ctx
def _finished_state() -> GameState:
# Team A sweeps the (single-card) table with carte + denara and reaches
# a target of 2, ending the match.
state = GameState(
id="stats-game",
join_code="STATS1",
creator_sub="alice",
target_score=2,
phase=engine.PHASE_PLAYING,
turn=0,
table=[engine.parse_card("02C")],
)
from tavolo.game.state import PlayerState, Card
state.players = [
PlayerState(sub="alice", name="alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="bob", seat=1),
PlayerState(sub="carol", name="carol", seat=2),
PlayerState(sub="dave", name="dave", seat=3),
]
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:
ctx = await _use_app_db()
state = _finished_state()
engine.play(state, "alice", "02D", ["02C"])
self.assertEqual(engine.PHASE_FINISHED, state.phase)
with ctx:
await save_match_result(state)
await save_match_result(state) # idempotent
self.assertEqual(1, await Match.all().count())
self.assertEqual(4, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
self.assertEqual(state.scores[0], match.team_a_score)
self.assertEqual("A", match.winner_team)
# The game type travels from the live state onto the row.
self.assertEqual("scopone_scientifico", match.game_type)
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()
with ctx:
for index, (a_score, b_score, winner, finished) in enumerate(
[
(11, 5, "A", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
(8, 11, "B", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
]
):
match = await Match.create(
id=uuid.uuid4(),
game_type=game_types[index],
team_a_score=a_score,
team_b_score=b_score,
winner_team=winner,
target_score=11,
hands_played=2 + index,
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
finished_at=finished,
)
seats = [
("alice", 0, "A"),
("bob", 1, "B"),
("carol", 2, "A"),
("dave", 3, "B"),
]
for sub, seat, team in seats:
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=sub,
display_name=sub,
seat=seat,
team=team,
won=(team == winner),
)
class StatsRouteTest(unittest.TestCase):
@async_test
async def test_my_matches_newest_first(self) -> None:
await _seed_two_matches()
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)
results = response.json()["results"]
self.assertEqual(2, len(results))
self.assertEqual("B", results[0]["winner_team"]) # newest first
self.assertFalse(results[0]["you_won"])
self.assertTrue(results[1]["you_won"])
self.assertEqual(4, len(results[0]["players"]))
self.assertIn("next_cursor", response.json())
@async_test
async def test_my_matches_pagination(self) -> None:
await _seed_two_matches()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
first = await client.get("/api/me/matches?limit=1")
cursor = first.json()["next_cursor"]
self.assertIsNotNone(cursor)
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
self.assertEqual(1, len(first.json()["results"]))
self.assertEqual(1, len(second.json()["results"]))
self.assertNotEqual(
first.json()["results"][0]["id"],
second.json()["results"][0]["id"],
)
@async_test
async def test_my_matches_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/matches")
self.assertEqual(401, response.status_code)
@async_test
async def test_leaderboard_aggregates(self) -> None:
await _seed_two_matches()
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)
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
self.assertEqual(2, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"]) # team A won match 1
self.assertEqual(19, by_sub["alice"]["points"])
self.assertEqual(1, by_sub["bob"]["wins"]) # team B won match 2
self.assertEqual(16, by_sub["bob"]["points"])
# 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."""
@async_test
async def test_my_matches_filter_by_game_type(self) -> None:
# The second seed names a game the registry does not know; rows are
# written directly, so this only exercises the SQL filter.
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
all_matches = await client.get("/api/me/matches")
scoped = await client.get("/api/me/matches?game_type=scopone_scientifico")
unknown = await client.get("/api/me/matches?game_type=briscola")
self.assertEqual(2, len(all_matches.json()["results"]))
self.assertEqual(
{"scopone_scientifico", "other_game"},
{m["game_type"] for m in all_matches.json()["results"]},
)
scoped_results = scoped.json()["results"]
self.assertEqual(1, len(scoped_results))
self.assertEqual("scopone_scientifico", scoped_results[0]["game_type"])
self.assertEqual(400, unknown.status_code)
@async_test
async def test_leaderboard_filter_by_game_type(self) -> None:
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
scoped = await client.get("/api/leaderboard?game_type=scopone_scientifico")
unknown = await client.get("/api/leaderboard?game_type=briscola")
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, team A won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(400, unknown.status_code)
if __name__ == "__main__":
unittest.main()