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:
2026-09-19 10:09:24 +08:00
parent ebeccc937d
commit 93d041b004
15 changed files with 651 additions and 17 deletions
+8
View File
@@ -72,6 +72,10 @@ pub fn HistoryPage() -> View {
.collect::<Vec<_>>()
.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::<String>()) }
@@ -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) }