CI / Build and push docker image (push) Successful in 3m12s
The platform now hosts multiple card games, with scopone scientifico as the first one. Rename the brand wherever it is not a game rule: - move the Python package to server/src/tavolo and update imports - rename the Postgres database/user, OIDC issuer path, client id and Redis key prefixes to tavolo (clean break: existing pgdata volumes and live games are not migrated) - rename the Cargo package to tavolo-web and set the page title to Tavolo - update docs and the Docker image path to woggioni/tavolo The scopa game term (clearing the table) in the engine, state and web UI is intentionally left untouched.
451 lines
18 KiB
Python
451 lines
18 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 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()
|