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).
tavolo-platform
The game-independent half of tavolo: lobby, live play, match history and
leaderboards. Everything here works for any game that implements the
GameEngine contract; the package
itself ships no game.
Contents
engine.py— the platform↔game contract:GameEngine(ABC),GameSession(the platform-owned envelope with an opaquestateblob),Seat,Deadline(kind + due time + revalidation token) and theMatchResult/PlayerResultoutcome types.registry.py—GameRegistry: game id → engine lookup, single source of truth for which games exist.mixin.py—Platform(registry + store + scheduler + OIDC, the collaborators every endpoint needs) andPlatformMixin, the kaya mixin that registers all routes and the websocket endpoint on an app.routes/—health(GET /api/health),me(GET /api/me),games(lobby:GET /api/game-types,POST /api/games,POST /api/games/join,GET /api/games/{id}) andstats(GET /api/me/matches,GET /api/leaderboard,GET /api/me/ratings).ws.py— the live-play websocket (/ws/games/{id}): connection lifecycle, the message envelope and the publish/subscribe fan-out. Game-specific actions are dispatched to the session's engine.store.py— live-session persistence:RedisGameStore(production) andInMemoryGameStore(tests/dev) over a JSON envelope plus the engine's opaque state blob, with per-game locks, change signals and the shared deadline queue.deadlines.py—DeadlineScheduler: enqueue the deadline an engine declares after each mutation; a per-loop consumer fires due entries throughengine.fire_deadlineunder the per-game lock. Engines revalidate the token, so stale or duplicate deliveries are harmless.models.py—Match(game_type, timestamps, game-specific JSONresult),MatchPlayer(seat, team, won, points, Elo delta, JSONdetails) andPlayerRating(Elo per(user_sub, game_type)).stats.py—save_match_result(engineMatchResult→ Postgres, transactionally, with Elo) andapply_elo.elo.py— chess-style Elo math generalized to two-team matches.backfill_elo.py— rebuild every rating from the match history:python -m tavolo.platform.backfill_elo --database-url postgres://….auth.py,http.py,pagination.py,openapi.py,tortoise_mixin.py— OIDC helpers, JSON helpers, keyset pagination, shared OpenAPI fragments, the TortoiseORM lifecycle mixin.
Adding a game
Implement GameEngine (see engine.py for the full contract), register
it in a GameRegistry, and mount PlatformMixin(Platform(...)) on a
KayaApp — see the composition root in tavolo.app. The canonical
example is
tavolo-scopone.
Development (from server/)
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m mypy -p tavolo.platform
Tests run fully in-process against a DummyEngine (a two-player toy game
in tests/helpers.py): in-memory sqlite, in-memory stores, a patched
OIDC user and httpx / httpx-ws ASGI transports. No test in this
package may import a real game.