New tavolo-scopa package with a pure rules engine and the platform adapters for two-player scopa and four-player fixed-partnership scopa a quattro: three cards each and four on the table, capture by rank or sum, sweep for scope and score carte/denara/settebello/primiera. The optional asso pigliatutto creation option turns either variant into scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing scopone's rule semantics. Wire both engines into the app and the Docker install, add the scopa table view to the SPA (duo and four-seat layouts, deck count, captured/scope stats, hand-summary and final screens, player names in the two-player summary), and cover the rules, the platform contract, the REST/WebSocket flows, match persistence and Elo.
1851 lines
61 KiB
Rust
1851 lines
61 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
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct LeaderboardPage {
|
|
#[serde(default)]
|
|
pub results: Vec<LeaderboardEntry>,
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
fn scopone_game_type() -> GameTypeInfo {
|
|
serde_json::from_value(json!({
|
|
"id": "scopone_scientifico",
|
|
"name": "Scopone scientifico",
|
|
"description": "d",
|
|
"min_players": 4,
|
|
"max_players": 4,
|
|
"options_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"target_score": {
|
|
"type": "integer", "minimum": 1, "maximum": 100,
|
|
"default": 11,
|
|
},
|
|
"napola": {"type": "boolean", "default": true},
|
|
"asso_pigliatutto": {"type": "boolean", "default": false},
|
|
},
|
|
},
|
|
}))
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn option_fields_parse_scopone_schema() {
|
|
let fields = scopone_game_type().option_fields();
|
|
assert_eq!(3, fields.len());
|
|
let by_key: std::collections::HashMap<_, _> =
|
|
fields.into_iter().map(|f| (f.key.clone(), f)).collect();
|
|
let target = &by_key["target_score"];
|
|
assert!(matches!(
|
|
target.kind,
|
|
OptionKind::Integer { min: Some(1), max: Some(100) }
|
|
));
|
|
assert_eq!(json!(11), target.default);
|
|
assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
|
|
assert_eq!(json!(true), by_key["napola"].default);
|
|
assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind);
|
|
assert_eq!(json!(false), by_key["asso_pigliatutto"].default);
|
|
}
|
|
|
|
#[test]
|
|
fn option_fields_skip_unknown_types() {
|
|
let g: GameTypeInfo = serde_json::from_value(json!({
|
|
"id": "x",
|
|
"name": "x",
|
|
"options_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"mystery": {"type": "object"},
|
|
"mode": {"type": "string", "enum": ["a", "b"], "default": "a"},
|
|
"nick": {"type": "string"},
|
|
},
|
|
},
|
|
}))
|
|
.unwrap();
|
|
let fields = g.option_fields();
|
|
assert_eq!(2, fields.len());
|
|
assert_eq!(
|
|
OptionKind::Enum { values: vec!["a".to_string(), "b".to_string()] },
|
|
fields[0].kind
|
|
);
|
|
assert_eq!(OptionKind::Text, fields[1].kind);
|
|
}
|
|
|
|
#[test]
|
|
fn default_options_collect_schema_defaults() {
|
|
let opts = scopone_game_type().default_options();
|
|
assert_eq!(Some(&json!(11)), opts.get("target_score"));
|
|
assert_eq!(Some(&json!(true)), opts.get("napola"));
|
|
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_new_lobby_payload() {
|
|
// Exact shape of the platform's lobby payload: envelope fields,
|
|
// seats with team labels, seats_open, plus the engine's
|
|
// lobby_view (phase/target_score/napola/asso_pigliatutto).
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "11111111-2222-3333-4444-555555555555",
|
|
"join_code": "ABC123",
|
|
"game_type": "scopone_scientifico",
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
|
|
],
|
|
"seats_open": 3,
|
|
"phase": "lobby",
|
|
"target_score": 16,
|
|
"napola": false,
|
|
"asso_pigliatutto": true,
|
|
}))
|
|
.unwrap();
|
|
assert_eq!("ABC123", g.join_code);
|
|
assert_eq!(Some(3), g.seats_open);
|
|
assert_eq!("lobby", g.phase);
|
|
assert_eq!(16, g.target_score);
|
|
assert!(!g.napola);
|
|
assert!(g.asso_pigliatutto);
|
|
assert_eq!(Some("A".to_string()), g.players[0].team);
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_new_snapshot_payload() {
|
|
// Envelope merged with the engine view; hands hidden for others.
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "11111111-2222-3333-4444-555555555555",
|
|
"join_code": "ABC123",
|
|
"game_type": "scopone_scientifico",
|
|
"phase": "playing",
|
|
"target_score": 11,
|
|
"napola": true,
|
|
"asso_pigliatutto": true,
|
|
"hand_number": 1,
|
|
"dealer": 0,
|
|
"turn": 1,
|
|
"scores": {"A": 0, "B": 0},
|
|
"winner": null,
|
|
"table": [],
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
|
|
"cards_left": 10, "captured_count": 0, "scope": 0},
|
|
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
|
|
"cards_left": 10, "captured_count": 0, "scope": 0,
|
|
"hand": ["01D"]},
|
|
],
|
|
"last_hand": null,
|
|
"last_move": null,
|
|
"acknowledged": [],
|
|
"hand_end_deadline": null,
|
|
"turn_deadline": "2026-01-01T00:00:30+00:00",
|
|
"your_turn": true,
|
|
"legal_moves": {},
|
|
}))
|
|
.unwrap();
|
|
assert_eq!("playing", g.phase);
|
|
assert!(g.asso_pigliatutto);
|
|
assert!(!g.spectator);
|
|
assert_eq!(None, g.players[0].hand);
|
|
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
|
|
assert_eq!(Some(true), g.your_turn);
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_spectator_snapshot() {
|
|
// A spectator payload: no hand anywhere, no join code, but the
|
|
// public table state is present.
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "11111111-2222-3333-4444-555555555555",
|
|
"game_type": "scopone_scientifico",
|
|
"spectator": true,
|
|
"phase": "playing",
|
|
"target_score": 11,
|
|
"scores": {"A": 2, "B": 1},
|
|
"table": ["07C"],
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
|
|
"cards_left": 5, "captured_count": 12, "scope": 1},
|
|
],
|
|
}))
|
|
.unwrap();
|
|
assert!(g.spectator);
|
|
assert_eq!("", g.join_code);
|
|
assert_eq!(Some(Scores { a: 2, b: 1 }), g.scores);
|
|
assert_eq!(vec!["07C".to_string()], g.table);
|
|
assert_eq!(None, g.players[0].hand);
|
|
assert_eq!(12, g.players[0].captured_count);
|
|
assert_eq!(1, g.players[0].scope);
|
|
}
|
|
|
|
#[test]
|
|
fn move_view_parses_pigliatutto() {
|
|
let mv: MoveView = serde_json::from_value(json!({
|
|
"seat": 0,
|
|
"name": "Alice",
|
|
"card": "01D",
|
|
"captured": ["05C", "02D"],
|
|
"scopa": false,
|
|
"pigliatutto": true,
|
|
}))
|
|
.unwrap();
|
|
assert!(mv.pigliatutto);
|
|
// Older payloads without the flag default to a normal capture.
|
|
let legacy: MoveView = serde_json::from_value(json!({
|
|
"seat": 0, "name": "Alice", "card": "01D",
|
|
}))
|
|
.unwrap();
|
|
assert!(!legacy.pigliatutto);
|
|
}
|
|
|
|
#[test]
|
|
fn join_preview_parses_scopone_payload() {
|
|
let p: JoinPreview = serde_json::from_value(json!({
|
|
"id": "11111111-2222-3333-4444-555555555555",
|
|
"join_code": "ABC123",
|
|
"game_type": "scopone_scientifico",
|
|
"game_name": "Scopone scientifico",
|
|
"game_description": "Four players in fixed partnerships.",
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
|
|
],
|
|
"seats_open": 3,
|
|
"phase": "lobby",
|
|
"target_score": 16,
|
|
"napola": false,
|
|
"asso_pigliatutto": true,
|
|
}))
|
|
.unwrap();
|
|
assert_eq!("Scopone scientifico", p.game_name);
|
|
assert_eq!("lobby", p.phase);
|
|
assert!(p.joinable());
|
|
assert_eq!(Some(3), p.seats_open);
|
|
assert_eq!("Alice", p.players[0].name);
|
|
assert_eq!(Some("A".to_string()), p.players[0].team);
|
|
// Options render from the schema, in schema order; keys the
|
|
// engine did not echo are skipped.
|
|
let opts = p.options(&scopone_game_type().option_fields());
|
|
assert_eq!(3, opts.len());
|
|
let by_label: HashMap<_, _> =
|
|
opts.into_iter().map(|o| (o.label.clone(), o)).collect();
|
|
assert_eq!("Target score", by_label["Target score"].label);
|
|
assert_eq!("16", by_label["Target score"].value);
|
|
assert_eq!(None, by_label["Target score"].on);
|
|
assert_eq!("Napola", by_label["Napola"].label);
|
|
assert_eq!("Off", by_label["Napola"].value);
|
|
assert_eq!(Some(false), by_label["Napola"].on);
|
|
assert_eq!("Asso pigliatutto", by_label["Asso pigliatutto"].label);
|
|
assert_eq!("On", by_label["Asso pigliatutto"].value);
|
|
assert_eq!(Some(true), by_label["Asso pigliatutto"].on);
|
|
}
|
|
|
|
#[test]
|
|
fn join_preview_joinable_states() {
|
|
let started: JoinPreview = serde_json::from_value(json!({
|
|
"game_type": "x", "phase": "playing", "seats_open": 0,
|
|
}))
|
|
.unwrap();
|
|
assert!(!started.joinable());
|
|
let full: JoinPreview = serde_json::from_value(json!({
|
|
"game_type": "x", "phase": "lobby", "seats_open": 0,
|
|
}))
|
|
.unwrap();
|
|
assert!(!full.joinable());
|
|
let open: JoinPreview = serde_json::from_value(json!({
|
|
"game_type": "x", "phase": "lobby", "seats_open": 1,
|
|
}))
|
|
.unwrap();
|
|
assert!(open.joinable());
|
|
assert!(open.options(&scopone_game_type().option_fields()).is_empty());
|
|
// Engines that don't report seats_open are assumed joinable.
|
|
let unknown: JoinPreview = serde_json::from_value(json!({
|
|
"game_type": "x", "phase": "lobby",
|
|
}))
|
|
.unwrap();
|
|
assert!(unknown.joinable());
|
|
assert_eq!("", unknown.seats_text());
|
|
assert_eq!(" · 1 seat open", open.seats_text());
|
|
assert_eq!(" · 0 seats open", full.seats_text());
|
|
let two: JoinPreview = serde_json::from_value(json!({
|
|
"game_type": "x", "phase": "lobby", "seats_open": 2,
|
|
}))
|
|
.unwrap();
|
|
assert_eq!(" · 2 seats open", two.seats_text());
|
|
}
|
|
|
|
#[test]
|
|
fn option_field_formats_values() {
|
|
let g: GameTypeInfo = serde_json::from_value(json!({
|
|
"id": "x",
|
|
"name": "x",
|
|
"options_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"flag": {"type": "boolean", "title": "Flag", "default": false},
|
|
"count": {"type": "integer", "default": 2},
|
|
"mode": {"type": "string", "enum": ["a", "b"], "default": "a"},
|
|
"note": {"type": "string"},
|
|
},
|
|
},
|
|
}))
|
|
.unwrap();
|
|
let fields: HashMap<_, _> =
|
|
g.option_fields().into_iter().map(|f| (f.key.clone(), f)).collect();
|
|
assert_eq!(Some("On".to_string()), fields["flag"].format_value(&json!(true)));
|
|
assert_eq!(Some("Off".to_string()), fields["flag"].format_value(&json!(false)));
|
|
assert_eq!(None, fields["flag"].format_value(&json!("yes")));
|
|
assert_eq!(Some("16".to_string()), fields["count"].format_value(&json!(16)));
|
|
assert_eq!(Some("2.5".to_string()), fields["count"].format_value(&json!(2.5)));
|
|
assert_eq!(Some("b".to_string()), fields["mode"].format_value(&json!("b")));
|
|
assert_eq!(Some("hi".to_string()), fields["note"].format_value(&json!("hi")));
|
|
// Badges use the schema title, or the humanized key without one.
|
|
assert_eq!("Flag", fields["flag"].label);
|
|
assert_eq!("Count", fields["count"].label);
|
|
}
|
|
|
|
#[test]
|
|
fn game_type_without_schema_has_no_fields() {
|
|
let g: GameTypeInfo = serde_json::from_value(json!({
|
|
"id": "legacy",
|
|
"name": "Legacy",
|
|
}))
|
|
.unwrap();
|
|
assert!(g.option_fields().is_empty());
|
|
assert!(g.default_options().is_empty());
|
|
assert_eq!(0, g.min_players);
|
|
}
|
|
|
|
#[test]
|
|
fn is_briscola_matches_both_variants() {
|
|
assert!(is_briscola("briscola"));
|
|
assert!(is_briscola("briscola_a_quattro"));
|
|
assert!(!is_briscola("briscola_chiamata"));
|
|
assert!(!is_briscola("scopone_scientifico"));
|
|
assert!(is_chiamata("briscola_chiamata"));
|
|
assert!(!is_chiamata("briscola"));
|
|
assert_eq!("denari", suit_name("D"));
|
|
assert_eq!("coppe", suit_name("C"));
|
|
assert_eq!("spade", suit_name("S"));
|
|
assert_eq!("bastoni", suit_name("B"));
|
|
assert_eq!("?", suit_name("?"));
|
|
}
|
|
|
|
#[test]
|
|
fn is_scopa_matches_both_variants() {
|
|
assert!(is_scopa("scopa"));
|
|
assert!(is_scopa("scopa_a_quattro"));
|
|
assert!(!is_scopa("scopone_scientifico"));
|
|
assert!(!is_scopa("briscola"));
|
|
}
|
|
|
|
#[test]
|
|
fn rank_names_cover_the_ladder() {
|
|
assert_eq!("Asso", rank_name(1));
|
|
assert_eq!("Due", rank_name(2));
|
|
assert_eq!("Tre", rank_name(3));
|
|
assert_eq!("Fante", rank_name(8));
|
|
assert_eq!("Cavallo", rank_name(9));
|
|
assert_eq!("Re", rank_name(10));
|
|
}
|
|
|
|
#[test]
|
|
fn is_individual_game_matches_two_player_games() {
|
|
assert!(is_individual_game("briscola"));
|
|
assert!(is_individual_game("scopa"));
|
|
assert!(!is_individual_game("briscola_a_quattro"));
|
|
assert!(!is_individual_game("scopa_a_quattro"));
|
|
assert!(!is_individual_game("scopone_scientifico"));
|
|
}
|
|
|
|
#[test]
|
|
fn player_name_for_team_resolves_names() {
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "b1",
|
|
"game_type": "briscola",
|
|
"phase": "playing",
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
|
|
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B"},
|
|
],
|
|
}))
|
|
.unwrap();
|
|
assert_eq!("Alice", g.player_name_for_team("A"));
|
|
assert_eq!("Bob", g.player_name_for_team("B"));
|
|
// Unknown labels fall back to the label itself.
|
|
assert_eq!("C", g.player_name_for_team("C"));
|
|
}
|
|
|
|
#[test]
|
|
fn my_game_phase_and_seats_text() {
|
|
let g: MyGame = serde_json::from_value(json!({
|
|
"id": "g1",
|
|
"game_type": "briscola_a_quattro",
|
|
"game_name": "Briscola a quattro",
|
|
"phase": "lobby",
|
|
"players": [
|
|
{"name": "Alice", "seat": 0, "team": "A"},
|
|
{"name": "Bob", "seat": 1, "team": "B"},
|
|
],
|
|
"seats_open": 2,
|
|
}))
|
|
.unwrap();
|
|
assert_eq!("Waiting for players", g.phase_label());
|
|
assert_eq!("2/4 seated", g.seats_text());
|
|
assert_eq!("Alice, Bob", g.players_text());
|
|
|
|
let playing: MyGame = serde_json::from_value(json!({
|
|
"id": "g2",
|
|
"game_type": "briscola",
|
|
"phase": "playing",
|
|
}))
|
|
.unwrap();
|
|
assert_eq!("In progress", playing.phase_label());
|
|
assert_eq!("0 seated", playing.seats_text());
|
|
|
|
let between: MyGame = serde_json::from_value(json!({
|
|
"id": "g3",
|
|
"phase": "hand_end",
|
|
}))
|
|
.unwrap();
|
|
assert_eq!("Between hands", between.phase_label());
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_game_types_cover_the_registered_games() {
|
|
let types = fallback_game_types();
|
|
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
|
assert_eq!(
|
|
vec![
|
|
"scopone_scientifico",
|
|
"scopa",
|
|
"scopa_a_quattro",
|
|
"briscola",
|
|
"briscola_a_quattro",
|
|
"briscola_chiamata",
|
|
],
|
|
ids
|
|
);
|
|
// The lobby's first game (the creation default) must exist.
|
|
assert_eq!("scopone_scientifico", types[0].id);
|
|
assert_eq!(5, types[5].max_players);
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_briscola_payload() {
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "b1",
|
|
"join_code": "ABC123",
|
|
"game_type": "briscola",
|
|
"phase": "playing",
|
|
"target_score": 61,
|
|
"player_count": 2,
|
|
"hand_number": 1,
|
|
"dealer": 0,
|
|
"turn": 1,
|
|
"scores": {"A": 0, "B": 0},
|
|
"briscola_suit": "D",
|
|
"briscola_card": "07D",
|
|
"stock_count": 33,
|
|
"trick": [{"seat": 1, "card": "04C"}],
|
|
"trick_leader": 1,
|
|
"last_trick": null,
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
|
|
"cards_left": 3, "captured_count": 0, "points": 0,
|
|
"hand": ["01D"]},
|
|
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
|
|
"cards_left": 3, "captured_count": 0, "points": 0},
|
|
],
|
|
"last_deal": null,
|
|
"acknowledged": [],
|
|
"hand_end_deadline": null,
|
|
"turn_deadline": "2026-01-01T00:00:30+00:00",
|
|
"your_turn": true,
|
|
}))
|
|
.unwrap();
|
|
assert!(is_briscola(&g.game_type));
|
|
assert_eq!("D", g.briscola_suit);
|
|
assert_eq!(Some("07D".to_string()), g.briscola_card);
|
|
assert_eq!(33, g.stock_count);
|
|
assert_eq!(1, g.trick.len());
|
|
assert_eq!(1, g.trick[0].seat);
|
|
assert_eq!("04C", g.trick[0].card);
|
|
assert_eq!(Some(1), g.trick_leader);
|
|
assert_eq!(0, g.players[0].points);
|
|
assert_eq!(Some(true), g.your_turn);
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_briscola_deal_summary() {
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "b1",
|
|
"game_type": "briscola_a_quattro",
|
|
"phase": "hand_end",
|
|
"player_count": 4,
|
|
"scores": {"A": 55, "B": 65},
|
|
"last_deal": {
|
|
"deal": 1,
|
|
"patta": false,
|
|
"points": {"A": 55, "B": 65},
|
|
"totals": {"A": 55, "B": 65},
|
|
"cards": {"A": 19, "B": 21},
|
|
},
|
|
"last_trick": {
|
|
"winner": 3,
|
|
"cards": [
|
|
{"seat": 0, "card": "02D"},
|
|
{"seat": 1, "card": "05D"},
|
|
{"seat": 2, "card": "04D"},
|
|
{"seat": 3, "card": "07D"},
|
|
],
|
|
},
|
|
}))
|
|
.unwrap();
|
|
let deal = g.last_deal.unwrap();
|
|
assert_eq!(1, deal.deal);
|
|
assert!(!deal.patta);
|
|
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
|
|
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
|
|
let cards = deal.cards.unwrap();
|
|
assert_eq!(19, cards.a);
|
|
assert_eq!(21, cards.b);
|
|
let trick = g.last_trick.unwrap();
|
|
assert_eq!(3, trick.winner);
|
|
assert_eq!(4, trick.cards.len());
|
|
}
|
|
|
|
#[test]
|
|
fn deal_summary_defaults() {
|
|
let d: DealSummary = serde_json::from_value(json!({
|
|
"points": {"A": 60, "B": 60},
|
|
"totals": {"A": 10, "B": 20},
|
|
}))
|
|
.unwrap();
|
|
assert!(!d.patta);
|
|
assert_eq!(0, d.deal);
|
|
assert!(d.cards.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn chiamata_deal_summary_has_no_totals() {
|
|
let d: DealSummary = serde_json::from_value(json!({
|
|
"deal": 2,
|
|
"patta": true,
|
|
"points": {"A": 60, "B": 60},
|
|
"declared_target": 61,
|
|
"called_card": "01D",
|
|
"caller": "Alice",
|
|
"solo": false,
|
|
}))
|
|
.unwrap();
|
|
assert!(d.patta);
|
|
assert_eq!(2, d.deal);
|
|
assert!(d.totals.is_none());
|
|
assert!(d.cards.is_none());
|
|
assert_eq!(Some(61), d.declared_target);
|
|
assert_eq!(Some("01D".to_string()), d.called_card);
|
|
assert!(!d.solo);
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_chiamata_auction() {
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "c1",
|
|
"join_code": "ABC123",
|
|
"game_type": "briscola_chiamata",
|
|
"phase": "auction",
|
|
"player_count": 5,
|
|
"dealer": 0,
|
|
"hand_number": 1,
|
|
"turn": 1,
|
|
"declared_target": 61,
|
|
"auction": {
|
|
"turn": 2,
|
|
"active": [0, 1, 2, 3, 4],
|
|
"passed": [],
|
|
"current": {"seat": 1, "rank": 10, "points": 61},
|
|
"history": [{"seat": 1, "rank": 10, "points": 61}],
|
|
},
|
|
"your_turn": true,
|
|
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
|
|
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
|
|
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
|
|
"cards_left": 8, "captured_count": 0, "points": 0},
|
|
],
|
|
"auction_deadline": "2026-01-01T00:00:30+00:00",
|
|
"call_deadline": null,
|
|
"turn_deadline": null,
|
|
}))
|
|
.unwrap();
|
|
assert!(is_chiamata(&g.game_type));
|
|
assert!(!is_briscola(&g.game_type));
|
|
let auction = g.auction.unwrap();
|
|
assert_eq!(2, auction.turn);
|
|
assert_eq!(5, auction.active.len());
|
|
let current = auction.current.unwrap();
|
|
assert_eq!(1, current.seat);
|
|
assert_eq!(10, current.rank);
|
|
assert_eq!(61, current.points);
|
|
assert_eq!(1, auction.history.len());
|
|
let bids = g.legal_bids.unwrap();
|
|
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
|
|
assert!(bids.points_min.is_none());
|
|
assert_eq!(Some(120), bids.points_max);
|
|
assert_eq!(Some("".to_string()), g.players[0].team);
|
|
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_chiamata_playing_and_reveal() {
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "c2",
|
|
"game_type": "briscola_chiamata",
|
|
"phase": "playing",
|
|
"player_count": 5,
|
|
"dealer": 0,
|
|
"hand_number": 1,
|
|
"turn": 3,
|
|
"caller": 1,
|
|
"called_card": "03C",
|
|
"briscola_suit": "C",
|
|
"declared_target": 64,
|
|
"called_played": true,
|
|
"partner": 3,
|
|
"solo": false,
|
|
"scores": {"A": 45, "B": 30},
|
|
"players": [
|
|
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
|
|
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
|
|
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
|
|
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
|
|
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
|
|
],
|
|
}))
|
|
.unwrap();
|
|
assert_eq!(Some(1), g.caller);
|
|
assert_eq!(Some("03C".to_string()), g.called_card);
|
|
assert_eq!("C", g.briscola_suit);
|
|
assert_eq!(64, g.declared_target);
|
|
assert!(g.called_played);
|
|
assert_eq!(Some(3), g.partner);
|
|
assert!(!g.solo);
|
|
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
|
|
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
|
|
}
|
|
|
|
#[test]
|
|
fn chiamata_sides_label_the_solo_caller() {
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "c3",
|
|
"game_type": "briscola_chiamata",
|
|
"phase": "finished",
|
|
"scores": {"A": 70, "B": 50},
|
|
"winner": "Chiamante",
|
|
"caller": 2,
|
|
"called_played": true,
|
|
"solo": true,
|
|
"players": [
|
|
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
|
|
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
|
|
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
|
|
],
|
|
}))
|
|
.unwrap();
|
|
let (a, b) = g.chiamata_sides();
|
|
assert_eq!("Carol (solo)", a);
|
|
assert_eq!("Difensori", b);
|
|
assert_eq!(Some("Chiamante".to_string()), g.winner);
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_scopa_payload() {
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "s1",
|
|
"join_code": "ABC123",
|
|
"game_type": "scopa",
|
|
"phase": "playing",
|
|
"target_score": 11,
|
|
"asso_pigliatutto": true,
|
|
"player_count": 2,
|
|
"hand_number": 1,
|
|
"dealer": 0,
|
|
"turn": 1,
|
|
"deck_count": 24,
|
|
"scores": {"A": 0, "B": 0},
|
|
"table": ["05C", "02D", "03S", "10B"],
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
|
|
"cards_left": 3, "captured_count": 0, "scope": 0},
|
|
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
|
|
"cards_left": 3, "captured_count": 0, "scope": 0,
|
|
"hand": ["01D", "06C", "09S"]},
|
|
],
|
|
"last_hand": null,
|
|
"last_move": {"seat": 0, "name": "Alice", "card": "04D",
|
|
"captured": ["01C", "03C"], "scopa": true,
|
|
"pigliatutto": false},
|
|
"acknowledged": [],
|
|
"hand_end_deadline": null,
|
|
"turn_deadline": "2026-01-01T00:00:30+00:00",
|
|
"your_turn": true,
|
|
"legal_moves": {"01D": [["01C"]]},
|
|
}))
|
|
.unwrap();
|
|
assert!(is_scopa(&g.game_type));
|
|
assert!(is_individual_game(&g.game_type));
|
|
assert!(g.asso_pigliatutto);
|
|
assert_eq!(2, g.player_count);
|
|
assert_eq!(24, g.deck_count);
|
|
assert_eq!(4, g.table.len());
|
|
assert_eq!(Some(vec!["01D".to_string(), "06C".to_string(), "09S".to_string()]),
|
|
g.players[1].hand.clone());
|
|
let mv = g.last_move.unwrap();
|
|
assert!(mv.scopa);
|
|
assert_eq!(2, mv.captured.len());
|
|
}
|
|
|
|
#[test]
|
|
fn game_view_parses_scopa_a_quattro_hand_summary() {
|
|
let g: GameView = serde_json::from_value(json!({
|
|
"id": "s2",
|
|
"game_type": "scopa_a_quattro",
|
|
"phase": "hand_end",
|
|
"player_count": 4,
|
|
"scores": {"A": 3, "B": 0},
|
|
"players": [
|
|
{"sub": "a", "name": "Alice", "seat": 0, "team": "A"},
|
|
{"sub": "b", "name": "Bob", "seat": 1, "team": "B"},
|
|
{"sub": "c", "name": "Carol", "seat": 2, "team": "A"},
|
|
{"sub": "d", "name": "Dave", "seat": 3, "team": "B"},
|
|
],
|
|
"last_hand": {
|
|
"cards": {"A": 21, "B": 19},
|
|
"denara": {"A": 6, "B": 4},
|
|
"settebello": {"A": true, "B": false},
|
|
"primiera": {"A": 70, "B": 60},
|
|
"scope": {"A": 1, "B": 0},
|
|
"award": {
|
|
"carte": "A", "denara": "A", "settebello": "A",
|
|
"primiera": "A",
|
|
},
|
|
"hand": 1,
|
|
"team_a_points": 3,
|
|
"team_b_points": 0,
|
|
},
|
|
}))
|
|
.unwrap();
|
|
assert!(is_scopa(&g.game_type));
|
|
assert!(!is_individual_game(&g.game_type));
|
|
let hand = g.last_hand.unwrap();
|
|
assert_eq!(21, hand.cards.a);
|
|
assert_eq!(Some("A".to_string()), hand.award.carte);
|
|
assert!(hand.napola.is_none());
|
|
assert_eq!(3, hand.team_a_points);
|
|
}
|
|
}
|