Multiplayer scopone scientifico backend on the kaya framework:
- OIDC login (kaya-oidc), session-backed WebSocket auth
- Pure rules engine (forced captures, scopa, primiera scoring) with
full-match simulation tests
- Live game state in Redis (JSON + TTL, join codes, per-game locks,
pub/sub state push); in-memory fallback for tests
- WebSocket /ws/games/{id} for real-time play; REST lobby endpoints
(create/join/snapshot) with hidden-hand views
- Finished matches persisted to Postgres (Tortoise + aerich) for match
history and leaderboard endpoints
- Docker Compose stack: postgres, redis, mock-oauth2-server, db-migrate, app
- 45 tests passing; mypy clean
96 lines
3.0 KiB
Python
96 lines
3.0 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 scopa.game import engine
|
|
from scopa.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_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
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|