Add preliminary support for multiple card games
CI / Build and push docker image (push) Successful in 3m5s
CI / Build and push docker image (push) Successful in 3m5s
A game-type registry (server/src/tavolo/games.py) is now the single source of truth for the games the platform can host; only scopone scientifico is registered so far. GET /api/game-types exposes it for the lobby's new game dropdown, and POST /api/games accepts a validated game_type (default scopone_scientifico) which is carried on the live GameState and onto each finished Match row (new indexed column, migration 1_20260916235833_update), so statistics can be scoped per game: /api/me/matches and /api/leaderboard take an optional game_type filter and every serialized match includes its game_type. Game states serialized before this change still load with the default game type.
This commit is contained in:
@@ -119,5 +119,53 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
|
||||
class GameTypesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_lists_available_game_types(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/game-types")
|
||||
self.assertEqual(200, response.status_code)
|
||||
results = response.json()["results"]
|
||||
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
|
||||
self.assertEqual("Scopone scientifico", results[0]["name"])
|
||||
self.assertTrue(results[0]["description"])
|
||||
|
||||
@async_test
|
||||
async def test_create_defaults_game_type(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={})
|
||||
self.assertEqual(201, created.status_code)
|
||||
self.assertEqual("scopone_scientifico", created.json()["game_type"])
|
||||
|
||||
@async_test
|
||||
async def test_create_with_explicit_game_type(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": "scopone_scientifico"}
|
||||
)
|
||||
self.assertEqual(201, created.status_code)
|
||||
body = created.json()
|
||||
self.assertEqual("scopone_scientifico", body["game_type"])
|
||||
|
||||
with oidc_user("alice"):
|
||||
snapshot = await client.get(f"/api/games/{body['id']}")
|
||||
self.assertEqual("scopone_scientifico", snapshot.json()["game_type"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_unknown_game_type(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
unknown = await client.post("/api/games", json={"game_type": "briscola"})
|
||||
non_string = await client.post("/api/games", json={"game_type": 42})
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
self.assertEqual(400, non_string.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -66,11 +66,13 @@ class SaveMatchResultTest(unittest.TestCase):
|
||||
assert match is not None
|
||||
self.assertEqual(state.scores[0], match.team_a_score)
|
||||
self.assertEqual("A", match.winner_team)
|
||||
# The game type travels from the live state onto the row.
|
||||
self.assertEqual("scopone_scientifico", match.game_type)
|
||||
winners = await MatchPlayer.filter(won=True)
|
||||
self.assertEqual({"alice", "carol"}, {p.user_sub for p in winners})
|
||||
|
||||
|
||||
async def _seed_two_matches() -> None:
|
||||
async def _seed_two_matches(game_types: tuple = ("scopone_scientifico", "scopone_scientifico")) -> None:
|
||||
ctx = await _use_app_db()
|
||||
with ctx:
|
||||
for index, (a_score, b_score, winner, finished) in enumerate(
|
||||
@@ -81,6 +83,7 @@ async def _seed_two_matches() -> None:
|
||||
):
|
||||
match = await Match.create(
|
||||
id=uuid.uuid4(),
|
||||
game_type=game_types[index],
|
||||
team_a_score=a_score,
|
||||
team_b_score=b_score,
|
||||
winner_team=winner,
|
||||
@@ -165,5 +168,45 @@ class StatsRouteTest(unittest.TestCase):
|
||||
self.assertEqual("alice", response.json()["results"][0]["user_sub"])
|
||||
|
||||
|
||||
class GameTypeFilterTest(unittest.TestCase):
|
||||
"""Stats endpoints scope results by the match's game type."""
|
||||
|
||||
@async_test
|
||||
async def test_my_matches_filter_by_game_type(self) -> None:
|
||||
# The second seed names a game the registry does not know; rows are
|
||||
# written directly, so this only exercises the SQL filter.
|
||||
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
all_matches = await client.get("/api/me/matches")
|
||||
scoped = await client.get("/api/me/matches?game_type=scopone_scientifico")
|
||||
unknown = await client.get("/api/me/matches?game_type=briscola")
|
||||
self.assertEqual(2, len(all_matches.json()["results"]))
|
||||
self.assertEqual(
|
||||
{"scopone_scientifico", "other_game"},
|
||||
{m["game_type"] for m in all_matches.json()["results"]},
|
||||
)
|
||||
scoped_results = scoped.json()["results"]
|
||||
self.assertEqual(1, len(scoped_results))
|
||||
self.assertEqual("scopone_scientifico", scoped_results[0]["game_type"])
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_leaderboard_filter_by_game_type(self) -> None:
|
||||
await _seed_two_matches(game_types=("scopone_scientifico", "other_game"))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
scoped = await client.get("/api/leaderboard?game_type=scopone_scientifico")
|
||||
unknown = await client.get("/api/leaderboard?game_type=briscola")
|
||||
self.assertEqual(200, scoped.status_code)
|
||||
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
|
||||
# Only the first match counts: one match per player, team A won.
|
||||
self.assertEqual(1, by_sub["alice"]["matches"])
|
||||
self.assertEqual(1, by_sub["alice"]["wins"])
|
||||
self.assertEqual(0, by_sub["bob"]["wins"])
|
||||
self.assertEqual(400, unknown.status_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -25,6 +25,24 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
self.assertEqual(16, loaded.target_score)
|
||||
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players])
|
||||
|
||||
@async_test
|
||||
async def test_game_type_roundtrip_and_default(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
state = engine.create_game(
|
||||
"g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico"
|
||||
)
|
||||
await store.save(state)
|
||||
loaded = await store.load("g1b")
|
||||
assert loaded is not None
|
||||
self.assertEqual("scopone_scientifico", loaded.game_type)
|
||||
|
||||
# States serialized before game types existed load with the default.
|
||||
legacy = state.to_json()
|
||||
del legacy["game_type"]
|
||||
from tavolo.game.state import GameState
|
||||
|
||||
self.assertEqual("scopone_scientifico", GameState.from_json(legacy).game_type)
|
||||
|
||||
@async_test
|
||||
async def test_load_missing_returns_none(self) -> None:
|
||||
store = InMemoryGameStore()
|
||||
|
||||
Reference in New Issue
Block a user