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:
woggioni committed 2026-09-21 13:04:08 +08:00
1 parent 4b66407fa4
commit 83ecf9bed3
6 files changed
+763 -73

No files matched your search

+123 -30
View File
@@ -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" }