//! Serde types mirroring the backend API payloads. use serde::Deserialize; use std::collections::HashMap; fn default_true() -> bool { true } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct User { pub sub: String, pub name: String, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct PlayerView { pub sub: String, pub name: String, pub seat: usize, /// Game-defined role/team label; empty before briscola chiamata /// reveals the roles. #[serde(default)] pub team: Option, #[serde(default)] pub cards_left: usize, #[serde(default)] pub captured_count: usize, #[serde(default)] pub scope: i32, /// Briscola: point value of the player's captured pile. #[serde(default)] pub points: i32, /// Own hand only; absent for the other players. #[serde(default)] pub hand: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Deserialize)] pub struct Scores { #[serde(rename = "A")] pub a: i32, #[serde(rename = "B")] pub b: i32, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct MoveView { pub seat: usize, pub name: String, pub card: String, #[serde(default)] pub captured: Vec, #[serde(default)] pub scopa: bool, /// The play was an asso pigliatutto sweep (it took the whole table). #[serde(default)] pub pigliatutto: bool, } /// One card played in the current briscola trick, in play order. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct TrickCard { pub seat: usize, pub card: String, } /// The most recently completed briscola trick. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct LastTrick { pub winner: usize, #[serde(default)] pub cards: Vec, } /// The scoring breakdown of one completed briscola deal. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct DealSummary { #[serde(default)] pub deal: i32, /// A 60-60 deal: its points are discarded and the deal is replayed. #[serde(default)] pub patta: bool, pub points: Scores, /// Running match totals; absent for briscola chiamata, whose match is /// a single deal. #[serde(default)] pub totals: Option, #[serde(default)] pub cards: Option, /// Briscola chiamata: the points target of the played deal. #[serde(default)] pub declared_target: Option, /// Briscola chiamata: the card named by the caller. #[serde(default)] pub called_card: Option, /// Briscola chiamata: the caller played without a partner. #[serde(default)] pub solo: bool, } #[derive(Debug, Clone, Copy, Deserialize)] pub struct TeamCounts { #[serde(rename = "A")] pub a: i32, #[serde(rename = "B")] pub b: i32, } #[derive(Debug, Clone, Copy, Deserialize)] #[allow(dead_code)] pub struct TeamBools { #[serde(rename = "A")] pub a: bool, #[serde(rename = "B")] pub b: bool, } /// Which team (if any) won each scoring category of a hand. #[derive(Debug, Clone, Deserialize)] pub struct Award { #[serde(default)] pub carte: Option, #[serde(default)] pub denara: Option, #[serde(default)] pub settebello: Option, #[serde(default)] pub primiera: Option, #[serde(default)] pub napola: Option, } /// The scoring breakdown of one completed hand. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct HandSummary { pub cards: TeamCounts, pub denara: TeamCounts, pub settebello: TeamBools, pub primiera: TeamCounts, pub scope: TeamCounts, /// Napola run lengths per team; absent when the rule is disabled (or /// the summary predates the option). #[serde(default)] pub napola: Option, pub award: Award, #[serde(default)] pub hand: i32, #[serde(default)] pub team_a_points: i32, #[serde(default)] pub team_b_points: i32, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct GameView { pub id: String, /// The lobby join code; withheld by the server from spectators. #[serde(default)] pub join_code: String, /// Which card game this match is (id from /api/game-types). #[serde(default)] pub game_type: String, /// Whether the viewer is watching without a seat: no hand, no join /// code, and actions are rejected by the server. #[serde(default)] pub spectator: bool, /// Whether the napola rule is scored in this match. #[serde(default = "default_true")] pub napola: bool, /// Whether the asso pigliatutto rule is scored in this match. #[serde(default)] pub asso_pigliatutto: bool, pub phase: String, #[serde(default)] pub target_score: i32, #[serde(default)] pub hand_number: i32, #[serde(default)] pub dealer: usize, #[serde(default)] pub turn: usize, #[serde(default)] pub scores: Option, #[serde(default)] pub winner: Option, #[serde(default)] pub table: Vec, /// Scopa: face-down cards left in the deck for the current hand. #[serde(default)] pub deck_count: usize, #[serde(default)] pub players: Vec, #[serde(default)] pub seats_open: Option, #[serde(default)] pub last_move: Option, /// Scoring breakdown of the most recent hand (present once a hand has /// been completed). #[serde(default)] pub last_hand: Option, /// Seats that acknowledged the hand-end summary. #[serde(default)] pub acknowledged: Vec, /// ISO-8601 instant at which the next hand is dealt automatically. #[serde(default)] pub hand_end_deadline: Option, /// ISO-8601 instant at which the server plays a random legal card for /// the player on turn. #[serde(default)] pub turn_deadline: Option, #[serde(default)] pub your_turn: Option, /// Legal captures per hand card; present only for the player on turn. #[serde(default)] pub legal_moves: Option>>>, /// Briscola: number of seats at the table (2 or 4). #[serde(default)] pub player_count: usize, /// Briscola: trump suit of the current deal (empty for other games). #[serde(default)] pub briscola_suit: String, /// Briscola: the face-up trump card, until it is drawn. #[serde(default)] pub briscola_card: Option, /// Briscola: face-down cards left in the stock. #[serde(default)] pub stock_count: usize, /// Briscola: cards of the trick in progress, in play order. #[serde(default)] pub trick: Vec, /// Briscola: seat that led the current trick. #[serde(default)] pub trick_leader: Option, /// Briscola: the most recently completed trick. #[serde(default)] pub last_trick: Option, /// Briscola: scoring breakdown of the most recent deal. #[serde(default)] pub last_deal: Option, /// Briscola chiamata: the calling auction of the current deal. #[serde(default)] pub auction: Option, /// Briscola chiamata: seat that won the auction. #[serde(default)] pub caller: Option, /// Briscola chiamata: the named card; its suit is the trump. #[serde(default)] pub called_card: Option, /// Briscola chiamata: points the calling side must reach. #[serde(default)] pub declared_target: i32, /// Briscola chiamata: the called card has been played; roles are /// public from then on. #[serde(default)] pub called_played: bool, /// Briscola chiamata: the partner's seat, once revealed. #[serde(default)] pub partner: Option, /// Briscola chiamata: the caller named one of their own cards. #[serde(default)] pub solo: bool, /// Briscola chiamata: legal bids for the player on turn. #[serde(default)] pub legal_bids: Option, /// Briscola chiamata: the cards the auction winner may name. #[serde(default)] pub callable_cards: Option>, /// Briscola chiamata: ISO-8601 deadline of the auction turn. #[serde(default)] pub auction_deadline: Option, /// Briscola chiamata: ISO-8601 deadline of the auction winner's call. #[serde(default)] pub call_deadline: Option, } /// One bid of the briscola chiamata auction. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct AuctionBid { pub seat: usize, /// Rank bid: 1 ace, 3 three, 10 king, ... 2 two. pub rank: i32, /// Points target the bid commits its side to. #[serde(default)] pub points: i32, } /// The briscola chiamata calling auction. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct AuctionView { /// Seat to act. pub turn: usize, #[serde(default)] pub active: Vec, #[serde(default)] pub passed: Vec, /// The standing bid, if any. #[serde(default)] pub current: Option, #[serde(default)] pub history: Vec, } /// The bids available to the player on turn. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct LegalBids { /// Ranks that may be bid, strongest first (empty at the point stage). #[serde(default)] pub ranks: Vec, /// Lowest legal points raise, when the standing bid is the two. #[serde(default)] pub points_min: Option, #[serde(default)] pub points_max: Option, } impl GameView { /// Display name of the player on team `team` ("A"/"B"), for games /// without partnerships; falls back to the team label itself. pub fn player_name_for_team(&self, team: &str) -> String { self.players .iter() .find(|p| p.team.as_deref() == Some(team)) .map(|p| p.name.clone()) .unwrap_or_else(|| team.to_string()) } /// Display name of the player in `seat`. pub fn player_name_for_seat(&self, seat: usize) -> String { self.players .iter() .find(|p| p.seat == seat) .map(|p| p.name.clone()) .unwrap_or_else(|| format!("Seat {seat}")) } /// Side labels for briscola chiamata: the caller (with the partner's /// name once revealed, or "(solo)") against the defenders. pub fn chiamata_sides(&self) -> (String, String) { let side_a = match self.caller { Some(caller) => { let caller_name = self.player_name_for_seat(caller); match self.partner { Some(partner) => { format!("{caller_name} & {}", self.player_name_for_seat(partner)) } None if self.called_played => format!("{caller_name} (solo)"), None => caller_name, } } None => "Chiamante".to_string(), }; (side_a, "Difensori".to_string()) } } #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] #[allow(dead_code)] pub enum ServerMessage { State { game: GameView }, GameOver { scores: Scores, #[serde(default)] winner: Option, }, Error { #[serde(default)] code: Option, message: String, }, } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct MatchPlayer { pub user_sub: String, pub display_name: String, pub seat: usize, /// 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, #[serde(default)] pub result: serde_json::Value, pub started_at: String, pub finished_at: String, #[serde(default)] pub you_won: bool, /// The viewer's Elo change in this match; absent when unrated. #[serde(default)] pub your_elo_delta: Option, #[serde(default)] 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)] pub results: Vec, #[serde(default)] pub next_cursor: Option, } fn default_elo() -> i32 { 1500 } #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct LeaderboardEntry { pub user_sub: String, pub display_name: String, /// Chess-style Elo rating for the requested game type. #[serde(default = "default_elo")] pub elo: i32, pub matches: i32, pub wins: 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}") } } /// Render a calendar instant as `YYYY-MM-DD HH:MM`. `month` is 0-based /// (the JavaScript `Date` convention); the other components are as /// returned by `Date`'s local-time getters. pub fn fmt_datetime_parts(year: u32, month: u32, day: u32, hour: u32, minute: u32) -> String { format!("{year:04}-{:02}-{day:02} {hour:02}:{minute:02}", month + 1) } /// The caller's Elo rating for one game type (GET /api/me/ratings). #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct PlayerRating { pub game_type: String, pub rating: i32, pub matches_played: i32, } #[derive(Debug, Clone, Deserialize)] pub struct RatingsPage { #[serde(default)] pub results: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct LeaderboardPage { #[serde(default)] pub results: Vec, } /// A card game the platform can host (GET /api/game-types). #[derive(Debug, Clone, PartialEq, Deserialize)] #[allow(dead_code)] pub struct GameTypeInfo { pub id: String, 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)] pub struct GameTypesPage { #[serde(default)] pub results: Vec, } /// The games to show in the lobby's and leaderboard's game selectors when /// the `/api/game-types` fetch fails: match creation and per-game views /// must still work. pub fn fallback_game_types() -> Vec { 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, }, GameTypeInfo { id: "scopa".to_string(), name: "Scopa".to_string(), description: String::new(), min_players: 2, max_players: 2, options_schema: serde_json::Value::Null, }, GameTypeInfo { id: "scopa_a_quattro".to_string(), name: "Scopa a quattro".to_string(), description: String::new(), min_players: 4, max_players: 4, options_schema: serde_json::Value::Null, }, GameTypeInfo { id: "briscola".to_string(), name: "Briscola".to_string(), description: String::new(), min_players: 2, max_players: 2, options_schema: serde_json::Value::Null, }, GameTypeInfo { id: "briscola_a_quattro".to_string(), name: "Briscola a quattro".to_string(), description: String::new(), min_players: 4, max_players: 4, options_schema: serde_json::Value::Null, }, GameTypeInfo { id: "briscola_chiamata".to_string(), name: "Briscola chiamata".to_string(), description: String::new(), min_players: 5, max_players: 5, options_schema: serde_json::Value::Null, }, ] } /// One configured OIDC login provider (GET /api/auth/providers). #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct AuthProvider { pub name: String, pub login_url: String, } #[derive(Debug, Clone, Deserialize)] pub struct AuthProvidersPage { #[serde(default)] pub providers: Vec, } /// 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, #[serde(default)] pub seats_open: Option, #[serde(default)] pub phase: String, #[serde(flatten)] pub details: HashMap, } /// 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, } /// One ongoing game the caller is seated in (GET /api/me/games): the /// lobby payload plus the game's display name, for the "Continue" list. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct MyGame { pub id: String, #[serde(default)] pub game_type: String, #[serde(default)] pub game_name: String, #[serde(default)] pub game_description: String, #[serde(default)] pub phase: String, #[serde(default)] pub players: Vec, #[serde(default)] pub seats_open: Option, #[serde(default)] pub your_seat: Option, #[serde(default)] pub created_at: Option, } impl MyGame { /// Human-readable phase for the lobby list. pub fn phase_label(&self) -> &'static str { match self.phase.as_str() { "lobby" => "Waiting for players", "hand_end" => "Between hands", _ => "In progress", } } /// Seated/total note, e.g. `"2/4 seated"`. pub fn seats_text(&self) -> String { match self.seats_open { Some(open) => { format!("{}/{} seated", self.players.len(), self.players.len() + open) } None => format!("{} seated", self.players.len()), } } /// The seated players' names, for the list subtitle. pub fn players_text(&self) -> String { self.players .iter() .map(|p| p.name.clone()) .collect::>() .join(", ") } } #[derive(Debug, Clone, Deserialize)] pub struct MyGamesPage { #[serde(default)] pub results: Vec, } /// 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, } 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 { 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 { Integer { min: Option, max: Option }, Boolean, Text, Enum { values: Vec }, } #[derive(Debug, Clone, PartialEq)] 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 { 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 { 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 { 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 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 { 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, label, 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") } /// Whether a game type is one of the briscola variants. pub fn is_briscola(game_type: &str) -> bool { game_type == "briscola" || game_type == "briscola_a_quattro" } /// Whether a game type is one of the scopa variants (classic two-player /// scopa and four-player partnership scopa a quattro). pub fn is_scopa(game_type: &str) -> bool { game_type == "scopa" || game_type == "scopa_a_quattro" } /// Whether a game type is five-player briscola chiamata, which has its /// own table view (auction, secret partner, five seats). pub fn is_chiamata(game_type: &str) -> bool { game_type == "briscola_chiamata" } /// The Italian rank name used by the auction UI: `1` -> "Asso", `3` -> /// "Tre", `10` -> "Re", and so on. pub fn rank_name(rank: i32) -> String { match rank { 1 => "Asso".to_string(), 2 => "Due".to_string(), 3 => "Tre".to_string(), 4 => "Quattro".to_string(), 5 => "Cinque".to_string(), 6 => "Sei".to_string(), 7 => "Sette".to_string(), 8 => "Fante".to_string(), 9 => "Cavallo".to_string(), 10 => "Re".to_string(), other => other.to_string(), } } /// Whether a game is played by two individuals rather than partnerships /// (classic two-player briscola, classic two-player scopa): team labels /// and team messages are replaced by the players' names. pub fn is_individual_game(game_type: &str) -> bool { game_type == "briscola" || game_type == "scopa" } /// Human-readable suit name for a one-letter code (`D` -> "denari"). pub fn suit_name(suit: &str) -> String { match suit { "D" => "denari".to_string(), "C" => "coppe".to_string(), "S" => "spade".to_string(), "B" => "bastoni".to_string(), _ => suit.to_string(), } } pub const CARD_BACK: &str = "/assets/cards/back.svg"; /// Display order key for a hand: rank ascending (01..10, i.e. 1..7 then /// Fante, Cavallo, Re), then suit (denari, coppe, spade, bastoni). /// Malformed codes sort last. pub fn hand_sort_key(code: &str) -> (u8, u8) { let rank = code.get(..2).and_then(|r| r.parse().ok()).unwrap_or(u8::MAX); let suit = match code.get(2..3) { Some("D") => 0, Some("C") => 1, Some("S") => 2, Some("B") => 3, _ => u8::MAX, }; (rank, suit) } /// Human-friendly rank+suit label, e.g. `07D` -> "7 of denari". pub fn card_label(code: &str) -> String { if code.len() != 3 { return code.to_string(); } let rank = match &code[..2] { "01" => "Asso", "08" => "Fante", "09" => "Cavallo", "10" => "Re", other => match other.trim_start_matches('0') { "2" => "2", "3" => "3", "4" => "4", "5" => "5", "6" => "6", "7" => "7", _ => other, }, }; let suit = match &code[2..] { "D" => "denari", "C" => "coppe", "S" => "spade", "B" => "bastoni", _ => "?", }; format!("{rank} di {suit}") } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn card_asset_maps_code() { assert_eq!("/assets/cards/07D.svg", card_asset("07D")); } #[test] fn card_label_names_courts() { assert_eq!("Asso di denari", card_label("01D")); assert_eq!("Fante di coppe", card_label("08C")); assert_eq!("Cavallo di spade", card_label("09S")); assert_eq!("Re di bastoni", card_label("10B")); assert_eq!("7 di denari", card_label("07D")); } #[test] fn hand_sort_orders_ranks_ascending() { let mut hand = vec![ "10B".to_string(), "01S".to_string(), "07D".to_string(), "08C".to_string(), ]; hand.sort_by_key(|c| hand_sort_key(c)); assert_eq!(vec!["01S", "07D", "08C", "10B"], hand); } #[test] fn hand_sort_breaks_ties_by_suit() { let mut hand = vec![ "02B".to_string(), "02S".to_string(), "02C".to_string(), "02D".to_string(), "01S".to_string(), ]; hand.sort_by_key(|c| hand_sort_key(c)); assert_eq!(vec!["01S", "02D", "02C", "02S", "02B"], hand); } #[test] fn hand_sort_puts_malformed_codes_last() { let mut hand = vec!["07D".to_string(), "xx".to_string(), "AB1".to_string()]; hand.sort_by_key(|c| hand_sort_key(c)); assert_eq!(vec!["07D", "xx", "AB1"], hand); } 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 fmt_datetime_parts_pads_and_offsets_month() { assert_eq!("2026-09-19 05:02", fmt_datetime_parts(2026, 8, 19, 5, 2)); assert_eq!("2026-01-01 00:00", fmt_datetime_parts(2026, 0, 1, 0, 0)); } #[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}, "asso_pigliatutto": {"type": "boolean", "default": false}, }, }, })) .unwrap() } #[test] fn option_fields_parse_scopone_schema() { let fields = scopone_game_type().option_fields(); assert_eq!(3, 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); assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind); assert_eq!(json!(false), by_key["asso_pigliatutto"].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")); assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto")); } #[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/asso_pigliatutto). 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, "asso_pigliatutto": true, })) .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!(g.asso_pigliatutto); assert_eq!(Some("A".to_string()), 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, "asso_pigliatutto": 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!(g.asso_pigliatutto); assert!(!g.spectator); 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_view_parses_spectator_snapshot() { // A spectator payload: no hand anywhere, no join code, but the // public table state is present. let g: GameView = serde_json::from_value(json!({ "id": "11111111-2222-3333-4444-555555555555", "game_type": "scopone_scientifico", "spectator": true, "phase": "playing", "target_score": 11, "scores": {"A": 2, "B": 1}, "table": ["07C"], "players": [ {"sub": "alice", "name": "Alice", "seat": 0, "team": "A", "cards_left": 5, "captured_count": 12, "scope": 1}, ], })) .unwrap(); assert!(g.spectator); assert_eq!("", g.join_code); assert_eq!(Some(Scores { a: 2, b: 1 }), g.scores); assert_eq!(vec!["07C".to_string()], g.table); assert_eq!(None, g.players[0].hand); assert_eq!(12, g.players[0].captured_count); assert_eq!(1, g.players[0].scope); } #[test] fn move_view_parses_pigliatutto() { let mv: MoveView = serde_json::from_value(json!({ "seat": 0, "name": "Alice", "card": "01D", "captured": ["05C", "02D"], "scopa": false, "pigliatutto": true, })) .unwrap(); assert!(mv.pigliatutto); // Older payloads without the flag default to a normal capture. let legacy: MoveView = serde_json::from_value(json!({ "seat": 0, "name": "Alice", "card": "01D", })) .unwrap(); 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!({ "id": "legacy", "name": "Legacy", })) .unwrap(); assert!(g.option_fields().is_empty()); assert!(g.default_options().is_empty()); assert_eq!(0, g.min_players); } #[test] fn is_briscola_matches_both_variants() { assert!(is_briscola("briscola")); assert!(is_briscola("briscola_a_quattro")); assert!(!is_briscola("briscola_chiamata")); assert!(!is_briscola("scopone_scientifico")); assert!(is_chiamata("briscola_chiamata")); assert!(!is_chiamata("briscola")); assert_eq!("denari", suit_name("D")); assert_eq!("coppe", suit_name("C")); assert_eq!("spade", suit_name("S")); assert_eq!("bastoni", suit_name("B")); assert_eq!("?", suit_name("?")); } #[test] fn is_scopa_matches_both_variants() { assert!(is_scopa("scopa")); assert!(is_scopa("scopa_a_quattro")); assert!(!is_scopa("scopone_scientifico")); assert!(!is_scopa("briscola")); } #[test] fn rank_names_cover_the_ladder() { assert_eq!("Asso", rank_name(1)); assert_eq!("Due", rank_name(2)); assert_eq!("Tre", rank_name(3)); assert_eq!("Fante", rank_name(8)); assert_eq!("Cavallo", rank_name(9)); assert_eq!("Re", rank_name(10)); } #[test] fn is_individual_game_matches_two_player_games() { assert!(is_individual_game("briscola")); assert!(is_individual_game("scopa")); assert!(!is_individual_game("briscola_a_quattro")); assert!(!is_individual_game("scopa_a_quattro")); assert!(!is_individual_game("scopone_scientifico")); } #[test] fn player_name_for_team_resolves_names() { let g: GameView = serde_json::from_value(json!({ "id": "b1", "game_type": "briscola", "phase": "playing", "players": [ {"sub": "alice", "name": "Alice", "seat": 0, "team": "A"}, {"sub": "bob", "name": "Bob", "seat": 1, "team": "B"}, ], })) .unwrap(); assert_eq!("Alice", g.player_name_for_team("A")); assert_eq!("Bob", g.player_name_for_team("B")); // Unknown labels fall back to the label itself. assert_eq!("C", g.player_name_for_team("C")); } #[test] fn my_game_phase_and_seats_text() { let g: MyGame = serde_json::from_value(json!({ "id": "g1", "game_type": "briscola_a_quattro", "game_name": "Briscola a quattro", "phase": "lobby", "players": [ {"name": "Alice", "seat": 0, "team": "A"}, {"name": "Bob", "seat": 1, "team": "B"}, ], "seats_open": 2, })) .unwrap(); assert_eq!("Waiting for players", g.phase_label()); assert_eq!("2/4 seated", g.seats_text()); assert_eq!("Alice, Bob", g.players_text()); let playing: MyGame = serde_json::from_value(json!({ "id": "g2", "game_type": "briscola", "phase": "playing", })) .unwrap(); assert_eq!("In progress", playing.phase_label()); assert_eq!("0 seated", playing.seats_text()); let between: MyGame = serde_json::from_value(json!({ "id": "g3", "phase": "hand_end", })) .unwrap(); assert_eq!("Between hands", between.phase_label()); } #[test] fn fallback_game_types_cover_the_registered_games() { let types = fallback_game_types(); let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect(); assert_eq!( vec![ "scopone_scientifico", "scopa", "scopa_a_quattro", "briscola", "briscola_a_quattro", "briscola_chiamata", ], ids ); // The lobby's first game (the creation default) must exist. assert_eq!("scopone_scientifico", types[0].id); assert_eq!(5, types[5].max_players); } #[test] fn game_view_parses_briscola_payload() { let g: GameView = serde_json::from_value(json!({ "id": "b1", "join_code": "ABC123", "game_type": "briscola", "phase": "playing", "target_score": 61, "player_count": 2, "hand_number": 1, "dealer": 0, "turn": 1, "scores": {"A": 0, "B": 0}, "briscola_suit": "D", "briscola_card": "07D", "stock_count": 33, "trick": [{"seat": 1, "card": "04C"}], "trick_leader": 1, "last_trick": null, "players": [ {"sub": "alice", "name": "Alice", "seat": 0, "team": "A", "cards_left": 3, "captured_count": 0, "points": 0, "hand": ["01D"]}, {"sub": "bob", "name": "Bob", "seat": 1, "team": "B", "cards_left": 3, "captured_count": 0, "points": 0}, ], "last_deal": null, "acknowledged": [], "hand_end_deadline": null, "turn_deadline": "2026-01-01T00:00:30+00:00", "your_turn": true, })) .unwrap(); assert!(is_briscola(&g.game_type)); assert_eq!("D", g.briscola_suit); assert_eq!(Some("07D".to_string()), g.briscola_card); assert_eq!(33, g.stock_count); assert_eq!(1, g.trick.len()); assert_eq!(1, g.trick[0].seat); assert_eq!("04C", g.trick[0].card); assert_eq!(Some(1), g.trick_leader); assert_eq!(0, g.players[0].points); assert_eq!(Some(true), g.your_turn); } #[test] fn game_view_parses_briscola_deal_summary() { let g: GameView = serde_json::from_value(json!({ "id": "b1", "game_type": "briscola_a_quattro", "phase": "hand_end", "player_count": 4, "scores": {"A": 55, "B": 65}, "last_deal": { "deal": 1, "patta": false, "points": {"A": 55, "B": 65}, "totals": {"A": 55, "B": 65}, "cards": {"A": 19, "B": 21}, }, "last_trick": { "winner": 3, "cards": [ {"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}, {"seat": 2, "card": "04D"}, {"seat": 3, "card": "07D"}, ], }, })) .unwrap(); let deal = g.last_deal.unwrap(); assert_eq!(1, deal.deal); assert!(!deal.patta); assert_eq!(Scores { a: 55, b: 65 }, deal.points); assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals); let cards = deal.cards.unwrap(); assert_eq!(19, cards.a); assert_eq!(21, cards.b); let trick = g.last_trick.unwrap(); assert_eq!(3, trick.winner); assert_eq!(4, trick.cards.len()); } #[test] fn deal_summary_defaults() { let d: DealSummary = serde_json::from_value(json!({ "points": {"A": 60, "B": 60}, "totals": {"A": 10, "B": 20}, })) .unwrap(); assert!(!d.patta); assert_eq!(0, d.deal); assert!(d.cards.is_none()); } #[test] fn chiamata_deal_summary_has_no_totals() { let d: DealSummary = serde_json::from_value(json!({ "deal": 2, "patta": true, "points": {"A": 60, "B": 60}, "declared_target": 61, "called_card": "01D", "caller": "Alice", "solo": false, })) .unwrap(); assert!(d.patta); assert_eq!(2, d.deal); assert!(d.totals.is_none()); assert!(d.cards.is_none()); assert_eq!(Some(61), d.declared_target); assert_eq!(Some("01D".to_string()), d.called_card); assert!(!d.solo); } #[test] fn game_view_parses_chiamata_auction() { let g: GameView = serde_json::from_value(json!({ "id": "c1", "join_code": "ABC123", "game_type": "briscola_chiamata", "phase": "auction", "player_count": 5, "dealer": 0, "hand_number": 1, "turn": 1, "declared_target": 61, "auction": { "turn": 2, "active": [0, 1, 2, 3, 4], "passed": [], "current": {"seat": 1, "rank": 10, "points": 61}, "history": [{"seat": 1, "rank": 10, "points": 61}], }, "your_turn": true, "legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120}, "players": [ {"sub": "alice", "name": "Alice", "seat": 0, "team": "", "cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]}, {"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante", "cards_left": 8, "captured_count": 0, "points": 0}, ], "auction_deadline": "2026-01-01T00:00:30+00:00", "call_deadline": null, "turn_deadline": null, })) .unwrap(); assert!(is_chiamata(&g.game_type)); assert!(!is_briscola(&g.game_type)); let auction = g.auction.unwrap(); assert_eq!(2, auction.turn); assert_eq!(5, auction.active.len()); let current = auction.current.unwrap(); assert_eq!(1, current.seat); assert_eq!(10, current.rank); assert_eq!(61, current.points); assert_eq!(1, auction.history.len()); let bids = g.legal_bids.unwrap(); assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks); assert!(bids.points_min.is_none()); assert_eq!(Some(120), bids.points_max); assert_eq!(Some("".to_string()), g.players[0].team); assert_eq!(Some("Chiamante".to_string()), g.players[1].team); } #[test] fn game_view_parses_chiamata_playing_and_reveal() { let g: GameView = serde_json::from_value(json!({ "id": "c2", "game_type": "briscola_chiamata", "phase": "playing", "player_count": 5, "dealer": 0, "hand_number": 1, "turn": 3, "caller": 1, "called_card": "03C", "briscola_suit": "C", "declared_target": 64, "called_played": true, "partner": 3, "solo": false, "scores": {"A": 45, "B": 30}, "players": [ {"sub": "a", "name": "A", "seat": 0, "team": "Difensori"}, {"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"}, {"sub": "c", "name": "C", "seat": 2, "team": "Difensori"}, {"sub": "d", "name": "D", "seat": 3, "team": "Socio"}, {"sub": "e", "name": "E", "seat": 4, "team": "Difensori"}, ], })) .unwrap(); assert_eq!(Some(1), g.caller); assert_eq!(Some("03C".to_string()), g.called_card); assert_eq!("C", g.briscola_suit); assert_eq!(64, g.declared_target); assert!(g.called_played); assert_eq!(Some(3), g.partner); assert!(!g.solo); assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores); assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides()); } #[test] fn chiamata_sides_label_the_solo_caller() { let g: GameView = serde_json::from_value(json!({ "id": "c3", "game_type": "briscola_chiamata", "phase": "finished", "scores": {"A": 70, "B": 50}, "winner": "Chiamante", "caller": 2, "called_played": true, "solo": true, "players": [ {"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"}, {"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"}, {"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"}, ], })) .unwrap(); let (a, b) = g.chiamata_sides(); assert_eq!("Carol (solo)", a); assert_eq!("Difensori", b); assert_eq!(Some("Chiamante".to_string()), g.winner); } #[test] fn game_view_parses_scopa_payload() { let g: GameView = serde_json::from_value(json!({ "id": "s1", "join_code": "ABC123", "game_type": "scopa", "phase": "playing", "target_score": 11, "asso_pigliatutto": true, "player_count": 2, "hand_number": 1, "dealer": 0, "turn": 1, "deck_count": 24, "scores": {"A": 0, "B": 0}, "table": ["05C", "02D", "03S", "10B"], "players": [ {"sub": "alice", "name": "Alice", "seat": 0, "team": "A", "cards_left": 3, "captured_count": 0, "scope": 0}, {"sub": "bob", "name": "Bob", "seat": 1, "team": "B", "cards_left": 3, "captured_count": 0, "scope": 0, "hand": ["01D", "06C", "09S"]}, ], "last_hand": null, "last_move": {"seat": 0, "name": "Alice", "card": "04D", "captured": ["01C", "03C"], "scopa": true, "pigliatutto": false}, "acknowledged": [], "hand_end_deadline": null, "turn_deadline": "2026-01-01T00:00:30+00:00", "your_turn": true, "legal_moves": {"01D": [["01C"]]}, })) .unwrap(); assert!(is_scopa(&g.game_type)); assert!(is_individual_game(&g.game_type)); assert!(g.asso_pigliatutto); assert_eq!(2, g.player_count); assert_eq!(24, g.deck_count); assert_eq!(4, g.table.len()); assert_eq!(Some(vec!["01D".to_string(), "06C".to_string(), "09S".to_string()]), g.players[1].hand.clone()); let mv = g.last_move.unwrap(); assert!(mv.scopa); assert_eq!(2, mv.captured.len()); } #[test] fn game_view_parses_scopa_a_quattro_hand_summary() { let g: GameView = serde_json::from_value(json!({ "id": "s2", "game_type": "scopa_a_quattro", "phase": "hand_end", "player_count": 4, "scores": {"A": 3, "B": 0}, "players": [ {"sub": "a", "name": "Alice", "seat": 0, "team": "A"}, {"sub": "b", "name": "Bob", "seat": 1, "team": "B"}, {"sub": "c", "name": "Carol", "seat": 2, "team": "A"}, {"sub": "d", "name": "Dave", "seat": 3, "team": "B"}, ], "last_hand": { "cards": {"A": 21, "B": 19}, "denara": {"A": 6, "B": 4}, "settebello": {"A": true, "B": false}, "primiera": {"A": 70, "B": 60}, "scope": {"A": 1, "B": 0}, "award": { "carte": "A", "denara": "A", "settebello": "A", "primiera": "A", }, "hand": 1, "team_a_points": 3, "team_b_points": 0, }, })) .unwrap(); assert!(is_scopa(&g.game_type)); assert!(!is_individual_game(&g.game_type)); let hand = g.last_hand.unwrap(); assert_eq!(21, hand.cards.a); assert_eq!(Some("A".to_string()), hand.award.carte); assert!(hand.napola.is_none()); assert_eq!(3, hand.team_a_points); } }