Files
tavolo/server/packages/tavolo-briscola/tests/test_chiamata.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

643 lines
23 KiB
Python

"""Rules tests for briscola chiamata: auction, call, play, scoring, patta."""
from __future__ import annotations
import random
import unittest
from tavolo.briscola import chiamata as rules
from tavolo.briscola.chiamata import (
BID_LADDER,
Bid,
ChiamataPlayer,
ChiamataState,
PHASE_AUCTION,
PHASE_CALLING,
PHASE_FINISHED,
PHASE_PLAYING,
ROLE_CALLER,
ROLE_DEFENDER,
ROLE_PARTNER,
SIDE_CALLER,
SIDE_DEFENDERS,
)
from tavolo.briscola.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.briscola.state import Card
def card(code: str) -> Card:
return Card.parse(code)
def started_state() -> ChiamataState:
"""A five-player game whose auction has just opened (dealer seat 0)."""
state = rules.create_game("p0", "p0")
for i in range(1, 5):
rules.join_game(state, f"p{i}", f"p{i}")
return state
def bid(state: ChiamataState, seat: int, rank: int) -> None:
rules.bid_rank(state, f"p{seat}", rank)
def make_state(
hands,
*,
briscola_suit: str = "S",
called_card: str | None = None,
caller: int | None = None,
solo: bool = False,
called_played: bool = False,
partner_seat: int | None = None,
captured=None,
turn: int = 0,
dealer: int = 0,
declared_target: int = rules.DEFAULT_TARGET,
phase: str = PHASE_PLAYING,
hand_number: int = 1,
) -> ChiamataState:
"""Build a controlled playing state directly (bypassing the auction)."""
state = ChiamataState(
phase=phase,
dealer=dealer,
hand_number=hand_number,
declared_target=declared_target,
briscola_suit=briscola_suit,
called_card=card(called_card) if called_card else None,
caller=caller,
solo=solo,
called_played=called_played,
partner_seat=partner_seat,
turn=turn,
)
for seat, hand in enumerate(hands):
state.players.append(
ChiamataPlayer(
sub=f"p{seat}", name=f"p{seat}", seat=seat,
hand=[card(c) for c in hand],
)
)
if captured is not None:
for player, codes in zip(state.players, captured):
player.captured = [card(c) for c in codes]
return state
def play_all(state: ChiamataState) -> None:
"""Play the first card of each hand in turn order until the trick resolves."""
for _ in range(rules.PLAYER_COUNT):
player = next(p for p in state.players if p.seat == state.turn)
rules.play(state, player.sub, player.hand[0].code)
class SetupTest(unittest.TestCase):
def test_five_player_deal(self) -> None:
state = started_state()
self.assertEqual(PHASE_AUCTION, state.phase)
self.assertEqual(rules.PLAYER_COUNT, len(state.players))
for player in state.players:
self.assertEqual(rules.HAND_SIZE, len(player.hand))
# The whole deck is dealt: no stock, no face-up card.
dealt = [c.code for p in state.players for c in p.hand]
self.assertEqual(40, len(dealt))
self.assertEqual(40, len(set(dealt)))
# The auction opens with the player after the dealer.
assert state.auction is not None
self.assertEqual(1, state.auction.turn)
self.assertEqual([0, 1, 2, 3, 4], state.auction.active)
self.assertIsNone(state.auction.current)
def test_join_errors(self) -> None:
from tavolo.platform.errors import AlreadyJoined, GameNotStarted, LobbyFull
state = rules.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
rules.join_game(state, "p0", "p0")
for i in range(1, 5):
rules.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_AUCTION, state.phase)
with self.assertRaises(GameNotStarted):
rules.join_game(state, "p5", "p5")
lobby = rules.create_game("p0", "p0")
for i in range(1, 5):
rules.join_game(lobby, f"p{i}", f"p{i}")
# Joining a sixth player is impossible once the fifth started the
# match; exercise LobbyFull on an artificially unfilled start.
unfilled = rules.create_game("p0", "p0")
unfilled.phase = rules.PHASE_LOBBY
for i in range(1, 5):
unfilled.players.append(
ChiamataPlayer(sub=f"p{i}", name=f"p{i}", seat=i)
)
with self.assertRaises(LobbyFull):
rules.join_game(unfilled, "p5", "p5")
class AuctionTest(unittest.TestCase):
def test_first_bid_accepts_any_rank(self) -> None:
state = started_state()
rules.bid_rank(state, "p1", 5)
assert state.auction is not None
self.assertEqual(Bid(seat=1, rank=5, points=61), state.auction.current)
def test_bids_must_descend(self) -> None:
state = started_state()
bid(state, 1, 1) # ace
with self.assertRaises(IllegalMove):
bid(state, 2, 1) # same rank
bid(state, 2, 3) # three, weaker than the ace
bid(state, 3, 10) # king, weaker than the three
with self.assertRaises(IllegalMove):
bid(state, 4, 3) # three is stronger than the king
bid(state, 4, 9) # horse
def test_bid_ladder_ordering(self) -> None:
# A bid must be weaker than the standing bid: descending strength
# follows A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2.
state = started_state()
bid(state, 1, 10) # king
for opponent, rank in ((2, 9), (3, 8), (4, 7), (0, 6)):
rules.bid_rank(state, f"p{opponent}", rank)
# 3 and A are stronger than 6: rejected.
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p1", 3)
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p1", 1)
rules.bid_rank(state, "p1", 5)
def test_not_your_turn(self) -> None:
state = started_state()
with self.assertRaises(NotYourTurn):
bid(state, 2, 1)
def test_pass_eliminates_and_last_standing_bids(self) -> None:
state = started_state()
for seat in (1, 2, 3, 4):
rules.pass_auction(state, f"p{seat}")
assert state.auction is not None
# Only seat 0 remains and nobody has bid: it must bid.
self.assertEqual([0], state.auction.active)
with self.assertRaises(IllegalMove):
rules.pass_auction(state, "p0")
rules.bid_rank(state, "p0", 2)
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(0, state.caller)
self.assertEqual(61, state.declared_target)
def test_auction_ends_when_one_bidder_remains(self) -> None:
state = started_state()
bid(state, 1, 1) # ace
rules.pass_auction(state, "p2")
bid(state, 3, 10) # king
rules.pass_auction(state, "p4")
rules.pass_auction(state, "p0")
rules.pass_auction(state, "p1")
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(3, state.caller)
self.assertEqual(61, state.declared_target)
def test_point_raises_only_after_the_two(self) -> None:
state = started_state()
bid(state, 1, 1)
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p2", 62)
bid(state, 2, 2)
with self.assertRaises(IllegalMove):
rules.bid_rank(state, "p3", 2) # no ranks below the 2
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p3", 61) # not higher
with self.assertRaises(IllegalMove):
rules.bid_points(state, "p3", 121) # above the cap
rules.bid_points(state, "p3", 63)
assert state.auction is not None
self.assertEqual(Bid(seat=3, rank=2, points=63), state.auction.current)
def test_legal_bids_exposed_to_the_bidder_only(self) -> None:
state = started_state()
bid(state, 1, 10) # king
view = rules.state_for_player(state, "p2")
self.assertTrue(view["your_turn"])
legal = view["legal_bids"]
self.assertEqual([9, 8, 7, 6, 5, 4, 2], legal["ranks"])
self.assertIsNone(legal["points_min"])
# A player who is not on turn gets no legal bids.
other = rules.state_for_player(state, "p3")
self.assertNotIn("legal_bids", other)
self.assertNotIn("your_turn", other)
def test_legal_points_exposed_at_the_two(self) -> None:
state = started_state()
bid(state, 1, 2)
view = rules.state_for_player(state, "p2")
self.assertEqual([], view["legal_bids"]["ranks"])
self.assertEqual(63, view["legal_bids"]["points_min"])
self.assertEqual(120, view["legal_bids"]["points_max"])
def test_auto_bid_bids_or_passes(self) -> None:
state = started_state()
rules.auto_bid(state, random.Random(1))
assert state.auction is not None
self.assertIsNotNone(state.auction.current)
# The auction keeps moving and eventually concludes.
for _ in range(50):
if state.phase != PHASE_AUCTION:
break
rules.auto_bid(state, random.Random(2))
self.assertEqual(PHASE_CALLING, state.phase)
self.assertIsNotNone(state.caller)
class CallTest(unittest.TestCase):
def _called_state(self, rank: int = 1) -> ChiamataState:
state = started_state()
bid(state, 1, rank)
for seat in (2, 3, 4, 0):
rules.pass_auction(state, f"p{seat}")
return state
def test_call_must_match_the_winning_rank(self) -> None:
state = self._called_state(1)
with self.assertRaises(IllegalMove):
rules.call(state, "p1", "03D")
# Any suit of the winning rank is fine.
rules.call(state, "p1", "01D")
self.assertEqual("D", state.briscola_suit)
self.assertEqual(PHASE_PLAYING, state.phase)
def test_only_the_winner_calls(self) -> None:
state = self._called_state()
with self.assertRaises(NotYourTurn):
rules.call(state, "p2", "01D")
def test_call_in_hand_means_playing_alone(self) -> None:
state = self._called_state(1)
# Seat 1 calls a card it holds: solo.
state.players[1].hand[0] = card("01S")
rules.call(state, "p1", "01S")
self.assertTrue(state.solo)
self.assertIsNone(state.partner_seat)
def test_play_starts_with_the_player_after_the_dealer(self) -> None:
state = self._called_state(1)
rules.call(state, "p1", "01D")
self.assertEqual(1, state.turn)
self.assertNotEqual(state.caller, None)
def test_auto_call_names_the_bid_rank(self) -> None:
state = self._called_state(10)
rules.auto_call(state, random.Random(3))
assert state.called_card is not None
self.assertEqual(10, state.called_card.rank)
self.assertEqual(PHASE_PLAYING, state.phase)
def test_calling_phase_view(self) -> None:
state = self._called_state(1)
view = rules.state_for_player(state, "p1")
self.assertEqual(PHASE_CALLING, view["phase"])
self.assertTrue(view["your_turn"])
self.assertEqual(
["01D", "01C", "01S", "01B"], view["callable_cards"]
)
other = rules.state_for_player(state, "p2")
self.assertNotIn("callable_cards", other)
class PlayTest(unittest.TestCase):
def test_five_player_trick_resolution(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
called_card="01D",
called_played=True,
partner_seat=4,
turn=0,
)
play_all(state)
assert state.last_trick is not None
self.assertEqual(3, state.last_trick.winner)
def test_trump_wins(self) -> None:
state = make_state(
[["02D"], ["01D"], ["04D"], ["02C"], ["03D"]],
briscola_suit="C",
caller=0,
called_card="01D",
called_played=True,
partner_seat=4,
turn=0,
)
play_all(state)
assert state.last_trick is not None
self.assertEqual(3, state.last_trick.winner)
def test_called_card_reveals_the_partner(self) -> None:
state = make_state(
[["02D"], ["01D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="D",
caller=0,
called_card="01S",
turn=1,
)
self.assertFalse(state.called_played)
view = rules.state_for_player(state, "p2")
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertNotIn("scores", view)
rules.play(state, "p1", "01D")
# The called card has not been played yet.
self.assertFalse(state.called_played)
# Drive the trick until the called card appears: give it to seat 3.
state.players[3].hand = [card("01S")]
state.turn = 3
rules.play(state, "p3", "01S")
self.assertTrue(state.called_played)
self.assertEqual(3, state.partner_seat)
view = rules.state_for_player(state, "p2")
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertEqual(ROLE_PARTNER, roles[3])
self.assertEqual(ROLE_DEFENDER, roles[1])
self.assertIn("scores", view)
def test_called_card_played_by_the_caller_is_solo(self) -> None:
state = make_state(
[["01S", "02D"], ["04D"], ["07D"], ["06D"], ["05D"]],
briscola_suit="S",
caller=0,
called_card="01S",
solo=True,
turn=0,
)
rules.play(state, "p0", "01S")
self.assertTrue(state.called_played)
self.assertIsNone(state.partner_seat)
view = rules.state_for_player(state, "p4")
self.assertTrue(view["solo"])
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[0])
self.assertEqual(ROLE_DEFENDER, roles[1])
def test_play_validations(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
turn=0,
)
with self.assertRaises(NotYourTurn):
rules.play(state, "p1", "05D")
with self.assertRaises(CardNotInHand):
rules.play(state, "p0", "01D")
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
rules.play(state, "p0", "02D")
def test_play_rejected_before_the_call(self) -> None:
state = make_state(
[["02D"], ["05D"], ["04D"], ["07D"], ["06D"]],
caller=0,
turn=0,
phase=PHASE_CALLING,
)
with self.assertRaises(IllegalMove):
rules.play(state, "p0", "02D")
def test_auto_play_advances(self) -> None:
state = make_state(
[["02D", "03C"], ["05D"], ["04D"], ["07D"], ["06D"]],
briscola_suit="S",
caller=0,
called_card="09B",
turn=0,
)
rules.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.trick))
self.assertEqual(1, len(state.players[0].hand))
class ScoringTest(unittest.TestCase):
def _piles(self):
# 60 points in denari + coppe, 60 in spade + bastoni.
a_cards = [f"{rank:02d}D" for rank in (1, 3, 10, 9, 8)] + [
f"{rank:02d}C" for rank in (1, 3, 10, 9, 8)
]
b_cards = [f"{rank:02d}S" for rank in (1, 3, 10, 9, 8)] + [
f"{rank:02d}B" for rank in (1, 3, 10, 9, 8)
]
return a_cards, b_cards
def test_side_points_split_caller_side_and_defenders(self) -> None:
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=3,
called_played=True,
captured=[
["01D"], # caller, side A: 11
["03C", "01S"], # defender: 21
["10D"], # defender: 4
["01C"], # partner, side A: 11
["03S"], # defender: 10
],
)
caller_points, defender_points = rules.side_points(state)
self.assertEqual(22, caller_points)
self.assertEqual(35, defender_points)
def test_solo_side_points(self) -> None:
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
solo=True,
called_played=True,
captured=[["01D"], ["01C"], ["03D"], ["03C"], ["10D"]],
)
caller_points, defender_points = rules.side_points(state)
self.assertEqual(11, caller_points)
self.assertEqual(35, defender_points)
def test_calling_side_wins_at_the_target(self) -> None:
a_cards, b_cards = self._piles()
b_rest = [c for c in b_cards if c != "01S"]
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards, ["01S"], [], b_rest, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_CALLER, state.winner)
self.assertEqual([71, 49], state.scores)
def test_defenders_win_below_the_declared_target(self) -> None:
a_cards, b_cards = self._piles()
# Calling side only 30 points of the 61 needed.
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards[:5], [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_DEFENDERS, state.winner)
self.assertEqual([30, 60], state.scores)
def test_patta_replays_the_deal_with_a_new_auction(self) -> None:
a_cards, b_cards = self._piles()
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
captured=[a_cards, [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_AUCTION, state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual(1, state.dealer)
assert state.auction is not None
self.assertEqual(2, state.auction.turn)
self.assertIsNone(state.caller)
self.assertIsNone(state.called_card)
# The patta entry is kept in the audit trail.
entry = state.hand_scores[-1]
self.assertTrue(entry["patta"])
self.assertEqual({"A": 60, "B": 60}, entry["points"])
def test_raised_target_disables_patta(self) -> None:
a_cards, b_cards = self._piles()
state = make_state(
[[], [], [], [], []],
briscola_suit="D",
caller=0,
partner_seat=1,
called_played=True,
declared_target=62,
captured=[a_cards, [], [], b_cards, []],
)
rules._end_deal(state)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(SIDE_DEFENDERS, state.winner)
def test_full_deal_scores_120(self) -> None:
state = started_state()
rng = random.Random(2026)
moves = 0
while state.phase != PHASE_FINISHED and moves < 5000:
if state.phase == PHASE_AUCTION:
rules.auto_bid(state, rng)
elif state.phase == PHASE_CALLING:
rules.auto_call(state, rng)
else:
rules.auto_play(state, rng)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(120, sum(state.scores))
self.assertIn(state.winner, (SIDE_CALLER, SIDE_DEFENDERS))
captured = sum(len(p.captured) for p in state.players)
self.assertEqual(40, captured)
class ViewTest(unittest.TestCase):
def test_state_for_player_hides_other_hands(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=0,
called_card="01D",
called_played=False,
turn=0,
)
view = rules.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(["02D", "03C"], players[0]["hand"])
self.assertNotIn("hand", players[1])
self.assertEqual(1, players[1]["cards_left"])
self.assertEqual("01D", view["called_card"])
self.assertEqual(5, view["player_count"])
def test_spectator_sees_public_information_only(self) -> None:
state = make_state(
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=2,
called_card="01D",
called_played=False,
)
view = rules.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("scores", view)
roles = {p["seat"]: p["team"] for p in view["players"]}
self.assertEqual(ROLE_CALLER, roles[2])
self.assertEqual("", roles[0])
def test_player_points_are_public(self) -> None:
state = make_state(
[["02D"], ["04D"], ["05D"], ["06D"], ["07D"]],
briscola_suit="D",
caller=0,
captured=[["01C", "03B"], ["10D"], [], [], []],
)
view = rules.state_for_player(state, "p0")
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(21, players[0]["points"])
self.assertEqual(4, players[1]["points"])
self.assertEqual(2, players[0]["captured_count"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = started_state()
bid(state, 1, 10)
bid(state, 2, 7)
restored = ChiamataState.from_json(state.to_json())
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.hand_number, restored.hand_number)
self.assertEqual(state.turn, restored.turn)
assert restored.auction is not None and state.auction is not None
self.assertEqual(state.auction.active, restored.auction.active)
self.assertEqual(state.auction.passed, restored.auction.passed)
self.assertEqual(
state.auction.history, restored.auction.history
)
self.assertEqual(
[[c.code for c in p.hand] for p in state.players],
[[c.code for c in p.hand] for p in restored.players],
)
def test_legacy_state_defaults(self) -> None:
state = started_state()
data = state.to_json()
del data["auction"]
del data["called_card"]
del data["declared_target"]
restored = ChiamataState.from_json(data)
self.assertIsNone(restored.auction)
self.assertIsNone(restored.called_card)
self.assertEqual(rules.DEFAULT_TARGET, restored.declared_target)
self.assertFalse(restored.solo)
if __name__ == "__main__":
unittest.main()