//! Live game page: table view over the websocket. use std::cell::Cell; 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>, card: String, capture: Option>) { 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::>(); 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::>(); 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>, 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); 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, 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="conn-banner") { "Connection closed by the server. " a(href="/") { "Back to lobby" } } } } else if gave_up { view! { div(class="conn-banner") { "Connection lost." button(class="button", on:click=move |_| reconnect()) { "Retry now" } a(href="/") { "Back to lobby" } } } } else { view! { div(class="conn-banner") { "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))), } }; 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="panel status-panel") { p { "Connection closed." } p { a(href="/") { "Back to lobby" } } } } } else if gave_up.get() { let reconnect = reconnect.clone(); view! { div(class="panel status-panel") { p { "Connection lost." } p { button(class="button primary", on:click=move |_| reconnect()) { "Retry now" } } p { a(href="/") { "Back to lobby" } } } } } else { let status = if closed.get() { "Connection lost — reconnecting…" } 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, now), }) (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" => hand_summary_modal(g, socket, now), _ => view! {}, }) (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::>(); 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 { 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. 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 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::>(); 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) } (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>, 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="button", on:click=move |_| capture_choice.set(None)) { "Cancel" } } } } } /// End-of-match overlay. fn game_over_view(over: Option<(Scores, Option)>, game: Option) -> 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="button primary", href="/") { "Back to lobby" } a(class="button", href="/history") { "My matches" } } } } } } } }