"""Rule engine tests: captures, scope, scoring and full-match simulation.""" from __future__ import annotations import random import unittest from tavolo.game import engine from tavolo.game.errors import ( CardNotInHand, GameFinished, GameNotStarted, IllegalMove, NotYourTurn, ) from tavolo.game.state import ( PHASE_FINISHED, PHASE_PLAYING, Card, GameState, PlayerState, ) def card(code: str) -> Card: return Card.parse(code) def make_state( hands, table, turn: int = 0, *, captured=None, scope=None, target: int = 11, last_taker=None, ) -> GameState: """Build a controlled game state directly (bypassing the deal).""" state = GameState( id="game-1", join_code="ABC123", creator_sub="p0", target_score=target, phase=PHASE_PLAYING, 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] 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"], ["01S"], ["01B"]], 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"], ["01S"], ["01B"]], 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"], ["01S"], ["01B"]], 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"], ["01S"], ["01B"]], 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"], ["01S"], ["01B"]], 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"], ["01S"], ["01B"]], table=["05C"]) with self.assertRaises(IllegalMove): engine.play(state, "p0", "05D") def test_not_your_turn(self) -> None: state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], 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"], ["04D"], ["05D"]], table=[]) with self.assertRaises(CardNotInHand): engine.play(state, "p0", "07D") def test_finished_game_rejects_moves(self) -> None: state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[]) state.phase = PHASE_FINISHED with self.assertRaises(GameFinished): engine.play(state, "p0", "02D") class LastPlayTest(unittest.TestCase): def test_no_scopa_on_last_play_of_hand(self) -> None: # p0 plays the last card of the hand (everyone else is already # empty): the capture empties the table but must NOT count as a # scopa. Team A still reaches the target of 2 with carte + denara. state = make_state([["02D"], [], [], []], table=["02C"], target=2) engine.play(state, "p0", "02D", ["02C"]) self.assertEqual(PHASE_FINISHED, state.phase) self.assertEqual(0, state.winner) self.assertEqual(0, state.hand_scores[-1]["scope"]["A"]) def test_table_swept_to_last_taker(self) -> None: # target 2 so the game ends on this hand and the capture piles are # not reset by the next deal. state = make_state([["02D"], [], [], []], table=["05C", "04D"], last_taker=1, target=2) engine.play(state, "p0", "02D") captured = {c.code for c in state.players[1].captured} self.assertEqual({"05C", "04D", "02D"}, captured) self.assertEqual(PHASE_FINISHED, state.phase) self.assertEqual(1, state.winner) 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_hand_points_carte_denara_settebello_primiera_scope(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"]) def test_ties_award_nothing(self) -> None: state = make_state( [[], [], [], []], table=[], captured=[ ["06C", "01S", "05B", "02D"], ["06S", "01B", "05D", "02C"], [], [], ], scope=[0, 0, 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) class MatchFlowTest(unittest.TestCase): def test_join_starts_when_full(self) -> None: state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11) self.assertEqual(1, len(state.players)) engine.join_game(state, "p1", "p1") engine.join_game(state, "p2", "p2") self.assertEqual("lobby", state.phase) engine.join_game(state, "p3", "p3") self.assertEqual(PHASE_PLAYING, state.phase) self.assertEqual(4, len(state.players)) for player in state.players: self.assertEqual(10, len(player.hand)) self.assertEqual([], state.table) self.assertEqual(1, state.turn) # dealer is seat 0 def test_match_ends_when_target_reached(self) -> None: state = make_state([["02D"], [], [], []], table=["02C"], target=1) engine.play(state, "p0", "02D", ["02C"]) self.assertEqual(PHASE_FINISHED, state.phase) self.assertEqual(0, state.winner) self.assertGreaterEqual(state.scores[0], 1) def test_state_for_player_hides_other_hands(self) -> None: state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]], table=["07C"]) view = engine.state_for_player(state, "p0") players = {p["seat"]: p for p in view["players"]} self.assertEqual(["02D", "03C"], players[0]["hand"]) self.assertNotIn("hand", players[1]) self.assertEqual(1, players[1]["cards_left"]) self.assertEqual(["07C"], view["table"]) self.assertTrue(view.get("your_turn")) def test_legal_moves_only_for_player_on_turn(self) -> None: state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]], table=["07C"]) view = engine.state_for_player(state, "p0") legal = view["legal_moves"] # 02D can capture nothing; 03C has no combination either (only 07C # on the table). self.assertEqual({}, legal) state = make_state([["09D"], ["04D"], ["05D"], ["06D"]], table=["07C", "02S"]) view = engine.state_for_player(state, "p0") self.assertEqual({"09D": [["07C", "02S"]]}, view["legal_moves"]) # A player who is not on turn gets no legal_moves key. other = engine.state_for_player(state, "p1") self.assertNotIn("legal_moves", other) self.assertNotIn("your_turn", other) def test_full_random_match_reaches_completion(self) -> None: state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11) for i in range(1, 4): engine.join_game(state, f"p{i}", f"p{i}") moves = 0 while state.phase != PHASE_FINISHED and moves < 200000: if state.phase == "hand_end": for p in state.players: engine.acknowledge_hand(state, p.sub) continue player = next(p for p in state.players if p.seat == state.turn) played = player.hand[0] options = engine.legal_captures(state.table, played) capture = [c.code for c in options[0]] if options else None engine.play(state, player.sub, played.code, capture) moves += 1 self.assertEqual(PHASE_FINISHED, state.phase) self.assertIn(state.winner, (0, 1)) # At the end all 40 cards are captured and no hand is left. self.assertEqual([], state.table) self.assertTrue(all(not p.hand for p in state.players)) self.assertEqual(40, sum(len(p.captured) for p in state.players)) self.assertTrue(state.finished_at) class HandEndAckTest(unittest.TestCase): def _hand_end_state(self) -> GameState: """Drive a game into the hand_end phase with a one-card hand.""" state = make_state([["02D"], [], [], []], table=["02C"], target=11) engine.play(state, "p0", "02D", ["02C"]) return state def test_end_of_hand_pauses_for_acknowledgement(self) -> None: state = self._hand_end_state() self.assertEqual("hand_end", state.phase) # Nobody has acknowledged yet, and no new hand was dealt. self.assertEqual([], state.acked) self.assertEqual(1, state.hand_number) self.assertTrue(state.hand_end_deadline) # Capture piles stay visible during the summary. self.assertEqual(["02C", "02D"], [c.code for c in state.players[0].captured]) # The summary carries the award map. summary = state.hand_scores[-1] self.assertEqual(1, summary["hand"]) self.assertIn("award", summary) def test_play_during_hand_end_is_rejected(self) -> None: state = self._hand_end_state() with self.assertRaises(IllegalMove): engine.play(state, "p0", "02D") def test_ack_all_four_deals_next_hand(self) -> None: state = self._hand_end_state() dealer_before = state.dealer for i, sub in enumerate(("p0", "p1", "p2")): engine.acknowledge_hand(state, sub) self.assertEqual(list(range(i + 1)), state.acked) self.assertEqual("hand_end", state.phase) engine.acknowledge_hand(state, "p3") self.assertEqual("playing", state.phase) self.assertEqual(2, state.hand_number) self.assertEqual((dealer_before + 1) % 4, state.dealer) self.assertEqual([], state.acked) self.assertIsNone(state.hand_end_deadline) self.assertIsNone(state.last_move) for player in state.players: self.assertEqual(10, len(player.hand)) self.assertEqual([], player.captured) self.assertEqual((dealer_before + 2) % 4, state.turn) def test_double_ack_is_idempotent(self) -> None: state = self._hand_end_state() engine.acknowledge_hand(state, "p0") engine.acknowledge_hand(state, "p0") self.assertEqual([0], state.acked) def test_ack_outside_hand_end_is_rejected(self) -> None: state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[]) with self.assertRaises(IllegalMove): engine.acknowledge_hand(state, "p0") def test_ack_by_non_player_is_rejected(self) -> None: state = self._hand_end_state() with self.assertRaises(NotYourTurn): engine.acknowledge_hand(state, "mallory") def test_state_exposes_ack_progress(self) -> None: state = self._hand_end_state() engine.acknowledge_hand(state, "p1") view = engine.state_for_player(state, "p0") self.assertEqual([1], view["acknowledged"]) self.assertTrue(view["hand_end_deadline"]) self.assertIsNotNone(view["last_hand"]) self.assertIn("award", view["last_hand"]) class AutoPlayTest(unittest.TestCase): def test_auto_play_plays_a_card_and_advances_turn(self) -> None: state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]], table=["09B"]) state.turn_deadline = "2000-01-01T00:00:00+00:00" engine.auto_play(state, random.Random(7)) self.assertEqual(1, state.turn) self.assertEqual(1, len(state.players[0].hand)) # The played card could not capture the nine, so the table grew. self.assertEqual(2, len(state.table)) self.assertIsNotNone(state.last_move) assert state.last_move is not None self.assertEqual(0, state.last_move.seat) self.assertNotEqual("2000-01-01T00:00:00+00:00", state.turn_deadline) def test_auto_play_takes_a_mandatory_capture(self) -> None: # p0 holds only the five of denari, which must capture the equal # five of coppe instead of the unrelated nine on the table. state = make_state([["05D"], ["04D"], ["05D"], ["06D"]], table=["05C", "09B"]) engine.auto_play(state) self.assertIsNotNone(state.last_move) assert state.last_move is not None self.assertEqual("05D", state.last_move.card) self.assertEqual(["05C"], state.last_move.captured) self.assertEqual(["09B"], [c.code for c in state.table]) self.assertEqual(["05C", "05D"], [c.code for c in state.players[0].captured]) def test_auto_play_can_end_the_hand_and_clears_deadline(self) -> None: state = make_state([["02D"], [], [], []], table=["02C"]) state.turn_deadline = "2000-01-01T00:00:00+00:00" engine.auto_play(state) self.assertEqual("hand_end", state.phase) self.assertIsNone(state.turn_deadline) self.assertTrue(state.hand_end_deadline) def test_auto_play_requires_playing_phase(self) -> None: state = make_state([["02D"], ["04D"], ["05D"], ["06D"]], table=[]) state.phase = "hand_end" with self.assertRaises(GameNotStarted): engine.auto_play(state) def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None: state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7) self.assertEqual(7, state.turn_timeout) for i in range(1, 4): engine.join_game(state, f"p{i}", f"p{i}") self.assertEqual(PHASE_PLAYING, state.phase) self.assertTrue(state.turn_deadline) view = engine.state_for_player(state, "p0") self.assertTrue(view["turn_deadline"]) if __name__ == "__main__": unittest.main()