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.
This commit is contained in:
2026-10-09 05:39:00 +00:00
parent 7883d0c458
commit d4d437c1a1
27 changed files with 3401 additions and 116 deletions
+130 -57
View File
@@ -4,27 +4,56 @@
//! language, how each scoring category played out and how the running
//! totals moved toward the target. Every player must acknowledge it before
//! the next hand is dealt (or the server-side timeout deals anyway).
//!
//! The sides are named from the game: the players' names for individual
//! games (classic two-player scopa), "Team A"/"Team B" for partnership
//! games (scopone scientifico, scopa a quattro).
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{GameView, HandSummary, Scores};
use crate::model::{is_individual_game, GameView, HandSummary, Scores};
use crate::ws::GameSocket;
/// Names of the two sides: the players' names for games without
/// partnerships, "Team A"/"Team B" otherwise.
fn side_names(game: &GameView) -> (String, String) {
if is_individual_game(&game.game_type) {
(
game.player_name_for_team("A"),
game.player_name_for_team("B"),
)
} else {
("Team A".to_string(), "Team B".to_string())
}
}
/// The display label for the side that won a category ("A"/"B").
fn side_label(game: &GameView, team: &str) -> String {
let (a, b) = side_names(game);
match team {
"A" => a,
"B" => b,
other => other.to_string(),
}
}
/// One explanatory row: icon, title, plain-language sentence, points chip.
/// `team` is the winning side's letter, or `None` for a tie.
fn award_row(
icon: View,
title: &'static str,
text: String,
winner: Option<String>,
team: Option<&str>,
name: &str,
points: &'static str,
) -> View {
let cls = match winner.as_deref() {
let cls = match team {
Some("A") => "score-row team-a",
Some("B") => "score-row team-b",
_ => "score-row tie",
};
let chip = match &winner {
Some(t) => format!("Team {t} {points}"),
let chip = match team {
Some(_) => format!("{name} {points}"),
None => "tie".to_string(),
};
view! {
@@ -48,69 +77,109 @@ fn winner_first(a: i32, b: i32, winner: Option<&String>) -> (i32, i32) {
}
/// The five scoring rows of a completed hand.
pub fn summary_rows(summary: HandSummary) -> View {
pub fn summary_rows(game: &GameView, summary: HandSummary) -> View {
// Carte
let carte = summary.award.carte.clone();
let carte_name = carte
.as_deref()
.map(|t| side_label(game, t))
.unwrap_or_default();
let carte_text = match &carte {
Some(t) => {
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
format!(
"{} captured more cards ({w} vs {l})",
side_label(game, t)
)
}
None => format!(
"Both sides captured {} cards — no point",
summary.cards.a
),
};
// Denara
let denara = summary.award.denara.clone();
let denara_name = denara
.as_deref()
.map(|t| side_label(game, t))
.unwrap_or_default();
let denara_text = match &denara {
Some(t) => {
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
format!(
"{} collected more denari cards ({w} vs {l})",
side_label(game, t)
)
}
None => format!(
"Both sides collected {} denari cards — no point",
summary.denara.a
),
};
// Settebello
let settebello = summary.award.settebello.clone();
let settebello_name = settebello
.as_deref()
.map(|t| side_label(game, t))
.unwrap_or_default();
let settebello_text = match &settebello {
Some(t) => format!(
"{} captured the 7 of denari — the Settebello",
side_label(game, t)
),
None => "Nobody captured the Settebello".to_string(),
};
// Primiera
let primiera = summary.award.primiera.clone();
let primiera_name = primiera
.as_deref()
.map(|t| side_label(game, t))
.unwrap_or_default();
let primiera_text = match &primiera {
Some(t) => {
let (w, l) = winner_first(summary.primiera.a, summary.primiera.b, Some(t));
format!(
"{} holds the strongest primiera ({w} vs {l})",
side_label(game, t)
)
}
None => format!(
"Both primiere are worth {} — no point",
summary.primiera.a
),
};
let rows: Vec<View> = vec![
// Carte
award_row(
card_back("score-mini"),
"Carte",
match &summary.award.carte {
Some(t) => {
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
format!("Team {t} captured more cards ({w} vs {l})")
}
None => format!(
"Both teams captured {} cards — no point",
summary.cards.a
),
},
summary.award.carte.clone(),
carte_text,
carte.as_deref(),
&carte_name,
"+1",
),
// Denara
award_row(
card_img("02D".to_string(), "score-mini"),
"Denari",
match &summary.award.denara {
Some(t) => {
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
format!("Team {t} collected more denari cards ({w} vs {l})")
}
None => format!(
"Both teams collected {} denari cards — no point",
summary.denara.a
),
},
summary.award.denara.clone(),
"Denara",
denara_text,
denara.as_deref(),
&denara_name,
"+1",
),
// Settebello
award_row(
card_img("07D".to_string(), "score-mini"),
"Settebello",
match &summary.award.settebello {
Some(t) => format!("Team {t} captured the 7 of denari — the Settebello"),
None => "Nobody captured the Settebello".to_string(),
},
summary.award.settebello.clone(),
settebello_text,
settebello.as_deref(),
&settebello_name,
"+1",
),
// Primiera
award_row(
card_img("10D".to_string(), "score-mini"),
"Primiera",
match &summary.award.primiera {
Some(t) => {
let (w, l) =
winner_first(summary.primiera.a, summary.primiera.b, Some(t));
format!("Team {t} holds the strongest primiera ({w} vs {l})")
}
None => format!(
"Both primiere are worth {} — no point",
summary.primiera.a
),
},
summary.award.primiera.clone(),
primiera_text,
primiera.as_deref(),
&primiera_name,
"+1",
),
// Napola (only when the rule is enabled for this match)
@@ -124,15 +193,17 @@ pub fn summary_rows(summary: HandSummary) -> View {
};
let text = match (&summary.award.napola, n) {
(Some(t), 10) => format!(
"Team {t} swept the whole denari suit — napola! Instant match win"
"{} swept the whole denari suit — napola! Instant match win",
side_label(game, t)
),
(Some(t), n) => format!(
"Team {t} captured {n} consecutive denari from the ace"
"{} captured {n} consecutive denari from the ace",
side_label(game, t)
),
(None, _) => "No napola this hand".to_string(),
};
let chip = match &summary.award.napola {
Some(t) => format!("Team {t} +{n}"),
Some(t) => format!("{} +{n}", side_label(game, t)),
None => "tie".to_string(),
};
let cls = match summary.award.napola.as_deref() {
@@ -154,12 +225,13 @@ pub fn summary_rows(summary: HandSummary) -> View {
},
// Scope
{
let (name_a, name_b) = side_names(game);
let a = summary.scope.a;
let b = summary.scope.b;
let text = if a == 0 && b == 0 {
"No scope this hand".to_string()
} else {
format!("Team A made {a} scope · Team B made {b} scope")
format!("{name_a} made {a} scope · {name_b} made {b} scope")
};
let chip = format!("+{a} · +{b}");
view! {
@@ -193,10 +265,11 @@ pub fn totals(game: &GameView) -> View {
.as_ref()
.map(|s| (s.team_a_points, s.team_b_points))
.unwrap_or((0, 0));
let (name_a, name_b) = side_names(game);
view! {
div(class="totals") {
div(class="total-row team-a") {
span(class="total-label") { "Team A" }
span(class="total-label") { (name_a) }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
@@ -206,7 +279,7 @@ pub fn totals(game: &GameView) -> View {
}
}
div(class="total-row team-b") {
span(class="total-label") { "Team B" }
span(class="total-label") { (name_b) }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
@@ -256,7 +329,7 @@ pub fn hand_summary_modal(
}
});
let rows = summary_rows(summary.clone());
let rows = summary_rows(&game, summary.clone());
let totals_view = totals(&game);
let title = format!("Hand {} — results", summary.hand);
+128 -5
View File
@@ -193,6 +193,9 @@ pub struct GameView {
pub winner: Option<String>,
#[serde(default)]
pub table: Vec<String>,
/// Scopa: face-down cards left in the deck for the current hand.
#[serde(default)]
pub deck_count: usize,
#[serde(default)]
pub players: Vec<PlayerView>,
#[serde(default)]
@@ -553,6 +556,22 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "scopa".to_string(),
name: "Scopa".to_string(),
description: String::new(),
min_players: 2,
max_players: 2,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "scopa_a_quattro".to_string(),
name: "Scopa a quattro".to_string(),
description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola".to_string(),
name: "Briscola".to_string(),
@@ -863,6 +882,12 @@ pub fn is_briscola(game_type: &str) -> bool {
game_type == "briscola" || game_type == "briscola_a_quattro"
}
/// Whether a game type is one of the scopa variants (classic two-player
/// scopa and four-player partnership scopa a quattro).
pub fn is_scopa(game_type: &str) -> bool {
game_type == "scopa" || game_type == "scopa_a_quattro"
}
/// Whether a game type is five-player briscola chiamata, which has its
/// own table view (auction, secret partner, five seats).
pub fn is_chiamata(game_type: &str) -> bool {
@@ -888,10 +913,10 @@ pub fn rank_name(rank: i32) -> String {
}
/// Whether a game is played by two individuals rather than partnerships
/// (classic two-player briscola): team labels and team messages are
/// replaced by the players' names.
/// (classic two-player briscola, classic two-player scopa): team labels
/// and team messages are replaced by the players' names.
pub fn is_individual_game(game_type: &str) -> bool {
game_type == "briscola"
game_type == "briscola" || game_type == "scopa"
}
/// Human-readable suit name for a one-letter code (`D` -> "denari").
@@ -1408,6 +1433,14 @@ mod tests {
assert_eq!("?", suit_name("?"));
}
#[test]
fn is_scopa_matches_both_variants() {
assert!(is_scopa("scopa"));
assert!(is_scopa("scopa_a_quattro"));
assert!(!is_scopa("scopone_scientifico"));
assert!(!is_scopa("briscola"));
}
#[test]
fn rank_names_cover_the_ladder() {
assert_eq!("Asso", rank_name(1));
@@ -1419,9 +1452,11 @@ mod tests {
}
#[test]
fn is_individual_game_only_matches_two_player_briscola() {
fn is_individual_game_matches_two_player_games() {
assert!(is_individual_game("briscola"));
assert!(is_individual_game("scopa"));
assert!(!is_individual_game("briscola_a_quattro"));
assert!(!is_individual_game("scopa_a_quattro"));
assert!(!is_individual_game("scopone_scientifico"));
}
@@ -1485,6 +1520,8 @@ mod tests {
assert_eq!(
vec![
"scopone_scientifico",
"scopa",
"scopa_a_quattro",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
@@ -1493,7 +1530,7 @@ mod tests {
);
// The lobby's first game (the creation default) must exist.
assert_eq!("scopone_scientifico", types[0].id);
assert_eq!(5, types[3].max_players);
assert_eq!(5, types[5].max_players);
}
#[test]
@@ -1724,4 +1761,90 @@ mod tests {
assert_eq!("Difensori", b);
assert_eq!(Some("Chiamante".to_string()), g.winner);
}
#[test]
fn game_view_parses_scopa_payload() {
let g: GameView = serde_json::from_value(json!({
"id": "s1",
"join_code": "ABC123",
"game_type": "scopa",
"phase": "playing",
"target_score": 11,
"asso_pigliatutto": true,
"player_count": 2,
"hand_number": 1,
"dealer": 0,
"turn": 1,
"deck_count": 24,
"scores": {"A": 0, "B": 0},
"table": ["05C", "02D", "03S", "10B"],
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
"cards_left": 3, "captured_count": 0, "scope": 0},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
"cards_left": 3, "captured_count": 0, "scope": 0,
"hand": ["01D", "06C", "09S"]},
],
"last_hand": null,
"last_move": {"seat": 0, "name": "Alice", "card": "04D",
"captured": ["01C", "03C"], "scopa": true,
"pigliatutto": false},
"acknowledged": [],
"hand_end_deadline": null,
"turn_deadline": "2026-01-01T00:00:30+00:00",
"your_turn": true,
"legal_moves": {"01D": [["01C"]]},
}))
.unwrap();
assert!(is_scopa(&g.game_type));
assert!(is_individual_game(&g.game_type));
assert!(g.asso_pigliatutto);
assert_eq!(2, g.player_count);
assert_eq!(24, g.deck_count);
assert_eq!(4, g.table.len());
assert_eq!(Some(vec!["01D".to_string(), "06C".to_string(), "09S".to_string()]),
g.players[1].hand.clone());
let mv = g.last_move.unwrap();
assert!(mv.scopa);
assert_eq!(2, mv.captured.len());
}
#[test]
fn game_view_parses_scopa_a_quattro_hand_summary() {
let g: GameView = serde_json::from_value(json!({
"id": "s2",
"game_type": "scopa_a_quattro",
"phase": "hand_end",
"player_count": 4,
"scores": {"A": 3, "B": 0},
"players": [
{"sub": "a", "name": "Alice", "seat": 0, "team": "A"},
{"sub": "b", "name": "Bob", "seat": 1, "team": "B"},
{"sub": "c", "name": "Carol", "seat": 2, "team": "A"},
{"sub": "d", "name": "Dave", "seat": 3, "team": "B"},
],
"last_hand": {
"cards": {"A": 21, "B": 19},
"denara": {"A": 6, "B": 4},
"settebello": {"A": true, "B": false},
"primiera": {"A": 70, "B": 60},
"scope": {"A": 1, "B": 0},
"award": {
"carte": "A", "denara": "A", "settebello": "A",
"primiera": "A",
},
"hand": 1,
"team_a_points": 3,
"team_b_points": 0,
},
}))
.unwrap();
assert!(is_scopa(&g.game_type));
assert!(!is_individual_game(&g.game_type));
let hand = g.last_hand.unwrap();
assert_eq!(21, hand.cards.a);
assert_eq!(Some("A".to_string()), hand.award.carte);
assert!(hand.napola.is_none());
assert_eq!(3, hand.team_a_points);
}
}
+21 -10
View File
@@ -12,11 +12,12 @@ 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, GameView, MoveView,
PlayerView, Scores, ServerMessage,
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>>) {
@@ -65,7 +66,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
}
/// The last-move announcement strip.
fn move_banner(mv: MoveView) -> View {
pub(crate) fn move_banner(mv: MoveView) -> View {
let name = mv.name.clone();
let action = if mv.captured.is_empty() {
"played"
@@ -428,6 +429,10 @@ pub fn GamePage(id: String) -> View {
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" => {
@@ -450,7 +455,11 @@ pub fn GamePage(id: String) -> View {
_ => view! {},
})
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
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) => {
@@ -779,8 +788,9 @@ fn capture_picker(
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 {
if g.game_type != "scopone_scientifico" && !briscola && !chiamata && !scopa {
return view! {};
}
}
@@ -793,7 +803,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
None => view! {},
Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string());
// Two-player briscola has no teams: name the winner instead.
// 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) => {
@@ -805,8 +816,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
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) or deal's (briscola,
// briscola chiamata) scoring before the result.
// 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() {
@@ -816,8 +827,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
None => view! {},
}
} else {
match game.as_ref().and_then(|g| g.last_hand.clone()) {
Some(hand) => summary_rows(hand),
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! {},
}
};
+1 -1
View File
@@ -514,7 +514,7 @@ pub fn LobbyPage() -> View {
div(class="lobby-hero text-center") {
h1(class="display-5 fw-bold") { "tavolo" }
p(class="lead text-secondary mb-0") {
"Play scopone scientifico and briscola with friends."
"Play scopone, scopa and briscola with friends."
}
}
// Ongoing games: a "Continue" link back into a
+1
View File
@@ -5,3 +5,4 @@ pub mod history;
pub mod leaderboard;
pub mod lobby;
pub mod login;
pub mod scopa;
+237
View File
@@ -0,0 +1,237 @@
//! Scopa table view: classic two-player and four-player partnership.
//!
//! The two variants share this view: four seats render in the usual cross
//! (partners opposite), two seats stack opponent over player. The center
//! holds the cards on the table, the face-down deck and the last-move
//! announcement.
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::model::{
hand_sort_key, is_individual_game, GameView, PlayerView, Scores,
};
use crate::pages::game::{move_banner, watch_link};
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
/// 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) = player_for_seat(&game, seat) 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_badge = (!is_individual_game(&game.game_type))
.then(|| player.team.clone())
.flatten()
.filter(|team| !team.is_empty())
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
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)
(team_badge)
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
(captured) " captured · " (scope) " scope"
}
}
}
}
/// The face-down deck with the cards left in the current hand.
fn deck_view(game: &GameView) -> View {
let backs = (0..game.deck_count.min(3))
.map(|_| card_back("stock-card"))
.collect::<Vec<_>>();
let label = format!("{} in deck", game.deck_count);
view! {
div(class="stock") {
div(class="stock-pile") { (backs) }
span(class="stock-count") { (label) }
}
}
}
/// The main scopa table.
pub fn scopa_table(
game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static,
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 player_count = if game.player_count > 0 {
game.player_count
} else {
game.players.len().max(1)
};
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 target = game.target_score;
let hand_number = game.hand_number;
// Two-player scopa has no partnerships: show the players' names.
let score_line = if is_individual_game(&game.game_type) {
format!(
"{} {} — {} {} (target {})",
game.player_name_for_team("A"),
scores.a,
scores.b,
game.player_name_for_team("B"),
target
)
} else {
format!("Team A {} — {} Team B (target {})", scores.a, scores.b, target)
};
let (grid_cls, top, left, right) = if player_count == 2 {
(
"table-grid duo",
seat_panel(game.clone(), (viewer_seat + 1) % 2, "top"),
view! {},
view! {},
)
} else {
(
"table-grid",
seat_panel(game.clone(), (viewer_seat + 2) % 4, "top"),
seat_panel(game.clone(), (viewer_seat + 1) % 4, "left"),
seat_panel(game.clone(), (viewer_seat + 3) % 4, "right"),
)
};
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 hand_cards = hand
.into_iter()
.map(|code| {
let card_view = card_img(code.clone(), "hand-card");
view! {
button(class="hand-slot", 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 rule_badge = game.asso_pigliatutto.then(|| view! {
span(class="pigliatutto-badge") { "Asso pigliatutto" }
});
let watch_link = watch_link(&game.id);
// Precomputed so the final view's closures only capture owned views.
let banner = game.last_move.clone().map(move_banner);
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 deck = deck_view(&game);
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Hand " (hand_number) }
(rule_badge)
(spectator_badge)
span(class="hud-scores") { (score_line) }
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
div(class=grid_cls) {
(top)
(left)
div(class="center") {
(banner)
div(class="table-cards") {
(table_cards)
(empty_table)
}
(deck)
}
(right)
(bottom)
}
}
}
}