Add an opt-in bell to the game HUD: the first click asks for the browser's notification permission, and the choice is remembered per device. A play by any other seat then raises a desktop notification with the player's name, the card and the next player, while the page is in the background and the viewer is actually seated in the game. Detect the newest play across the game families: scopone and scopa report it in last_move, while briscola and briscola chiamata only expose the trick, which mid-trick or once completed ends with the card just played. The first snapshot after every (re)connection is a resync baseline, so reconnects and the initial load never replay old moves; so are the viewer's own plays and the server's auto-plays. Deliver through the service worker registration (installed PWAs on Android reject page-context notifications), falling back to a page notification when no worker is active, under a per-game tag that replaces the previous move. sw.js now focuses the game window when a notification is clicked.
906 lines
33 KiB
Rust
906 lines
33 KiB
Rust
//! 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::notify::{notify_toggle, NotifyCtx};
|
||
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::notify::{self, Permission};
|
||
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<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||
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::<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.
|
||
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::<Vec<_>>();
|
||
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)]
|
||
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>>,
|
||
/// Baseline for the move notifications; reset on every (re)connect.
|
||
baseline: notify::Baseline,
|
||
/// Whether the player opted into move notifications.
|
||
notify_enabled: Signal<bool>,
|
||
/// The browser's notification permission, as last read.
|
||
notify_permission: Signal<Permission>,
|
||
}
|
||
|
||
/// 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>>) {
|
||
// A fresh connection resyncs from scratch: the first snapshot it
|
||
// delivers is the notification baseline, not a play to announce.
|
||
ctx.baseline.reset();
|
||
let on_message = {
|
||
let ctx = ctx.clone();
|
||
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 } => {
|
||
// Observe every snapshot, even with the preference
|
||
// off: enabling it mid-game must not replay old moves.
|
||
let play = ctx.baseline.observe(&g);
|
||
if let Some(play) = play {
|
||
// Move notifications are for when the player
|
||
// cannot watch the table themselves.
|
||
if ctx.notify_enabled.get()
|
||
&& ctx.notify_permission.get() == Permission::Granted
|
||
&& notify::page_in_background()
|
||
{
|
||
notify::notify_move(&g, &play);
|
||
}
|
||
}
|
||
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 ctx = ctx.clone();
|
||
let alive = alive.clone();
|
||
move |_code: Option<u16>| {
|
||
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 ctx = ctx.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.clone(), 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);
|
||
}
|
||
});
|
||
|
||
// Move notifications: opt-in and persisted per device. The signals
|
||
// are page-scoped (not per table render) so a permission prompt that
|
||
// resolves after the table rebuilt still lands somewhere safe.
|
||
let notify_enabled = create_signal(notify::enabled());
|
||
let notify_permission = create_signal(notify::permission());
|
||
let notify_ctx = NotifyCtx {
|
||
enabled: notify_enabled,
|
||
permission: notify_permission,
|
||
alive: alive.clone(),
|
||
};
|
||
|
||
let id = Rc::new(id);
|
||
let ctx = ConnCtx {
|
||
socket,
|
||
game,
|
||
over,
|
||
error,
|
||
closed,
|
||
gave_up,
|
||
fatal,
|
||
attempts,
|
||
capture_choice,
|
||
selected,
|
||
baseline: notify::Baseline::new(),
|
||
notify_enabled,
|
||
notify_permission,
|
||
};
|
||
start_connect(id.clone(), ctx.clone(), 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.clone(), 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,
|
||
notify_ctx.clone(),
|
||
)
|
||
}
|
||
// Briscola has its own table view for both variants.
|
||
Some(g) if is_briscola(&g.game_type) => {
|
||
briscola_table(g, on_hand_card, now, notify_ctx.clone())
|
||
}
|
||
// Scopa has its own table view for both variants.
|
||
Some(g) if is_scopa(&g.game_type) => {
|
||
scopa_table(g, on_hand_card, now, notify_ctx.clone())
|
||
}
|
||
// 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, notify_ctx.clone())
|
||
}
|
||
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::<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}%");
|
||
|
||
// 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<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>,
|
||
notify: NotifyCtx,
|
||
) -> 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::<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 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::<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."
|
||
}
|
||
});
|
||
|
||
// 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 bell = (!game.spectator).then(|| notify_toggle(notify));
|
||
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())
|
||
(bell)
|
||
}
|
||
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<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: the final hand/deal breakdown and the winner.
|
||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|