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-19 08:57:20 +00:00
parent 5a73601ddf
commit 0b06f23311
6 changed files with 763 additions and 73 deletions
+5 -2
View File
@@ -35,9 +35,12 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
Ok(page.results) Ok(page.results)
} }
pub async fn create_game(game_type: &str, target_score: i32, napola: bool) -> Result<GameView, String> { /// Create a lobby game. `options` is the game-specific creation object
/// described by the engine's `options_schema` (see
/// [`crate::model::GameTypeInfo::option_fields`]).
pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<GameView, String> {
let resp = Request::post("/api/games") let resp = Request::post("/api/games")
.json(&serde_json::json!({ "game_type": game_type, "target_score": target_score, "napola": napola })) .json(&serde_json::json!({ "game_type": game_type, "options": options }))
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.send() .send()
.await .await
+370 -7
View File
@@ -182,25 +182,36 @@ pub struct MatchPlayer {
pub user_sub: String, pub user_sub: String,
pub display_name: String, pub display_name: String,
pub seat: usize, pub seat: usize,
pub team: String, /// Game-defined team label; absent for games without fixed teams.
#[serde(default)]
pub team: Option<String>,
pub won: bool, pub won: bool,
/// Points the player scored in this match.
#[serde(default)]
pub score: f64,
/// Elo change this match produced for the player; absent for matches /// Elo change this match produced for the player; absent for matches
/// recorded before ratings existed. /// recorded before ratings existed.
#[serde(default)] #[serde(default)]
pub elo_delta: Option<i32>, pub elo_delta: Option<i32>,
/// Game-specific extras reported by the engine.
#[serde(default)]
pub details: serde_json::Value,
} }
/// The game-specific outcome of a finished match, as reported by the
/// engine (for scopone: the teams' final scores, the winner, the target
/// score, hands played and the per-hand audit). Games define their own
/// shape, so callers read it through [`MatchSummary::result_str`] and
/// [`MatchSummary::result_i64`], which return `None` for absent or
/// mistyped values.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct MatchSummary { pub struct MatchSummary {
pub id: String, pub id: String,
#[serde(default)] #[serde(default)]
pub game_type: String, pub game_type: String,
pub team_a_score: i32, #[serde(default)]
pub team_b_score: i32, pub result: serde_json::Value,
pub winner_team: String,
pub target_score: i32,
pub hands_played: i32,
pub started_at: String, pub started_at: String,
pub finished_at: String, pub finished_at: String,
#[serde(default)] #[serde(default)]
@@ -212,6 +223,32 @@ pub struct MatchSummary {
pub players: Vec<MatchPlayer>, pub players: Vec<MatchPlayer>,
} }
impl MatchSummary {
/// Read a string field from the game-specific result, if present.
pub fn result_str(&self, key: &str) -> Option<String> {
self.result
.get(key)
.and_then(|v| v.as_str())
.map(str::to_string)
}
/// Read an integer field from the game-specific result, if present.
/// JSON floats with an integral value (e.g. `11.0`) are accepted.
pub fn result_i64(&self, key: &str) -> Option<i64> {
self.result.get(key).and_then(|v| {
v.as_i64().or_else(|| {
v.as_f64().and_then(|f| {
if f.fract() == 0.0 {
Some(f as i64)
} else {
None
}
})
})
})
}
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct MatchesPage { pub struct MatchesPage {
#[serde(default)] #[serde(default)]
@@ -234,7 +271,18 @@ pub struct LeaderboardEntry {
pub elo: i32, pub elo: i32,
pub matches: i32, pub matches: i32,
pub wins: i32, pub wins: i32,
pub points: i32, /// Aggregated points; the backend reports a float.
#[serde(default)]
pub points: f64,
}
/// Render aggregated points: integral values without decimals.
pub fn fmt_points(points: f64) -> String {
if points.fract() == 0.0 {
format!("{}", points as i64)
} else {
format!("{points:.1}")
}
} }
/// The caller's Elo rating for one game type (GET /api/me/ratings). /// The caller's Elo rating for one game type (GET /api/me/ratings).
@@ -266,6 +314,15 @@ pub struct GameTypeInfo {
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
pub description: String, pub description: String,
/// Player-count range of the game; zero when the backend predates them.
#[serde(default)]
pub min_players: usize,
#[serde(default)]
pub max_players: usize,
/// JSON-schema fragment describing the game-specific creation options
/// accepted by POST /api/games (see [`OptionField`]).
#[serde(default)]
pub options_schema: serde_json::Value,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -274,6 +331,88 @@ pub struct GameTypesPage {
pub results: Vec<GameTypeInfo>, pub results: Vec<GameTypeInfo>,
} }
/// One creation option rendered from a game's `options_schema`.
#[derive(Debug, Clone, PartialEq)]
pub enum OptionKind {
Integer { min: Option<i64>, max: Option<i64> },
Boolean,
Text,
Enum { values: Vec<String> },
}
#[derive(Debug, Clone, PartialEq)]
pub struct OptionField {
pub key: String,
pub title: String,
pub kind: OptionKind,
pub default: serde_json::Value,
}
fn schema_string(schema: &serde_json::Value, key: &str) -> Option<String> {
schema.get(key).and_then(|v| v.as_str()).map(str::to_string)
}
fn schema_i64(schema: &serde_json::Value, key: &str) -> Option<i64> {
schema.get(key).and_then(|v| {
v.as_i64().or_else(|| {
v.as_f64()
.and_then(|f| if f.fract() == 0.0 { Some(f as i64) } else { None })
})
})
}
impl GameTypeInfo {
/// Parse the game's `options_schema` properties into renderable
/// fields, in alphabetical key order. Properties of unrecognized
/// types are skipped.
pub fn option_fields(&self) -> Vec<OptionField> {
let Some(properties) = self.options_schema.get("properties").and_then(|v| v.as_object()) else {
return Vec::new();
};
properties
.iter()
.filter_map(|(key, schema)| {
let title = schema_string(schema, "title")
.or_else(|| schema_string(schema, "description"))
.unwrap_or_else(|| key.clone());
let default = schema.get("default").cloned().unwrap_or(serde_json::Value::Null);
let kind = match schema.get("type").and_then(|v| v.as_str()) {
Some("integer") => OptionKind::Integer {
min: schema_i64(schema, "minimum"),
max: schema_i64(schema, "maximum"),
},
Some("boolean") => OptionKind::Boolean,
Some("string") if schema.get("enum").and_then(|v| v.as_array()).is_some() => {
OptionKind::Enum {
values: schema["enum"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
}
}
Some("string") => OptionKind::Text,
_ => return None,
};
Some(OptionField { key: key.clone(), title, kind, default })
})
.collect()
}
/// The schema defaults as a JSON object, for initializing the
/// creation form (and as the request body when the user changes
/// nothing).
pub fn default_options(&self) -> serde_json::Map<String, serde_json::Value> {
self.option_fields()
.into_iter()
.map(|f| (f.key, f.default))
.collect()
}
}
/// Map a card code (e.g. `07D`) to its asset path. /// Map a card code (e.g. `07D`) to its asset path.
pub fn card_asset(code: &str) -> String { pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg") format!("/assets/cards/{code}.svg")
@@ -314,6 +453,7 @@ pub fn card_label(code: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde_json::json;
#[test] #[test]
fn card_asset_maps_code() { fn card_asset_maps_code() {
@@ -328,4 +468,227 @@ mod tests {
assert_eq!("Re di bastoni", card_label("10B")); assert_eq!("Re di bastoni", card_label("10B"));
assert_eq!("7 di denari", card_label("07D")); assert_eq!("7 di denari", card_label("07D"));
} }
fn scopone_match() -> MatchSummary {
serde_json::from_value(json!({
"id": "m1",
"game_type": "scopone_scientifico",
"result": {
"team_a_score": 11,
"team_b_score": 7.0,
"winner_team": "A",
"target_score": 11,
"hands_played": 3,
},
"started_at": "2026-01-01T00:00:00+00:00",
"finished_at": "2026-01-01T01:00:00+00:00",
"you_won": true,
"players": [
{"user_sub": "a", "display_name": "a", "seat": 0,
"team": "A", "won": true, "score": 11.0},
{"user_sub": "b", "display_name": "b", "seat": 1,
"team": null, "won": false},
],
}))
.unwrap()
}
#[test]
fn match_summary_reads_scopone_result() {
let m = scopone_match();
assert_eq!(Some(11), m.result_i64("team_a_score"));
// Integral floats are accepted.
assert_eq!(Some(7), m.result_i64("team_b_score"));
assert_eq!(Some("A".to_string()), m.result_str("winner_team"));
assert_eq!(None, m.result_str("team_a_score"));
assert_eq!(None, m.result_i64("missing"));
assert_eq!(None, m.result_i64("winner_team"));
}
#[test]
fn match_summary_tolerates_empty_result() {
let m: MatchSummary = serde_json::from_value(json!({
"id": "m2",
"started_at": "x",
"finished_at": "y",
}))
.unwrap();
assert_eq!(None, m.result_i64("team_a_score"));
assert_eq!(None, m.result_str("winner_team"));
assert!(m.players.is_empty());
}
#[test]
fn match_player_team_is_optional() {
let m = scopone_match();
assert_eq!(Some("A".to_string()), m.players[0].team);
assert_eq!(None, m.players[1].team);
assert_eq!(11.0, m.players[0].score);
assert_eq!(0.0, m.players[1].score);
}
#[test]
fn fmt_points_drops_integral_decimals() {
assert_eq!("11", fmt_points(11.0));
assert_eq!("0", fmt_points(0.0));
assert_eq!("2.5", fmt_points(2.5));
}
#[test]
fn leaderboard_points_accept_floats() {
let e: LeaderboardEntry = serde_json::from_value(json!({
"user_sub": "a",
"display_name": "a",
"matches": 2,
"wins": 1,
"points": 19.0,
}))
.unwrap();
assert_eq!(19.0, e.points);
assert_eq!(1500, e.elo);
}
fn scopone_game_type() -> GameTypeInfo {
serde_json::from_value(json!({
"id": "scopone_scientifico",
"name": "Scopone scientifico",
"description": "d",
"min_players": 4,
"max_players": 4,
"options_schema": {
"type": "object",
"properties": {
"target_score": {
"type": "integer", "minimum": 1, "maximum": 100,
"default": 11,
},
"napola": {"type": "boolean", "default": true},
},
},
}))
.unwrap()
}
#[test]
fn option_fields_parse_scopone_schema() {
let fields = scopone_game_type().option_fields();
assert_eq!(2, fields.len());
let by_key: std::collections::HashMap<_, _> =
fields.into_iter().map(|f| (f.key.clone(), f)).collect();
let target = &by_key["target_score"];
assert!(matches!(
target.kind,
OptionKind::Integer { min: Some(1), max: Some(100) }
));
assert_eq!(json!(11), target.default);
assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
assert_eq!(json!(true), by_key["napola"].default);
}
#[test]
fn option_fields_skip_unknown_types() {
let g: GameTypeInfo = serde_json::from_value(json!({
"id": "x",
"name": "x",
"options_schema": {
"type": "object",
"properties": {
"mystery": {"type": "object"},
"mode": {"type": "string", "enum": ["a", "b"], "default": "a"},
"nick": {"type": "string"},
},
},
}))
.unwrap();
let fields = g.option_fields();
assert_eq!(2, fields.len());
assert_eq!(
OptionKind::Enum { values: vec!["a".to_string(), "b".to_string()] },
fields[0].kind
);
assert_eq!(OptionKind::Text, fields[1].kind);
}
#[test]
fn default_options_collect_schema_defaults() {
let opts = scopone_game_type().default_options();
assert_eq!(Some(&json!(11)), opts.get("target_score"));
assert_eq!(Some(&json!(true)), opts.get("napola"));
}
#[test]
fn game_view_parses_new_lobby_payload() {
// Exact shape of the platform's lobby payload: envelope fields,
// seats with team labels, seats_open, plus the engine's
// lobby_view (phase/target_score/napola for scopone).
let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555",
"join_code": "ABC123",
"game_type": "scopone_scientifico",
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
],
"seats_open": 3,
"phase": "lobby",
"target_score": 16,
"napola": false,
}))
.unwrap();
assert_eq!("ABC123", g.join_code);
assert_eq!(Some(3), g.seats_open);
assert_eq!("lobby", g.phase);
assert_eq!(16, g.target_score);
assert!(!g.napola);
assert_eq!("A", g.players[0].team);
}
#[test]
fn game_view_parses_new_snapshot_payload() {
// Envelope merged with the engine view; hands hidden for others.
let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555",
"join_code": "ABC123",
"game_type": "scopone_scientifico",
"phase": "playing",
"target_score": 11,
"napola": true,
"hand_number": 1,
"dealer": 0,
"turn": 1,
"scores": {"A": 0, "B": 0},
"winner": null,
"table": [],
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
"cards_left": 10, "captured_count": 0, "scope": 0},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
"cards_left": 10, "captured_count": 0, "scope": 0,
"hand": ["01D"]},
],
"last_hand": null,
"last_move": null,
"acknowledged": [],
"hand_end_deadline": null,
"turn_deadline": "2026-01-01T00:00:30+00:00",
"your_turn": true,
"legal_moves": {},
}))
.unwrap();
assert_eq!("playing", g.phase);
assert_eq!(None, g.players[0].hand);
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
assert_eq!(Some(true), g.your_turn);
}
#[test]
fn game_type_without_schema_has_no_fields() {
let g: GameTypeInfo = serde_json::from_value(json!({
"id": "legacy",
"name": "Legacy",
}))
.unwrap();
assert!(g.option_fields().is_empty());
assert!(g.default_options().is_empty());
assert_eq!(0, g.min_players);
}
} }
+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) 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)| { (move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice) capture_picker(card, options, socket, capture_choice)
})) }))
(move || match game.get_clone() { (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! {}, _ => view! {},
}) })
(move || game_over_view(over.get_clone(), game.get_clone())) (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 { 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 join_code = game.join_code.clone();
let players = game let players = game
.players .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> { fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned() 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 { 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(|| { let result = over.or_else(|| {
game.clone() game.clone()
.filter(|g| g.phase == "finished") .filter(|g| g.phase == "finished")
+123 -30
View File
@@ -1,10 +1,72 @@
//! Match history page. //! 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 wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::api; use crate::api;
use crate::components::toast::toast; 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] #[component]
pub fn HistoryPage() -> View { pub fn HistoryPage() -> View {
@@ -13,6 +75,7 @@ pub fn HistoryPage() -> View {
let cursor = create_signal(Option::<String>::None); let cursor = create_signal(Option::<String>::None);
// Accumulated rows across "load more" clicks. // Accumulated rows across "load more" clicks.
let rows = create_signal(Vec::<crate::model::MatchSummary>::new()); let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
let game_types = create_signal(Vec::<GameTypeInfo>::new());
let load = move |next: Option<String>| { let load = move |next: Option<String>| {
spawn_local(async move { 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; let load2 = load;
spawn_local(async move { spawn_local(async move {
match api::my_matches(None).await { match api::my_matches(None).await {
@@ -53,51 +122,75 @@ pub fn HistoryPage() -> View {
p(class="status") { "No matches played yet." } p(class="status") { "No matches played yet." }
}, },
Some(_) => { Some(_) => {
let names = game_types.get_clone();
let table_rows = rows let table_rows = rows
.get_clone() .get_clone()
.into_iter() .into_iter()
.map(|m| { .map(|m| {
let team_a: String = m let finished = m
.players .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() .iter()
.filter(|p| p.team == "A") .map(|t| team_names(&m, t))
.map(|p| p.display_name.clone()) .map(|c| view! { td { (c) } })
.collect::<Vec<_>>() .collect();
.join(" & "); let score = score_text(&m);
let team_b: String = m let winner = winner_text(&m);
.players
.iter()
.filter(|p| p.team == "B")
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ");
let outcome = if m.you_won { "Won" } else { "Lost" }; let outcome = if m.you_won { "Won" } else { "Lost" };
let elo_delta = m let elo_delta = m
.your_elo_delta .your_elo_delta
.map(|d| if d >= 0 { format!("+{d}") } else { d.to_string() }) .map(|d| {
.unwrap_or_else(|| "".to_string()); if d >= 0 {
view! { format!("+{d}")
tr { } else {
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) } d.to_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)
}
}
} }
}) })
.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<_>>(); .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! { view! {
table(class="matches") { table(class="matches") {
thead { thead {
tr { tr {
th { "Finished" } th { "Finished" }
th { "Team A" } th { "Game" }
th { "Team B" } (header_cells)
th { "Score" } th { "Score" }
th { "Winner" } th { "Winner" }
th { "You" } th { "You" }
+2 -2
View File
@@ -4,7 +4,7 @@ use sycamore::prelude::*;
use crate::api; use crate::api;
use crate::components::toast::toast; use crate::components::toast::toast;
use crate::model::LeaderboardPage; use crate::model::{fmt_points, LeaderboardPage};
#[component] #[component]
pub fn LeaderboardPage() -> View { pub fn LeaderboardPage() -> View {
@@ -42,7 +42,7 @@ pub fn LeaderboardPage() -> View {
td { (e.elo) } td { (e.elo) }
td { (e.wins) } td { (e.wins) }
td { (e.matches) } td { (e.matches) }
td { (e.points) } td { (fmt_points(e.points)) }
} }
} }
}) })
+226 -24
View File
@@ -1,11 +1,17 @@
//! Lobby page: login prompt, match creation and joining by code. //! Lobby page: login prompt, match creation and joining by code.
//!
//! Match creation is game-generic: the form is rendered from the selected
//! game's `options_schema` (see [`crate::model::GameTypeInfo`]), so new
//! games get a working creation UI without frontend changes.
use std::collections::HashMap;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*; use sycamore::prelude::*;
use sycamore_router::navigate; use sycamore_router::navigate;
use crate::api; use crate::api;
use crate::components::toast::toast; use crate::components::toast::toast;
use crate::model::{GameTypeInfo, User}; use crate::model::{GameTypeInfo, OptionField, OptionKind, PlayerRating, User};
/// Used when the game-types fetch fails: match creation must still work. /// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> { fn fallback_game_types() -> Vec<GameTypeInfo> {
@@ -13,20 +19,184 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
id: "scopone_scientifico".to_string(), id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(), name: "Scopone scientifico".to_string(),
description: String::new(), description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
}] }]
} }
fn json_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => String::new(),
}
}
/// One creation option: renders the control matching the schema kind and
/// writes the parsed value back into the shared `options` map.
#[derive(Props)]
struct OptionInputProps {
field: OptionField,
options: Signal<HashMap<String, serde_json::Value>>,
}
#[component]
fn OptionInput(props: OptionInputProps) -> View {
let field = props.field;
let options: Signal<HashMap<String, serde_json::Value>> = props.options;
let key = field.key.clone();
let title = field.title.clone();
match field.kind.clone() {
OptionKind::Boolean => {
let initial = options
.get_clone()
.get(&key)
.and_then(|v| v.as_bool())
.unwrap_or_else(|| field.default.as_bool().unwrap_or(false));
let flag = create_signal(initial);
let write_key = key.clone();
let id_attr = key.clone();
let for_attr = key;
create_effect(move || {
let value = flag.get_clone();
options.update(|m| {
m.insert(write_key.clone(), serde_json::Value::Bool(value));
});
});
view! {
label(class="check", r#for=for_attr) {
input(id=id_attr, r#type="checkbox", bind:checked=flag)
" " (title)
}
}
}
OptionKind::Enum { values } => {
let initial = options
.get_clone()
.get(&key)
.map(json_to_string)
.unwrap_or_else(|| json_to_string(&field.default));
let value = create_signal(initial);
let default = field.default.clone();
let write_key = key.clone();
create_effect(move || {
let text = value.get_clone();
let parsed = if text.is_empty() {
default.clone()
} else {
serde_json::Value::String(text)
};
options.update(|m| {
m.insert(write_key.clone(), parsed);
});
});
let items = create_signal(values);
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
select(id=id_attr, bind:value=value) {
Keyed(
list=items,
view=|v| {
let val = v.clone();
let label = v;
view! { option(value=val) { (label) } }
},
key=|v| v.clone(),
)
}
}
}
OptionKind::Integer { min, max } => {
let initial = options
.get_clone()
.get(&key)
.map(json_to_string)
.unwrap_or_else(|| json_to_string(&field.default));
let value = create_signal(initial);
let default = field.default.clone();
let write_key = key.clone();
create_effect(move || {
let text = value.get_clone();
let parsed = text
.parse::<i64>()
.map(serde_json::Value::from)
.unwrap_or_else(|_| default.clone());
options.update(|m| {
m.insert(write_key.clone(), parsed);
});
});
let min_attr = min.map(|m| m.to_string()).unwrap_or_default();
let max_attr = max.map(|m| m.to_string()).unwrap_or_default();
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
input(
id=id_attr,
r#type="number",
min=min_attr,
max=max_attr,
bind:value=value,
)
}
}
OptionKind::Text => {
let initial = options
.get_clone()
.get(&key)
.map(json_to_string)
.unwrap_or_else(|| json_to_string(&field.default));
let value = create_signal(initial);
let write_key = key.clone();
create_effect(move || {
let text = value.get_clone();
options.update(|m| {
m.insert(write_key.clone(), serde_json::Value::String(text));
});
});
let id_attr = key.clone();
let for_attr = key;
view! {
label(r#for=for_attr) { (title) }
input(id=id_attr, r#type="text", bind:value=value)
}
}
}
}
#[component] #[component]
pub fn LobbyPage() -> View { pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out. // Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None); let user = create_signal(Option::<Option<User>>::None);
// The player's Elo rating for the first rated game; None while loading. // The player's Elo ratings, one row per game type played.
let rating = create_signal(Option::<i32>::None); let ratings = create_signal(Vec::<PlayerRating>::new());
let error = create_signal(Option::<String>::None); let error = create_signal(Option::<String>::None);
let code = create_signal(String::new()); let code = create_signal(String::new());
let game_types = create_signal(fallback_game_types()); let game_types = create_signal(fallback_game_types());
let selected_game = create_signal("scopone_scientifico".to_string()); let selected_game = create_signal("scopone_scientifico".to_string());
let napola = create_signal(true); // Current creation-form values (option key -> JSON value), reset to
// the schema defaults whenever the selection changes.
let options = create_signal(HashMap::<String, serde_json::Value>::new());
let fields = create_signal(Vec::<OptionField>::new());
create_effect(move || {
let id = selected_game.get_clone();
let game = game_types.get_clone().into_iter().find(|g| g.id == id);
match game {
Some(g) => {
options.set(g.default_options().into_iter().collect());
fields.set(g.option_fields());
}
None => {
options.set(HashMap::new());
fields.set(Vec::new());
}
}
});
spawn_local(async move { spawn_local(async move {
match api::me().await { match api::me().await {
@@ -34,8 +204,7 @@ pub fn LobbyPage() -> View {
if me.is_some() { if me.is_some() {
spawn_local(async move { spawn_local(async move {
if let Ok(p) = api::my_ratings().await { if let Ok(p) = api::my_ratings().await {
// Unrated players sit at the initial 1500. ratings.set(p.results);
rating.set(Some(p.results.first().map(|r| r.rating).unwrap_or(1500)));
} }
}); });
} }
@@ -58,11 +227,12 @@ pub fn LobbyPage() -> View {
} }
}); });
let on_create = move |target: i32| { let on_create = move |_| {
let game_type = selected_game.get_clone(); let game_type = selected_game.get_clone();
let napola = napola.get(); let opts: serde_json::Map<String, serde_json::Value> =
options.get_clone().into_iter().collect();
spawn_local(async move { spawn_local(async move {
match api::create_game(&game_type, target, napola).await { match api::create_game(&game_type, serde_json::Value::Object(opts)).await {
Ok(game) => navigate(&format!("/game/{}", game.id)), Ok(game) => navigate(&format!("/game/{}", game.id)),
Err(e) => error.set(Some(e)), Err(e) => error.set(Some(e)),
} }
@@ -84,7 +254,7 @@ pub fn LobbyPage() -> View {
view! { view! {
div(class="lobby") { div(class="lobby") {
h1 { "Scopone scientifico" } h1 { "tavolo" }
(toast(error)) (toast(error))
(move || match user.get_clone() { (move || match user.get_clone() {
None => view! { p(class="status") { "Loading…" } }, None => view! { p(class="status") { "Loading…" } },
@@ -102,8 +272,12 @@ pub fn LobbyPage() -> View {
nav(class="top-nav") { nav(class="top-nav") {
span(class="whoami") { span(class="whoami") {
"Signed in as " strong { (me.name.clone()) } "Signed in as " strong { (me.name.clone()) }
(rating.get_clone().map(|r| view! { (move || ratings
span(class="rating") { " · Elo " (r) } .get_clone()
.into_iter()
.find(|r| r.game_type == selected_game.get_clone())
.map(|r| view! {
span(class="rating") { " · Elo " (r.rating) }
})) }))
} }
a(href="/history") { "My matches" } a(href="/history") { "My matches" }
@@ -120,20 +294,48 @@ pub fn LobbyPage() -> View {
key=|g| g.id.clone(), key=|g| g.id.clone(),
) )
} }
label(class="check", r#for="napola") { (move || game_types
input(id="napola", r#type="checkbox", bind:checked=napola) .get_clone()
" Napola" .into_iter()
.find(|g| g.id == selected_game.get_clone())
.map(|g| {
let players_note = match (g.min_players, g.max_players) {
(0, 0) => None,
(min, max) if min == max => {
Some(format!("{min} players"))
} }
p(class="hint") { (min, max) => {
"A-2-3 of denari scores 3 points, plus 1 per extra " Some(format!("{min}{max} players"))
"consecutive denari card; sweeping the whole suit "
"wins the match instantly."
} }
p { "First team to reach the target score wins." } };
div(class="target-buttons") { let description: View = if g.description.is_empty() {
button(class="button", on:click=move |_| on_create(11)) { "Target 11" } view! {}
button(class="button", on:click=move |_| on_create(16)) { "Target 16" } } else {
button(class="button", on:click=move |_| on_create(21)) { "Target 21" } let text = g.description.clone();
view! {
p(class="hint") { (text) }
}
};
let note: View = match players_note {
Some(note) => view! {
p(class="hint") { (note) }
},
None => view! {},
};
view! {
(description)
(note)
}
}))
Keyed(
list=fields,
view=move |f| view! {
OptionInput(field=f.clone(), options=options)
},
key=|f| f.key.clone(),
)
button(class="button primary", on:click=on_create) {
"Create match"
} }
} }
div(class="panel") { div(class="panel") {