Add scopa: classic two-player and scopa a quattro
New tavolo-scopa package with a pure rules engine and the platform adapters for two-player scopa and four-player fixed-partnership scopa a quattro: three cards each and four on the table, capture by rank or sum, sweep for scope and score carte/denara/settebello/primiera. The optional asso pigliatutto creation option turns either variant into scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing scopone's rule semantics. Wire both engines into the app and the Docker install, add the scopa table view to the SPA (duo and four-seat layouts, deck count, captured/scope stats, hand-summary and final screens, player names in the two-player summary), and cover the rules, the platform contract, the REST/WebSocket flows, match persistence and Elo.
This commit is contained in:
+21
-10
@@ -12,11 +12,12 @@ 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, is_briscola, is_chiamata, is_individual_game, GameView, MoveView,
|
||||
PlayerView, Scores, ServerMessage,
|
||||
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, is_scopa, GameView,
|
||||
MoveView, PlayerView, Scores, ServerMessage,
|
||||
};
|
||||
use crate::pages::briscola::briscola_table;
|
||||
use crate::pages::chiamata::{call_picker, chiamata_table};
|
||||
use crate::pages::scopa::scopa_table;
|
||||
use crate::ws::{self, GameSocket};
|
||||
|
||||
fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<Vec<String>>) {
|
||||
@@ -65,7 +66,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
}
|
||||
|
||||
/// The last-move announcement strip.
|
||||
fn move_banner(mv: MoveView) -> View {
|
||||
pub(crate) fn move_banner(mv: MoveView) -> View {
|
||||
let name = mv.name.clone();
|
||||
let action = if mv.captured.is_empty() {
|
||||
"played"
|
||||
@@ -428,6 +429,10 @@ pub fn GamePage(id: String) -> View {
|
||||
Some(g) if is_briscola(&g.game_type) => {
|
||||
briscola_table(g, on_hand_card, now)
|
||||
}
|
||||
// Scopa has its own table view for both variants.
|
||||
Some(g) if is_scopa(&g.game_type) => {
|
||||
scopa_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" => {
|
||||
@@ -450,7 +455,11 @@ pub fn GamePage(id: String) -> View {
|
||||
_ => view! {},
|
||||
})
|
||||
(move || match game.get_clone() {
|
||||
Some(g) if g.phase == "hand_end" && g.game_type == "scopone_scientifico" => {
|
||||
Some(g)
|
||||
if g.phase == "hand_end"
|
||||
&& (g.game_type == "scopone_scientifico"
|
||||
|| is_scopa(&g.game_type)) =>
|
||||
{
|
||||
hand_summary_modal(g, socket, now)
|
||||
}
|
||||
Some(g) if g.phase == "hand_end" && is_briscola(&g.game_type) => {
|
||||
@@ -779,8 +788,9 @@ fn capture_picker(
|
||||
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));
|
||||
let chiamata = game.as_ref().is_some_and(|g| is_chiamata(&g.game_type));
|
||||
let scopa = game.as_ref().is_some_and(|g| is_scopa(&g.game_type));
|
||||
if let Some(g) = &game {
|
||||
if g.game_type != "scopone_scientifico" && !briscola && !chiamata {
|
||||
if g.game_type != "scopone_scientifico" && !briscola && !chiamata && !scopa {
|
||||
return view! {};
|
||||
}
|
||||
}
|
||||
@@ -793,7 +803,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
None => view! {},
|
||||
Some((scores, winner)) => {
|
||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||
// Two-player briscola has no teams: name the winner instead.
|
||||
// Games without partnerships (two-player briscola and scopa)
|
||||
// name the winner instead.
|
||||
// Briscola chiamata names the caller's side (or the defenders).
|
||||
let winner_label = match game.as_ref() {
|
||||
Some(g) if is_individual_game(&g.game_type) => {
|
||||
@@ -805,8 +816,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
let line = format!("{winner_label} 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 (scopone) or deal's (briscola,
|
||||
// briscola chiamata) scoring before the result.
|
||||
// Explain the final hand's (scopone, scopa) or deal's
|
||||
// (briscola, briscola chiamata) scoring before the result.
|
||||
let final_summary: View = if briscola || chiamata {
|
||||
match game.as_ref() {
|
||||
Some(g) => match g.last_deal.clone() {
|
||||
@@ -816,8 +827,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
||||
None => view! {},
|
||||
}
|
||||
} else {
|
||||
match game.as_ref().and_then(|g| g.last_hand.clone()) {
|
||||
Some(hand) => summary_rows(hand),
|
||||
match game.as_ref().and_then(|g| g.last_hand.clone().map(|h| (g.clone(), h))) {
|
||||
Some((g, hand)) => summary_rows(&g, hand),
|
||||
None => view! {},
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user