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
+229 -27
View File
@@ -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<GameTypeInfo> {
@@ -13,20 +19,184 @@ fn fallback_game_types() -> Vec<GameTypeInfo> {
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<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]
pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None);
// The player's Elo rating for the first rated game; None while loading.
let rating = create_signal(Option::<i32>::None);
// The player's Elo ratings, one row per game type played.
let ratings = create_signal(Vec::<PlayerRating>::new());
let error = create_signal(Option::<String>::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::<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 {
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<String, serde_json::Value> =
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") {