From cc0b31ce6bf3d255a23af08ed068e7232c5b361e Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Wed, 23 Sep 2026 23:53:40 +0800 Subject: [PATCH] Let the asso pigliatutto ace capture itself on a bare table With the rule enabled, an ace played on an empty table goes straight to the player's capture pile instead of staying on the table: it counts as the last capture but never scores a scopa. An empty capture request now matches only that self-capture option, so requesting nothing still can't satisfy a real capture. --- server/README.md | 7 +- server/packages/tavolo-scopone/README.md | 2 +- .../src/tavolo/scopone/engine.py | 31 ++++--- .../src/tavolo/scopone/state.py | 8 +- .../tavolo-scopone/tests/test_engine.py | 89 ++++++++++++++++++- 5 files changed, 114 insertions(+), 23 deletions(-) diff --git a/server/README.md b/server/README.md index 1403ca8..c428e38 100644 --- a/server/README.md +++ b/server/README.md @@ -272,9 +272,10 @@ must dismiss. A play attempted in this phase is rejected with an 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. + sweeps every card on the table — or captures itself when the table is + empty. The capture never scores a scopa, but it counts as the last + capture, 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. diff --git a/server/packages/tavolo-scopone/README.md b/server/packages/tavolo-scopone/README.md index 6457aea..c491067 100644 --- a/server/packages/tavolo-scopone/README.md +++ b/server/packages/tavolo-scopone/README.md @@ -11,7 +11,7 @@ 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), the optional asso pigliatutto sweep, hand-end + napola), the optional asso pigliatutto capture, 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 diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py b/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py index 06647d4..8df3c2e 100644 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py @@ -29,9 +29,11 @@ Rules implemented 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. + when no equal rank is on the table sweeps the whole table — or captures + itself, leaving the table empty, when the table is bare. The sweep never + scores a scopa, even when cards remain in hand, but it counts as a + capture for the last-taker rule. 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. """ @@ -122,14 +124,15 @@ def legal_captures( If an equal-ranked card is on the table, only those single-card captures are returned (the rule forbids taking a combination instead). 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. + the only capture is the whole table — or an empty capture when the + table is bare, the ace taking itself. 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 [] + return [list(table)] candidates = [c for c in table if c.rank <= card.rank] captures: List[List[Card]] = [] @@ -255,12 +258,12 @@ def play( 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. + # An asso pigliatutto capture: the ace takes the whole table because no + # equal rank was on it, or takes itself when the table is bare. Such a + # capture 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) ) @@ -305,10 +308,12 @@ def play( def _match_option( options: Sequence[Sequence[Card]], requested: Sequence[Card] ) -> Optional[List[Card]]: - """Return the option matching ``requested`` exactly, if any.""" + """Return the option matching ``requested`` exactly, if any. + + An empty request matches only an empty option — the asso pigliatutto + self-capture — so requesting nothing never satisfies a real capture. + """ wanted = sorted(c.code for c in requested) - if not wanted: - return None for option in options: if sorted(c.code for c in option) == wanted: return list(option) @@ -339,7 +344,7 @@ def auto_play(state: ScoponeState, rng: Optional[random.Random] = None) -> None: state, player.sub, card.code, - [c.code for c in capture] if capture else None, + [c.code for c in capture] if capture is not None else None, ) diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/state.py b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py index 6fe863c..f1edb69 100644 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/state.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py @@ -92,7 +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). + # The play was an asso pigliatutto capture (the whole table, or the + # ace itself when the table was empty). pigliatutto: bool = False def to_json(self) -> Dict[str, Any]: @@ -159,8 +160,9 @@ class ScoponeState: # 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. + # table when no equal rank is on it, or captures itself when the table + # is empty; the capture 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) diff --git a/server/packages/tavolo-scopone/tests/test_engine.py b/server/packages/tavolo-scopone/tests/test_engine.py index d693fe4..53c159f 100644 --- a/server/packages/tavolo-scopone/tests/test_engine.py +++ b/server/packages/tavolo-scopone/tests/test_engine.py @@ -353,6 +353,14 @@ class AssoPigliatuttoTest(unittest.TestCase): self.assertEqual( [["05C", "02D", "03S"]], [[c.code for c in o] for o in options] ) + # A bare table leaves only the self-capture option; without the + # rule the ace stays on the table. + self.assertEqual( + [[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True) + ) + self.assertEqual( + [], engine.legal_captures([], card("01D"), asso_pigliatutto=False) + ) # Non-ace cards keep the normal rules. self.assertEqual( [], @@ -388,6 +396,13 @@ class AssoPigliatuttoTest(unittest.TestCase): 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) + # ... and on a bare table it stays there too. + state = make_state( + [["01D", "09C"], ["04D"], ["05D"], ["06D"]], table=[] + ) + engine.play(state, "p0", "01D") + self.assertEqual(["01D"], [c.code for c in state.table]) + self.assertEqual([], state.players[0].captured) self.assertEqual( [], engine.legal_captures( @@ -395,13 +410,34 @@ class AssoPigliatuttoTest(unittest.TestCase): ), ) - def test_ace_on_empty_table_is_a_normal_play(self) -> None: + def test_ace_on_empty_table_captures_itself(self) -> None: + # Other players still hold cards, so the bare table would trigger a + # (nonsensical) scopa without the pigliatutto suppression. The + # self-capture makes p0 the last taker, replacing seat 1. state = make_state( - [["01D", "09C"], ["04D"], ["05D"], ["06D"]], table=[] + [["01D", "09C"], ["04D"], ["05D"], ["06D"]], + table=[], + last_taker=1, ) state.asso_pigliatutto = True engine.play(state, "p0", "01D") - self.assertEqual(["01D"], [c.code for c in state.table]) + self.assertEqual([], state.table) + self.assertEqual(["01D"], [c.code for c in state.players[0].captured]) + self.assertEqual(0, state.players[0].scope) + self.assertEqual(0, state.last_taker) + assert state.last_move is not None + self.assertTrue(state.last_move.pigliatutto) + self.assertEqual([], state.last_move.captured) + self.assertFalse(state.last_move.scopa) + + def test_non_ace_on_empty_table_stays(self) -> None: + state = make_state( + [["02D", "09C"], ["04D"], ["05D"], ["06D"]], table=[] + ) + state.asso_pigliatutto = True + engine.play(state, "p0", "02D") + self.assertEqual(["02D"], [c.code for c in state.table]) + self.assertEqual([], state.players[0].captured) assert state.last_move is not None self.assertFalse(state.last_move.pigliatutto) @@ -444,6 +480,13 @@ class AssoPigliatuttoTest(unittest.TestCase): self.assertEqual( {"01D": [["05C", "02D", "03S"]]}, view["legal_moves"] ) + # On a bare table the ace advertises the self-capture. + state = make_state( + [["01D"], ["04D"], ["05D"], ["06D"]], table=[] + ) + state.asso_pigliatutto = True + view = engine.state_for_player(state, "p0") + self.assertEqual({"01D": [[]]}, view["legal_moves"]) def test_auto_play_sweeps_with_the_rule(self) -> None: state = make_state( @@ -456,6 +499,46 @@ class AssoPigliatuttoTest(unittest.TestCase): self.assertTrue(state.last_move.pigliatutto) self.assertEqual(["05C", "02D"], state.last_move.captured) + def test_auto_play_self_captures_on_bare_table(self) -> None: + state = make_state( + [["01D"], ["04D"], ["05D"], ["06D"]], table=[] + ) + state.asso_pigliatutto = True + engine.auto_play(state) + assert state.last_move is not None + self.assertTrue(state.last_move.pigliatutto) + self.assertEqual(["01D"], [c.code for c in state.players[0].captured]) + self.assertEqual([], state.table) + + def test_full_random_match_with_rule_keeps_all_cards(self) -> None: + state = engine.create_game( + "p0", "p0", target_score=11, asso_pigliatutto=True + ) + for i in range(1, 4): + engine.join_game(state, f"p{i}", f"p{i}") + + moves = 0 + while state.phase != PHASE_FINISHED and moves < 200000: + if state.phase == "hand_end": + for p in state.players: + engine.acknowledge_hand(state, p.sub) + continue + player = next(p for p in state.players if p.seat == state.turn) + played = player.hand[0] + options = engine.legal_captures( + state.table, played, asso_pigliatutto=state.asso_pigliatutto + ) + capture = [c.code for c in options[0]] if options else None + engine.play(state, player.sub, played.code, capture) + moves += 1 + + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertIn(state.winner, (0, 1)) + # Self-captures included, all 40 cards end up in capture piles. + self.assertEqual([], state.table) + self.assertTrue(all(not p.hand for p in state.players)) + self.assertEqual(40, sum(len(p.captured) for p in state.players)) + class MatchFlowTest(unittest.TestCase): def test_join_starts_when_full(self) -> None: