Files
tavolo/web/src/pages/game.rs
T
woggioni 68200bbe62 Add scopa: classic two-player and scopa a quattro
New tavolo-scopa package with a pure rules engine and the platform
adapters for two-player scopa and four-player fixed-partnership scopa a
quattro: three cards each and four on the table, capture by rank or sum,
sweep for scope and score carte/denara/settebello/primiera.

The optional asso pigliatutto creation option turns either variant into
scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing
scopone's rule semantics.

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
2026-10-09 14:45:20 +08:00

854 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<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, 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);
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<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))),
}
};
// 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::<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>,
) -> 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 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<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)
}
}
}
}
}
}
}