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

@@ -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", [])],