Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s

New tavolo-briscola distribution implementing briscola on the platform:

- pure rules (state.py, engine.py): 3-card deals, a face-up trump that
  is the last card drawn, trump-first trick resolution (A > 3 > K > H >
  J > 7 > 6 > 5 > 4 > 2), 120 points per deal, 60-60 patta re-deals, a
  configurable target score (default 61), deal-end acknowledgements and
  turn auto-play;
- BriscolaEngine (briscola, 2 players) and BriscolaQuattroEngine
  (briscola_a_quattro, 4 players in fixed partnerships) adapters;
- registered in tavolo-app, packaged in the Docker image, documented.

Platform fixes surfaced by a two-player lobby:

- the websocket state and snapshot payloads now carry seats_open (they
  were only in the REST lobby payload), so lobbies with player counts
  other than four render correctly; seats_open is clamped at zero;
- the web client logs unparseable server messages instead of silently
  dropping them.

Web client: briscola table for both variants (stock, face-up trump,
trick, last-trick banner), deal summary modal and game-over overlay.

Tests: tavolo-briscola rules/adapter suites, app integration tests
(lobby, websocket play, turn timeout, deal-end acks, persistence), a
platform seats_open test and frontend model parsing tests.
This commit is contained in:
2026-10-06 14:08:25 +08:00
parent b5219062f2
commit 7bb6b74fdc
34 changed files with 3610 additions and 90 deletions
+190
View File
@@ -0,0 +1,190 @@
//! The briscola deal-end summary screen.
//!
//! Shown when a deal finishes but the match continues: the points won in
//! the deal just played, the running totals toward the target and the
//! acknowledgement button (the server deals on when everyone is ready, or
//! after the hand-end timeout).
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{DealSummary, GameView};
use crate::ws::GameSocket;
/// One explanatory row: icon, title, plain-language sentence, points chip.
fn summary_row(
icon: View,
title: &'static str,
text: String,
cls: &'static str,
chip: String,
) -> View {
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) }
}
}
}
/// The scoring rows of one completed deal (also used by the final screen).
pub fn deal_rows(summary: &DealSummary) -> View {
let a = summary.points.a;
let b = summary.points.b;
let cls = if summary.patta {
"score-row tie"
} else if a > b {
"score-row team-a"
} else {
"score-row team-b"
};
let chip = if summary.patta {
"patta".to_string()
} else {
format!("+{a} · +{b}")
};
let patta_note = summary.patta.then(|| view! {
p(class="text-warning small mb-0") {
"60–60: patta — the deal is replayed and its points do not count."
}
});
let points_row = summary_row(
card_img("01D".to_string(), "score-mini"),
"Deal points",
format!("Team A {a} · Team B {b}"),
cls,
chip,
);
let cards = summary.cards.map(|c| {
summary_row(
card_back("score-mini"),
"Cards captured",
format!("Team A {} · Team B {}", c.a, c.b),
"score-row",
format!("{} · {}", c.a, c.b),
)
});
view! {
div(class="score-rows") {
(points_row)
(patta_note)
(cards)
}
}
}
/// Running totals with progress toward the target, including the points
/// gained in the deal just played (zero for a patta).
fn totals(game: &GameView, summary: &DealSummary) -> View {
let target = game.target_score.max(1);
let pct_a = (100 * summary.totals.a / target).min(100);
let pct_b = (100 * summary.totals.b / target).min(100);
let (gained_a, gained_b) = if summary.patta {
(0, 0)
} else {
(summary.points.a, summary.points.b)
};
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") {
(summary.totals.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") {
(summary.totals.b) " / " (target)
span(class="gained") { "+" (gained_b) }
}
}
}
}
}
/// The full deal-end modal: points + totals + acknowledgement button.
pub fn deal_summary_modal(
game: GameView,
socket: Signal<Option<GameSocket>>,
now: Signal<f64>,
) -> View {
let Some(summary) = game.last_deal.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 title = format!("Deal {} — results", summary.deal);
let rows = deal_rows(&summary);
let totals_view = totals(&game, &summary);
let action = if game.spectator {
// Spectators cannot acknowledge; they just watch the summary.
view! {
p(class="text-secondary small mb-0") {
"Spectating — the next deal 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 deal" }
}
};
view! {
div(class="overlay") {
div(class="picker summary-panel") {
h2 { (title) }
(rows)
(totals_view)
div(class="summary-actions") { (action) }
(countdown)
}
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod briscola;
pub mod card;
pub mod copy;
pub mod nav;