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
+58 -20
View File
@@ -8,14 +8,44 @@ from httpx import ASGITransport, AsyncClient
from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from tavolo.app import app, game_store
from tavolo.game import engine
from tavolo.game.state import Card, GameState, PlayerState
from tavolo.app import app, game_store, platform
from tavolo.platform import GameSession, Seat
from tavolo.scopone.state import Card, PlayerState, ScoponeState
from tests.helpers import async_test, make_user, oidc_user, ws_users
PLAYERS = ("alice", "bob", "carol", "dave")
def _started_session(
engine,
game_id: str,
code: str,
hand_ack_timeout: int = 30,
turn_timeout: int = 30,
) -> GameSession:
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine.create(
session,
{
"target_score": 11,
"napola": True,
},
)
# Apply per-test timeouts (the plugin normally copies them from its
# own constructor arguments).
session.state.hand_ack_timeout = hand_ack_timeout
session.state.turn_timeout = turn_timeout
for name in PLAYERS[1:]:
engine.join(session, name, name.capitalize())
return session
class WebSocketTest(unittest.TestCase):
async def _started_game(self, client: AsyncClient) -> dict:
"""Create a game and seat four players; return the playing state."""
@@ -132,24 +162,34 @@ class WebSocketTest(unittest.TestCase):
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
"""Seed a game where a single play ends the hand: p0 holds the only
card left and can capture the only table card."""
state = GameState(
engine = platform.registry.require("scopone_scientifico")
session = GameSession(
id="hand-end-1",
game_type=engine.id,
join_code="HEND01",
creator_sub="alice",
players=[
Seat(user_sub="alice", display_name="Alice", team="A"),
Seat(user_sub="bob", display_name="Bob", team="B"),
Seat(user_sub="carol", display_name="Carol", team="A"),
Seat(user_sub="dave", display_name="Dave", team="B"),
],
)
session.state = ScoponeState(
target_score=11,
phase="playing",
turn=0,
table=[Card.parse("02C")],
players=[
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub="carol", name="Carol", seat=2),
PlayerState(sub="dave", name="Dave", seat=3),
],
hand_ack_timeout=hand_ack_timeout,
)
state.players = [
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
PlayerState(sub="bob", name="Bob", seat=1),
PlayerState(sub="carol", name="Carol", seat=2),
PlayerState(sub="dave", name="Dave", seat=3),
]
state.hand_ack_timeout = hand_ack_timeout
await game_store.save(state)
return state.id
await game_store.save(session)
return session.id
class HandEndWebSocketTest(unittest.TestCase):
@@ -240,18 +280,16 @@ class HandEndWebSocketTest(unittest.TestCase):
class TurnTimeoutWebSocketTest(unittest.TestCase):
@async_test
async def test_turn_timeout_auto_plays_a_card(self) -> None:
state = engine.create_game(
"turn-timeout-1", "TT0001", "alice", "Alice",
target_score=11, turn_timeout=1,
engine = platform.registry.require("scopone_scientifico")
session = _started_session(
engine, "turn-timeout-1", "TT0001", turn_timeout=1
)
for name in PLAYERS[1:]:
engine.join_game(state, name, name.capitalize())
await game_store.save(state)
await game_store.save(session)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{state.id}", ws_client) as ws:
async with aconnect_ws(f"/ws/games/{session.id}", ws_client) as ws:
first = await ws.receive_json()
# Bob (seat 1) is first to act and never connects.
self.assertEqual(1, first["game"]["turn"])