Split backend into tavolo-platform, tavolo-scopone and tavolo-app packages

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).
This commit is contained in:
2026-09-19 07:28:58 +00:00
parent b2b514ab91
commit 5a73601ddf
72 changed files with 4490 additions and 2102 deletions
+23 -7
View File
@@ -1,4 +1,4 @@
"""Game lobby route tests via kaya's ASGI transport."""
"""Game lobby route tests via kaya's ASGI transport (scopone game)."""
from __future__ import annotations
import unittest
@@ -23,7 +23,9 @@ class GamesRouteTest(unittest.TestCase):
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={"target_score": 16})
created = await client.post(
"/api/games", json={"options": {"target_score": 16}}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("lobby", body["phase"])
@@ -80,12 +82,16 @@ class GamesRouteTest(unittest.TestCase):
self.assertTrue(default.json()["napola"])
with oidc_user("alice"):
disabled = await client.post("/api/games", json={"napola": False})
disabled = await client.post(
"/api/games", json={"options": {"napola": False}}
)
self.assertEqual(201, disabled.status_code)
self.assertFalse(disabled.json()["napola"])
with oidc_user("alice"):
invalid = await client.post("/api/games", json={"napola": "yes"})
invalid = await client.post(
"/api/games", json={"options": {"napola": "yes"}}
)
self.assertEqual(400, invalid.status_code)
@async_test
@@ -120,9 +126,15 @@ class GamesRouteTest(unittest.TestCase):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
zero = await client.post("/api/games", json={"target_score": 0})
text = await client.post("/api/games", json={"target_score": "eleven"})
huge = await client.post("/api/games", json={"target_score": 1000})
zero = await client.post(
"/api/games", json={"options": {"target_score": 0}}
)
text = await client.post(
"/api/games", json={"options": {"target_score": "eleven"}}
)
huge = await client.post(
"/api/games", json={"options": {"target_score": 1000}}
)
self.assertEqual(400, zero.status_code)
self.assertEqual(400, text.status_code)
self.assertEqual(400, huge.status_code)
@@ -147,6 +159,10 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(["scopone_scientifico"], [g["id"] for g in results])
self.assertEqual("Scopone scientifico", results[0]["name"])
self.assertTrue(results[0]["description"])
self.assertEqual(4, results[0]["min_players"])
self.assertEqual(4, results[0]["max_players"])
self.assertIn("target_score", results[0]["options_schema"]["properties"])
self.assertIn("napola", results[0]["options_schema"]["properties"])
@async_test
async def test_create_defaults_game_type(self) -> None: