Turn auto-play and hand-end auto-continue were process-local asyncio tasks armed only by client connects and state broadcasts: with no sockets connected the next turn's timer was never armed, a hand-end timer died with its worker, and neither survived a pod restart. Deadlines are now driven by the absolute timestamps persisted on the game state and enqueued in a shared Redis sorted set. Every worker runs a consumer that fires due entries under the per-game lock after revalidating them against the live state, so timeouts no longer depend on any player being connected and survive the death of any worker. Delivery is at-least-once: entries are removed only after processing, and revalidation makes duplicate deliveries no-ops. Queue entries carry the deadline as integer epoch milliseconds, which also serves as the revalidation token, and the score derives from the same value.
137 lines
4.8 KiB
Python
137 lines
4.8 KiB
Python
"""In-memory game store behaviour (the Redis store shares this interface)."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import unittest
|
|
|
|
from pwo import async_test
|
|
|
|
from tavolo.game import engine
|
|
from tavolo.store import InMemoryGameStore
|
|
|
|
|
|
class InMemoryGameStoreTest(unittest.TestCase):
|
|
@async_test
|
|
async def test_save_load_roundtrip(self) -> None:
|
|
store = InMemoryGameStore()
|
|
state = engine.create_game("g1", "CODE01", "alice", "alice", target_score=16)
|
|
engine.join_game(state, "bob", "bob")
|
|
await store.save(state)
|
|
|
|
loaded = await store.load("g1")
|
|
self.assertIsNotNone(loaded)
|
|
assert loaded is not None
|
|
self.assertEqual("CODE01", loaded.join_code)
|
|
self.assertEqual(16, loaded.target_score)
|
|
self.assertEqual(["alice", "bob"], [p.sub for p in loaded.players])
|
|
|
|
@async_test
|
|
async def test_game_type_roundtrip_and_default(self) -> None:
|
|
store = InMemoryGameStore()
|
|
state = engine.create_game(
|
|
"g1b", "CODE1B", "alice", "alice", game_type="scopone_scientifico"
|
|
)
|
|
await store.save(state)
|
|
loaded = await store.load("g1b")
|
|
assert loaded is not None
|
|
self.assertEqual("scopone_scientifico", loaded.game_type)
|
|
|
|
# States serialized before game types existed load with the default.
|
|
legacy = state.to_json()
|
|
del legacy["game_type"]
|
|
from tavolo.game.state import GameState
|
|
|
|
self.assertEqual("scopone_scientifico", GameState.from_json(legacy).game_type)
|
|
|
|
@async_test
|
|
async def test_load_missing_returns_none(self) -> None:
|
|
store = InMemoryGameStore()
|
|
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()
|
|
state = engine.create_game("g2", "CODE02", "alice", "alice")
|
|
await store.save(state)
|
|
found = await store.find_by_code("code02") # case-insensitive
|
|
self.assertIsNotNone(found)
|
|
assert found is not None
|
|
self.assertEqual("g2", found.id)
|
|
|
|
@async_test
|
|
async def test_load_returns_a_copy(self) -> None:
|
|
store = InMemoryGameStore()
|
|
state = engine.create_game("g3", "CODE03", "alice", "alice")
|
|
await store.save(state)
|
|
first = await store.load("g3")
|
|
assert first is not None
|
|
first.phase = "tampered"
|
|
second = await store.load("g3")
|
|
assert second is not None
|
|
self.assertEqual("lobby", second.phase)
|
|
|
|
@async_test
|
|
async def test_publish_reaches_subscriber(self) -> None:
|
|
store = InMemoryGameStore()
|
|
state = engine.create_game("g4", "CODE04", "alice", "alice")
|
|
await store.save(state)
|
|
|
|
received = []
|
|
|
|
async with store.subscribe("g4") as events:
|
|
await store.publish("g4")
|
|
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()
|
|
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()
|
|
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()
|