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
+2 -1
View File
@@ -38,7 +38,8 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: every player sees how each category was won
(carte, denara, settebello, primiera, scope — plus napola when enabled)
(carte, denara, settebello, primiera, scope — plus napola and asso
pigliatutto when enabled)
with the running totals and
must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
+6 -1
View File
@@ -189,7 +189,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
@@ -270,6 +270,11 @@ must dismiss. A play attempted in this phase is rejected with an
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
A team that captures the whole denari suit (ace to king) wins the match
instantly, regardless of the score.
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
option, default off): an ace played when no equal rank is on the table
sweeps every card on the table. The sweep never scores a scopa, and the
mandatory equal-rank capture still applies when another ace is on the
table.
- The match ends when a team reaches the target score (default 11,
configurable per game via the `target_score` creation option) with a
clear lead; a tie at or above the target is broken by another hand.
+7 -5
View File
@@ -11,15 +11,17 @@ implementation.
(de)serialization. No session envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, legal captures, plays,
auto-play, hand scoring (carte, denara, settebello, primiera, scope,
napola), hand-end acknowledgements. Deterministic and I/O-free apart
from logging, so the whole rule set is unit-testable.
napola), the optional asso pigliatutto sweep, hand-end
acknowledgements. Deterministic and I/O-free apart from logging, so the
whole rule set is unit-testable.
- `errors.py` — scopone-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
It translates create/join/websocket actions/deadlines into rules-engine
calls and back, validates the `target_score`/`napola` creation options,
and extracts the `MatchResult` (teams, winner, per-player scores and the
match summary persisted as the match's JSON `result`).
calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
creation options, and extracts the `MatchResult` (teams, winner,
per-player scores and the match summary persisted as the match's JSON
`result`).
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
@@ -28,6 +28,10 @@ Rules implemented
consecutive denari starting from the ace scores one point per card when
it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
capturing the whole denari suit (ace to king) wins the match instantly.
* Optional ``asso_pigliatutto`` rule (disabled by default): an ace played
when no equal rank is on the table sweeps the whole table. The sweep
never scores a scopa, even when cards remain in hand. When another ace
is on the table the mandatory equal-rank capture still applies.
* The match ends when a team reaches the target score with a clear lead; a
tie at or above the target is broken by playing another hand.
"""
@@ -110,17 +114,22 @@ def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
return deck
def legal_captures(table: Sequence[Card], card: Card) -> List[List[Card]]:
def legal_captures(
table: Sequence[Card], card: Card, *, asso_pigliatutto: bool = False
) -> List[List[Card]]:
"""Return every legal capture (a list of card sets) for ``card``.
If an equal-ranked card is on the table, only those single-card
captures are returned (the rule forbids taking a combination instead).
Otherwise every subset of the table whose ranks sum to ``card.rank`` is
returned.
Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
the only capture is the whole table; without the option every subset of
the table whose ranks sum to ``card.rank`` is returned.
"""
equal = [c for c in table if c.rank == card.rank]
if equal:
return [[c] for c in equal]
if asso_pigliatutto and card.rank == 1:
return [list(table)] if table else []
candidates = [c for c in table if c.rank <= card.rank]
captures: List[List[Card]] = []
@@ -140,6 +149,7 @@ def create_game(
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
napola: bool = True,
asso_pigliatutto: bool = False,
) -> ScoponeState:
"""Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100:
@@ -147,6 +157,7 @@ def create_game(
return ScoponeState(
target_score=target_score,
napola=napola,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout,
@@ -240,7 +251,18 @@ def play(
player.hand.remove(card)
requested = [parse_card(c) for c in (capture_codes or [])]
options = legal_captures(state.table, card)
table_before = list(state.table)
options = legal_captures(
table_before, card, asso_pigliatutto=state.asso_pigliatutto
)
# An asso pigliatutto sweep: the ace takes the whole table because no
# equal rank was on it. Such a sweep never scores a scopa.
pigliatutto = (
state.asso_pigliatutto
and card.rank == 1
and bool(table_before)
and all(c.rank != card.rank for c in table_before)
)
taken: List[Card] = []
scopa = False
@@ -260,7 +282,7 @@ def play(
state.last_taker = player.seat
# A scopa scores only if cards remain to be played this hand.
hands_empty = all(not p.hand for p in state.players)
if not state.table and not hands_empty:
if not state.table and not hands_empty and not pigliatutto:
player.scope += 1
scopa = True
@@ -270,6 +292,7 @@ def play(
card=card.code,
captured=[c.code for c in taken],
scopa=scopa,
pigliatutto=pigliatutto,
)
if all(not p.hand for p in state.players):
@@ -308,7 +331,9 @@ def auto_play(state: ScoponeState, rng: Optional[random.Random] = None) -> None:
raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng
card = chooser.choice(player.hand)
options = legal_captures(state.table, card)
options = legal_captures(
state.table, card, asso_pigliatutto=state.asso_pigliatutto
)
capture = chooser.choice(options) if options else None
play(
state,
@@ -526,6 +551,7 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
"hand_number": state.hand_number,
"dealer": state.dealer,
"turn": state.turn,
@@ -545,7 +571,9 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
# rule logic stays server-side.
legal_moves: Dict[str, List[List[str]]] = {}
for hand_card in viewer.hand:
options = legal_captures(state.table, hand_card)
options = legal_captures(
state.table, hand_card, asso_pigliatutto=state.asso_pigliatutto
)
if options:
legal_moves[hand_card.code] = [
[c.code for c in option] for option in options
@@ -75,6 +75,12 @@ class ScoponeEngine(GameEngine):
"description": "Score the napola rule; a full denari "
"sweep wins the match",
},
"asso_pigliatutto": {
"type": "boolean",
"default": False,
"description": "Score the asso pigliatutto rule; an ace "
"sweeps the whole table (no scopa)",
},
},
}
@@ -95,6 +101,9 @@ class ScoponeEngine(GameEngine):
napola = options.get("napola", True)
if not isinstance(napola, bool):
raise IllegalMove("napola must be a boolean")
asso_pigliatutto = options.get("asso_pigliatutto", False)
if not isinstance(asso_pigliatutto, bool):
raise IllegalMove("asso_pigliatutto must be a boolean")
creator = session.players[0]
session.state = engine.create_game(
creator_sub=creator.user_sub,
@@ -103,6 +112,7 @@ class ScoponeEngine(GameEngine):
hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout,
napola=napola,
asso_pigliatutto=asso_pigliatutto,
)
# The creator takes seat 0, i.e. team A.
session.players[0] = Seat(
@@ -133,6 +143,7 @@ class ScoponeEngine(GameEngine):
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
}
# -- play -------------------------------------------------------------
@@ -92,6 +92,8 @@ class Move:
card: str
captured: List[str] = field(default_factory=list)
scopa: bool = False
# The play was an asso pigliatutto sweep (it took the whole table).
pigliatutto: bool = False
def to_json(self) -> Dict[str, Any]:
return {
@@ -100,6 +102,7 @@ class Move:
"card": self.card,
"captured": list(self.captured),
"scopa": self.scopa,
"pigliatutto": self.pigliatutto,
}
@staticmethod
@@ -110,6 +113,7 @@ class Move:
card=str(data["card"]),
captured=[str(c) for c in data.get("captured", [])],
scopa=bool(data.get("scopa", False)),
pigliatutto=bool(data.get("pigliatutto", False)),
)
@@ -154,6 +158,10 @@ class ScoponeState:
# Whether the napola rule is scored (denari run from the ace; a full
# suit wins the match instantly). Default on.
napola: bool = True
# Whether the asso pigliatutto rule is scored (an ace sweeps the whole
# table when no equal rank is on it; the sweep never scores a scopa).
# Default off, also for states serialized before the option existed.
asso_pigliatutto: bool = False
phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list)
table: List[Card] = field(default_factory=list)
@@ -184,6 +192,7 @@ class ScoponeState:
return {
"target_score": self.target_score,
"napola": self.napola,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase,
"players": [p.to_json() for p in self.players],
"table": [c.to_json() for c in self.table],
@@ -207,6 +216,7 @@ class ScoponeState:
return ScoponeState(
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
napola=bool(data.get("napola", True)),
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])],
table=[Card.from_json(c) for c in data.get("table", [])],
@@ -325,6 +325,138 @@ class NapolaTest(unittest.TestCase):
)
class AssoPigliatuttoTest(unittest.TestCase):
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
# The other players still hold cards, so emptying the table would
# normally score a scopa: the sweep must suppress it.
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D", "03S"],
)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
self.assertEqual([], state.table)
self.assertEqual(
{"05C", "02D", "03S", "01D"},
{c.code for c in state.players[0].captured},
)
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertFalse(state.last_move.scopa)
def test_legal_captures_offer_only_the_whole_table(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(
table, card("01D"), asso_pigliatutto=True
)
self.assertEqual(
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
)
# Non-ace cards keep the normal rules.
self.assertEqual(
[],
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
)
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
table = ["01C", "02D"]
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
state.asso_pigliatutto = True
options = engine.legal_captures(
state.table, card("01D"), asso_pigliatutto=True
)
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "01D", ["01C", "02D"])
# A rejected play consumes the card, so retry on a fresh state.
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D", ["01C"])
self.assertEqual(["02D"], [c.code for c in state.table])
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_disabled_by_default(self) -> None:
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D"],
)
engine.play(state, "p0", "01D")
# Without the rule an ace captures nothing here and stays on the
# table.
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
self.assertEqual(
[],
engine.legal_captures(
[card("05C")], card("01D"), asso_pigliatutto=False
),
)
def test_ace_on_empty_table_is_a_normal_play(self) -> None:
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D")
self.assertEqual(["01D"], [c.code for c in state.table])
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_move_pigliatutto_serialization(self) -> None:
move = engine.Move(
seat=0, name="p0", card="01D", captured=["05C"], pigliatutto=True
)
restored = engine.Move.from_json(move.to_json())
self.assertTrue(restored.pigliatutto)
legacy = move.to_json()
del legacy["pigliatutto"]
self.assertFalse(engine.Move.from_json(legacy).pigliatutto)
def test_state_serialization_roundtrip(self) -> None:
state = make_state([["02D"], [], [], []], table=[])
self.assertFalse(state.asso_pigliatutto)
state.asso_pigliatutto = True
self.assertTrue(
ScoponeState.from_json(state.to_json()).asso_pigliatutto
)
# States serialized before the option existed default to disabled.
data = state.to_json()
del data["asso_pigliatutto"]
self.assertFalse(ScoponeState.from_json(data).asso_pigliatutto)
def test_create_game_default_and_override(self) -> None:
self.assertFalse(engine.create_game("p0", "p0").asso_pigliatutto)
self.assertTrue(
engine.create_game("p0", "p0", asso_pigliatutto=True).asso_pigliatutto
)
def test_state_for_player_exposes_rule_and_legal_moves(self) -> None:
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D", "03S"],
)
state.asso_pigliatutto = True
view = engine.state_for_player(state, "p0")
self.assertTrue(view["asso_pigliatutto"])
self.assertEqual(
{"01D": [["05C", "02D", "03S"]]}, view["legal_moves"]
)
def test_auto_play_sweeps_with_the_rule(self) -> None:
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D"],
)
state.asso_pigliatutto = True
engine.auto_play(state)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual(["05C", "02D"], state.last_move.captured)
class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None:
state = engine.create_game("p0", "p0", target_score=11)
@@ -43,6 +43,12 @@ class CreateTest(unittest.TestCase):
session = _session(engine, target_score=16, napola=False)
self.assertEqual(16, session.state.target_score)
self.assertFalse(session.state.napola)
self.assertFalse(session.state.asso_pigliatutto)
def test_create_asso_pigliatutto_option(self) -> None:
engine = ScoponeEngine()
session = _session(engine, asso_pigliatutto=True)
self.assertTrue(session.state.asso_pigliatutto)
def test_create_rejects_bad_options(self) -> None:
engine = ScoponeEngine()
@@ -52,6 +58,8 @@ class CreateTest(unittest.TestCase):
_session(engine, target_score="eleven")
with self.assertRaises(IllegalMove):
_session(engine, napola="yes")
with self.assertRaises(IllegalMove):
_session(engine, asso_pigliatutto="yes")
def test_timeouts_come_from_the_engine(self) -> None:
engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
@@ -159,6 +167,7 @@ class ViewTest(unittest.TestCase):
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(16, lobby["target_score"])
self.assertTrue(lobby["napola"])
self.assertFalse(lobby["asso_pigliatutto"])
class SerializationTest(unittest.TestCase):
@@ -283,6 +292,7 @@ class ResultTest(unittest.TestCase):
self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"])
self.assertIn("napola", engine.options_schema["properties"])
self.assertIn("asso_pigliatutto", engine.options_schema["properties"])
if __name__ == "__main__":
+25
View File
@@ -94,6 +94,28 @@ class GamesRouteTest(unittest.TestCase):
)
self.assertEqual(400, invalid.status_code)
@async_test
async def test_create_asso_pigliatutto_option(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
default = await client.post("/api/games", json={})
self.assertEqual(201, default.status_code)
self.assertFalse(default.json()["asso_pigliatutto"])
with oidc_user("alice"):
enabled = await client.post(
"/api/games", json={"options": {"asso_pigliatutto": True}}
)
self.assertEqual(201, enabled.status_code)
self.assertTrue(enabled.json()["asso_pigliatutto"])
with oidc_user("alice"):
invalid = await client.post(
"/api/games", json={"options": {"asso_pigliatutto": "yes"}}
)
self.assertEqual(400, invalid.status_code)
@async_test
async def test_join_errors(self) -> None:
transport = ASGITransport(app=app)
@@ -163,6 +185,9 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(4, results[0]["max_players"])
self.assertIn("target_score", results[0]["options_schema"]["properties"])
self.assertIn("napola", results[0]["options_schema"]["properties"])
self.assertIn(
"asso_pigliatutto", results[0]["options_schema"]["properties"]
)
@async_test
async def test_create_defaults_game_type(self) -> None:
+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 {