Repo is now a monorepo:
- server/: the kaya backend, unchanged in behaviour, plus:
- GET /api/me for SPA session detection
- last_move recorded on every play and broadcast in the game state, so
clients can show who played which card the moment they play it
- legal_moves per hand card for the player on turn (rules stay
server-side)
- static catch-all route serving the compiled SPA with index.html
fallback; Tortoise context now bound only for /api/* requests
- configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
lobby (create match / join by code), live game page over websocket with
card images (CC0 woodcut napoletane deck), capture picker, move banner,
game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
app image serves the SPA; compose builds from the repo root with
overridable ports/OIDC env
Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
207 lines
4.8 KiB
Rust
207 lines
4.8 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, Deserialize)]
|
|
#[allow(dead_code)]
|
|
pub struct GameView {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub join_code: 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>,
|
|
#[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,
|
|
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>,
|
|
}
|
|
|
|
/// 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"));
|
|
}
|
|
}
|