"""Rules tests for briscola chiamata: auction, call, play, scoring, patta.""" from __future__ import annotations import random import unittest from tavolo.briscola import chiamata as rules from tavolo.briscola.chiamata import ( BID_LADDER, Bid, ChiamataPlayer, ChiamataState, PHASE_AUCTION, PHASE_CALLING, PHASE_FINISHED, PHASE_PLAYING, ROLE_CALLER, ROLE_DEFENDER, ROLE_PARTNER, SIDE_CALLER, SIDE_DEFENDERS, ) from tavolo.briscola.errors import ( CardNotInHand, GameFinished, GameNotStarted, IllegalMove, NotYourTurn, ) from tavolo.briscola.state import Card def card(code: str) -> Card: return Card.parse(code) def started_state() -> ChiamataState: """A five-player game whose auction has just opened (dealer seat 0).""" state = rules.create_game("p0", "p0") for i in range(1, 5): rules.join_game(state, f"p{i}", f"p{i}") return state def bid(state: ChiamataState, seat: int, rank: int) -> None: rules.bid_rank(state, f"p{seat}", rank) def make_state( hands, *, briscola_suit: str = "S", called_card: str | None = None, caller: int | None = None, solo: bool = False, called_played: bool = False, partner_seat: int | None = None, captured=None, turn: int = 0, dealer: int = 0, declared_target: int = rules.DEFAULT_TARGET, phase: str = PHASE_PLAYING, hand_number: int = 1, ) -> ChiamataState: """Build a controlled playing state directly (bypassing the auction).""" state = ChiamataState( phase=phase, dealer=dealer, hand_number=hand_number, declared_target=declared_target, briscola_suit=briscola_suit, called_card=card(called_card) if called_card else None, caller=caller, solo=solo, called_played=called_played, partner_seat=partner_seat, turn=turn, ) for seat, hand in enumerate(hands): state.players.append( ChiamataPlayer( 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 play_all(state: ChiamataState) -> None: """Play the first card of each hand in turn order until the trick resolves.""" for _ in range(rules.PLAYER_COUNT): player = next(p for p in state.players if p.seat == state.turn) rules.play(state, player.sub, player.hand[0].code) class SetupTest(unittest.TestCase): def test_five_player_deal(self) -> None: state = started_state() self.assertEqual(PHASE_AUCTION, state.phase) self.assertEqual(rules.PLAYER_COUNT, len(state.players)) for player in state.players: self.assertEqual(rules.HAND_SIZE, len(player.hand)) # The whole deck is dealt: no stock, no face-up card. dealt = [c.code for p in state.players for c in p.hand] self.assertEqual(40, len(dealt)) self.assertEqual(40, len(set(dealt))) # The auction opens with the player after the dealer. assert state.auction is not None self.assertEqual(1, state.auction.turn) self.assertEqual([0, 1, 2, 3, 4], state.auction.active) self.assertIsNone(state.auction.current) def test_join_errors(self) -> None: from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull state = rules.create_game("p0", "p0") with self.assertRaises(AlreadyJoined): rules.join_game(state, "p0", "p0") for i in range(1, 5): rules.join_game(state, f"p{i}", f"p{i}") self.assertEqual(PHASE_AUCTION, state.phase) with self.assertRaises(GameNotStarted): rules.join_game(state, "p5", "p5") lobby = rules.create_game("p0", "p0") for i in range(1, 5): rules.join_game(lobby, f"p{i}", f"p{i}") # Joining a sixth player is impossible once the fifth started the # match; exercise LobbyFull on an artificially unfilled start. unfilled = rules.create_game("p0", "p0") unfilled.phase = rules.PHASE_LOBBY for i in range(1, 5): unfilled.players.append( ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i) ) with self.assertRaises(LobbyFull): rules.join_game(unfilled, "p5", "p5") class AuctionTest(unittest.TestCase): def test_first_bid_accepts_any_rank(self) -> None: state = started_state() rules.bid_rank(state, "p1", 5) assert state.auction is not None self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current) def test_bids_must_descend(self) -> None: state = started_state() bid(state, 1, 1) # ace with self.assertRaises(IllegalMove): bid(state, 2, 1) # same rank bid(state, 2, 3) # three, weaker than the ace bid(state, 3, 10) # king, weaker than the three with self.assertRaises(IllegalMove): bid(state, 4, 3) # three is stronger than the king bid(state, 4, 9) # horse def test_bid_ladder_ordering(self) -> None: # A bid must be weaker than the standing bid: descending strength # follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2. state = started_state() bid(state, 1, 10) # king for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)): rules.bid_rank(state, f"p{opponent}", rank) # 3 and A are stronger than 6: rejected. with self.assertRaises(IllegalMove): rules.bid_rank(state, "p1", 3) with self.assertRaises(IllegalMove): rules.bid_rank(state, "p1", 1) rules.bid_rank(state, "p1", 5) def test_not_your_turn(self) -> None: state = started_state() with self.assertRaises(NotYourTurn): bid(state, 2, 1) def test_pass_eliminates_and_last_standing_bids(self) -> None: state = started_state() for seat in (1, 2, 3, 4): rules.pass_auction(state, f"p{seat}") assert state.auction is not None # Only seat 0 remains and nobody has bid: it must bid. self.assertEqual([0], state.auction.active) with self.assertRaises(IllegalMove): rules.pass_auction(state, "p0") rules.bid_rank(state, "p0", 2) self.assertEqual(PHASE_CALLING, state.phase) self.assertEqual(0, state.caller) self.assertEqual(61, state.declared_target) def test_auction_ends_when_one_bidder_remains(self) -> None: state = started_state() bid(state, 1, 1) # ace rules.pass_auction(state, "p2") bid(state, 3, 10) # king rules.pass_auction(state, "p4") rules.pass_auction(state, "p0") rules.pass_auction(state, "p1") self.assertEqual(PHASE_CALLING, state.phase) self.assertEqual(3, state.caller) self.assertEqual(61, state.declared_target) def test_point_raises_only_after_the_two(self) -> None: state = started_state() bid(state, 1, 1) with self.assertRaises(IllegalMove): rules.bid_points(state, "p2", 62) bid(state, 2, 2) with self.assertRaises(IllegalMove): rules.bid_rank(state, "p3", 2) # no ranks below the 2 with self.assertRaises(IllegalMove): rules.bid_points(state, "p3", 61) # not higher with self.assertRaises(IllegalMove): rules.bid_points(state, "p3", 121) # above the cap rules.bid_points(state, "p3", 63) assert state.auction is not None self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current) def test_legal_bids_exposed_to_the_bidder_only(self) -> None: state = started_state() bid(state, 1, 10) # king view = rules.state_for_player(state, "p2") self.assertTrue(view["your_turn"]) legal = view["legal_bids"] self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"]) self.assertIsNone(legal["points_min"]) # A player who is not on turn gets no legal bids. other = rules.state_for_player(state, "p3") self.assertNotIn("legal_bids", other) self.assertNotIn("your_turn", other) def test_legal_points_exposed_at_the_two(self) -> None: state = started_state() bid(state, 1, 2) view = rules.state_for_player(state, "p2") self.assertEqual([], view["legal_bids"]["ranks"]) self.assertEqual(63, view["legal_bids"]["points_min"]) self.assertEqual(120, view["legal_bids"]["points_max"]) def test_auto_bid_always_passes_when_legal(self) -> None: state = started_state() assert state.auction is not None rules.auto_bid(state) # Seat 1 passes: nobody has bid and the turn moves on. self.assertEqual([1], state.auction.passed) self.assertIsNone(state.auction.current) self.assertEqual(2, state.auction.turn) # An absent bidder still passes when there is a standing bid. rules.bid_rank(state, "p2", 10) rules.auto_bid(state) self.assertEqual([1, 3], state.auction.passed) assert state.auction.current is not None self.assertEqual(10, state.auction.current.rank) def test_auto_bid_passes_at_the_point_stage(self) -> None: state = started_state() rules.bid_rank(state, "p1", 2) rules.bid_points(state, "p2", 62) rules.auto_bid(state) # seat 3 assert state.auction is not None self.assertIn(3, state.auction.passed) assert state.auction.current is not None self.assertEqual(62, state.auction.current.points) def test_auto_bid_forced_to_call_the_ace(self) -> None: state = started_state() for seat in (1, 2, 3, 4): rules.pass_auction(state, f"p{seat}") # Seat 0 is the last one standing and nobody has bid yet: the # forced auto-bid calls the ace. rules.auto_bid(state) self.assertEqual(PHASE_CALLING, state.phase) self.assertEqual(0, state.caller) assert state.auction is not None and state.auction.current is not None self.assertEqual(1, state.auction.current.rank) self.assertEqual(61, state.declared_target) class CallTest(unittest.TestCase): def _called_state(self, rank: int = 1) -> ChiamataState: state = started_state() bid(state, 1, rank) for seat in (2, 3, 4, 0): rules.pass_auction(state, f"p{seat}") return state def test_call_must_match_the_winning_rank(self) -> None: state = self._called_state(1) with self.assertRaises(IllegalMove): rules.call(state, "p1", "03D") # Any suit of the winning rank is fine. rules.call(state, "p1", "01D") self.assertEqual("D", state.briscola_suit) self.assertEqual(PHASE_PLAYING, state.phase) def test_only_the_winner_calls(self) -> None: state = self._called_state() with self.assertRaises(NotYourTurn): rules.call(state, "p2", "01D") def test_call_in_hand_means_playing_alone(self) -> None: state = self._called_state(1) # Seat 1 calls a card it holds: solo. state.players[1].hand[0] = card("01S") rules.call(state, "p1", "01S") self.assertTrue(state.solo) self.assertIsNone(state.partner_seat) def test_play_starts_with_the_player_after_the_dealer(self) -> None: state = self._called_state(1) rules.call(state, "p1", "01D") self.assertEqual(1, state.turn) self.assertNotEqual(state.caller, None) def test_auto_call_names_the_bid_rank(self) -> None: state = self._called_state(10) rules.auto_call(state, random.Random(3)) assert state.called_card is not None self.assertEqual(10, state.called_card.rank) self.assertEqual(PHASE_PLAYING, state.phase) def test_calling_phase_view(self) -> None: state = self._called_state(1) view = rules.state_for_player(state, "p1") self.assertEqual(PHASE_CALLING, view["phase"]) self.assertTrue(view["your_turn"]) self.assertEqual( ["01D", "01C", "01S", "01B"], view["callable_cards"] ) other = rules.state_for_player(state, "p2") self.assertNotIn("callable_cards", other) class PlayTest(unittest.TestCase): def test_five_player_trick_resolution(self) -> None: state = make_state( [["02D"], ["05D"], ["04D"], ["07D"], ["06D"]], briscola_suit="S", caller=0, called_card="01D", called_played=True, partner_seat=4, turn=0, ) play_all(state) assert state.last_trick is not None self.assertEqual(3, state.last_trick.winner) def test_trump_wins(self) -> None: state = make_state( [["02D"], ["01D"], ["04D"], ["02C"], ["03D"]], briscola_suit="C", caller=0, called_card="01D", called_played=True, partner_seat=4, turn=0, ) play_all(state) assert state.last_trick is not None self.assertEqual(3, state.last_trick.winner) def test_called_card_reveals_the_partner(self) -> None: state = make_state( [["02D"], ["01D"], ["04D"], ["07D"], ["06D"]], briscola_suit="D", caller=0, called_card="01S", turn=1, ) self.assertFalse(state.called_played) view = rules.state_for_player(state, "p2") roles = {p["seat"]: p["team"] for p in view["players"]} self.assertEqual(ROLE_CALLER, roles[0]) self.assertNotIn("scores", view) rules.play(state, "p1", "01D") # The called card has not been played yet. self.assertFalse(state.called_played) # Drive the trick until the called card appears: give it to seat 3. state.players[3].hand = [card("01S")] state.turn = 3 rules.play(state, "p3", "01S") self.assertTrue(state.called_played) self.assertEqual(3, state.partner_seat) view = rules.state_for_player(state, "p2") roles = {p["seat"]: p["team"] for p in view["players"]} self.assertEqual(ROLE_CALLER, roles[0]) self.assertEqual(ROLE_PARTNER, roles[3]) self.assertEqual(ROLE_DEFENDER, roles[1]) self.assertIn("scores", view) def test_called_card_played_by_the_caller_is_solo(self) -> None: state = make_state( [["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]], briscola_suit="S", caller=0, called_card="01S", solo=True, turn=0, ) rules.play(state, "p0", "01S") self.assertTrue(state.called_played) self.assertIsNone(state.partner_seat) view = rules.state_for_player(state, "p4") self.assertTrue(view["solo"]) roles = {p["seat"]: p["team"] for p in view["players"]} self.assertEqual(ROLE_CALLER, roles[0]) self.assertEqual(ROLE_DEFENDER, roles[1]) def test_play_validations(self) -> None: state = make_state( [["02D"], ["05D"], ["04D"], ["07D"], ["06D"]], briscola_suit="S", caller=0, turn=0, ) with self.assertRaises(NotYourTurn): rules.play(state, "p1", "05D") with self.assertRaises(CardNotInHand): rules.play(state, "p0", "01D") state.phase = PHASE_FINISHED with self.assertRaises(GameFinished): rules.play(state, "p0", "02D") def test_play_rejected_before_the_call(self) -> None: state = make_state( [["02D"], ["05D"], ["04D"], ["07D"], ["06D"]], caller=0, turn=0, phase=PHASE_CALLING, ) with self.assertRaises(IllegalMove): rules.play(state, "p0", "02D") def test_auto_play_advances(self) -> None: state = make_state( [["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]], briscola_suit="S", caller=0, called_card="09B", turn=0, ) rules.auto_play(state, random.Random(7)) self.assertEqual(1, state.turn) self.assertEqual(1, len(state.trick)) self.assertEqual(1, len(state.players[0].hand)) class ScoringTest(unittest.TestCase): def _piles(self): # 60 points in denari + coppe, 60 in spade + bastoni. a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [ f"{rank:02d}C" for rank in (1, 3, 10, 9, 8) ] b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [ f"{rank:02d}B" for rank in (1, 3, 10, 9, 8) ] return a_cards, b_cards def test_side_points_split_caller_side_and_defenders(self) -> None: state = make_state( [[], [], [], [], []], briscola_suit="D", caller=0, partner_seat=3, called_played=True, captured=[ ["01D"], # caller, side A: 11 ["03C", "01S"], # defender: 21 ["10D"], # defender: 4 ["01C"], # partner, side A: 11 ["03S"], # defender: 10 ], ) caller_points, defender_points = rules.side_points(state) self.assertEqual(22, caller_points) self.assertEqual(35, defender_points) def test_solo_side_points(self) -> None: state = make_state( [[], [], [], [], []], briscola_suit="D", caller=0, solo=True, called_played=True, captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]], ) caller_points, defender_points = rules.side_points(state) self.assertEqual(11, caller_points) self.assertEqual(35, defender_points) def test_calling_side_wins_at_the_target(self) -> None: a_cards, b_cards = self._piles() b_rest = [c for c in b_cards if c != "01S"] state = make_state( [[], [], [], [], []], briscola_suit="D", caller=0, partner_seat=1, called_played=True, captured=[a_cards, ["01S"], [], b_rest, []], ) rules._end_deal(state) self.assertEqual(PHASE_FINISHED, state.phase) self.assertEqual(SIDE_CALLER, state.winner) self.assertEqual([71, 49], state.scores) def test_defenders_win_below_the_declared_target(self) -> None: a_cards, b_cards = self._piles() # Calling side only 30 points of the 61 needed. state = make_state( [[], [], [], [], []], briscola_suit="D", caller=0, partner_seat=1, called_played=True, captured=[a_cards[:5], [], [], b_cards, []], ) rules._end_deal(state) self.assertEqual(PHASE_FINISHED, state.phase) self.assertEqual(SIDE_DEFENDERS, state.winner) self.assertEqual([30, 60], state.scores) def test_patta_replays_the_deal_with_a_new_auction(self) -> None: a_cards, b_cards = self._piles() state = make_state( [[], [], [], [], []], briscola_suit="D", caller=0, partner_seat=1, called_played=True, captured=[a_cards, [], [], b_cards, []], ) rules._end_deal(state) self.assertEqual(PHASE_AUCTION, state.phase) self.assertEqual(2, state.hand_number) self.assertEqual(1, state.dealer) assert state.auction is not None self.assertEqual(2, state.auction.turn) self.assertIsNone(state.caller) self.assertIsNone(state.called_card) # The patta entry is kept in the audit trail. entry = state.hand_scores[-1] self.assertTrue(entry["patta"]) self.assertEqual({"A": 60, "B": 60}, entry["points"]) def test_raised_target_disables_patta(self) -> None: a_cards, b_cards = self._piles() state = make_state( [[], [], [], [], []], briscola_suit="D", caller=0, partner_seat=1, called_played=True, declared_target=62, captured=[a_cards, [], [], b_cards, []], ) rules._end_deal(state) self.assertEqual(PHASE_FINISHED, state.phase) self.assertEqual(SIDE_DEFENDERS, state.winner) def test_full_deal_scores_120(self) -> None: state = started_state() rng = random.Random(2026) moves = 0 while state.phase != PHASE_FINISHED and moves < 5000: if state.phase == PHASE_AUCTION: rules.auto_bid(state) elif state.phase == PHASE_CALLING: rules.auto_call(state, rng) else: rules.auto_play(state, rng) moves += 1 self.assertEqual(PHASE_FINISHED, state.phase) self.assertEqual(120, sum(state.scores)) self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS)) captured = sum(len(p.captured) for p in state.players) self.assertEqual(40, captured) class ViewTest(unittest.TestCase): def test_state_for_player_hides_other_hands(self) -> None: state = make_state( [["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]], briscola_suit="D", caller=0, called_card="01D", called_played=False, turn=0, ) view = rules.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("01D", view["called_card"]) self.assertEqual(5, view["player_count"]) def test_spectator_sees_public_information_only(self) -> None: state = make_state( [["02D"], ["04D"], ["05D"], ["06D"], ["07D"]], briscola_suit="D", caller=2, called_card="01D", called_played=False, ) view = rules.state_for_player(state, None) for player in view["players"]: self.assertNotIn("hand", player) self.assertNotIn("your_turn", view) self.assertNotIn("scores", view) roles = {p["seat"]: p["team"] for p in view["players"]} self.assertEqual(ROLE_CALLER, roles[2]) self.assertEqual("", roles[0]) def test_player_points_are_public(self) -> None: state = make_state( [["02D"], ["04D"], ["05D"], ["06D"], ["07D"]], briscola_suit="D", caller=0, captured=[["01C", "03B"], ["10D"], [], [], []], ) view = rules.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"]) class SerializationTest(unittest.TestCase): def test_state_roundtrip(self) -> None: state = started_state() bid(state, 1, 10) bid(state, 2, 7) restored = ChiamataState.from_json(state.to_json()) self.assertEqual(state.phase, restored.phase) self.assertEqual(state.hand_number, restored.hand_number) self.assertEqual(state.turn, restored.turn) assert restored.auction is not None and state.auction is not None self.assertEqual(state.auction.active, restored.auction.active) self.assertEqual(state.auction.passed, restored.auction.passed) self.assertEqual( state.auction.history, restored.auction.history ) 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 = started_state() data = state.to_json() del data["auction"] del data["called_card"] del data["declared_target"] restored = ChiamataState.from_json(data) self.assertIsNone(restored.auction) self.assertIsNone(restored.called_card) self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target) self.assertFalse(restored.solo) if __name__ == "__main__": unittest.main()