Add briscola: classic two-player and briscola a quattro
CI / Build and push docker image (push) Successful in 1m45s
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:
@@ -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
@@ -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
@@ -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,3 +1,4 @@
|
||||
pub mod briscola;
|
||||
pub mod game;
|
||||
pub mod history;
|
||||
pub mod leaderboard;
|
||||
|
||||
Reference in New Issue
Block a user