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:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user