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.
1900 lines
62 KiB
Rust
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);
|
|
}
|
|
}
|