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.
275 lines
8.8 KiB
Rust
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)
|
|
}
|
|
}
|
|
}
|
|
}
|