Files
tavolo/server/packages/tavolo-scopa/tests/test_engine.py
T
woggioni bf613eb1c4 Set scopa targets to their traditional defaults
Partnership games go to 21: scopone scientifico (the tournament target)
and scopa a quattro, while two-player scopa keeps its usual 11. The scopa
package resolves the default from the player count and exposes a per
variant options_schema default, so the lobby creation form shows the
right value without frontend changes.
2026-10-09 06:36:06 +00:00

626 lines
25 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)
# Two-player scopa is traditionally played to 11 points.
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_four_player_default_target(self) -> None:
# The four-player partnership game (like scopone) goes to 21.
state = engine.create_game("p0", "p0", player_count=4)
self.assertEqual(21, state.target_score)
# An explicit target still wins over the default.
explicit = engine.create_game("p0", "p0", player_count=4, target_score=16)
self.assertEqual(16, explicit.target_score)
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()