CI / Build and push docker image (push) Successful in 1m19s
Store each player's leaderboard totals (wins, accumulated points and the latest display name) on player_rating alongside the Elo rating, updated in the same transaction as the match, so the board is a single indexed table query instead of aggregating every participation on each request. GET /api/leaderboard is now page-number paginated (page/limit) and returns the page, page count and total, plus the caller's own rank and page when authenticated; focus=me returns the caller's page directly. The migration adds the columns, backfills them from the match history (inserting rows for players from pre-Elo matches at the initial rating) and replaces the index. backfill_elo rebuilds the aggregates too. The web page opens on the page containing the signed-in player, marks their row and adds a numbered footer pager.
465 lines
19 KiB
Python
465 lines
19 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.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
|
|
from tavolo.briscola.state import Card
|
|
from tavolo.platform import GameSession, Seat
|
|
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
|
|
|
|
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"]))
|
|
|
|
with oidc_user("alice"):
|
|
board = await client.get("/api/leaderboard")
|
|
self.assertEqual(200, board.status_code)
|
|
body = board.json()
|
|
by_sub = {r["user_sub"]: r for r in body["results"]}
|
|
self.assertEqual(4, len(by_sub))
|
|
self.assertTrue(all(r["matches"] == 1 for r in by_sub.values()))
|
|
self.assertEqual(1, body["page"])
|
|
self.assertEqual(1, body["pages"])
|
|
self.assertEqual(4, body["total"])
|
|
# The caller's own rank rides along on the response.
|
|
self.assertIsNotNone(body["your_rank"])
|
|
self.assertEqual(1, body["your_page"])
|
|
|
|
paged = await client.get("/api/leaderboard?limit=1&page=2")
|
|
self.assertEqual(200, paged.status_code)
|
|
self.assertEqual(4, paged.json()["pages"])
|
|
self.assertEqual(2, paged.json()["page"])
|
|
self.assertEqual(1, len(paged.json()["results"]))
|
|
|
|
|
|
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"]))
|
|
|
|
|
|
async def _finished_chiamata_session() -> GameSession:
|
|
engine = platform.registry.require("briscola_chiamata")
|
|
session = GameSession(
|
|
id="stats-chiamata-1",
|
|
game_type=engine.id,
|
|
join_code="BC0001",
|
|
creator_sub="alice",
|
|
players=[
|
|
Seat(user_sub=name, display_name=name.capitalize())
|
|
for name in ("alice", "bob", "carol", "dave", "erin")
|
|
],
|
|
)
|
|
engine.create(session, {})
|
|
for name in ("bob", "carol", "dave", "erin"):
|
|
engine.join(session, name, name.capitalize())
|
|
moves = 0
|
|
while not engine.is_finished(session) and moves < 200000:
|
|
state = session.state
|
|
if state.phase == PHASE_AUCTION:
|
|
player = next(p for p in state.players if p.seat == state.turn)
|
|
try:
|
|
engine.handle_action(session, player.sub, "pass", {})
|
|
except IllegalMove:
|
|
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
|
elif state.phase == PHASE_CALLING:
|
|
assert state.auction is not None and state.auction.current is not None
|
|
caller = next(p for p in state.players if p.seat == state.caller)
|
|
card = Card(rank=state.auction.current.rank, suit="D")
|
|
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
|
else:
|
|
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 ChiamataStatsTest(unittest.TestCase):
|
|
@async_test
|
|
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
|
|
await tortoise_mixin._bind()
|
|
ctx = tortoise_mixin._ctx
|
|
assert ctx is not None
|
|
engine = platform.registry.require("briscola_chiamata")
|
|
session = await _finished_chiamata_session()
|
|
with ctx:
|
|
await save_match_result(session, engine)
|
|
self.assertEqual(1, await Match.all().count())
|
|
self.assertEqual(5, await MatchPlayer.all().count())
|
|
|
|
match = await Match.all().first()
|
|
assert match is not None
|
|
self.assertEqual("briscola_chiamata", match.game_type)
|
|
summary = match.result
|
|
for key in (
|
|
"team_a_score",
|
|
"team_b_score",
|
|
"winner_team",
|
|
"declared_target",
|
|
"called_card",
|
|
"caller",
|
|
"solo",
|
|
"deals_played",
|
|
"deal_scores",
|
|
):
|
|
self.assertIn(key, summary)
|
|
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
|
|
|
players = await MatchPlayer.all()
|
|
roles = {p.team for p in players}
|
|
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
|
|
# Roles match the sides: exactly one caller, the rest defenders
|
|
# plus at most one partner.
|
|
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
|
|
winner_team = summary["winner_team"]
|
|
for row in players:
|
|
on_caller_side = row.team in ("Chiamante", "Socio")
|
|
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
|
|
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
|
|
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
|
self.assertEqual(5, len(ratings))
|
|
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},
|
|
)
|
|
|
|
|
|
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()
|