"""Adapter tests: the platform contract over the pure briscola rules.""" from __future__ import annotations import unittest from datetime import datetime, timedelta, timezone from tavolo.platform import GameSession, Seat from tavolo.platform.errors import ( AlreadyJoined, GameError, GameFinished, GameNotStarted, IllegalMove, ) from tavolo.briscola import BriscolaEngine, BriscolaQuattroEngine from tavolo.briscola import engine as rules from tavolo.briscola.state import ( PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING, BriscolaState, ) def _session(engine, **options) -> GameSession: session = GameSession( id="b1", game_type=engine.id, join_code="CODE01", creator_sub="alice", players=[Seat(user_sub="alice", display_name="Alice")], ) engine.create(session, options) return session def _started(engine, **options) -> GameSession: session = _session(engine, **options) for i in range(1, engine.max_players): name = f"p{i}" engine.join(session, name, name.capitalize()) return session 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_HAND_END: for player in state.players: engine.handle_action(session, player.sub, "ack", {}) continue player = next(p for p in state.players if p.seat == state.turn) engine.handle_action( session, player.sub, "play", {"card": player.hand[0].code} ) moves += 1 class MetadataTest(unittest.TestCase): def test_two_player_metadata(self) -> None: engine = BriscolaEngine() self.assertEqual("briscola", engine.id) self.assertEqual("Briscola", engine.name) self.assertTrue(engine.description) self.assertEqual(2, engine.min_players) self.assertEqual(2, engine.max_players) target = engine.options_schema["properties"]["target_score"] self.assertEqual("integer", target["type"]) self.assertEqual(61, target["minimum"]) self.assertEqual(120, target["maximum"]) self.assertEqual(61, target["default"]) def test_four_player_metadata(self) -> None: engine = BriscolaQuattroEngine() self.assertEqual("briscola_a_quattro", engine.id) self.assertEqual("Briscola a quattro", engine.name) self.assertTrue(engine.description) self.assertEqual(4, engine.min_players) self.assertEqual(4, engine.max_players) self.assertIn("target_score", engine.options_schema["properties"]) class CreateTest(unittest.TestCase): def test_create_seats_creator_on_team_a(self) -> None: engine = BriscolaEngine() session = _session(engine) self.assertEqual("A", session.players[0].team) self.assertIsInstance(session.state, BriscolaState) self.assertEqual(61, session.state.target_score) self.assertEqual(2, session.state.player_count) def test_create_options(self) -> None: engine = BriscolaQuattroEngine() session = _session(engine, target_score=101) self.assertEqual(101, session.state.target_score) self.assertEqual(4, session.state.player_count) def test_create_rejects_bad_options(self) -> None: engine = BriscolaEngine() with self.assertRaises(IllegalMove): _session(engine, target_score=60) with self.assertRaises(IllegalMove): _session(engine, target_score=121) with self.assertRaises(IllegalMove): _session(engine, target_score="sixtyone") with self.assertRaises(IllegalMove): _session(engine, target_score=True) def test_timeouts_come_from_the_engine(self) -> None: engine = BriscolaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9) session = _session(engine) self.assertEqual(7, session.state.turn_timeout) self.assertEqual(9, session.state.hand_ack_timeout) class JoinTest(unittest.TestCase): def test_two_player_join_starts(self) -> None: engine = BriscolaEngine() session = _session(engine) self.assertTrue(engine.in_lobby(session)) engine.join(session, "bob", "Bob") self.assertEqual("B", session.players[-1].team) self.assertFalse(engine.in_lobby(session)) self.assertEqual(PHASE_PLAYING, session.state.phase) def test_four_player_teams_alternate(self) -> None: engine = BriscolaQuattroEngine() session = _session(engine) teams = [] for name in ("bob", "carol", "dave"): engine.join(session, name, name.capitalize()) teams.append(session.players[-1].team) self.assertEqual(["B", "A", "B"], teams) self.assertEqual(PHASE_PLAYING, session.state.phase) def test_join_errors(self) -> None: engine = BriscolaEngine() session = _session(engine) with self.assertRaises(AlreadyJoined): engine.join(session, "alice", "Alice") engine.join(session, "bob", "Bob") with self.assertRaises(GameNotStarted): engine.join(session, "carol", "Carol") class ActionTest(unittest.TestCase): def test_unknown_action_rejected(self) -> None: engine = BriscolaEngine() session = _started(engine) with self.assertRaises(IllegalMove): engine.handle_action(session, "alice", "dance", {}) def test_play_validates_payload(self) -> None: engine = BriscolaEngine() session = _started(engine) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "play", {"card": 42}) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "play", {}) with self.assertRaises(IllegalMove): engine.handle_action(session, "p1", "play", {"card": "nope"}) def test_play_advances_the_trick(self) -> None: engine = BriscolaEngine() session = _started(engine) state = session.state self.assertEqual(1, state.turn) card = state.players[1].hand[0].code engine.handle_action(session, "p1", "play", {"card": card}) self.assertEqual([{"seat": 1, "card": card}], engine.view_for(session, "p1")["trick"]) def test_play_out_of_turn_rejected(self) -> None: engine = BriscolaEngine() session = _started(engine) state = session.state card = state.players[0].hand[0].code with self.assertRaises(GameError): engine.handle_action(session, "alice", "play", {"card": card}) def test_ack_outside_hand_end_rejected(self) -> None: engine = BriscolaEngine() session = _started(engine) with self.assertRaises(IllegalMove): engine.handle_action(session, "alice", "ack", {}) def test_finished_match_rejects_actions(self) -> None: engine = BriscolaEngine() session = _started(engine) _drive_to_finish(engine, session) self.assertTrue(engine.is_finished(session)) with self.assertRaises(GameFinished): engine.handle_action(session, "alice", "play", {"card": "01D"}) def test_ack_all_deals_next(self) -> None: engine = BriscolaEngine() session = _started(engine, target_score=120) state = session.state # Force the deal-end phase: both players keep 3 cards, so the # match continues after the summary. state.phase = PHASE_HAND_END state.acked = [] state.hand_end_deadline = ( datetime.now(timezone.utc) + timedelta(seconds=60) ).isoformat() engine.handle_action(session, "alice", "ack", {}) self.assertEqual(PHASE_HAND_END, state.phase) engine.handle_action(session, "p1", "ack", {}) self.assertEqual(PHASE_PLAYING, state.phase) self.assertEqual(2, state.hand_number) class ViewTest(unittest.TestCase): def test_view_for_hides_other_hands(self) -> None: engine = BriscolaEngine() session = _started(engine) view = engine.view_for(session, "alice") players = {p["seat"]: p for p in view["players"]} self.assertIn("hand", players[0]) self.assertNotIn("hand", players[1]) # The envelope is the platform's job, not the view's. self.assertNotIn("id", view) self.assertNotIn("join_code", view) self.assertNotIn("game_type", view) self.assertIn("briscola_suit", view) self.assertIn("stock_count", view) def test_view_for_spectator_hides_all_hands(self) -> None: engine = BriscolaQuattroEngine() session = _started(engine) view = engine.view_for(session, None) for player in view["players"]: self.assertNotIn("hand", player) self.assertNotIn("your_turn", view) self.assertEqual({"A": 0, "B": 0}, view["scores"]) def test_lobby_view(self) -> None: engine = BriscolaEngine() session = _session(engine, target_score=101) lobby = engine.lobby_view(session) self.assertEqual("lobby", lobby["phase"]) self.assertEqual(101, lobby["target_score"]) class SerializationTest(unittest.TestCase): def test_state_roundtrip(self) -> None: engine = BriscolaQuattroEngine() session = _started(engine) restored = engine.state_from_json(engine.state_to_json(session.state)) self.assertIsInstance(restored, BriscolaState) self.assertEqual(session.state.phase, restored.phase) self.assertEqual(session.state.turn, restored.turn) 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 = BriscolaEngine() self.assertIsNone(engine.next_deadline(_session(engine))) def test_turn_deadline_and_revalidation(self) -> None: engine = BriscolaEngine() session = _started(engine) deadline = engine.next_deadline(session) assert deadline is not None self.assertEqual("turn", deadline.kind) with self.assertRaises(GameError): engine.fire_deadline(session, "turn", "turn:1:1:0") turn_before = session.state.turn engine.fire_deadline(session, deadline.kind, deadline.token) self.assertNotEqual(turn_before, session.state.turn) def test_stale_deadline_after_play(self) -> None: engine = BriscolaEngine() session = _started(engine) deadline = engine.next_deadline(session) assert deadline is not None state = session.state player = next(p for p in state.players if p.seat == state.turn) engine.handle_action(session, player.sub, "play", {"card": player.hand[0].code}) with self.assertRaises(GameError): engine.fire_deadline(session, deadline.kind, deadline.token) def test_hand_end_deadline_advances(self) -> None: engine = BriscolaEngine() session = _started(engine, target_score=120) state = session.state state.phase = PHASE_HAND_END state.hand_end_deadline = ( datetime.now(timezone.utc) + timedelta(seconds=60) ).isoformat() deadline = engine.next_deadline(session) assert deadline is not None self.assertEqual("hand_end", deadline.kind) engine.fire_deadline(session, deadline.kind, deadline.token) self.assertEqual(PHASE_PLAYING, session.state.phase) self.assertEqual(2, session.state.hand_number) class ResultTest(unittest.TestCase): def test_result_of_finished_match(self) -> None: engine = BriscolaQuattroEngine() session = _started(engine) _drive_to_finish(engine, session) result = engine.result(session) self.assertEqual(2, len(result.teams)) self.assertEqual([2, 2], [len(team) for team in result.teams]) self.assertIn(result.winner_team, (0, 1)) self.assertEqual(4, len(result.players)) winner_name = "A" if result.winner_team == 0 else "B" for player in result.players: self.assertEqual(player.won, player.team == winner_name) self.assertIn("captured_cards", player.details) self.assertIn("team_a_score", result.summary) self.assertIn("team_b_score", result.summary) self.assertIn("winner_team", result.summary) self.assertIn("target_score", result.summary) self.assertIn("deals_played", result.summary) self.assertIn("deal_scores", result.summary) def test_two_player_result(self) -> None: engine = BriscolaEngine() session = _started(engine) _drive_to_finish(engine, session) result = engine.result(session) self.assertEqual([[session.players[0].user_sub], [session.players[1].user_sub]], result.teams) self.assertEqual(2, len(result.players)) def test_result_requires_finished_match(self) -> None: engine = BriscolaEngine() with self.assertRaises(GameError): engine.result(_started(engine)) def test_game_over_view(self) -> None: engine = BriscolaEngine() session = _started(engine) session.state.phase = PHASE_FINISHED session.state.winner = 1 session.state.scores = [55, 65] over = engine.game_over_view(session, "alice") self.assertEqual({"A": 55, "B": 65}, over["scores"]) self.assertEqual("B", over["winner"]) if __name__ == "__main__": unittest.main()