Files
tavolo/web/src/model.rs
T
woggioni 551c4c9c28 Paginate the leaderboard from materialized player ratings
Store each player's leaderboard totals (wins, accumulated points and the
latest display name) on player_rating alongside the Elo rating, updated
in the same transaction as the match, so the board is a single indexed
table query instead of aggregating every participation on each request.

GET /api/leaderboard is now page-number paginated (page/limit) and
returns the page, page count and total, plus the caller's own rank and
page when authenticated; focus=me returns the caller's page directly.
The migration adds the columns, backfills them from the match history
(inserting rows for players from pre-Elo matches at the initial rating)
and replaces the index. backfill_elo rebuilds the aggregates too.

The web page opens on the page containing the signed-in player, marks
their row and adds a numbered footer pager.
2026-10-09 08:35:03 +00:00

1900 lines
62 KiB
Rust

//! 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<String>,
#[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<Vec<String>>,
}
#[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<String>,
#[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<TrickCard>,
}
/// 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<Scores>,
#[serde(default)]
pub cards: Option<TeamCounts>,
/// Briscola chiamata: the points target of the played deal.
#[serde(default)]
pub declared_target: Option<i32>,
/// Briscola chiamata: the card named by the caller.
#[serde(default)]
pub called_card: Option<String>,
/// 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<String>,
#[serde(default)]
pub denara: Option<String>,
#[serde(default)]
pub settebello: Option<String>,
#[serde(default)]
pub primiera: Option<String>,
#[serde(default)]
pub napola: Option<String>,
}
/// 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<TeamCounts>,
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<Scores>,
#[serde(default)]
pub winner: Option<String>,
#[serde(default)]
pub table: Vec<String>,
/// Scopa: face-down cards left in the deck for the current hand.
#[serde(default)]
pub deck_count: usize,
#[serde(default)]
pub players: Vec<PlayerView>,
#[serde(default)]
pub seats_open: Option<usize>,
#[serde(default)]
pub last_move: Option<MoveView>,
/// Scoring breakdown of the most recent hand (present once a hand has
/// been completed).
#[serde(default)]
pub last_hand: Option<HandSummary>,
/// Seats that acknowledged the hand-end summary.
#[serde(default)]
pub acknowledged: Vec<usize>,
/// ISO-8601 instant at which the next hand is dealt automatically.
#[serde(default)]
pub hand_end_deadline: Option<String>,
/// ISO-8601 instant at which the server plays a random legal card for
/// the player on turn.
#[serde(default)]
pub turn_deadline: Option<String>,
#[serde(default)]
pub your_turn: Option<bool>,
/// Legal captures per hand card; present only for the player on turn.
#[serde(default)]
pub legal_moves: Option<HashMap<String, Vec<Vec<String>>>>,
/// 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<String>,
/// 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<TrickCard>,
/// Briscola: seat that led the current trick.
#[serde(default)]
pub trick_leader: Option<usize>,
/// Briscola: the most recently completed trick.
#[serde(default)]
pub last_trick: Option<LastTrick>,
/// Briscola: scoring breakdown of the most recent deal.
#[serde(default)]
pub last_deal: Option<DealSummary>,
/// Briscola chiamata: the calling auction of the current deal.
#[serde(default)]
pub auction: Option<AuctionView>,
/// Briscola chiamata: seat that won the auction.
#[serde(default)]
pub caller: Option<usize>,
/// Briscola chiamata: the named card; its suit is the trump.
#[serde(default)]
pub called_card: Option<String>,
/// 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<usize>,
/// 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<LegalBids>,
/// Briscola chiamata: the cards the auction winner may name.
#[serde(default)]
pub callable_cards: Option<Vec<String>>,
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
#[serde(default)]
pub auction_deadline: Option<String>,
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
#[serde(default)]
pub call_deadline: Option<String>,
}
/// 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<usize>,
#[serde(default)]
pub passed: Vec<usize>,
/// The standing bid, if any.
#[serde(default)]
pub current: Option<AuctionBid>,
#[serde(default)]
pub history: Vec<AuctionBid>,
}
/// 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<i32>,
/// Lowest legal points raise, when the standing bid is the two.
#[serde(default)]
pub points_min: Option<i32>,
#[serde(default)]
pub points_max: Option<i32>,
}
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<String>,
},
Error {
#[serde(default)]
code: Option<String>,
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<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,
#[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<i32>,
#[serde(default)]
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)]
pub results: Vec<MatchSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
fn default_elo() -> i32 {
1500
}
fn default_page() -> u32 {
1
}
#[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<PlayerRating>,
}
/// Page size requested by the leaderboard page. Also used to turn a rank
/// into the row number on a page (and back).
pub const LEADERBOARD_PAGE_SIZE: u32 = 20;
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardPage {
#[serde(default)]
pub results: Vec<LeaderboardEntry>,
/// 1-based page actually returned (clamped to the last page).
#[serde(default = "default_page")]
pub page: u32,
/// Total number of pages; 0 when the board has no rated players.
#[serde(default)]
pub pages: u32,
/// Total number of rated players.
#[serde(default)]
pub total: u32,
/// The viewer's own rank, when authenticated and rated for this game.
#[serde(default)]
pub your_rank: Option<u32>,
/// The page holding the viewer's row.
#[serde(default)]
pub your_page: Option<u32>,
}
/// 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<GameTypeInfo>,
}
/// 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<GameTypeInfo> {
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<AuthProvider>,
}
/// 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 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<PreviewPlayer>,
#[serde(default)]
pub seats_open: Option<usize>,
#[serde(default)]
pub your_seat: Option<usize>,
#[serde(default)]
pub created_at: Option<String>,
}
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::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct MyGamesPage {
#[serde(default)]
pub results: Vec<MyGame>,
}
/// 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 {
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,
/// 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(|| {
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 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<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")
}
/// 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);
}
#[test]
fn leaderboard_page_parses_pagination_fields() {
let p: LeaderboardPage = serde_json::from_value(json!({
"results": [],
"page": 3,
"pages": 12,
"total": 234,
"your_rank": 47,
"your_page": 3,
}))
.unwrap();
assert_eq!(3, p.page);
assert_eq!(12, p.pages);
assert_eq!(234, p.total);
assert_eq!(Some(47), p.your_rank);
assert_eq!(Some(3), p.your_page);
// The pagination fields are optional for older payloads.
let legacy: LeaderboardPage =
serde_json::from_value(json!({"results": []})).unwrap();
assert_eq!(1, legacy.page);
assert_eq!(0, legacy.pages);
assert_eq!(None, legacy.your_rank);
assert_eq!(None, legacy.your_page);
}
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": 21,
},
"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!(21), 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!(21)), 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);
}
}