Files
tavolo/server/packages/tavolo-briscola/tests/test_chiamata.py
T
woggioni e0030ee94a Make an absent chiamata bidder pass instead of bidding randomly
The auction timeout bid a random legal rank, which could hand the
auction (and the call) to a disconnected player. An absent bidder now
always passes; only the last player standing before any bid cannot
pass, and is made to bid the ace - its suit is picked at random by the
existing call timeout. This keeps a deal from stalling without a caller
while never letting an absent player outbid someone.

The deadline log line now says which of the two happened.
2026-10-08 22:47:32 +00:00

668 lines
24 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_always_passes_when_legal(self) -> None:
state = started_state()
assert state.auction is not None
rules.auto_bid(state)
# Seat 1 passes: nobody has bid and the turn moves on.
self.assertEqual([1], state.auction.passed)
self.assertIsNone(state.auction.current)
self.assertEqual(2, state.auction.turn)
# An absent bidder still passes when there is a standing bid.
rules.bid_rank(state, "p2", 10)
rules.auto_bid(state)
self.assertEqual([1, 3], state.auction.passed)
assert state.auction.current is not None
self.assertEqual(10, state.auction.current.rank)
def test_auto_bid_passes_at_the_point_stage(self) -> None:
state = started_state()
rules.bid_rank(state, "p1", 2)
rules.bid_points(state, "p2", 62)
rules.auto_bid(state) # seat 3
assert state.auction is not None
self.assertIn(3, state.auction.passed)
assert state.auction.current is not None
self.assertEqual(62, state.auction.current.points)
def test_auto_bid_forced_to_call_the_ace(self) -> None:
state = started_state()
for seat in (1, 2, 3, 4):
rules.pass_auction(state, f"p{seat}")
# Seat 0 is the last one standing and nobody has bid yet: the
# forced auto-bid calls the ace.
rules.auto_bid(state)
self.assertEqual(PHASE_CALLING, state.phase)
self.assertEqual(0, state.caller)
assert state.auction is not None and state.auction.current is not None
self.assertEqual(1, state.auction.current.rank)
self.assertEqual(61, state.declared_target)
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)
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()