CI / Build and push docker image (push) Successful in 5m35s
Five-player briscola with a descending calling auction and a secret partner. Starting with the player after the dealer, each player bids a lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the standing bid is the two the auction continues on points (62, 64, ...). The last player standing must bid, and the winner names any card of the won rank: its holder is the secret partner, or the caller plays alone when it is one of their own cards, and its suit becomes the trump. The calling side must reach 61 (or the raised target); a 60-60 deal with the plain target is a patta and is replayed. A match is a single deal, so the caller and partner (or the lone caller) against the other players map onto the platform's two-team result, Elo and match history. The rules live in tavolo.briscola.chiamata with the BriscolaChiamataEngine adapter registered alongside the other briscola variants; the partner's role and the side totals stay hidden until the called card is played. The web client gets a five-seat table, the auction panel with legal bids and point raises, the call picker, the reveal bands and a chiamata-aware final summary.
233 lines
7.4 KiB
Rust
233 lines
7.4 KiB
Rust
//! 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::{card_label, is_chiamata, is_individual_game, DealSummary, GameView};
|
||
use crate::ws::GameSocket;
|
||
|
||
/// Names of the two sides: the caller and partner for briscola chiamata,
|
||
/// the players' names for games without partnerships (classic two-player
|
||
/// briscola), "Team A"/"Team B" otherwise.
|
||
fn side_names(game: &GameView) -> (String, String) {
|
||
if is_chiamata(&game.game_type) {
|
||
game.chiamata_sides()
|
||
} else if is_individual_game(&game.game_type) {
|
||
(
|
||
game.player_name_for_team("A"),
|
||
game.player_name_for_team("B"),
|
||
)
|
||
} else {
|
||
("Team A".to_string(), "Team B".to_string())
|
||
}
|
||
}
|
||
|
||
/// 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(game: &GameView, summary: &DealSummary) -> View {
|
||
let a = summary.points.a;
|
||
let b = summary.points.b;
|
||
let (name_a, name_b) = side_names(game);
|
||
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 called_row = if is_chiamata(&game.game_type) {
|
||
game.called_card.clone().map(|code| {
|
||
let label = card_label(&code);
|
||
let text = if game.solo {
|
||
format!("{name_a} called {label} in hand and plays alone.")
|
||
} else {
|
||
format!("{label}: its holder is the secret partner; its suit is trump.")
|
||
};
|
||
summary_row(
|
||
card_img(code, "score-mini"),
|
||
"Chiamata",
|
||
text,
|
||
"score-row chiamata-row",
|
||
label,
|
||
)
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
let points_row = summary_row(
|
||
card_img("01D".to_string(), "score-mini"),
|
||
"Deal points",
|
||
format!("{name_a} {a} · {name_b} {b}"),
|
||
cls,
|
||
chip,
|
||
);
|
||
let cards = summary.cards.map(|c| {
|
||
summary_row(
|
||
card_back("score-mini"),
|
||
"Cards captured",
|
||
format!("{name_a} {} · {name_b} {}", c.a, c.b),
|
||
"score-row",
|
||
format!("{} · {}", c.a, c.b),
|
||
)
|
||
});
|
||
view! {
|
||
div(class="score-rows") {
|
||
(called_row)
|
||
(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 {
|
||
// Briscola chiamata plays a single deal: there is no running total.
|
||
let Some(running) = summary.totals else {
|
||
return view! {};
|
||
};
|
||
let target = game.target_score.max(1);
|
||
let pct_a = (100 * running.a / target).min(100);
|
||
let pct_b = (100 * running.b / target).min(100);
|
||
let (gained_a, gained_b) = if summary.patta {
|
||
(0, 0)
|
||
} else {
|
||
(summary.points.a, summary.points.b)
|
||
};
|
||
let (name_a, name_b) = side_names(game);
|
||
view! {
|
||
div(class="totals") {
|
||
div(class="total-row team-a") {
|
||
span(class="total-label") { (name_a) }
|
||
div(class="progress") {
|
||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||
}
|
||
span(class="total-value") {
|
||
(running.a) " / " (target)
|
||
span(class="gained") { "+" (gained_a) }
|
||
}
|
||
}
|
||
div(class="total-row team-b") {
|
||
span(class="total-label") { (name_b) }
|
||
div(class="progress") {
|
||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||
}
|
||
span(class="total-value") {
|
||
(running.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(&game, &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)
|
||
}
|
||
}
|
||
}
|
||
}
|