CI / Build and push docker image (push) Successful in 5m35s
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.
422 lines
16 KiB
Python
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()
|