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,142 @@
|
||||
"""In-memory game store behaviour (the Redis store shares this interface)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import unittest
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.registry import GameRegistry
|
||||
from tavolo.platform.store import InMemoryGameStore
|
||||
from helpers import DummyEngine, async_test
|
||||
|
||||
|
||||
def _registry() -> GameRegistry:
|
||||
return GameRegistry([DummyEngine()])
|
||||
|
||||
|
||||
def _session(game_id: str = "g1", code: str = "CODE01") -> 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")],
|
||||
)
|
||||
engine.create(session, {"target": 5})
|
||||
return session
|
||||
|
||||
|
||||
class InMemoryGameStoreTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_save_load_roundtrip(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
session = _session()
|
||||
await store.save(session)
|
||||
|
||||
loaded = await store.load("g1")
|
||||
self.assertIsNotNone(loaded)
|
||||
assert loaded is not None
|
||||
self.assertEqual("CODE01", loaded.join_code)
|
||||
self.assertEqual("dummy", loaded.game_type)
|
||||
self.assertEqual(5, loaded.state["target"])
|
||||
self.assertEqual(["alice"], [p.user_sub for p in loaded.players])
|
||||
# The loaded state is a deserialized copy, not the same object.
|
||||
self.assertIsNot(loaded.state, session.state)
|
||||
|
||||
@async_test
|
||||
async def test_unknown_game_type_rejected(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
session = _session()
|
||||
session.game_type = "nope"
|
||||
with self.assertRaises(Exception):
|
||||
await store.save(session)
|
||||
|
||||
@async_test
|
||||
async def test_load_missing_returns_none(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
self.assertIsNone(await store.load("nope"))
|
||||
self.assertIsNone(await store.find_by_code("NOPE01"))
|
||||
|
||||
@async_test
|
||||
async def test_find_by_code(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
await store.save(_session())
|
||||
found = await store.find_by_code("code01") # case-insensitive
|
||||
self.assertIsNotNone(found)
|
||||
assert found is not None
|
||||
self.assertEqual("g1", found.id)
|
||||
|
||||
@async_test
|
||||
async def test_load_returns_a_copy(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
await store.save(_session())
|
||||
first = await store.load("g1")
|
||||
assert first is not None
|
||||
first.state["target"] = 999
|
||||
second = await store.load("g1")
|
||||
assert second is not None
|
||||
self.assertEqual(5, second.state["target"])
|
||||
|
||||
@async_test
|
||||
async def test_publish_reaches_subscriber(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
await store.save(_session())
|
||||
|
||||
received = []
|
||||
|
||||
async with store.subscribe("g1") as events:
|
||||
await store.publish("g1")
|
||||
async for _ in events:
|
||||
received.append(True)
|
||||
break
|
||||
|
||||
self.assertEqual([True], received)
|
||||
|
||||
@async_test
|
||||
async def test_lock_serializes_concurrent_mutations(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
order = []
|
||||
|
||||
async def holder() -> None:
|
||||
async with store.lock("g5"):
|
||||
order.append("holder-enter")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append("holder-exit")
|
||||
|
||||
async def contender() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
async with store.lock("g5"):
|
||||
order.append("contender")
|
||||
|
||||
await asyncio.gather(holder(), contender())
|
||||
self.assertEqual(
|
||||
["holder-enter", "holder-exit", "contender"], order
|
||||
)
|
||||
|
||||
@async_test
|
||||
async def test_deadline_queue(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
self.assertIsNone(await store.next_deadline())
|
||||
self.assertEqual([], await store.due_deadlines(now=100.0))
|
||||
|
||||
await store.add_deadline("b", due_at=50.0)
|
||||
await store.add_deadline("a", due_at=10.0)
|
||||
await store.add_deadline("c", due_at=200.0)
|
||||
# Re-adding an existing member only updates its due time.
|
||||
await store.add_deadline("b", due_at=60.0)
|
||||
|
||||
self.assertEqual(10.0, await store.next_deadline())
|
||||
self.assertEqual(["a"], await store.due_deadlines(now=10.0))
|
||||
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||
# Due entries come out in due-time order and stay queued until removed.
|
||||
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
|
||||
|
||||
await store.remove_deadline("a")
|
||||
await store.remove_deadline("a") # removing twice is a no-op
|
||||
self.assertEqual(60.0, await store.next_deadline())
|
||||
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user