Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s
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:
@@ -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,3 +1,4 @@
|
||||
pub mod briscola;
|
||||
pub mod card;
|
||||
pub mod copy;
|
||||
pub mod nav;
|
||||
|
||||
Reference in New Issue
Block a user