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
+58
View File
@@ -47,6 +47,54 @@ pub struct MoveView {
pub scopa: bool,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct TeamCounts {
#[serde(rename = "A")]
pub a: i32,
#[serde(rename = "B")]
pub b: i32,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[allow(dead_code)]
pub struct TeamBools {
#[serde(rename = "A")]
pub a: bool,
#[serde(rename = "B")]
pub b: bool,
}
/// Which team (if any) won each scoring category of a hand.
#[derive(Debug, Clone, Deserialize)]
pub struct Award {
#[serde(default)]
pub carte: Option<String>,
#[serde(default)]
pub denara: Option<String>,
#[serde(default)]
pub settebello: Option<String>,
#[serde(default)]
pub primiera: Option<String>,
}
/// The scoring breakdown of one completed hand.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct HandSummary {
pub cards: TeamCounts,
pub denara: TeamCounts,
pub settebello: TeamBools,
pub primiera: TeamCounts,
pub scope: TeamCounts,
pub award: Award,
#[serde(default)]
pub hand: i32,
#[serde(default)]
pub team_a_points: i32,
#[serde(default)]
pub team_b_points: i32,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct GameView {
@@ -74,6 +122,16 @@ pub struct GameView {
pub seats_open: Option<usize>,
#[serde(default)]
pub last_move: Option<MoveView>,
/// Scoring breakdown of the most recent hand (present once a hand has
/// been completed).
#[serde(default)]
pub last_hand: Option<HandSummary>,
/// Seats that acknowledged the hand-end summary.
#[serde(default)]
pub acknowledged: Vec<usize>,
/// ISO-8601 instant at which the next hand is dealt automatically.
#[serde(default)]
pub hand_end_deadline: Option<String>,
#[serde(default)]
pub your_turn: Option<bool>,
/// Legal captures per hand card; present only for the player on turn.