Files
tavolo/web/src/model.rs
T
woggioni 7165429dd5 Add preliminary support for multiple card games
A game-type registry (server/src/tavolo/games.py) is now the single
source of truth for the games the platform can host; only scopone
scientifico is registered so far. GET /api/game-types exposes it for the
lobby's new game dropdown, and POST /api/games accepts a validated
game_type (default scopone_scientifico) which is carried on the live
GameState and onto each finished Match row (new indexed column,
migration 1_20260916235833_update), so statistics can be scoped per
game: /api/me/matches and /api/leaderboard take an optional game_type
filter and every serialized match includes its game_type.

Game states serialized before this change still load with the default
game type.
2026-09-17 00:25:58 +00:00

290 lines
6.9 KiB
Rust

//! Serde types mirroring the backend API payloads.
use serde::Deserialize;
use std::collections::HashMap;
#[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,
pub team: String,
#[serde(default)]
pub cards_left: usize,
#[serde(default)]
pub captured_count: usize,
#[serde(default)]
pub scope: i32,
/// Own hand only; absent for the other players.
#[serde(default)]
pub hand: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, 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,
}
#[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>,
}
/// 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,
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,
#[serde(default)]
pub join_code: String,
/// Which card game this match is (id from /api/game-types).
#[serde(default)]
pub game_type: String,
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>,
#[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>>>>,
}
#[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,
pub team: String,
pub won: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct MatchSummary {
pub id: String,
#[serde(default)]
pub game_type: String,
pub team_a_score: i32,
pub team_b_score: i32,
pub winner_team: String,
pub target_score: i32,
pub hands_played: i32,
pub started_at: String,
pub finished_at: String,
#[serde(default)]
pub you_won: bool,
#[serde(default)]
pub players: Vec<MatchPlayer>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MatchesPage {
#[serde(default)]
pub results: Vec<MatchSummary>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LeaderboardEntry {
pub user_sub: String,
pub display_name: String,
pub matches: i32,
pub wins: i32,
pub points: i32,
}
#[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,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GameTypesPage {
#[serde(default)]
pub results: Vec<GameTypeInfo>,
}
/// Map a card code (e.g. `07D`) to its asset path.
pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg")
}
pub const CARD_BACK: &str = "/assets/cards/back.svg";
/// 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::*;
#[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"));
}
}