CI / Build and push docker image (push) Failing after 39s
The lobby was hand-rolled CSS and looked plain. Vendor Bootstrap 5.3 (MIT) under web/vendor so builds stay offline, load it in dark mode and bridge the felt/gold palette onto Bootstrap's CSS variables in a new theme.css; style.css now only owns the game table, cards and scoring summary. Rebuild the lobby, waiting room and shared chrome with Bootstrap components: a shared navbar, creation form as cards with schema-driven controls, roster as a list group with seat/team badges, a seats progress bar and a copy-to-clipboard button on the join code. History, leaderboard, connection/error states and the 404 page use cards, tables and buttons too; the play table itself is unchanged. The copy feedback timer is cancelled when the waiting room is rebuilt by another player joining, fixing a disposed-signal panic that froze the page mid-match.
713 lines
25 KiB
Rust
713 lines
25 KiB
Rust
//! Live game page: table view over the websocket.
|
||
use std::cell::Cell;
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use sycamore::prelude::*;
|
||
|
||
use crate::components::card::{card_back, card_img};
|
||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||
use crate::components::toast::toast;
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Signals shared by the websocket connection and its reconnect attempts.
|
||
#[derive(Clone, Copy)]
|
||
struct ConnCtx {
|
||
socket: Signal<Option<GameSocket>>,
|
||
game: Signal<Option<GameView>>,
|
||
over: Signal<Option<(Scores, Option<String>)>>,
|
||
error: Signal<Option<String>>,
|
||
closed: Signal<bool>,
|
||
/// Reconnect attempts exhausted; only a manual retry resumes.
|
||
gave_up: Signal<bool>,
|
||
/// The server closed the connection deliberately (auth or game gone);
|
||
/// retrying is pointless.
|
||
fatal: Signal<bool>,
|
||
attempts: Signal<u32>,
|
||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||
selected: Signal<Option<String>>,
|
||
}
|
||
|
||
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
|
||
const MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||
|
||
fn backoff_ms(attempt: u32) -> u32 {
|
||
(1000u32 << attempt.min(5)).min(30_000)
|
||
}
|
||
|
||
/// Connect the game websocket, wiring state updates and reconnects.
|
||
///
|
||
/// The server pushes a full state snapshot on connect, so a reconnect is
|
||
/// also a resync: no client-side state merging is needed.
|
||
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||
let on_message = {
|
||
let alive = alive.clone();
|
||
move |msg: ServerMessage| {
|
||
if !alive.get() {
|
||
// The page is unmounted; its signals are disposed.
|
||
return;
|
||
}
|
||
match msg {
|
||
ServerMessage::State { game: g } => {
|
||
ctx.capture_choice.set(None);
|
||
ctx.selected.set(None);
|
||
// A received state proves the (re)connection works.
|
||
ctx.attempts.set(0);
|
||
ctx.gave_up.set(false);
|
||
ctx.closed.set(false);
|
||
ctx.game.set(Some(g));
|
||
}
|
||
ServerMessage::GameOver { scores, winner } => {
|
||
ctx.over.set(Some((scores, winner)))
|
||
}
|
||
ServerMessage::Error { message, .. } => ctx.error.set(Some(message)),
|
||
}
|
||
}
|
||
};
|
||
let on_close = {
|
||
let id = id.clone();
|
||
let alive = alive.clone();
|
||
move |code: Option<u16>| {
|
||
if !alive.get() {
|
||
return;
|
||
}
|
||
ctx.closed.set(true);
|
||
match code {
|
||
Some(4401) => {
|
||
ctx.fatal.set(true);
|
||
ctx.error
|
||
.set(Some("Session expired — please log in again.".to_string()));
|
||
}
|
||
Some(4403) | Some(4404) => {
|
||
ctx.fatal.set(true);
|
||
ctx.error
|
||
.set(Some("This game is no longer available.".to_string()));
|
||
}
|
||
_ => schedule_retry(id.clone(), ctx, alive.clone()),
|
||
}
|
||
}
|
||
};
|
||
match ws::connect(&id, on_message, on_close) {
|
||
Some(s) => ctx.socket.set(Some(s)),
|
||
// WebSocket::open failed synchronously: treat as a transient loss.
|
||
None if alive.get() => {
|
||
ctx.closed.set(true);
|
||
schedule_retry(id, ctx, alive);
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
|
||
/// Retry `start_connect` with exponential backoff, unless we gave up.
|
||
fn schedule_retry(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||
let attempt = ctx.attempts.get();
|
||
if attempt >= MAX_RECONNECT_ATTEMPTS {
|
||
ctx.gave_up.set(true);
|
||
return;
|
||
}
|
||
ctx.attempts.set(attempt + 1);
|
||
gloo_timers::callback::Timeout::new(backoff_ms(attempt), move || {
|
||
if alive.get() {
|
||
start_connect(id, ctx, alive);
|
||
}
|
||
})
|
||
.forget();
|
||
}
|
||
|
||
/// Slim banner shown over the table while the socket is down.
|
||
fn conn_banner(
|
||
closed: bool,
|
||
gave_up: bool,
|
||
fatal: bool,
|
||
has_game: bool,
|
||
reconnect: Rc<dyn Fn()>,
|
||
) -> View {
|
||
if !closed || !has_game {
|
||
return view! {};
|
||
}
|
||
if fatal {
|
||
view! {
|
||
div(
|
||
class="alert alert-warning d-flex align-items-center justify-content-center \
|
||
gap-3 mx-3 mt-3 mb-0"
|
||
) {
|
||
"Connection closed by the server."
|
||
a(class="alert-link", href="/") { "Back to lobby" }
|
||
}
|
||
}
|
||
} else if gave_up {
|
||
view! {
|
||
div(
|
||
class="alert alert-warning d-flex align-items-center justify-content-center \
|
||
gap-3 mx-3 mt-3 mb-0"
|
||
) {
|
||
"Connection lost."
|
||
button(
|
||
class="btn btn-sm btn-warning",
|
||
on:click=move |_| reconnect(),
|
||
) {
|
||
"Retry now"
|
||
}
|
||
a(class="alert-link", href="/") { "Back to lobby" }
|
||
}
|
||
}
|
||
} else {
|
||
view! {
|
||
div(class="alert alert-warning text-center mx-3 mt-3 mb-0") {
|
||
"Connection lost — reconnecting…"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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 gave_up = create_signal(false);
|
||
let fatal = create_signal(false);
|
||
let attempts = create_signal(0u32);
|
||
let socket = create_signal(Option::<GameSocket>::None);
|
||
// Ticking clock driving the hand-end countdown display.
|
||
let now = create_signal(js_sys::Date::now());
|
||
let ticker = gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now()));
|
||
|
||
// Stops the ticker and any pending reconnect once the page unmounts.
|
||
let alive = Rc::new(Cell::new(true));
|
||
on_cleanup({
|
||
let alive = alive.clone();
|
||
move || {
|
||
alive.set(false);
|
||
drop(ticker);
|
||
}
|
||
});
|
||
|
||
let id = Rc::new(id);
|
||
let ctx = ConnCtx {
|
||
socket,
|
||
game,
|
||
over,
|
||
error,
|
||
closed,
|
||
gave_up,
|
||
fatal,
|
||
attempts,
|
||
capture_choice,
|
||
selected,
|
||
};
|
||
start_connect(id.clone(), ctx, alive.clone());
|
||
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
||
ctx.attempts.set(0);
|
||
ctx.gave_up.set(false);
|
||
ctx.closed.set(false);
|
||
start_connect(id.clone(), ctx, alive.clone());
|
||
});
|
||
|
||
// Clicking a card in the player's own hand.
|
||
let on_hand_card = move |code: String| {
|
||
if closed.get() {
|
||
// A dead socket would swallow the play silently.
|
||
return;
|
||
}
|
||
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))),
|
||
}
|
||
};
|
||
|
||
let reconnect_banner = reconnect.clone();
|
||
view! {
|
||
div(class="game-page") {
|
||
(toast(error))
|
||
(move || conn_banner(
|
||
closed.get(),
|
||
gave_up.get(),
|
||
fatal.get(),
|
||
game.get_clone().is_some(),
|
||
reconnect_banner.clone(),
|
||
))
|
||
(move || match game.get_clone() {
|
||
None => {
|
||
if fatal.get() {
|
||
view! {
|
||
div(class="status-card") {
|
||
div(class="card") {
|
||
div(class="card-body p-4") {
|
||
p { "Connection closed." }
|
||
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if gave_up.get() {
|
||
let reconnect = reconnect.clone();
|
||
view! {
|
||
div(class="status-card") {
|
||
div(class="card") {
|
||
div(class="card-body p-4") {
|
||
p { "Connection lost." }
|
||
button(
|
||
class="btn btn-warning me-2",
|
||
on:click=move |_| reconnect(),
|
||
) {
|
||
"Retry now"
|
||
}
|
||
a(class="btn btn-outline-light", href="/") {
|
||
"Back to lobby"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
let status = if closed.get() {
|
||
"Connection lost — reconnecting…"
|
||
} else {
|
||
"Connecting to the game…"
|
||
};
|
||
view! {
|
||
div(class="status-card") {
|
||
div(class="card") {
|
||
div(class="card-body p-4") {
|
||
div(class="spinner-border text-warning mb-3", role="status") {
|
||
span(class="visually-hidden") { "Loading…" }
|
||
}
|
||
p { (status) }
|
||
a(class="btn btn-outline-light", href="/") {
|
||
"Back to lobby"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
|
||
// The live table is scopone-specific; other games get a
|
||
// fallback until they ship their own play view.
|
||
Some(g) if g.game_type == "scopone_scientifico" => {
|
||
table_view(g, on_hand_card, selected, now)
|
||
}
|
||
Some(g) => unsupported_game_view(g),
|
||
})
|
||
(move || capture_choice.get_clone().map(|(card, options)| {
|
||
capture_picker(card, options, socket, capture_choice)
|
||
}))
|
||
(move || match game.get_clone() {
|
||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||
hand_summary_modal(g, socket, now)
|
||
}
|
||
_ => view! {},
|
||
})
|
||
(move || game_over_view(over.get_clone(), game.get_clone()))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Lobby view while waiting for the remaining players. The seat count
|
||
/// comes from the payload (`seats_open`), so games with other player
|
||
/// counts render correctly.
|
||
#[component(inline_props)]
|
||
fn LobbyView(game: GameView) -> View {
|
||
let seats_open = game
|
||
.seats_open
|
||
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
||
let join_code = game.join_code.clone();
|
||
let copied = create_signal(false);
|
||
// Held so the reset timer is cancelled if the waiting room is rebuilt
|
||
// (e.g. another player joins) while the "Copied!" feedback is showing:
|
||
// firing it afterwards would set a disposed signal and panic.
|
||
let reset_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
|
||
Rc::new(RefCell::new(None));
|
||
on_cleanup({
|
||
let reset_timer = reset_timer.clone();
|
||
move || {
|
||
reset_timer.borrow_mut().take();
|
||
}
|
||
});
|
||
|
||
let copy = {
|
||
let join_code = join_code.clone();
|
||
move |_| {
|
||
if let Some(win) = web_sys::window() {
|
||
let _ = win.navigator().clipboard().write_text(&join_code);
|
||
}
|
||
copied.set(true);
|
||
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(1500, move || {
|
||
copied.set(false);
|
||
}));
|
||
}
|
||
};
|
||
|
||
let players = game
|
||
.players
|
||
.iter()
|
||
.map(|p| {
|
||
let name = p.name.clone();
|
||
let seat = p.seat;
|
||
let team = p.team.clone();
|
||
view! {
|
||
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||
strong { (name) }
|
||
span {
|
||
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
|
||
span(class="badge text-bg-info") { "Team " (team) }
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let open_seats = (0..seats_open)
|
||
.map(|_| view! {
|
||
li(class="list-group-item seat-open") { "Waiting for a player…" }
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let seated = game.players.len();
|
||
let total = seated + seats_open;
|
||
let percent = if total == 0 {
|
||
0
|
||
} else {
|
||
seated * 100 / total
|
||
};
|
||
let percent_style = format!("width: {percent}%");
|
||
|
||
view! {
|
||
div(class="container page-shell py-4") {
|
||
div(class="card status-card") {
|
||
div(class="card-body p-4") {
|
||
h1(class="h4 mb-3") { "Waiting for players" }
|
||
p(class="text-secondary") { "Share this join code with your friends:" }
|
||
p(class="code-display") { (join_code) }
|
||
div(class="d-grid mb-4") {
|
||
button(class="btn btn-outline-light", on:click=copy) {
|
||
(move || if copied.get() { "Copied!" } else { "Copy code" })
|
||
}
|
||
}
|
||
ul(class="list-group list-group-flush text-start mb-3") {
|
||
(players)
|
||
(open_seats)
|
||
}
|
||
div(
|
||
class="progress mb-2",
|
||
role="progressbar",
|
||
aria-valuenow=percent.to_string(),
|
||
aria-valuemin="0",
|
||
aria-valuemax="100",
|
||
) {
|
||
div(class="progress-fill", style=percent_style) {}
|
||
}
|
||
p(class="text-secondary small") {
|
||
(seated) " of " (total) " players seated"
|
||
}
|
||
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Fallback for live games the web client has no play view for yet.
|
||
fn unsupported_game_view(game: GameView) -> View {
|
||
let game_type = game.game_type.clone();
|
||
view! {
|
||
div(class="status-card") {
|
||
div(class="card") {
|
||
div(class="card-body p-4") {
|
||
h1(class="h4 mb-3") { "Unsupported game" }
|
||
p(class="text-secondary") {
|
||
"Live play for “" (game_type) "” isn't supported in the web client yet."
|
||
}
|
||
a(class="btn btn-warning", 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>>,
|
||
now: Signal<f64>,
|
||
) -> 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 game.phase == "hand_end" {
|
||
"Hand 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 countdown = game.turn_deadline.as_ref().map(|deadline| {
|
||
// A dynamic closure so only the ticking number re-renders.
|
||
let deadline_ms = js_sys::Date::parse(deadline);
|
||
view! {
|
||
span(class="turn-timer") {
|
||
"Auto-play in "
|
||
(move || {
|
||
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
||
})
|
||
"s"
|
||
}
|
||
}
|
||
});
|
||
|
||
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 viewer = player_for_seat(&game, viewer_seat);
|
||
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
|
||
let my_scope = viewer.as_ref().map(|p| p.scope).unwrap_or(0);
|
||
let hand = viewer.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="text-secondary small text-center") {
|
||
"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) }
|
||
(countdown)
|
||
}
|
||
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="seat-stats own-stats") {
|
||
(my_captured) " captured · " (my_scope) " scope"
|
||
}
|
||
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="btn btn-outline-light", on:click=move |_| capture_choice.set(None)) {
|
||
"Cancel"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// End-of-match overlay (scopone's team score; other games render
|
||
/// nothing until they ship their own play view).
|
||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||
if let Some(g) = &game {
|
||
if g.game_type != "scopone_scientifico" {
|
||
return view! {};
|
||
}
|
||
}
|
||
let result = over.or_else(|| {
|
||
game.clone()
|
||
.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);
|
||
// Explain the final hand's scoring before the result.
|
||
let final_summary = game
|
||
.and_then(|g| g.last_hand)
|
||
.map(|s| {
|
||
let rows = summary_rows(s);
|
||
view! { (rows) }
|
||
});
|
||
view! {
|
||
div(class="overlay") {
|
||
div(class="picker summary-panel") {
|
||
h2 { "Match over" }
|
||
(final_summary)
|
||
p(class="final-score") { (line) }
|
||
div(class="gameover-actions") {
|
||
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
||
a(class="btn btn-outline-light", href="/history") { "My matches" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|