Add optional asso pigliatutto rule with a lobby toggle
CI / Build and push docker image (push) Successful in 3m16s
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:
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", [])],
|
||||
|
||||
Reference in new issue
Block a user