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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
@@ -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!({
|
||||||
|
|||||||
@@ -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) ")"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user