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:
2026-09-23 23:22:37 +08:00
parent abc55479e4
commit 8f0e974842
12 changed files with 291 additions and 16 deletions
+2 -1
View File
@@ -38,7 +38,8 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
When a hand ends but the match is not decided, the game pauses on a When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: every player sees how each category was won **scoring summary screen**: every player sees how each category was won
(carte, denara, settebello, primiera, scope — plus napola when enabled) (carte, denara, settebello, primiera, scope — plus napola and asso
pigliatutto when enabled)
with the running totals and with the running totals and
must click "Understood" before the next hand is dealt. If someone is away must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
+6 -1
View File
@@ -189,7 +189,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` | | `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) | | `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) | | `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
@@ -270,6 +270,11 @@ must dismiss. A play attempted in this phase is rejected with an
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …). point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
A team that captures the whole denari suit (ace to king) wins the match A team that captures the whole denari suit (ace to king) wins the match
instantly, regardless of the score. 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.
- The match ends when a team reaches the target score (default 11, - The match ends when a team reaches the target score (default 11,
configurable per game via the `target_score` creation option) with a 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. clear lead; a tie at or above the target is broken by another hand.
+7 -5
View File
@@ -11,15 +11,17 @@ implementation.
(de)serialization. No session envelope, no transport, no I/O. (de)serialization. No session envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, legal captures, plays, - `engine.py` — the pure rules engine: deck, legal captures, plays,
auto-play, hand scoring (carte, denara, settebello, primiera, scope, auto-play, hand scoring (carte, denara, settebello, primiera, scope,
napola), hand-end acknowledgements. Deterministic and I/O-free apart napola), the optional asso pigliatutto sweep, hand-end
from logging, so the whole rule set is unit-testable. 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 - `errors.py` — scopone-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass. failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter. - `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
It translates create/join/websocket actions/deadlines into rules-engine It translates create/join/websocket actions/deadlines into rules-engine
calls and back, validates the `target_score`/`napola` creation options, calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
and extracts the `MatchResult` (teams, winner, per-player scores and the creation options, and extracts the `MatchResult` (teams, winner,
match summary persisted as the match's JSON `result`). per-player scores and the match summary persisted as the match's JSON
`result`).
Timeouts are constructor arguments (`turn_timeout_seconds`, Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the `hand_ack_timeout_seconds`), wired from the environment by the
@@ -28,6 +28,10 @@ Rules implemented
consecutive denari starting from the ace scores one point per card when 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 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. 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 * 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. 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 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``. """Return every legal capture (a list of card sets) for ``card``.
If an equal-ranked card is on the table, only those single-card If an equal-ranked card is on the table, only those single-card
captures are returned (the rule forbids taking a combination instead). captures are returned (the rule forbids taking a combination instead).
Otherwise every subset of the table whose ranks sum to ``card.rank`` is Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
returned. 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] equal = [c for c in table if c.rank == card.rank]
if equal: if equal:
return [[c] for c in 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] candidates = [c for c in table if c.rank <= card.rank]
captures: List[List[Card]] = [] captures: List[List[Card]] = []
@@ -140,6 +149,7 @@ def create_game(
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
napola: bool = True, napola: bool = True,
asso_pigliatutto: bool = False,
) -> ScoponeState: ) -> ScoponeState:
"""Create a lobby game with the creator seated first.""" """Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100: if target_score < 1 or target_score > 100:
@@ -147,6 +157,7 @@ def create_game(
return ScoponeState( return ScoponeState(
target_score=target_score, target_score=target_score,
napola=napola, napola=napola,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_LOBBY, phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout, hand_ack_timeout=hand_ack_timeout,
@@ -240,7 +251,18 @@ def play(
player.hand.remove(card) player.hand.remove(card)
requested = [parse_card(c) for c in (capture_codes or [])] 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] = [] taken: List[Card] = []
scopa = False scopa = False
@@ -260,7 +282,7 @@ def play(
state.last_taker = player.seat state.last_taker = player.seat
# A scopa scores only if cards remain to be played this hand. # A scopa scores only if cards remain to be played this hand.
hands_empty = all(not p.hand for p in state.players) 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 player.scope += 1
scopa = True scopa = True
@@ -270,6 +292,7 @@ def play(
card=card.code, card=card.code,
captured=[c.code for c in taken], captured=[c.code for c in taken],
scopa=scopa, scopa=scopa,
pigliatutto=pigliatutto,
) )
if all(not p.hand for p in state.players): 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") raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng chooser = rng or _rng
card = chooser.choice(player.hand) 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 capture = chooser.choice(options) if options else None
play( play(
state, state,
@@ -526,6 +551,7 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
"phase": state.phase, "phase": state.phase,
"target_score": state.target_score, "target_score": state.target_score,
"napola": state.napola, "napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
"hand_number": state.hand_number, "hand_number": state.hand_number,
"dealer": state.dealer, "dealer": state.dealer,
"turn": state.turn, "turn": state.turn,
@@ -545,7 +571,9 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
# rule logic stays server-side. # rule logic stays server-side.
legal_moves: Dict[str, List[List[str]]] = {} legal_moves: Dict[str, List[List[str]]] = {}
for hand_card in viewer.hand: 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: if options:
legal_moves[hand_card.code] = [ legal_moves[hand_card.code] = [
[c.code for c in option] for option in options [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 " "description": "Score the napola rule; a full denari "
"sweep wins the match", "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) napola = options.get("napola", True)
if not isinstance(napola, bool): if not isinstance(napola, bool):
raise IllegalMove("napola must be a boolean") 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] creator = session.players[0]
session.state = engine.create_game( session.state = engine.create_game(
creator_sub=creator.user_sub, creator_sub=creator.user_sub,
@@ -103,6 +112,7 @@ class ScoponeEngine(GameEngine):
hand_ack_timeout=self._hand_ack_timeout, hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout, turn_timeout=self._turn_timeout,
napola=napola, napola=napola,
asso_pigliatutto=asso_pigliatutto,
) )
# The creator takes seat 0, i.e. team A. # The creator takes seat 0, i.e. team A.
session.players[0] = Seat( session.players[0] = Seat(
@@ -133,6 +143,7 @@ class ScoponeEngine(GameEngine):
"phase": state.phase, "phase": state.phase,
"target_score": state.target_score, "target_score": state.target_score,
"napola": state.napola, "napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
} }
# -- play ------------------------------------------------------------- # -- play -------------------------------------------------------------
@@ -92,6 +92,8 @@ class Move:
card: str card: str
captured: List[str] = field(default_factory=list) captured: List[str] = field(default_factory=list)
scopa: bool = False 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]: def to_json(self) -> Dict[str, Any]:
return { return {
@@ -100,6 +102,7 @@ class Move:
"card": self.card, "card": self.card,
"captured": list(self.captured), "captured": list(self.captured),
"scopa": self.scopa, "scopa": self.scopa,
"pigliatutto": self.pigliatutto,
} }
@staticmethod @staticmethod
@@ -110,6 +113,7 @@ class Move:
card=str(data["card"]), card=str(data["card"]),
captured=[str(c) for c in data.get("captured", [])], captured=[str(c) for c in data.get("captured", [])],
scopa=bool(data.get("scopa", False)), 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 # Whether the napola rule is scored (denari run from the ace; a full
# suit wins the match instantly). Default on. # suit wins the match instantly). Default on.
napola: bool = True 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 phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list) players: List[PlayerState] = field(default_factory=list)
table: List[Card] = field(default_factory=list) table: List[Card] = field(default_factory=list)
@@ -184,6 +192,7 @@ class ScoponeState:
return { return {
"target_score": self.target_score, "target_score": self.target_score,
"napola": self.napola, "napola": self.napola,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase, "phase": self.phase,
"players": [p.to_json() for p in self.players], "players": [p.to_json() for p in self.players],
"table": [c.to_json() for c in self.table], "table": [c.to_json() for c in self.table],
@@ -207,6 +216,7 @@ class ScoponeState:
return ScoponeState( return ScoponeState(
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
napola=bool(data.get("napola", True)), napola=bool(data.get("napola", True)),
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
phase=str(data.get("phase", PHASE_LOBBY)), phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])], players=[PlayerState.from_json(p) for p in data.get("players", [])],
table=[Card.from_json(c) for c in data.get("table", [])], table=[Card.from_json(c) for c in data.get("table", [])],
@@ -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): class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None: def test_join_starts_when_full(self) -> None:
state = engine.create_game("p0", "p0", target_score=11) state = engine.create_game("p0", "p0", target_score=11)
@@ -43,6 +43,12 @@ class CreateTest(unittest.TestCase):
session = _session(engine, target_score=16, napola=False) session = _session(engine, target_score=16, napola=False)
self.assertEqual(16, session.state.target_score) self.assertEqual(16, session.state.target_score)
self.assertFalse(session.state.napola) self.assertFalse(session.state.napola)
self.assertFalse(session.state.asso_pigliatutto)
def test_create_asso_pigliatutto_option(self) -> None:
engine = ScoponeEngine()
session = _session(engine, asso_pigliatutto=True)
self.assertTrue(session.state.asso_pigliatutto)
def test_create_rejects_bad_options(self) -> None: def test_create_rejects_bad_options(self) -> None:
engine = ScoponeEngine() engine = ScoponeEngine()
@@ -52,6 +58,8 @@ class CreateTest(unittest.TestCase):
_session(engine, target_score="eleven") _session(engine, target_score="eleven")
with self.assertRaises(IllegalMove): with self.assertRaises(IllegalMove):
_session(engine, napola="yes") _session(engine, napola="yes")
with self.assertRaises(IllegalMove):
_session(engine, asso_pigliatutto="yes")
def test_timeouts_come_from_the_engine(self) -> None: def test_timeouts_come_from_the_engine(self) -> None:
engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9) engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
@@ -159,6 +167,7 @@ class ViewTest(unittest.TestCase):
self.assertEqual("lobby", lobby["phase"]) self.assertEqual("lobby", lobby["phase"])
self.assertEqual(16, lobby["target_score"]) self.assertEqual(16, lobby["target_score"])
self.assertTrue(lobby["napola"]) self.assertTrue(lobby["napola"])
self.assertFalse(lobby["asso_pigliatutto"])
class SerializationTest(unittest.TestCase): class SerializationTest(unittest.TestCase):
@@ -283,6 +292,7 @@ class ResultTest(unittest.TestCase):
self.assertEqual(4, engine.max_players) self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"]) self.assertIn("target_score", engine.options_schema["properties"])
self.assertIn("napola", engine.options_schema["properties"]) self.assertIn("napola", engine.options_schema["properties"])
self.assertIn("asso_pigliatutto", engine.options_schema["properties"])
if __name__ == "__main__": if __name__ == "__main__":
+25
View File
@@ -94,6 +94,28 @@ class GamesRouteTest(unittest.TestCase):
) )
self.assertEqual(400, invalid.status_code) self.assertEqual(400, invalid.status_code)
@async_test
async def test_create_asso_pigliatutto_option(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
default = await client.post("/api/games", json={})
self.assertEqual(201, default.status_code)
self.assertFalse(default.json()["asso_pigliatutto"])
with oidc_user("alice"):
enabled = await client.post(
"/api/games", json={"options": {"asso_pigliatutto": True}}
)
self.assertEqual(201, enabled.status_code)
self.assertTrue(enabled.json()["asso_pigliatutto"])
with oidc_user("alice"):
invalid = await client.post(
"/api/games", json={"options": {"asso_pigliatutto": "yes"}}
)
self.assertEqual(400, invalid.status_code)
@async_test @async_test
async def test_join_errors(self) -> None: async def test_join_errors(self) -> None:
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
@@ -163,6 +185,9 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(4, results[0]["max_players"]) self.assertEqual(4, results[0]["max_players"])
self.assertIn("target_score", results[0]["options_schema"]["properties"]) self.assertIn("target_score", results[0]["options_schema"]["properties"])
self.assertIn("napola", results[0]["options_schema"]["properties"]) self.assertIn("napola", results[0]["options_schema"]["properties"])
self.assertIn(
"asso_pigliatutto", results[0]["options_schema"]["properties"]
)
@async_test @async_test
async def test_create_defaults_game_type(self) -> None: async def test_create_defaults_game_type(self) -> None:
+36 -2
View File
@@ -49,6 +49,9 @@ pub struct MoveView {
pub captured: Vec<String>, pub captured: Vec<String>,
#[serde(default)] #[serde(default)]
pub scopa: bool, pub scopa: bool,
/// The play was an asso pigliatutto sweep (it took the whole table).
#[serde(default)]
pub pigliatutto: bool,
} }
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, Deserialize)]
@@ -117,6 +120,9 @@ pub struct GameView {
/// Whether the napola rule is scored in this match. /// Whether the napola rule is scored in this match.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub napola: bool, pub napola: bool,
/// Whether the asso pigliatutto rule is scored in this match.
#[serde(default)]
pub asso_pigliatutto: bool,
pub phase: String, pub phase: String,
#[serde(default)] #[serde(default)]
pub target_score: i32, pub target_score: i32,
@@ -563,6 +569,7 @@ mod tests {
"default": 11, "default": 11,
}, },
"napola": {"type": "boolean", "default": true}, "napola": {"type": "boolean", "default": true},
"asso_pigliatutto": {"type": "boolean", "default": false},
}, },
}, },
})) }))
@@ -572,7 +579,7 @@ mod tests {
#[test] #[test]
fn option_fields_parse_scopone_schema() { fn option_fields_parse_scopone_schema() {
let fields = scopone_game_type().option_fields(); let fields = scopone_game_type().option_fields();
assert_eq!(2, fields.len()); assert_eq!(3, fields.len());
let by_key: std::collections::HashMap<_, _> = let by_key: std::collections::HashMap<_, _> =
fields.into_iter().map(|f| (f.key.clone(), f)).collect(); fields.into_iter().map(|f| (f.key.clone(), f)).collect();
let target = &by_key["target_score"]; let target = &by_key["target_score"];
@@ -583,6 +590,8 @@ mod tests {
assert_eq!(json!(11), target.default); assert_eq!(json!(11), target.default);
assert_eq!(OptionKind::Boolean, by_key["napola"].kind); assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
assert_eq!(json!(true), by_key["napola"].default); assert_eq!(json!(true), by_key["napola"].default);
assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind);
assert_eq!(json!(false), by_key["asso_pigliatutto"].default);
} }
#[test] #[test]
@@ -614,13 +623,14 @@ mod tests {
let opts = scopone_game_type().default_options(); let opts = scopone_game_type().default_options();
assert_eq!(Some(&json!(11)), opts.get("target_score")); assert_eq!(Some(&json!(11)), opts.get("target_score"));
assert_eq!(Some(&json!(true)), opts.get("napola")); assert_eq!(Some(&json!(true)), opts.get("napola"));
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
} }
#[test] #[test]
fn game_view_parses_new_lobby_payload() { fn game_view_parses_new_lobby_payload() {
// Exact shape of the platform's lobby payload: envelope fields, // Exact shape of the platform's lobby payload: envelope fields,
// seats with team labels, seats_open, plus the engine's // seats with team labels, seats_open, plus the engine's
// lobby_view (phase/target_score/napola for scopone). // lobby_view (phase/target_score/napola/asso_pigliatutto).
let g: GameView = serde_json::from_value(json!({ let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555", "id": "11111111-2222-3333-4444-555555555555",
"join_code": "ABC123", "join_code": "ABC123",
@@ -632,6 +642,7 @@ mod tests {
"phase": "lobby", "phase": "lobby",
"target_score": 16, "target_score": 16,
"napola": false, "napola": false,
"asso_pigliatutto": true,
})) }))
.unwrap(); .unwrap();
assert_eq!("ABC123", g.join_code); assert_eq!("ABC123", g.join_code);
@@ -639,6 +650,7 @@ mod tests {
assert_eq!("lobby", g.phase); assert_eq!("lobby", g.phase);
assert_eq!(16, g.target_score); assert_eq!(16, g.target_score);
assert!(!g.napola); assert!(!g.napola);
assert!(g.asso_pigliatutto);
assert_eq!("A", g.players[0].team); assert_eq!("A", g.players[0].team);
} }
@@ -652,6 +664,7 @@ mod tests {
"phase": "playing", "phase": "playing",
"target_score": 11, "target_score": 11,
"napola": true, "napola": true,
"asso_pigliatutto": true,
"hand_number": 1, "hand_number": 1,
"dealer": 0, "dealer": 0,
"turn": 1, "turn": 1,
@@ -675,11 +688,32 @@ mod tests {
})) }))
.unwrap(); .unwrap();
assert_eq!("playing", g.phase); assert_eq!("playing", g.phase);
assert!(g.asso_pigliatutto);
assert_eq!(None, g.players[0].hand); assert_eq!(None, g.players[0].hand);
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand); assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
assert_eq!(Some(true), g.your_turn); assert_eq!(Some(true), g.your_turn);
} }
#[test]
fn move_view_parses_pigliatutto() {
let mv: MoveView = serde_json::from_value(json!({
"seat": 0,
"name": "Alice",
"card": "01D",
"captured": ["05C", "02D"],
"scopa": false,
"pigliatutto": true,
}))
.unwrap();
assert!(mv.pigliatutto);
// Older payloads without the flag default to a normal capture.
let legacy: MoveView = serde_json::from_value(json!({
"seat": 0, "name": "Alice", "card": "01D",
}))
.unwrap();
assert!(!legacy.pigliatutto);
}
#[test] #[test]
fn game_type_without_schema_has_no_fields() { fn game_type_without_schema_has_no_fields() {
let g: GameTypeInfo = serde_json::from_value(json!({ let g: GameTypeInfo = serde_json::from_value(json!({
+8
View File
@@ -63,6 +63,9 @@ fn move_banner(mv: MoveView) -> View {
.map(|c| card_img(c.clone(), "move-card")) .map(|c| card_img(c.clone(), "move-card"))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } }); let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } });
let pigliatutto = mv
.pigliatutto
.then(|| view! { span(class="pigliatutto-badge") { "Asso pigliatutto!" } });
view! { view! {
div(class="move-banner") { div(class="move-banner") {
strong { (name) } strong { (name) }
@@ -70,6 +73,7 @@ fn move_banner(mv: MoveView) -> View {
(played) (played)
(captured) (captured)
(scopa) (scopa)
(pigliatutto)
} }
} }
} }
@@ -565,6 +569,9 @@ fn table_view(
let banner = game.last_move.clone().map(move_banner); let banner = game.last_move.clone().map(move_banner);
let hand_number = game.hand_number; let hand_number = game.hand_number;
let target_score = game.target_score; let target_score = game.target_score;
let rule_badge = game.asso_pigliatutto.then(|| view! {
span(class="pigliatutto-badge") { "Asso pigliatutto" }
});
let viewer = player_for_seat(&game, viewer_seat); let viewer = player_for_seat(&game, viewer_seat);
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0); let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
@@ -602,6 +609,7 @@ fn table_view(
div(class="hud") { div(class="hud") {
a(href="/") { "← Lobby" } a(href="/") { "← Lobby" }
span { "Hand " (hand_number) } span { "Hand " (hand_number) }
(rule_badge)
span(class="hud-scores") { span(class="hud-scores") {
"Team A " (scores.a) " — " (scores.b) " Team B (target " "Team A " (scores.a) " — " (scores.b) " Team B (target "
(target_score) ")" (target_score) ")"
+9
View File
@@ -257,6 +257,15 @@ body {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
.pigliatutto-badge {
background: var(--danger, #d9534f);
color: #fff;
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
margin-left: 0.25rem;
}
/* ---------- overlays ---------- */ /* ---------- overlays ---------- */
.overlay { .overlay {