"""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()