Each player's rating starts at 1500 and updates transactionally with every finished match: a team's rating is the mean of its two members and the standard K=32 formula decides the zero-sum delta applied to both members of a team. Ratings are per game type in a new player_rating table; match_player records each match's elo_delta. - GET /api/leaderboard exposes elo and sorts by it - GET /api/me/matches includes per-player elo deltas - new GET /api/me/ratings returns the caller's rating per game type - frontend: Elo column on the leaderboard, per-match delta in the history page, current rating in the lobby - python -m tavolo.backfill_elo recomputes all ratings from the recorded match history (one-off backfill for existing matches)
332 lines
8.1 KiB
Rust
332 lines
8.1 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,
|
|
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>,
|
|
#[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,
|
|
#[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 napola rule is scored in this match.
|
|
#[serde(default = "default_true")]
|
|
pub napola: 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>,
|
|
#[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,
|
|
/// Elo change this match produced for the player; absent for matches
|
|
/// recorded before ratings existed.
|
|
#[serde(default)]
|
|
pub elo_delta: Option<i32>,
|
|
}
|
|
|
|
#[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,
|
|
/// 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>,
|
|
}
|
|
|
|
#[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,
|
|
pub points: i32,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|