Files
tavolo/server/packages/tavolo-briscola/tests/test_plugin.py
T
woggioni 7bb6b74fdc
CI / Build and push docker image (push) Successful in 1m45s
Add briscola: classic two-player and briscola a quattro
New tavolo-briscola distribution implementing briscola on the platform:

- pure rules (state.py, engine.py): 3-card deals, a face-up trump that
  is the last card drawn, trump-first trick resolution (A > 3 > K > H >
  J > 7 > 6 > 5 > 4 > 2), 120 points per deal, 60-60 patta re-deals, a
  configurable target score (default 61), deal-end acknowledgements and
  turn auto-play;
- BriscolaEngine (briscola, 2 players) and BriscolaQuattroEngine
  (briscola_a_quattro, 4 players in fixed partnerships) adapters;
- registered in tavolo-app, packaged in the Docker image, documented.

Platform fixes surfaced by a two-player lobby:

- the websocket state and snapshot payloads now carry seats_open (they
  were only in the REST lobby payload), so lobbies with player counts
  other than four render correctly; seats_open is clamped at zero;
- the web client logs unparseable server messages instead of silently
  dropping them.

Web client: briscola table for both variants (stock, face-up trump,
trick, last-trick banner), deal summary modal and game-over overlay.

Tests: tavolo-briscola rules/adapter suites, app integration tests
(lobby, websocket play, turn timeout, deal-end acks, persistence), a
platform seats_open test and frontend model parsing tests.
2026-10-06 14:08:25 +08:00

350 lines
14 KiB
Python

"""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()