"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation.""" from __future__ import annotations import random import unittest from tavolo.briscola import engine from tavolo.briscola.errors import ( CardNotInHand, GameFinished, GameNotStarted, IllegalMove, NotYourTurn, ) from tavolo.briscola.state import ( HAND_SIZE, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, BriscolaState, Card, PlayerState, ) def card(code: str) -> Card: return Card.parse(code) def make_state( hands, *, briscola_suit: str = "S", briscola_card: str | None = None, stock=None, captured=None, turn: int = 0, dealer: int = 0, target: int = 61, scores=None, phase: str = PHASE_PLAYING, ) -> BriscolaState: """Build a controlled game state directly (bypassing the deal).""" state = BriscolaState( player_count=len(hands), target_score=target, phase=phase, dealer=dealer, turn=turn, briscola_suit=briscola_suit, briscola_card=card(briscola_card) if briscola_card else None, stock=[card(c) for c in (stock or [])], scores=list(scores or [0, 0]), ) 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] return state def trick_cards(state: BriscolaState) -> list[str]: return [t.card for t in state.trick] def play_all(state: BriscolaState) -> None: """Play every card of the current trick in turn order.""" for _ in range(state.player_count): player = next(p for p in state.players if p.seat == state.turn) engine.play(state, player.sub, player.hand[0].code) 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})) 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), ) def test_card_points_add_up_to_120(self) -> None: self.assertEqual( 120, sum(engine.card_points(c) for c in engine.full_deck()) ) self.assertEqual(11, engine.card_points(card("01D"))) self.assertEqual(10, engine.card_points(card("03C"))) self.assertEqual(4, engine.card_points(card("10S"))) self.assertEqual(3, engine.card_points(card("09B"))) self.assertEqual(2, engine.card_points(card("08D"))) self.assertEqual(0, engine.card_points(card("07C"))) class DealTest(unittest.TestCase): def test_two_player_deal(self) -> None: state = engine.create_game("p0", "p0") engine.join_game(state, "p1", "p1") self.assertEqual(PHASE_PLAYING, state.phase) for player in state.players: self.assertEqual(HAND_SIZE, len(player.hand)) # 40 - 3*2 - 1 face-up = 33 face-down. self.assertEqual(33, len(state.stock)) self.assertIsNotNone(state.briscola_card) assert state.briscola_card is not None self.assertEqual(state.briscola_card.suit, state.briscola_suit) self.assertEqual(1, state.turn) # dealer is seat 0 self.assertEqual([], state.trick) def test_four_player_deal(self) -> None: state = engine.create_game("p0", "p0", player_count=4) for i in range(1, 4): engine.join_game(state, f"p{i}", f"p{i}") self.assertEqual(PHASE_PLAYING, state.phase) for player in state.players: self.assertEqual(HAND_SIZE, len(player.hand)) self.assertEqual(40 - 3 * 4 - 1, len(state.stock)) self.assertEqual(1, state.turn) def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None: state = engine.create_game("p0", "p0", player_count=4) for i in range(1, 4): engine.join_game(state, f"p{i}", f"p{i}") dealt = [c.code for p in state.players for c in p.hand] assert state.briscola_card is not None dealt.append(state.briscola_card.code) dealt.extend(c.code for c in state.stock) self.assertEqual(40, len(dealt)) self.assertEqual(40, len(set(dealt))) def test_unsupported_player_count(self) -> None: with self.assertRaises(IllegalMove): engine.create_game("p0", "p0", player_count=3) def test_target_score_bounds(self) -> None: with self.assertRaises(IllegalMove): engine.create_game("p0", "p0", target_score=60) with self.assertRaises(IllegalMove): engine.create_game("p0", "p0", target_score=121) with self.assertRaises(IllegalMove): engine.create_game("p0", "p0", target_score=True) state = engine.create_game("p0", "p0", target_score=101) self.assertEqual(101, state.target_score) class TrickTest(unittest.TestCase): def test_higher_card_of_led_suit_wins(self) -> None: state = make_state([["02D"], ["05D"]], briscola_suit="S") engine.play(state, "p0", "02D") engine.play(state, "p1", "05D") self.assertEqual(1, state.last_trick.winner) self.assertEqual(["02D", "05D"], [c.code for c in state.players[1].captured]) def test_trump_beats_a_higher_led_card(self) -> None: state = make_state([["07D"], ["02C"]], briscola_suit="C") engine.play(state, "p0", "07D") engine.play(state, "p1", "02C") self.assertEqual(1, state.last_trick.winner) def test_higher_trump_wins_among_trumps(self) -> None: state = make_state([["02C"], ["05C"]], briscola_suit="C") engine.play(state, "p0", "02C") engine.play(state, "p1", "05C") self.assertEqual(1, state.last_trick.winner) def test_off_suit_non_trump_loses(self) -> None: state = make_state([["04D"], ["01C"]], briscola_suit="B") engine.play(state, "p0", "04D") engine.play(state, "p1", "01C") self.assertEqual(0, state.last_trick.winner) def test_rank_strength_order(self) -> None: # (winner card, loser card) pairs of the led suit, strongest first: # ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2. pairs = [ ("01D", "03D"), # ace beats three ("03D", "10D"), # three beats king ("10D", "09D"), # king beats horse ("09D", "08D"), # horse beats jack ("08D", "07D"), # jack beats seven ("07D", "06D"), ("06D", "05D"), ("05D", "04D"), ("04D", "02D"), ] for winner, loser in pairs: state = make_state([["02D"], ["05D"]], briscola_suit="B") state.players[0].hand = [card(winner)] state.players[1].hand = [card(loser)] engine.play(state, "p0", winner) engine.play(state, "p1", loser) self.assertEqual( 0, state.last_trick.winner, f"{winner} should beat {loser}" ) def test_four_player_trick(self) -> None: state = make_state( [["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S" ) for seat in range(4): engine.play(state, f"p{seat}", state.players[seat].hand[0].code) self.assertEqual(3, state.last_trick.winner) def test_four_player_trump_beats_all(self) -> None: state = make_state( [["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C" ) for seat in range(4): engine.play(state, f"p{seat}", state.players[seat].hand[0].code) self.assertEqual(1, state.last_trick.winner) def test_winner_leads_the_next_trick(self) -> None: state = make_state( [["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S", stock=["01B", "02B", "03B", "04B"], ) for seat in range(4): engine.play(state, f"p{seat}", state.players[seat].hand[0].code) self.assertEqual(3, state.turn) self.assertEqual(3, state.trick_leader) self.assertEqual([], state.trick) def test_turn_advances_within_a_trick(self) -> None: state = make_state([["02D"], ["05D"]], briscola_suit="S") engine.play(state, "p0", "02D") self.assertEqual(1, state.turn) self.assertEqual(["02D"], trick_cards(state)) def test_not_your_turn(self) -> None: state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1) with self.assertRaises(NotYourTurn): engine.play(state, "p0", "02D") def test_card_not_in_hand(self) -> None: state = make_state([["02D"], ["05D"]], briscola_suit="S") with self.assertRaises(CardNotInHand): engine.play(state, "p0", "07D") def test_finished_and_hand_end_reject_plays(self) -> None: state = make_state([["02D"], ["05D"]], briscola_suit="S") state.phase = PHASE_FINISHED with self.assertRaises(GameFinished): engine.play(state, "p0", "02D") state = make_state([["02D"], ["05D"]], briscola_suit="S") state.phase = PHASE_HAND_END with self.assertRaises(IllegalMove): engine.play(state, "p0", "02D") class DrawTest(unittest.TestCase): def test_winner_draws_first(self) -> None: # The stock is drawn from its end: 04B first, then 03B, ... state = make_state( [["02D", "09C"], ["05D", "09B"]], briscola_suit="S", stock=["01B", "02B", "03B", "04B"], ) engine.play(state, "p0", "02D") engine.play(state, "p1", "05D") hands = {p.seat: [c.code for c in p.hand] for p in state.players} self.assertEqual(["09C", "03B"], hands[0]) self.assertEqual(["09B", "04B"], hands[1]) def test_face_up_briscola_is_drawn_last(self) -> None: state = make_state( [["02D", "09C"], ["05D", "09B"]], briscola_suit="C", briscola_card="05C", stock=["03B"], ) engine.play(state, "p0", "02D") engine.play(state, "p1", "05D") hands = {p.seat: [c.code for c in p.hand] for p in state.players} # The winner draws first and takes the last face-down card; the # other player then takes the face-up briscola. self.assertEqual(["09B", "03B"], hands[1]) self.assertEqual(["09C", "05C"], hands[0]) self.assertIsNone(state.briscola_card) def test_no_draw_when_stock_is_empty(self) -> None: state = make_state( [["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[] ) engine.play(state, "p0", "02D") engine.play(state, "p1", "05D") # p1 wins the trick; with no stock nobody draws and the hands # shrink for the rest of the deal. for player in state.players: self.assertEqual(1, len(player.hand)) def test_hands_stay_at_three_until_stock_runs_out(self) -> None: state = engine.create_game("p0", "p0") engine.join_game(state, "p1", "p1") while state.stock or state.briscola_card is not None: play_all(state) for player in state.players: self.assertEqual(HAND_SIZE, len(player.hand)) self.assertEqual(0, len(state.stock)) self.assertIsNone(state.briscola_card) class ScoringTest(unittest.TestCase): def test_deal_points_group_by_team(self) -> None: state = make_state( [[], [], [], []], briscola_suit="S", captured=[ ["01D", "07C"], # seat 0, team A: 11 ["03C", "08D"], # seat 1, team B: 10 ["10S"], # seat 2, team A: 4 ["09B"], # seat 3, team B: 3 ], ) points, details = engine.deal_points(state) self.assertEqual([15, 15], points) self.assertEqual({"A": 15, "B": 15}, details["points"]) self.assertEqual({"A": 3, "B": 3}, details["cards"]) def test_full_deal_scores_120(self) -> None: state = engine.create_game("p0", "p0", player_count=4) for i in range(1, 4): engine.join_game(state, f"p{i}", f"p{i}") while state.phase == PHASE_PLAYING: player = next(p for p in state.players if p.seat == state.turn) engine.play(state, player.sub, player.hand[0].code) points, _ = engine.deal_points(state) self.assertEqual(120, sum(points)) def test_match_ends_when_target_reached(self) -> None: # p0 captures the ace of denari (11) on the last trick: 60+11 >= 61. state = make_state( [["02D"], ["05D"]], briscola_suit="S", stock=[], captured=[["01C"], ["10C"]], scores=[60, 50], target=61, ) # p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a # higher card: 02D vs 05D -> p1 would win, so give p0 the five. state.players[0].hand = [card("05D")] state.players[1].hand = [card("02D")] engine.play(state, "p0", "05D") engine.play(state, "p1", "02D") self.assertEqual(PHASE_FINISHED, state.phase) self.assertEqual(0, state.winner) self.assertEqual([71, 54], state.scores) def test_tie_at_target_is_broken_by_another_deal(self) -> None: # 65-55 before the deal; this deal gives A 55 and B 65: both reach # 120 and tie, so another deal must be played. a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"] b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [ f"{rank:02d}S" for rank in range(1, 11) ] + [f"{rank:02d}B" for rank in range(1, 11)] state = make_state( [[], []], briscola_suit="S", stock=[], captured=[a_cards, b_cards], scores=[65, 55], target=120, ) engine._end_deal(state) self.assertEqual(PHASE_HAND_END, state.phase) self.assertEqual([120, 120], state.scores) self.assertIsNone(state.winner) def test_patta_is_discarded_and_replayed(self) -> None: a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [ f"{rank:02d}C" for rank in range(1, 11) ] b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [ f"{rank:02d}B" for rank in range(1, 11) ] state = make_state( [["02D"], ["02S"]], briscola_suit="S", stock=[], captured=[a_cards, b_cards], scores=[10, 20], target=61, ) engine._end_deal(state) self.assertEqual(PHASE_HAND_END, state.phase) # The 60-60 deal does not count: totals are unchanged. self.assertEqual([10, 20], state.scores) entry = state.hand_scores[-1] self.assertTrue(entry["patta"]) self.assertEqual({"A": 60, "B": 60}, entry["points"]) # The next deal is played at the same cumulative totals. engine.acknowledge_hand(state, "p0") engine.acknowledge_hand(state, "p1") self.assertEqual(PHASE_PLAYING, state.phase) self.assertEqual(2, state.hand_number) self.assertEqual([10, 20], state.scores) def test_audit_trail_records_each_deal(self) -> None: state = make_state( [["02D"], ["05D"]], briscola_suit="S", stock=[], captured=[["01C"], []], scores=[0, 0], ) engine._end_deal(state) entry = state.hand_scores[-1] self.assertEqual(1, entry["deal"]) self.assertFalse(entry["patta"]) self.assertEqual({"A": 11, "B": 0}, entry["points"]) self.assertEqual({"A": 11, "B": 0}, entry["totals"]) self.assertEqual({"A": 1, "B": 0}, entry["cards"]) class MatchFlowTest(unittest.TestCase): def test_join_starts_when_full(self) -> None: state = engine.create_game("p0", "p0") self.assertEqual(1, len(state.players)) engine.join_game(state, "p1", "p1") self.assertEqual(PHASE_PLAYING, state.phase) state = engine.create_game("p0", "p0", player_count=4) for i in range(1, 3): engine.join_game(state, f"p{i}", f"p{i}") self.assertEqual("lobby", state.phase) engine.join_game(state, "p3", "p3") self.assertEqual(PHASE_PLAYING, state.phase) def test_join_errors(self) -> None: from tavolo.platform.errors import AlreadyJoined, GameNotStarted state = engine.create_game("p0", "p0") with self.assertRaises(AlreadyJoined): engine.join_game(state, "p0", "p0") engine.join_game(state, "p1", "p1") with self.assertRaises(GameNotStarted): engine.join_game(state, "p2", "p2") def test_full_random_two_player_match(self) -> None: self._random_match(player_count=2) def test_full_random_four_player_match(self) -> None: self._random_match(player_count=4) def _random_match(self, player_count: int) -> None: state = engine.create_game("p0", "p0", player_count=player_count) for i in range(1, player_count): engine.join_game(state, f"p{i}", f"p{i}") rng = random.Random(2026) moves = 0 while state.phase != PHASE_FINISHED and moves < 200000: if state.phase == PHASE_HAND_END: for player in state.players: engine.acknowledge_hand(state, player.sub) continue player = next(p for p in state.players if p.seat == state.turn) engine.play(state, player.sub, rng.choice(player.hand).code) moves += 1 self.assertEqual(PHASE_FINISHED, state.phase) self.assertIn(state.winner, (0, 1)) self.assertEqual([], state.trick) self.assertTrue(all(not p.hand for p in state.players)) self.assertEqual( 40, sum(len(p.captured) for p in state.players) ) # Every counted deal totals 120 points, and the winner reached the # target with a clear lead. winner_score = state.scores[state.winner] loser_score = state.scores[1 - state.winner] self.assertGreaterEqual(winner_score, state.target_score) self.assertGreater(winner_score, loser_score) self.assertEqual(0, (winner_score + loser_score) % 120) class AckTest(unittest.TestCase): def _hand_end_state(self) -> BriscolaState: state = make_state( [["02D"], ["05D"]], briscola_suit="S", stock=[], captured=[["01C"], []], target=120, ) engine._end_deal(state) return state def test_ack_all_deals_the_next_deal(self) -> None: state = self._hand_end_state() dealer_before = state.dealer engine.acknowledge_hand(state, "p0") self.assertEqual([0], state.acked) 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((dealer_before + 1) % 2, state.dealer) self.assertEqual((dealer_before + 2) % 2, state.turn) self.assertEqual([], state.acked) self.assertIsNone(state.hand_end_deadline) for player in state.players: self.assertEqual(HAND_SIZE, len(player.hand)) self.assertEqual([], player.captured) 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"], ["05D"]], briscola_suit="S") 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") class AutoPlayTest(unittest.TestCase): def test_auto_play_plays_a_card_and_advances_turn(self) -> None: state = make_state( [["02D", "03C"], ["05D"]], briscola_suit="S", stock=["01B", "02B"], briscola_card="03B", ) engine.auto_play(state, random.Random(7)) self.assertEqual(1, state.turn) self.assertEqual(1, len(state.players[0].hand)) self.assertEqual(1, len(state.trick)) def test_auto_play_can_end_the_deal(self) -> None: state = make_state( [["02D"], ["05D"]], briscola_suit="S", stock=[], captured=[["01C"], []], target=120, ) # Both players are on their last card: two auto-plays complete the # trick and the deal, scored A=11 / B=0. engine.auto_play(state) engine.auto_play(state) self.assertEqual(PHASE_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"], ["05D"]], briscola_suit="S") state.phase = PHASE_HAND_END with self.assertRaises(GameNotStarted): engine.auto_play(state) class ViewTest(unittest.TestCase): def test_state_for_player_hides_other_hands(self) -> None: state = make_state( [["02D", "03C"], ["04D"], ["05D"], ["06D"]], briscola_suit="D", briscola_card="07D", stock=["01B", "02B"], ) 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("D", view["briscola_suit"]) self.assertEqual("07D", view["briscola_card"]) self.assertEqual(2, view["stock_count"]) self.assertTrue(view.get("your_turn")) def test_spectator_sees_public_info_only(self) -> None: state = make_state( [["02D"], ["04D"]], briscola_suit="D", briscola_card="07D", ) view = engine.state_for_player(state, None) for player in view["players"]: self.assertNotIn("hand", player) self.assertNotIn("your_turn", view) self.assertEqual("07D", view["briscola_card"]) self.assertEqual({"A": 0, "B": 0}, view["scores"]) def test_player_points_are_public(self) -> None: state = make_state( [["02D"], ["04D"]], briscola_suit="D", captured=[["01C", "03B"], ["10D"]], ) view = engine.state_for_player(state, "p0") players = {p["seat"]: p for p in view["players"]} self.assertEqual(21, players[0]["points"]) self.assertEqual(4, players[1]["points"]) self.assertEqual(2, players[0]["captured_count"]) def test_hand_end_view_exposes_the_deal_summary(self) -> None: state = make_state( [["02D"], ["05D"]], briscola_suit="S", stock=[], captured=[["01C"], []], target=120, ) engine._end_deal(state) view = engine.state_for_player(state, "p0") self.assertEqual(PHASE_HAND_END, view["phase"]) self.assertEqual( { "deal": 1, "patta": False, "points": {"A": 11, "B": 0}, "cards": {"A": 1, "B": 0}, "totals": {"A": 11, "B": 0}, }, view["last_deal"], ) self.assertTrue(view["hand_end_deadline"]) def test_trick_is_public_in_play_order(self) -> None: state = make_state( [["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S" ) engine.play(state, "p0", "02D") engine.play(state, "p1", "05D") view = engine.state_for_player(state, None) self.assertEqual( [{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}], view["trick"], ) class SerializationTest(unittest.TestCase): def test_state_roundtrip(self) -> None: state = engine.create_game("p0", "p0", player_count=4, target_score=101) for i in range(1, 4): engine.join_game(state, f"p{i}", f"p{i}") engine.play(state, state.players[1].sub, state.players[1].hand[0].code) restored = BriscolaState.from_json(state.to_json()) self.assertEqual(state.phase, restored.phase) self.assertEqual(state.turn, restored.turn) self.assertEqual(state.hand_number, restored.hand_number) self.assertEqual(state.target_score, restored.target_score) self.assertEqual(state.briscola_suit, restored.briscola_suit) self.assertEqual( state.briscola_card.code if state.briscola_card else None, restored.briscola_card.code if restored.briscola_card else None, ) self.assertEqual( [c.code for c in state.stock], [c.code for c in restored.stock] ) self.assertEqual( [(t.seat, t.card) for t in state.trick], [(t.seat, t.card) for t in restored.trick], ) self.assertEqual( [p.sub for p in state.players], [p.sub for p in restored.players] ) self.assertEqual( [[c.code for c in p.hand] for p in state.players], [[c.code for c in p.hand] for p in restored.players], ) def test_legacy_state_defaults(self) -> None: state = make_state([["02D"], ["05D"]], briscola_suit="S") data = state.to_json() del data["player_count"] del data["briscola_card"] del data["last_trick"] restored = BriscolaState.from_json(data) self.assertEqual(2, restored.player_count) self.assertIsNone(restored.briscola_card) self.assertIsNone(restored.last_trick) if __name__ == "__main__": unittest.main()