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:
2026-10-09 14:45:20 +08:00
parent 9eea293471
commit 68200bbe62
27 changed files with 3401 additions and 116 deletions
+25 -3
View File
@@ -302,6 +302,8 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(
[
"scopone_scientifico",
"scopa",
"scopa_a_quattro",
"briscola",
"briscola_a_quattro",
"briscola_chiamata",
@@ -318,19 +320,39 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertIn(
"asso_pigliatutto", scopone["options_schema"]["properties"]
)
briscola = results[1]
scopa = results[1]
self.assertEqual("Scopa", scopa["name"])
self.assertTrue(scopa["description"])
self.assertEqual(2, scopa["min_players"])
self.assertEqual(2, scopa["max_players"])
self.assertIn("target_score", scopa["options_schema"]["properties"])
self.assertIn(
"asso_pigliatutto", scopa["options_schema"]["properties"]
)
scopa_quattro = results[2]
self.assertEqual("Scopa a quattro", scopa_quattro["name"])
self.assertTrue(scopa_quattro["description"])
self.assertEqual(4, scopa_quattro["min_players"])
self.assertEqual(4, scopa_quattro["max_players"])
self.assertIn(
"target_score", scopa_quattro["options_schema"]["properties"]
)
self.assertIn(
"asso_pigliatutto", scopa_quattro["options_schema"]["properties"]
)
briscola = results[3]
self.assertEqual("Briscola", briscola["name"])
self.assertTrue(briscola["description"])
self.assertEqual(2, briscola["min_players"])
self.assertEqual(2, briscola["max_players"])
self.assertIn("target_score", briscola["options_schema"]["properties"])
quattro = results[2]
quattro = results[4]
self.assertEqual("Briscola a quattro", quattro["name"])
self.assertTrue(quattro["description"])
self.assertEqual(4, quattro["min_players"])
self.assertEqual(4, quattro["max_players"])
self.assertIn("target_score", quattro["options_schema"]["properties"])
chiamata = results[3]
chiamata = results[5]
self.assertEqual("Briscola chiamata", chiamata["name"])
self.assertTrue(chiamata["description"])
self.assertEqual(5, chiamata["min_players"])
+412
View File
@@ -0,0 +1,412 @@
"""Scopa integration tests: lobby, websocket play, timeouts and summaries.
The per-package suites cover the rules and the engine contract; these
tests wire both scopa variants through the full platform stack (REST,
WebSocket, deadline scheduler).
"""
from __future__ import annotations
import asyncio
import contextlib
import unittest
from httpx import ASGITransport, AsyncClient
from httpx_ws import aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from tavolo.app import app, game_store, platform
from tavolo.scopa.state import (
PHASE_HAND_END,
PHASE_PLAYING,
Card,
PlayerState,
ScopaState,
)
from tavolo.platform import GameSession, Seat
from tests.helpers import async_test, make_user, oidc_user, ws_users
class ScopaRoutesTest(unittest.TestCase):
@async_test
async def test_create_and_start_two_player_game(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games",
json={
"game_type": "scopa",
"options": {"target_score": 21, "asso_pigliatutto": True},
},
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("scopa", body["game_type"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(1, body["seats_open"])
self.assertEqual(21, body["target_score"])
self.assertTrue(body["asso_pigliatutto"])
self.assertEqual("A", body["players"][0]["team"])
code = body["join_code"]
with oidc_user("bob"):
started = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, started.status_code)
state = started.json()
self.assertEqual("playing", state["phase"])
self.assertEqual(2, len(state["players"]))
self.assertEqual(["A", "B"], [p["team"] for p in state["players"]])
for participant in state["players"]:
self.assertEqual(3, participant["cards_left"])
self.assertEqual(4, len(state["table"]))
self.assertEqual(30, state["deck_count"])
self.assertEqual(1, state["turn"])
self.assertTrue(state["asso_pigliatutto"])
# Bob's view carries his own hand only.
self.assertIn("hand", state["players"][1])
self.assertNotIn("hand", state["players"][0])
# A non-player watches without a hand or the join code.
watched = await client.get(f"/api/games/{state['id']}")
self.assertEqual(200, watched.status_code)
spectator = watched.json()
self.assertTrue(spectator["spectator"])
self.assertNotIn("join_code", spectator)
for participant in spectator["players"]:
self.assertNotIn("hand", participant)
@async_test
async def test_create_and_start_four_player_game(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games", json={"game_type": "scopa_a_quattro"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("scopa_a_quattro", body["game_type"])
self.assertEqual(3, body["seats_open"])
code = body["join_code"]
for player in ("bob", "carol"):
with oidc_user(player):
joined = await client.post("/api/games/join", json={"code": code})
self.assertEqual("lobby", joined.json()["phase"])
with oidc_user("dave"):
started = await client.post("/api/games/join", json={"code": code})
state = started.json()
self.assertEqual("playing", state["phase"])
self.assertEqual(["A", "B", "A", "B"], [p["team"] for p in state["players"]])
for participant in state["players"]:
self.assertEqual(3, participant["cards_left"])
self.assertEqual(4, len(state["table"]))
self.assertEqual(24, state["deck_count"])
@async_test
async def test_preview_shows_scopa_options(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games",
json={
"game_type": "scopa",
"options": {"target_score": 21, "asso_pigliatutto": True},
},
)
code = created.json()["join_code"]
with oidc_user("bob"):
preview = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, preview.status_code)
body = preview.json()
self.assertEqual("scopa", body["game_type"])
self.assertEqual("Scopa", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual(21, body["target_score"])
self.assertTrue(body["asso_pigliatutto"])
self.assertEqual(1, body["seats_open"])
@async_test
async def test_create_rejects_bad_options(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
for bad in (0, 101, "eleven", True):
with oidc_user("alice"):
response = await client.post(
"/api/games",
json={"game_type": "scopa", "options": {"target_score": bad}},
)
self.assertEqual(400, response.status_code, bad)
with oidc_user("alice"):
response = await client.post(
"/api/games",
json={"game_type": "scopa", "options": {"asso_pigliatutto": "yes"}},
)
self.assertEqual(400, response.status_code)
class ScopaWebSocketTest(unittest.TestCase):
async def _started_two_player(self, client: AsyncClient) -> dict:
with oidc_user("alice"):
created = await client.post("/api/games", json={"game_type": "scopa"})
code = created.json()["join_code"]
with oidc_user("bob"):
started = await client.post("/api/games/join", json={"code": code})
return started.json()
@async_test
async def test_play_trails_or_captures(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_two_player(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
first = await bob_ws.receive_json()
self.assertEqual("state", first["type"])
game = first["game"]
self.assertEqual(1, game["turn"])
self.assertTrue(game.get("your_turn"))
self.assertEqual(4, len(game["table"]))
card = game["players"][1]["hand"][0]
options = (game.get("legal_moves") or {}).get(card)
payload = {"action": "play", "card": card}
if options:
payload["capture"] = options[0]
await bob_ws.send_json(payload)
update = await bob_ws.receive_json()
self.assertEqual("state", update["type"])
game = update["game"]
self.assertEqual(0, game["turn"])
self.assertIsNotNone(game["last_move"])
self.assertEqual(1, game["last_move"]["seat"])
self.assertEqual(card, game["last_move"]["card"])
if options:
self.assertEqual(2, game["players"][1]["captured_count"])
else:
self.assertEqual(5, len(game["table"]))
@async_test
async def test_illegal_move_returns_error(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_two_player(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
await alice_ws.receive_json()
# Bob (seat 1) is on turn, not Alice.
await alice_ws.send_json({"action": "play", "card": "01D"})
error = await alice_ws.receive_json()
self.assertEqual("error", error["type"])
@async_test
async def test_spectator_cannot_act(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_two_player(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
for player in first["game"]["players"]:
self.assertNotIn("hand", player)
await ws.send_json({"action": "play", "card": "01D"})
error = await ws.receive_json()
self.assertEqual("forbidden", error["code"])
@async_test
async def test_asso_pigliatutto_sweeps_the_table(self) -> None:
# A controlled scopa d'assi position: Alice's ace sweeps the whole
# table, scores no scopa and counts as the last capture.
state = ScopaState(
player_count=2,
target_score=100,
asso_pigliatutto=True,
phase=PHASE_PLAYING,
dealer=0,
turn=0,
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
hand=[Card.parse("01D"), Card.parse("09C")],
),
PlayerState(
sub="bob", name="Bob", seat=1,
hand=[Card.parse("04D")],
),
],
table=[Card.parse("05C"), Card.parse("02D")],
)
engine = platform.registry.require("scopa")
session = GameSession(
id="scopa-asso-pigliatutto-1",
game_type="scopa",
join_code="SA0001",
creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
],
)
session.state = state
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(0, first["game"]["turn"])
self.assertTrue(first["game"].get("your_turn"))
await ws.send_json(
{"action": "play", "card": "01D", "capture": ["05C", "02D"]}
)
update = await ws.receive_json()
game = update["game"]
self.assertEqual([], game["table"])
self.assertEqual(3, game["players"][0]["captured_count"])
self.assertEqual(0, game["players"][0]["scope"])
self.assertEqual(0, game["scores"]["A"])
self.assertTrue(game["last_move"]["pigliatutto"])
self.assertFalse(game["last_move"]["scopa"])
@async_test
async def test_hand_end_ack_flow(self) -> None:
# A one-play hand with target 100 so the match continues to the
# scoring summary.
state = ScopaState(
player_count=2,
target_score=100,
phase=PHASE_PLAYING,
dealer=0,
turn=0,
players=[
PlayerState(
sub="alice", name="Alice", seat=0,
hand=[Card.parse("02D")],
),
PlayerState(sub="bob", name="Bob", seat=1),
],
table=[Card.parse("02C")],
)
session = GameSession(
id="scopa-hand-end-1",
game_type="scopa",
join_code="SH0001",
creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
],
)
session.state = state
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
async with contextlib.AsyncExitStack() as stack:
with ws_users([make_user("alice"), make_user("bob")]):
sockets = [
await stack.enter_async_context(
aconnect_ws(f"/ws/games/{session.id}", ws_client)
)
for _ in range(2)
]
for ws in sockets:
await ws.receive_json()
# Alice captures the last table card: the hand ends.
await sockets[0].send_json(
{"action": "play", "card": "02D", "capture": ["02C"]}
)
summaries = [await ws.receive_json() for ws in sockets]
for summary in summaries:
self.assertEqual("state", summary["type"])
self.assertEqual(PHASE_HAND_END, summary["game"]["phase"])
self.assertEqual([], summary["game"]["acknowledged"])
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
hand = summary["game"]["last_hand"]
self.assertEqual({"A": 2, "B": 0}, hand["cards"])
self.assertEqual("A", hand["award"]["carte"])
# Both acknowledge; the second ack deals the next hand.
await sockets[0].send_json({"action": "ack"})
updates = [await ws.receive_json() for ws in sockets]
for update in updates:
self.assertEqual(PHASE_HAND_END, update["game"]["phase"])
self.assertEqual([0], update["game"]["acknowledged"])
await sockets[1].send_json({"action": "ack"})
updates = [await ws.receive_json() for ws in sockets]
for update in updates:
self.assertEqual(PHASE_PLAYING, update["game"]["phase"])
self.assertEqual(2, update["game"]["hand_number"])
for player in update["game"]["players"]:
self.assertEqual(3, player["cards_left"])
class ScopaTurnTimeoutTest(unittest.TestCase):
@async_test
async def test_turn_timeout_auto_plays_a_card(self) -> None:
engine = platform.registry.require("scopa")
session = GameSession(
id="scopa-turn-timeout-1",
game_type=engine.id,
join_code="ST0001",
creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
],
)
engine.create(session, {})
session.state.turn_timeout = 1
engine.join(session, "bob", "Bob")
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
first = await ws.receive_json()
# Bob (seat 1) is first to act and never connects.
self.assertEqual(1, first["game"]["turn"])
self.assertIsNotNone(first["game"]["turn_deadline"])
update = None
for _ in range(20):
try:
update = await asyncio.wait_for(ws.receive_json(), timeout=2)
except asyncio.TimeoutError:
break
if (
update.get("type") == "state"
and update["game"]["last_move"] is not None
):
break
self.assertIsNotNone(update)
assert update is not None
# The absent player was auto-played: the move is on the
# board and the turn advanced.
self.assertEqual(1, update["game"]["last_move"]["seat"])
self.assertEqual(0, update["game"]["turn"])
players = {p["seat"]: p for p in update["game"]["players"]}
self.assertEqual(2, players[1]["cards_left"])
self.assertEqual(3, players[0]["cards_left"])
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -19,6 +19,7 @@ from tavolo.platform.elo import INITIAL_RATING
from tavolo.platform.errors import IllegalMove
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from tavolo.scopa import engine as scopa_rules
from tavolo.scopone import engine as rules
from tests.helpers import async_test, oidc_user
@@ -318,5 +319,132 @@ class ChiamataStatsTest(unittest.TestCase):
)
class ScopaStatsTest(unittest.TestCase):
async def _finished_session(
self, player_count: int = 2, asso_pigliatutto: bool = False
) -> GameSession:
engine = platform.registry.require(
"scopa" if player_count == 2 else "scopa_a_quattro"
)
session = GameSession(
id=f"stats-scopa-{player_count}",
game_type=engine.id,
join_code="SC0001",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(
session,
{"target_score": 1, "asso_pigliatutto": asso_pigliatutto},
)
for name in PLAYERS[1:player_count]:
engine.join(session, name, name.capitalize())
moves = 0
while not engine.is_finished(session) and moves < 200000:
state = session.state
if state.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 = scopa_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
assert engine.is_finished(session)
return session
@async_test
async def test_finished_two_player_match_persisted_with_summary(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("scopa")
session = await self._finished_session(2, asso_pigliatutto=True)
with ctx:
await save_match_result(session, engine)
self.assertEqual(1, await Match.all().count())
self.assertEqual(2, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
self.assertEqual("scopa", match.game_type)
summary = match.result
for key in (
"team_a_score",
"team_b_score",
"winner_team",
"target_score",
"asso_pigliatutto",
"hands_played",
"hand_scores",
):
self.assertIn(key, summary)
self.assertTrue(summary["asso_pigliatutto"])
winner_team = summary["winner_team"]
players = {p.user_sub: p for p in await MatchPlayer.all()}
for row in players.values():
self.assertEqual(row.won, row.team == winner_team)
# A two-player match moves Elo by 16 points each.
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
self.assertEqual(
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
)
@async_test
async def test_finished_four_player_match_persisted_with_teams(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("scopa_a_quattro")
session = await self._finished_session(4)
with ctx:
await save_match_result(session, engine)
self.assertEqual(4, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
self.assertEqual("scopa_a_quattro", match.game_type)
players = await MatchPlayer.all()
self.assertEqual({"A", "B"}, {p.team for p in players})
# Partnership Elo: the winning side gains 16 each.
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
winners = {p.user_sub for p in players if p.won}
self.assertEqual(
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
)
self.assertEqual(
{INITIAL_RATING - 16},
{ratings[sub] for sub in ratings if sub not in winners},
)
@async_test
async def test_history_exposes_scopa_result(self) -> None:
await tortoise_mixin._bind()
ctx = tortoise_mixin._ctx
assert ctx is not None
engine = platform.registry.require("scopa")
session = await self._finished_session(2)
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
history = await client.get("/api/me/matches")
self.assertEqual(200, history.status_code)
results = history.json()["results"]
self.assertEqual(1, len(results))
self.assertEqual("scopa", results[0]["game_type"])
self.assertIn("team_a_score", results[0]["result"])
self.assertIn("your_elo_delta", results[0])
self.assertEqual(2, len(results[0]["players"]))
if __name__ == "__main__":
unittest.main()