"""Adapter tests: the platform contract over the briscola chiamata rules.""" from __future__ import annotations import unittest from datetime import datetime, timedelta, timezone from tavolo.briscola import BriscolaChiamataEngine from tavolo.briscola import chiamata as rules from tavolo.briscola.chiamata import ( PHASE_AUCTION, PHASE_CALLING, PHASE_FINISHED, PHASE_LOBBY, PHASE_PLAYING, ROLE_CALLER, ROLE_DEFENDER, ROLE_PARTNER, ChiamataState, ) from tavolo.briscola.state import Card from tavolo.platform import GameSession, Seat from tavolo.platform.errors import ( AlreadyJoined, GameError, GameNotStarted, IllegalMove, ) def _session(engine, **options) -> GameSession: session = GameSession( id="c1", game_type=engine.id, join_code="CODE01", creator_sub="p0", players=[Seat(user_sub="p0", display_name="P0")], ) engine.create(session, options) return session def _started(engine, **options) -> GameSession: session = _session(engine, **options) for i in range(1, engine.max_players): engine.join(session, f"p{i}", f"P{i}") return session def _player_on_turn(session: GameSession): state = session.state return next(p for p in state.players if p.seat == state.turn) def _drive_to_finish(engine, session: GameSession, limit: int = 200000) -> None: moves = 0 while not engine.is_finished(session) and moves < limit: state = session.state if state.phase == PHASE_AUCTION: player = _player_on_turn(session) try: engine.handle_action(session, player.sub, "pass", {}) except IllegalMove: # The last player standing must bid. engine.handle_action(session, player.sub, "bid", {"rank": 2}) elif state.phase == PHASE_CALLING: assert state.auction is not None and state.auction.current is not None caller = next( p for p in state.players if p.seat == state.caller ) card = Card(rank=state.auction.current.rank, suit="D") engine.handle_action(session, caller.sub, "call", {"card": card.code}) else: player = _player_on_turn(session) engine.handle_action( session, player.sub, "play", {"card": player.hand[0].code} ) moves += 1 assert engine.is_finished(session), "match did not finish" class MetadataTest(unittest.TestCase): def test_metadata(self) -> None: engine = BriscolaChiamataEngine() self.assertEqual("briscola_chiamata", engine.id) self.assertEqual("Briscola chiamata", engine.name) self.assertTrue(engine.description) self.assertEqual(5, engine.min_players) self.assertEqual(5, engine.max_players) self.assertEqual({}, engine.options_schema) class CreateJoinTest(unittest.TestCase): def test_create_seats_creator_without_a_team(self) -> None: engine = BriscolaChiamataEngine() session = _session(engine) self.assertIsNone(session.players[0].team) self.assertIsInstance(session.state, ChiamataState) self.assertEqual(PHASE_LOBBY, session.state.phase) self.assertTrue(engine.in_lobby(session)) def test_join_starts_when_full(self) -> None: engine = BriscolaChiamataEngine() session = _session(engine) self.assertTrue(engine.in_lobby(session)) for i in range(1, 4): engine.join(session, f"p{i}", f"P{i}") self.assertTrue(engine.in_lobby(session)) engine.join(session, "p4", "P4") self.assertFalse(engine.in_lobby(session)) self.assertEqual(PHASE_AUCTION, session.state.phase) self.assertEqual(5, len(session.players)) for seat in session.players: self.assertIsNone(seat.team) def test_join_errors(self) -> None: engine = BriscolaChiamataEngine() session = _session(engine) with self.assertRaises(AlreadyJoined): engine.join(session, "p0", "P0") for i in range(1, 5): engine.join(session, f"p{i}", f"P{i}") with self.assertRaises(GameNotStarted): engine.join(session, "p5", "P5") def test_timeouts_come_from_the_engine(self) -> None: engine = BriscolaChiamataEngine( turn_timeout_seconds=7, hand_ack_timeout_seconds=9 ) session = _session(engine) self.assertEqual(7, session.state.turn_timeout) class ActionTest(unittest.TestCase): def test_unknown_action_rejected(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "dance", {}) def test_bid_payload_validation(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "bid", {}) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "bid", {"rank": "ace"}) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "bid", {"rank": True}) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "bid", {"points": False}) # points cannot be raised before a bid of the 2 with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "bid", {"points": 64}) def test_auction_flow_through_actions(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) engine.handle_action(session, "p1", "bid", {"rank": 1}) engine.handle_action(session, "p2", "pass", {}) engine.handle_action(session, "p3", "bid", {"rank": 2}) engine.handle_action(session, "p4", "bid", {"points": 64}) engine.handle_action(session, "p0", "pass", {}) engine.handle_action(session, "p1", "pass", {}) engine.handle_action(session, "p3", "pass", {}) state = session.state self.assertEqual(PHASE_CALLING, state.phase) self.assertEqual(4, state.caller) self.assertEqual(64, state.declared_target) assert state.auction is not None and state.auction.current is not None self.assertEqual(2, state.auction.current.rank) engine.handle_action(session, "p4", "call", {"card": "02S"}) self.assertEqual(PHASE_PLAYING, state.phase) self.assertEqual("S", state.briscola_suit) # Play the first card of the player on turn. player = _player_on_turn(session) engine.handle_action( session, player.sub, "play", {"card": player.hand[0].code} ) self.assertEqual(1, len(state.trick)) def test_call_payload_validation(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) engine.handle_action(session, "p1", "bid", {"rank": 2}) for i in (2, 3, 4, 0): engine.handle_action(session, f"p{i}", "pass", {}) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "call", {"card": 42}) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "call", {"card": "nope"}) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "call", {"card": "01D"}) def test_finished_match_rejects_actions(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) _drive_to_finish(engine, session) with self.assertRaises(GameError): engine.handle_action(session, session.players[0].user_sub, "play", {}) class ViewTest(unittest.TestCase): def test_view_hides_other_hands_and_roles(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) view = engine.view_for(session, "p0") players = {p["seat"]: p for p in view["players"]} self.assertIn("hand", players[0]) self.assertNotIn("hand", players[1]) self.assertNotIn("your_turn", view) self.assertNotIn("scores", view) self.assertEqual(5, view["player_count"]) # p1 opens the auction. auction = view["auction"] self.assertEqual(1, auction["turn"]) self.assertIsNone(auction["current"]) def test_view_marks_the_acting_player(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) view = engine.view_for(session, "p1") self.assertTrue(view["your_turn"]) self.assertIn("legal_bids", view) # The caller is labeled as soon as the auction is won. engine.handle_action(session, "p1", "bid", {"rank": 1}) for i in (2, 3, 4, 0): engine.handle_action(session, f"p{i}", "pass", {}) view = engine.view_for(session, "p0") self.assertIn("callable_cards", engine.view_for(session, "p1")) roles = {p["seat"]: p["team"] for p in view["players"]} self.assertEqual(ROLE_CALLER, roles[1]) def test_spectator_view(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) view = engine.view_for(session, None) for player in view["players"]: self.assertNotIn("hand", player) self.assertNotIn("your_turn", view) def test_lobby_view(self) -> None: engine = BriscolaChiamataEngine() session = _session(engine) lobby = engine.lobby_view(session) self.assertEqual("lobby", lobby["phase"]) self.assertEqual(61, lobby["declared_target"]) class SerializationTest(unittest.TestCase): def test_state_roundtrip(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) engine.handle_action(session, "p1", "bid", {"rank": 10}) restored = engine.state_from_json(engine.state_to_json(session.state)) self.assertIsInstance(restored, ChiamataState) self.assertEqual(session.state.phase, restored.phase) self.assertEqual(session.state.turn, restored.turn) assert restored.auction is not None and session.state.auction is not None self.assertEqual(session.state.auction.history, restored.auction.history) self.assertEqual( [p.sub for p in session.state.players], [p.sub for p in restored.players], ) class DeadlineTest(unittest.TestCase): def test_no_deadline_in_lobby(self) -> None: engine = BriscolaChiamataEngine() self.assertIsNone(engine.next_deadline(_session(engine))) def test_auction_deadline_and_revalidation(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) deadline = engine.next_deadline(session) assert deadline is not None self.assertEqual("auction", deadline.kind) with self.assertRaises(GameError): engine.fire_deadline(session, "auction", "auction:1:1:0") engine.fire_deadline(session, deadline.kind, deadline.token) # The auto-bid moved the auction on: a fresh deadline exists. state = session.state self.assertIn( state.phase, (PHASE_AUCTION, PHASE_CALLING, PHASE_PLAYING) ) self.assertIsNotNone(engine.next_deadline(session)) def test_stale_auction_deadline_after_a_bid(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) deadline = engine.next_deadline(session) assert deadline is not None engine.handle_action(session, "p1", "bid", {"rank": 1}) with self.assertRaises(GameError): engine.fire_deadline(session, deadline.kind, deadline.token) def test_call_deadline_auto_calls(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) engine.handle_action(session, "p1", "bid", {"rank": 2}) for i in (2, 3, 4, 0): engine.handle_action(session, f"p{i}", "pass", {}) deadline = engine.next_deadline(session) assert deadline is not None self.assertEqual("call", deadline.kind) engine.fire_deadline(session, deadline.kind, deadline.token) state = session.state self.assertEqual(PHASE_PLAYING, state.phase) assert state.called_card is not None self.assertEqual(2, state.called_card.rank) def test_turn_deadline_auto_plays(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) engine.handle_action(session, "p1", "bid", {"rank": 2}) for i in (2, 3, 4, 0): engine.handle_action(session, f"p{i}", "pass", {}) engine.handle_action(session, "p1", "call", {"card": "02D"}) deadline = engine.next_deadline(session) assert deadline is not None self.assertEqual("turn", deadline.kind) state = session.state before = state.turn engine.fire_deadline(session, deadline.kind, deadline.token) self.assertEqual(1, len(state.trick)) self.assertNotEqual(before, state.turn) class ResultTest(unittest.TestCase): def test_result_of_finished_match(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) _drive_to_finish(engine, session) state = session.state result = engine.result(session) sizes = sorted(len(team) for team in result.teams) self.assertIn(sizes, ([2, 3], [1, 4])) self.assertEqual(5, len(result.players)) self.assertIn(result.winner_team, (0, 1)) for player in result.players: self.assertIn(player.team, (ROLE_CALLER, ROLE_PARTNER, ROLE_DEFENDER)) self.assertEqual( player.won, (player.team in (ROLE_CALLER, ROLE_PARTNER)) == (result.winner_team == 0), ) self.assertIn("points", player.details) summary = result.summary for key in ( "team_a_score", "team_b_score", "winner_team", "declared_target", "called_card", "caller", "solo", "deals_played", "deal_scores", ): self.assertIn(key, summary) self.assertIn(summary["winner_team"], ("Chiamante", "Difensori")) self.assertEqual(state.hand_number, summary["deals_played"]) def test_result_requires_finished_match(self) -> None: engine = BriscolaChiamataEngine() with self.assertRaises(GameError): engine.result(_started(engine)) def test_solo_result_teams(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) state = session.state # Force a finished solo match: seat 0 called its own card. state.caller = 0 state.solo = True state.partner_seat = None state.called_played = True state.called_card = Card.parse("01D") # Caller side captures 71 (60 + 11), defenders 49. state.players[0].captured = [ Card.parse(code) for code in [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)] + ["01S"] ] state.players[1].captured = [ Card.parse(code) for code in [f"{rank:02d}S" for rank in (3, 10, 9, 8)] + [f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)] ] state.scores = [71, 49] state.winner = 0 state.phase = PHASE_FINISHED result = engine.result(session) self.assertEqual([1, 4], sorted(len(team) for team in result.teams)) # The lone caller won: exactly one winner. self.assertEqual(1, sum(1 for p in result.players if p.won)) caller = next(p for p in result.players if p.team == ROLE_CALLER) self.assertTrue(caller.won) self.assertEqual(71.0, caller.score) def test_game_over_view(self) -> None: engine = BriscolaChiamataEngine() session = _started(engine) state = session.state state.phase = PHASE_FINISHED state.winner = 1 state.scores = [55, 65] state.caller = 2 state.solo = False state.partner_seat = 4 state.called_card = Card.parse("03C") over = engine.game_over_view(session, "p0") self.assertEqual({"A": 55, "B": 65}, over["scores"]) self.assertEqual("Difensori", over["winner"]) self.assertEqual("03C", over["called_card"]) if __name__ == "__main__": unittest.main()