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,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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user