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
+90
View File
@@ -0,0 +1,90 @@
//! REST client for the scopa backend. Same-origin requests carry the
//! session cookie automatically.
use crate::model::*;
use gloo_net::http::Request;
fn server_error(status: u16) -> String {
format!("server returned {status}")
}
/// Fetch the current user; `None` when unauthenticated (401).
pub async fn me() -> Result<Option<User>, String> {
let resp = Request::get("/api/me")
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 401 {
return Ok(None);
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map(Some).map_err(|e| e.to_string())
}
pub async fn create_game(target_score: i32) -> Result<GameView, String> {
let resp = Request::post("/api/games")
.json(&serde_json::json!({ "target_score": target_score }))
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn join_game(code: &str) -> Result<GameView, String> {
let resp = Request::post("/api/games/join")
.json(&serde_json::json!({ "code": code }))
.map_err(|e| e.to_string())?
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let message = body
.get("error")
.and_then(|e| e.as_str())
.map(str::to_string)
.unwrap_or_else(|| server_error(resp.status()));
return Err(message);
}
resp.json().await.map_err(|e| e.to_string())
}
#[allow(dead_code)]
pub async fn game_state(game_id: &str) -> Result<GameView, String> {
let resp = Request::get(&format!("/api/games/{game_id}"))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
let url = match cursor {
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
None => "/api/me/matches?limit=10".to_string(),
};
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
let resp = Request::get("/api/leaderboard")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map_err(|e| e.to_string())
}