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:
@@ -91,6 +91,18 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fetch the caller's Elo ratings (one row per game type played).
|
||||
pub async fn my_ratings() -> Result<RatingsPage, String> {
|
||||
let resp = Request::get("/api/me/ratings")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.ok() {
|
||||
return Err(server_error(resp.status()));
|
||||
}
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
||||
let resp = Request::get("/api/leaderboard")
|
||||
.send()
|
||||
|
||||
@@ -184,6 +184,10 @@ pub struct MatchPlayer {
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
pub won: bool,
|
||||
/// Elo change this match produced for the player; absent for matches
|
||||
/// recorded before ratings existed.
|
||||
#[serde(default)]
|
||||
pub elo_delta: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -201,6 +205,9 @@ pub struct MatchSummary {
|
||||
pub finished_at: String,
|
||||
#[serde(default)]
|
||||
pub you_won: bool,
|
||||
/// The viewer's Elo change in this match; absent when unrated.
|
||||
#[serde(default)]
|
||||
pub your_elo_delta: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub players: Vec<MatchPlayer>,
|
||||
}
|
||||
@@ -213,16 +220,38 @@ pub struct MatchesPage {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
fn default_elo() -> i32 {
|
||||
1500
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LeaderboardEntry {
|
||||
pub user_sub: String,
|
||||
pub display_name: String,
|
||||
/// Chess-style Elo rating for the requested game type.
|
||||
#[serde(default = "default_elo")]
|
||||
pub elo: i32,
|
||||
pub matches: i32,
|
||||
pub wins: i32,
|
||||
pub points: i32,
|
||||
}
|
||||
|
||||
/// The caller's Elo rating for one game type (GET /api/me/ratings).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PlayerRating {
|
||||
pub game_type: String,
|
||||
pub rating: i32,
|
||||
pub matches_played: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RatingsPage {
|
||||
#[serde(default)]
|
||||
pub results: Vec<PlayerRating>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LeaderboardPage {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
@@ -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" }
|
||||
|
||||
@@ -49,6 +49,10 @@ body {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.whoami .rating {
|
||||
color: var(--muted, #888);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
|
||||
Reference in New Issue
Block a user