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:
+4
-3
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user