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.
643 lines
23 KiB
Python
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()
|