Add Sycamore/WASM frontend and restructure into server/ + web/
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.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user