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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user