Files
tavolo/server/packages/tavolo-scopa/tests/test_engine.py
T
woggioni d4d437c1a1 Add scopa: classic two-player and scopa a quattro
New tavolo-scopa package with a pure rules engine and the platform
adapters for two-player scopa and four-player fixed-partnership scopa a
quattro: three cards each and four on the table, capture by rank or sum,
sweep for scope and score carte/denara/settebello/primiera.

The optional asso pigliatutto creation option turns either variant into
scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing
scopone's rule semantics.

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
2026-10-09 05:39:00 +00:00

617 lines
24 KiB
Python

"""Rule engine tests: deals, captures, scope, scoring and full matches."""
from __future__ import annotations
import random
import unittest
from tavolo.scopa import engine
from tavolo.scopa.errors import (
AlreadyJoined,
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.scopa.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
Card,
PlayerState,
ScopaState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
table,
turn: int = 0,
*,
dealer: int = 0,
captured=None,
scope=None,
target: int = 11,
last_taker=None,
deck=None,
asso_pigliatutto: bool = False,
) -> ScopaState:
"""Build a controlled game state directly (bypassing the deal)."""
state = ScopaState(
player_count=len(hands),
target_score=target,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_PLAYING,
dealer=dealer,
turn=turn,
last_taker=last_taker,
)
for seat, hand in enumerate(hands):
state.players.append(
PlayerState(sub=f"p{seat}", name=f"p{seat}", seat=seat,
hand=[card(c) for c in hand])
)
if captured is not None:
for player, codes in zip(state.players, captured):
player.captured = [card(c) for c in codes]
if scope is not None:
for player, value in zip(state.players, scope):
player.scope = value
state.table = [card(c) for c in table]
state.deck = [card(c) for c in (deck or [])]
return state
class DeckTest(unittest.TestCase):
def test_full_deck_has_40_unique_cards(self) -> None:
deck = engine.full_deck()
self.assertEqual(40, len(deck))
self.assertEqual(40, len({c.code for c in deck}))
self.assertEqual(4, len({c.suit for c in deck}))
self.assertEqual(4, sum(1 for c in deck if c.rank == 7))
def test_shuffled_deck_is_permutation(self) -> None:
deck = engine.shuffled_deck()
self.assertEqual(
sorted(c.code for c in engine.full_deck()),
sorted(c.code for c in deck),
)
class CaptureTest(unittest.TestCase):
def test_equal_card_is_mandatory(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(table, card("05D"))
self.assertEqual([["05C"]], [[c.code for c in o] for o in options])
def test_sum_combination(self) -> None:
table = [card("01C"), card("03C"), card("02S")]
options = engine.legal_captures(table, card("04D"))
self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options])
def test_multiple_equal_cards_each_a_separate_option(self) -> None:
table = [card("05C"), card("05S")]
options = engine.legal_captures(table, card("05D"))
self.assertEqual(
[["05C"], ["05S"]], sorted([[c.code for c in o] for o in options])
)
def test_no_capture(self) -> None:
table = [card("09C"), card("08S")]
self.assertEqual([], engine.legal_captures(table, card("02D")))
def test_play_without_capture_places_card_on_table(self) -> None:
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
engine.play(state, "p0", "02D")
self.assertIn("02D", [c.code for c in state.table])
self.assertNotIn("02D", [c.code for c in state.players[0].hand])
self.assertEqual(1, state.turn)
def test_play_capture_and_scopa(self) -> None:
state = make_state([["02D", "09C"], ["01C"]], table=["02C"])
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(1, state.players[0].scope)
self.assertEqual([], state.table)
self.assertEqual(
["02C", "02D"], [c.code for c in state.players[0].captured]
)
# The move is recorded for the "who played what" announcement.
assert state.last_move is not None
self.assertEqual(0, state.last_move.seat)
self.assertEqual("p0", state.last_move.name)
self.assertEqual("02D", state.last_move.card)
self.assertEqual(["02C"], state.last_move.captured)
self.assertTrue(state.last_move.scopa)
def test_play_without_capture_records_move(self) -> None:
state = make_state([["02D", "03D"], ["01C"]], table=["09C"])
engine.play(state, "p0", "02D")
assert state.last_move is not None
self.assertEqual("02D", state.last_move.card)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_illegal_combination_when_equal_card_present(self) -> None:
state = make_state([["05D"], ["01C"]],
table=["05C", "02D", "03S"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D", ["02D", "03S"])
def test_illegal_capture_rejected(self) -> None:
state = make_state([["04D"], ["01C"]], table=["02C", "03S"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "04D", ["02C"])
def test_no_capture_requested_when_capture_possible(self) -> None:
state = make_state([["05D"], ["01C"]], table=["05C"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D")
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["03D"]], table=[], turn=1)
with self.assertRaises(NotYourTurn):
engine.play(state, "p0", "02D")
def test_card_not_in_hand(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_game_rejects_moves(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
class DealTest(unittest.TestCase):
def test_create_game_defaults(self) -> None:
state = engine.create_game("p0", "p0")
self.assertEqual(2, state.player_count)
self.assertEqual(11, state.target_score)
self.assertFalse(state.asso_pigliatutto)
self.assertEqual("p0", state.players[0].sub)
self.assertEqual("lobby", state.phase)
def test_create_game_player_count_validation(self) -> None:
for bad in (1, 3, 5, 0):
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", player_count=bad)
def test_join_starts_two_player_game(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.table))
self.assertEqual(4, len({c.code for c in state.table}))
for player in state.players:
self.assertEqual(3, len(player.hand))
# 40 - 4 on the table - 6 in hand.
self.assertEqual(30, len(state.deck))
self.assertEqual(1, state.turn)
def test_join_starts_four_player_game(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for seat in range(1, 4):
engine.join_game(state, f"p{seat}", f"p{seat}")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.table))
for player in state.players:
self.assertEqual(3, len(player.hand))
# 40 - 4 on the table - 12 in hand.
self.assertEqual(24, len(state.deck))
self.assertEqual(1, state.turn)
for seat in range(4):
self.assertEqual(seat % 2, engine._player_at(state, seat).team)
def test_join_errors(self) -> None:
state = engine.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
engine.join_game(state, "p0", "p0")
engine.join_game(state, "p1", "p1")
# The lobby filled up and the match started.
with self.assertRaises(GameNotStarted):
engine.join_game(state, "p2", "p2")
def test_start_requires_full_table(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
engine.join_game(state, "p1", "p1")
with self.assertRaises(GameNotStarted):
engine.start_game(state)
class RoundTest(unittest.TestCase):
def test_redeal_when_hands_empty_and_deck_has_cards(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], ["03D"]], table=["09C"],
deck=deck, turn=0, dealer=0)
engine.play(state, "p0", "02D")
self.assertEqual(1, state.turn)
# The last card of the round: the next three cards are dealt.
engine.play(state, "p1", "03D")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(3, len(player.hand))
self.assertEqual([], state.deck)
# The table is never topped up.
self.assertEqual(
{"09C", "02D", "03D"}, {c.code for c in state.table}
)
# The player after the dealer leads every round again.
self.assertEqual(1, state.turn)
def test_redeal_gives_the_lead_back_to_the_non_dealer(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], ["03D"]], table=[], deck=deck,
turn=0, dealer=1)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "03D")
# Dealer seat 1: the lead is seat 0 again.
self.assertEqual(0, state.turn)
def test_hand_ends_when_deck_is_exhausted(self) -> None:
state = make_state([["02D"], ["03D"]], table=[], deck=[], target=100)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "03D")
self.assertEqual(PHASE_HAND_END, state.phase)
class ScopaTest(unittest.TestCase):
def test_scopa_on_the_last_play_of_a_round_with_deck_left(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], []], table=["02C"], deck=deck, turn=0)
engine.play(state, "p0", "02D", ["02C"])
# Cards remain in the deck: the sweep is a scopa, and the round is
# re-dealt immediately.
self.assertEqual(1, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.scopa)
for player in state.players:
self.assertEqual(3, len(player.hand))
def test_no_scopa_on_the_last_play_of_the_hand(self) -> None:
state = make_state([["02D"], []], table=["02C"], deck=[],
turn=0, target=100)
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertFalse(state.last_move.scopa)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
class AssoPigliatuttoTest(unittest.TestCase):
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
# p1 still holds cards, so emptying the table would normally score
# a scopa: the sweep must suppress it.
state = make_state(
[["01D", "09C"], ["04D"]],
table=["05C", "02D", "03S"],
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]
)
# A bare table leaves only the self-capture option; without the
# rule the ace stays on the table.
self.assertEqual(
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
)
self.assertEqual(
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
)
# 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:
state = make_state(
[["01D"], ["04D"]],
table=["01C", "02D"],
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"]],
table=["01C", "02D"],
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"]], 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)
# ... and on a bare table it stays there too.
state = make_state([["01D", "09C"], ["04D"]], table=[])
engine.play(state, "p0", "01D")
self.assertEqual(["01D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
def test_ace_on_empty_table_captures_itself(self) -> None:
# The self-capture makes p0 the last taker and scores no scopa.
state = make_state(
[["01D", "09C"], ["04D"]],
table=[],
last_taker=1,
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D")
self.assertEqual([], state.table)
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
self.assertEqual(0, state.players[0].scope)
self.assertEqual(0, state.last_taker)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_non_ace_on_empty_table_stays(self) -> None:
state = make_state(
[["02D", "09C"], ["04D"]], table=[], asso_pigliatutto=True
)
engine.play(state, "p0", "02D")
self.assertEqual(["02D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
class HandEndTest(unittest.TestCase):
def test_table_swept_to_last_taker(self) -> None:
state = make_state([["02D"], []], table=["05C", "04D"],
last_taker=1, deck=[], target=100)
engine.play(state, "p0", "02D")
captured = {c.code for c in state.players[1].captured}
self.assertEqual({"05C", "04D", "02D"}, captured)
self.assertEqual(PHASE_HAND_END, state.phase)
def test_match_ends_at_target_with_clear_lead(self) -> None:
state = make_state([["02D"], []], table=[], deck=[], turn=0,
target=1, captured=[["07D"], []])
engine.play(state, "p0", "02D")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
def test_tie_at_target_plays_another_hand(self) -> None:
state = make_state(
[["02D"], []],
table=[],
deck=[],
target=1,
captured=[["01D", "02C"], ["02D", "01C"]],
)
state.scores = [1, 1]
engine.play(state, "p0", "02D")
# A 0-0 hand at the target is not a win: another hand is played.
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertIsNone(state.winner)
def test_acknowledge_deals_next_hand(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
state.phase = PHASE_HAND_END
engine.acknowledge_hand(state, "p0")
self.assertEqual(PHASE_HAND_END, state.phase)
engine.acknowledge_hand(state, "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual(1, state.dealer)
self.assertEqual(4, len(state.table))
for player in state.players:
self.assertEqual(3, len(player.hand))
def test_acknowledge_rejects_outside_hand_end(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_hand_summary_has_no_napola(self) -> None:
state = make_state([["02D"], []], table=[], deck=[], target=1,
captured=[["07D"], []])
engine.play(state, "p0", "02D")
self.assertNotIn("napola", state.hand_scores[-1])
class ScoringTest(unittest.TestCase):
def test_primiera_values_and_all_suits_requirement(self) -> None:
self.assertEqual(70, engine.primiera_score(
[card(c) for c in ["07D", "06C", "01S", "05B"]]))
self.assertEqual(0, engine.primiera_score(
[card(c) for c in ["07D", "06C", "01S"]]))
self.assertEqual(40, engine.primiera_score(
[card(c) for c in ["08D", "09C", "10S", "10B"]]))
def test_two_player_hand_points(self) -> None:
state = make_state(
[[], []],
table=[],
captured=[
["07D", "06C", "01S", "05B"],
["03D", "04C", "07S", "02B"],
],
scope=[1, 0],
)
points, details = engine.hand_points(state)
# Carte and denara tie; A takes settebello and primiera, plus one
# scopa: A+3.
self.assertEqual({"A": 4, "B": 4}, details["cards"])
self.assertEqual({"A": 1, "B": 1}, details["denara"])
self.assertEqual({"A": True, "B": False}, details["settebello"])
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
self.assertEqual({"A": 1, "B": 0}, details["scope"])
self.assertEqual([3, 0], points)
def test_ties_award_nothing(self) -> None:
state = make_state(
[[], []],
table=[],
captured=[
["06C", "01S", "05B", "02D"],
["06S", "01B", "05D", "02C"],
],
scope=[0, 0],
)
points, _ = engine.hand_points(state)
# Equal cards, equal denara, equal primiera and no settebello:
# everything ties, so no points at all.
self.assertEqual([0, 0], points)
def test_four_player_pools_the_partners(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["07D", "06C", "01S", "05B"], # seat 0, team A
["03D", "04C", "07S", "02B"], # seat 1, team B
["02D"], # seat 2, team A
["10D", "10C", "10S", "10B"], # seat 3, team B
],
scope=[1, 0, 0, 2],
)
points, details = engine.hand_points(state)
self.assertEqual([3, 3], points)
self.assertEqual({"A": 5, "B": 8}, details["cards"])
self.assertEqual({"A": 2, "B": 2}, details["denara"])
self.assertEqual({"A": True, "B": False}, details["settebello"])
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
self.assertEqual({"A": 1, "B": 2}, details["scope"])
self.assertNotIn("napola", details)
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = engine.create_game(
"p0", "p0", player_count=4, target_score=16,
asso_pigliatutto=True,
)
for seat in range(1, 4):
engine.join_game(state, f"p{seat}", f"p{seat}")
restored = ScopaState.from_json(state.to_json())
self.assertEqual(4, restored.player_count)
self.assertEqual(16, restored.target_score)
self.assertTrue(restored.asso_pigliatutto)
self.assertEqual(
[c.code for c in state.deck], [c.code for c in restored.deck]
)
self.assertEqual(
[c.code for c in state.table], [c.code for c in restored.table]
)
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.turn, restored.turn)
self.assertEqual(
[p.sub for p in state.players], [p.sub for p in restored.players]
)
def test_asso_pigliatutto_defaults_off(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
data = state.to_json()
del data["asso_pigliatutto"]
self.assertFalse(ScopaState.from_json(data).asso_pigliatutto)
def test_player_count_defaults_to_two(self) -> None:
data = ScopaState().to_json()
del data["player_count"]
self.assertEqual(2, ScopaState.from_json(data).player_count)
class AutoPlayTest(unittest.TestCase):
def test_auto_play_applies_a_legal_move(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
rng = random.Random(7)
player = engine._player_at(state, state.turn)
hand_size = len(player.hand)
engine.auto_play(state, rng)
self.assertEqual(hand_size - 1, len(player.hand))
assert state.last_move is not None
self.assertEqual(player.seat, state.last_move.seat)
class FullMatchTest(unittest.TestCase):
def _play_out(self, player_count: int, target: int) -> ScopaState:
state = engine.create_game("p0", "p0", player_count=player_count,
target_score=target)
for seat in range(1, player_count):
engine.join_game(state, f"p{seat}", f"p{seat}")
rng = random.Random(1234 + player_count)
steps = 0
while state.phase != PHASE_FINISHED and steps < 100_000:
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.acknowledge_hand(state, player.sub)
continue
engine.auto_play(state, rng)
steps += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIsNotNone(state.winner)
self.assertGreaterEqual(max(state.scores), target)
self.assertNotEqual(state.scores[0], state.scores[1])
return state
def test_random_two_player_match_reaches_a_winner(self) -> None:
state = self._play_out(2, 5)
for hand in state.hand_scores:
self.assertIn("cards", hand)
self.assertIn("scope", hand)
def test_random_four_player_match_reaches_a_winner(self) -> None:
state = self._play_out(4, 5)
self.assertEqual(4, len(state.players))
def test_all_cards_are_captured_by_the_end_of_a_hand(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
rng = random.Random(99)
# Finish exactly one hand (target 100 keeps the match alive).
state.target_score = 100
while state.phase != PHASE_HAND_END:
engine.auto_play(state, rng)
self.assertIsNotNone(state.last_taker)
self.assertEqual(
40, sum(len(p.captured) for p in state.players)
)
if __name__ == "__main__":
unittest.main()