Add briscola chiamata
Five-player briscola with a descending calling auction and a secret partner. Starting with the player after the dealer, each player bids a lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the standing bid is the two the auction continues on points (62, 64, ...). The last player standing must bid, and the winner names any 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 becomes the trump. The calling side must reach 61 (or the raised target); a 60-60 deal with the plain target is a patta and is replayed. A match is a single deal, so the caller and partner (or the lone caller) against the other players map onto the platform's two-team result, Elo and match history. The rules live in tavolo.briscola.chiamata with the BriscolaChiamataEngine adapter registered alongside the other briscola variants; the partner's role and the side totals stay hidden until the called card is played. The web client gets a five-seat table, the auction panel with legal bids and point raises, the call picker, the reveal bands and a chiamata-aware final summary.
This commit is contained in:
+310
-6
@@ -19,7 +19,10 @@ pub struct PlayerView {
|
||||
pub sub: String,
|
||||
pub name: String,
|
||||
pub seat: usize,
|
||||
pub team: String,
|
||||
/// Game-defined role/team label; empty before briscola chiamata
|
||||
/// reveals the roles.
|
||||
#[serde(default)]
|
||||
pub team: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cards_left: usize,
|
||||
#[serde(default)]
|
||||
@@ -84,9 +87,21 @@ pub struct DealSummary {
|
||||
#[serde(default)]
|
||||
pub patta: bool,
|
||||
pub points: Scores,
|
||||
pub totals: Scores,
|
||||
/// Running match totals; absent for briscola chiamata, whose match is
|
||||
/// a single deal.
|
||||
#[serde(default)]
|
||||
pub totals: Option<Scores>,
|
||||
#[serde(default)]
|
||||
pub cards: Option<TeamCounts>,
|
||||
/// Briscola chiamata: the points target of the played deal.
|
||||
#[serde(default)]
|
||||
pub declared_target: Option<i32>,
|
||||
/// Briscola chiamata: the card named by the caller.
|
||||
#[serde(default)]
|
||||
pub called_card: Option<String>,
|
||||
/// Briscola chiamata: the caller played without a partner.
|
||||
#[serde(default)]
|
||||
pub solo: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
@@ -227,6 +242,83 @@ pub struct GameView {
|
||||
/// Briscola: scoring breakdown of the most recent deal.
|
||||
#[serde(default)]
|
||||
pub last_deal: Option<DealSummary>,
|
||||
/// Briscola chiamata: the calling auction of the current deal.
|
||||
#[serde(default)]
|
||||
pub auction: Option<AuctionView>,
|
||||
/// Briscola chiamata: seat that won the auction.
|
||||
#[serde(default)]
|
||||
pub caller: Option<usize>,
|
||||
/// Briscola chiamata: the named card; its suit is the trump.
|
||||
#[serde(default)]
|
||||
pub called_card: Option<String>,
|
||||
/// Briscola chiamata: points the calling side must reach.
|
||||
#[serde(default)]
|
||||
pub declared_target: i32,
|
||||
/// Briscola chiamata: the called card has been played; roles are
|
||||
/// public from then on.
|
||||
#[serde(default)]
|
||||
pub called_played: bool,
|
||||
/// Briscola chiamata: the partner's seat, once revealed.
|
||||
#[serde(default)]
|
||||
pub partner: Option<usize>,
|
||||
/// Briscola chiamata: the caller named one of their own cards.
|
||||
#[serde(default)]
|
||||
pub solo: bool,
|
||||
/// Briscola chiamata: legal bids for the player on turn.
|
||||
#[serde(default)]
|
||||
pub legal_bids: Option<LegalBids>,
|
||||
/// Briscola chiamata: the cards the auction winner may name.
|
||||
#[serde(default)]
|
||||
pub callable_cards: Option<Vec<String>>,
|
||||
/// Briscola chiamata: ISO-8601 deadline of the auction turn.
|
||||
#[serde(default)]
|
||||
pub auction_deadline: Option<String>,
|
||||
/// Briscola chiamata: ISO-8601 deadline of the auction winner's call.
|
||||
#[serde(default)]
|
||||
pub call_deadline: Option<String>,
|
||||
}
|
||||
|
||||
/// One bid of the briscola chiamata auction.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AuctionBid {
|
||||
pub seat: usize,
|
||||
/// Rank bid: 1 ace, 3 three, 10 king, ... 2 two.
|
||||
pub rank: i32,
|
||||
/// Points target the bid commits its side to.
|
||||
#[serde(default)]
|
||||
pub points: i32,
|
||||
}
|
||||
|
||||
/// The briscola chiamata calling auction.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct AuctionView {
|
||||
/// Seat to act.
|
||||
pub turn: usize,
|
||||
#[serde(default)]
|
||||
pub active: Vec<usize>,
|
||||
#[serde(default)]
|
||||
pub passed: Vec<usize>,
|
||||
/// The standing bid, if any.
|
||||
#[serde(default)]
|
||||
pub current: Option<AuctionBid>,
|
||||
#[serde(default)]
|
||||
pub history: Vec<AuctionBid>,
|
||||
}
|
||||
|
||||
/// The bids available to the player on turn.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct LegalBids {
|
||||
/// Ranks that may be bid, strongest first (empty at the point stage).
|
||||
#[serde(default)]
|
||||
pub ranks: Vec<i32>,
|
||||
/// Lowest legal points raise, when the standing bid is the two.
|
||||
#[serde(default)]
|
||||
pub points_min: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub points_max: Option<i32>,
|
||||
}
|
||||
|
||||
impl GameView {
|
||||
@@ -235,10 +327,38 @@ impl GameView {
|
||||
pub fn player_name_for_team(&self, team: &str) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.find(|p| p.team == team)
|
||||
.find(|p| p.team.as_deref() == Some(team))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| team.to_string())
|
||||
}
|
||||
|
||||
/// Display name of the player in `seat`.
|
||||
pub fn player_name_for_seat(&self, seat: usize) -> String {
|
||||
self.players
|
||||
.iter()
|
||||
.find(|p| p.seat == seat)
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| format!("Seat {seat}"))
|
||||
}
|
||||
|
||||
/// Side labels for briscola chiamata: the caller (with the partner's
|
||||
/// name once revealed, or "(solo)") against the defenders.
|
||||
pub fn chiamata_sides(&self) -> (String, String) {
|
||||
let side_a = match self.caller {
|
||||
Some(caller) => {
|
||||
let caller_name = self.player_name_for_seat(caller);
|
||||
match self.partner {
|
||||
Some(partner) => {
|
||||
format!("{caller_name} & {}", self.player_name_for_seat(partner))
|
||||
}
|
||||
None if self.called_played => format!("{caller_name} (solo)"),
|
||||
None => caller_name,
|
||||
}
|
||||
}
|
||||
None => "Chiamante".to_string(),
|
||||
};
|
||||
(side_a, "Difensori".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -449,6 +569,14 @@ pub fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||
max_players: 4,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
GameTypeInfo {
|
||||
id: "briscola_chiamata".to_string(),
|
||||
name: "Briscola chiamata".to_string(),
|
||||
description: String::new(),
|
||||
min_players: 5,
|
||||
max_players: 5,
|
||||
options_schema: serde_json::Value::Null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -735,6 +863,30 @@ pub fn is_briscola(game_type: &str) -> bool {
|
||||
game_type == "briscola" || game_type == "briscola_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 {
|
||||
game_type == "briscola_chiamata"
|
||||
}
|
||||
|
||||
/// The Italian rank name used by the auction UI: `1` -> "Asso", `3` ->
|
||||
/// "Tre", `10` -> "Re", and so on.
|
||||
pub fn rank_name(rank: i32) -> String {
|
||||
match rank {
|
||||
1 => "Asso".to_string(),
|
||||
2 => "Due".to_string(),
|
||||
3 => "Tre".to_string(),
|
||||
4 => "Quattro".to_string(),
|
||||
5 => "Cinque".to_string(),
|
||||
6 => "Sei".to_string(),
|
||||
7 => "Sette".to_string(),
|
||||
8 => "Fante".to_string(),
|
||||
9 => "Cavallo".to_string(),
|
||||
10 => "Re".to_string(),
|
||||
other => other.to_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.
|
||||
@@ -1033,7 +1185,7 @@ mod tests {
|
||||
assert_eq!(16, g.target_score);
|
||||
assert!(!g.napola);
|
||||
assert!(g.asso_pigliatutto);
|
||||
assert_eq!("A", g.players[0].team);
|
||||
assert_eq!(Some("A".to_string()), g.players[0].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1245,7 +1397,10 @@ mod tests {
|
||||
fn is_briscola_matches_both_variants() {
|
||||
assert!(is_briscola("briscola"));
|
||||
assert!(is_briscola("briscola_a_quattro"));
|
||||
assert!(!is_briscola("briscola_chiamata"));
|
||||
assert!(!is_briscola("scopone_scientifico"));
|
||||
assert!(is_chiamata("briscola_chiamata"));
|
||||
assert!(!is_chiamata("briscola"));
|
||||
assert_eq!("denari", suit_name("D"));
|
||||
assert_eq!("coppe", suit_name("C"));
|
||||
assert_eq!("spade", suit_name("S"));
|
||||
@@ -1253,6 +1408,16 @@ mod tests {
|
||||
assert_eq!("?", suit_name("?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rank_names_cover_the_ladder() {
|
||||
assert_eq!("Asso", rank_name(1));
|
||||
assert_eq!("Due", rank_name(2));
|
||||
assert_eq!("Tre", rank_name(3));
|
||||
assert_eq!("Fante", rank_name(8));
|
||||
assert_eq!("Cavallo", rank_name(9));
|
||||
assert_eq!("Re", rank_name(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_individual_game_only_matches_two_player_briscola() {
|
||||
assert!(is_individual_game("briscola"));
|
||||
@@ -1318,11 +1483,17 @@ mod tests {
|
||||
let types = fallback_game_types();
|
||||
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||
vec![
|
||||
"scopone_scientifico",
|
||||
"briscola",
|
||||
"briscola_a_quattro",
|
||||
"briscola_chiamata",
|
||||
],
|
||||
ids
|
||||
);
|
||||
// The lobby's first game (the creation default) must exist.
|
||||
assert_eq!("scopone_scientifico", types[0].id);
|
||||
assert_eq!(5, types[3].max_players);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1400,7 +1571,7 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(Some(Scores { a: 55, b: 65 }), deal.totals);
|
||||
let cards = deal.cards.unwrap();
|
||||
assert_eq!(19, cards.a);
|
||||
assert_eq!(21, cards.b);
|
||||
@@ -1420,4 +1591,137 @@ mod tests {
|
||||
assert_eq!(0, d.deal);
|
||||
assert!(d.cards.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chiamata_deal_summary_has_no_totals() {
|
||||
let d: DealSummary = serde_json::from_value(json!({
|
||||
"deal": 2,
|
||||
"patta": true,
|
||||
"points": {"A": 60, "B": 60},
|
||||
"declared_target": 61,
|
||||
"called_card": "01D",
|
||||
"caller": "Alice",
|
||||
"solo": false,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(d.patta);
|
||||
assert_eq!(2, d.deal);
|
||||
assert!(d.totals.is_none());
|
||||
assert!(d.cards.is_none());
|
||||
assert_eq!(Some(61), d.declared_target);
|
||||
assert_eq!(Some("01D".to_string()), d.called_card);
|
||||
assert!(!d.solo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_chiamata_auction() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c1",
|
||||
"join_code": "ABC123",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "auction",
|
||||
"player_count": 5,
|
||||
"dealer": 0,
|
||||
"hand_number": 1,
|
||||
"turn": 1,
|
||||
"declared_target": 61,
|
||||
"auction": {
|
||||
"turn": 2,
|
||||
"active": [0, 1, 2, 3, 4],
|
||||
"passed": [],
|
||||
"current": {"seat": 1, "rank": 10, "points": 61},
|
||||
"history": [{"seat": 1, "rank": 10, "points": 61}],
|
||||
},
|
||||
"your_turn": true,
|
||||
"legal_bids": {"ranks": [9, 8, 7, 6, 5, 4, 2], "points_min": null, "points_max": 120},
|
||||
"players": [
|
||||
{"sub": "alice", "name": "Alice", "seat": 0, "team": "",
|
||||
"cards_left": 8, "captured_count": 0, "points": 0, "hand": ["01D"]},
|
||||
{"sub": "bob", "name": "Bob", "seat": 1, "team": "Chiamante",
|
||||
"cards_left": 8, "captured_count": 0, "points": 0},
|
||||
],
|
||||
"auction_deadline": "2026-01-01T00:00:30+00:00",
|
||||
"call_deadline": null,
|
||||
"turn_deadline": null,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(is_chiamata(&g.game_type));
|
||||
assert!(!is_briscola(&g.game_type));
|
||||
let auction = g.auction.unwrap();
|
||||
assert_eq!(2, auction.turn);
|
||||
assert_eq!(5, auction.active.len());
|
||||
let current = auction.current.unwrap();
|
||||
assert_eq!(1, current.seat);
|
||||
assert_eq!(10, current.rank);
|
||||
assert_eq!(61, current.points);
|
||||
assert_eq!(1, auction.history.len());
|
||||
let bids = g.legal_bids.unwrap();
|
||||
assert_eq!(vec![9, 8, 7, 6, 5, 4, 2], bids.ranks);
|
||||
assert!(bids.points_min.is_none());
|
||||
assert_eq!(Some(120), bids.points_max);
|
||||
assert_eq!(Some("".to_string()), g.players[0].team);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.players[1].team);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_chiamata_playing_and_reveal() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c2",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "playing",
|
||||
"player_count": 5,
|
||||
"dealer": 0,
|
||||
"hand_number": 1,
|
||||
"turn": 3,
|
||||
"caller": 1,
|
||||
"called_card": "03C",
|
||||
"briscola_suit": "C",
|
||||
"declared_target": 64,
|
||||
"called_played": true,
|
||||
"partner": 3,
|
||||
"solo": false,
|
||||
"scores": {"A": 45, "B": 30},
|
||||
"players": [
|
||||
{"sub": "a", "name": "A", "seat": 0, "team": "Difensori"},
|
||||
{"sub": "b", "name": "B", "seat": 1, "team": "Chiamante"},
|
||||
{"sub": "c", "name": "C", "seat": 2, "team": "Difensori"},
|
||||
{"sub": "d", "name": "D", "seat": 3, "team": "Socio"},
|
||||
{"sub": "e", "name": "E", "seat": 4, "team": "Difensori"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(Some(1), g.caller);
|
||||
assert_eq!(Some("03C".to_string()), g.called_card);
|
||||
assert_eq!("C", g.briscola_suit);
|
||||
assert_eq!(64, g.declared_target);
|
||||
assert!(g.called_played);
|
||||
assert_eq!(Some(3), g.partner);
|
||||
assert!(!g.solo);
|
||||
assert_eq!(Some(Scores { a: 45, b: 30 }), g.scores);
|
||||
assert_eq!(("B & D".to_string(), "Difensori".to_string()), g.chiamata_sides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chiamata_sides_label_the_solo_caller() {
|
||||
let g: GameView = serde_json::from_value(json!({
|
||||
"id": "c3",
|
||||
"game_type": "briscola_chiamata",
|
||||
"phase": "finished",
|
||||
"scores": {"A": 70, "B": 50},
|
||||
"winner": "Chiamante",
|
||||
"caller": 2,
|
||||
"called_played": true,
|
||||
"solo": true,
|
||||
"players": [
|
||||
{"sub": "a", "name": "Alice", "seat": 0, "team": "Difensori"},
|
||||
{"sub": "b", "name": "Bob", "seat": 1, "team": "Difensori"},
|
||||
{"sub": "c", "name": "Carol", "seat": 2, "team": "Chiamante"},
|
||||
],
|
||||
}))
|
||||
.unwrap();
|
||||
let (a, b) = g.chiamata_sides();
|
||||
assert_eq!("Carol (solo)", a);
|
||||
assert_eq!("Difensori", b);
|
||||
assert_eq!(Some("Chiamante".to_string()), g.winner);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user