Preview a match by join code before joining
CI / Build and push docker image (push) Successful in 1m22s
CI / Build and push docker image (push) Successful in 1m22s
GET /api/games/preview returns the lobby behind a code plus the game's name and engine-echoed options. The lobby debounces the lookup as a complete code is typed and shows the game, seated players, open seats and option badges rendered from the game's schema, disabling Join when the lobby is full or the match has already started.
This commit is contained in:
+225
-2
@@ -337,6 +337,83 @@ pub struct GameTypesPage {
|
||||
pub results: Vec<GameTypeInfo>,
|
||||
}
|
||||
|
||||
/// A join-code lookup result (GET /api/games/preview): the game's lobby
|
||||
/// payload plus its display name, for the pre-join summary. The options
|
||||
/// echoed by the engine land in `details` under their `options_schema`
|
||||
/// keys.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct JoinPreview {
|
||||
#[serde(default)]
|
||||
pub game_type: String,
|
||||
#[serde(default)]
|
||||
pub game_name: String,
|
||||
#[serde(default)]
|
||||
pub game_description: String,
|
||||
#[serde(default)]
|
||||
pub players: Vec<PreviewPlayer>,
|
||||
#[serde(default)]
|
||||
pub seats_open: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub phase: String,
|
||||
#[serde(flatten)]
|
||||
pub details: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// One seated player in a join preview.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PreviewPlayer {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub seat: usize,
|
||||
#[serde(default)]
|
||||
pub team: Option<String>,
|
||||
}
|
||||
|
||||
/// One option to show in the join preview, rendered from the game's
|
||||
/// schema and the engine-echoed value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreviewOption {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
/// Present for booleans, for on/off styling.
|
||||
pub on: Option<bool>,
|
||||
}
|
||||
|
||||
impl JoinPreview {
|
||||
/// Whether the lobby is still waiting for players with a free seat.
|
||||
pub fn joinable(&self) -> bool {
|
||||
self.phase == "lobby" && self.seats_open.map(|n| n > 0).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The open-seat note for the preview, e.g. `" · 2 seats open"` (empty
|
||||
/// when the engine does not report a seat count).
|
||||
pub fn seats_text(&self) -> String {
|
||||
match self.seats_open {
|
||||
Some(1) => " · 1 seat open".to_string(),
|
||||
Some(n) => format!(" · {n} seats open"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The options to display: every schema field the engine echoed, in
|
||||
/// schema order. Fields the engine does not echo are skipped.
|
||||
pub fn options(&self, fields: &[OptionField]) -> Vec<PreviewOption> {
|
||||
fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
let raw = self.details.get(&field.key)?;
|
||||
let value = field.format_value(raw)?;
|
||||
let on = match &field.kind {
|
||||
OptionKind::Boolean => raw.as_bool(),
|
||||
_ => None,
|
||||
};
|
||||
Some(PreviewOption { label: field.label.clone(), value, on })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One creation option rendered from a game's `options_schema`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OptionKind {
|
||||
@@ -350,14 +427,52 @@ pub enum OptionKind {
|
||||
pub struct OptionField {
|
||||
pub key: String,
|
||||
pub title: String,
|
||||
/// Compact label for badges: the schema `title` when present, else the
|
||||
/// key with underscores turned into spaces.
|
||||
pub label: String,
|
||||
pub kind: OptionKind,
|
||||
pub default: serde_json::Value,
|
||||
}
|
||||
|
||||
impl OptionField {
|
||||
/// Human-readable rendering of one engine-echoed option value.
|
||||
pub fn format_value(&self, value: &serde_json::Value) -> Option<String> {
|
||||
match &self.kind {
|
||||
OptionKind::Boolean => value
|
||||
.as_bool()
|
||||
.map(|b| if b { "On" } else { "Off" }.to_string()),
|
||||
OptionKind::Integer { .. } => value
|
||||
.as_i64()
|
||||
.map(|n| n.to_string())
|
||||
.or_else(|| value.as_f64().map(fmt_points)),
|
||||
OptionKind::Text | OptionKind::Enum { .. } => {
|
||||
value.as_str().map(str::to_string)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_string(schema: &serde_json::Value, key: &str) -> Option<String> {
|
||||
schema.get(key).and_then(|v| v.as_str()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// Turn a schema key into a readable label: `asso_pigliatutto` ->
|
||||
/// "Asso pigliatutto".
|
||||
fn humanize_key(key: &str) -> String {
|
||||
let spaced = key.replace('_', " ");
|
||||
let spaced = spaced.trim();
|
||||
if spaced.is_empty() {
|
||||
return key.to_string();
|
||||
}
|
||||
let mut chars = spaced.chars();
|
||||
let mut out = String::new();
|
||||
if let Some(first) = chars.next() {
|
||||
out.extend(first.to_uppercase());
|
||||
out.push_str(chars.as_str());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn schema_i64(schema: &serde_json::Value, key: &str) -> Option<i64> {
|
||||
schema.get(key).and_then(|v| {
|
||||
v.as_i64().or_else(|| {
|
||||
@@ -378,9 +493,12 @@ impl GameTypeInfo {
|
||||
properties
|
||||
.iter()
|
||||
.filter_map(|(key, schema)| {
|
||||
let title = schema_string(schema, "title")
|
||||
let explicit_title = schema_string(schema, "title");
|
||||
let title = explicit_title
|
||||
.clone()
|
||||
.or_else(|| schema_string(schema, "description"))
|
||||
.unwrap_or_else(|| key.clone());
|
||||
let label = explicit_title.unwrap_or_else(|| humanize_key(key));
|
||||
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 {
|
||||
@@ -403,7 +521,7 @@ impl GameTypeInfo {
|
||||
Some("string") => OptionKind::Text,
|
||||
_ => return None,
|
||||
};
|
||||
Some(OptionField { key: key.clone(), title, kind, default })
|
||||
Some(OptionField { key: key.clone(), title, label, kind, default })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -714,6 +832,111 @@ mod tests {
|
||||
assert!(!legacy.pigliatutto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_preview_parses_scopone_payload() {
|
||||
let p: JoinPreview = serde_json::from_value(json!({
|
||||
"id": "11111111-2222-3333-4444-555555555555",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "scopone_scientifico",
|
||||
"game_name": "Scopone scientifico",
|
||||
"game_description": "Four players in fixed partnerships.",
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
|
||||
],
|
||||
"seats_open": 3,
|
||||
"phase": "lobby",
|
||||
"target_score": 16,
|
||||
"napola": false,
|
||||
"asso_pigliatutto": true,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("Scopone scientifico", p.game_name);
|
||||
assert_eq!("lobby", p.phase);
|
||||
assert!(p.joinable());
|
||||
assert_eq!(Some(3), p.seats_open);
|
||||
assert_eq!("Alice", p.players[0].name);
|
||||
assert_eq!(Some("A".to_string()), p.players[0].team);
|
||||
// Options render from the schema, in schema order; keys the
|
||||
// engine did not echo are skipped.
|
||||
let opts = p.options(&scopone_game_type().option_fields());
|
||||
assert_eq!(3, opts.len());
|
||||
let by_label: HashMap<_, _> =
|
||||
opts.into_iter().map(|o| (o.label.clone(), o)).collect();
|
||||
assert_eq!("Target score", by_label["Target score"].label);
|
||||
assert_eq!("16", by_label["Target score"].value);
|
||||
assert_eq!(None, by_label["Target score"].on);
|
||||
assert_eq!("Napola", by_label["Napola"].label);
|
||||
assert_eq!("Off", by_label["Napola"].value);
|
||||
assert_eq!(Some(false), by_label["Napola"].on);
|
||||
assert_eq!("Asso pigliatutto", by_label["Asso pigliatutto"].label);
|
||||
assert_eq!("On", by_label["Asso pigliatutto"].value);
|
||||
assert_eq!(Some(true), by_label["Asso pigliatutto"].on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_preview_joinable_states() {
|
||||
let started: JoinPreview = serde_json::from_value(json!({
|
||||
"game_type": "x", "phase": "playing", "seats_open": 0,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!started.joinable());
|
||||
let full: JoinPreview = serde_json::from_value(json!({
|
||||
"game_type": "x", "phase": "lobby", "seats_open": 0,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!full.joinable());
|
||||
let open: JoinPreview = serde_json::from_value(json!({
|
||||
"game_type": "x", "phase": "lobby", "seats_open": 1,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(open.joinable());
|
||||
assert!(open.options(&scopone_game_type().option_fields()).is_empty());
|
||||
// Engines that don't report seats_open are assumed joinable.
|
||||
let unknown: JoinPreview = serde_json::from_value(json!({
|
||||
"game_type": "x", "phase": "lobby",
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(unknown.joinable());
|
||||
assert_eq!("", unknown.seats_text());
|
||||
assert_eq!(" · 1 seat open", open.seats_text());
|
||||
assert_eq!(" · 0 seats open", full.seats_text());
|
||||
let two: JoinPreview = serde_json::from_value(json!({
|
||||
"game_type": "x", "phase": "lobby", "seats_open": 2,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(" · 2 seats open", two.seats_text());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_field_formats_values() {
|
||||
let g: GameTypeInfo = serde_json::from_value(json!({
|
||||
"id": "x",
|
||||
"name": "x",
|
||||
"options_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"flag": {"type": "boolean", "title": "Flag", "default": false},
|
||||
"count": {"type": "integer", "default": 2},
|
||||
"mode": {"type": "string", "enum": ["a", "b"], "default": "a"},
|
||||
"note": {"type": "string"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
.unwrap();
|
||||
let fields: HashMap<_, _> =
|
||||
g.option_fields().into_iter().map(|f| (f.key.clone(), f)).collect();
|
||||
assert_eq!(Some("On".to_string()), fields["flag"].format_value(&json!(true)));
|
||||
assert_eq!(Some("Off".to_string()), fields["flag"].format_value(&json!(false)));
|
||||
assert_eq!(None, fields["flag"].format_value(&json!("yes")));
|
||||
assert_eq!(Some("16".to_string()), fields["count"].format_value(&json!(16)));
|
||||
assert_eq!(Some("2.5".to_string()), fields["count"].format_value(&json!(2.5)));
|
||||
assert_eq!(Some("b".to_string()), fields["mode"].format_value(&json!("b")));
|
||||
assert_eq!(Some("hi".to_string()), fields["note"].format_value(&json!("hi")));
|
||||
// Badges use the schema title, or the humanized key without one.
|
||||
assert_eq!("Flag", fields["flag"].label);
|
||||
assert_eq!("Count", fields["count"].label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_type_without_schema_has_no_fields() {
|
||||
let g: GameTypeInfo = serde_json::from_value(json!({
|
||||
|
||||
Reference in New Issue
Block a user