Rename the app from scopa to tavolo
CI / Build and push docker image (push) Successful in 3m12s

The platform now hosts multiple card games, with scopone scientifico as
the first one. Rename the brand wherever it is not a game rule:

- move the Python package to server/src/tavolo and update imports
- rename the Postgres database/user, OIDC issuer path, client id and
  Redis key prefixes to tavolo (clean break: existing pgdata volumes and
  live games are not migrated)
- rename the Cargo package to tavolo-web and set the page title to Tavolo
- update docs and the Docker image path to woggioni/tavolo

The scopa game term (clearing the table) in the engine, state and web UI
is intentionally left untouched.
This commit is contained in:
2026-09-16 21:46:06 +08:00
parent 89cf0a4c96
commit 6932a3272c
46 changed files with 141 additions and 140 deletions
+187
View File
@@ -0,0 +1,187 @@
"""Persistence for live games.
Game state is small, mutable and short-lived, which makes Redis a natural
fit: the whole match is a single JSON value under ``tavolo:game:<id>`` with
a sliding TTL, and a join-code index maps the short code a player shares to
that id. Completed matches are copied to Postgres (see
:mod:`tavolo.models`); Redis keeps serving the finished state until it
expires.
Two implementations satisfy the same interface:
* :class:`RedisGameStore` — production, used when ``REDIS_URL`` is set.
* :class:`InMemoryGameStore` — tests and ephemeral dev, used otherwise.
Concurrency is handled with a per-game lock so two simultaneous plays
cannot interleave. State changes are broadcast on a per-game pub/sub
channel as a simple "something changed" signal; every open websocket
reloads the state and renders the personalized view. Publishing only a
signal (never the state) means updated state reaches connections on every
worker without leaking hidden hands into the channel.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from abc import ABC, abstractmethod
from typing import AsyncContextManager, AsyncIterator, Dict, Optional, Set
from redis.asyncio import Redis
from .game.state import GameState
GAME_KEY_PREFIX = "tavolo:game:"
CODE_KEY_PREFIX = "tavolo:code:"
CHANNEL_PREFIX = "tavolo:game:"
# Sentinel pushed into in-memory subscriber queues to signal a change.
_BUMP = b"update"
class GameStore(ABC):
"""Abstract persistence + notification layer for live games."""
@abstractmethod
async def load(self, game_id: str) -> Optional[GameState]:
"""Return the live state for ``game_id`` or ``None``."""
@abstractmethod
async def save(self, state: GameState) -> None:
"""Persist ``state``, refreshing its TTL and code index."""
@abstractmethod
async def find_by_code(self, code: str) -> Optional[GameState]:
"""Return the live state for a join ``code`` or ``None``."""
@abstractmethod
def lock(self, game_id: str) -> AsyncContextManager[None]:
"""Async context manager serializing mutations of one game."""
@abstractmethod
def subscribe(self, game_id: str) -> AsyncContextManager[AsyncIterator[None]]:
"""Async context manager yielding an async iterator of change signals."""
@abstractmethod
async def publish(self, game_id: str) -> None:
"""Signal that the state of ``game_id`` changed."""
def _channel(game_id: str) -> str:
return f"{CHANNEL_PREFIX}{game_id}:events"
class RedisGameStore(GameStore):
def __init__(self, redis: Redis, ttl_seconds: int = 86400) -> None:
self._redis = redis
self._ttl = ttl_seconds
def lock(self, game_id: str):
# Lock and state use distinct key names; the lock expires on its own
# if a worker dies mid-mutation.
return self._redis.lock(f"{GAME_KEY_PREFIX}{game_id}:lock",
timeout=10, blocking_timeout=10)
async def load(self, game_id: str) -> Optional[GameState]:
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
if raw is None:
return None
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
return GameState.from_json(json.loads(raw))
async def save(self, state: GameState) -> None:
payload = json.dumps(state.to_json())
async with self._redis.pipeline(transaction=True) as pipe:
pipe.set(f"{GAME_KEY_PREFIX}{state.id}", payload, ex=self._ttl)
pipe.set(f"{CODE_KEY_PREFIX}{state.join_code}", state.id, ex=self._ttl)
await pipe.execute()
async def find_by_code(self, code: str) -> Optional[GameState]:
game_id = await self._redis.get(f"{CODE_KEY_PREFIX}{code.upper()}")
if game_id is None:
return None
if isinstance(game_id, bytes):
game_id = game_id.decode("utf-8")
return await self.load(str(game_id))
@contextlib.asynccontextmanager
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
pubsub = self._redis.pubsub()
await pubsub.subscribe(_channel(game_id))
try:
yield _redis_events(pubsub)
finally:
with contextlib.suppress(Exception):
await pubsub.unsubscribe(_channel(game_id))
await pubsub.aclose()
async def publish(self, game_id: str) -> None:
await self._redis.publish(_channel(game_id), "update")
async def _redis_events(pubsub) -> AsyncIterator[None]:
async for message in pubsub.listen():
if message.get("type") == "message":
yield None
class InMemoryGameStore(GameStore):
"""Process-local store used by tests and when Redis is not configured."""
def __init__(self) -> None:
self._games: Dict[str, GameState] = {}
self._codes: Dict[str, str] = {}
self._locks: Dict[str, asyncio.Lock] = {}
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
def _lock_for(self, game_id: str) -> asyncio.Lock:
lock = self._locks.get(game_id)
if lock is None:
lock = asyncio.Lock()
self._locks[game_id] = lock
return lock
@contextlib.asynccontextmanager
async def lock(self, game_id: str) -> AsyncIterator[None]:
async with self._lock_for(game_id):
yield
async def load(self, game_id: str) -> Optional[GameState]:
state = self._games.get(game_id)
return GameState.from_json(state.to_json()) if state else None
async def save(self, state: GameState) -> None:
self._games[state.id] = GameState.from_json(state.to_json())
self._codes[state.join_code] = state.id
async def find_by_code(self, code: str) -> Optional[GameState]:
game_id = self._codes.get(code.upper())
if game_id is None:
return None
return await self.load(game_id)
@contextlib.asynccontextmanager
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
queue: asyncio.Queue = asyncio.Queue()
self._subscribers.setdefault(game_id, set()).add(queue)
try:
yield _queue_events(queue)
finally:
subscribers = self._subscribers.get(game_id)
if subscribers is not None:
subscribers.discard(queue)
if not subscribers:
self._subscribers.pop(game_id, None)
async def publish(self, game_id: str) -> None:
for queue in list(self._subscribers.get(game_id, ())):
queue.put_nowait(_BUMP)
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
while True:
await queue.get()
yield None