//! 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 { 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::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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::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, ) -> 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::>(); 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. /// /// The winning bid fixes the rank, so the choice is which of the four /// suits to call: its holder becomes the secret partner, unless the /// caller holds it too — then that option means playing alone. pub fn call_picker(game: GameView, socket: Signal>) -> View { let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else { return view! {}; }; let rank = cards .first() .and_then(|code| code.get(..2)) .and_then(|rank| rank.parse::().ok()) .map(rank_name) .unwrap_or_default(); let title = if rank.is_empty() { "Name the called card".to_string() } else { format!("You won with the {rank} — name the card") }; 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(), "call-card"); let note = if held { view! { span(class="call-note solo") { "Plays alone" } } } else { view! { span(class="call-note partner") { "Partner" } } }; let click_code = code.clone(); view! { button(class="call-option", on:click=move |_| { if let Some(s) = socket.get_clone() { s.call_card(&click_code); } }) { (img) span(class="call-label") { (label) } (note) } } }) .collect::>(); view! { div(class="overlay") { div(class="picker call-picker") { h3 { (title) } p(class="text-secondary small") { "Its holder becomes your secret partner; name one of your " "own cards to play alone." } div(class="call-options") { (options) } } } } }