Files
tavolo/server/packages/tavolo-briscola/tests/test_chiamata_plugin.py
T
woggioni 353f012906
CI / Build and push docker image (push) Successful in 5m35s
Add briscola chiamata
Five-player briscola with a descending calling auction and a secret
partner. Starting with the player after the dealer, each player bids a
lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the
standing bid is the two the auction continues on points (62, 64, ...).
The last player standing must bid, and the winner names any card of the
won rank: its holder is the secret partner, or the caller plays alone
when it is one of their own cards, and its suit becomes the trump. The
calling side must reach 61 (or the raised target); a 60-60 deal with the
plain target is a patta and is replayed. A match is a single deal, so
the caller and partner (or the lone caller) against the other players
map onto the platform's two-team result, Elo and match history.

The rules live in tavolo.briscola.chiamata with the
BriscolaChiamataEngine adapter registered alongside the other briscola
variants; the partner's role and the side totals stay hidden until the
called card is played. The web client gets a five-seat table, the
auction panel with legal bids and point raises, the call picker, the
reveal bands and a chiamata-aware final summary.
2026-10-08 21:50:56 +08:00

422 lines
16 KiB
Python

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