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.
668 lines
24 KiB
Python
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()
|