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.
This commit is contained in:
2026-09-23 23:53:40 +08:00
parent 8f0e974842
commit cc0b31ce6b
5 changed files with 114 additions and 23 deletions
+4 -3
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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,
)
@@ -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)
@@ -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: