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:
+128
-5
@@ -193,6 +193,9 @@ pub struct GameView {
|
||||
pub winner: Option<String>,
|
||||
#[serde(default)]
|
||||
pub table: Vec<String>,
|
||||
/// Scopa: face-down cards left in the deck for the current hand.
|
||||
#[serde(default)]
|
||||
pub deck_count: usize,
|
||||
#[serde(default)]
|
||||
pub players: Vec<PlayerView>,
|
||||
#[serde(default)]
|
||||
@@ -553,6 +556,22 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "scopa".to_string(),
|
||||
name: "Scopa".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 2,
|
||||
max_players: 2,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "scopa_a_quattro".to_string(),
|
||||
name: "Scopa a quattro".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(),
|
||||
@@ -863,6 +882,12 @@ pub fn is_briscola(game_type: &str) -> bool {
|
||||
game_type == "briscola" || game_type == "briscola_a_quattro"
|
||||
}
|
||||
|
||||
/// Whether a game type is one of the scopa variants (classic two-player
|
||||
/// scopa and four-player partnership scopa a quattro).
|
||||
pub fn is_scopa(game_type: &str) -> bool {
|
||||
game_type == "scopa" || game_type == "scopa_a_quattro"
|
||||
}
|
||||
|
||||
/// Whether a game type is five-player briscola chiamata, which has its
|
||||
/// own table view (auction, secret partner, five seats).
|
||||
pub fn is_chiamata(game_type: &str) -> bool {
|
||||
@@ -888,10 +913,10 @@ pub fn rank_name(rank: i32) -> String {
|
||||
}
|
||||
|
||||
/// Whether a game is played by two individuals rather than partnerships
|
||||
/// (classic two-player briscola): team labels and team messages are
|
||||
/// replaced by the players' names.
|
||||
/// (classic two-player briscola, classic two-player scopa): team labels
|
||||
/// and team messages are replaced by the players' names.
|
||||
pub fn is_individual_game(game_type: &str) -> bool {
|
||||
game_type == "briscola"
|
||||
game_type == "briscola" || game_type == "scopa"
|
||||
}
|
||||
|
||||
/// Human-readable suit name for a one-letter code (`D` -> "denari").
|
||||
@@ -1408,6 +1433,14 @@ mod tests {
|
||||
assert_eq!("?", suit_name("?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_scopa_matches_both_variants() {
|
||||
assert!(is_scopa("scopa"));
|
||||
assert!(is_scopa("scopa_a_quattro"));
|
||||
assert!(!is_scopa("scopone_scientifico"));
|
||||
assert!(!is_scopa("briscola"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_names_cover_the_ladder() {
|
||||
assert_eq!("Asso", rank_name(1));
|
||||
@@ -1419,9 +1452,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_individual_game_only_matches_two_player_briscola() {
|
||||
fn is_individual_game_matches_two_player_games() {
|
||||
assert!(is_individual_game("briscola"));
|
||||
assert!(is_individual_game("scopa"));
|
||||
assert!(!is_individual_game("briscola_a_quattro"));
|
||||
assert!(!is_individual_game("scopa_a_quattro"));
|
||||
assert!(!is_individual_game("scopone_scientifico"));
|
||||
}
|
||||
|
||||
@@ -1485,6 +1520,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
vec![
|
||||
"scopone_scientifico",
|
||||
"scopa",
|
||||
"scopa_a_quattro",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
@@ -1493,7 +1530,7 @@ mod tests {
|
||||
);
|
||||
// The lobby's first game (the creation default) must exist.
|
||||
assert_eq!("scopone_scientifico", types[0].id);
|
||||
assert_eq!(5, types[3].max_players);
|
||||
assert_eq!(5, types[5].max_players);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1724,4 +1761,90 @@ mod tests {
|
||||
assert_eq!("Difensori", b);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.winner);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_scopa_payload() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "s1",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "scopa",
|
||||
"phase": "playing",
|
||||
"target_score": 11,
|
||||
"asso_pigliatutto": true,
|
||||
"player_count": 2,
|
||||
"hand_number": 1,
|
||||
"dealer": 0,
|
||||
"turn": 1,
|
||||
"deck_count": 24,
|
||||
"scores": {"A": 0, "B": 0},
|
||||
"table": ["05C", "02D", "03S", "10B"],
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
|
||||
"cards_left": 3, "captured_count": 0, "scope": 0},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B",
|
||||
"cards_left": 3, "captured_count": 0, "scope": 0,
|
||||
"hand": ["01D", "06C", "09S"]},
|
||||
],
|
||||
"last_hand": null,
|
||||
"last_move": {"seat": 0, "name": "Alice", "card": "04D",
|
||||
"captured": ["01C", "03C"], "scopa": true,
|
||||
"pigliatutto": false},
|
||||
"acknowledged": [],
|
||||
"hand_end_deadline": null,
|
||||
"turn_deadline": "2026-01-01T00:00:30+00:00",
|
||||
"your_turn": true,
|
||||
"legal_moves": {"01D": [["01C"]]},
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(is_scopa(&g.game_type));
|
||||
assert!(is_individual_game(&g.game_type));
|
||||
assert!(g.asso_pigliatutto);
|
||||
assert_eq!(2, g.player_count);
|
||||
assert_eq!(24, g.deck_count);
|
||||
assert_eq!(4, g.table.len());
|
||||
assert_eq!(Some(vec!["01D".to_string(), "06C".to_string(), "09S".to_string()]),
|
||||
g.players[1].hand.clone());
|
||||
let mv = g.last_move.unwrap();
|
||||
assert!(mv.scopa);
|
||||
assert_eq!(2, mv.captured.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_scopa_a_quattro_hand_summary() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "s2",
|
||||
"game_type": "scopa_a_quattro",
|
||||
"phase": "hand_end",
|
||||
"player_count": 4,
|
||||
"scores": {"A": 3, "B": 0},
|
||||
"players": [
|
||||
{"sub": "a", "name": "Alice", "seat": 0, "team": "A"},
|
||||
{"sub": "b", "name": "Bob", "seat": 1, "team": "B"},
|
||||
{"sub": "c", "name": "Carol", "seat": 2, "team": "A"},
|
||||
{"sub": "d", "name": "Dave", "seat": 3, "team": "B"},
|
||||
],
|
||||
"last_hand": {
|
||||
"cards": {"A": 21, "B": 19},
|
||||
"denara": {"A": 6, "B": 4},
|
||||
"settebello": {"A": true, "B": false},
|
||||
"primiera": {"A": 70, "B": 60},
|
||||
"scope": {"A": 1, "B": 0},
|
||||
"award": {
|
||||
"carte": "A", "denara": "A", "settebello": "A",
|
||||
"primiera": "A",
|
||||
},
|
||||
"hand": 1,
|
||||
"team_a_points": 3,
|
||||
"team_b_points": 0,
|
||||
},
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(is_scopa(&g.game_type));
|
||||
assert!(!is_individual_game(&g.game_type));
|
||||
let hand = g.last_hand.unwrap();
|
||||
assert_eq!(21, hand.cards.a);
|
||||
assert_eq!(Some("A".to_string()), hand.award.carte);
|
||||
assert!(hand.napola.is_none());
|
||||
assert_eq!(3, hand.team_a_points);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user