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
+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" }