Store each player's leaderboard totals (wins, accumulated points and the latest display name) on player_rating alongside the Elo rating, updated in the same transaction as the match, so the board is a single indexed table query instead of aggregating every participation on each request. GET /api/leaderboard is now page-number paginated (page/limit) and returns the page, page count and total, plus the caller's own rank and page when authenticated; focus=me returns the caller's page directly. The migration adds the columns, backfills them from the match history (inserting rows for players from pre-Elo matches at the initial rating) and replaces the index. backfill_elo rebuilds the aggregates too. The web page opens on the page containing the signed-in player, marks their row and adds a numbered footer pager.
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 and leaderboard aggregates per(user_sub, game_type)).stats.py—save_match_result(engineMatchResult→ Postgres, transactionally, with Elo and the leaderboard aggregates) andapply_match_stats.elo.py— chess-style Elo math generalized to two-team matches.backfill_elo.py— rebuild every rating and aggregate 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 and page-number 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
examples are tavolo-scopone,
tavolo-scopa and
tavolo-briscola.
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.