Move the game-independent machinery (lobby, live-game store, websocket, deadline scheduler, match history, leaderboards) into a new tavolo-platform distribution behind a GameEngine contract, the scopone scientifico rules plus a platform adapter into tavolo-scopone, and keep only the composition root in tavolo-app. The three distributions share the tavolo namespace (PEP 420, kaya-style monorepo). Match history becomes fully generic: Match carries the engine's result JSON and MatchPlayer points/details instead of scopone-shaped team columns (migration 3 backfills existing rows). Lobby creation takes an opaque per-game options object and websocket actions dispatch to the session's engine. Tests: platform suite runs against a DummyEngine toy game, scopone keeps the rules tests plus new adapter tests, server/tests covers the wired stack end to end (194 tests, was 143).
141 lines
5.6 KiB
Python
141 lines
5.6 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()))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|