Add optional asso pigliatutto rule with a lobby toggle
CI / Build and push docker image (push) Successful in 3m16s

An ace played when no equal rank is on the table sweeps the whole
table; the sweep never scores a scopa, and an ace already on the table
still forces the mandatory equal-rank capture. The rule is off by
default, selectable per match via the schema-driven creation form, and
announced in the game view.
This commit is contained in:
2026-09-23 23:22:37 +08:00
parent abc55479e4
commit 8f0e974842
12 changed files with 291 additions and 16 deletions
+36 -2
View File
@@ -49,6 +49,9 @@ pub struct MoveView {
pub captured: Vec<String>,
#[serde(default)]
pub scopa: bool,
/// The play was an asso pigliatutto sweep (it took the whole table).
#[serde(default)]
pub pigliatutto: bool,
}
#[derive(Debug, Clone, Copy, Deserialize)]
@@ -117,6 +120,9 @@ pub struct GameView {
/// Whether the napola rule is scored in this match.
#[serde(default = "default_true")]
pub napola: bool,
/// Whether the asso pigliatutto rule is scored in this match.
#[serde(default)]
pub asso_pigliatutto: bool,
pub phase: String,
#[serde(default)]
pub target_score: i32,
@@ -563,6 +569,7 @@ mod tests {
"default": 11,
},
"napola": {"type": "boolean", "default": true},
"asso_pigliatutto": {"type": "boolean", "default": false},
},
},
}))
@@ -572,7 +579,7 @@ mod tests {
#[test]
fn option_fields_parse_scopone_schema() {
let fields = scopone_game_type().option_fields();
assert_eq!(2, fields.len());
assert_eq!(3, fields.len());
let by_key: std::collections::HashMap<_, _> =
fields.into_iter().map(|f| (f.key.clone(), f)).collect();
let target = &by_key["target_score"];
@@ -583,6 +590,8 @@ mod tests {
assert_eq!(json!(11), target.default);
assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
assert_eq!(json!(true), by_key["napola"].default);
assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind);
assert_eq!(json!(false), by_key["asso_pigliatutto"].default);
}
#[test]
@@ -614,13 +623,14 @@ mod tests {
let opts = scopone_game_type().default_options();
assert_eq!(Some(&json!(11)), opts.get("target_score"));
assert_eq!(Some(&json!(true)), opts.get("napola"));
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
}
#[test]
fn game_view_parses_new_lobby_payload() {
// Exact shape of the platform's lobby payload: envelope fields,
// seats with team labels, seats_open, plus the engine's
// lobby_view (phase/target_score/napola for scopone).
// lobby_view (phase/target_score/napola/asso_pigliatutto).
let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555",
"join_code": "ABC123",
@@ -632,6 +642,7 @@ mod tests {
"phase": "lobby",
"target_score": 16,
"napola": false,
"asso_pigliatutto": true,
}))
.unwrap();
assert_eq!("ABC123", g.join_code);
@@ -639,6 +650,7 @@ mod tests {
assert_eq!("lobby", g.phase);
assert_eq!(16, g.target_score);
assert!(!g.napola);
assert!(g.asso_pigliatutto);
assert_eq!("A", g.players[0].team);
}
@@ -652,6 +664,7 @@ mod tests {
"phase": "playing",
"target_score": 11,
"napola": true,
"asso_pigliatutto": true,
"hand_number": 1,
"dealer": 0,
"turn": 1,
@@ -675,11 +688,32 @@ mod tests {
}))
.unwrap();
assert_eq!("playing", g.phase);
assert!(g.asso_pigliatutto);
assert_eq!(None, g.players[0].hand);
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
assert_eq!(Some(true), g.your_turn);
}
#[test]
fn move_view_parses_pigliatutto() {
let mv: MoveView = serde_json::from_value(json!({
"seat": 0,
"name": "Alice",
"card": "01D",
"captured": ["05C", "02D"],
"scopa": false,
"pigliatutto": true,
}))
.unwrap();
assert!(mv.pigliatutto);
// Older payloads without the flag default to a normal capture.
let legacy: MoveView = serde_json::from_value(json!({
"seat": 0, "name": "Alice", "card": "01D",
}))
.unwrap();
assert!(!legacy.pigliatutto);
}
#[test]
fn game_type_without_schema_has_no_fields() {
let g: GameTypeInfo = serde_json::from_value(json!({
+8
View File
@@ -63,6 +63,9 @@ fn move_banner(mv: MoveView) -> View {
.map(|c| card_img(c.clone(), "move-card"))
.collect::<Vec<_>>();
let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } });
let pigliatutto = mv
.pigliatutto
.then(|| view! { span(class="pigliatutto-badge") { "Asso pigliatutto!" } });
view! {
div(class="move-banner") {
strong { (name) }
@@ -70,6 +73,7 @@ fn move_banner(mv: MoveView) -> View {
(played)
(captured)
(scopa)
(pigliatutto)
}
}
}
@@ -565,6 +569,9 @@ fn table_view(
let banner = game.last_move.clone().map(move_banner);
let hand_number = game.hand_number;
let target_score = game.target_score;
let rule_badge = game.asso_pigliatutto.then(|| view! {
span(class="pigliatutto-badge") { "Asso pigliatutto" }
});
let viewer = player_for_seat(&game, viewer_seat);
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
@@ -602,6 +609,7 @@ fn table_view(
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Hand " (hand_number) }
(rule_badge)
span(class="hud-scores") {
"Team A " (scores.a) " — " (scores.b) " Team B (target "
(target_score) ")"
+9
View File
@@ -257,6 +257,15 @@ body {
margin-left: 0.25rem;
}
.pigliatutto-badge {
background: var(--danger, #d9534f);
color: #fff;
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
margin-left: 0.25rem;
}
/* ---------- overlays ---------- */
.overlay {