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:
2026-09-16 13:20:05 +08:00
parent e9ddb82e9a
commit 96a95d74b6
104 changed files with 151484 additions and 236 deletions
+206
View File
@@ -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"));
}
}