CI / Build and push docker image (push) Successful in 3m0s
Anonymous users and logged-in non-players can now open /game/<id> and
follow the match: they see the table, the score and each player's
captured cards and scope, but never a hand and never the join code.
Spectator actions are rejected with a forbidden error.
Backend (tavolo-platform / tavolo-scopone):
- /ws/games/{id} accepts anonymous and non-seated connections as
spectators; only unknown games are refused. The state payload carries
spectator: true and withholds join_code unless the viewer is seated.
- GET /api/games/{id} is public and returns the same personalized
(player) or read-only (spectator) view.
- GameEngine.view_for/game_over_view take Optional[str] (None = spectator)
and are documented to hide privileged information for non-seated
viewers.
Frontend (Sycamore):
- Parse the spectator flag and render a spectator table: card backs plus
captured/scope for every seat, no clickable hand, "Spectating" badge.
- A pre-start spectator sees a "game not started yet" waiting room with
the watch link and no join code.
- "Copy watch link" buttons in the waiting room and the game HUD.
- The hand-end summary carries no acknowledge button for spectators and
the game-over overlay hides "My matches".
Tests and docs updated.
297 lines
10 KiB
Rust
297 lines
10 KiB
Rust
//! The hand-end scoring summary screen.
|
|
//!
|
|
//! Shown when a hand finishes but the match continues: explains, in plain
|
|
//! 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).
|
|
use sycamore::prelude::*;
|
|
|
|
use crate::components::card::{card_back, card_img};
|
|
use crate::model::{GameView, HandSummary, Scores};
|
|
use crate::ws::GameSocket;
|
|
|
|
/// One explanatory row: icon, title, plain-language sentence, points chip.
|
|
fn award_row(
|
|
icon: View,
|
|
title: &'static str,
|
|
text: String,
|
|
winner: Option<String>,
|
|
points: &'static str,
|
|
) -> View {
|
|
let cls = match winner.as_deref() {
|
|
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}"),
|
|
None => "tie".to_string(),
|
|
};
|
|
view! {
|
|
div(class=cls) {
|
|
div(class="score-icon") { (icon) }
|
|
div(class="score-body") {
|
|
div(class="score-title") { (title) }
|
|
div(class="score-text") { (text) }
|
|
}
|
|
div(class="score-points") { (chip) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Winner's count first, then the loser's, for a natural sentence.
|
|
fn winner_first(a: i32, b: i32, winner: Option<&String>) -> (i32, i32) {
|
|
match winner.map(String::as_str) {
|
|
Some("B") => (b, a),
|
|
_ => (a, b),
|
|
}
|
|
}
|
|
|
|
/// The five scoring rows of a completed hand.
|
|
pub fn summary_rows(summary: HandSummary) -> View {
|
|
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(),
|
|
"+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(),
|
|
"+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(),
|
|
"+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(),
|
|
"+1",
|
|
),
|
|
// Napola (only when the rule is enabled for this match)
|
|
match summary.napola {
|
|
None => view! {},
|
|
Some(napola) => {
|
|
let n = match summary.award.napola.as_deref() {
|
|
Some("A") => napola.a,
|
|
Some("B") => napola.b,
|
|
_ => 0,
|
|
};
|
|
let text = match (&summary.award.napola, n) {
|
|
(Some(t), 10) => format!(
|
|
"Team {t} swept the whole denari suit — napola! Instant match win"
|
|
),
|
|
(Some(t), n) => format!(
|
|
"Team {t} captured {n} consecutive denari from the ace"
|
|
),
|
|
(None, _) => "No napola this hand".to_string(),
|
|
};
|
|
let chip = match &summary.award.napola {
|
|
Some(t) => format!("Team {t} +{n}"),
|
|
None => "tie".to_string(),
|
|
};
|
|
let cls = match summary.award.napola.as_deref() {
|
|
Some("A") => "score-row team-a",
|
|
Some("B") => "score-row team-b",
|
|
_ => "score-row tie",
|
|
};
|
|
view! {
|
|
div(class=cls) {
|
|
div(class="score-icon") { (card_img("01D".to_string(), "score-mini")) }
|
|
div(class="score-body") {
|
|
div(class="score-title") { "Napola" }
|
|
div(class="score-text") { (text) }
|
|
}
|
|
div(class="score-points") { (chip) }
|
|
}
|
|
}
|
|
}
|
|
},
|
|
// Scope
|
|
{
|
|
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")
|
|
};
|
|
let chip = format!("+{a} · +{b}");
|
|
view! {
|
|
div(class="score-row scope-row") {
|
|
div(class="score-icon") {
|
|
(card_back("score-mini"))
|
|
}
|
|
div(class="score-body") {
|
|
div(class="score-title") { "Scope" }
|
|
div(class="score-text") { (text) }
|
|
}
|
|
div(class="score-points") { (chip) }
|
|
}
|
|
}
|
|
},
|
|
];
|
|
view! {
|
|
div(class="score-rows") { (rows) }
|
|
}
|
|
}
|
|
|
|
/// Running totals with progress toward the target score, including the
|
|
/// points gained in the hand just played.
|
|
pub fn totals(game: &GameView) -> View {
|
|
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
|
let target = game.target_score.max(1);
|
|
let pct_a = (100 * scores.a / target).min(100);
|
|
let pct_b = (100 * scores.b / target).min(100);
|
|
let (gained_a, gained_b) = game
|
|
.last_hand
|
|
.as_ref()
|
|
.map(|s| (s.team_a_points, s.team_b_points))
|
|
.unwrap_or((0, 0));
|
|
view! {
|
|
div(class="totals") {
|
|
div(class="total-row team-a") {
|
|
span(class="total-label") { "Team A" }
|
|
div(class="progress") {
|
|
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
|
}
|
|
span(class="total-value") {
|
|
(scores.a) " / " (target)
|
|
span(class="gained") { "+" (gained_a) }
|
|
}
|
|
}
|
|
div(class="total-row team-b") {
|
|
span(class="total-label") { "Team B" }
|
|
div(class="progress") {
|
|
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
|
}
|
|
span(class="total-value") {
|
|
(scores.b) " / " (target)
|
|
span(class="gained") { "+" (gained_b) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The full hand-end modal: explanation + totals + acknowledgement button.
|
|
pub fn hand_summary_modal(
|
|
game: GameView,
|
|
socket: Signal<Option<GameSocket>>,
|
|
now: Signal<f64>,
|
|
) -> View {
|
|
let Some(summary) = game.last_hand.clone() else {
|
|
return view! {};
|
|
};
|
|
let viewer_seat = game
|
|
.players
|
|
.iter()
|
|
.find(|p| p.hand.is_some())
|
|
.map(|p| p.seat);
|
|
let acked = game.acknowledged.clone();
|
|
let already_acked = viewer_seat.is_some_and(|s| acked.contains(&s));
|
|
let waiting: Vec<String> = game
|
|
.players
|
|
.iter()
|
|
.filter(|p| !acked.contains(&p.seat))
|
|
.map(|p| p.name.clone())
|
|
.collect();
|
|
let countdown = game.hand_end_deadline.as_ref().map(|deadline| {
|
|
// A dynamic closure so only the ticking number re-renders, not the
|
|
// whole modal (which would swap DOM nodes under the user's cursor).
|
|
let deadline_ms = js_sys::Date::parse(deadline);
|
|
view! {
|
|
p(class="text-secondary small") {
|
|
"Auto-continuing in "
|
|
(move || {
|
|
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
|
})
|
|
"s"
|
|
}
|
|
}
|
|
});
|
|
|
|
let rows = summary_rows(summary.clone());
|
|
let totals_view = totals(&game);
|
|
let title = format!("Hand {} — results", summary.hand);
|
|
|
|
let action = if game.spectator {
|
|
// Spectators cannot acknowledge; they just watch the summary.
|
|
view! {
|
|
p(class="text-secondary small mb-0") {
|
|
"Spectating — the next hand is dealt when every player is ready."
|
|
}
|
|
}
|
|
} else if already_acked {
|
|
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
|
view! {
|
|
button(class="btn btn-warning", disabled=true) { (waiting_text) }
|
|
}
|
|
} else {
|
|
view! {
|
|
button(class="btn btn-warning", on:click=move |_| {
|
|
if let Some(s) = socket.get_clone() {
|
|
s.ack();
|
|
}
|
|
}) { "Understood, next hand" }
|
|
}
|
|
};
|
|
|
|
view! {
|
|
div(class="overlay") {
|
|
div(class="picker summary-panel") {
|
|
h2 { (title) }
|
|
(rows)
|
|
(totals_view)
|
|
div(class="summary-actions") { (action) }
|
|
(countdown)
|
|
}
|
|
}
|
|
}
|
|
}
|