From 0b06f23311f706a9c6b1d0cf159ff0aab745986b Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Sat, 19 Sep 2026 08:57:20 +0000 Subject: [PATCH] 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. --- web/src/api.rs | 7 +- web/src/model.rs | 377 ++++++++++++++++++++++++++++++++++- web/src/pages/game.rs | 39 +++- web/src/pages/history.rs | 153 +++++++++++--- web/src/pages/leaderboard.rs | 4 +- web/src/pages/lobby.rs | 256 +++++++++++++++++++++--- 6 files changed, 763 insertions(+), 73 deletions(-) diff --git a/web/src/api.rs b/web/src/api.rs index fb51af5..fd47ad7 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -35,9 +35,12 @@ pub async fn game_types() -> Result, String> { Ok(page.results) } -pub async fn create_game(game_type: &str, target_score: i32, napola: bool) -> Result { +/// 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 { 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())? .send() .await diff --git a/web/src/model.rs b/web/src/model.rs index 2d394ff..ae78e1f 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -182,25 +182,36 @@ pub struct MatchPlayer { pub user_sub: String, pub display_name: String, pub seat: usize, - pub team: String, + /// Game-defined team label; absent for games without fixed teams. + #[serde(default)] + pub team: Option, 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 /// recorded before ratings existed. #[serde(default)] pub elo_delta: Option, + /// 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)] #[allow(dead_code)] pub struct MatchSummary { pub id: String, #[serde(default)] pub game_type: String, - pub team_a_score: i32, - pub team_b_score: i32, - pub winner_team: String, - pub target_score: i32, - pub hands_played: i32, + #[serde(default)] + pub result: serde_json::Value, pub started_at: String, pub finished_at: String, #[serde(default)] @@ -212,6 +223,32 @@ pub struct MatchSummary { pub players: Vec, } +impl MatchSummary { + /// Read a string field from the game-specific result, if present. + pub fn result_str(&self, key: &str) -> Option { + 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 { + 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)] pub struct MatchesPage { #[serde(default)] @@ -234,7 +271,18 @@ pub struct LeaderboardEntry { pub elo: i32, pub matches: 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). @@ -266,6 +314,15 @@ pub struct GameTypeInfo { pub name: String, #[serde(default)] 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)] @@ -274,6 +331,88 @@ pub struct GameTypesPage { pub results: Vec, } +/// One creation option rendered from a game's `options_schema`. +#[derive(Debug, Clone, PartialEq)] +pub enum OptionKind { + Integer { min: Option, max: Option }, + Boolean, + Text, + Enum { values: Vec }, +} + +#[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 { + schema.get(key).and_then(|v| v.as_str()).map(str::to_string) +} + +fn schema_i64(schema: &serde_json::Value, key: &str) -> Option { + 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 { + 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 { + self.option_fields() + .into_iter() + .map(|f| (f.key, f.default)) + .collect() + } +} + /// Map a card code (e.g. `07D`) to its asset path. pub fn card_asset(code: &str) -> String { format!("/assets/cards/{code}.svg") @@ -314,6 +453,7 @@ pub fn card_label(code: &str) -> String { #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn card_asset_maps_code() { @@ -328,4 +468,227 @@ mod tests { assert_eq!("Re di bastoni", card_label("10B")); 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); + } } diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index 1c89c90..9d0cadd 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -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 { 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)>, game: Option) -> 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") diff --git a/web/src/pages/history.rs b/web/src/pages/history.rs index 9ece686..1acc720 100644 --- a/web/src/pages/history.rs +++ b/web/src/pages/history.rs @@ -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> { + let mut teams = BTreeSet::new(); + for p in &m.players { + teams.insert(p.team.clone()); + } + let mut ordered: Vec> = 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 { + m.players + .iter() + .filter(|p| &p.team == team) + .map(|p| p.display_name.clone()) + .collect::>() + .join(" & ") +} + +fn team_header(team: &Option) -> 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::::None); // Accumulated rows across "load more" clicks. let rows = create_signal(Vec::::new()); + let game_types = create_signal(Vec::::new()); let load = move |next: Option| { 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::(); + let game = game_name(&names, &m.game_type); + let mut cells: Vec = team_columns(&m) .iter() - .filter(|p| p.team == "A") - .map(|p| p.display_name.clone()) - .collect::>() - .join(" & "); - let team_b: String = m - .players - .iter() - .filter(|p| p.team == "B") - .map(|p| p.display_name.clone()) - .collect::>() - .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::()) } - 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::>(); + // 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> = + 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 = 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" } diff --git a/web/src/pages/leaderboard.rs b/web/src/pages/leaderboard.rs index a91621c..1f95db3 100644 --- a/web/src/pages/leaderboard.rs +++ b/web/src/pages/leaderboard.rs @@ -4,7 +4,7 @@ use sycamore::prelude::*; use crate::api; use crate::components::toast::toast; -use crate::model::LeaderboardPage; +use crate::model::{fmt_points, LeaderboardPage}; #[component] pub fn LeaderboardPage() -> View { @@ -42,7 +42,7 @@ pub fn LeaderboardPage() -> View { td { (e.elo) } td { (e.wins) } td { (e.matches) } - td { (e.points) } + td { (fmt_points(e.points)) } } } }) diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index 41a4548..11bf39b 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -1,11 +1,17 @@ //! 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 sycamore::prelude::*; use sycamore_router::navigate; use crate::api; 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. fn fallback_game_types() -> Vec { @@ -13,20 +19,184 @@ fn fallback_game_types() -> Vec { 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, }] } +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>, +} + +#[component] +fn OptionInput(props: OptionInputProps) -> View { + let field = props.field; + let options: Signal> = 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::() + .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] pub fn LobbyPage() -> View { // Outer None = still loading; Some(None) = logged out. let user = create_signal(Option::>::None); - // The player's Elo rating for the first rated game; None while loading. - let rating = create_signal(Option::::None); + // The player's Elo ratings, one row per game type played. + let ratings = create_signal(Vec::::new()); let error = create_signal(Option::::None); let code = create_signal(String::new()); let game_types = create_signal(fallback_game_types()); 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::::new()); + let fields = create_signal(Vec::::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 { match api::me().await { @@ -34,8 +204,7 @@ pub fn LobbyPage() -> View { if me.is_some() { spawn_local(async move { if let Ok(p) = api::my_ratings().await { - // Unrated players sit at the initial 1500. - rating.set(Some(p.results.first().map(|r| r.rating).unwrap_or(1500))); + ratings.set(p.results); } }); } @@ -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 napola = napola.get(); + let opts: serde_json::Map = + options.get_clone().into_iter().collect(); 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)), Err(e) => error.set(Some(e)), } @@ -84,7 +254,7 @@ pub fn LobbyPage() -> View { view! { div(class="lobby") { - h1 { "Scopone scientifico" } + h1 { "tavolo" } (toast(error)) (move || match user.get_clone() { None => view! { p(class="status") { "Loading…" } }, @@ -102,9 +272,13 @@ pub fn LobbyPage() -> View { nav(class="top-nav") { span(class="whoami") { "Signed in as " strong { (me.name.clone()) } - (rating.get_clone().map(|r| view! { - span(class="rating") { " · Elo " (r) } - })) + (move || ratings + .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="/leaderboard") { "Leaderboard" } @@ -120,20 +294,48 @@ pub fn LobbyPage() -> View { key=|g| g.id.clone(), ) } - label(class="check", r#for="napola") { - input(id="napola", r#type="checkbox", bind:checked=napola) - " Napola" - } - p(class="hint") { - "A-2-3 of denari scores 3 points, plus 1 per extra " - "consecutive denari card; sweeping the whole suit " - "wins the match instantly." - } - p { "First team to reach the target score wins." } - div(class="target-buttons") { - button(class="button", on:click=move |_| on_create(11)) { "Target 11" } - button(class="button", on:click=move |_| on_create(16)) { "Target 16" } - button(class="button", on:click=move |_| on_create(21)) { "Target 21" } + (move || game_types + .get_clone() + .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")) + } + (min, max) => { + Some(format!("{min}–{max} players")) + } + }; + let description: View = if g.description.is_empty() { + view! {} + } else { + 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") {