CI / Build and push docker image (push) Successful in 1m45s
New tavolo-briscola distribution implementing briscola on the platform: - pure rules (state.py, engine.py): 3-card deals, a face-up trump that is the last card drawn, trump-first trick resolution (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2), 120 points per deal, 60-60 patta re-deals, a configurable target score (default 61), deal-end acknowledgements and turn auto-play; - BriscolaEngine (briscola, 2 players) and BriscolaQuattroEngine (briscola_a_quattro, 4 players in fixed partnerships) adapters; - registered in tavolo-app, packaged in the Docker image, documented. Platform fixes surfaced by a two-player lobby: - the websocket state and snapshot payloads now carry seats_open (they were only in the REST lobby payload), so lobbies with player counts other than four render correctly; seats_open is clamped at zero; - the web client logs unparseable server messages instead of silently dropping them. Web client: briscola table for both variants (stock, face-up trump, trick, last-trick banner), deal summary modal and game-over overlay. Tests: tavolo-briscola rules/adapter suites, app integration tests (lobby, websocket play, turn timeout, deal-end acks, persistence), a platform seats_open test and frontend model parsing tests.
227 lines
9.1 KiB
Python
227 lines
9.1 KiB
Python
"""Scopone result-persistence tests: engine result to Postgres to API.
|
|
|
|
The platform suite covers the generic machinery against a toy game;
|
|
these tests pin the scopone-specific shape: the match ``result`` summary,
|
|
per-player scores/teams, Elo deltas and what the history and leaderboard
|
|
endpoints expose for a finished scopone match.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from tavolo.app import app, platform, tortoise_mixin
|
|
from tavolo.platform import GameSession, Seat
|
|
from tavolo.platform.elo import INITIAL_RATING
|
|
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
|
from tavolo.platform.stats import save_match_result
|
|
from tavolo.scopone import engine as rules
|
|
from tests.helpers import async_test, oidc_user
|
|
|
|
PLAYERS = ("alice", "bob", "carol", "dave")
|
|
|
|
|
|
async def _finished_session(target_score: int = 1) -> GameSession:
|
|
engine = platform.registry.require("scopone_scientifico")
|
|
session = GameSession(
|
|
id="stats-scope-1",
|
|
game_type=engine.id,
|
|
join_code="SS0001",
|
|
creator_sub="alice",
|
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
|
)
|
|
engine.create(session, {"target_score": target_score})
|
|
for name in PLAYERS[1:]:
|
|
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 = 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)
|
|
moves += 1
|
|
assert engine.is_finished(session)
|
|
return session
|
|
|
|
|
|
class ScoponeStatsTest(unittest.TestCase):
|
|
@async_test
|
|
async def test_finished_match_persisted_with_scopone_summary(self) -> None:
|
|
await tortoise_mixin._bind()
|
|
ctx = tortoise_mixin._ctx
|
|
assert ctx is not None
|
|
engine = platform.registry.require("scopone_scientifico")
|
|
session = await _finished_session()
|
|
with ctx:
|
|
await save_match_result(session, engine)
|
|
self.assertEqual(1, await Match.all().count())
|
|
self.assertEqual(4, await MatchPlayer.all().count())
|
|
|
|
match = await Match.all().first()
|
|
assert match is not None
|
|
self.assertEqual("scopone_scientifico", match.game_type)
|
|
summary = match.result
|
|
self.assertEqual(1, summary["target_score"])
|
|
self.assertIn("team_a_score", summary)
|
|
self.assertIn("team_b_score", summary)
|
|
self.assertIn("winner_team", summary)
|
|
self.assertIn("hands_played", summary)
|
|
self.assertIn("hand_scores", summary)
|
|
|
|
players = {p.user_sub: p for p in await MatchPlayer.all()}
|
|
winner_team = summary["winner_team"]
|
|
for sub, row in players.items():
|
|
if row.won:
|
|
self.assertEqual(winner_team, row.team)
|
|
else:
|
|
self.assertNotEqual(winner_team, row.team)
|
|
self.assertEqual(
|
|
summary["team_a_score"] if row.team == "A"
|
|
else summary["team_b_score"],
|
|
row.score,
|
|
)
|
|
# Winners share a team, losers the other.
|
|
winners = {sub for sub, row in players.items() if row.won}
|
|
self.assertEqual(2, len(winners))
|
|
|
|
@async_test
|
|
async def test_finished_match_updates_elo(self) -> None:
|
|
await tortoise_mixin._bind()
|
|
ctx = tortoise_mixin._ctx
|
|
assert ctx is not None
|
|
engine = platform.registry.require("scopone_scientifico")
|
|
session = await _finished_session()
|
|
with ctx:
|
|
await save_match_result(session, engine)
|
|
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
|
self.assertEqual(4, len(ratings))
|
|
self.assertEqual(
|
|
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
|
|
)
|
|
|
|
@async_test
|
|
async def test_history_and_leaderboard_expose_scopone_result(self) -> None:
|
|
await tortoise_mixin._bind()
|
|
ctx = tortoise_mixin._ctx
|
|
assert ctx is not None
|
|
engine = platform.registry.require("scopone_scientifico")
|
|
session = await _finished_session()
|
|
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("scopone_scientifico", results[0]["game_type"])
|
|
self.assertIn("team_a_score", results[0]["result"])
|
|
self.assertIn("your_elo_delta", results[0])
|
|
self.assertEqual(4, len(results[0]["players"]))
|
|
|
|
board = await client.get("/api/leaderboard")
|
|
self.assertEqual(200, board.status_code)
|
|
by_sub = {r["user_sub"]: r for r in board.json()["results"]}
|
|
self.assertEqual(4, len(by_sub))
|
|
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
|
|
|
|
|
|
async def _finished_briscola_session() -> GameSession:
|
|
engine = platform.registry.require("briscola")
|
|
session = GameSession(
|
|
id="stats-briscola-1",
|
|
game_type=engine.id,
|
|
join_code="BS0001",
|
|
creator_sub="alice",
|
|
players=[Seat(user_sub="alice", display_name="Alice")],
|
|
)
|
|
engine.create(session, {})
|
|
engine.join(session, "bob", "Bob")
|
|
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)
|
|
engine.handle_action(
|
|
session, player.sub, "play", {"card": player.hand[0].code}
|
|
)
|
|
moves += 1
|
|
assert engine.is_finished(session)
|
|
return session
|
|
|
|
|
|
class BriscolaStatsTest(unittest.TestCase):
|
|
@async_test
|
|
async def test_finished_briscola_match_persisted_with_summary(self) -> None:
|
|
await tortoise_mixin._bind()
|
|
ctx = tortoise_mixin._ctx
|
|
assert ctx is not None
|
|
engine = platform.registry.require("briscola")
|
|
session = await _finished_briscola_session()
|
|
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("briscola", match.game_type)
|
|
summary = match.result
|
|
self.assertIn("team_a_score", summary)
|
|
self.assertIn("team_b_score", summary)
|
|
self.assertIn("winner_team", summary)
|
|
self.assertIn("target_score", summary)
|
|
self.assertIn("deals_played", summary)
|
|
self.assertIn("deal_scores", summary)
|
|
|
|
winner_team = summary["winner_team"]
|
|
players = {p.user_sub: p for p in await MatchPlayer.all()}
|
|
for row in players.values():
|
|
if row.won:
|
|
self.assertEqual(winner_team, row.team)
|
|
else:
|
|
self.assertNotEqual(winner_team, row.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(2, len(ratings))
|
|
self.assertEqual(
|
|
{INITIAL_RATING + 16, INITIAL_RATING - 16}, set(ratings.values())
|
|
)
|
|
|
|
@async_test
|
|
async def test_history_exposes_briscola_result(self) -> None:
|
|
await tortoise_mixin._bind()
|
|
ctx = tortoise_mixin._ctx
|
|
assert ctx is not None
|
|
engine = platform.registry.require("briscola")
|
|
session = await _finished_briscola_session()
|
|
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("briscola", results[0]["game_type"])
|
|
self.assertIn("team_a_score", results[0]["result"])
|
|
self.assertEqual(2, len(results[0]["players"]))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|