Multiplayer scopone scientifico backend on the kaya framework:
- OIDC login (kaya-oidc), session-backed WebSocket auth
- Pure rules engine (forced captures, scopa, primiera scoring) with
full-match simulation tests
- Live game state in Redis (JSON + TTL, join codes, per-game locks,
pub/sub state push); in-memory fallback for tests
- WebSocket /ws/games/{id} for real-time play; REST lobby endpoints
(create/join/snapshot) with hidden-hand views
- Finished matches persisted to Postgres (Tortoise + aerich) for match
history and leaderboard endpoints
- Docker Compose stack: postgres, redis, mock-oauth2-server, db-migrate, app
- 45 tests passing; mypy clean
283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Rule engine tests: captures, scope, scoring and full-match simulation."""
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from scopa.game import engine
|
|
from scopa.game.errors import (
|
|
CardNotInHand,
|
|
GameFinished,
|
|
IllegalMove,
|
|
NotYourTurn,
|
|
)
|
|
from scopa.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]
|
|
)
|
|
|
|
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_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:
|
|
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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|