Make the web client game-generic against the platform API
Send creation options as the nested options object the lobby expects, drive the creation form from each game's options_schema, and render history/leaderboard from the generic result payload (nullable teams, float points). The lobby uses the payload's seats_open, and the scopone-specific table, hand-end and game-over views are gated on its game_type with an unsupported-game fallback for future games. The websocket protocol is unchanged.
This commit is contained in:
6 files changed
+763
-73
No files matched your search
+123
-30
@@ -1,10 +1,72 @@
|
||||
//! Match history page.
|
||||
//!
|
||||
//! Rendering is game-generic: teams, scores and the winner are read from
|
||||
//! each match's game-specific `result` object, degrading gracefully when
|
||||
//! a game reports a different shape.
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::api;
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::MatchesPage;
|
||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||
|
||||
/// Distinct team labels in seat order; players without a team render in
|
||||
/// a shared "Players" column.
|
||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
||||
let mut teams = BTreeSet::new();
|
||||
for p in &m.players {
|
||||
teams.insert(p.team.clone());
|
||||
}
|
||||
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
|
||||
// Seated teams first (in first-seat order), then the team-less column.
|
||||
ordered.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
ordered
|
||||
}
|
||||
|
||||
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||
m.players
|
||||
.iter()
|
||||
.filter(|p| &p.team == team)
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ")
|
||||
}
|
||||
|
||||
fn team_header(team: &Option<String>) -> String {
|
||||
match team {
|
||||
Some(t) => format!("Team {t}"),
|
||||
None => "Players".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// "11 – 7" when the result carries both team scores, "—" otherwise.
|
||||
fn score_text(m: &MatchSummary) -> String {
|
||||
match (m.result_i64("team_a_score"), m.result_i64("team_b_score")) {
|
||||
(Some(a), Some(b)) => format!("{a} – {b}"),
|
||||
_ => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Team A" from `winner_team`, a plain `winner`, or "—".
|
||||
fn winner_text(m: &MatchSummary) -> String {
|
||||
if let Some(team) = m.result_str("winner_team") {
|
||||
return format!("Team {team}");
|
||||
}
|
||||
m.result_str("winner").unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn game_name(game_types: &[GameTypeInfo], game_type: &str) -> String {
|
||||
game_types
|
||||
.iter()
|
||||
.find(|g| g.id == game_type)
|
||||
.map(|g| g.name.clone())
|
||||
.unwrap_or_else(|| game_type.to_string())
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn HistoryPage() -> View {
|
||||
@@ -13,6 +75,7 @@ pub fn HistoryPage() -> View {
|
||||
let cursor = create_signal(Option::<String>::None);
|
||||
// Accumulated rows across "load more" clicks.
|
||||
let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
|
||||
let game_types = create_signal(Vec::<GameTypeInfo>::new());
|
||||
|
||||
let load = move |next: Option<String>| {
|
||||
spawn_local(async move {
|
||||
@@ -27,6 +90,12 @@ pub fn HistoryPage() -> View {
|
||||
});
|
||||
};
|
||||
|
||||
spawn_local(async move {
|
||||
if let Ok(types) = api::game_types().await {
|
||||
game_types.set(types);
|
||||
}
|
||||
});
|
||||
|
||||
let load2 = load;
|
||||
spawn_local(async move {
|
||||
match api::my_matches(None).await {
|
||||
@@ -53,51 +122,75 @@ pub fn HistoryPage() -> View {
|
||||
p(class="status") { "No matches played yet." }
|
||||
},
|
||||
Some(_) => {
|
||||
let names = game_types.get_clone();
|
||||
let table_rows = rows
|
||||
.get_clone()
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let team_a: String = m
|
||||
.players
|
||||
let finished = m
|
||||
.finished_at
|
||||
.replace('T', " ")
|
||||
.chars()
|
||||
.take(16)
|
||||
.collect::<String>();
|
||||
let game = game_name(&names, &m.game_type);
|
||||
let mut cells: Vec<View> = team_columns(&m)
|
||||
.iter()
|
||||
.filter(|p| p.team == "A")
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
let team_b: String = m
|
||||
.players
|
||||
.iter()
|
||||
.filter(|p| p.team == "B")
|
||||
.map(|p| p.display_name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" & ");
|
||||
.map(|t| team_names(&m, t))
|
||||
.map(|c| view! { td { (c) } })
|
||||
.collect();
|
||||
let score = score_text(&m);
|
||||
let winner = winner_text(&m);
|
||||
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>()) }
|
||||
td { (team_a) }
|
||||
td { (team_b) }
|
||||
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)
|
||||
.map(|d| {
|
||||
if d >= 0 {
|
||||
format!("+{d}")
|
||||
} else {
|
||||
d.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
let cls = if m.you_won { "won" } else { "lost" };
|
||||
let mut row_cells = vec![
|
||||
view! { td { (finished) } },
|
||||
view! { td { (game) } },
|
||||
];
|
||||
row_cells.append(&mut cells);
|
||||
row_cells.push(view! { td { (score) } });
|
||||
row_cells.push(view! { td { (winner) } });
|
||||
row_cells.push(view! { td(class=cls) { (outcome) } });
|
||||
row_cells.push(view! { td(class=cls) { (elo_delta) } });
|
||||
view! { tr { (row_cells) } }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// Team columns are per-row (matches may mix games), so
|
||||
// the header shows the union across the loaded rows.
|
||||
let mut header_teams = BTreeSet::new();
|
||||
for m in rows.get_clone().iter() {
|
||||
for t in team_columns(m) {
|
||||
header_teams.insert(t);
|
||||
}
|
||||
}
|
||||
let mut header_teams: Vec<Option<String>> =
|
||||
header_teams.into_iter().collect();
|
||||
header_teams.sort_by_key(|t| match t {
|
||||
Some(_) => (0, String::new()),
|
||||
None => (1, String::new()),
|
||||
});
|
||||
let header_cells: Vec<View> = header_teams
|
||||
.iter()
|
||||
.map(team_header)
|
||||
.map(|h| view! { th { (h) } })
|
||||
.collect();
|
||||
view! {
|
||||
table(class="matches") {
|
||||
thead {
|
||||
tr {
|
||||
th { "Finished" }
|
||||
th { "Team A" }
|
||||
th { "Team B" }
|
||||
th { "Game" }
|
||||
(header_cells)
|
||||
th { "Score" }
|
||||
th { "Winner" }
|
||||
th { "You" }
|
||||
|
||||
Reference in new issue
Block a user