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)
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user