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:
Generated
+12
@@ -174,6 +174,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-timers"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-utils"
|
||||
version = "0.2.0"
|
||||
@@ -366,6 +376,8 @@ dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"futures",
|
||||
"gloo-net",
|
||||
"gloo-timers",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sycamore",
|
||||
|
||||
@@ -14,6 +14,8 @@ wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
futures = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
|
||||
js-sys = "0.3"
|
||||
gloo-timers = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod card;
|
||||
pub mod summary;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+22
-3
@@ -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" }
|
||||
|
||||
@@ -40,6 +40,11 @@ impl GameSocket {
|
||||
self.send_json(serde_json::json!({ "action": "state" }));
|
||||
}
|
||||
|
||||
/// Acknowledge the hand-end scoring summary.
|
||||
pub fn ack(&self) {
|
||||
self.send_json(serde_json::json!({ "action": "ack" }));
|
||||
}
|
||||
|
||||
fn send_json(&self, value: serde_json::Value) {
|
||||
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
||||
}
|
||||
|
||||
+153
@@ -9,6 +9,8 @@
|
||||
--muted: #a9b7ab;
|
||||
--accent: #e8c547;
|
||||
--danger: #d9534f;
|
||||
--team-a: #7db4e8;
|
||||
--team-b: #e8967d;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -448,3 +450,154 @@ table.matches td.lost {
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ---------- hand-end scoring summary ---------- */
|
||||
|
||||
.summary-panel {
|
||||
min-width: 420px;
|
||||
max-width: 560px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.summary-panel h2 {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.score-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--panel-light);
|
||||
border-left: 4px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
}
|
||||
|
||||
.score-row.team-a {
|
||||
border-left-color: var(--team-a);
|
||||
}
|
||||
|
||||
.score-row.team-b {
|
||||
border-left-color: var(--team-b);
|
||||
}
|
||||
|
||||
.score-row.tie {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.score-icon {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-img.score-mini {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.score-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.score-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.score-points {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.score-row.team-a .score-points {
|
||||
color: var(--team-a);
|
||||
}
|
||||
|
||||
.score-row.team-b .score-points {
|
||||
color: var(--team-b);
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr 4rem;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.gained {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.total-row.team-a .gained {
|
||||
color: var(--team-a);
|
||||
}
|
||||
|
||||
.total-row.team-b .gained {
|
||||
color: var(--team-b);
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 10px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.total-row.team-a .progress-fill {
|
||||
background: var(--team-a);
|
||||
}
|
||||
|
||||
.total-row.team-b .progress-fill {
|
||||
background: var(--team-b);
|
||||
}
|
||||
|
||||
.summary-actions {
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.final-score {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user