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) }
+2
View File
@@ -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" }
+19 -2
View File
@@ -20,6 +20,8 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None);
// The player's Elo rating for the first rated game; None while loading.
let rating = create_signal(Option::<i32>::None);
let error = create_signal(Option::<String>::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" }