From 97d4b3f217ce026f06e7de624128fada86b7d93c Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Tue, 6 Oct 2026 08:39:39 +0000 Subject: [PATCH] Scope the leaderboard to a selectable game Elo ratings are stored per game type, so the leaderboard now always requests /api/leaderboard?game_type= and shows only that game's players, Elo, wins, matches and points instead of mixing all games' aggregates with the default game's rating. - api::leaderboard takes the game type; - a "Game" dropdown next to the heading lists every registered game and reloads the board on change (stale in-flight responses are dropped); - move fallback_game_types() from the lobby to model.rs so both the lobby and leaderboard selectors work when /api/game-types fails. --- web/src/api.rs | 6 ++-- web/src/model.rs | 44 +++++++++++++++++++++++ web/src/pages/leaderboard.rs | 68 ++++++++++++++++++++++++++++++++---- web/src/pages/lobby.rs | 33 ++--------------- 4 files changed, 112 insertions(+), 39 deletions(-) diff --git a/web/src/api.rs b/web/src/api.rs index 2cea385..3efde4a 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -152,8 +152,10 @@ pub async fn my_ratings() -> Result { resp.json().await.map_err(|e| e.to_string()) } -pub async fn leaderboard() -> Result { - let resp = Request::get("/api/leaderboard") +/// Fetch the leaderboard for one game type: Elo, wins, matches and points +/// are all scoped to that game. +pub async fn leaderboard(game_type: &str) -> Result { + let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}")) .send() .await .map_err(|e| e.to_string())?; diff --git a/web/src/model.rs b/web/src/model.rs index 1093cf4..23d59da 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -408,6 +408,38 @@ pub struct GameTypesPage { pub results: Vec, } +/// The games to show in the lobby's and leaderboard's game selectors when +/// the `/api/game-types` fetch fails: match creation and per-game views +/// must still work. +pub fn fallback_game_types() -> Vec { + vec![ + GameTypeInfo { + id: "scopone_scientifico".to_string(), + name: "Scopone scientifico".to_string(), + description: String::new(), + min_players: 4, + max_players: 4, + options_schema: serde_json::Value::Null, + }, + GameTypeInfo { + id: "briscola".to_string(), + name: "Briscola".to_string(), + description: String::new(), + min_players: 2, + max_players: 2, + options_schema: serde_json::Value::Null, + }, + GameTypeInfo { + id: "briscola_a_quattro".to_string(), + name: "Briscola a quattro".to_string(), + description: String::new(), + min_players: 4, + max_players: 4, + options_schema: serde_json::Value::Null, + }, + ] +} + /// One configured OIDC login provider (GET /api/auth/providers). #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct AuthProvider { @@ -1142,6 +1174,18 @@ mod tests { assert_eq!("?", suit_name("?")); } + #[test] + fn fallback_game_types_cover_the_registered_games() { + let types = fallback_game_types(); + let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect(); + assert_eq!( + vec!["scopone_scientifico", "briscola", "briscola_a_quattro"], + ids + ); + // The lobby's first game (the creation default) must exist. + assert_eq!("scopone_scientifico", types[0].id); + } + #[test] fn game_view_parses_briscola_payload() { let g: GameView = serde_json::from_value(json!({ diff --git a/web/src/pages/leaderboard.rs b/web/src/pages/leaderboard.rs index dad6605..e487297 100644 --- a/web/src/pages/leaderboard.rs +++ b/web/src/pages/leaderboard.rs @@ -1,20 +1,53 @@ -//! Global leaderboard page. +//! Per-game leaderboard page. +//! +//! Elo ratings are stored per game type, so the board is scoped to the +//! game chosen in the dropdown: Elo, wins, matches and points all come +//! from that game's matches only. use wasm_bindgen_futures::spawn_local; use sycamore::prelude::*; use crate::api; use crate::components::toast::toast; -use crate::model::{fmt_points, LeaderboardPage}; +use crate::model::{fallback_game_types, fmt_points, LeaderboardPage}; #[component] pub fn LeaderboardPage() -> View { let page = create_signal(Option::::None); let error = create_signal(Option::::None); + let game_types = create_signal(fallback_game_types()); + let selected_game = create_signal("scopone_scientifico".to_string()); + + // Reload whenever the selection changes; a response for a game the + // user has already switched away from is dropped. + create_effect(move || { + let game_type = selected_game.get_clone(); + page.set(None); + spawn_local(async move { + match api::leaderboard(&game_type).await { + Ok(p) => { + if selected_game.get_clone() == game_type { + page.set(Some(p)); + } + } + Err(e) => { + if selected_game.get_clone() == game_type { + error.set(Some(e)); + } + } + } + }); + }); spawn_local(async move { - match api::leaderboard().await { - Ok(p) => page.set(Some(p)), - Err(e) => error.set(Some(e)), + match api::game_types().await { + Ok(types) if !types.is_empty() => { + let current = selected_game.get_clone(); + if !types.iter().any(|g| g.id == current) { + selected_game.set(types[0].id.clone()); + } + game_types.set(types); + } + _ => {} // keep the fallback list } }); @@ -22,7 +55,30 @@ pub fn LeaderboardPage() -> View { div { (crate::components::nav::app_nav("/leaderboard", || view! {})) div(class="container page-shell py-4") { - h1(class="h3 mb-4") { "Leaderboard" } + div( + class="d-flex flex-wrap gap-3 align-items-center \ + justify-content-between mb-4" + ) { + h1(class="h3 mb-0") { "Leaderboard" } + div(class="d-flex align-items-center gap-2") { + label(class="form-label mb-0", r#for="leaderboard-game") { + "Game" + } + select( + class="form-select w-auto", + id="leaderboard-game", + bind:value=selected_game, + ) { + Keyed( + list=game_types, + view=|g| view! { + option(value=g.id.clone()) { (g.name) } + }, + key=|g| g.id.clone(), + ) + } + } + } (toast(error)) (move || match page.get_clone() { None => view! { diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index 831568e..ee46f0e 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -15,39 +15,10 @@ use crate::api; use crate::components::card::card_back; use crate::components::toast::toast; use crate::model::{ - AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User, + fallback_game_types, AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, + PlayerRating, User, }; -/// Used when the game-types fetch fails: match creation must still work. -fn fallback_game_types() -> Vec { - vec![ - GameTypeInfo { - id: "scopone_scientifico".to_string(), - name: "Scopone scientifico".to_string(), - description: String::new(), - min_players: 4, - max_players: 4, - options_schema: serde_json::Value::Null, - }, - GameTypeInfo { - id: "briscola".to_string(), - name: "Briscola".to_string(), - description: String::new(), - min_players: 2, - max_players: 2, - options_schema: serde_json::Value::Null, - }, - GameTypeInfo { - id: "briscola_a_quattro".to_string(), - name: "Briscola a quattro".to_string(), - description: String::new(), - min_players: 4, - max_players: 4, - options_schema: serde_json::Value::Null, - }, - ] -} - /// Human-readable label for a provider name ("google" -> "Google"). fn provider_label(name: &str) -> String { let mut chars = name.chars();