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)
74 lines
2.6 KiB
Python
74 lines
2.6 KiB
Python
"""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()
|