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())
}
+21
View File
@@ -0,0 +1,21 @@
//! Card rendering helpers (plain functions returning views).
use sycamore::prelude::*;
use crate::model::{card_asset, card_label, CARD_BACK};
/// Render a card image at a given size class (`mini`, `move-card`,
/// `hand-card`, `table-card`).
pub fn card_img(code: String, class: &'static str) -> View {
let src = card_asset(&code);
let alt = card_label(&code);
view! {
img(src=src, class=format!("card-img {class}"), alt=alt, draggable="false")
}
}
/// Render the back of a card (used for opponents' hidden hands).
pub fn card_back(class: &'static str) -> View {
view! {
img(src=CARD_BACK, class=format!("card-img {class}"), alt="card back", draggable="false")
}
}
+1
View File
@@ -0,0 +1 @@
pub mod card;
+57
View File
@@ -0,0 +1,57 @@
//! Application entry point.
mod api;
mod components;
mod model;
mod pages;
mod ws;
use sycamore::prelude::*;
use sycamore_router::{HistoryIntegration, Route, Router};
use pages::game::GamePage;
use pages::history::HistoryPage;
use pages::leaderboard::LeaderboardPage;
use pages::lobby::LobbyPage;
#[derive(Route, Clone)]
enum AppRoutes {
#[to("/")]
Lobby,
#[to("/game/<id>")]
Game { id: String },
#[to("/history")]
History,
#[to("/leaderboard")]
Leaderboard,
#[not_found]
NotFound,
}
fn main() {
console_error_panic_hook::set_once();
sycamore::render(|| {
view! {
Router(
integration=HistoryIntegration::new(),
view=|route: ReadSignal<AppRoutes>| {
view! {
div(class="app") {
(match route.get_clone() {
AppRoutes::Lobby => view! { LobbyPage() },
AppRoutes::Game { id } => view! { GamePage(id=id) },
AppRoutes::History => view! { HistoryPage() },
AppRoutes::Leaderboard => view! { LeaderboardPage() },
AppRoutes::NotFound => view! {
div(class="panel") {
h1 { "Page not found" }
a(href="/") { "Back to lobby" }
}
},
})
}
}
}
)
}
});
}
+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"));
}
}
+346
View File
@@ -0,0 +1,346 @@
//! Live game page: table view over the websocket.
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
if let Some(s) = socket.get_clone() {
s.play(&card, capture);
}
}
/// Panel for one player seat (name, team, hidden card count, stats).
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else {
return view! {};
};
let active = game.phase == "playing" && game.turn == seat;
let cls = format!(
"seat seat-{position}{}",
if active { " active" } else { "" }
);
let name = player.name.clone();
let team = player.team.clone();
let captured = player.captured_count;
let scope = player.scope;
let backs = (0..player.cards_left)
.map(|_| card_back("mini"))
.collect::<Vec<_>>();
view! {
div(class=cls) {
div(class="seat-name") {
(name)
span(class="team-badge") { "Team " (team) }
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
(captured) " captured · " (scope) " scope"
}
}
}
}
/// The last-move announcement strip.
fn move_banner(mv: MoveView) -> View {
let name = mv.name.clone();
let action = if mv.captured.is_empty() {
"played"
} else {
"capturing"
};
let played = card_img(mv.card.clone(), "move-card");
let captured = mv
.captured
.iter()
.map(|c| card_img(c.clone(), "move-card"))
.collect::<Vec<_>>();
let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } });
view! {
div(class="move-banner") {
strong { (name) }
span { " " (action) " " }
(played)
(captured)
(scopa)
}
}
}
#[component(inline_props)]
pub fn GamePage(id: String) -> View {
let game = create_signal(Option::<GameView>::None);
let error = create_signal(Option::<String>::None);
let capture_choice = create_signal(Option::<(String, Vec<Vec<String>>)>::None);
let selected = create_signal(Option::<String>::None);
let over = create_signal(Option::<(Scores, Option<String>)>::None);
let closed = create_signal(false);
let socket = create_signal(Option::<GameSocket>::None);
{
let on_message = move |msg: ServerMessage| match msg {
ServerMessage::State { game: g } => {
capture_choice.set(None);
selected.set(None);
game.set(Some(g));
}
ServerMessage::GameOver { scores, winner } => {
over.set(Some((scores, winner)));
}
ServerMessage::Error { message, .. } => error.set(Some(message)),
};
let on_close = move || closed.set(true);
match ws::connect(&id, on_message, on_close) {
Some(s) => socket.set(Some(s)),
None => error.set(Some("Could not connect to the game".to_string())),
}
}
// Clicking a card in the player's own hand.
let on_hand_card = move |code: String| {
let Some(g) = game.get_clone() else { return };
if g.your_turn != Some(true) {
return;
}
let options = g
.legal_moves
.as_ref()
.and_then(|m| m.get(&code))
.cloned();
match options {
None => send_play(socket, code, None),
Some(mut opts) if opts.len() == 1 => {
send_play(socket, code, Some(opts.remove(0)))
}
Some(opts) => capture_choice.set(Some((code, opts))),
}
};
view! {
div(class="game-page") {
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
(move || match game.get_clone() {
None => {
let status = if closed.get() {
"Connection closed."
} else {
"Connecting to the game…"
};
view! {
div(class="panel status-panel") {
p { (status) }
p { a(href="/") { "Back to lobby" } }
}
}
}
Some(g) if g.phase == "lobby" => lobby_view(g),
Some(g) => table_view(g, on_hand_card, selected),
})
(move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice)
}))
(move || game_over_view(over.get_clone(), game.get_clone()))
}
}
}
/// Lobby view while waiting for the fourth player.
fn lobby_view(game: GameView) -> View {
let seats_open = 4usize.saturating_sub(game.players.len());
let join_code = game.join_code.clone();
let players = game
.players
.iter()
.map(|p| {
let name = p.name.clone();
let seat = p.seat;
let team = p.team.clone();
view! {
li {
strong { (name) }
span { " (seat " (seat) ", team " (team) ")" }
}
}
})
.collect::<Vec<_>>();
view! {
div(class="panel status-panel") {
h2 { "Waiting for players" }
p { "Share this join code:" }
p(class="join-code") { (join_code) }
ul(class="roster") { (players) }
p { (seats_open) " seat(s) still open" }
p { a(href="/") { "Back to lobby" } }
}
}
}
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
/// The main table view.
fn table_view(
game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static,
selected: Signal<Option<String>>,
) -> View {
// Own seat: the only player entry carrying a hand.
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat)
.unwrap_or(0);
let left = seat_panel(game.clone(), (viewer_seat + 1) % 4, "left");
let top = seat_panel(game.clone(), (viewer_seat + 2) % 4, "top");
let right = seat_panel(game.clone(), (viewer_seat + 3) % 4, "right");
let my_turn = game.your_turn == Some(true);
let turn_note = if game.phase == "finished" {
"Match finished".to_string()
} else if my_turn {
"Your turn".to_string()
} else {
let name = player_for_seat(&game, game.turn)
.map(|p| p.name)
.unwrap_or_default();
format!("{name}'s turn")
};
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
let table_cards = game
.table
.iter()
.map(|c| card_img(c.clone(), "table-card"))
.collect::<Vec<_>>();
let empty_table = game.table.is_empty().then(|| view! {
p(class="table-empty") { "Empty table" }
});
let banner = game.last_move.clone().map(move_banner);
let hand_number = game.hand_number;
let target_score = game.target_score;
let hand = player_for_seat(&game, viewer_seat)
.and_then(|p| p.hand)
.unwrap_or_default();
let current_selection = selected.get_clone();
let hand_cards = hand
.into_iter()
.map(|code| {
let is_selected = current_selection.as_deref() == Some(code.as_str());
let cls = if is_selected {
"hand-slot selected"
} else {
"hand-slot"
};
let card_view = card_img(code.clone(), "hand-card");
view! {
button(class=cls, on:click=move |_| {
if my_turn {
on_hand_card(code.clone());
}
}) { (card_view) }
}
})
.collect::<Vec<_>>();
let hint = my_turn.then(|| view! {
p(class="hint") {
"Click a card to play it. If it can capture in several ways you "
"will be asked to choose."
}
});
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Hand " (hand_number) }
span(class="hud-scores") {
"Team A " (scores.a) "" (scores.b) " Team B (target "
(target_score) ")"
}
span(class=turn_cls) { (turn_note) }
}
div(class="table-grid") {
(top)
(left)
div(class="center") {
(banner)
div(class="table-cards") {
(table_cards)
(empty_table)
}
}
(right)
div(class="seat-bottom") {
div(class="hand") { (hand_cards) }
(hint)
}
}
}
}
}
/// Popup listing the legal captures for a selected card.
fn capture_picker(
card: String,
options: Vec<Vec<String>>,
socket: Signal<Option<GameSocket>>,
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
) -> View {
let title = format!("Capture with {}", card_label(&card));
let option_views = options
.into_iter()
.map(|capture| {
let played_card = card.clone();
let cards = capture
.iter()
.map(|c| card_img(c.clone(), "mini"))
.collect::<Vec<_>>();
view! {
button(class="capture-option", on:click=move |_| {
send_play(socket, played_card.clone(), Some(capture.clone()));
capture_choice.set(None);
}) { (cards) }
}
})
.collect::<Vec<_>>();
view! {
div(class="overlay") {
div(class="picker") {
h3 { (title) }
div(class="capture-options") { (option_views) }
button(class="button", on:click=move |_| capture_choice.set(None)) { "Cancel" }
}
}
}
}
/// End-of-match overlay.
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
let result = over.or_else(|| {
game.filter(|g| g.phase == "finished")
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
});
match result {
None => view! {},
Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string());
let line = format!("Team {winner} wins {} {}", scores.a, scores.b);
view! {
div(class="overlay") {
div(class="picker") {
h2 { "Match over" }
p { (line) }
div(class="gameover-actions") {
a(class="button primary", href="/") { "Back to lobby" }
a(class="button", href="/history") { "My matches" }
}
}
}
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Match history page.
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use crate::api;
use crate::model::MatchesPage;
#[component]
pub fn HistoryPage() -> View {
let page = create_signal(Option::<MatchesPage>::None);
let error = create_signal(Option::<String>::None);
let cursor = create_signal(Option::<String>::None);
// Accumulated rows across "load more" clicks.
let rows = create_signal(Vec::<crate::model::MatchSummary>::new());
let load = move |next: Option<String>| {
spawn_local(async move {
match api::my_matches(next.as_deref()).await {
Ok(p) => {
cursor.set(p.next_cursor.clone());
rows.update(|acc| acc.extend(p.results.iter().cloned()));
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
}
});
};
let load2 = load;
spawn_local(async move {
match api::my_matches(None).await {
Ok(p) => {
cursor.set(p.next_cursor.clone());
rows.set(p.results.clone());
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
}
});
view! {
div(class="page") {
nav(class="top-nav") {
a(href="/") { "← Lobby" }
a(href="/leaderboard") { "Leaderboard" }
}
h1 { "My matches" }
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
(move || match page.get_clone() {
None => view! { p(class="status") { "Loading…" } },
Some(_) if rows.get_clone().is_empty() => view! {
p(class="status") { "No matches played yet." }
},
Some(_) => {
let table_rows = rows
.get_clone()
.into_iter()
.map(|m| {
let team_a: String = m
.players
.iter()
.filter(|p| p.team == "A")
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ");
let team_b: String = m
.players
.iter()
.filter(|p| p.team == "B")
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ");
let outcome = if m.you_won { "Won" } else { "Lost" };
view! {
tr {
td { (m.finished_at.replace('T', " ").chars().take(16).collect::<String>()) }
td { (team_a) }
td { (team_b) }
td { (m.team_a_score) " " (m.team_b_score) }
td { "Team " (m.winner_team) }
td(class=if m.you_won { "won" } else { "lost" }) { (outcome) }
}
}
})
.collect::<Vec<_>>();
view! {
table(class="matches") {
thead {
tr {
th { "Finished" }
th { "Team A" }
th { "Team B" }
th { "Score" }
th { "Winner" }
th { "You" }
}
}
tbody { (table_rows) }
}
(cursor.get_clone().map(|c| view! {
button(class="button", on:click=move |_| load2(Some(c.clone()))) {
"Load more"
}
}))
}
}
})
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//! Global leaderboard page.
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use crate::api;
use crate::model::LeaderboardPage;
#[component]
pub fn LeaderboardPage() -> View {
let page = create_signal(Option::<LeaderboardPage>::None);
let error = create_signal(Option::<String>::None);
spawn_local(async move {
match api::leaderboard().await {
Ok(p) => page.set(Some(p)),
Err(e) => error.set(Some(e)),
}
});
view! {
div(class="page") {
nav(class="top-nav") {
a(href="/") { "← Lobby" }
a(href="/history") { "My matches" }
}
h1 { "Leaderboard" }
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
(move || match page.get_clone() {
None => view! { p(class="status") { "Loading…" } },
Some(p) => {
let rows = p
.results
.iter()
.cloned()
.enumerate()
.map(|(i, e)| {
view! {
tr {
td { (i + 1) }
td { (e.display_name.clone()) }
td { (e.wins) }
td { (e.matches) }
td { (e.points) }
}
}
})
.collect::<Vec<_>>();
view! {
table(class="matches") {
thead {
tr {
th { "#" }
th { "Player" }
th { "Wins" }
th { "Matches" }
th { "Points" }
}
}
tbody { (rows) }
}
}
}
})
}
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Lobby page: login prompt, match creation and joining by code.
use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*;
use sycamore_router::navigate;
use crate::api;
use crate::model::User;
#[component]
pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out.
let user = create_signal(Option::<Option<User>>::None);
let error = create_signal(Option::<String>::None);
let code = create_signal(String::new());
spawn_local(async move {
match api::me().await {
Ok(me) => user.set(Some(me)),
Err(e) => {
error.set(Some(e));
user.set(Some(None));
}
}
});
let on_create = move |target: i32| {
spawn_local(async move {
match api::create_game(target).await {
Ok(game) => navigate(&format!("/game/{}", game.id)),
Err(e) => error.set(Some(e)),
}
});
};
let on_join = move |_| {
let value = code.get_clone().trim().to_uppercase();
if value.is_empty() {
return;
}
spawn_local(async move {
match api::join_game(&value).await {
Ok(game) => navigate(&format!("/game/{}", game.id)),
Err(e) => error.set(Some(e)),
}
});
};
view! {
div(class="lobby") {
h1 { "Scopone scientifico" }
(move || error.get_clone().map(|e| view! { div(class="toast") { (e) } }))
(move || match user.get_clone() {
None => view! { p(class="status") { "Loading…" } },
Some(None) => view! {
div(class="panel login-panel") {
p { "Log in with your account to play." }
a(class="button primary", href="/auth/login") { "Log in" }
}
},
Some(Some(me)) => view! {
div(class="lobby-grid") {
nav(class="top-nav") {
span(class="whoami") { "Signed in as " strong { (me.name.clone()) } }
a(href="/history") { "My matches" }
a(href="/leaderboard") { "Leaderboard" }
a(href="/auth/logout") { "Log out" }
}
div(class="panel") {
h2 { "New match" }
p { "First team to reach the target score wins." }
div(class="target-buttons") {
button(class="button", on:click=move |_| on_create(11)) { "Target 11" }
button(class="button", on:click=move |_| on_create(16)) { "Target 16" }
button(class="button", on:click=move |_| on_create(21)) { "Target 21" }
}
}
div(class="panel") {
h2 { "Join with a code" }
div(class="join-form") {
input(
r#type="text",
placeholder="6-letter code",
maxlength="6",
bind:value=code,
)
button(class="button primary", on:click=on_join) { "Join" }
}
}
}
},
})
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod game;
pub mod history;
pub mod leaderboard;
pub mod lobby;
+87
View File
@@ -0,0 +1,87 @@
//! WebSocket client for live play.
use std::cell::RefCell;
use std::rc::Rc;
use futures::channel::mpsc;
use futures::{SinkExt, StreamExt};
use gloo_net::websocket::{futures::WebSocket, Message};
use wasm_bindgen_futures::spawn_local;
use crate::model::ServerMessage;
fn ws_url(game_id: &str) -> String {
let location = web_sys::window().unwrap().location();
let protocol = location.protocol().unwrap_or_else(|_| "http:".into());
let scheme = if protocol == "https:" { "wss" } else { "ws" };
let host = location.host().unwrap_or_else(|_| "127.0.0.1:8080".into());
format!("{scheme}://{host}/ws/games/{game_id}")
}
/// A cloneable handle to the game websocket. Incoming server messages are
/// pushed into the receiver returned by [`connect`].
#[derive(Clone)]
pub struct GameSocket {
sender: Rc<RefCell<mpsc::UnboundedSender<String>>>,
}
impl GameSocket {
/// Play a card, optionally capturing the given table cards.
pub fn play(&self, card: &str, capture: Option<Vec<String>>) {
let mut msg = serde_json::json!({ "action": "play", "card": card });
if let Some(capture) = capture {
msg["capture"] = serde_json::json!(capture);
}
self.send_json(msg);
}
/// Ask the server for a fresh snapshot.
#[allow(dead_code)]
pub fn sync(&self) {
self.send_json(serde_json::json!({ "action": "state" }));
}
fn send_json(&self, value: serde_json::Value) {
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
}
}
/// Open the websocket for `game_id` and forward parsed server messages to
/// `on_message`. Returns the socket handle, or `None` if the connection
/// could not be created.
pub fn connect(
game_id: &str,
on_message: impl Fn(ServerMessage) + 'static,
on_close: impl Fn() + 'static,
) -> Option<GameSocket> {
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
let (mut write, mut read) = ws.split();
// Outgoing channel: GameSocket::send_json queues text frames here.
let (out_tx, mut out_rx) = mpsc::unbounded::<String>();
spawn_local(async move {
while let Some(text) = out_rx.next().await {
if write.send(Message::Text(text)).await.is_err() {
break;
}
}
});
spawn_local(async move {
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => {
if let Ok(parsed) = serde_json::from_str::<ServerMessage>(&text) {
on_message(parsed);
}
}
Ok(Message::Bytes(_)) => {}
Err(_) => break,
}
}
on_close();
});
Some(GameSocket {
sender: Rc::new(RefCell::new(out_tx)),
})
}