Files
tavolo/web/src/pages/briscola.rs
T
woggioni 353f012906
CI / Build and push docker image (push) Successful in 5m35s
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 21:50:56 +08:00

275 lines
8.8 KiB
Rust

//! Briscola table view: classic two-player and four-player partnership.
//!
//! The two variants share this view: four seats render in the usual cross
//! (partners opposite), two seats stack opponent over player. The center
//! holds the trick in progress, the stock pile and the face-up briscola.
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::model::{
hand_sort_key, is_individual_game, suit_name, GameView, PlayerView, Scores,
};
use crate::pages::game::watch_link;
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
game.players.iter().find(|p| p.seat == seat).cloned()
}
/// Panel for one player seat (name, team, hidden card count, 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();
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)
.map(|_| card_back("mini"))
.collect::<Vec<_>>();
view! {
div(class=cls) {
div(class="seat-name") {
(name)
(team_badge)
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
(points) " points · " (captured) " cards"
}
}
}
}
/// The stock pile with the face-up briscola card beside it.
fn stock_view(game: &GameView) -> View {
let backs = (0..game.stock_count.min(3))
.map(|_| card_back("stock-card"))
.collect::<Vec<_>>();
let trump = game
.briscola_card
.clone()
.map(|code| card_img(code, "briscola-card"));
let label = format!("{} in stock", game.stock_count);
view! {
div(class="stock") {
div(class="stock-pile") { (backs) }
(trump)
span(class="stock-count") { (label) }
}
}
}
/// 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_for_seat(game, tc.seat)
.map(|p| p.name)
.unwrap_or_default();
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_for_seat(game, trick.winner)
.map(|p| p.name)
.unwrap_or_default();
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)
}
}
}
/// The main briscola table.
pub fn briscola_table(
game: GameView,
on_hand_card: impl Fn(String) + 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 player_count = if game.player_count > 0 {
game.player_count
} else {
game.players.len().max(1)
};
let my_turn = game.your_turn == Some(true);
let turn_note = if game.phase == "finished" {
"Match finished".to_string()
} else if game.phase == "hand_end" {
"Deal finished".to_string()
} else if my_turn {
"Your turn".to_string()
} else {
let name = player_for_seat(&game, game.turn)
.map(|p| p.name)
.unwrap_or_default();
format!("{name}'s turn")
};
let turn_cls = if my_turn { "turn-note you" } else { "turn-note" };
let countdown = game.turn_deadline.as_ref().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-play in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
let target = game.target_score;
let deal = game.hand_number;
// Two-player briscola has no partnerships: show the players' names.
let score_line = if is_individual_game(&game.game_type) {
format!(
"{} {} — {} {} (target {})",
game.player_name_for_team("A"),
scores.a,
scores.b,
game.player_name_for_team("B"),
target
)
} else {
format!("Team A {} — {} Team B (target {})", scores.a, scores.b, target)
};
let (grid_cls, top, left, right) = if player_count == 2 {
(
"table-grid duo",
seat_panel(game.clone(), (viewer_seat + 1) % 2, "top"),
view! {},
view! {},
)
} else {
(
"table-grid",
seat_panel(game.clone(), (viewer_seat + 2) % 4, "top"),
seat_panel(game.clone(), (viewer_seat + 1) % 4, "left"),
seat_panel(game.clone(), (viewer_seat + 3) % 4, "right"),
)
};
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");
view! {
button(class="hand-slot", on:click=move |_| {
if my_turn {
on_hand_card(code.clone());
}
}) { (card_view) }
}
})
.collect::<Vec<_>>();
let hint = my_turn.then(|| view! {
p(class="text-secondary small text-center") {
"Click a card to play it."
}
});
// 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) } }
});
// Precomputed so the final view's closures only capture owned views.
let banner = last_trick_banner(&game);
let trick = trick_view(&game);
let stock = stock_view(&game);
view! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Deal " (deal) }
(trump_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=grid_cls) {
(top)
(left)
div(class="center") {
(banner)
div(class="table-cards") { (trick) }
(stock)
}
(right)
(bottom)
}
}
}
}