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:
2026-09-21 13:04:08 +08:00
parent 4b66407fa4
commit 83ecf9bed3
6 changed files with 763 additions and 73 deletions
+34 -5
View File
@@ -329,13 +329,20 @@ pub fn GamePage(id: String) -> View {
}
}
Some(g) if g.phase == "lobby" => lobby_view(g),
Some(g) => table_view(g, on_hand_card, selected, now),
// The live table is scopone-specific; other games get a
// fallback until they ship their own play view.
Some(g) if g.game_type == "scopone_scientifico" => {
table_view(g, on_hand_card, selected, now)
}
Some(g) => unsupported_game_view(g),
})
(move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice)
}))
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now),
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
hand_summary_modal(g, socket, now)
}
_ => view! {},
})
(move || game_over_view(over.get_clone(), game.get_clone()))
@@ -343,9 +350,13 @@ pub fn GamePage(id: String) -> View {
}
}
/// Lobby view while waiting for the fourth player.
/// Lobby view while waiting for the remaining players. The seat count
/// comes from the payload (`seats_open`), so games with other player
/// counts render correctly.
fn lobby_view(game: GameView) -> View {
let seats_open = 4usize.saturating_sub(game.players.len());
let seats_open = game
.seats_open
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
let join_code = game.join_code.clone();
let players = game
.players
@@ -374,6 +385,18 @@ fn lobby_view(game: GameView) -> View {
}
}
/// Fallback for live games the web client has no play view for yet.
fn unsupported_game_view(game: GameView) -> View {
let game_type = game.game_type.clone();
view! {
div(class="panel status-panel") {
h2 { "Unsupported game" }
p { "Live play for “" (game_type) "” isn't supported in the web client yet." }
p { a(href="/") { "Back to lobby" } }
}
}
}
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
@@ -537,8 +560,14 @@ fn capture_picker(
}
}
/// End-of-match overlay.
/// End-of-match overlay (scopone's team score; other games render
/// nothing until they ship their own play view).
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" {
return view! {};
}
}
let result = over.or_else(|| {
game.clone()
.filter(|g| g.phase == "finished")