Add briscola chiamata
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.
This commit is contained in:
2026-10-08 21:50:56 +08:00
parent 6427afd12b
commit 353f012906
21 changed files with 4036 additions and 73 deletions
+35 -9
View File
@@ -7,14 +7,16 @@
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{is_individual_game, DealSummary, GameView};
use crate::model::{card_label, is_chiamata, is_individual_game, DealSummary, GameView};
use crate::ws::GameSocket;
/// Names of the two sides: the players' names for games without
/// partnerships (classic two-player briscola), "Team A"/"Team B"
/// otherwise.
/// 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_individual_game(&game.game_type) {
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"),
@@ -66,6 +68,25 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
"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",
@@ -84,6 +105,7 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
});
view! {
div(class="score-rows") {
(called_row)
(points_row)
(patta_note)
(cards)
@@ -94,9 +116,13 @@ pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
/// 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 * summary.totals.a / target).min(100);
let pct_b = (100 * summary.totals.b / target).min(100);
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 {
@@ -111,7 +137,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
span(class="total-value") {
(summary.totals.a) " / " (target)
(running.a) " / " (target)
span(class="gained") { "+" (gained_a) }
}
}
@@ -121,7 +147,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
span(class="total-value") {
(summary.totals.b) " / " (target)
(running.b) " / " (target)
span(class="gained") { "+" (gained_b) }
}
}
+310 -6
View File
@@ -19,7 +19,10 @@ pub struct PlayerView {
pub sub: String,
pub name: String,
pub seat: usize,
pub team: String,
/// Game-defined role/team label; empty before briscola chiamata
/// reveals the roles.
#[serde(default)]
pub team: Option<String>,
#[serde(default)]
pub cards_left: usize,
#[serde(default)]
@@ -84,9 +87,21 @@ pub struct DealSummary {
#[serde(default)]
pub patta: bool,
pub points: Scores,
pub totals: Scores,
/// Running match totals; absent for briscola chiamata, whose match is
/// a single deal.
#[serde(default)]
pub totals: Option<Scores>,
#[serde(default)]
pub cards: Option<TeamCounts>,
/// Briscola chiamata: the points target of the played deal.
#[serde(default)]
pub declared_target: Option<i32>,
/// Briscola chiamata: the card named by the caller.
#[serde(default)]
pub called_card: Option<String>,
/// Briscola chiamata: the caller played without a partner.
#[serde(default)]
pub solo: bool,
}
#[derive(Debug, Clone, Copy, Deserialize)]
@@ -227,6 +242,83 @@ pub struct GameView {
/// Briscola: scoring breakdown of the most recent deal.
#[serde(default)]
pub last_deal: Option<DealSummary>,
/// Briscola chiamata: the calling auction of the current deal.
#[serde(default)]
pub auction: Option<AuctionView>,
/// Briscola chiamata: seat that won the auction.
#[serde(default)]
pub caller: Option<usize>,
/// Briscola chiamata: the named card; its suit is the trump.
#[serde(default)]
pub called_card: Option<String>,
/// Briscola chiamata: points the calling side must reach.
#[serde(default)]
pub declared_target: i32,
/// Briscola chiamata: the called card has been played; roles are
/// public from then on.
#[serde(default)]
pub called_played: bool,
/// Briscola chiamata: the partner's seat, once revealed.
#[serde(default)]
pub partner: Option<usize>,
/// Briscola chiamata: the caller named one of their own cards.
#[serde(default)]
pub solo: bool,
/// Briscola chiamata: legal bids for the player on turn.
#[serde(default)]
pub legal_bids: Option<LegalBids>,
/// Briscola chiamata: the cards the auction winner may name.
#[serde(default)]
pub callable_cards: Option<Vec<String>>,
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
#[serde(default)]
pub auction_deadline: Option<String>,
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
#[serde(default)]
pub call_deadline: Option<String>,
}
/// One bid of the briscola chiamata auction.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct AuctionBid {
pub seat: usize,
/// Rank bid: 1 ace, 3 three, 10 king, ... 2 two.
pub rank: i32,
/// Points target the bid commits its side to.
#[serde(default)]
pub points: i32,
}
/// The briscola chiamata calling auction.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct AuctionView {
/// Seat to act.
pub turn: usize,
#[serde(default)]
pub active: Vec<usize>,
#[serde(default)]
pub passed: Vec<usize>,
/// The standing bid, if any.
#[serde(default)]
pub current: Option<AuctionBid>,
#[serde(default)]
pub history: Vec<AuctionBid>,
}
/// The bids available to the player on turn.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LegalBids {
/// Ranks that may be bid, strongest first (empty at the point stage).
#[serde(default)]
pub ranks: Vec<i32>,
/// Lowest legal points raise, when the standing bid is the two.
#[serde(default)]
pub points_min: Option<i32>,
#[serde(default)]
pub points_max: Option<i32>,
}
impl GameView {
@@ -235,10 +327,38 @@ impl GameView {
pub fn player_name_for_team(&self, team: &str) -> String {
self.players
.iter()
.find(|p| p.team == team)
.find(|p| p.team.as_deref() == Some(team))
.map(|p| p.name.clone())
.unwrap_or_else(|| team.to_string())
}
/// Display name of the player in `seat`.
pub fn player_name_for_seat(&self, seat: usize) -> String {
self.players
.iter()
.find(|p| p.seat == seat)
.map(|p| p.name.clone())
.unwrap_or_else(|| format!("Seat {seat}"))
}
/// Side labels for briscola chiamata: the caller (with the partner's
/// name once revealed, or "(solo)") against the defenders.
pub fn chiamata_sides(&self) -> (String, String) {
let side_a = match self.caller {
Some(caller) => {
let caller_name = self.player_name_for_seat(caller);
match self.partner {
Some(partner) => {
format!("{caller_name} & {}", self.player_name_for_seat(partner))
}
None if self.called_played => format!("{caller_name} (solo)"),
None => caller_name,
}
}
None => "Chiamante".to_string(),
};
(side_a, "Difensori".to_string())
}
}
#[derive(Debug, Deserialize)]
@@ -449,6 +569,14 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola_chiamata".to_string(),
name: "Briscola chiamata".to_string(),
description: String::new(),
min_players: 5,
max_players: 5,
options_schema: serde_json::Value::Null,
},
]
}
@@ -735,6 +863,30 @@ pub fn is_briscola(game_type: &str) -> bool {
game_type == "briscola" || game_type == "briscola_a_quattro"
}
/// Whether a game type is five-player briscola chiamata, which has its
/// own table view (auction, secret partner, five seats).
pub fn is_chiamata(game_type: &str) -> bool {
game_type == "briscola_chiamata"
}
/// The Italian rank name used by the auction UI: `1` -> "Asso", `3` ->
/// "Tre", `10` -> "Re", and so on.
pub fn rank_name(rank: i32) -> String {
match rank {
1 => "Asso".to_string(),
2 => "Due".to_string(),
3 => "Tre".to_string(),
4 => "Quattro".to_string(),
5 => "Cinque".to_string(),
6 => "Sei".to_string(),
7 => "Sette".to_string(),
8 => "Fante".to_string(),
9 => "Cavallo".to_string(),
10 => "Re".to_string(),
other => other.to_string(),
}
}
/// Whether a game is played by two individuals rather than partnerships
/// (classic two-player briscola): team labels and team messages are
/// replaced by the players' names.
@@ -1033,7 +1185,7 @@ mod tests {
assert_eq!(16, g.target_score);
assert!(!g.napola);
assert!(g.asso_pigliatutto);
assert_eq!("A", g.players[0].team);
assert_eq!(Some("A".to_string()), g.players[0].team);
}
#[test]
@@ -1245,7 +1397,10 @@ mod tests {
fn is_briscola_matches_both_variants() {
assert!(is_briscola("briscola"));
assert!(is_briscola("briscola_a_quattro"));
assert!(!is_briscola("briscola_chiamata"));
assert!(!is_briscola("scopone_scientifico"));
assert!(is_chiamata("briscola_chiamata"));
assert!(!is_chiamata("briscola"));
assert_eq!("denari", suit_name("D"));
assert_eq!("coppe", suit_name("C"));
assert_eq!("spade", suit_name("S"));
@@ -1253,6 +1408,16 @@ mod tests {
assert_eq!("?", suit_name("?"));
}
#[test]
fn rank_names_cover_the_ladder() {
assert_eq!("Asso", rank_name(1));
assert_eq!("Due", rank_name(2));
assert_eq!("Tre", rank_name(3));
assert_eq!("Fante", rank_name(8));
assert_eq!("Cavallo", rank_name(9));
assert_eq!("Re", rank_name(10));
}
#[test]
fn is_individual_game_only_matches_two_player_briscola() {
assert!(is_individual_game("briscola"));
@@ -1318,11 +1483,17 @@ mod tests {
let types = fallback_game_types();
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
assert_eq!(
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
vec![
"scopone_scientifico",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
],
ids
);
// The lobby's first game (the creation default) must exist.
assert_eq!("scopone_scientifico", types[0].id);
assert_eq!(5, types[3].max_players);
}
#[test]
@@ -1400,7 +1571,7 @@ mod tests {
assert_eq!(1, deal.deal);
assert!(!deal.patta);
assert_eq!(Scores { a: 55, b: 65 }, deal.points);
assert_eq!(Scores { a: 55, b: 65 }, deal.totals);
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
let cards = deal.cards.unwrap();
assert_eq!(19, cards.a);
assert_eq!(21, cards.b);
@@ -1420,4 +1591,137 @@ mod tests {
assert_eq!(0, d.deal);
assert!(d.cards.is_none());
}
#[test]
fn chiamata_deal_summary_has_no_totals() {
let d: DealSummary = serde_json::from_value(json!({
"deal": 2,
"patta": true,
"points": {"A": 60, "B": 60},
"declared_target": 61,
"called_card": "01D",
"caller": "Alice",
"solo": false,
}))
.unwrap();
assert!(d.patta);
assert_eq!(2, d.deal);
assert!(d.totals.is_none());
assert!(d.cards.is_none());
assert_eq!(Some(61), d.declared_target);
assert_eq!(Some("01D".to_string()), d.called_card);
assert!(!d.solo);
}
#[test]
fn game_view_parses_chiamata_auction() {
let g: GameView = serde_json::from_value(json!({
"id": "c1",
"join_code": "ABC123",
"game_type": "briscola_chiamata",
"phase": "auction",
"player_count": 5,
"dealer": 0,
"hand_number": 1,
"turn": 1,
"declared_target": 61,
"auction": {
"turn": 2,
"active": [0, 1, 2, 3, 4],
"passed": [],
"current": {"seat": 1, "rank": 10, "points": 61},
"history": [{"seat": 1, "rank": 10, "points": 61}],
},
"your_turn": true,
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
"cards_left": 8, "captured_count": 0, "points": 0},
],
"auction_deadline": "2026-01-01T00:00:30+00:00",
"call_deadline": null,
"turn_deadline": null,
}))
.unwrap();
assert!(is_chiamata(&g.game_type));
assert!(!is_briscola(&g.game_type));
let auction = g.auction.unwrap();
assert_eq!(2, auction.turn);
assert_eq!(5, auction.active.len());
let current = auction.current.unwrap();
assert_eq!(1, current.seat);
assert_eq!(10, current.rank);
assert_eq!(61, current.points);
assert_eq!(1, auction.history.len());
let bids = g.legal_bids.unwrap();
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
assert!(bids.points_min.is_none());
assert_eq!(Some(120), bids.points_max);
assert_eq!(Some("".to_string()), g.players[0].team);
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
}
#[test]
fn game_view_parses_chiamata_playing_and_reveal() {
let g: GameView = serde_json::from_value(json!({
"id": "c2",
"game_type": "briscola_chiamata",
"phase": "playing",
"player_count": 5,
"dealer": 0,
"hand_number": 1,
"turn": 3,
"caller": 1,
"called_card": "03C",
"briscola_suit": "C",
"declared_target": 64,
"called_played": true,
"partner": 3,
"solo": false,
"scores": {"A": 45, "B": 30},
"players": [
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
],
}))
.unwrap();
assert_eq!(Some(1), g.caller);
assert_eq!(Some("03C".to_string()), g.called_card);
assert_eq!("C", g.briscola_suit);
assert_eq!(64, g.declared_target);
assert!(g.called_played);
assert_eq!(Some(3), g.partner);
assert!(!g.solo);
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
}
#[test]
fn chiamata_sides_label_the_solo_caller() {
let g: GameView = serde_json::from_value(json!({
"id": "c3",
"game_type": "briscola_chiamata",
"phase": "finished",
"scores": {"A": 70, "B": 50},
"winner": "Chiamante",
"caller": 2,
"called_played": true,
"solo": true,
"players": [
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
],
}))
.unwrap();
let (a, b) = g.chiamata_sides();
assert_eq!("Carol (solo)", a);
assert_eq!("Difensori", b);
assert_eq!(Some("Chiamante".to_string()), g.winner);
}
}
+5 -4
View File
@@ -27,10 +27,11 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
if active { " active" } else { "" }
);
let name = player.name.clone();
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
let team = player.team.clone();
view! { span(class="team-badge") { "Team " (team) } }
});
let team_badge = (!is_individual_game(&game.game_type))
.then(|| player.team.clone())
.flatten()
.filter(|team| !team.is_empty())
.map(|team| view! { span(class="team-badge") { "Team " (team) } });
let points = player.points;
let captured = player.captured_count;
let backs = (0..player.cards_left)
+512
View File
@@ -0,0 +1,512 @@
//! Briscola chiamata table: five seats, the calling auction and the
//! secret-partner reveal.
//!
//! The auction starts with the player after the dealer: each player bids
//! a lower rank than the standing bid (A > 3 > K > H > J > 7 > 6 > 5 >
//! 4 > 2) or passes, and once the standing bid is the two the bidding
//! continues on points. The last bidder names a 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 is the trump. The partner's
//! identity is only revealed when the called card is played.
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::model::{
card_label, hand_sort_key, rank_name, suit_name, AuctionBid, GameView, PlayerView,
};
use crate::pages::game::watch_link;
use crate::ws::GameSocket;
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
fn player_name(game: &GameView, seat: usize) -> String {
player_for_seat(game, seat)
.map(|p| p.name)
.unwrap_or_else(|| format!("Seat {seat}"))
}
/// Human label of one auction bid.
fn bid_label(bid: &AuctionBid) -> String {
if bid.rank == 2 {
format!("2 at {} pts", bid.points)
} else {
rank_name(bid.rank)
}
}
/// Panel for one player seat (name, revealed role, hidden cards, points).
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
let Some(player) = player_for_seat(&game, seat) else {
return view! {};
};
let active = game.phase == "playing" && game.turn == seat;
let cls = format!(
"seat seat-{position}{}",
if active { " active" } else { "" }
);
let name = player.name.clone();
// Role labels only appear once the server makes them public.
let role_badge = player
.team
.clone()
.filter(|role| !role.is_empty())
.map(|role| view! { span(class="team-badge role-badge") { (role) } });
let points = player.points;
let captured = player.captured_count;
let backs = (0..player.cards_left)
.map(|_| card_back("mini"))
.collect::<Vec<_>>();
view! {
div(class=cls) {
div(class="seat-name") {
(name)
(role_badge)
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
(points) " points · " (captured) " cards"
}
}
}
}
/// The cards of the trick in progress, in play order, with player names.
fn trick_view(game: &GameView) -> View {
if game.trick.is_empty() {
return view! { p(class="table-empty") { "Empty table" } };
}
let cards = game
.trick
.iter()
.map(|tc| {
let name = player_name(game, tc.seat);
let card = card_img(tc.card.clone(), "table-card");
view! {
div(class="trick-slot") {
span(class="trick-name") { (name) }
(card)
}
}
})
.collect::<Vec<_>>();
view! { (cards) }
}
/// Announcement strip for the most recently completed trick.
fn last_trick_banner(game: &GameView) -> View {
let Some(trick) = game.last_trick.clone() else {
return view! {};
};
let name = player_name(game, trick.winner);
let cards = trick
.cards
.iter()
.map(|tc| card_img(tc.card.clone(), "move-card"))
.collect::<Vec<_>>();
view! {
div(class="move-banner") {
strong { (name) }
span { " takes the trick " }
(cards)
}
}
}
/// "Patta — the deal is replayed" after a 60-60 deal.
fn patta_banner(game: &GameView) -> View {
let patta = game
.last_deal
.as_ref()
.is_some_and(|deal| deal.patta);
if !patta || game.phase != "auction" {
return view! {};
}
view! {
div(class="move-banner patta-banner") {
strong { "Patta!" }
span { " 60–60 — the deal is replayed." }
}
}
}
/// Announcement of the partner (or the solo caller) once the called card
/// has hit the table.
fn reveal_banner(game: &GameView) -> View {
if !game.called_played || game.phase == "auction" || game.phase == "calling" {
return view! {};
}
let caller = game
.caller
.map(|seat| player_name(game, seat))
.unwrap_or_default();
let text = if game.solo {
format!("{caller} is playing alone")
} else {
let partner = game
.partner
.map(|seat| player_name(game, seat))
.unwrap_or_default();
format!("{partner} is the partner of {caller}")
};
view! {
div(class="move-banner reveal-banner") {
span(class="reveal-text") { (text) }
}
}
}
/// The calling auction panel: standing bid, trail and, on turn, the bids.
fn auction_panel(
game: &GameView,
on_bid_rank: impl Fn(i32) + Copy + 'static,
on_bid_points: impl Fn(i32) + Copy + 'static,
on_pass: impl Fn() + Copy + 'static,
) -> View {
let Some(auction) = game.auction.clone() else {
return view! {};
};
let standing = match &auction.current {
Some(bid) => format!(
"Standing bid: {} — {}",
player_name(game, bid.seat),
bid_label(bid)
),
None => "No bids yet".to_string(),
};
let waiting_for = player_name(game, auction.turn);
let history = auction
.history
.iter()
.map(|bid| {
let text = format!("{}: {}", player_name(game, bid.seat), bid_label(bid));
view! { span(class="auction-chip") { (text) } }
})
.collect::<Vec<_>>();
let actions = if game.your_turn == Some(true) {
let legal = game.legal_bids.clone();
let (rank_buttons, point_buttons) = match legal {
Some(bids) => {
let ranks = bids
.ranks
.iter()
.map(|rank| {
let rank = *rank;
let name = rank_name(rank);
view! {
button(
class="bid-button",
on:click=move |_| on_bid_rank(rank),
) { (name) }
}
})
.collect::<Vec<_>>();
let points = match (bids.points_min, bids.points_max) {
(Some(min), Some(max)) => (0..=2)
.map(|step| min + 2 * step)
.filter(|points| *points <= max)
.map(|points| {
view! {
button(
class="bid-button points",
on:click=move |_| on_bid_points(points),
) { (points) " pts" }
}
})
.collect::<Vec<_>>(),
_ => Vec::new(),
};
(ranks, points)
}
None => (Vec::new(), Vec::new()),
};
view! {
div(class="bid-options") {
(rank_buttons)
(point_buttons)
button(class="pass-button", on:click=move |_| on_pass()) { "Pass" }
}
}
} else {
view! {
p(class="text-secondary small mb-0") {
"Waiting for " (waiting_for) " to bid…"
}
}
};
view! {
div(class="auction-panel") {
div(class="auction-standing") { (standing) }
div(class="auction-history") { (history) }
(actions)
}
}
}
/// The main briscola chiamata table.
pub fn chiamata_table(
game: GameView,
on_hand_card: impl Fn(String) + Copy + 'static,
on_bid_rank: impl Fn(i32) + Copy + 'static,
on_bid_points: impl Fn(i32) + Copy + 'static,
on_pass: impl Fn() + Copy + 'static,
now: Signal<f64>,
) -> View {
// Own seat: the only player entry carrying a hand. Spectators have no
// hand, so they watch from seat 0's perspective.
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat)
.unwrap_or(0);
let my_turn = game.your_turn == Some(true);
let can_play = game.phase == "playing" && my_turn;
let turn_note = if game.phase == "finished" {
"Match finished".to_string()
} else if game.phase == "auction" {
if my_turn {
"Your bid".to_string()
} else {
let name = game
.auction
.as_ref()
.map(|a| player_name(&game, a.turn))
.unwrap_or_default();
format!("{name} to bid")
}
} else if game.phase == "calling" {
if my_turn {
"Name the called card".to_string()
} else {
let name = game
.caller
.map(|seat| player_name(&game, seat))
.unwrap_or_default();
format!("{name} is calling")
}
} else if my_turn {
"Your turn".to_string()
} else {
format!("{}'s turn", player_name(&game, game.turn))
};
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
let deadline = if game.phase == "auction" {
game.auction_deadline.clone()
} else if game.phase == "calling" {
game.call_deadline.clone()
} else {
game.turn_deadline.clone()
};
let countdown = deadline.map(|deadline| {
// A dynamic closure so only the ticking number re-renders.
let deadline_ms = js_sys::Date::parse(&deadline);
view! {
span(class="turn-timer") {
"Auto in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let score_line = match game.scores {
Some(scores) => {
let (side_a, side_b) = game.chiamata_sides();
format!(
"{side_a} {} — {} {side_b} (target {})",
scores.a, scores.b, game.declared_target
)
}
// Before the called card is played the side totals would leak the
// partner's identity; only the target is shown.
None => format!("Target {}", game.declared_target),
};
// Four opponents around the table: next player on the left, then
// clockwise up to the player before us on the right.
let left = seat_panel(game.clone(), (viewer_seat + 1) % 5, "left");
let top_left = seat_panel(game.clone(), (viewer_seat + 2) % 5, "tl");
let top_right = seat_panel(game.clone(), (viewer_seat + 3) % 5, "tr");
let right = seat_panel(game.clone(), (viewer_seat + 4) % 5, "right");
let patta = patta_banner(&game);
let reveal = reveal_banner(&game);
let center_content = match game.phase.as_str() {
"auction" => auction_panel(&game, on_bid_rank, on_bid_points, on_pass),
"calling" => {
let caller = game
.caller
.map(|seat| player_name(&game, seat))
.unwrap_or_default();
view! {
div(class="auction-panel") {
div(class="auction-standing") { (caller) " won the auction" }
p(class="text-secondary small mb-0") {
"Choosing the called card…"
}
}
}
}
_ => {
let banner = last_trick_banner(&game);
let trick = trick_view(&game);
view! {
(banner)
div(class="table-cards") { (trick) }
}
}
};
let viewer = player_for_seat(&game, viewer_seat);
let my_points = viewer.as_ref().map(|p| p.points).unwrap_or(0);
let mut hand = viewer.and_then(|p| p.hand).unwrap_or_default();
hand.sort_by_key(|c| hand_sort_key(c));
let hand_cards = hand
.into_iter()
.map(|code| {
let card_view = card_img(code.clone(), "hand-card");
let click_code = code.clone();
view! {
button(class="hand-slot", on:click=move |_| {
if can_play {
on_hand_card(click_code.clone());
}
}) { (card_view) }
}
})
.collect::<Vec<_>>();
let hint = if can_play {
Some(view! {
p(class="text-secondary small text-center") {
"Click a card to play it."
}
})
} else if game.phase == "auction" {
Some(view! {
p(class="text-secondary small text-center") {
"Bid or pass, then play the cards."
}
})
} else if game.phase == "calling" && my_turn {
Some(view! {
p(class="text-secondary small text-center") {
"Name a card: its holder will be your secret partner."
}
})
} else {
None
};
// Spectators have no hand: render the pivot seat as a regular panel so
// its points stay visible where the hand would be.
let bottom = if game.spectator {
seat_panel(game.clone(), viewer_seat, "bottom")
} else {
view! {
div(class="seat-bottom") {
div(class="seat-stats own-stats") { (my_points) " points" }
div(class="hand") { (hand_cards) }
(hint)
}
}
};
let spectator_badge = game.spectator.then(|| view! {
span(class="spectator-badge") { "Spectating" }
});
let watch_link = watch_link(&game.id);
let trump_badge = (!game.briscola_suit.is_empty()).then(|| {
let suit = suit_name(&game.briscola_suit);
view! { span(class="briscola-badge") { "Briscola: " (suit) } }
});
let called_badge = game.called_card.clone().map(|code| {
let label = card_label(&code);
view! { span(class="called-badge") { "Chiamata: " (label) } }
});
let deal = game.hand_number;
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Deal " (deal) }
(trump_badge)
(called_badge)
(spectator_badge)
span(class="hud-scores") { (score_line) }
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
div(class="table-grid cinque") {
(top_left)
(top_right)
(left)
div(class="center") {
(patta)
(reveal)
(center_content)
}
(right)
(bottom)
}
}
}
}
/// Overlay for the auction winner: name the called card.
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
return view! {};
};
let own_hand = game
.players
.iter()
.find(|p| p.hand.is_some())
.and_then(|p| p.hand.clone())
.unwrap_or_default();
let options = cards
.into_iter()
.map(|code| {
let held = own_hand.contains(&code);
let label = card_label(&code);
let img = card_img(code.clone(), "hand-card");
let solo_note = held.then(|| view! {
span(class="solo-note") { "plays alone" }
});
let click_code = code.clone();
view! {
button(class="capture-option call-option", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.call_card(&click_code);
}
}) {
(img)
span(class="call-label") { (label) }
(solo_note)
}
}
})
.collect::<Vec<_>>();
view! {
div(class="overlay") {
div(class="picker") {
h3 { "Name the called card" }
p(class="text-secondary small") {
"Its holder becomes your secret partner; name one of your "
"own cards to play alone."
}
div(class="capture-options") { (options) }
}
}
}
}
+57 -10
View File
@@ -12,10 +12,11 @@ use crate::components::copy::CopyButton;
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{
card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, GameView, MoveView,
PlayerView, Scores, ServerMessage,
};
use crate::pages::briscola::briscola_table;
use crate::pages::chiamata::{call_picker, chiamata_table};
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
@@ -43,7 +44,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
if active { " active" } else { "" }
);
let name = player.name.clone();
let team = player.team.clone();
let team = player.team.clone().unwrap_or_default();
let captured = player.captured_count;
let scope = player.scope;
let backs = (0..player.cards_left)
@@ -324,6 +325,32 @@ pub fn GamePage(id: String) -> View {
}
};
// Briscola chiamata: auction and call actions.
let on_bid_rank = move |rank: i32| {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.bid_rank(rank);
}
};
let on_bid_points = move |points: i32| {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.bid_points(points);
}
};
let on_pass = move || {
if closed.get() || game.get_clone().and_then(|g| g.your_turn) != Some(true) {
return;
}
if let Some(s) = socket.get_clone() {
s.pass();
}
};
let reconnect_banner = reconnect.clone();
view! {
div(class="game-page") {
@@ -392,6 +419,11 @@ pub fn GamePage(id: String) -> View {
}
}
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
// Briscola chiamata has its own five-seat table with the
// calling auction and the secret-partner reveal.
Some(g) if is_chiamata(&g.game_type) => {
chiamata_table(g, on_hand_card, on_bid_rank, on_bid_points, on_pass, now)
}
// Briscola has its own table view for both variants.
Some(g) if is_briscola(&g.game_type) => {
briscola_table(g, on_hand_card, now)
@@ -406,6 +438,17 @@ pub fn GamePage(id: String) -> View {
(move || capture_choice.get_clone().map(|(card, options)| {
capture_picker(card, options, socket, capture_choice)
}))
// Briscola chiamata: the auction winner names the called card.
(move || match game.get_clone() {
Some(g)
if is_chiamata(&g.game_type)
&& g.phase == "calling"
&& g.your_turn == Some(true) =>
{
call_picker(g, socket)
}
_ => view! {},
})
(move || match game.get_clone() {
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
hand_summary_modal(g, socket, now)
@@ -440,10 +483,11 @@ fn LobbyView(game: GameView) -> View {
.map(|p| {
let name = p.name.clone();
let seat = p.seat;
let team_badge = show_teams.then(|| {
let team = p.team.clone();
view! { span(class="badge text-bg-info") { "Team " (team) } }
});
let team_badge = show_teams
.then(|| p.team.clone())
.flatten()
.filter(|team| !team.is_empty())
.map(|team| view! { span(class="badge text-bg-info") { "Team " (team) } });
view! {
li(class="list-group-item d-flex justify-content-between align-items-center") {
strong { (name) }
@@ -734,8 +778,9 @@ fn capture_picker(
/// End-of-match overlay: the final hand/deal breakdown and the winner.
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
let briscola = game.as_ref().is_some_and(|g| is_briscola(&g.game_type));
let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type));
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" && !briscola {
if g.game_type != "scopone_scientifico" && !briscola && !chiamata {
return view! {};
}
}
@@ -749,18 +794,20 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
Some((scores, winner)) => {
let winner = winner.unwrap_or_else(|| "?".to_string());
// Two-player briscola has no teams: name the winner instead.
// Briscola chiamata names the caller's side (or the defenders).
let winner_label = match game.as_ref() {
Some(g) if is_individual_game(&g.game_type) => {
g.player_name_for_team(&winner)
}
Some(g) if is_chiamata(&g.game_type) => winner.clone(),
_ => format!("Team {winner}"),
};
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
// Spectators have no match history to look at.
let spectator = game.as_ref().is_some_and(|g| g.spectator);
// Explain the final hand's (scopone) or deal's (briscola)
// scoring before the result.
let final_summary: View = if briscola {
// Explain the final hand's (scopone) or deal's (briscola,
// briscola chiamata) scoring before the result.
let final_summary: View = if briscola || chiamata {
match game.as_ref() {
Some(g) => match g.last_deal.clone() {
Some(deal) => deal_rows(g, &deal),
+9 -2
View File
@@ -39,7 +39,10 @@ fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
fn team_header(team: &Option<String>) -> String {
match team {
Some(t) => format!("Team {t}"),
// Single-letter labels are the A/B team names; longer labels
// (e.g. briscola chiamata's "Chiamante"/"Socio") read on their own.
Some(t) if t.len() <= 2 => format!("Team {t}"),
Some(t) => t.clone(),
None => "Players".to_string(),
}
}
@@ -55,7 +58,11 @@ fn score_text(m: &MatchSummary) -> String {
/// "Team A" from `winner_team`, a plain `winner`, or "—".
fn winner_text(m: &MatchSummary) -> String {
if let Some(team) = m.result_str("winner_team") {
return format!("Team {team}");
return if team.len() <= 2 {
format!("Team {team}")
} else {
team
};
}
m.result_str("winner").unwrap_or_else(|| "—".to_string())
}
+1
View File
@@ -1,4 +1,5 @@
pub mod briscola;
pub mod chiamata;
pub mod game;
pub mod history;
pub mod leaderboard;
+20
View File
@@ -45,6 +45,26 @@ impl GameSocket {
self.send_json(serde_json::json!({ "action": "ack" }));
}
/// Briscola chiamata: bid a rank in the calling auction.
pub fn bid_rank(&self, rank: i32) {
self.send_json(serde_json::json!({ "action": "bid", "rank": rank }));
}
/// Briscola chiamata: raise the points target (standing bid is the 2).
pub fn bid_points(&self, points: i32) {
self.send_json(serde_json::json!({ "action": "bid", "points": points }));
}
/// Briscola chiamata: leave the auction.
pub fn pass(&self) {
self.send_json(serde_json::json!({ "action": "pass" }));
}
/// Briscola chiamata: name the called card.
pub fn call_card(&self, card: &str) {
self.send_json(serde_json::json!({ "action": "call", "card": card }));
}
fn send_json(&self, value: serde_json::Value) {
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
}
+159
View File
@@ -667,3 +667,162 @@ body {
gap: 0.4rem;
}
}
/* ---------- briscola chiamata ---------- */
/* Five seats: two across the top and one on each side of the viewer. */
.table-grid.cinque {
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
grid-template-areas:
"tl . tr"
"left center right"
"bottom bottom bottom";
}
.seat-tl {
grid-area: tl;
}
.seat-tr {
grid-area: tr;
}
.called-badge {
background: var(--panel-light);
color: var(--accent);
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
border: 1px solid var(--accent);
}
.role-badge {
background: var(--accent);
color: var(--felt-dark);
}
.reveal-banner {
background: rgba(232, 197, 71, 0.15);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.35rem 0.75rem;
}
.patta-banner {
background: rgba(217, 83, 79, 0.25);
}
/* The calling auction: standing bid, trail and the bid buttons. */
.auction-panel {
background: rgba(0, 0, 0, 0.25);
border-radius: 10px;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
max-width: 32rem;
}
.auction-standing {
font-weight: 600;
text-align: center;
}
.auction-history {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
justify-content: center;
}
.auction-chip {
background: var(--panel-light);
color: var(--muted);
border-radius: 4px;
padding: 0.1rem 0.4rem;
font-size: 0.75rem;
}
.bid-options {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: center;
}
.bid-button {
background: var(--panel-light);
color: var(--text);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.bid-button:hover {
background: var(--accent);
color: var(--felt-dark);
}
.bid-button.points {
border-color: var(--team-a);
}
.pass-button {
background: transparent;
color: var(--muted);
border: 1px solid var(--muted);
border-radius: 6px;
padding: 0.25rem 0.6rem;
cursor: pointer;
}
.pass-button:hover {
color: var(--text);
border-color: var(--text);
}
/* The auction winner's call picker: one card per suit. */
.call-option {
flex-direction: column;
align-items: center;
}
.call-option .playing-card {
margin: 0 auto;
}
.call-label {
display: block;
font-size: 0.85rem;
color: var(--text);
margin-top: 0.25rem;
}
.solo-note {
display: block;
font-size: 0.7rem;
color: var(--accent);
}
.chiamata-row .score-title {
color: var(--accent);
}
@media (max-width: 576px) {
.table-grid.cinque {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"tl tr"
"left right"
"center center"
"bottom bottom";
}
.auction-panel {
padding: 0.5rem;
max-width: 100%;
}
}