//! Live game page: table view over the websocket. use std::cell::Cell; use std::rc::Rc; use sycamore::prelude::*; use wasm_bindgen_futures::spawn_local; use crate::api; use crate::components::briscola::{deal_rows, deal_summary_modal}; use crate::components::card::{card_back, card_img}; use crate::components::copy::CopyButton; use crate::components::summary::{hand_summary_modal, summary_rows}; use crate::components::toast::toast; use crate::model::{ card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, is_scopa, GameView, MoveView, PlayerView, Scores, ServerMessage, }; use crate::pages::briscola::briscola_table; use crate::pages::chiamata::{call_picker, chiamata_table}; use crate::pages::scopa::scopa_table; use crate::ws::{self, GameSocket}; fn send_play(socket: Signal>, card: String, capture: Option>) { if let Some(s) = socket.get_clone() { s.play(&card, capture); } } /// The absolute, shareable URL of this game's page, for the clipboard. pub(crate) fn watch_link(id: &str) -> String { let origin = web_sys::window() .and_then(|w| w.location().origin().ok()) .unwrap_or_default(); format!("{origin}/game/{id}") } /// 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().unwrap_or_default(); let captured = player.captured_count; let scope = player.scope; let backs = (0..player.cards_left) .map(|_| card_back("mini")) .collect::>(); 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. pub(crate) 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::>(); let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } }); let pigliatutto = mv .pigliatutto .then(|| view! { span(class="pigliatutto-badge") { "Asso pigliatutto!" } }); view! { div(class="move-banner") { strong { (name) } span { " " (action) " " } (played) (captured) (scopa) (pigliatutto) } } } /// Signals shared by the websocket connection and its reconnect attempts. #[derive(Clone, Copy)] struct ConnCtx { socket: Signal>, game: Signal>, over: Signal)>>, error: Signal>, closed: Signal, /// Reconnect attempts exhausted; only a manual retry resumes. gave_up: Signal, /// The server closed the connection deliberately (auth or game gone); /// retrying is pointless. fatal: Signal, attempts: Signal, capture_choice: Signal>)>>, selected: Signal>, } /// 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, ctx: ConnCtx, alive: Rc>) { 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| { if !alive.get() { return; } ctx.closed.set(true); if ctx.game.get_clone().is_none() && ctx.attempts.get() == 0 { // The connection ended before any snapshot arrived. A // refused handshake looks exactly like a network drop to // the browser (close code 1006, no HTTP status), so ask // the API whether the server deliberately rejected us — // in which case retrying is pointless — or the loss was // transient. Only on the first failure: later attempts // are governed by the retry backoff. let id = id.clone(); let alive = alive.clone(); spawn_local(async move { match api::probe_game(&id).await { api::GameEntry::Unavailable => { ctx.fatal.set(true); ctx.error .set(Some("This game is no longer available.".to_string())); } api::GameEntry::Transient => schedule_retry(id, ctx, alive), } }); } else { 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, ctx: ConnCtx, alive: Rc>) { 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, ) -> 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::::None); let error = create_signal(Option::::None); let capture_choice = create_signal(Option::<(String, Vec>)>::None); let selected = create_signal(Option::::None); let over = create_signal(Option::<(Scores, Option)>::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::::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 = 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))), } }; // Briscola chiamata: auction and call actions. let on_bid_rank = move |rank: i32| { if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) { return; } if let Some(s) = socket.get_clone() { s.bid_rank(rank); } }; let on_bid_points = move |points: i32| { if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) { return; } if let Some(s) = socket.get_clone() { s.bid_points(points); } }; let on_pass = move || { if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) { return; } if let Some(s) = socket.get_clone() { s.pass(); } }; 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) }, // Briscola chiamata has its own five-seat table with the // calling auction and the secret-partner reveal. Some(g) if is_chiamata(&g.game_type) => { chiamata_table(g, on_hand_card, on_bid_rank, on_bid_points, on_pass, now) } // Briscola has its own table view for both variants. Some(g) if is_briscola(&g.game_type) => { briscola_table(g, on_hand_card, now) } // Scopa has its own table view for both variants. Some(g) if is_scopa(&g.game_type) => { scopa_table(g, on_hand_card, now) } // 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) })) // Briscola chiamata: the auction winner names the called card. (move || match game.get_clone() { Some(g) if is_chiamata(&g.game_type) && g.phase == "calling" && g.your_turn == Some(true) => { call_picker(g, socket) } _ => view! {}, }) (move || match game.get_clone() { Some(g) if g.phase == "hand_end" && (g.game_type == "scopone_scientifico" || is_scopa(&g.game_type)) => { hand_summary_modal(g, socket, now) } Some(g) if g.phase == "hand_end" && is_briscola(&g.game_type) => { deal_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. Spectators (no seat, no join code) get a /// "not started yet" note and the watch link instead. #[component(inline_props)] fn LobbyView(game: GameView) -> View { let seats_open = game .seats_open .unwrap_or_else(|| 4usize.saturating_sub(game.players.len())); let spectator = game.spectator; let join_code = game.join_code.clone(); let watch_link = watch_link(&game.id); let show_teams = !is_individual_game(&game.game_type); let players = game .players .iter() .map(|p| { let name = p.name.clone(); let seat = p.seat; let team_badge = show_teams .then(|| p.team.clone()) .flatten() .filter(|team| !team.is_empty()) .map(|team| view! { span(class="badge text-bg-info") { "Team " (team) } }); 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) } (team_badge) } } } }) .collect::>(); let open_seats = (0..seats_open) .map(|_| view! { li(class="list-group-item seat-open") { "Waiting for a player…" } }) .collect::>(); 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}%"); // The server withholds the join code from spectators; they get a // "not started yet" note and can share the watch link instead. let share = if spectator { view! { p(class="text-secondary") { "This game hasn't started yet — waiting for the players to take their seats." } div(class="d-grid mb-4") { CopyButton(text=watch_link, label="Copy watch link".to_string()) } } } else { // Separate clones: each interpolation/prop captures its own copy. let display_code = join_code.clone(); let copy_code = join_code.clone(); view! { p(class="text-secondary") { "Share this join code with your friends:" } p(class="code-display") { (display_code) } div(class="d-grid gap-2 mb-4") { CopyButton(text=copy_code, label="Copy code".to_string()) CopyButton(text=watch_link, label="Copy watch link".to_string()) } } }; view! { div(class="container page-shell py-4") { div(class="card status-card") { div(class="card-body p-4") { h1(class="h4 mb-3") { (if spectator { "Game not started yet" } else { "Waiting for players" }) } (share) 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 { 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>, now: Signal, ) -> View { // Own seat: the only player entry carrying a hand. Spectators have no // hand, so they watch from seat 0's perspective. 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::>(); 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 rule_badge = game.asso_pigliatutto.then(|| view! { span(class="pigliatutto-badge") { "Asso pigliatutto" } }); 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 mut hand = viewer.and_then(|p| p.hand).unwrap_or_default(); hand.sort_by_key(|c| hand_sort_key(c)); 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::>(); 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." } }); // Spectators have no hand: render the pivot seat as a regular panel // so its captured count and scope stay visible where the hand would be. let bottom = if game.spectator { seat_panel(game.clone(), viewer_seat, "bottom") } else { view! { div(class="seat-bottom") { div(class="seat-stats own-stats") { (my_captured) " captured · " (my_scope) " scope" } div(class="hand") { (hand_cards) } (hint) } } }; let spectator_badge = game.spectator.then(|| view! { span(class="spectator-badge") { "Spectating" } }); let watch_link = watch_link(&game.id); view! { div(class="table-wrap") { div(class="hud") { a(href="/") { "← Lobby" } span { "Hand " (hand_number) } (rule_badge) (spectator_badge) span(class="hud-scores") { "Team A " (scores.a) " — " (scores.b) " Team B (target " (target_score) ")" } span(class=turn_cls) { (turn_note) } (countdown) CopyButton(text=watch_link, label="Copy watch link".to_string()) } div(class="table-grid") { (top) (left) div(class="center") { (banner) div(class="table-cards") { (table_cards) (empty_table) } } (right) (bottom) } } } } /// Popup listing the legal captures for a selected card. fn capture_picker( card: String, options: Vec>, socket: Signal>, capture_choice: Signal>)>>, ) -> 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::>(); view! { button(class="capture-option", on:click=move |_| { send_play(socket, played_card.clone(), Some(capture.clone())); capture_choice.set(None); }) { (cards) } } }) .collect::>(); 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: the final hand/deal breakdown and the winner. fn game_over_view(over: Option<(Scores, Option)>, game: Option) -> View { let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type)); let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type)); let scopa = game.as_ref().is_some_and(|g| is_scopa(&g.game_type)); if let Some(g) = &game { if g.game_type != "scopone_scientifico" && !briscola && !chiamata && !scopa { 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()); // Games without partnerships (two-player briscola and scopa) // name the winner instead. // Briscola chiamata names the caller's side (or the defenders). let winner_label = match game.as_ref() { Some(g) if is_individual_game(&g.game_type) => { g.player_name_for_team(&winner) } Some(g) if is_chiamata(&g.game_type) => winner.clone(), _ => format!("Team {winner}"), }; let line = format!("{winner_label} wins {} – {}", scores.a, scores.b); // Spectators have no match history to look at. let spectator = game.as_ref().is_some_and(|g| g.spectator); // Explain the final hand's (scopone, scopa) or deal's // (briscola, briscola chiamata) scoring before the result. let final_summary: View = if briscola || chiamata { match game.as_ref() { Some(g) => match g.last_deal.clone() { Some(deal) => deal_rows(g, &deal), None => view! {}, }, None => view! {}, } } else { match game.as_ref().and_then(|g| g.last_hand.clone().map(|h| (g.clone(), h))) { Some((g, hand)) => summary_rows(&g, hand), None => view! {}, } }; let my_matches = (!spectator).then(|| view! { a(class="btn btn-outline-light", href="/history") { "My matches" } }); 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" } (my_matches) } } } } } } }