Add hand-end scoring summary screen with acknowledgement

After each hand of an unfinished match the game now pauses in a new
hand_end phase instead of dealing immediately:

- engine: hand_points gains an 'award' map (which team won each category),
  _end_hand stops at hand_end with a deadline, new acknowledge_hand deals
  the next hand once all four players have acked; plays are rejected while
  the summary is up
- state: acked seats, hand_end_deadline and hand_ack_timeout are persisted
  and exposed in the personalized view (also on the finished state, so the
  final hand is explained before the result)
- ws: new {"action": "ack"}; a per-hand timer force-deals the next hand
  after HAND_ACK_TIMEOUT_SECONDS (new env var, default 30s) so an away
  player cannot stall the match
- web: modal explaining each category in plain language with icons (card
  images for denara/settebello/primiera), team-coloured rows, running
  totals with progress bars, an 'Understood — next hand' button that turns
  into 'Waiting for …' plus an auto-continue countdown; the final screen
  shows the last hand's breakdown too

Verified in the browser against the compose stack: hand played to
completion, summary rendered (including a carte tie), ack from all four
players dealt the next hand live, and the auto-continue path fired when
nobody acked. 60 backend tests + mypy + cargo tests green.
This commit is contained in:
2026-09-16 21:46:01 +08:00
parent b6a3a95f52
commit d9cdba33a1
19 changed files with 873 additions and 13 deletions
+1
View File
@@ -1 +1,2 @@
pub mod card;
pub mod summary;
+250
View File
@@ -0,0 +1,250 @@
//! 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"),
"Denara",
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",
),
// 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="hint") {
"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 already_acked {
let waiting_text = format!("Waiting for {}", waiting.join(", "));
view! {
button(class="button primary", disabled=true) { (waiting_text) }
}
} else {
view! {
button(class="button primary", 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)
}
}
}
}