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
+22 -3
View File
@@ -2,6 +2,7 @@
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::ws::{self, GameSocket};
@@ -77,6 +78,9 @@ pub fn GamePage(id: String) -> View {
let over = create_signal(Option::<(Scores, Option<String>)>::None);
let closed = create_signal(false);
let socket = create_signal(Option::<GameSocket>::None);
// Ticking clock driving the hand-end countdown display.
let now = create_signal(js_sys::Date::now());
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
{
let on_message = move |msg: ServerMessage| match msg {
@@ -140,6 +144,10 @@ pub fn GamePage(id: String) -> View {
(move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice)
}))
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now),
_ => view! {},
})
(move || game_over_view(over.get_clone(), game.get_clone()))
}
}
@@ -199,6 +207,8 @@ fn table_view(
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 {
@@ -321,7 +331,8 @@ fn capture_picker(
/// End-of-match overlay.
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
let result = over.or_else(|| {
game.filter(|g| g.phase == "finished")
game.clone()
.filter(|g| g.phase == "finished")
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
});
match result {
@@ -329,11 +340,19 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string());
let line = format!("Team {winner} wins {} {}", scores.a, scores.b);
// Explain the final hand's scoring before the result.
let final_summary = game
.and_then(|g| g.last_hand)
.map(|s| {
let rows = summary_rows(s);
view! { (rows) }
});
view! {
div(class="overlay") {
div(class="picker") {
div(class="picker summary-panel") {
h2 { "Match over" }
p { (line) }
(final_summary)
p(class="final-score") { (line) }
div(class="gameover-actions") {
a(class="button primary", href="/") { "Back to lobby" }
a(class="button", href="/history") { "My matches" }