Files
tavolo/server/tests/test_engine.py
T

540 lines
22 KiB
Python

"""Rule engine tests: captures, scope, scoring and full-match simulation."""
from __future__ import annotations
import random
import unittest
from tavolo.game import engine
from tavolo.game.errors import (
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.game.state import (
PHASE_FINISHED,
PHASE_PLAYING,
Card,
GameState,
PlayerState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
table,
turn: int = 0,
*,
captured=None,
scope=None,
target: int = 11,
last_taker=None,
) -> GameState:
"""Build a controlled game state directly (bypassing the deal)."""
state = GameState(
id="game-1",
join_code="ABC123",
creator_sub="p0",
target_score=target,
phase=PHASE_PLAYING,
turn=turn,
last_taker=last_taker,
)
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]
if scope is not None:
for player, value in zip(state.players, scope):
player.scope = value
state.table = [card(c) for c in table]
return state
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}))
self.assertEqual(4, sum(1 for c in deck if c.rank == 7))
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),
)
class CaptureTest(unittest.TestCase):
def test_equal_card_is_mandatory(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(table, card("05D"))
self.assertEqual([["05C"]], [[c.code for c in o] for o in options])
def test_sum_combination(self) -> None:
table = [card("01C"), card("03C"), card("02S")]
options = engine.legal_captures(table, card("04D"))
self.assertEqual([["01C", "03C"]], [[c.code for c in o] for o in options])
def test_multiple_equal_cards_each_a_separate_option(self) -> None:
table = [card("05C"), card("05S")]
options = engine.legal_captures(table, card("05D"))
self.assertEqual(
[["05C"], ["05S"]], sorted([[c.code for c in o] for o in options])
)
def test_no_capture(self) -> None:
table = [card("09C"), card("08S")]
self.assertEqual([], engine.legal_captures(table, card("02D")))
def test_play_without_capture_places_card_on_table(self) -> None:
state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]],
table=["09C"])
engine.play(state, "p0", "02D")
self.assertIn("02D", [c.code for c in state.table])
self.assertNotIn("02D", [c.code for c in state.players[0].hand])
self.assertEqual(1, state.turn)
def test_play_capture_and_scopa(self) -> None:
state = make_state([["02D", "09C"], ["01C"], ["01S"], ["01B"]],
table=["02C"])
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(1, state.players[0].scope)
self.assertEqual([], state.table)
self.assertEqual(
["02C", "02D"], [c.code for c in state.players[0].captured]
)
# The move is recorded for the "who played what" announcement.
assert state.last_move is not None
self.assertEqual(0, state.last_move.seat)
self.assertEqual("p0", state.last_move.name)
self.assertEqual("02D", state.last_move.card)
self.assertEqual(["02C"], state.last_move.captured)
self.assertTrue(state.last_move.scopa)
def test_play_without_capture_records_move(self) -> None:
state = make_state([["02D", "03D"], ["01C"], ["01S"], ["01B"]],
table=["09C"])
engine.play(state, "p0", "02D")
assert state.last_move is not None
self.assertEqual("02D", state.last_move.card)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_illegal_combination_when_equal_card_present(self) -> None:
state = make_state([["05D"], ["01C"], ["01S"], ["01B"]],
table=["05C", "02D", "03S"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D", ["02D", "03S"])
def test_illegal_capture_rejected(self) -> None:
state = make_state([["04D"], ["01C"], ["01S"], ["01B"]],
table=["02C", "03S"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "04D", ["02C"])
def test_no_capture_requested_when_capture_possible(self) -> None:
state = make_state([["05D"], ["01C"], ["01S"], ["01B"]],
table=["05C"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D")
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]],
table=[], turn=1)
with self.assertRaises(NotYourTurn):
engine.play(state, "p0", "02D")
def test_card_not_in_hand(self) -> None:
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_game_rejects_moves(self) -> None:
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
class LastPlayTest(unittest.TestCase):
def test_no_scopa_on_last_play_of_hand(self) -> None:
# p0 plays the last card of the hand (everyone else is already
# empty): the capture empties the table but must NOT count as a
# scopa. Team A still reaches the target of 2 with carte + denara.
state = make_state([["02D"], [], [], []],
table=["02C"], target=2)
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
def test_table_swept_to_last_taker(self) -> None:
# target 2 so the game ends on this hand and the capture piles are
# not reset by the next deal.
state = make_state([["02D"], [], [], []],
table=["05C", "04D"], last_taker=1, target=2)
engine.play(state, "p0", "02D")
captured = {c.code for c in state.players[1].captured}
self.assertEqual({"05C", "04D", "02D"}, captured)
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(1, state.winner)
class ScoringTest(unittest.TestCase):
def test_primiera_values_and_all_suits_requirement(self) -> None:
self.assertEqual(70, engine.primiera_score(
[card(c) for c in ["07D", "06C", "01S", "05B"]]))
self.assertEqual(0, engine.primiera_score(
[card(c) for c in ["07D", "06C", "01S"]]))
self.assertEqual(40, engine.primiera_score(
[card(c) for c in ["08D", "09C", "10S", "10B"]]))
def test_hand_points_carte_denara_settebello_primiera_scope(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["07D", "06C", "01S", "05B"], # seat 0, team A
["03D", "04C", "07S", "02B"], # seat 1, team B
["02D"], # seat 2, team A
["10D", "10C", "10S", "10B"], # seat 3, team B
],
scope=[1, 0, 0, 2],
)
points, details = engine.hand_points(state)
self.assertEqual([3, 3], points)
self.assertEqual({"A": 5, "B": 8}, details["cards"])
self.assertEqual({"A": 2, "B": 2}, details["denara"])
self.assertEqual({"A": True, "B": False}, details["settebello"])
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
self.assertEqual({"A": 1, "B": 2}, details["scope"])
def test_ties_award_nothing(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["06C", "01S", "05B", "02D"],
["06S", "01B", "05D", "02C"],
[],
[],
],
scope=[0, 0, 0, 0],
)
points, _ = engine.hand_points(state)
# Equal cards, equal denara, equal primiera and no settebello:
# everything ties, so no points at all.
self.assertEqual([0, 0], points)
class NapolaTest(unittest.TestCase):
def test_napola_score_runs(self) -> None:
self.assertEqual(0, engine.napola_score(
[card(c) for c in ["02D", "03D", "04D"]])) # no ace
self.assertEqual(0, engine.napola_score(
[card(c) for c in ["01D", "02D"]])) # too short
self.assertEqual(3, engine.napola_score(
[card(c) for c in ["03D", "01D", "02D"]])) # order-independent
self.assertEqual(4, engine.napola_score(
[card(c) for c in ["01D", "02D", "03D", "04D", "07C"]]))
self.assertEqual(3, engine.napola_score(
[card(c) for c in ["01D", "02D", "03D", "05D"]])) # broken run
self.assertEqual(10, engine.napola_score(
[card(f"{rank:02d}D") for rank in range(1, 11)]))
def test_hand_points_napola(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["01D", "02D", "03D", "04C"], # seat 0, team A
["05D", "06D", "07D", "08D"], # seat 1, team B
["09D", "10D", "01C", "02C"], # seat 2, team A
["03C", "05C", "06C", "07C"], # seat 3, team B
],
)
points, details = engine.hand_points(state)
# Team A has the ace-led run 01D-03D (3 points); team B's denari
# start at the 5, so no napola. Carte tie (8 each), denara to A
# (5 vs 4), settebello to B, primiere tied at 0 (missing suits).
self.assertEqual({"A": 3, "B": 0}, details["napola"])
self.assertEqual("A", details["award"]["napola"])
self.assertEqual([4, 1], points)
def test_napola_disabled(self) -> None:
state = make_state(
[[], [], [], []],
table=[],
captured=[
["01D", "02D", "03D", "04C"],
["05D", "06D", "07D", "08D"],
["09D", "10D", "01C", "02C"],
["03C", "05C", "06C", "07C"],
],
)
state.napola = False
points, details = engine.hand_points(state)
self.assertNotIn("napola", details)
self.assertEqual([1, 1], points)
def test_full_denari_sweep_wins_match_instantly(self) -> None:
# Team A already captured the whole denari suit; the last play of
# the hand cannot capture. Team B leads 50-0, yet the napola ends
# the match in team A's favour, well below the target of 100.
state = make_state(
[["02C"], [], [], []],
table=[],
target=100,
captured=[
[f"{rank:02d}D" for rank in range(1, 11)],
[],
[],
[],
],
)
state.scores = [0, 50]
engine.play(state, "p0", "02C")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
self.assertLess(state.scores[0], 100)
self.assertEqual(10, state.hand_scores[-1]["napola"]["A"])
def test_napola_serialization_roundtrip(self) -> None:
state = make_state([["02D"], [], [], []], table=[])
self.assertTrue(state.napola)
state.napola = False
self.assertFalse(GameState.from_json(state.to_json()).napola)
# States serialized before the option existed default to enabled.
data = state.to_json()
del data["napola"]
self.assertTrue(GameState.from_json(data).napola)
def test_create_game_napola_default_and_override(self) -> None:
self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola)
self.assertFalse(
engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola
)
class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None:
state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11)
self.assertEqual(1, len(state.players))
engine.join_game(state, "p1", "p1")
engine.join_game(state, "p2", "p2")
self.assertEqual("lobby", state.phase)
engine.join_game(state, "p3", "p3")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.players))
for player in state.players:
self.assertEqual(10, len(player.hand))
self.assertEqual([], state.table)
self.assertEqual(1, state.turn) # dealer is seat 0
def test_match_ends_when_target_reached(self) -> None:
state = make_state([["02D"], [], [], []],
table=["02C"], target=1)
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
self.assertGreaterEqual(state.scores[0], 1)
def test_state_for_player_hides_other_hands(self) -> None:
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"])
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(["07C"], view["table"])
self.assertTrue(view.get("your_turn"))
def test_legal_moves_only_for_player_on_turn(self) -> None:
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"])
view = engine.state_for_player(state, "p0")
legal = view["legal_moves"]
# 02D can capture nothing; 03C has no combination either (only 07C
# on the table).
self.assertEqual({}, legal)
state = make_state([["09D"], ["04D"], ["05D"], ["06D"]],
table=["07C", "02S"])
view = engine.state_for_player(state, "p0")
self.assertEqual({"09D": [["07C", "02S"]]}, view["legal_moves"])
# A player who is not on turn gets no legal_moves key.
other = engine.state_for_player(state, "p1")
self.assertNotIn("legal_moves", other)
self.assertNotIn("your_turn", other)
def test_full_random_match_reaches_completion(self) -> None:
state = engine.create_game("g", "CODE99", "p0", "p0", target_score=11)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
moves = 0
while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == "hand_end":
for p in state.players:
engine.acknowledge_hand(state, p.sub)
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = engine.legal_captures(state.table, played)
capture = [c.code for c in options[0]] if options else None
engine.play(state, player.sub, played.code, capture)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIn(state.winner, (0, 1))
# At the end all 40 cards are captured and no hand is left.
self.assertEqual([], state.table)
self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(40, sum(len(p.captured) for p in state.players))
self.assertTrue(state.finished_at)
class HandEndAckTest(unittest.TestCase):
def _hand_end_state(self) -> GameState:
"""Drive a game into the hand_end phase with a one-card hand."""
state = make_state([["02D"], [], [], []],
table=["02C"], target=11)
engine.play(state, "p0", "02D", ["02C"])
return state
def test_end_of_hand_pauses_for_acknowledgement(self) -> None:
state = self._hand_end_state()
self.assertEqual("hand_end", state.phase)
# Nobody has acknowledged yet, and no new hand was dealt.
self.assertEqual([], state.acked)
self.assertEqual(1, state.hand_number)
self.assertTrue(state.hand_end_deadline)
# Capture piles stay visible during the summary.
self.assertEqual(["02C", "02D"],
[c.code for c in state.players[0].captured])
# The summary carries the award map.
summary = state.hand_scores[-1]
self.assertEqual(1, summary["hand"])
self.assertIn("award", summary)
def test_play_during_hand_end_is_rejected(self) -> None:
state = self._hand_end_state()
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "02D")
def test_ack_all_four_deals_next_hand(self) -> None:
state = self._hand_end_state()
dealer_before = state.dealer
for i, sub in enumerate(("p0", "p1", "p2")):
engine.acknowledge_hand(state, sub)
self.assertEqual(list(range(i + 1)), state.acked)
self.assertEqual("hand_end", state.phase)
engine.acknowledge_hand(state, "p3")
self.assertEqual("playing", state.phase)
self.assertEqual(2, state.hand_number)
self.assertEqual((dealer_before + 1) % 4, state.dealer)
self.assertEqual([], state.acked)
self.assertIsNone(state.hand_end_deadline)
self.assertIsNone(state.last_move)
for player in state.players:
self.assertEqual(10, len(player.hand))
self.assertEqual([], player.captured)
self.assertEqual((dealer_before + 2) % 4, state.turn)
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"], ["03D"], ["04D"], ["05D"]], table=[])
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")
def test_state_exposes_ack_progress(self) -> None:
state = self._hand_end_state()
engine.acknowledge_hand(state, "p1")
view = engine.state_for_player(state, "p0")
self.assertEqual([1], view["acknowledged"])
self.assertTrue(view["hand_end_deadline"])
self.assertIsNotNone(view["last_hand"])
self.assertIn("award", view["last_hand"])
class AutoPlayTest(unittest.TestCase):
def test_auto_play_plays_a_card_and_advances_turn(self) -> None:
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["09B"])
state.turn_deadline = "2000-01-01T00:00:00+00:00"
engine.auto_play(state, random.Random(7))
self.assertEqual(1, state.turn)
self.assertEqual(1, len(state.players[0].hand))
# The played card could not capture the nine, so the table grew.
self.assertEqual(2, len(state.table))
self.assertIsNotNone(state.last_move)
assert state.last_move is not None
self.assertEqual(0, state.last_move.seat)
self.assertNotEqual("2000-01-01T00:00:00+00:00", state.turn_deadline)
def test_auto_play_takes_a_mandatory_capture(self) -> None:
# p0 holds only the five of denari, which must capture the equal
# five of coppe instead of the unrelated nine on the table.
state = make_state([["05D"], ["04D"], ["05D"], ["06D"]],
table=["05C", "09B"])
engine.auto_play(state)
self.assertIsNotNone(state.last_move)
assert state.last_move is not None
self.assertEqual("05D", state.last_move.card)
self.assertEqual(["05C"], state.last_move.captured)
self.assertEqual(["09B"], [c.code for c in state.table])
self.assertEqual(["05C", "05D"],
[c.code for c in state.players[0].captured])
def test_auto_play_can_end_the_hand_and_clears_deadline(self) -> None:
state = make_state([["02D"], [], [], []], table=["02C"])
state.turn_deadline = "2000-01-01T00:00:00+00:00"
engine.auto_play(state)
self.assertEqual("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"], ["04D"], ["05D"], ["06D"]], table=[])
state.phase = "hand_end"
with self.assertRaises(GameNotStarted):
engine.auto_play(state)
def test_create_game_copies_turn_timeout_and_arms_deadline(self) -> None:
state = engine.create_game("g", "CODE98", "p0", "p0", turn_timeout=7)
self.assertEqual(7, state.turn_timeout)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertTrue(state.turn_deadline)
view = engine.state_for_player(state, "p0")
self.assertTrue(view["turn_deadline"])
if __name__ == "__main__":
unittest.main()