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:
woggioni committed 2026-09-23 23:22:37 +08:00
1 parent abc55479e4
commit 8f0e974842
12 files changed
+291 -16

No files matched your search

@@ -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)