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
+370 -7
View File
@@ -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<String>,
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<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)]
#[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<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)]
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<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.
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);
}
}