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:
@@ -0,0 +1,187 @@
|
||||
"""Deadline-scheduler tests, driven by the DummyEngine.
|
||||
|
||||
Timeouts must be driven by the persisted deadlines and the shared queue,
|
||||
not by connected sockets: these tests seed sessions, enqueue their
|
||||
deadlines and let the background consumer fire them without a single
|
||||
websocket. The engine owns the meaning of each deadline; the scheduler
|
||||
owns enqueueing, delivery and removal.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.deadlines import encode
|
||||
from helpers import DummyEngine, async_test, make_platform, use_db
|
||||
|
||||
|
||||
def _started_session(
|
||||
game_id: str = "dl-1",
|
||||
code: str = "DL0001",
|
||||
target: int = 3,
|
||||
deadline_in_seconds: Optional[float] = None,
|
||||
) -> GameSession:
|
||||
engine = DummyEngine()
|
||||
session = GameSession(
|
||||
id=game_id,
|
||||
game_type=engine.id,
|
||||
join_code=code,
|
||||
creator_sub="alice",
|
||||
players=[Seat(user_sub="alice", display_name="alice", team="A")],
|
||||
)
|
||||
options: Dict[str, Any] = {"target": target}
|
||||
if deadline_in_seconds is not None:
|
||||
options["deadline_in_seconds"] = deadline_in_seconds
|
||||
engine.create(session, options)
|
||||
engine.join(session, "bob", "bob")
|
||||
return session
|
||||
|
||||
|
||||
async def _wait_for(predicate, timeout: float = 5.0):
|
||||
"""Poll the store until ``predicate`` returns a truthy value."""
|
||||
deadline = asyncio.get_running_loop().time() + timeout
|
||||
while asyncio.get_running_loop().time() < deadline:
|
||||
result = await predicate()
|
||||
if result:
|
||||
return result
|
||||
await asyncio.sleep(0.05)
|
||||
return None
|
||||
|
||||
|
||||
class ConnectionIndependenceTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_tick_fires_with_no_connections(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
store = platform.game_store
|
||||
scheduler = platform.scheduler
|
||||
session = _started_session(deadline_in_seconds=0.05)
|
||||
await store.save(session)
|
||||
await scheduler.sync_deadline(session)
|
||||
|
||||
# Nobody ever connects: the consumer must still fire the tick,
|
||||
# which plays for the first player.
|
||||
result = await _wait_for(
|
||||
lambda: _plays_is(store, session.id, 1),
|
||||
)
|
||||
self.assertIsNotNone(result, "deadline never fired")
|
||||
|
||||
@async_test
|
||||
async def test_no_deadline_nothing_enqueued(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
session = _started_session() # no deadline_in_seconds option
|
||||
await platform.game_store.save(session)
|
||||
await platform.scheduler.sync_deadline(session)
|
||||
self.assertIsNone(await platform.game_store.next_deadline())
|
||||
|
||||
|
||||
async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
|
||||
session = await store.load(game_id)
|
||||
if session is not None and len(session.state["plays"]) == count:
|
||||
return session
|
||||
return None
|
||||
|
||||
|
||||
class ProcessDueTest(unittest.TestCase):
|
||||
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
|
||||
|
||||
@async_test
|
||||
async def test_processing_twice_is_a_no_op(self) -> None:
|
||||
# Simulates a worker dying after firing but before removing the
|
||||
# entry: another worker re-delivers the same entry. The engine's
|
||||
# token has moved on, so the second delivery is stale.
|
||||
_, platform, _ = make_platform()
|
||||
store = platform.game_store
|
||||
scheduler = platform.scheduler
|
||||
session = _started_session(deadline_in_seconds=3600)
|
||||
await store.save(session)
|
||||
deadline = DummyEngine().next_deadline(session)
|
||||
assert deadline is not None
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": deadline.kind,
|
||||
"token": deadline.token,
|
||||
})
|
||||
|
||||
await scheduler.process_due(member)
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await store.load(session.id)
|
||||
assert result is not None
|
||||
# Fired exactly once: one play, not two.
|
||||
self.assertEqual(["alice"], result.state["plays"])
|
||||
|
||||
@async_test
|
||||
async def test_stale_entry_is_discarded(self) -> None:
|
||||
# A tick enqueued before a play landed in time: the token has
|
||||
# moved, so the entry must not fire.
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
session = _started_session(deadline_in_seconds=3600)
|
||||
session.state["plays"].append("alice") # a play landed in time
|
||||
await store.save(session)
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": "tick",
|
||||
"token": "tick:0", # not the live token ("tick:1")
|
||||
})
|
||||
await store.add_deadline(member, due_at=0.0)
|
||||
|
||||
await scheduler.process_due(member)
|
||||
|
||||
result = await store.load(session.id)
|
||||
assert result is not None
|
||||
self.assertEqual(["alice"], result.state["plays"])
|
||||
# The entry was removed after processing.
|
||||
self.assertNotIn(member, await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_entry_for_expired_game_is_dropped(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
member = encode({
|
||||
"game_id": "dl-gone",
|
||||
"kind": "tick",
|
||||
"token": "tick:0",
|
||||
})
|
||||
await store.add_deadline(member, due_at=0.0)
|
||||
await scheduler.process_due(member)
|
||||
self.assertNotIn(member, await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_malformed_entry_is_dropped(self) -> None:
|
||||
_, platform, _ = make_platform()
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
await store.add_deadline("not json", due_at=0.0)
|
||||
await scheduler.process_due("not json")
|
||||
self.assertNotIn("not json", await store.due_deadlines(float("inf")))
|
||||
|
||||
@async_test
|
||||
async def test_finished_match_is_persisted_on_tick(self) -> None:
|
||||
# A tick that completes the match writes the result to Postgres.
|
||||
from tavolo.platform.models import Match
|
||||
|
||||
_, platform, tortoise_mixin = make_platform()
|
||||
ctx = await use_db(tortoise_mixin)
|
||||
scheduler = platform.scheduler
|
||||
store = platform.game_store
|
||||
session = _started_session(target=1, deadline_in_seconds=3600)
|
||||
await store.save(session)
|
||||
deadline = DummyEngine().next_deadline(session)
|
||||
assert deadline is not None
|
||||
member = encode({
|
||||
"game_id": session.id,
|
||||
"kind": deadline.kind,
|
||||
"token": deadline.token,
|
||||
})
|
||||
with ctx:
|
||||
await scheduler.process_due(member)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user