CI / Build and push docker image (push) Successful in 2m6s
Partnership games go to 21: scopone scientifico (the tournament target) and scopa a quattro, while two-player scopa keeps its usual 11. The scopa package resolves the default from the player count and exposes a per variant options_schema default, so the lobby creation form shows the right value without frontend changes.
626 lines
25 KiB
Python
626 lines
25 KiB
Python
"""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)
|
|
# Two-player scopa is traditionally played to 11 points.
|
|
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_four_player_default_target(self) -> None:
|
|
# The four-player partnership game (like scopone) goes to 21.
|
|
state = engine.create_game("p0", "p0", player_count=4)
|
|
self.assertEqual(21, state.target_score)
|
|
# An explicit target still wins over the default.
|
|
explicit = engine.create_game("p0", "p0", player_count=4, target_score=16)
|
|
self.assertEqual(16, explicit.target_score)
|
|
|
|
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()
|