Add scopa: classic two-player and scopa a quattro

New tavolo-scopa package with a pure rules engine and the platform
adapters for two-player scopa and four-player fixed-partnership scopa a
quattro: three cards each and four on the table, capture by rank or sum,
sweep for scope and score carte/denara/settebello/primiera.

The optional asso pigliatutto creation option turns either variant into
scopa d'assi (an ace sweeps the whole table and scores no scopa), reusing
scopone's rule semantics.

Wire both engines into the app and the Docker install, add the scopa
table view to the SPA (duo and four-seat layouts, deck count,
captured/scope stats, hand-summary and final screens, player names in the
two-player summary), and cover the rules, the platform contract, the
REST/WebSocket flows, match persistence and Elo.
This commit is contained in:
woggioni committed 2026-10-09 05:39:00 +00:00
1 parent 7883d0c458
commit d4d437c1a1
27 files changed
+3401 -116

No files matched your search

@@ -0,0 +1,616 @@
"""Rule engine tests: deals, captures, scope, scoring and full matches."""
from __future__ import annotations
import random
import unittest
from tavolo.scopa import engine
from tavolo.scopa.errors import (
AlreadyJoined,
CardNotInHand,
GameFinished,
GameNotStarted,
IllegalMove,
NotYourTurn,
)
from tavolo.scopa.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
Card,
PlayerState,
ScopaState,
)
def card(code: str) -> Card:
return Card.parse(code)
def make_state(
hands,
table,
turn: int = 0,
*,
dealer: int = 0,
captured=None,
scope=None,
target: int = 11,
last_taker=None,
deck=None,
asso_pigliatutto: bool = False,
) -> ScopaState:
"""Build a controlled game state directly (bypassing the deal)."""
state = ScopaState(
player_count=len(hands),
target_score=target,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_PLAYING,
dealer=dealer,
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]
state.deck = [card(c) for c in (deck or [])]
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"]], 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"]], 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"]], 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"]],
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"]], 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"]], table=["05C"])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "05D")
def test_not_your_turn(self) -> None:
state = make_state([["02D"], ["03D"]], 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"]], table=[])
with self.assertRaises(CardNotInHand):
engine.play(state, "p0", "07D")
def test_finished_game_rejects_moves(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
state.phase = PHASE_FINISHED
with self.assertRaises(GameFinished):
engine.play(state, "p0", "02D")
class DealTest(unittest.TestCase):
def test_create_game_defaults(self) -> None:
state = engine.create_game("p0", "p0")
self.assertEqual(2, state.player_count)
self.assertEqual(11, state.target_score)
self.assertFalse(state.asso_pigliatutto)
self.assertEqual("p0", state.players[0].sub)
self.assertEqual("lobby", state.phase)
def test_create_game_player_count_validation(self) -> None:
for bad in (1, 3, 5, 0):
with self.assertRaises(IllegalMove):
engine.create_game("p0", "p0", player_count=bad)
def test_join_starts_two_player_game(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.table))
self.assertEqual(4, len({c.code for c in state.table}))
for player in state.players:
self.assertEqual(3, len(player.hand))
# 40 - 4 on the table - 6 in hand.
self.assertEqual(30, len(state.deck))
self.assertEqual(1, state.turn)
def test_join_starts_four_player_game(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
for seat in range(1, 4):
engine.join_game(state, f"p{seat}", f"p{seat}")
self.assertEqual(PHASE_PLAYING, state.phase)
self.assertEqual(4, len(state.table))
for player in state.players:
self.assertEqual(3, len(player.hand))
# 40 - 4 on the table - 12 in hand.
self.assertEqual(24, len(state.deck))
self.assertEqual(1, state.turn)
for seat in range(4):
self.assertEqual(seat % 2, engine._player_at(state, seat).team)
def test_join_errors(self) -> None:
state = engine.create_game("p0", "p0")
with self.assertRaises(AlreadyJoined):
engine.join_game(state, "p0", "p0")
engine.join_game(state, "p1", "p1")
# The lobby filled up and the match started.
with self.assertRaises(GameNotStarted):
engine.join_game(state, "p2", "p2")
def test_start_requires_full_table(self) -> None:
state = engine.create_game("p0", "p0", player_count=4)
engine.join_game(state, "p1", "p1")
with self.assertRaises(GameNotStarted):
engine.start_game(state)
class RoundTest(unittest.TestCase):
def test_redeal_when_hands_empty_and_deck_has_cards(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], ["03D"]], table=["09C"],
deck=deck, turn=0, dealer=0)
engine.play(state, "p0", "02D")
self.assertEqual(1, state.turn)
# The last card of the round: the next three cards are dealt.
engine.play(state, "p1", "03D")
self.assertEqual(PHASE_PLAYING, state.phase)
for player in state.players:
self.assertEqual(3, len(player.hand))
self.assertEqual([], state.deck)
# The table is never topped up.
self.assertEqual(
{"09C", "02D", "03D"}, {c.code for c in state.table}
)
# The player after the dealer leads every round again.
self.assertEqual(1, state.turn)
def test_redeal_gives_the_lead_back_to_the_non_dealer(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], ["03D"]], table=[], deck=deck,
turn=0, dealer=1)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "03D")
# Dealer seat 1: the lead is seat 0 again.
self.assertEqual(0, state.turn)
def test_hand_ends_when_deck_is_exhausted(self) -> None:
state = make_state([["02D"], ["03D"]], table=[], deck=[], target=100)
engine.play(state, "p0", "02D")
engine.play(state, "p1", "03D")
self.assertEqual(PHASE_HAND_END, state.phase)
class ScopaTest(unittest.TestCase):
def test_scopa_on_the_last_play_of_a_round_with_deck_left(self) -> None:
deck = [f"{rank:02d}{suit}" for rank in (4, 5, 6)
for suit in ("D", "C")]
state = make_state([["02D"], []], table=["02C"], deck=deck, turn=0)
engine.play(state, "p0", "02D", ["02C"])
# Cards remain in the deck: the sweep is a scopa, and the round is
# re-dealt immediately.
self.assertEqual(1, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.scopa)
for player in state.players:
self.assertEqual(3, len(player.hand))
def test_no_scopa_on_the_last_play_of_the_hand(self) -> None:
state = make_state([["02D"], []], table=["02C"], deck=[],
turn=0, target=100)
engine.play(state, "p0", "02D", ["02C"])
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertFalse(state.last_move.scopa)
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertEqual(0, state.hand_scores[-1]["scope"]["A"])
class AssoPigliatuttoTest(unittest.TestCase):
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
# p1 still holds cards, so emptying the table would normally score
# a scopa: the sweep must suppress it.
state = make_state(
[["01D", "09C"], ["04D"]],
table=["05C", "02D", "03S"],
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
self.assertEqual([], state.table)
self.assertEqual(
{"05C", "02D", "03S", "01D"},
{c.code for c in state.players[0].captured},
)
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertFalse(state.last_move.scopa)
def test_legal_captures_offer_only_the_whole_table(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(
table, card("01D"), asso_pigliatutto=True
)
self.assertEqual(
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
)
# A bare table leaves only the self-capture option; without the
# rule the ace stays on the table.
self.assertEqual(
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
)
self.assertEqual(
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
)
# Non-ace cards keep the normal rules.
self.assertEqual(
[],
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
)
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
state = make_state(
[["01D"], ["04D"]],
table=["01C", "02D"],
asso_pigliatutto=True,
)
options = engine.legal_captures(
state.table, card("01D"), asso_pigliatutto=True
)
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "01D", ["01C", "02D"])
# A rejected play consumes the card, so retry on a fresh state.
state = make_state(
[["01D"], ["04D"]],
table=["01C", "02D"],
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D", ["01C"])
self.assertEqual(["02D"], [c.code for c in state.table])
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_disabled_by_default(self) -> None:
state = make_state([["01D", "09C"], ["04D"]], table=["05C", "02D"])
engine.play(state, "p0", "01D")
# Without the rule an ace captures nothing here and stays on the
# table.
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
# ... and on a bare table it stays there too.
state = make_state([["01D", "09C"], ["04D"]], table=[])
engine.play(state, "p0", "01D")
self.assertEqual(["01D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
def test_ace_on_empty_table_captures_itself(self) -> None:
# The self-capture makes p0 the last taker and scores no scopa.
state = make_state(
[["01D", "09C"], ["04D"]],
table=[],
last_taker=1,
asso_pigliatutto=True,
)
engine.play(state, "p0", "01D")
self.assertEqual([], state.table)
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
self.assertEqual(0, state.players[0].scope)
self.assertEqual(0, state.last_taker)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_non_ace_on_empty_table_stays(self) -> None:
state = make_state(
[["02D", "09C"], ["04D"]], table=[], asso_pigliatutto=True
)
engine.play(state, "p0", "02D")
self.assertEqual(["02D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
class HandEndTest(unittest.TestCase):
def test_table_swept_to_last_taker(self) -> None:
state = make_state([["02D"], []], table=["05C", "04D"],
last_taker=1, deck=[], target=100)
engine.play(state, "p0", "02D")
captured = {c.code for c in state.players[1].captured}
self.assertEqual({"05C", "04D", "02D"}, captured)
self.assertEqual(PHASE_HAND_END, state.phase)
def test_match_ends_at_target_with_clear_lead(self) -> None:
state = make_state([["02D"], []], table=[], deck=[], turn=0,
target=1, captured=[["07D"], []])
engine.play(state, "p0", "02D")
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertEqual(0, state.winner)
def test_tie_at_target_plays_another_hand(self) -> None:
state = make_state(
[["02D"], []],
table=[],
deck=[],
target=1,
captured=[["01D", "02C"], ["02D", "01C"]],
)
state.scores = [1, 1]
engine.play(state, "p0", "02D")
# A 0-0 hand at the target is not a win: another hand is played.
self.assertEqual(PHASE_HAND_END, state.phase)
self.assertIsNone(state.winner)
def test_acknowledge_deals_next_hand(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
state.phase = PHASE_HAND_END
engine.acknowledge_hand(state, "p0")
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(1, state.dealer)
self.assertEqual(4, len(state.table))
for player in state.players:
self.assertEqual(3, len(player.hand))
def test_acknowledge_rejects_outside_hand_end(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
with self.assertRaises(IllegalMove):
engine.acknowledge_hand(state, "p0")
def test_hand_summary_has_no_napola(self) -> None:
state = make_state([["02D"], []], table=[], deck=[], target=1,
captured=[["07D"], []])
engine.play(state, "p0", "02D")
self.assertNotIn("napola", state.hand_scores[-1])
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_two_player_hand_points(self) -> None:
state = make_state(
[[], []],
table=[],
captured=[
["07D", "06C", "01S", "05B"],
["03D", "04C", "07S", "02B"],
],
scope=[1, 0],
)
points, details = engine.hand_points(state)
# Carte and denara tie; A takes settebello and primiera, plus one
# scopa: A+3.
self.assertEqual({"A": 4, "B": 4}, details["cards"])
self.assertEqual({"A": 1, "B": 1}, details["denara"])
self.assertEqual({"A": True, "B": False}, details["settebello"])
self.assertEqual({"A": 70, "B": 60}, details["primiera"])
self.assertEqual({"A": 1, "B": 0}, details["scope"])
self.assertEqual([3, 0], points)
def test_ties_award_nothing(self) -> None:
state = make_state(
[[], []],
table=[],
captured=[
["06C", "01S", "05B", "02D"],
["06S", "01B", "05D", "02C"],
],
scope=[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)
def test_four_player_pools_the_partners(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"])
self.assertNotIn("napola", details)
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
state = engine.create_game(
"p0", "p0", player_count=4, target_score=16,
asso_pigliatutto=True,
)
for seat in range(1, 4):
engine.join_game(state, f"p{seat}", f"p{seat}")
restored = ScopaState.from_json(state.to_json())
self.assertEqual(4, restored.player_count)
self.assertEqual(16, restored.target_score)
self.assertTrue(restored.asso_pigliatutto)
self.assertEqual(
[c.code for c in state.deck], [c.code for c in restored.deck]
)
self.assertEqual(
[c.code for c in state.table], [c.code for c in restored.table]
)
self.assertEqual(state.phase, restored.phase)
self.assertEqual(state.turn, restored.turn)
self.assertEqual(
[p.sub for p in state.players], [p.sub for p in restored.players]
)
def test_asso_pigliatutto_defaults_off(self) -> None:
state = make_state([["02D"], ["03D"]], table=[])
data = state.to_json()
del data["asso_pigliatutto"]
self.assertFalse(ScopaState.from_json(data).asso_pigliatutto)
def test_player_count_defaults_to_two(self) -> None:
data = ScopaState().to_json()
del data["player_count"]
self.assertEqual(2, ScopaState.from_json(data).player_count)
class AutoPlayTest(unittest.TestCase):
def test_auto_play_applies_a_legal_move(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
rng = random.Random(7)
player = engine._player_at(state, state.turn)
hand_size = len(player.hand)
engine.auto_play(state, rng)
self.assertEqual(hand_size - 1, len(player.hand))
assert state.last_move is not None
self.assertEqual(player.seat, state.last_move.seat)
class FullMatchTest(unittest.TestCase):
def _play_out(self, player_count: int, target: int) -> ScopaState:
state = engine.create_game("p0", "p0", player_count=player_count,
target_score=target)
for seat in range(1, player_count):
engine.join_game(state, f"p{seat}", f"p{seat}")
rng = random.Random(1234 + player_count)
steps = 0
while state.phase != PHASE_FINISHED and steps < 100_000:
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.acknowledge_hand(state, player.sub)
continue
engine.auto_play(state, rng)
steps += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIsNotNone(state.winner)
self.assertGreaterEqual(max(state.scores), target)
self.assertNotEqual(state.scores[0], state.scores[1])
return state
def test_random_two_player_match_reaches_a_winner(self) -> None:
state = self._play_out(2, 5)
for hand in state.hand_scores:
self.assertIn("cards", hand)
self.assertIn("scope", hand)
def test_random_four_player_match_reaches_a_winner(self) -> None:
state = self._play_out(4, 5)
self.assertEqual(4, len(state.players))
def test_all_cards_are_captured_by_the_end_of_a_hand(self) -> None:
state = engine.create_game("p0", "p0")
engine.join_game(state, "p1", "p1")
rng = random.Random(99)
# Finish exactly one hand (target 100 keeps the match alive).
state.target_score = 100
while state.phase != PHASE_HAND_END:
engine.auto_play(state, rng)
self.assertIsNotNone(state.last_taker)
self.assertEqual(
40, sum(len(p.captured) for p in state.players)
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,335 @@
"""Adapter tests: the platform contract over the pure scopa rules."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from tavolo.platform import GameSession, Seat
from tavolo.platform.errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotStarted,
IllegalMove,
)
from tavolo.scopa import ScopaEngine, ScopaQuattroEngine
from tavolo.scopa import engine as rules
from tavolo.scopa.state import (
PHASE_FINISHED,
PHASE_HAND_END,
PHASE_PLAYING,
ScopaState,
)
ENGINES = (ScopaEngine, ScopaQuattroEngine)
def _session(engine, **options) -> GameSession:
session = GameSession(
id="s1",
game_type=engine.id,
join_code="CODE01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(session, options)
return session
def _started(engine, **options) -> GameSession:
session = _session(engine, **options)
for seat in range(1, engine.max_players):
engine.join(session, f"p{seat}", f"P{seat}")
return session
def _drive_to_end(engine, session: GameSession) -> None:
"""Play the match out with the first legal move each turn."""
moves = 0
while not engine.is_finished(session) and moves < 200_000:
state = session.state
if state.phase == PHASE_HAND_END:
for player in state.players:
engine.handle_action(session, player.sub, "ack", {})
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(
state.table, played, asso_pigliatutto=state.asso_pigliatutto
)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
moves += 1
class CreateTest(unittest.TestCase):
def test_create_seats_creator_on_side_a(self) -> None:
for engine_type in ENGINES:
engine = engine_type()
session = _session(engine)
self.assertEqual("A", session.players[0].team)
self.assertIsInstance(session.state, ScopaState)
self.assertEqual(11, session.state.target_score)
self.assertFalse(session.state.asso_pigliatutto)
def test_create_options(self) -> None:
engine = ScopaEngine()
session = _session(engine, target_score=21, asso_pigliatutto=True)
self.assertEqual(21, session.state.target_score)
self.assertTrue(session.state.asso_pigliatutto)
def test_create_rejects_bad_options(self) -> None:
engine = ScopaEngine()
with self.assertRaises(IllegalMove):
_session(engine, target_score=0)
with self.assertRaises(IllegalMove):
_session(engine, target_score=101)
with self.assertRaises(IllegalMove):
_session(engine, target_score="eleven")
with self.assertRaises(IllegalMove):
_session(engine, target_score=True)
with self.assertRaises(IllegalMove):
_session(engine, asso_pigliatutto="yes")
def test_timeouts_come_from_the_engine(self) -> None:
engine = ScopaEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
session = _session(engine)
self.assertEqual(7, session.state.turn_timeout)
self.assertEqual(9, session.state.hand_ack_timeout)
class JoinTest(unittest.TestCase):
def test_join_starts_two_player_game(self) -> None:
engine = ScopaEngine()
session = _session(engine)
self.assertTrue(engine.in_lobby(session))
engine.join(session, "bob", "Bob")
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(["A", "B"], [p.team for p in session.players])
def test_join_starts_four_player_game_with_teams(self) -> None:
engine = ScopaQuattroEngine()
session = _session(engine)
for seat, team in ((1, "B"), (2, "A"), (3, "B")):
engine.join(session, f"p{seat}", f"P{seat}")
self.assertEqual(team, session.players[-1].team)
self.assertFalse(engine.in_lobby(session))
self.assertEqual(PHASE_PLAYING, session.state.phase)
def test_join_errors(self) -> None:
engine = ScopaEngine()
session = _session(engine)
with self.assertRaises(AlreadyJoined):
engine.join(session, "alice", "Alice")
engine.join(session, "bob", "Bob")
with self.assertRaises(GameNotStarted):
engine.join(session, "carol", "Carol")
with self.assertRaises(GameNotStarted):
engine.join(session, "alice", "Alice")
class ActionTest(unittest.TestCase):
def test_unknown_action_rejected(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "alice", "dance", {})
def test_play_validates_payload(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": 42})
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {})
with self.assertRaises(IllegalMove):
engine.handle_action(
session, "p1", "play", {"card": "01D", "capture": "02C"}
)
def test_invalid_card_code_is_illegal_move(self) -> None:
engine = ScopaEngine()
session = _started(engine)
with self.assertRaises(IllegalMove):
engine.handle_action(session, "p1", "play", {"card": "nope"})
def test_finished_match_rejects_actions(self) -> None:
engine = ScopaEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
self.assertTrue(engine.is_finished(session))
with self.assertRaises(GameFinished):
engine.handle_action(session, "alice", "play", {"card": "01D"})
class ViewTest(unittest.TestCase):
def test_view_hides_other_hands_and_reports_the_deck(self) -> None:
engine = ScopaEngine()
session = _started(engine)
view = engine.view_for(session, "alice")
players = {p["seat"]: p for p in view["players"]}
self.assertIn("hand", players[0])
self.assertNotIn("hand", players[1])
self.assertEqual(2, view["player_count"])
self.assertEqual(30, view["deck_count"])
self.assertEqual(4, len(view["table"]))
# The envelope is the platform's job, not the view's.
self.assertNotIn("id", view)
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
self.assertIn("table", view)
self.assertEqual(4, view["player_count"])
def test_lobby_view(self) -> None:
engine = ScopaEngine()
session = _session(engine, target_score=16, asso_pigliatutto=True)
lobby = engine.lobby_view(session)
self.assertEqual("lobby", lobby["phase"])
self.assertEqual(16, lobby["target_score"])
self.assertTrue(lobby["asso_pigliatutto"])
class SerializationTest(unittest.TestCase):
def test_state_roundtrip(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine)
restored = engine.state_from_json(engine.state_to_json(session.state))
self.assertIsInstance(restored, ScopaState)
self.assertEqual(session.state.phase, restored.phase)
self.assertEqual(session.state.turn, restored.turn)
self.assertEqual(
[p.sub for p in session.state.players],
[p.sub for p in restored.players],
)
self.assertEqual(
[c.code for c in session.state.deck],
[c.code for c in restored.deck],
)
class DeadlineTest(unittest.TestCase):
def test_no_deadline_in_lobby(self) -> None:
engine = ScopaEngine()
self.assertIsNone(engine.next_deadline(_session(engine)))
def test_turn_deadline_and_revalidation(self) -> None:
engine = ScopaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("turn", deadline.kind)
# A forged token is stale.
with self.assertRaises(GameError):
engine.fire_deadline(session, "turn", "turn:1:1:0")
turn_before = session.state.turn
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertNotEqual(turn_before, session.state.turn)
def test_stale_deadline_after_play(self) -> None:
engine = ScopaEngine()
session = _started(engine)
deadline = engine.next_deadline(session)
assert deadline is not None
state = session.state
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = rules.legal_captures(state.table, played)
payload = {"card": played.code}
if options:
payload["capture"] = [c.code for c in options[0]]
engine.handle_action(session, player.sub, "play", payload)
with self.assertRaises(GameError):
engine.fire_deadline(session, deadline.kind, deadline.token)
def test_hand_end_deadline_advances(self) -> None:
engine = ScopaEngine()
session = _started(engine)
state = session.state
# Force the hand-end phase with an imminent deadline.
state.phase = PHASE_HAND_END
state.hand_end_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=60)
).isoformat()
deadline = engine.next_deadline(session)
assert deadline is not None
self.assertEqual("hand_end", deadline.kind)
engine.fire_deadline(session, deadline.kind, deadline.token)
self.assertEqual(PHASE_PLAYING, session.state.phase)
self.assertEqual(2, session.state.hand_number)
class ResultTest(unittest.TestCase):
def test_result_of_finished_two_player_match(self) -> None:
engine = ScopaEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
result = engine.result(session)
self.assertEqual(2, len(result.teams))
self.assertEqual(1, len(result.teams[0]))
self.assertEqual(1, len(result.teams[1]))
self.assertIn(result.winner_team, (0, 1))
self.assertEqual(2, len(result.players))
for player in result.players:
self.assertEqual(
player.won,
player.team == ("A" if result.winner_team == 0 else "B"),
)
self.assertIn("team_a_score", result.summary)
self.assertIn("hands_played", result.summary)
self.assertIn("asso_pigliatutto", result.summary)
def test_result_of_finished_four_player_match(self) -> None:
engine = ScopaQuattroEngine()
session = _started(engine, target_score=1)
_drive_to_end(engine, session)
result = engine.result(session)
self.assertEqual([2, 2], [len(team) for team in result.teams])
self.assertEqual(4, len(result.players))
def test_result_requires_finished_match(self) -> None:
engine = ScopaEngine()
with self.assertRaises(GameError):
engine.result(_started(engine))
def test_game_over_view(self) -> None:
engine = ScopaEngine()
session = _started(engine)
session.state.phase = PHASE_FINISHED
session.state.winner = 1
session.state.scores = [3, 11]
over = engine.game_over_view(session, "alice")
self.assertEqual({"A": 3, "B": 11}, over["scores"])
self.assertEqual("B", over["winner"])
def test_registry_metadata(self) -> None:
two = ScopaEngine()
self.assertEqual("scopa", two.id)
self.assertEqual("Scopa", two.name)
self.assertEqual(2, two.min_players)
self.assertEqual(2, two.max_players)
four = ScopaQuattroEngine()
self.assertEqual("scopa_a_quattro", four.id)
self.assertEqual("Scopa a quattro", four.name)
self.assertEqual(4, four.min_players)
self.assertEqual(4, four.max_players)
for engine in (two, four):
self.assertIn("target_score", engine.options_schema["properties"])
self.assertIn(
"asso_pigliatutto", engine.options_schema["properties"]
)
if __name__ == "__main__":
unittest.main()