Files
tavolo/server/packages/tavolo-platform
woggioni ed8a004ec8
CI / Build and push docker image (push) Successful in 1m15s
Persist matches finished by deadline timeouts
The deadline consumer runs in a long-lived task outside any request, so
a timeout that ended the match raised 'No TortoiseContext is currently
active' in save_match_result before the state was saved: the game stayed
stuck on the last turn and the entry retried forever. It only surfaced
on the match-deciding turn; ordinary timeouts and human plays were fine.

Bind the Tortoise context before persisting a finished match (optional
context_binder wired to TortoiseMixin.ensure_context), and back off to
the heartbeat when a due entry fails instead of hot-looping on it.
2026-09-21 17:00:08 +08:00
..

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 opaque state blob), Seat, Deadline (kind + due time + revalidation token) and the MatchResult/PlayerResult outcome types.
  • registry.pyGameRegistry: game id → engine lookup, single source of truth for which games exist.
  • mixin.pyPlatform (registry + store + scheduler + OIDC, the collaborators every endpoint needs) and PlatformMixin, 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}) and stats (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) and InMemoryGameStore (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.pyDeadlineScheduler: enqueue the deadline an engine declares after each mutation; a per-loop consumer fires due entries through engine.fire_deadline under the per-game lock. Engines revalidate the token, so stale or duplicate deliveries are harmless.
  • models.pyMatch (game_type, timestamps, game-specific JSON result), MatchPlayer (seat, team, won, points, Elo delta, JSON details) and PlayerRating (Elo per (user_sub, game_type)).
  • stats.pysave_match_result (engine MatchResult → Postgres, transactionally, with Elo) and apply_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.