Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s

New tavolo-briscola distribution implementing briscola on the platform:

- pure rules (state.py, engine.py): 3-card deals, a face-up trump that
  is the last card drawn, trump-first trick resolution (A > 3 > K > H >
  J > 7 > 6 > 5 > 4 > 2), 120 points per deal, 60-60 patta re-deals, a
  configurable target score (default 61), deal-end acknowledgements and
  turn auto-play;
- BriscolaEngine (briscola, 2 players) and BriscolaQuattroEngine
  (briscola_a_quattro, 4 players in fixed partnerships) adapters;
- registered in tavolo-app, packaged in the Docker image, documented.

Platform fixes surfaced by a two-player lobby:

- the websocket state and snapshot payloads now carry seats_open (they
  were only in the REST lobby payload), so lobbies with player counts
  other than four render correctly; seats_open is clamped at zero;
- the web client logs unparseable server messages instead of silently
  dropping them.

Web client: briscola table for both variants (stock, face-up trump,
trick, last-trick banner), deal summary modal and game-over overlay.

Tests: tavolo-briscola rules/adapter suites, app integration tests
(lobby, websocket play, turn timeout, deal-end acks, persistence), a
platform seats_open test and frontend model parsing tests.
This commit is contained in:
2026-10-06 14:08:25 +08:00
parent b5219062f2
commit 7bb6b74fdc
34 changed files with 3610 additions and 90 deletions
+190
View File
@@ -0,0 +1,190 @@
//! The briscola deal-end summary screen.
//!
//! Shown when a deal finishes but the match continues: the points won in
//! the deal just played, the running totals toward the target and the
//! acknowledgement button (the server deals on when everyone is ready, or
//! after the hand-end timeout).
use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
use crate::model::{DealSummary, GameView};
use crate::ws::GameSocket;
/// One explanatory row: icon, title, plain-language sentence, points chip.
fn summary_row(
icon: View,
title: &'static str,
text: String,
cls: &'static str,
chip: String,
) -> View {
view! {
div(class=cls) {
div(class="score-icon") { (icon) }
div(class="score-body") {
div(class="score-title") { (title) }
div(class="score-text") { (text) }
}
div(class="score-points") { (chip) }
}
}
}
/// The scoring rows of one completed deal (also used by the final screen).
pub fn deal_rows(summary: &DealSummary) -> View {
let a = summary.points.a;
let b = summary.points.b;
let cls = if summary.patta {
"score-row tie"
} else if a > b {
"score-row team-a"
} else {
"score-row team-b"
};
let chip = if summary.patta {
"patta".to_string()
} else {
format!("+{a} · +{b}")
};
let patta_note = summary.patta.then(|| view! {
p(class="text-warning small mb-0") {
"60–60: patta — the deal is replayed and its points do not count."
}
});
let points_row = summary_row(
card_img("01D".to_string(), "score-mini"),
"Deal points",
format!("Team A {a} · Team B {b}"),
cls,
chip,
);
let cards = summary.cards.map(|c| {
summary_row(
card_back("score-mini"),
"Cards captured",
format!("Team A {} · Team B {}", c.a, c.b),
"score-row",
format!("{} · {}", c.a, c.b),
)
});
view! {
div(class="score-rows") {
(points_row)
(patta_note)
(cards)
}
}
}
/// 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 {
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 (gained_a, gained_b) = if summary.patta {
(0, 0)
} else {
(summary.points.a, summary.points.b)
};
view! {
div(class="totals") {
div(class="total-row team-a") {
span(class="total-label") { "Team A" }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
}
span(class="total-value") {
(summary.totals.a) " / " (target)
span(class="gained") { "+" (gained_a) }
}
}
div(class="total-row team-b") {
span(class="total-label") { "Team B" }
div(class="progress") {
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
}
span(class="total-value") {
(summary.totals.b) " / " (target)
span(class="gained") { "+" (gained_b) }
}
}
}
}
}
/// The full deal-end modal: points + totals + acknowledgement button.
pub fn deal_summary_modal(
game: GameView,
socket: Signal<Option<GameSocket>>,
now: Signal<f64>,
) -> View {
let Some(summary) = game.last_deal.clone() else {
return view! {};
};
let viewer_seat = game
.players
.iter()
.find(|p| p.hand.is_some())
.map(|p| p.seat);
let acked = game.acknowledged.clone();
let already_acked = viewer_seat.is_some_and(|s| acked.contains(&s));
let waiting: Vec<String> = game
.players
.iter()
.filter(|p| !acked.contains(&p.seat))
.map(|p| p.name.clone())
.collect();
let countdown = game.hand_end_deadline.as_ref().map(|deadline| {
// A dynamic closure so only the ticking number re-renders, not the
// whole modal (which would swap DOM nodes under the user's cursor).
let deadline_ms = js_sys::Date::parse(deadline);
view! {
p(class="text-secondary small") {
"Auto-continuing in "
(move || {
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
})
"s"
}
}
});
let title = format!("Deal {} — results", summary.deal);
let rows = deal_rows(&summary);
let totals_view = totals(&game, &summary);
let action = if game.spectator {
// Spectators cannot acknowledge; they just watch the summary.
view! {
p(class="text-secondary small mb-0") {
"Spectating — the next deal is dealt when every player is ready."
}
}
} else if already_acked {
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
view! {
button(class="btn btn-warning", disabled=true) { (waiting_text) }
}
} else {
view! {
button(class="btn btn-warning", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.ack();
}
}) { "Understood, next deal" }
}
};
view! {
div(class="overlay") {
div(class="picker summary-panel") {
h2 { (title) }
(rows)
(totals_view)
div(class="summary-actions") { (action) }
(countdown)
}
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod briscola;
pub mod card;
pub mod copy;
pub mod nav;
+183
View File
@@ -26,6 +26,9 @@ pub struct PlayerView {
pub captured_count: usize,
#[serde(default)]
pub scope: i32,
/// Briscola: point value of the player's captured pile.
#[serde(default)]
pub points: i32,
/// Own hand only; absent for the other players.
#[serde(default)]
pub hand: Option<Vec<String>>,
@@ -54,6 +57,38 @@ pub struct MoveView {
pub pigliatutto: bool,
}
/// One card played in the current briscola trick, in play order.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct TrickCard {
pub seat: usize,
pub card: String,
}
/// The most recently completed briscola trick.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct LastTrick {
pub winner: usize,
#[serde(default)]
pub cards: Vec<TrickCard>,
}
/// The scoring breakdown of one completed briscola deal.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct DealSummary {
#[serde(default)]
pub deal: i32,
/// A 60-60 deal: its points are discarded and the deal is replayed.
#[serde(default)]
pub patta: bool,
pub points: Scores,
pub totals: Scores,
#[serde(default)]
pub cards: Option<TeamCounts>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct TeamCounts {
#[serde(rename = "A")]
@@ -168,6 +203,30 @@ pub struct GameView {
/// Legal captures per hand card; present only for the player on turn.
#[serde(default)]
pub legal_moves: Option<HashMap<String, Vec<Vec<String>>>>,
/// Briscola: number of seats at the table (2 or 4).
#[serde(default)]
pub player_count: usize,
/// Briscola: trump suit of the current deal (empty for other games).
#[serde(default)]
pub briscola_suit: String,
/// Briscola: the face-up trump card, until it is drawn.
#[serde(default)]
pub briscola_card: Option<String>,
/// Briscola: face-down cards left in the stock.
#[serde(default)]
pub stock_count: usize,
/// Briscola: cards of the trick in progress, in play order.
#[serde(default)]
pub trick: Vec<TrickCard>,
/// Briscola: seat that led the current trick.
#[serde(default)]
pub trick_leader: Option<usize>,
/// Briscola: the most recently completed trick.
#[serde(default)]
pub last_trick: Option<LastTrick>,
/// Briscola: scoring breakdown of the most recent deal.
#[serde(default)]
pub last_deal: Option<DealSummary>,
}
#[derive(Debug, Deserialize)]
@@ -567,6 +626,22 @@ pub fn card_asset(code: &str) -> String {
format!("/assets/cards/{code}.svg")
}
/// Whether a game type is one of the briscola variants.
pub fn is_briscola(game_type: &str) -> bool {
game_type == "briscola" || game_type == "briscola_a_quattro"
}
/// Human-readable suit name for a one-letter code (`D` -> "denari").
pub fn suit_name(suit: &str) -> String {
match suit {
"D" => "denari".to_string(),
"C" => "coppe".to_string(),
"S" => "spade".to_string(),
"B" => "bastoni".to_string(),
_ => suit.to_string(),
}
}
pub const CARD_BACK: &str = "/assets/cards/back.svg";
/// Display order key for a hand: rank ascending (01..10, i.e. 1..7 then
@@ -1054,4 +1129,112 @@ mod tests {
assert!(g.default_options().is_empty());
assert_eq!(0, g.min_players);
}
#[test]
fn is_briscola_matches_both_variants() {
assert!(is_briscola("briscola"));
assert!(is_briscola("briscola_a_quattro"));
assert!(!is_briscola("scopone_scientifico"));
assert_eq!("denari", suit_name("D"));
assert_eq!("coppe", suit_name("C"));
assert_eq!("spade", suit_name("S"));
assert_eq!("bastoni", suit_name("B"));
assert_eq!("?", suit_name("?"));
}
#[test]
fn game_view_parses_briscola_payload() {
let g: GameView = serde_json::from_value(json!({
"id": "b1",
"join_code": "ABC123",
"game_type": "briscola",
"phase": "playing",
"target_score": 61,
"player_count": 2,
"hand_number": 1,
"dealer": 0,
"turn": 1,
"scores": {"A": 0, "B": 0},
"briscola_suit": "D",
"briscola_card": "07D",
"stock_count": 33,
"trick": [{"seat": 1, "card": "04C"}],
"trick_leader": 1,
"last_trick": null,
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
"cards_left": 3, "captured_count": 0, "points": 0,
"hand": ["01D"]},
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
"cards_left": 3, "captured_count": 0, "points": 0},
],
"last_deal": null,
"acknowledged": [],
"hand_end_deadline": null,
"turn_deadline": "2026-01-01T00:00:30+00:00",
"your_turn": true,
}))
.unwrap();
assert!(is_briscola(&g.game_type));
assert_eq!("D", g.briscola_suit);
assert_eq!(Some("07D".to_string()), g.briscola_card);
assert_eq!(33, g.stock_count);
assert_eq!(1, g.trick.len());
assert_eq!(1, g.trick[0].seat);
assert_eq!("04C", g.trick[0].card);
assert_eq!(Some(1), g.trick_leader);
assert_eq!(0, g.players[0].points);
assert_eq!(Some(true), g.your_turn);
}
#[test]
fn game_view_parses_briscola_deal_summary() {
let g: GameView = serde_json::from_value(json!({
"id": "b1",
"game_type": "briscola_a_quattro",
"phase": "hand_end",
"player_count": 4,
"scores": {"A": 55, "B": 65},
"last_deal": {
"deal": 1,
"patta": false,
"points": {"A": 55, "B": 65},
"totals": {"A": 55, "B": 65},
"cards": {"A": 19, "B": 21},
},
"last_trick": {
"winner": 3,
"cards": [
{"seat": 0, "card": "02D"},
{"seat": 1, "card": "05D"},
{"seat": 2, "card": "04D"},
{"seat": 3, "card": "07D"},
],
},
}))
.unwrap();
let deal = g.last_deal.unwrap();
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);
let cards = deal.cards.unwrap();
assert_eq!(19, cards.a);
assert_eq!(21, cards.b);
let trick = g.last_trick.unwrap();
assert_eq!(3, trick.winner);
assert_eq!(4, trick.cards.len());
}
#[test]
fn deal_summary_defaults() {
let d: DealSummary = serde_json::from_value(json!({
"points": {"A": 60, "B": 60},
"totals": {"A": 10, "B": 20},
}))
.unwrap();
assert!(!d.patta);
assert_eq!(0, d.deal);
assert!(d.cards.is_none());
}
}
+258
View File
@@ -0,0 +1,258 @@
//! 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, 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 = player.team.clone();
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)
span(class="team-badge") { "Team " (team) }
}
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;
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") {
"Team A " (scores.a) " — " (scores.b) " Team B (target "
(target) ")"
}
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)
}
}
}
}
+30 -12
View File
@@ -6,11 +6,16 @@ use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::briscola::{deal_rows, deal_summary_modal};
use crate::components::card::{card_back, card_img};
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, GameView, MoveView, PlayerView, Scores, ServerMessage};
use crate::model::{
card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores,
ServerMessage,
};
use crate::pages::briscola::briscola_table;
use crate::ws::{self, GameSocket};
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
@@ -20,7 +25,7 @@ fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<V
}
/// The absolute, shareable URL of this game's page, for the clipboard.
fn watch_link(id: &str) -> String {
pub(crate) fn watch_link(id: &str) -> String {
let origin = web_sys::window()
.and_then(|w| w.location().origin().ok())
.unwrap_or_default();
@@ -387,6 +392,10 @@ pub fn GamePage(id: String) -> View {
}
}
Some(g) if g.phase == "lobby" => view! { LobbyView(game=g) },
// Briscola has its own table view for both variants.
Some(g) if is_briscola(&g.game_type) => {
briscola_table(g, on_hand_card, now)
}
// The live table is scopone-specific; other games get a
// fallback until they ship their own play view.
Some(g) if g.game_type == "scopone_scientifico" => {
@@ -401,6 +410,9 @@ pub fn GamePage(id: String) -> View {
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
hand_summary_modal(g, socket, now)
}
Some(g) if g.phase == "hand_end" && is_briscola(&g.game_type) => {
deal_summary_modal(g, socket, now)
}
_ => view! {},
})
(move || game_over_view(over.get_clone(), game.get_clone()))
@@ -715,11 +727,11 @@ fn capture_picker(
}
}
/// End-of-match overlay (scopone's team score; other games render
/// nothing until they ship their own play view).
/// 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));
if let Some(g) = &game {
if g.game_type != "scopone_scientifico" {
if g.game_type != "scopone_scientifico" && !briscola {
return view! {};
}
}
@@ -735,13 +747,19 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
let line = format!("Team {winner} 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 scoring before the result.
let final_summary = game
.and_then(|g| g.last_hand)
.map(|s| {
let rows = summary_rows(s);
view! { (rows) }
});
// Explain the final hand's (scopone) or deal's (briscola)
// scoring before the result.
let final_summary: View = if briscola {
match game.as_ref().and_then(|g| g.last_deal.clone()) {
Some(deal) => deal_rows(&deal),
None => view! {},
}
} else {
match game.as_ref().and_then(|g| g.last_hand.clone()) {
Some(hand) => summary_rows(hand),
None => view! {},
}
};
let my_matches = (!spectator).then(|| view! {
a(class="btn btn-outline-light", href="/history") { "My matches" }
});
+27 -9
View File
@@ -20,14 +20,32 @@ use crate::model::{
/// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> {
vec![GameTypeInfo {
id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(),
description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
}]
vec![
GameTypeInfo {
id: "scopone_scientifico".to_string(),
name: "Scopone scientifico".to_string(),
description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola".to_string(),
name: "Briscola".to_string(),
description: String::new(),
min_players: 2,
max_players: 2,
options_schema: serde_json::Value::Null,
},
GameTypeInfo {
id: "briscola_a_quattro".to_string(),
name: "Briscola a quattro".to_string(),
description: String::new(),
min_players: 4,
max_players: 4,
options_schema: serde_json::Value::Null,
},
]
}
/// Human-readable label for a provider name ("google" -> "Google").
@@ -552,7 +570,7 @@ pub fn LobbyPage() -> View {
div(class="lobby-hero text-center") {
h1(class="display-5 fw-bold") { "tavolo" }
p(class="lead text-secondary mb-0") {
"Play scopone scientifico with friends."
"Play scopone scientifico and briscola with friends."
}
}
div(class="row g-4") {
+1
View File
@@ -1,3 +1,4 @@
pub mod briscola;
pub mod game;
pub mod history;
pub mod leaderboard;
+8 -2
View File
@@ -83,8 +83,14 @@ pub fn connect(
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => {
if let Ok(parsed) = serde_json::from_str::<ServerMessage>(&text) {
on_message(parsed);
match serde_json::from_str::<ServerMessage>(&text) {
Ok(parsed) => on_message(parsed),
// A silently dropped message would leave the page
// waiting forever; surface the parse error so it is
// visible in the browser console.
Err(err) => web_sys::console::error_1(
&format!("unparseable server message: {err}").into(),
),
}
}
Ok(Message::Bytes(_)) => {}
+67 -1
View File
@@ -1,4 +1,4 @@
/* Scopone scientifico frontend — game table, cards and scoring summary.
/* tavolo frontend — game tables, cards and scoring summaries.
*
* The felt palette and the Bootstrap theme overrides live in theme.css,
* which is loaded before this file. */
@@ -474,3 +474,69 @@ body {
font-size: 1.25rem;
font-weight: 700;
}
/* ---------- briscola ---------- */
.briscola-badge {
background: var(--panel-light);
color: var(--accent);
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
border: 1px solid var(--accent);
}
/* The face-down stock with the face-up trump card beside it. */
.stock {
display: flex;
align-items: center;
gap: 0.75rem;
}
.stock-pile {
display: flex;
}
.playing-card.stock-card {
height: 48px;
margin-left: -34px;
}
.playing-card.stock-card:first-child {
margin-left: 0;
}
.playing-card.briscola-card {
height: 64px;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.stock-count {
color: var(--muted);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
/* Current-trick cards with the player's name above each. */
.trick-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.trick-name {
font-size: 0.7rem;
color: var(--muted);
}
/* Two-player table: opponent on top, player at the bottom. */
.table-grid.duo {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
grid-template-areas:
"top"
"center"
"bottom";
}