CI / Build and push docker image (push) Successful in 1m45s
New tavolo-briscola distribution implementing briscola on the platform: - pure rules (state.py, engine.py): 3-card deals, a face-up trump that is the last card drawn, trump-first trick resolution (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2), 120 points per deal, 60-60 patta re-deals, a configurable target score (default 61), deal-end acknowledgements and turn auto-play; - BriscolaEngine (briscola, 2 players) and BriscolaQuattroEngine (briscola_a_quattro, 4 players in fixed partnerships) adapters; - registered in tavolo-app, packaged in the Docker image, documented. Platform fixes surfaced by a two-player lobby: - the websocket state and snapshot payloads now carry seats_open (they were only in the REST lobby payload), so lobbies with player counts other than four render correctly; seats_open is clamped at zero; - the web client logs unparseable server messages instead of silently dropping them. Web client: briscola table for both variants (stock, face-up trump, trick, last-trick banner), deal summary modal and game-over overlay. Tests: tavolo-briscola rules/adapter suites, app integration tests (lobby, websocket play, turn timeout, deal-end acks, persistence), a platform seats_open test and frontend model parsing tests.
692 lines
26 KiB
Python
692 lines
26 KiB
Python
"""Rule engine tests: deals, tricks, draws, scoring and full-match simulation."""
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
import unittest
|
|
|
|
from tavolo.briscola import engine
|
|
from tavolo.briscola.errors import (
|
|
CardNotInHand,
|
|
GameFinished,
|
|
GameNotStarted,
|
|
IllegalMove,
|
|
NotYourTurn,
|
|
)
|
|
from tavolo.briscola.state import (
|
|
HAND_SIZE,
|
|
PHASE_FINISHED,
|
|
PHASE_HAND_END,
|
|
PHASE_PLAYING,
|
|
BriscolaState,
|
|
Card,
|
|
PlayerState,
|
|
)
|
|
|
|
|
|
def card(code: str) -> Card:
|
|
return Card.parse(code)
|
|
|
|
|
|
def make_state(
|
|
hands,
|
|
*,
|
|
briscola_suit: str = "S",
|
|
briscola_card: str | None = None,
|
|
stock=None,
|
|
captured=None,
|
|
turn: int = 0,
|
|
dealer: int = 0,
|
|
target: int = 61,
|
|
scores=None,
|
|
phase: str = PHASE_PLAYING,
|
|
) -> BriscolaState:
|
|
"""Build a controlled game state directly (bypassing the deal)."""
|
|
state = BriscolaState(
|
|
player_count=len(hands),
|
|
target_score=target,
|
|
phase=phase,
|
|
dealer=dealer,
|
|
turn=turn,
|
|
briscola_suit=briscola_suit,
|
|
briscola_card=card(briscola_card) if briscola_card else None,
|
|
stock=[card(c) for c in (stock or [])],
|
|
scores=list(scores or [0, 0]),
|
|
)
|
|
for seat, hand in enumerate(hands):
|
|
state.players.append(
|
|
PlayerState(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 trick_cards(state: BriscolaState) -> list[str]:
|
|
return [t.card for t in state.trick]
|
|
|
|
|
|
def play_all(state: BriscolaState) -> None:
|
|
"""Play every card of the current trick in turn order."""
|
|
for _ in range(state.player_count):
|
|
player = next(p for p in state.players if p.seat == state.turn)
|
|
engine.play(state, player.sub, player.hand[0].code)
|
|
|
|
|
|
class DeckTest(unittest.TestCase):
|
|
def test_full_deck_has_40_unique_cards(self) -> None:
|
|
deck = engine.full_deck()
|
|
self.assertEqual(40, len(deck))
|
|
self.assertEqual(40, len({c.code for c in deck}))
|
|
self.assertEqual(4, len({c.suit for c in deck}))
|
|
|
|
def test_shuffled_deck_is_permutation(self) -> None:
|
|
deck = engine.shuffled_deck()
|
|
self.assertEqual(
|
|
sorted(c.code for c in engine.full_deck()),
|
|
sorted(c.code for c in deck),
|
|
)
|
|
|
|
def test_card_points_add_up_to_120(self) -> None:
|
|
self.assertEqual(
|
|
120, sum(engine.card_points(c) for c in engine.full_deck())
|
|
)
|
|
self.assertEqual(11, engine.card_points(card("01D")))
|
|
self.assertEqual(10, engine.card_points(card("03C")))
|
|
self.assertEqual(4, engine.card_points(card("10S")))
|
|
self.assertEqual(3, engine.card_points(card("09B")))
|
|
self.assertEqual(2, engine.card_points(card("08D")))
|
|
self.assertEqual(0, engine.card_points(card("07C")))
|
|
|
|
|
|
class DealTest(unittest.TestCase):
|
|
def test_two_player_deal(self) -> None:
|
|
state = engine.create_game("p0", "p0")
|
|
engine.join_game(state, "p1", "p1")
|
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
|
for player in state.players:
|
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
|
# 40 - 3*2 - 1 face-up = 33 face-down.
|
|
self.assertEqual(33, len(state.stock))
|
|
self.assertIsNotNone(state.briscola_card)
|
|
assert state.briscola_card is not None
|
|
self.assertEqual(state.briscola_card.suit, state.briscola_suit)
|
|
self.assertEqual(1, state.turn) # dealer is seat 0
|
|
self.assertEqual([], state.trick)
|
|
|
|
def test_four_player_deal(self) -> None:
|
|
state = engine.create_game("p0", "p0", player_count=4)
|
|
for i in range(1, 4):
|
|
engine.join_game(state, f"p{i}", f"p{i}")
|
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
|
for player in state.players:
|
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
|
self.assertEqual(40 - 3 * 4 - 1, len(state.stock))
|
|
self.assertEqual(1, state.turn)
|
|
|
|
def test_deal_cards_are_unique_and_leave_nothing_out(self) -> None:
|
|
state = engine.create_game("p0", "p0", player_count=4)
|
|
for i in range(1, 4):
|
|
engine.join_game(state, f"p{i}", f"p{i}")
|
|
dealt = [c.code for p in state.players for c in p.hand]
|
|
assert state.briscola_card is not None
|
|
dealt.append(state.briscola_card.code)
|
|
dealt.extend(c.code for c in state.stock)
|
|
self.assertEqual(40, len(dealt))
|
|
self.assertEqual(40, len(set(dealt)))
|
|
|
|
def test_unsupported_player_count(self) -> None:
|
|
with self.assertRaises(IllegalMove):
|
|
engine.create_game("p0", "p0", player_count=3)
|
|
|
|
def test_target_score_bounds(self) -> None:
|
|
with self.assertRaises(IllegalMove):
|
|
engine.create_game("p0", "p0", target_score=60)
|
|
with self.assertRaises(IllegalMove):
|
|
engine.create_game("p0", "p0", target_score=121)
|
|
with self.assertRaises(IllegalMove):
|
|
engine.create_game("p0", "p0", target_score=True)
|
|
state = engine.create_game("p0", "p0", target_score=101)
|
|
self.assertEqual(101, state.target_score)
|
|
|
|
|
|
class TrickTest(unittest.TestCase):
|
|
def test_higher_card_of_led_suit_wins(self) -> None:
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
|
engine.play(state, "p0", "02D")
|
|
engine.play(state, "p1", "05D")
|
|
self.assertEqual(1, state.last_trick.winner)
|
|
self.assertEqual(["02D", "05D"],
|
|
[c.code for c in state.players[1].captured])
|
|
|
|
def test_trump_beats_a_higher_led_card(self) -> None:
|
|
state = make_state([["07D"], ["02C"]], briscola_suit="C")
|
|
engine.play(state, "p0", "07D")
|
|
engine.play(state, "p1", "02C")
|
|
self.assertEqual(1, state.last_trick.winner)
|
|
|
|
def test_higher_trump_wins_among_trumps(self) -> None:
|
|
state = make_state([["02C"], ["05C"]], briscola_suit="C")
|
|
engine.play(state, "p0", "02C")
|
|
engine.play(state, "p1", "05C")
|
|
self.assertEqual(1, state.last_trick.winner)
|
|
|
|
def test_off_suit_non_trump_loses(self) -> None:
|
|
state = make_state([["04D"], ["01C"]], briscola_suit="B")
|
|
engine.play(state, "p0", "04D")
|
|
engine.play(state, "p1", "01C")
|
|
self.assertEqual(0, state.last_trick.winner)
|
|
|
|
def test_rank_strength_order(self) -> None:
|
|
# (winner card, loser card) pairs of the led suit, strongest first:
|
|
# ace > three > king > horse > jack > 7 > 6 > 5 > 4 > 2.
|
|
pairs = [
|
|
("01D", "03D"), # ace beats three
|
|
("03D", "10D"), # three beats king
|
|
("10D", "09D"), # king beats horse
|
|
("09D", "08D"), # horse beats jack
|
|
("08D", "07D"), # jack beats seven
|
|
("07D", "06D"),
|
|
("06D", "05D"),
|
|
("05D", "04D"),
|
|
("04D", "02D"),
|
|
]
|
|
for winner, loser in pairs:
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="B")
|
|
state.players[0].hand = [card(winner)]
|
|
state.players[1].hand = [card(loser)]
|
|
engine.play(state, "p0", winner)
|
|
engine.play(state, "p1", loser)
|
|
self.assertEqual(
|
|
0, state.last_trick.winner, f"{winner} should beat {loser}"
|
|
)
|
|
|
|
def test_four_player_trick(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
|
|
)
|
|
for seat in range(4):
|
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
|
self.assertEqual(3, state.last_trick.winner)
|
|
|
|
def test_four_player_trump_beats_all(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["02C"], ["04D"], ["07D"]], briscola_suit="C"
|
|
)
|
|
for seat in range(4):
|
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
|
self.assertEqual(1, state.last_trick.winner)
|
|
|
|
def test_winner_leads_the_next_trick(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["05D"], ["04D"], ["07D"]],
|
|
briscola_suit="S",
|
|
stock=["01B", "02B", "03B", "04B"],
|
|
)
|
|
for seat in range(4):
|
|
engine.play(state, f"p{seat}", state.players[seat].hand[0].code)
|
|
self.assertEqual(3, state.turn)
|
|
self.assertEqual(3, state.trick_leader)
|
|
self.assertEqual([], state.trick)
|
|
|
|
def test_turn_advances_within_a_trick(self) -> None:
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
|
engine.play(state, "p0", "02D")
|
|
self.assertEqual(1, state.turn)
|
|
self.assertEqual(["02D"], trick_cards(state))
|
|
|
|
def test_not_your_turn(self) -> None:
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="S", turn=1)
|
|
with self.assertRaises(NotYourTurn):
|
|
engine.play(state, "p0", "02D")
|
|
|
|
def test_card_not_in_hand(self) -> None:
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
|
with self.assertRaises(CardNotInHand):
|
|
engine.play(state, "p0", "07D")
|
|
|
|
def test_finished_and_hand_end_reject_plays(self) -> None:
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
|
state.phase = PHASE_FINISHED
|
|
with self.assertRaises(GameFinished):
|
|
engine.play(state, "p0", "02D")
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
|
state.phase = PHASE_HAND_END
|
|
with self.assertRaises(IllegalMove):
|
|
engine.play(state, "p0", "02D")
|
|
|
|
|
|
class DrawTest(unittest.TestCase):
|
|
def test_winner_draws_first(self) -> None:
|
|
# The stock is drawn from its end: 04B first, then 03B, ...
|
|
state = make_state(
|
|
[["02D", "09C"], ["05D", "09B"]],
|
|
briscola_suit="S",
|
|
stock=["01B", "02B", "03B", "04B"],
|
|
)
|
|
engine.play(state, "p0", "02D")
|
|
engine.play(state, "p1", "05D")
|
|
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
|
|
self.assertEqual(["09C", "03B"], hands[0])
|
|
self.assertEqual(["09B", "04B"], hands[1])
|
|
|
|
def test_face_up_briscola_is_drawn_last(self) -> None:
|
|
state = make_state(
|
|
[["02D", "09C"], ["05D", "09B"]],
|
|
briscola_suit="C",
|
|
briscola_card="05C",
|
|
stock=["03B"],
|
|
)
|
|
engine.play(state, "p0", "02D")
|
|
engine.play(state, "p1", "05D")
|
|
hands = {p.seat: [c.code for c in p.hand] for p in state.players}
|
|
# The winner draws first and takes the last face-down card; the
|
|
# other player then takes the face-up briscola.
|
|
self.assertEqual(["09B", "03B"], hands[1])
|
|
self.assertEqual(["09C", "05C"], hands[0])
|
|
self.assertIsNone(state.briscola_card)
|
|
|
|
def test_no_draw_when_stock_is_empty(self) -> None:
|
|
state = make_state(
|
|
[["02D", "03D"], ["05D", "06D"]], briscola_suit="S", stock=[]
|
|
)
|
|
engine.play(state, "p0", "02D")
|
|
engine.play(state, "p1", "05D")
|
|
# p1 wins the trick; with no stock nobody draws and the hands
|
|
# shrink for the rest of the deal.
|
|
for player in state.players:
|
|
self.assertEqual(1, len(player.hand))
|
|
|
|
def test_hands_stay_at_three_until_stock_runs_out(self) -> None:
|
|
state = engine.create_game("p0", "p0")
|
|
engine.join_game(state, "p1", "p1")
|
|
while state.stock or state.briscola_card is not None:
|
|
play_all(state)
|
|
for player in state.players:
|
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
|
self.assertEqual(0, len(state.stock))
|
|
self.assertIsNone(state.briscola_card)
|
|
|
|
|
|
class ScoringTest(unittest.TestCase):
|
|
def test_deal_points_group_by_team(self) -> None:
|
|
state = make_state(
|
|
[[], [], [], []],
|
|
briscola_suit="S",
|
|
captured=[
|
|
["01D", "07C"], # seat 0, team A: 11
|
|
["03C", "08D"], # seat 1, team B: 10
|
|
["10S"], # seat 2, team A: 4
|
|
["09B"], # seat 3, team B: 3
|
|
],
|
|
)
|
|
points, details = engine.deal_points(state)
|
|
self.assertEqual([15, 15], points)
|
|
self.assertEqual({"A": 15, "B": 15}, details["points"])
|
|
self.assertEqual({"A": 3, "B": 3}, details["cards"])
|
|
|
|
def test_full_deal_scores_120(self) -> None:
|
|
state = engine.create_game("p0", "p0", player_count=4)
|
|
for i in range(1, 4):
|
|
engine.join_game(state, f"p{i}", f"p{i}")
|
|
while state.phase == PHASE_PLAYING:
|
|
player = next(p for p in state.players if p.seat == state.turn)
|
|
engine.play(state, player.sub, player.hand[0].code)
|
|
points, _ = engine.deal_points(state)
|
|
self.assertEqual(120, sum(points))
|
|
|
|
def test_match_ends_when_target_reached(self) -> None:
|
|
# p0 captures the ace of denari (11) on the last trick: 60+11 >= 61.
|
|
state = make_state(
|
|
[["02D"], ["05D"]],
|
|
briscola_suit="S",
|
|
stock=[],
|
|
captured=[["01C"], ["10C"]],
|
|
scores=[60, 50],
|
|
target=61,
|
|
)
|
|
# p0's pile is worth 11+0 = 11; p1's 4. p0 wins the trick with a
|
|
# higher card: 02D vs 05D -> p1 would win, so give p0 the five.
|
|
state.players[0].hand = [card("05D")]
|
|
state.players[1].hand = [card("02D")]
|
|
engine.play(state, "p0", "05D")
|
|
engine.play(state, "p1", "02D")
|
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
|
self.assertEqual(0, state.winner)
|
|
self.assertEqual([71, 54], state.scores)
|
|
|
|
def test_tie_at_target_is_broken_by_another_deal(self) -> None:
|
|
# 65-55 before the deal; this deal gives A 55 and B 65: both reach
|
|
# 120 and tie, so another deal must be played.
|
|
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + ["01C", "03C", "10C"]
|
|
b_cards = ["09C", "08C", "07C", "06C", "05C", "04C", "02C"] + [
|
|
f"{rank:02d}S" for rank in range(1, 11)
|
|
] + [f"{rank:02d}B" for rank in range(1, 11)]
|
|
state = make_state(
|
|
[[], []],
|
|
briscola_suit="S",
|
|
stock=[],
|
|
captured=[a_cards, b_cards],
|
|
scores=[65, 55],
|
|
target=120,
|
|
)
|
|
engine._end_deal(state)
|
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
|
self.assertEqual([120, 120], state.scores)
|
|
self.assertIsNone(state.winner)
|
|
|
|
def test_patta_is_discarded_and_replayed(self) -> None:
|
|
a_cards = [f"{rank:02d}D" for rank in range(1, 11)] + [
|
|
f"{rank:02d}C" for rank in range(1, 11)
|
|
]
|
|
b_cards = [f"{rank:02d}S" for rank in range(1, 11)] + [
|
|
f"{rank:02d}B" for rank in range(1, 11)
|
|
]
|
|
state = make_state(
|
|
[["02D"], ["02S"]],
|
|
briscola_suit="S",
|
|
stock=[],
|
|
captured=[a_cards, b_cards],
|
|
scores=[10, 20],
|
|
target=61,
|
|
)
|
|
engine._end_deal(state)
|
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
|
# The 60-60 deal does not count: totals are unchanged.
|
|
self.assertEqual([10, 20], state.scores)
|
|
entry = state.hand_scores[-1]
|
|
self.assertTrue(entry["patta"])
|
|
self.assertEqual({"A": 60, "B": 60}, entry["points"])
|
|
# The next deal is played at the same cumulative totals.
|
|
engine.acknowledge_hand(state, "p0")
|
|
engine.acknowledge_hand(state, "p1")
|
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
|
self.assertEqual(2, state.hand_number)
|
|
self.assertEqual([10, 20], state.scores)
|
|
|
|
def test_audit_trail_records_each_deal(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["05D"]],
|
|
briscola_suit="S",
|
|
stock=[],
|
|
captured=[["01C"], []],
|
|
scores=[0, 0],
|
|
)
|
|
engine._end_deal(state)
|
|
entry = state.hand_scores[-1]
|
|
self.assertEqual(1, entry["deal"])
|
|
self.assertFalse(entry["patta"])
|
|
self.assertEqual({"A": 11, "B": 0}, entry["points"])
|
|
self.assertEqual({"A": 11, "B": 0}, entry["totals"])
|
|
self.assertEqual({"A": 1, "B": 0}, entry["cards"])
|
|
|
|
|
|
class MatchFlowTest(unittest.TestCase):
|
|
def test_join_starts_when_full(self) -> None:
|
|
state = engine.create_game("p0", "p0")
|
|
self.assertEqual(1, len(state.players))
|
|
engine.join_game(state, "p1", "p1")
|
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
|
|
|
state = engine.create_game("p0", "p0", player_count=4)
|
|
for i in range(1, 3):
|
|
engine.join_game(state, f"p{i}", f"p{i}")
|
|
self.assertEqual("lobby", state.phase)
|
|
engine.join_game(state, "p3", "p3")
|
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
|
|
|
def test_join_errors(self) -> None:
|
|
from tavolo.platform.errors import AlreadyJoined, GameNotStarted
|
|
|
|
state = engine.create_game("p0", "p0")
|
|
with self.assertRaises(AlreadyJoined):
|
|
engine.join_game(state, "p0", "p0")
|
|
engine.join_game(state, "p1", "p1")
|
|
with self.assertRaises(GameNotStarted):
|
|
engine.join_game(state, "p2", "p2")
|
|
|
|
def test_full_random_two_player_match(self) -> None:
|
|
self._random_match(player_count=2)
|
|
|
|
def test_full_random_four_player_match(self) -> None:
|
|
self._random_match(player_count=4)
|
|
|
|
def _random_match(self, player_count: int) -> None:
|
|
state = engine.create_game("p0", "p0", player_count=player_count)
|
|
for i in range(1, player_count):
|
|
engine.join_game(state, f"p{i}", f"p{i}")
|
|
rng = random.Random(2026)
|
|
moves = 0
|
|
while state.phase != PHASE_FINISHED and moves < 200000:
|
|
if state.phase == PHASE_HAND_END:
|
|
for player in state.players:
|
|
engine.acknowledge_hand(state, player.sub)
|
|
continue
|
|
player = next(p for p in state.players if p.seat == state.turn)
|
|
engine.play(state, player.sub, rng.choice(player.hand).code)
|
|
moves += 1
|
|
|
|
self.assertEqual(PHASE_FINISHED, state.phase)
|
|
self.assertIn(state.winner, (0, 1))
|
|
self.assertEqual([], state.trick)
|
|
self.assertTrue(all(not p.hand for p in state.players))
|
|
self.assertEqual(
|
|
40, sum(len(p.captured) for p in state.players)
|
|
)
|
|
# Every counted deal totals 120 points, and the winner reached the
|
|
# target with a clear lead.
|
|
winner_score = state.scores[state.winner]
|
|
loser_score = state.scores[1 - state.winner]
|
|
self.assertGreaterEqual(winner_score, state.target_score)
|
|
self.assertGreater(winner_score, loser_score)
|
|
self.assertEqual(0, (winner_score + loser_score) % 120)
|
|
|
|
|
|
class AckTest(unittest.TestCase):
|
|
def _hand_end_state(self) -> BriscolaState:
|
|
state = make_state(
|
|
[["02D"], ["05D"]],
|
|
briscola_suit="S",
|
|
stock=[],
|
|
captured=[["01C"], []],
|
|
target=120,
|
|
)
|
|
engine._end_deal(state)
|
|
return state
|
|
|
|
def test_ack_all_deals_the_next_deal(self) -> None:
|
|
state = self._hand_end_state()
|
|
dealer_before = state.dealer
|
|
engine.acknowledge_hand(state, "p0")
|
|
self.assertEqual([0], state.acked)
|
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
|
engine.acknowledge_hand(state, "p1")
|
|
self.assertEqual(PHASE_PLAYING, state.phase)
|
|
self.assertEqual(2, state.hand_number)
|
|
self.assertEqual((dealer_before + 1) % 2, state.dealer)
|
|
self.assertEqual((dealer_before + 2) % 2, state.turn)
|
|
self.assertEqual([], state.acked)
|
|
self.assertIsNone(state.hand_end_deadline)
|
|
for player in state.players:
|
|
self.assertEqual(HAND_SIZE, len(player.hand))
|
|
self.assertEqual([], player.captured)
|
|
|
|
def test_double_ack_is_idempotent(self) -> None:
|
|
state = self._hand_end_state()
|
|
engine.acknowledge_hand(state, "p0")
|
|
engine.acknowledge_hand(state, "p0")
|
|
self.assertEqual([0], state.acked)
|
|
|
|
def test_ack_outside_hand_end_is_rejected(self) -> None:
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
|
with self.assertRaises(IllegalMove):
|
|
engine.acknowledge_hand(state, "p0")
|
|
|
|
def test_ack_by_non_player_is_rejected(self) -> None:
|
|
state = self._hand_end_state()
|
|
with self.assertRaises(NotYourTurn):
|
|
engine.acknowledge_hand(state, "mallory")
|
|
|
|
|
|
class AutoPlayTest(unittest.TestCase):
|
|
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
|
|
state = make_state(
|
|
[["02D", "03C"], ["05D"]],
|
|
briscola_suit="S",
|
|
stock=["01B", "02B"],
|
|
briscola_card="03B",
|
|
)
|
|
engine.auto_play(state, random.Random(7))
|
|
self.assertEqual(1, state.turn)
|
|
self.assertEqual(1, len(state.players[0].hand))
|
|
self.assertEqual(1, len(state.trick))
|
|
|
|
def test_auto_play_can_end_the_deal(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["05D"]],
|
|
briscola_suit="S",
|
|
stock=[],
|
|
captured=[["01C"], []],
|
|
target=120,
|
|
)
|
|
# Both players are on their last card: two auto-plays complete the
|
|
# trick and the deal, scored A=11 / B=0.
|
|
engine.auto_play(state)
|
|
engine.auto_play(state)
|
|
self.assertEqual(PHASE_HAND_END, state.phase)
|
|
self.assertIsNone(state.turn_deadline)
|
|
self.assertTrue(state.hand_end_deadline)
|
|
|
|
def test_auto_play_requires_playing_phase(self) -> None:
|
|
state = make_state([["02D"], ["05D"]], briscola_suit="S")
|
|
state.phase = PHASE_HAND_END
|
|
with self.assertRaises(GameNotStarted):
|
|
engine.auto_play(state)
|
|
|
|
|
|
class ViewTest(unittest.TestCase):
|
|
def test_state_for_player_hides_other_hands(self) -> None:
|
|
state = make_state(
|
|
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
|
briscola_suit="D",
|
|
briscola_card="07D",
|
|
stock=["01B", "02B"],
|
|
)
|
|
view = engine.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("D", view["briscola_suit"])
|
|
self.assertEqual("07D", view["briscola_card"])
|
|
self.assertEqual(2, view["stock_count"])
|
|
self.assertTrue(view.get("your_turn"))
|
|
|
|
def test_spectator_sees_public_info_only(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["04D"]],
|
|
briscola_suit="D",
|
|
briscola_card="07D",
|
|
)
|
|
view = engine.state_for_player(state, None)
|
|
for player in view["players"]:
|
|
self.assertNotIn("hand", player)
|
|
self.assertNotIn("your_turn", view)
|
|
self.assertEqual("07D", view["briscola_card"])
|
|
self.assertEqual({"A": 0, "B": 0}, view["scores"])
|
|
|
|
def test_player_points_are_public(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["04D"]],
|
|
briscola_suit="D",
|
|
captured=[["01C", "03B"], ["10D"]],
|
|
)
|
|
view = engine.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"])
|
|
|
|
def test_hand_end_view_exposes_the_deal_summary(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["05D"]],
|
|
briscola_suit="S",
|
|
stock=[],
|
|
captured=[["01C"], []],
|
|
target=120,
|
|
)
|
|
engine._end_deal(state)
|
|
view = engine.state_for_player(state, "p0")
|
|
self.assertEqual(PHASE_HAND_END, view["phase"])
|
|
self.assertEqual(
|
|
{
|
|
"deal": 1,
|
|
"patta": False,
|
|
"points": {"A": 11, "B": 0},
|
|
"cards": {"A": 1, "B": 0},
|
|
"totals": {"A": 11, "B": 0},
|
|
},
|
|
view["last_deal"],
|
|
)
|
|
self.assertTrue(view["hand_end_deadline"])
|
|
|
|
def test_trick_is_public_in_play_order(self) -> None:
|
|
state = make_state(
|
|
[["02D"], ["05D"], ["04D"], ["07D"]], briscola_suit="S"
|
|
)
|
|
engine.play(state, "p0", "02D")
|
|
engine.play(state, "p1", "05D")
|
|
view = engine.state_for_player(state, None)
|
|
self.assertEqual(
|
|
[{"seat": 0, "card": "02D"}, {"seat": 1, "card": "05D"}],
|
|
view["trick"],
|
|
)
|
|
|
|
|
|
class SerializationTest(unittest.TestCase):
|
|
def test_state_roundtrip(self) -> None:
|
|
state = engine.create_game("p0", "p0", player_count=4, target_score=101)
|
|
for i in range(1, 4):
|
|
engine.join_game(state, f"p{i}", f"p{i}")
|
|
engine.play(state, state.players[1].sub, state.players[1].hand[0].code)
|
|
restored = BriscolaState.from_json(state.to_json())
|
|
self.assertEqual(state.phase, restored.phase)
|
|
self.assertEqual(state.turn, restored.turn)
|
|
self.assertEqual(state.hand_number, restored.hand_number)
|
|
self.assertEqual(state.target_score, restored.target_score)
|
|
self.assertEqual(state.briscola_suit, restored.briscola_suit)
|
|
self.assertEqual(
|
|
state.briscola_card.code if state.briscola_card else None,
|
|
restored.briscola_card.code if restored.briscola_card else None,
|
|
)
|
|
self.assertEqual(
|
|
[c.code for c in state.stock], [c.code for c in restored.stock]
|
|
)
|
|
self.assertEqual(
|
|
[(t.seat, t.card) for t in state.trick],
|
|
[(t.seat, t.card) for t in restored.trick],
|
|
)
|
|
self.assertEqual(
|
|
[p.sub for p in state.players], [p.sub for p in restored.players]
|
|
)
|
|
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 = make_state([["02D"], ["05D"]], briscola_suit="S")
|
|
data = state.to_json()
|
|
del data["player_count"]
|
|
del data["briscola_card"]
|
|
del data["last_trick"]
|
|
restored = BriscolaState.from_json(data)
|
|
self.assertEqual(2, restored.player_count)
|
|
self.assertIsNone(restored.briscola_card)
|
|
self.assertIsNone(restored.last_trick)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|