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).
188 lines
6.7 KiB
Python
188 lines
6.7 KiB
Python
"""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()
|