Files
tavolo/web/src/pages/chiamata.rs
T
woggioni 43d5a6e2f9 Add briscola chiamata
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.
2026-10-08 04:27:15 +00:00

513 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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) }
}
}
}
}