Split backend into tavolo-platform, tavolo-scopone and tavolo-app packages

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).
This commit is contained in:
2026-09-19 07:28:58 +00:00
parent b2b514ab91
commit 5a73601ddf
72 changed files with 4490 additions and 2102 deletions
+64
View File
@@ -0,0 +1,64 @@
# 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`](src/tavolo/platform/engine.py) 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.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) 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.py``DeadlineScheduler`: 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.py``Match` (`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.py``save_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`](../tavolo-scopone/README.md).
## Development (from `server/`)
```sh
.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.
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tavolo-platform"
version = "0.1.0"
description = "Game-independent multiplayer game platform (lobby, live play, match history, leaderboards) built on the kaya framework"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"kaya-core",
"kaya-session",
"kaya-oidc",
"kaya-openapi",
"tortoise-orm",
"redis",
]
[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
plugins = []
# TortoiseORM auto-generates `<fk>_id` attributes on ForeignKeyField at
# runtime; without the (unavailable here) tortoise mypy plugin the stubs
# only declare the relation field. These are real attributes, not bugs.
[[tool.mypy.overrides]]
module = "tavolo.platform.models"
disable_error_code = ["attr-defined"]
[[tool.mypy.overrides]]
module = "tavolo.platform.routes.*"
disable_error_code = ["attr-defined"]
@@ -0,0 +1,53 @@
"""The game-independent half of tavolo: lobby, live play, history, ratings.
This package hosts every concern that does not depend on the rules of a
specific game: the lobby HTTP API, the live-game store, the websocket
endpoint, the deadline scheduler, match persistence and the Elo
leaderboards. A game plugs in by implementing
:class:`~tavolo.platform.engine.GameEngine` and registering it in a
:class:`~tavolo.platform.registry.GameRegistry`; the application then
mounts everything with :class:`~tavolo.platform.mixin.PlatformMixin`.
"""
from __future__ import annotations
from .engine import (
Deadline,
GameEngine,
GameSession,
MatchResult,
PlayerResult,
Seat,
)
from .errors import (
AlreadyJoined,
GameError,
GameFinished,
GameNotFound,
GameNotStarted,
IllegalMove,
LobbyFull,
NotYourTurn,
)
from .mixin import Platform, PlatformMixin
from .registry import GameRegistry, UnknownGameType
__all__ = [
"AlreadyJoined",
"Deadline",
"GameEngine",
"GameError",
"GameFinished",
"GameNotFound",
"GameNotStarted",
"GameRegistry",
"GameSession",
"IllegalMove",
"LobbyFull",
"MatchResult",
"NotYourTurn",
"Platform",
"PlatformMixin",
"PlayerResult",
"Seat",
"UnknownGameType",
]
@@ -0,0 +1,60 @@
"""Authentication helpers on top of the kaya-oidc mixin.
The platform has no application roles: every authenticated user may
create and join games. Authorization beyond login is game membership,
checked against the seats of the live session.
"""
from __future__ import annotations
from typing import Any, Callable, Mapping, Optional
from kaya.core import HttpContext, WebSocket
from kaya.oidc import OIDCMixin, OIDCUser
def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
"""Return the authenticated user of a WebSocket connection, if any.
The session mixin injects ``session`` into the websocket wrapper; the
OIDC mixin stores the userinfo there at login. Patched in tests.
"""
session = getattr(ws, "session", None)
if session is None:
return None
claims = session.get("oidc_user")
if not isinstance(claims, Mapping):
return None
return OIDCUser(claims)
def display_name(user: OIDCUser) -> str:
"""Best-effort human-readable name for a user."""
for key in ("name", "preferred_username", "email"):
value = user.get(key)
if isinstance(value, str) and value:
return value
return user.sub
def require_auth(oidc: OIDCMixin) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Build a decorator gating a handler on being authenticated.
Responds ``401`` with a JSON error envelope when unauthenticated —
unlike kaya's built-in ``OIDCMixin.require_auth`` which redirects to
the login page (wrong for a JSON API).
"""
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None:
if oidc.get_user(ctx) is None:
await ctx.send_bytes(
401,
b'{"error":"unauthenticated"}',
{"content-type": ("application/json",)},
)
return
await handler(ctx, *args, **kwargs)
return guarded
return decorator
@@ -0,0 +1,81 @@
"""Recompute every Elo rating from the recorded match history.
Ratings are deterministic given the finished matches, so this replays all
matches in chronological order and rewrites the ``player_rating`` table
and each ``match_player.elo_delta`` from scratch. Run any time ratings
need to be rebuilt::
python -m tavolo.platform.backfill_elo --database-url postgres://...
"""
from __future__ import annotations
import argparse
import asyncio
from collections import defaultdict
from logging import getLogger
from typing import Dict, List
from tortoise.transactions import in_transaction
from .stats import apply_elo
from .tortoise_mixin import TortoiseMixin
log = getLogger(__name__)
async def backfill_elo() -> int:
"""Rebuild all ratings; returns the number of matches replayed."""
from .models import Match, MatchPlayer, PlayerRating
replayed = 0
async with in_transaction():
await PlayerRating.all().delete()
matches = await Match.all().order_by("finished_at", "id")
for match in matches:
players = await MatchPlayer.filter(match_id=match.id)
by_team: Dict[str, List[str]] = defaultdict(list)
for player in players:
if player.team is not None:
by_team[player.team].append(player.user_sub)
# Deterministic team order; the winner is the team of any
# player flagged as having won.
teams = [by_team[team] for team in sorted(by_team)]
winner_index = next(
(
i
for i, members in enumerate(teams)
if any(p.won and p.user_sub in members for p in players)
),
0,
)
deltas = await apply_elo(match.game_type, teams, winner_index)
for player in players:
player.elo_delta = deltas[player.user_sub]
await player.save()
replayed += 1
return replayed
async def _main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--database-url",
required=True,
help="Tortoise database URL, e.g. postgres://user:pass@host/db",
)
args = parser.parse_args()
mixin = TortoiseMixin(
database_url=args.database_url,
models_modules=["tavolo.platform.models"],
)
await mixin._bind()
try:
replayed = await backfill_elo()
log.info("elo backfill complete: %d matches replayed", replayed)
print(f"Recomputed ratings from {replayed} matches.")
finally:
await mixin.aclose()
if __name__ == "__main__":
asyncio.run(_main())
@@ -0,0 +1,239 @@
"""Deadline-driven timeouts, independent of player connections.
In-match timeouts (e.g. scopone's per-turn auto-play and hand-end
auto-continue) are driven by absolute deadlines, never by which players
(or whether any players) are connected. The platform owns the scheduling
machinery; the *meaning* of each deadline belongs to the game engine:
* after every mutation the engine's
:meth:`~tavolo.platform.engine.GameEngine.next_deadline` computes the
deadline the new state implies (kind + due time + a revalidation
token), and the scheduler enqueues it in the store's shared deadline
queue (a Redis sorted set in production, see
:mod:`tavolo.platform.store`); enqueueing is idempotent;
* a background consumer running on **every** worker polls the queue for
due entries and hands them to the engine's
:meth:`~tavolo.platform.engine.GameEngine.fire_deadline` under the
per-game lock; the engine revalidates the token against the live state
and raises :class:`~tavolo.platform.errors.GameError` when the entry
was overtaken by events, so duplicate or stale deliveries are harmless.
Delivery is at-least-once: an entry is removed from the queue only after
it has been processed. If a worker dies mid-processing, the entry stays
in Redis and another worker's consumer picks it up. Entries whose game
has expired are dropped the first time they fire, so the queue is
self-cleaning.
"""
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from logging import getLogger
from typing import Any, Dict, Optional
from kaya.core import KayaApp, KayaMixin
from .engine import GameSession
from .errors import GameError
from .registry import GameRegistry
from .stats import save_match_result
from .store import GameStore
log = getLogger(__name__)
def encode(entry: Dict[str, Any]) -> str:
"""Canonical queue-member encoding for a deadline entry."""
return json.dumps(entry, sort_keys=True)
def _decode(member: Any) -> Optional[Dict[str, Any]]:
if isinstance(member, bytes):
member = member.decode("utf-8")
if not isinstance(member, str):
return None
try:
entry = json.loads(member)
except ValueError:
return None
return entry if isinstance(entry, dict) else None
class DeadlineScheduler:
"""Enqueue and fire the deadlines game engines ask for.
Holds the store and the registry so a single instance serves every
game type. One consumer task (and its wake-up event) is kept per
event loop — tests run each test on a fresh loop.
"""
def __init__(
self,
store: GameStore,
registry: GameRegistry,
heartbeat_ms: int = 1000,
) -> None:
self._store = store
self._registry = registry
self._heartbeat = heartbeat_ms / 1000
self._consumers: Dict[asyncio.AbstractEventLoop, asyncio.Task] = {}
self._wake_events: Dict[asyncio.AbstractEventLoop, asyncio.Event] = {}
async def sync_deadline(self, session: GameSession) -> None:
"""Enqueue the deadline the current state implies, if any.
Called after every mutation that can set a deadline and as a
backstop when a client connects. Enqueueing is idempotent: an
identical entry is already queued with the same due time, so
re-adding it changes nothing.
"""
engine = self._registry.require(session.game_type)
deadline = engine.next_deadline(session)
if deadline is None:
return
entry = {
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
}
self.ensure_consumer()
due_at = deadline.due_at.timestamp()
await self._store.add_deadline(encode(entry), due_at)
wake = self._wake_events.get(asyncio.get_running_loop())
if wake is not None:
wake.set()
async def finalize_mutation(self, session: GameSession) -> None:
"""Persist a successful mutation, notify subscribers and enqueue
the next deadline.
Callers must hold the per-game lock. Handles the terminal
transition: the match result is written to Postgres once
(guarded by ``stats_saved``).
"""
engine = self._registry.require(session.game_type)
if engine.is_finished(session):
if session.finished_at is None:
session.finished_at = datetime.now(timezone.utc)
await save_match_result(session, engine)
log.info("game %s (%s) finished", session.id, session.game_type)
await self._store.save(session)
await self._store.publish(session.id)
await self.sync_deadline(session)
async def process_due(self, member: Any) -> None:
"""Fire a single due deadline entry.
The engine revalidates the entry against the live state under
the per-game lock; stale or foreign entries are discarded
without effect. The entry is removed from the queue once handled
(including "nothing to do"); if handling fails unexpectedly
(e.g. the lock cannot be acquired), the entry is left in the
queue so another consumer retries it.
"""
entry = _decode(member)
if entry is None:
log.warning("deadline consumer: dropping malformed entry %r", member)
await self._store.remove_deadline(member)
return
game_id = entry.get("game_id")
kind = entry.get("kind")
token = entry.get("token")
if not isinstance(game_id, str) or not isinstance(kind, str):
await self._store.remove_deadline(member)
return
async with self._store.lock(game_id):
session = await self._store.load(game_id)
if session is not None:
engine = self._registry.require(session.game_type)
try:
engine.fire_deadline(session, kind, str(token))
except GameError:
# Stale or inapplicable entry: nothing to do.
pass
else:
await self.finalize_mutation(session)
await self._store.remove_deadline(member)
# --- consumer lifecycle -------------------------------------------------
def ensure_consumer(
self, loop: Optional[asyncio.AbstractEventLoop] = None
) -> None:
"""Start the deadline consumer on the given (or running) loop if
not yet running.
Called lazily whenever a deadline is enqueued (the ASGI test
transport never fires the lifespan hooks, so the mixin's
``setup`` alone is not enough) and on application startup. The
explicit ``loop`` matters at startup: under RSGI granian calls
``setup`` before the loop runs, so ``asyncio.get_running_loop()``
would fail there.
"""
if loop is None:
loop = asyncio.get_running_loop()
for old in list(self._consumers):
if old.is_closed():
self._consumers.pop(old, None)
self._wake_events.pop(old, None)
task = self._consumers.get(loop)
if task is None or task.done():
self._wake_events[loop] = asyncio.Event()
self._consumers[loop] = loop.create_task(self._run(loop))
log.debug("deadline consumer started")
def stop_consumer(self, loop: asyncio.AbstractEventLoop) -> None:
task = self._consumers.pop(loop, None)
self._wake_events.pop(loop, None)
if task is not None:
task.cancel()
async def _run(self, loop: asyncio.AbstractEventLoop) -> None:
wake = self._wake_events[loop]
while True:
# Clear before polling so an enqueue racing the poll re-wakes us.
wake.clear()
delay = self._heartbeat
try:
for member in await self._store.due_deadlines(time.time()):
try:
await self.process_due(member)
except asyncio.CancelledError:
raise
except Exception:
# Left in the queue; retried on the next pass.
log.exception("deadline consumer: failed to process %r", member)
next_due = await self._store.next_deadline()
if next_due is not None:
delay = max(0.0, min(self._heartbeat, next_due - time.time()))
except asyncio.CancelledError:
raise
except Exception:
log.exception("deadline consumer: poll failed; retrying")
try:
await asyncio.wait_for(wake.wait(), timeout=delay)
except asyncio.TimeoutError:
pass
class DeadlineSchedulerMixin(KayaMixin):
"""Run the deadline consumer for the whole app lifetime.
Every worker (and every pod) runs the same consumer; coordination
happens exclusively through the shared deadline queue and the
per-game locks, so any worker may fire any game's deadline.
"""
def __init__(self, scheduler: DeadlineScheduler) -> None:
self._scheduler = scheduler
def apply(self, app: KayaApp) -> None:
pass
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
self._scheduler.ensure_consumer(loop)
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
self._scheduler.stop_consumer(loop)
@@ -0,0 +1,49 @@
"""Chess-style Elo ratings, generalized to two-team matches.
Every player starts at :data:`INITIAL_RATING`. A team's rating is the mean
of its members' current ratings, so the usual chess formula applies
unchanged between the two teams:
* expected score ``E = 1 / (1 + 10 ** ((R_opponent - R_team) / 400))``
* actual score ``S`` is 1 for a win and 0 for a loss (matches never draw)
* every member of a team gains/loses the same ``round(K * (S - E))``
Deltas are rounded to integers and ratings are stored as integers, so the
system is exactly zero-sum: what the winners gain the losers lose.
"""
from __future__ import annotations
from typing import Sequence
INITIAL_RATING = 1500
K_FACTOR = 32
def expected_score(rating: float, opponent_rating: float) -> float:
"""Expected score (0..1) of a side rated ``rating`` against
``opponent_rating``."""
return 1.0 / (1.0 + 10.0 ** ((opponent_rating - rating) / 400.0))
def team_rating(ratings: Sequence[float]) -> float:
"""A team's rating is the mean of its members' ratings."""
if not ratings:
raise ValueError("a team needs at least one rating")
return sum(ratings) / len(ratings)
def match_delta(
team_a_ratings: Sequence[float],
team_b_ratings: Sequence[float],
winner_team: int,
) -> int:
"""Rating change applied to each member of team A.
``winner_team`` is 0 when team A won, 1 when team B won. Team B
members change by the negation of the returned value (zero-sum).
"""
rating_a = team_rating(team_a_ratings)
rating_b = team_rating(team_b_ratings)
expected = expected_score(rating_a, rating_b)
score = 1.0 if winner_team == 0 else 0.0
return round(K_FACTOR * (score - expected))
@@ -0,0 +1,247 @@
"""The contract between the platform and a game implementation.
The platform owns everything that is game-independent: the lobby, live
game storage, websockets, timeouts, match history and leaderboards. A
game (e.g. scopone scientifico) implements :class:`GameEngine` and plugs
into the platform through :class:`~tavolo.platform.registry.GameRegistry`;
the platform never imports a concrete game module.
The central type is :class:`GameSession`: the platform-owned envelope
carrying the lifecycle metadata (id, join code, seats, timestamps) plus
an opaque ``state`` blob that only the engine interprets. The engine
serializes/deserializes that blob (:meth:`GameEngine.state_to_json` /
:meth:`GameEngine.state_from_json`) so the store stays game-agnostic.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, List, Mapping, Optional
from .errors import GameError
__all__ = [
"Deadline",
"GameEngine",
"GameSession",
"MatchResult",
"PlayerResult",
"Seat",
]
@dataclass
class Seat:
"""One occupied chair in a game, owned by the platform.
Games may keep their own per-player state internally; the seat is
what the platform needs for the lobby, for access control and for
persisting participations.
"""
user_sub: str
display_name: str
# Game-defined team label ("A"/"B", ...); ``None`` for games without
# fixed teams.
team: Optional[str] = None
def to_json(self) -> Dict[str, Any]:
return {
"user_sub": self.user_sub,
"display_name": self.display_name,
"team": self.team,
}
@staticmethod
def from_json(data: Mapping[str, Any]) -> "Seat":
return Seat(
user_sub=str(data["user_sub"]),
display_name=str(data["display_name"]),
team=data.get("team"),
)
@dataclass
class GameSession:
"""The platform-owned envelope of a live game.
``state`` is opaque to the platform: only the engine registered for
``game_type`` reads or writes it.
"""
id: str
game_type: str
join_code: str
creator_sub: str
players: List[Seat] = field(default_factory=list)
created_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
# Set once the finished match has been copied to Postgres.
stats_saved: bool = False
state: Any = None
def seated(self, sub: str) -> bool:
"""Whether ``sub`` occupies a seat in this game."""
return self.seat_of(sub) is not None
def seat_of(self, sub: str) -> Optional[Seat]:
for seat in self.players:
if seat.user_sub == sub:
return seat
return None
@dataclass(frozen=True)
class Deadline:
"""A timeout the engine wants the platform to fire.
``kind`` is an engine-defined string (e.g. ``"turn"``); ``token`` is
an opaque revalidation token: when the deadline fires, the engine must
recompute it from the live session and refuse to act (raising
:class:`~tavolo.platform.errors.GameError`) if it no longer matches,
which makes duplicate or overtaken deliveries harmless.
"""
kind: str
due_at: datetime
token: str
@dataclass
class PlayerResult:
"""The outcome of a finished match for one participant."""
user_sub: str
seat: int
won: bool
team: Optional[str] = None
# Points scored, aggregated by the leaderboard.
score: float = 0.0
# Game-specific extras persisted alongside the participation.
details: Dict[str, Any] = field(default_factory=dict)
@dataclass
class MatchResult:
"""The game-independent outcome of a finished match.
``teams`` groups the participants' subs into the sides the Elo rating
treats as opponents (exactly two for now); ``winner_team`` is the
index of the winning side. ``summary`` is persisted verbatim as the
match's JSON ``result`` column.
"""
teams: List[List[str]]
winner_team: int
players: List[PlayerResult]
summary: Dict[str, Any] = field(default_factory=dict)
class GameEngine(ABC):
"""The interface every playable game implements.
Implementations must be deterministic and I/O-free apart from
logging: all persistence, locking and transport concerns belong to
the platform. Rule violations are reported by raising
:class:`~tavolo.platform.errors.GameError` subclasses.
"""
#: Unique id carried by sessions and persisted matches.
id: str
#: Human-readable name and description, for the lobby UI.
name: str
description: str
min_players: int
max_players: int
#: JSON-schema fragment describing the game-specific creation options
#: accepted by :meth:`create` (exposed via ``GET /api/game-types``).
options_schema: Mapping[str, Any] = {}
@abstractmethod
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
"""Initialize ``session.state`` for a fresh lobby game.
The platform has already filled the session envelope and seated
the creator (``session.players[0]``, without a team label yet).
Assign the creator's team here if the game has fixed teams, then
validate ``options`` (the opaque object from the create request;
raise :class:`~tavolo.platform.errors.GameError` on invalid
values) and initialize ``session.state``.
"""
@abstractmethod
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
"""Seat a player, starting the match when the lobby fills up.
Must append a :class:`Seat` to ``session.players`` and mirror
whatever per-player state the game keeps in ``session.state``.
Raises ``AlreadyJoined`` / ``LobbyFull`` / ``GameNotStarted``.
"""
@abstractmethod
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
"""Apply one player action received over the websocket.
``payload`` is the client message minus its ``action`` field.
Unknown actions and rule violations raise
:class:`~tavolo.platform.errors.GameError`.
"""
@abstractmethod
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""The personalized game view for one player.
Must hide information the player is not entitled to (e.g. other
players' hands). The platform merges in the envelope fields
(``id``, ``join_code``, ``game_type``) itself.
"""
@abstractmethod
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
"""Game-specific fields of the lobby payload (options echo, phase)."""
@abstractmethod
def in_lobby(self, session: GameSession) -> bool:
"""Whether the game is still waiting for players."""
@abstractmethod
def is_finished(self, session: GameSession) -> bool:
"""Whether the match is over and a result can be extracted."""
@abstractmethod
def result(self, session: GameSession) -> MatchResult:
"""The outcome of the match. Only called when finished."""
@abstractmethod
def state_to_json(self, state: Any) -> Dict[str, Any]:
"""Serialize the opaque game state to plain JSON."""
@abstractmethod
def state_from_json(self, data: Mapping[str, Any]) -> Any:
"""Rebuild the opaque game state from its JSON form."""
@abstractmethod
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
"""The deadline the current state implies, if any.
Called after every mutation; the platform enqueues the returned
deadline (enqueueing is idempotent) and never removes previously
enqueued ones — stale entries are discarded at fire time by
:meth:`fire_deadline` revalidation.
"""
@abstractmethod
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
"""Apply the timeout action for a due deadline.
Must recompute the current deadline and raise
:class:`~tavolo.platform.errors.GameError` without acting when
``kind``/``token`` no longer match the live state.
"""
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message."""
return {}
@@ -0,0 +1,43 @@
"""Typed errors shared between the platform and game implementations.
Game engines raise :class:`GameError` subclasses on rule/validation
failures; the platform translates them into 4xx HTTP responses or
``error`` WebSocket messages, using :attr:`GameError.code` as the
machine-readable error code. Engines themselves stay transport-agnostic.
"""
from __future__ import annotations
class GameError(Exception):
"""Base class for every rule/validation failure a game can raise."""
#: Machine-readable code carried by WebSocket ``error`` messages.
code = "illegal_move"
class IllegalMove(GameError):
"""The requested action violates the rules of the game."""
class NotYourTurn(GameError):
"""A player attempted to act out of turn."""
class GameNotStarted(GameError):
"""An action was attempted before the game left the lobby."""
class GameFinished(GameError):
"""An action was attempted after the match ended."""
class LobbyFull(GameError):
"""A game already has its full complement of players."""
class AlreadyJoined(GameError):
"""A player tried to join a game they are already seated in."""
class GameNotFound(GameError):
"""No live game exists for the given id or join code."""
@@ -0,0 +1,73 @@
"""JSON helpers for kaya HTTP handlers.
Kaya has no built-in request/response JSON helpers: the request body is an
async byte stream on ``ctx.request_body`` and responses are sent with
``ctx.send_*``. These wrappers handle the boilerplate of draining the body,
parsing JSON, and sending JSON responses.
"""
from __future__ import annotations
import json
from typing import Any, List, Mapping
from kaya.core import HttpContext
JSON_HEADERS = {"content-type": ("application/json",)}
class JsonRequestError(ValueError):
"""Raised by :func:`read_json` when the request body is not valid JSON
or is not a JSON object."""
def extract_query_params(query_string: str) -> Mapping[str, List[str]]:
"""Parse a raw query string into a mapping of param name to list of
values.
Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the
incantation; always returns a mapping (never None).
"""
from urllib.parse import parse_qs
return parse_qs(query_string, keep_blank_values=True)
async def read_json(ctx: HttpContext) -> dict:
"""Drain and parse the request body as JSON.
Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError`
with a short human-readable reason on failure.
"""
body = b""
async for chunk in ctx.request_body:
body += chunk
if not body:
raise JsonRequestError("empty body")
try:
parsed = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise JsonRequestError("invalid JSON") from exc
if not isinstance(parsed, dict):
raise JsonRequestError("JSON body must be an object")
return parsed
async def read_json_optional(ctx: HttpContext) -> dict:
"""Like :func:`read_json` but treats an empty body as ``{}``."""
try:
return await read_json(ctx)
except JsonRequestError as exc:
if str(exc) == "empty body":
return {}
raise
async def send_json(ctx: HttpContext, status: int, payload: Any) -> None:
"""Send ``payload`` as a JSON response."""
body = json.dumps(payload).encode("utf-8")
await ctx.send_bytes(status, body, JSON_HEADERS)
async def send_error(ctx: HttpContext, status: int, message: str) -> None:
"""Send a JSON error envelope."""
await send_json(ctx, status, {"error": message})
@@ -0,0 +1,50 @@
"""The :class:`PlatformMixin`: mounts the whole platform on a kaya app.
``Platform`` bundles the collaborators every platform endpoint needs —
the game registry, the live-game store, the deadline scheduler and the
OIDC mixin used for authentication — so route and websocket modules
never reach for module-global state. :class:`PlatformMixin` is a plain
:class:`~kaya.core.KayaMixin`: applying it registers the lobby, stats
and health HTTP routes plus the live-play websocket endpoint, and the
app stays a :class:`~kaya.core.KayaApp` (both ASGI and RSGI keep
working).
"""
from __future__ import annotations
from dataclasses import dataclass
from kaya.core import KayaApp, KayaMixin
from kaya.oidc import OIDCMixin
from .deadlines import DeadlineScheduler
from .registry import GameRegistry
from .store import GameStore
@dataclass(frozen=True)
class Platform:
"""The collaborators shared by all platform endpoints."""
registry: GameRegistry
game_store: GameStore
scheduler: DeadlineScheduler
oidc: OIDCMixin
class PlatformMixin(KayaMixin):
"""Register the game-independent routes and the websocket endpoint."""
def __init__(self, platform: Platform) -> None:
self.platform = platform
def apply(self, app: KayaApp) -> None:
# Imported here so module import order stays acyclic: the route
# modules reference Platform only for typing.
from . import ws
from .routes import games, health, me, stats
health.register(app, self.platform)
me.register(app, self.platform)
games.register(app, self.platform)
stats.register(app, self.platform)
ws.register(app, self.platform)
@@ -0,0 +1,86 @@
"""Tortoise ORM models: match statistics persisted in Postgres.
Live game state lives in Redis (see :mod:`tavolo.platform.store`); only
completed matches are written here. The schema is game-independent:
* :class:`Match` — one row per finished match; everything game-specific
(scores, hands played, options, per-hand audit trail) lives in the
JSON ``result`` column produced by the game's engine.
* :class:`MatchPlayer` — one row per participant, linking an OIDC
``sub`` to a seat, an optional team label, whether they won, the
points they scored and their Elo delta.
* :class:`PlayerRating` — current chess-style Elo rating of a player for
one game type, updated transactionally with every finished match (see
:mod:`tavolo.platform.elo`).
"""
from __future__ import annotations
from tortoise import fields
from tortoise.models import Model
from .elo import INITIAL_RATING
class Match(Model):
"""A completed match of one of the registered game types."""
id = fields.UUIDField(pk=True)
# Which game was played (an id from the game registry).
game_type = fields.CharField(max_length=32, db_index=True)
started_at = fields.DatetimeField()
finished_at = fields.DatetimeField()
# Game-specific outcome summary as reported by the engine's
# ``MatchResult.summary`` (scores, hands played, options, ...).
result: dict = fields.JSONField(default=dict)
players: fields.ReverseRelation["MatchPlayer"]
class Meta:
table = "match"
ordering = ["-finished_at"]
class MatchPlayer(Model):
"""Participation of one user in one match."""
id = fields.UUIDField(pk=True)
match: fields.ForeignKeyRelation[Match] = fields.ForeignKeyField(
"models.Match", related_name="players", on_delete=fields.CASCADE
)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255, db_index=True)
display_name = fields.CharField(max_length=200)
seat = fields.SmallIntField()
# Game-defined team label; null for games without fixed teams.
team = fields.CharField(max_length=32, null=True)
won = fields.BooleanField()
# Points the player scored, aggregated by the leaderboard.
score = fields.FloatField(default=0)
# Elo change this match produced for the player (see
# tavolo.platform.elo); null for matches recorded before ratings
# existed.
elo_delta = fields.SmallIntField(null=True)
# Game-specific extras reported by the engine's PlayerResult.
details: dict = fields.JSONField(default=dict)
class Meta:
table = "match_player"
unique_together = (("match", "user_sub"),)
class PlayerRating(Model):
"""Current Elo rating of one player for one game type."""
id = fields.UUIDField(pk=True)
# OIDC subject of the player; no local users table.
user_sub = fields.CharField(max_length=255)
# Which game the rating applies to (an id from the game registry).
game_type = fields.CharField(max_length=32)
rating = fields.IntField(default=INITIAL_RATING)
matches_played = fields.IntField(default=0)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "player_rating"
unique_together = (("user_sub", "game_type"),)
indexes = (("game_type", "rating"),)
@@ -0,0 +1,21 @@
"""Shared OpenAPI fragments for the ``@operation`` decorators in ``routes/``."""
from __future__ import annotations
from typing import Any, Dict, List
PAGINATION_PARAMETERS: List[Dict[str, Any]] = [
{
"name": "limit",
"in": "query",
"required": False,
"schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
"description": "Maximum number of results per page (clamped to [1, 100]).",
},
{
"name": "cursor",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Opaque pagination cursor from a previous response's next_cursor.",
},
]
@@ -0,0 +1,128 @@
"""Cursor-based pagination for listing endpoints.
Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque
cursor encoding the sort key tuple of the last item on that page; the next
request passes that cursor and the query continues from the point it left
off. This is stable under concurrent inserts and cheaper than OFFSET for
large result sets.
Cursor is a base64-encoded JSON object mapping the sort-field names to the
values of the last item on the previous page. It's opaque to callers and
must be treated as a black box.
"""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from tortoise.queryset import QuerySet
from .http import extract_query_params
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
MIN_LIMIT = 1
CURSOR_PARAM = "cursor"
LIMIT_PARAM = "limit"
class CursorDecodeError(ValueError):
"""Raised when the ``cursor`` query param cannot be decoded."""
def encode_cursor(values: Dict[str, Any]) -> str:
# ``default=str`` handles datetimes (ISO) so keyset cursors can carry
# datetime-typed sort fields (e.g. finished_at for match history).
raw = json.dumps(values, separators=(",", ":"), default=str).encode("utf-8")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def decode_cursor(s: Optional[str]) -> Optional[Dict[str, Any]]:
if s is None or s == "":
return None
try:
# Tolerate missing padding.
padded = s + "=" * (-len(s) % 4)
raw = base64.urlsafe_b64decode(padded.encode("ascii"))
obj = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError) as exc:
raise CursorDecodeError("invalid cursor") from exc
if not isinstance(obj, dict):
raise CursorDecodeError("invalid cursor")
return obj
@dataclass(frozen=True)
class Cursor:
limit: int
after: Optional[Dict[str, Any]]
def parse_cursor_params(query_string: str) -> Cursor:
params = extract_query_params(query_string)
limit_raw = params.get(LIMIT_PARAM)
if limit_raw:
try:
limit = int(limit_raw[0])
except ValueError as exc:
raise CursorDecodeError("invalid limit") from exc
else:
limit = DEFAULT_LIMIT
limit = max(MIN_LIMIT, min(MAX_LIMIT, limit))
after = decode_cursor(params.get(CURSOR_PARAM, [None])[0])
return Cursor(limit=limit, after=after)
# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full
# keyset; the cursor encodes exactly these fields.
Sort = List[Tuple[str, str]]
def _keyset_where(sort: Sort, after: Dict[str, Any]):
"""Build a Tortoise ``Q`` filter from a cursor."""
from tortoise.queryset import Q # local to keep import edge narrow
clauses = []
for i, (field, direction) in enumerate(sort):
key = f"{field}__{'gt' if direction == 'ASC' else 'lt'}"
value = after.get(field)
if value is None:
return Q()
clause = Q(**{key: value})
for j in range(i):
prev_field, _ = sort[j]
prev_value = after.get(prev_field)
if prev_value is None:
return Q()
clause = clause & Q(**{prev_field: prev_value})
clauses.append(clause)
result = clauses[0]
for clause in clauses[1:]:
result = result | clause
return result
async def paginate(
queryset: QuerySet,
sort: Sort,
cursor: Cursor,
) -> Tuple[List[Any], Optional[str]]:
"""Return one page of ``queryset`` plus the opaque cursor to continue."""
order_by: List[str] = []
for field, direction in sort:
order_by.append(field if direction == "ASC" else f"-{field}")
qs = queryset.order_by(*order_by)
if cursor.after:
qs = qs.filter(_keyset_where(sort, cursor.after))
rows = await qs.limit(cursor.limit + 1)
if len(rows) <= cursor.limit:
return rows, None
page = rows[: cursor.limit]
last = page[-1]
key: Dict[str, Any] = {}
for field, _ in sort:
key[field] = getattr(last, field)
return page, encode_cursor(key)
@@ -0,0 +1,60 @@
"""Registry of the games the platform can host.
The registry maps a ``game_type`` id to its
:class:`~tavolo.platform.engine.GameEngine` implementation and is the
single source of truth for which games exist: the lobby lists it, the
store uses it to deserialize opaque game state, and the stats endpoints
use it to validate ``game_type`` filters. Games register themselves at
composition time (see the application entry point); the platform itself
ships no game.
"""
from __future__ import annotations
from typing import Dict, Iterable, List, Optional
from .engine import GameEngine
from .errors import GameError
class UnknownGameType(GameError):
"""A request named a ``game_type`` no registered engine provides."""
code = "unknown_game_type"
class GameRegistry:
"""An ordered collection of game engines, keyed by their id."""
def __init__(self, engines: Iterable[GameEngine] = ()) -> None:
self._engines: Dict[str, GameEngine] = {}
for engine in engines:
self.register(engine)
def register(self, engine: GameEngine) -> GameEngine:
"""Add ``engine``; the first registered becomes the default."""
if engine.id in self._engines:
raise ValueError(f"duplicate game engine id: {engine.id!r}")
self._engines[engine.id] = engine
return engine
def get(self, game_type: str) -> Optional[GameEngine]:
"""Return the engine for ``game_type``, or ``None``."""
return self._engines.get(game_type)
def require(self, game_type: str) -> GameEngine:
"""Return the engine for ``game_type`` or raise."""
engine = self.get(game_type)
if engine is None:
raise UnknownGameType(f"unknown game_type: {game_type!r}")
return engine
@property
def default(self) -> GameEngine:
"""The first registered engine, used when no type is requested."""
try:
return next(iter(self._engines.values()))
except StopIteration:
raise RuntimeError("no game engines registered") from None
def all(self) -> List[GameEngine]:
return list(self._engines.values())
@@ -0,0 +1 @@
"""HTTP endpoints of the platform: health, identity, lobby, statistics."""
@@ -0,0 +1,287 @@
"""Game lobby endpoints.
A game starts as a lobby: the creator is seated first and shares the
six-character ``join_code``. When the lobby fills up, the engine starts
the match. Live play then happens over the ``/ws/games/{id}`` websocket
(see :mod:`tavolo.platform.ws`); these endpoints cover creation, joining
and snapshotting state.
Everything game-specific — which options a game accepts, when the lobby
is full, what the personalized view looks like — is delegated to the
engine registered for the session's ``game_type``.
"""
from __future__ import annotations
import secrets
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import display_name, require_auth
from ..engine import GameSession, Seat
from ..errors import GameError
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json
if TYPE_CHECKING:
from ..mixin import Platform
log = getLogger(__name__)
# Join codes avoid characters that are easy to confuse when read aloud.
_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6
_MAX_CODE_ATTEMPTS = 20
def _now_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
async def _unique_code(platform: "Platform") -> str:
for _ in range(_MAX_CODE_ATTEMPTS):
code = _now_code()
if await platform.game_store.find_by_code(code) is None:
return code
raise RuntimeError("could not allocate a unique join code")
def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
"players": [
{
"sub": seat.user_sub,
"name": seat.display_name,
"seat": index,
"team": seat.team,
}
for index, seat in enumerate(session.players)
],
"seats_open": engine.max_players - len(session.players),
**engine.lobby_view(session),
}
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
**engine.view_for(session, sub),
}
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/game-types")
@operation(summary="List available games",
description="Every game the platform can host, for the "
"match-creation dropdown. ``options_schema`` "
"describes the per-game creation options accepted "
"by POST /api/games.",
tags=["games"],
responses={200: {"description": "The available game types"}})
async def list_game_types(ctx: HttpContext) -> None:
await send_json(ctx, 200, {
"results": [
{
"id": engine.id,
"name": engine.name,
"description": engine.description,
"min_players": engine.min_players,
"max_players": engine.max_players,
"options_schema": engine.options_schema,
}
for engine in platform.registry.all()
]
})
@app.POST("/api/games")
@operation(summary="Create a game",
description="Creates a lobby game and seats the caller in "
"seat 0. Share the returned join_code with the "
"other players. ``options`` is an opaque object "
"validated by the chosen game (see "
"GET /api/game-types).",
tags=["games"],
request_body={
"required": False,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"game_type": {
"type": "string",
"description": "One of the ids from "
"GET /api/game-types; "
"defaults to the "
"platform's first "
"registered game",
},
"options": {
"type": "object",
"description": "Game-specific creation "
"options",
},
},
}
}
},
},
responses={
201: {"description": "The created lobby"},
400: {"description": "Invalid game_type, options or body"},
401: {"description": "Authentication required"},
})
@require_auth(platform.oidc)
async def create_game(ctx: HttpContext) -> None:
body: dict = {}
try:
body = await read_json_optional(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
game_type: Any = body.get("game_type")
if game_type is None:
engine = platform.registry.default
elif isinstance(game_type, str) and platform.registry.get(game_type) is not None:
engine = platform.registry.require(game_type)
else:
await send_error(ctx, 400, f"unknown game_type: {game_type!r}")
return
options: Any = body.get("options", {})
if not isinstance(options, dict):
await send_error(ctx, 400, "options must be an object")
return
user = platform.oidc.get_user(ctx)
assert user is not None # enforced by @require_auth
session = GameSession(
id=str(uuid.uuid4()),
game_type=engine.id,
join_code=await _unique_code(platform),
creator_sub=user.sub,
players=[Seat(user_sub=user.sub, display_name=display_name(user))],
created_at=datetime.now(timezone.utc),
)
try:
engine.create(session, options)
except GameError as exc:
await send_error(ctx, 400, str(exc))
return
await platform.game_store.save(session)
log.info(
"game %s created by %s (%s, options %r)",
session.id,
user.sub,
engine.id,
options,
)
await send_json(ctx, 201, _lobby_payload(platform, session))
@app.POST("/api/games/join")
@operation(summary="Join a game by code",
description="Seats the caller in the next free chair. Joining "
"as the last player starts the match.",
tags=["games"],
request_body={
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
}
}
},
},
responses={
200: {"description": "Seated; game state (may be playing)"},
400: {"description": "Missing code"},
401: {"description": "Authentication required"},
404: {"description": "Unknown join code"},
409: {"description": "Already joined or lobby full"},
})
@require_auth(platform.oidc)
async def join_game(ctx: HttpContext) -> None:
try:
body = await read_json(ctx)
except JsonRequestError as exc:
await send_error(ctx, 400, str(exc))
return
code = body.get("code")
if not isinstance(code, str) or not code:
await send_error(ctx, 400, "code is required")
return
user = platform.oidc.get_user(ctx)
assert user is not None
existing = await platform.game_store.find_by_code(code)
if existing is None:
log.debug("join rejected for %s: unknown code %r", user.sub, code)
await send_error(ctx, 404, "unknown join code")
return
async with platform.game_store.lock(existing.id):
session = await platform.game_store.load(existing.id)
if session is None:
await send_error(ctx, 404, "unknown join code")
return
engine = platform.registry.require(session.game_type)
try:
engine.join(session, user.sub, display_name(user))
except GameError as exc:
log.debug("join rejected for %s in game %s: %s", user.sub, session.id, exc)
await send_error(ctx, 409, str(exc))
return
await platform.game_store.save(session)
await platform.game_store.publish(session.id)
# When the last join started the match, the engine may have
# armed a deadline; queue it so it fires even if nobody ever
# connects.
await platform.scheduler.sync_deadline(session)
seat = next(i for i, s in enumerate(session.players) if s.user_sub == user.sub)
if engine.in_lobby(session):
log.info(
"%s joined game %s (seat %d, %d/%d players)",
user.sub, session.id, seat, len(session.players), engine.max_players,
)
await send_json(ctx, 200, _lobby_payload(platform, session))
return
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
@app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot",
description="Only seated players may read a game; the view is "
"personalized by the engine (e.g. hidden hands).",
tags=["games"],
responses={
200: {"description": "The personalized game state"},
401: {"description": "Authentication required"},
403: {"description": "Not a player in this game"},
404: {"description": "Game not found"},
})
@require_auth(platform.oidc)
async def get_game(ctx: HttpContext, game_id: str) -> None:
session = await platform.game_store.load(game_id)
if session is None:
await send_error(ctx, 404, "game not found")
return
user = platform.oidc.get_user(ctx)
assert user is not None
if not session.seated(user.sub):
await send_error(ctx, 403, "forbidden")
return
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
@@ -0,0 +1,23 @@
"""Liveness probe."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
if TYPE_CHECKING:
from ..mixin import Platform
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/health")
@operation(summary="Health check",
tags=["health"],
responses={200: {"description": "The service is up"}})
async def health(ctx: HttpContext) -> None:
await ctx.send_bytes(
200,
b'{"status":"ok"}',
{"content-type": ("application/json",)},
)
@@ -0,0 +1,30 @@
"""Whoami endpoint: lets the single-page app detect the login state."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import display_name, require_auth
from ..http import send_json
if TYPE_CHECKING:
from ..mixin import Platform
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/me")
@operation(summary="Current user",
description="Returns the authenticated user's identity from the "
"session; 401 when not logged in.",
tags=["auth"],
responses={
200: {"description": "The current user"},
401: {"description": "Not logged in"},
})
@require_auth(platform.oidc)
async def me(ctx: HttpContext) -> None:
user = platform.oidc.get_user(ctx)
assert user is not None # enforced by @require_auth
await send_json(ctx, 200, {"sub": user.sub, "name": display_name(user)})
@@ -0,0 +1,191 @@
"""Player statistics endpoints, served from Postgres.
Every finished match is persisted by
:func:`tavolo.platform.stats.save_match_result`. These endpoints expose a
player's own match history and a global leaderboard aggregated from the
same two tables. The game-specific outcome of each match is exposed
verbatim through the ``result`` JSON column.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
from ..auth import require_auth
from ..elo import INITIAL_RATING
from ..http import extract_query_params, send_error, send_json
from ..models import Match, MatchPlayer, PlayerRating
from ..openapi import PAGINATION_PARAMETERS
from ..pagination import CursorDecodeError, paginate, parse_cursor_params
if TYPE_CHECKING:
from ..mixin import Platform
GAME_TYPE_PARAMETER: Dict[str, Any] = {
"name": "game_type",
"in": "query",
"required": False,
"schema": {"type": "string"},
"description": "Only count matches of this game (id from GET /api/game-types).",
}
def _parse_game_type(
platform: "Platform", query_string: str
) -> Tuple[Optional[str], Optional[str]]:
"""Parse the ``game_type`` query parameter.
Returns ``(value, error)``: ``(None, None)`` when absent, ``(id, None)``
when valid, ``(None, message)`` when it names no registered game."""
values = extract_query_params(query_string).get("game_type")
if not values:
return None, None
game_type = values[0]
if platform.registry.get(game_type) is None:
return None, f"unknown game_type: {game_type!r}"
return game_type, None
async def _serialize_match(match: Match, viewer: str) -> Dict[str, Any]:
participants = await MatchPlayer.filter(match_id=match.id).order_by("seat")
return {
"id": str(match.id),
"game_type": match.game_type,
# The engine's MatchResult.summary: for scopone, the teams' final
# scores, the winner, the target score and the per-hand audit.
"result": match.result,
"started_at": match.started_at.isoformat(),
"finished_at": match.finished_at.isoformat(),
"you_won": any(p.user_sub == viewer and p.won for p in participants),
"your_elo_delta": next(
(p.elo_delta for p in participants if p.user_sub == viewer), None
),
"players": [
{
"user_sub": p.user_sub,
"display_name": p.display_name,
"seat": p.seat,
"team": p.team,
"won": p.won,
"score": p.score,
"elo_delta": p.elo_delta,
"details": p.details,
}
for p in participants
],
}
def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/me/matches")
@operation(summary="List my matches",
description="Cursor-paginated history of finished matches the "
"caller played, newest first, with the result.",
tags=["stats"],
parameters=[*PAGINATION_PARAMETERS, GAME_TYPE_PARAMETER],
responses={
200: {"description": "A page of matches"},
400: {"description": "Invalid pagination cursor or game_type"},
401: {"description": "Authentication required"},
})
@require_auth(platform.oidc)
async def my_matches(ctx: HttpContext) -> None:
try:
cursor = parse_cursor_params(ctx.query_string)
except CursorDecodeError as exc:
await send_error(ctx, 400, str(exc))
return
game_type, error = _parse_game_type(platform, ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
user = platform.oidc.get_user(ctx)
assert user is not None
queryset = Match.filter(players__user_sub=user.sub).distinct()
if game_type is not None:
queryset = queryset.filter(game_type=game_type)
matches, next_cursor = await paginate(
queryset, [("finished_at", "DESC"), ("id", "ASC")], cursor
)
results = [await _serialize_match(m, user.sub) for m in matches]
await send_json(ctx, 200, {"results": results, "next_cursor": next_cursor})
@app.GET("/api/leaderboard")
@operation(summary="Global leaderboard",
description="Elo rating, aggregated wins, matches played and "
"points for every player with at least one "
"finished match. Sorted by Elo rating (the rating "
"for the requested game_type, or the default game "
"when the filter is absent).",
tags=["stats"],
parameters=[GAME_TYPE_PARAMETER],
responses={
200: {"description": "The leaderboard"},
400: {"description": "Unknown game_type"},
})
async def leaderboard(ctx: HttpContext) -> None:
game_type, error = _parse_game_type(platform, ctx.query_string)
if error is not None:
await send_error(ctx, 400, error)
return
queryset = MatchPlayer.all()
if game_type is not None:
queryset = queryset.filter(match__game_type=game_type)
rows = await queryset
# Ratings are per game type; without a filter show the default
# game's.
rating_game = game_type or platform.registry.default.id
rating_rows = await PlayerRating.filter(game_type=rating_game)
ratings = {row.user_sub: row.rating for row in rating_rows}
aggregate: Dict[str, Dict[str, Any]] = {}
for row in rows:
entry = aggregate.setdefault(
row.user_sub,
{
"user_sub": row.user_sub,
"display_name": row.display_name,
"matches": 0,
"wins": 0,
"points": 0,
"elo": ratings.get(row.user_sub, INITIAL_RATING),
},
)
entry["matches"] += 1
entry["wins"] += 1 if row.won else 0
entry["points"] += row.score
# Keep the most recent display name seen.
entry["display_name"] = row.display_name
ranking: List[Dict[str, Any]] = sorted(
aggregate.values(),
key=lambda e: (e["elo"], e["wins"], e["points"], -e["matches"]),
reverse=True,
)
await send_json(ctx, 200, {"results": ranking})
@app.GET("/api/me/ratings")
@operation(summary="My Elo ratings",
description="The caller's current Elo rating for every game "
"type they have played.",
tags=["stats"],
responses={
200: {"description": "The caller's ratings"},
401: {"description": "Authentication required"},
})
@require_auth(platform.oidc)
async def my_ratings(ctx: HttpContext) -> None:
user = platform.oidc.get_user(ctx)
assert user is not None
rows = await PlayerRating.filter(user_sub=user.sub).order_by("game_type")
await send_json(ctx, 200, {
"results": [
{
"game_type": row.game_type,
"rating": row.rating,
"matches_played": row.matches_played,
}
for row in rows
]
})
@@ -0,0 +1,110 @@
"""Copy finished match results from the live store into Postgres.
Called once when a session reaches the finished state (guarded by the
``stats_saved`` flag on the session). The write is transactional so a
match never appears with only some of its players. The same transaction
also updates the participants' Elo ratings (see
:mod:`tavolo.platform.elo`).
This module is game-independent: everything it persists comes from the
engine's :class:`~tavolo.platform.engine.MatchResult`.
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import Dict, List
from tortoise.transactions import in_transaction
from .elo import match_delta
from .engine import GameEngine, GameSession
log = getLogger(__name__)
async def apply_elo(
game_type: str, teams: List[List[str]], winner_team: int
) -> Dict[str, int]:
"""Update the Elo ratings of ``teams`` for ``game_type``.
``teams`` groups the participants' subs into the two opposing sides;
``winner_team`` is the index of the winning side. Ratings are read
from (and written back to) the ``player_rating`` table; unrated
players start at the initial rating. Returns the per-player delta.
Must be called inside a transaction.
"""
if len(teams) != 2:
raise ValueError("Elo rating requires exactly two teams")
from .models import PlayerRating
ratings: Dict[str, "PlayerRating"] = {}
for subs in teams:
for sub in subs:
rating = await PlayerRating.get_or_none(
user_sub=sub, game_type=game_type
)
if rating is None:
rating = await PlayerRating.create(
id=uuid.uuid4(), user_sub=sub, game_type=game_type
)
ratings[sub] = rating
delta_a = match_delta(
[ratings[sub].rating for sub in teams[0]],
[ratings[sub].rating for sub in teams[1]],
winner_team,
)
deltas: Dict[str, int] = {
**{sub: delta_a for sub in teams[0]},
**{sub: -delta_a for sub in teams[1]},
}
for sub, delta in deltas.items():
rating = ratings[sub]
rating.rating += delta
rating.matches_played += 1
await rating.save()
return deltas
async def save_match_result(session: GameSession, engine: GameEngine) -> None:
"""Persist ``session`` to Postgres if it is finished and not yet saved."""
if session.stats_saved or not engine.is_finished(session):
return
result = engine.result(session)
from .models import Match, MatchPlayer
started_at = session.created_at or datetime.now(timezone.utc)
finished_at = session.finished_at or datetime.now(timezone.utc)
display_names = {seat.user_sub: seat.display_name for seat in session.players}
async with in_transaction():
match = await Match.create(
id=uuid.uuid4(),
game_type=session.game_type,
started_at=started_at,
finished_at=finished_at,
result=result.summary,
)
deltas = await apply_elo(session.game_type, result.teams, result.winner_team)
for player in result.players:
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=player.user_sub,
display_name=display_names.get(player.user_sub, player.user_sub),
seat=player.seat,
team=player.team,
won=player.won,
score=player.score,
elo_delta=deltas[player.user_sub],
details=player.details,
)
session.stats_saved = True
log.info(
"match result persisted: game %s (%s), team %d of %d won",
session.id,
session.game_type,
result.winner_team,
len(result.teams),
)
@@ -0,0 +1,313 @@
"""Persistence for live games.
Game sessions are 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.platform.models`); Redis keeps serving the
finished session 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.
The store is game-agnostic: a session is serialized as a platform-owned
envelope (id, join code, seats, timestamps) plus an opaque ``state``
blob produced by the game's engine (resolved through the
:class:`~tavolo.platform.registry.GameRegistry` both stores are
constructed with).
Concurrency is handled with a per-game lock so two simultaneous actions
cannot interleave. State changes are broadcast on a per-game pub/sub
channel as a simple "something changed" signal; every open websocket
reloads the session and renders the personalized view. Publishing only a
signal (never the state) means updated state reaches connections on
every worker without leaking hidden information into the channel.
Timeouts are driven by a shared delayed-deadline queue: producers
enqueue an opaque ``member`` string with a due timestamp, and a consumer
on every worker polls for due entries (see
:mod:`tavolo.platform.deadlines`). Delivery is at-least-once — entries
are removed only after they are processed — so a worker dying
mid-processing cannot lose a deadline; engines revalidate entries
against the live state, which makes duplicate deliveries harmless.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from abc import ABC, abstractmethod
from datetime import datetime
from logging import getLogger
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
from redis.asyncio import Redis
from .engine import GameSession, Seat
from .registry import GameRegistry
log = getLogger(__name__)
GAME_KEY_PREFIX = "tavolo:game:"
CODE_KEY_PREFIX = "tavolo:code:"
CHANNEL_PREFIX = "tavolo:game:"
DEADLINES_KEY = "tavolo:deadlines"
# Sentinel pushed into in-memory subscriber queues to signal a change.
_BUMP = b"update"
def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
return value.isoformat() if value is not None else None
def _dt_from_json(value: Any) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(str(value))
except ValueError:
return None
def session_to_json(session: GameSession, registry: GameRegistry) -> Dict[str, Any]:
"""Serialize a session: platform envelope plus the engine's state blob."""
engine = registry.require(session.game_type)
return {
"id": session.id,
"game_type": session.game_type,
"join_code": session.join_code,
"creator_sub": session.creator_sub,
"players": [seat.to_json() for seat in session.players],
"created_at": _dt_to_json(session.created_at),
"finished_at": _dt_to_json(session.finished_at),
"stats_saved": session.stats_saved,
"state": engine.state_to_json(session.state),
}
def session_from_json(data: Mapping[str, Any], registry: GameRegistry) -> GameSession:
"""Rebuild a session, delegating the state blob to its game engine."""
game_type = str(data["game_type"])
engine = registry.require(game_type)
return GameSession(
id=str(data["id"]),
game_type=game_type,
join_code=str(data["join_code"]),
creator_sub=str(data.get("creator_sub", "")),
players=[Seat.from_json(p) for p in data.get("players", [])],
created_at=_dt_from_json(data.get("created_at")),
finished_at=_dt_from_json(data.get("finished_at")),
stats_saved=bool(data.get("stats_saved", False)),
state=engine.state_from_json(data.get("state") or {}),
)
class GameStore(ABC):
"""Abstract persistence + notification layer for live games."""
@abstractmethod
async def load(self, game_id: str) -> Optional[GameSession]:
"""Return the live session for ``game_id`` or ``None``."""
@abstractmethod
async def save(self, session: GameSession) -> None:
"""Persist ``session``, refreshing its TTL and code index."""
@abstractmethod
async def find_by_code(self, code: str) -> Optional[GameSession]:
"""Return the live session 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 session of ``game_id`` changed."""
@abstractmethod
async def add_deadline(self, member: str, due_at: float) -> None:
"""Enqueue ``member`` to fire at ``due_at`` (epoch seconds).
Idempotent for identical members: re-adding an existing member only
updates its due time.
"""
@abstractmethod
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
"""Return up to ``limit`` enqueued members due at or before ``now``."""
@abstractmethod
async def next_deadline(self) -> Optional[float]:
"""Return the earliest pending due time (epoch seconds), if any."""
@abstractmethod
async def remove_deadline(self, member: str) -> None:
"""Remove ``member`` from the queue; a no-op when absent."""
def _channel(game_id: str) -> str:
return f"{CHANNEL_PREFIX}{game_id}:events"
class RedisGameStore(GameStore):
def __init__(self, redis: Redis, registry: GameRegistry, ttl_seconds: int = 86400) -> None:
self._redis = redis
self._registry = registry
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[GameSession]:
raw = await self._redis.get(f"{GAME_KEY_PREFIX}{game_id}")
if raw is None:
log.debug("redis load %s: miss", game_id)
return None
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
log.debug("redis load %s: hit", game_id)
return session_from_json(json.loads(raw), self._registry)
async def save(self, session: GameSession) -> None:
payload = json.dumps(session_to_json(session, self._registry))
async with self._redis.pipeline(transaction=True) as pipe:
pipe.set(f"{GAME_KEY_PREFIX}{session.id}", payload, ex=self._ttl)
pipe.set(f"{CODE_KEY_PREFIX}{session.join_code}", session.id, ex=self._ttl)
await pipe.execute()
log.debug("redis save %s (game %s, ttl %ds)", session.id, session.game_type, self._ttl)
async def find_by_code(self, code: str) -> Optional[GameSession]:
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")
log.debug("redis publish %s", game_id)
async def add_deadline(self, member: str, due_at: float) -> None:
await self._redis.zadd(DEADLINES_KEY, {member: due_at})
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
members = cast(
list,
await self._redis.zrangebyscore(
DEADLINES_KEY, "-inf", now, start=0, num=limit
),
)
return [m.decode("utf-8") if isinstance(m, bytes) else m for m in members]
async def next_deadline(self) -> Optional[float]:
earliest = await self._redis.zrange(DEADLINES_KEY, 0, 0, withscores=True)
return float(earliest[0][1]) if earliest else None
async def remove_deadline(self, member: str) -> None:
await self._redis.zrem(DEADLINES_KEY, member)
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, registry: GameRegistry) -> None:
self._registry = registry
self._games: Dict[str, str] = {}
self._codes: Dict[str, str] = {}
self._locks: Dict[str, asyncio.Lock] = {}
self._subscribers: Dict[str, Set[asyncio.Queue]] = {}
self._deadlines: Dict[str, float] = {}
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[GameSession]:
raw = self._games.get(game_id)
if raw is None:
return None
return session_from_json(json.loads(raw), self._registry)
async def save(self, session: GameSession) -> None:
self._games[session.id] = json.dumps(session_to_json(session, self._registry))
self._codes[session.join_code] = session.id
async def find_by_code(self, code: str) -> Optional[GameSession]:
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 add_deadline(self, member: str, due_at: float) -> None:
self._deadlines[member] = due_at
async def due_deadlines(self, now: float, limit: int = 32) -> List[str]:
due = [m for m, due_at in self._deadlines.items() if due_at <= now]
due.sort(key=self._deadlines.__getitem__)
return due[:limit]
async def next_deadline(self) -> Optional[float]:
return min(self._deadlines.values(), default=None)
async def remove_deadline(self, member: str) -> None:
self._deadlines.pop(member, None)
async def _queue_events(queue: asyncio.Queue) -> AsyncIterator[None]:
while True:
await queue.get()
yield None
@@ -0,0 +1,143 @@
"""A :class:`~kaya.core.KayaMixin` that drives the TortoiseORM lifecycle.
kaya calls ``KayaMixin.setup`` / ``shutdown`` synchronously from inside a
running event loop. Tortoise 1.1.7 binds database connections to a
:class:`~tortoise.context.TortoiseContext` looked up via a contextvar, and
kaya dispatches each HTTP request (and WebSocket connection) as a separate
``loop.create_task``, so a context set by an early request does not
automatically reach later requests.
This mixin therefore:
1. Lazily builds a :class:`TortoiseContext` for the active event loop
(rebuilding it if the running loop changes, which happens in tests that
use a fresh ``asyncio.run`` per test).
2. Per HTTP request, binds that context to the current task via the
``_current_context`` contextvar so the handler — running in the same
task as the ``before_request`` hook — sees an active context.
3. Binds the same context at the start of every WebSocket connection
(``before_websocket`` hook), because the match-result write happens at
the end of a WebSocket match. The long-lived connection task keeps the
context for its whole lifetime.
It deliberately avoids the global-fallback singleton
(``_enable_global_fallback``), which Tortoise only allows to be set once
per process and would therefore break across event loops.
Schema management is split by backend: in-memory sqlite databases (the
test suite) get ``generate_schemas`` on every fresh context; Postgres
schemas are owned by aerich migrations and must be applied externally
(``aerich upgrade``, run by the ``db-migrate`` compose service) before
the app serves requests.
"""
from __future__ import annotations
from asyncio import AbstractEventLoop, get_running_loop
from logging import getLogger
from typing import AbstractSet, Optional, Sequence
from urllib.parse import urlsplit, urlunsplit
from kaya.core import HttpContext, KayaApp, KayaMixin, WebSocket
from tortoise.context import TortoiseContext, _current_context
log = getLogger(__name__)
def _safe_url(database_url: str) -> str:
"""The database URL with any credentials stripped, for logging."""
parts = urlsplit(database_url)
host = parts.hostname or ""
try:
if parts.port:
host = f"{host}:{parts.port}"
except ValueError:
# Non-numeric netloc (e.g. sqlite://:memory:): keep the host only.
pass
return urlunsplit((parts.scheme, host, parts.path, "", ""))
class TortoiseMixin(KayaMixin):
"""Initialize and tear down a per-loop :class:`TortoiseContext`."""
def __init__(self,
database_url: str,
models_modules: Sequence[str],
skip_paths: AbstractSet[str] = frozenset({"/api/health"})) -> None:
self._database_url = database_url
self._models_modules = list(models_modules)
self._skip_paths = skip_paths
self._ctx: "Optional[TortoiseContext]" = None
self._init_loop: "Optional[AbstractEventLoop]" = None
def apply(self, app: KayaApp) -> None:
app.add_before_request_hook(self._ensure_context)
app.add_before_websocket_hook(self._ensure_ws_context)
def setup(self, loop: AbstractEventLoop) -> None:
pass
def shutdown(self, loop: AbstractEventLoop) -> None:
if self._init_loop is loop and self._ctx is not None:
log.info("closing database connections")
loop.create_task(self._ctx.close_connections())
self._ctx = None
self._init_loop = None
async def aclose(self) -> None:
"""Close the current context's connections and forget it.
Must be called from the event loop that owns the context. When a
loop goes away without this, its aiosqlite connections are orphaned;
their non-daemon worker threads then block interpreter shutdown
forever. The test suite calls this at the end of every test because
each test runs in a fresh event loop.
"""
ctx = self._ctx
self._ctx = None
self._init_loop = None
if ctx is not None:
log.info("closing database connections")
await ctx.close_connections()
async def _build_context(self) -> TortoiseContext:
ctx = TortoiseContext()
with ctx:
await ctx.init(
db_url=self._database_url,
modules={"models": self._models_modules},
)
log.info("database context initialized (%s)", _safe_url(self._database_url))
# Schema creation is only done for sqlite (in-memory test
# databases). Postgres schemas are managed by aerich migrations
# (applied by the db-migrate compose service / `aerich upgrade`).
if self._database_url.startswith("sqlite"):
await ctx.generate_schemas()
log.info("sqlite schemas generated")
return ctx
async def _bind(self) -> None:
loop = get_running_loop()
if self._init_loop is not loop:
if self._ctx is not None:
# A previous test loop went away; drop its context.
self._ctx = None
log.debug("building a Tortoise context for a new event loop")
self._ctx = await self._build_context()
self._init_loop = loop
assert self._ctx is not None
_current_context.set(self._ctx)
async def _ensure_context(self, ctx: HttpContext):
if ctx.path in self._skip_paths:
return None
# Only API endpoints touch the database: auth callbacks, websocket
# handshakes (handled by _ensure_ws_context) and the static SPA
# catch-all must not pay for a Tortoise context.
if not ctx.path.startswith("/api/"):
return None
await self._bind()
return None
async def _ensure_ws_context(self, ws: WebSocket):
await self._bind()
return None
@@ -0,0 +1,210 @@
"""WebSocket endpoint for live play.
Clients connect to ``/ws/games/{game_id}`` using their session cookie
(the OIDC login stores the user in the session, which the session mixin
loads onto the websocket). Only seated players are accepted.
The platform owns the connection lifecycle — authentication, seat
check, the subscription/publish fan-out and the message envelope — and
delegates the game-specific actions to the engine registered for the
session's ``game_type``.
Protocol
--------
Server -> client messages are JSON objects with a ``type``:
* ``state`` — the personalized game view (the engine's
:meth:`~tavolo.platform.engine.GameEngine.view_for` output merged with
the session envelope: ``id``, ``join_code``, ``game_type``).
* ``game_over`` — sent once when the match ends, carrying the engine's
:meth:`~tavolo.platform.engine.GameEngine.game_over_view` fields.
* ``error`` — a rejected action or malformed message.
Client -> server messages are JSON objects::
{"action": "state"} # platform: resend the view
{"action": "<game action>", ...fields} # dispatched to the engine
For scopone scientifico the game actions are ``play`` (with ``card``
and optionally ``capture``) and ``ack`` — see
:mod:`tavolo.scopone.plugin`. Because the whole message (minus
``action``) is handed to the engine as the payload, games define their
own fields freely.
Mutations run under the per-game lock; after a successful action the new
session is saved and a change signal is published. Every connected
websocket is subscribed to that signal and re-renders the state, so all
players see the move immediately (and consistently across workers).
Timeouts do not depend on anyone being connected: they are driven by
the absolute deadlines the engine declares, via the shared deadline
queue drained by a consumer on every worker (see
:mod:`tavolo.platform.deadlines`). A disconnected or idle player
therefore cannot stall the match, and a worker dying cannot either.
"""
from __future__ import annotations
import asyncio
import json
from contextlib import suppress
from logging import getLogger
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
from kaya.core import KayaApp, WebSocket
from . import auth
from .engine import GameSession
from .errors import GameError
if TYPE_CHECKING: # avoid the import cycle: mixin imports this module
from .mixin import Platform
log = getLogger(__name__)
Send = Callable[[Dict[str, Any]], Awaitable[None]]
def _error(message: str, code: str = "invalid") -> Dict[str, Any]:
return {"type": "error", "code": code, "message": message}
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
return {
"type": "state",
"game": {
"id": session.id,
"join_code": session.join_code,
"game_type": session.game_type,
**engine.view_for(session, sub),
},
}
def register(app: KayaApp, platform: "Platform") -> None:
"""Register the live-play websocket endpoint on ``app``."""
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> None:
user = auth.get_ws_user(ws)
if user is None:
log.debug("websocket %s rejected: no authenticated user", game_id)
await ws.close(4401)
return
session = await platform.game_store.load(game_id)
if session is None:
log.debug("websocket rejected: unknown game %s", game_id)
await ws.close(4404)
return
if not session.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await ws.close(4403)
return
await ws.accept()
log.info("%s connected to game %s", user.sub, game_id)
send_lock = asyncio.Lock()
async def send(payload: Dict[str, Any]) -> None:
async with send_lock:
await ws.send_text(json.dumps(payload))
await send(_state_message(platform, session, user.sub))
# Backstop: make sure the current deadline is queued even if its
# entry was lost (e.g. the queue was flushed while the game lived
# on thanks to its sliding TTL).
await platform.scheduler.sync_deadline(session)
async with platform.game_store.subscribe(game_id) as events:
forward = asyncio.create_task(
_forward(platform, events, game_id, user.sub, send)
)
try:
async for message in ws:
if message.kind == "close":
break
if message.kind != "text" or not isinstance(message.data, str):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(platform, send, game_id, user.sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
await forward
log.debug("%s disconnected from game %s", user.sub, game_id)
async def _forward(
platform: "Platform",
events,
game_id: str,
sub: str,
send: Send,
) -> None:
async for _ in events:
session = await platform.game_store.load(game_id)
if session is None:
return
engine = platform.registry.require(session.game_type)
await send(_state_message(platform, session, sub))
if engine.is_finished(session):
await send(
{
"type": "game_over",
**engine.game_over_view(session, sub),
}
)
return
async def _handle_message(
platform: "Platform", send: Send, game_id: str, sub: str, raw: str
) -> None:
try:
data = json.loads(raw)
except (ValueError, TypeError):
log.debug("game %s: malformed message from %s (not JSON)", game_id, sub)
await send(_error("invalid JSON"))
return
if not isinstance(data, dict):
log.debug("game %s: malformed message from %s (not an object)", game_id, sub)
await send(_error("message must be a JSON object"))
return
action = data.get("action")
if action in ("state", "sync"):
session = await platform.game_store.load(game_id)
if session is not None:
await send(_state_message(platform, session, sub))
return
if not isinstance(action, str):
await send(_error(f"unknown action: {action!r}"))
return
await _handle_action(platform, send, game_id, sub, action, data)
async def _handle_action(
platform: "Platform",
send: Send,
game_id: str,
sub: str,
action: str,
data: Dict[str, Any],
) -> None:
async with platform.game_store.lock(game_id):
session = await platform.game_store.load(game_id)
if session is None:
await send(_error("game not found", code="not_found"))
return
engine = platform.registry.require(session.game_type)
payload = {key: value for key, value in data.items() if key != "action"}
try:
engine.handle_action(session, sub, action, payload)
except GameError as exc:
log.debug("game %s: rejected %r by %s: %s", game_id, action, sub, exc)
await send(_error(str(exc), code=exc.code))
return
log.debug("game %s: %s performed %r", game_id, sub, action)
await platform.scheduler.finalize_mutation(session)
@@ -0,0 +1,348 @@
"""Shared fixtures for the tavolo-platform test suite.
The centerpiece is :class:`DummyEngine`: a tiny two-player game
implementing the platform's
:class:`~tavolo.platform.engine.GameEngine` contract, so every platform
behaviour (lobby, store, websockets, deadlines, stats) is exercised
without importing any real game. Its rules: the first player to reach
``target`` plays wins the match.
:func:`make_platform` builds a throwaway :class:`~kaya.core.KayaApp`
wired with in-memory stores, a dummy OIDC mixin (patched per test) and
a sqlite :class:`~tavolo.platform.tortoise_mixin.TortoiseMixin`, so
tests construct their own app instead of importing a global one.
"""
from __future__ import annotations
import asyncio
import contextlib
import unittest.mock as _mock
from datetime import datetime, timedelta, timezone
from functools import wraps
from typing import Any, Callable, Coroutine, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
from kaya.core import KayaApp
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCUser
from kaya.openapi import OpenAPIMixin
from kaya.session import InMemorySessionStore, SessionMixin
from tavolo.platform import (
AlreadyJoined,
Deadline,
GameEngine,
GameError,
GameFinished,
GameNotStarted,
GameSession,
IllegalMove,
LobbyFull,
MatchResult,
NotYourTurn,
Platform,
PlatformMixin,
PlayerResult,
Seat,
)
from tavolo.platform.auth import get_ws_user # noqa: F401 (re-exported for patching)
from tavolo.platform.deadlines import DeadlineScheduler
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import InMemoryGameStore
from tavolo.platform.tortoise_mixin import TortoiseMixin
class DummyEngine(GameEngine):
"""A two-player toy game: first to ``target`` plays wins.
Team labels are "A"/"B" (one player per team) so Elo paths are
exercised too. A ``deadline_in_seconds`` creation option arms a
``tick`` deadline that plays for the first player when it fires.
"""
id = "dummy"
name = "Dummy game"
description = "A two-player toy game for testing the platform."
min_players = 2
max_players = 2
options_schema = {
"type": "object",
"properties": {
"target": {
"type": "integer",
"minimum": 1,
"default": 3,
"description": "Plays needed to win the match.",
},
"deadline_in_seconds": {
"type": "number",
"description": "Arm a tick deadline this far in the future.",
},
},
}
def create(self, session: GameSession, options: Mapping[str, Any]) -> None:
target = options.get("target", 3)
if isinstance(target, bool) or not isinstance(target, int) or target < 1:
raise IllegalMove("target must be a positive integer")
# The creator takes team A.
creator = session.players[0]
session.players[0] = Seat(
user_sub=creator.user_sub,
display_name=creator.display_name,
team="A",
)
session.state = {
"target": target,
"plays": [],
"started": False,
"finished": False,
"winner": None,
"deadline_in_seconds": options.get("deadline_in_seconds"),
}
def join(self, session: GameSession, user_sub: str, display_name: str) -> None:
state = session.state
if state["started"]:
raise GameNotStarted("game has already started")
if session.seated(user_sub):
raise AlreadyJoined("already joined this game")
if len(session.players) >= 2:
raise LobbyFull("game is full")
session.players.append(
Seat(
user_sub=user_sub,
display_name=display_name,
team="A" if not session.players else "B",
)
)
if len(session.players) == 2:
state["started"] = True
def handle_action(
self, session: GameSession, user_sub: str, action: str, payload: Mapping[str, Any]
) -> None:
state = session.state
if not state["started"]:
raise GameNotStarted("the game has not started yet")
if state["finished"]:
raise GameFinished("the match is over")
if not session.seated(user_sub):
raise NotYourTurn("you are not seated in this game")
if action != "play":
raise IllegalMove(f"unknown action: {action!r}")
self._play(session, user_sub)
def _play(self, session: GameSession, user_sub: str) -> None:
state = session.state
state["plays"].append(user_sub)
if len(state["plays"]) >= state["target"]:
state["finished"] = True
state["winner"] = user_sub
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
state = session.state
return {
"started": state["started"],
"finished": state["finished"],
"winner": state["winner"],
"plays": len(state["plays"]),
"target": state["target"],
"viewer_seated": session.seated(user_sub),
}
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
return {
"phase": "playing" if session.state["started"] else "lobby",
"target": session.state["target"],
}
def in_lobby(self, session: GameSession) -> bool:
return not session.state["started"]
def is_finished(self, session: GameSession) -> bool:
return bool(session.state["finished"])
def result(self, session: GameSession) -> MatchResult:
state = session.state
winner = state["winner"]
if winner is None:
raise GameError("no result: the match is not finished")
subs = [seat.user_sub for seat in session.players]
plays: List[str] = state["plays"]
return MatchResult(
teams=[[subs[0]], [subs[1]]],
winner_team=subs.index(winner),
players=[
PlayerResult(
user_sub=seat.user_sub,
seat=index,
won=seat.user_sub == winner,
team=seat.team,
score=float(plays.count(seat.user_sub)),
details={"plays": plays.count(seat.user_sub)},
)
for index, seat in enumerate(session.players)
],
summary={
"target": state["target"],
"plays": len(plays),
"winner": winner,
},
)
def state_to_json(self, state: Any) -> Dict[str, Any]:
return dict(state)
def state_from_json(self, data: Mapping[str, Any]) -> Any:
return dict(data)
def next_deadline(self, session: GameSession) -> Optional[Deadline]:
seconds = session.state.get("deadline_in_seconds")
if (
seconds is None
or not session.state["started"]
or session.state["finished"]
):
return None
due_at = datetime.now(timezone.utc) + timedelta(seconds=float(seconds))
return Deadline(
kind="tick",
due_at=due_at,
token=f"tick:{len(session.state['plays'])}",
)
def fire_deadline(self, session: GameSession, kind: str, token: str) -> None:
current = self.next_deadline(session)
if current is None or current.kind != kind or current.token != token:
raise GameError("stale deadline")
# The house plays for the first player.
self._play(session, session.players[0].user_sub)
def make_platform(
engines: Sequence[GameEngine] = (DummyEngine(),),
) -> Tuple[KayaApp, Platform, TortoiseMixin]:
"""Build a throwaway app + platform wired with in-memory stores."""
registry = GameRegistry(engines)
game_store = InMemoryGameStore(registry)
session_mixin = SessionMixin(InMemorySessionStore())
oidc_mixin = OIDCMixin(
OIDCConfig(
issuer="http://localhost:8180/tavolo",
client_id="tavolo",
client_secret=None,
redirect_uri="http://localhost:8080/auth/callback",
post_login_redirect="/",
post_logout_redirect="/",
),
session=session_mixin,
)
tortoise_mixin = TortoiseMixin(
database_url="sqlite://:memory:",
models_modules=["tavolo.platform.models"],
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
)
openapi_mixin = OpenAPIMixin(
title="tavolo-platform-tests",
version="0.1.0",
description="test app",
spec_path="/api/openapi.json",
docs_path="/api/docs",
)
scheduler = DeadlineScheduler(game_store, registry, heartbeat_ms=50)
platform = Platform(
registry=registry,
game_store=game_store,
scheduler=scheduler,
oidc=oidc_mixin,
)
app = KayaApp(
mixins=[
session_mixin,
oidc_mixin,
tortoise_mixin,
openapi_mixin,
PlatformMixin(platform),
]
)
_tortoise_mixins.append(tortoise_mixin)
_schedulers.append(scheduler)
return app, platform, tortoise_mixin
_tortoise_mixins: List[TortoiseMixin] = []
_schedulers: List[DeadlineScheduler] = []
def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]:
"""Like ``pwo.async_test`` (fresh loop per test), but tear down the
platform pieces afterwards: close Tortoise contexts (otherwise
orphaned aiosqlite threads block interpreter shutdown) and stop
deadline consumers."""
@wraps(coro)
def wrapper(*args: Any, **kwargs: Any) -> None:
async def run() -> None:
loop = asyncio.get_running_loop()
try:
await coro(*args, **kwargs)
finally:
for scheduler in _schedulers:
scheduler.stop_consumer(loop)
_schedulers.clear()
for mixin in _tortoise_mixins:
await mixin.aclose()
_tortoise_mixins.clear()
with asyncio.Runner() as runner:
runner.run(run())
return wrapper
async def use_db(tortoise_mixin: TortoiseMixin):
"""Bind the app's Tortoise context for this loop, for seeding rows."""
from tortoise.context import TortoiseContext
await tortoise_mixin._bind()
ctx: Optional[TortoiseContext] = tortoise_mixin._ctx
assert ctx is not None
return ctx
def make_user(sub: str, name: Optional[str] = None) -> OIDCUser:
return OIDCUser({"sub": sub, "preferred_username": name or sub})
@contextlib.contextmanager
def oidc_user(oidc: OIDCMixin, sub: str, name: Optional[str] = None) -> Iterator[OIDCUser]:
"""Context manager: patch ``oidc.get_user`` to return this user."""
user = make_user(sub, name)
patcher = _mock.patch.object(oidc, "get_user", return_value=user)
patcher.start()
try:
yield user
finally:
patcher.stop()
@contextlib.contextmanager
def ws_users(users: Sequence[OIDCUser]) -> Iterator[None]:
"""Context manager: patch ``auth.get_ws_user`` to hand out ``users``
one per websocket connection, in order. Once exhausted it keeps
returning the last user."""
from tavolo.platform import auth
remaining = list(users)
last = remaining[-1] if remaining else None
def _next(_ws):
if remaining:
return remaining.pop(0)
return last
patcher = _mock.patch.object(auth, "get_ws_user", side_effect=_next)
patcher.start()
try:
yield
finally:
patcher.stop()
@@ -0,0 +1,187 @@
"""Deadline-scheduler tests, driven by the DummyEngine.
Timeouts must be driven by the persisted deadlines and the shared queue,
not by connected sockets: these tests seed sessions, enqueue their
deadlines and let the background consumer fire them without a single
websocket. The engine owns the meaning of each deadline; the scheduler
owns enqueueing, delivery and removal.
"""
from __future__ import annotations
import asyncio
import unittest
from typing import Any, Dict, Optional
from tavolo.platform import GameSession, Seat
from tavolo.platform.deadlines import encode
from helpers import DummyEngine, async_test, make_platform, use_db
def _started_session(
game_id: str = "dl-1",
code: str = "DL0001",
target: int = 3,
deadline_in_seconds: Optional[float] = None,
) -> GameSession:
engine = DummyEngine()
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
options: Dict[str, Any] = {"target": target}
if deadline_in_seconds is not None:
options["deadline_in_seconds"] = deadline_in_seconds
engine.create(session, options)
engine.join(session, "bob", "bob")
return session
async def _wait_for(predicate, timeout: float = 5.0):
"""Poll the store until ``predicate`` returns a truthy value."""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
result = await predicate()
if result:
return result
await asyncio.sleep(0.05)
return None
class ConnectionIndependenceTest(unittest.TestCase):
@async_test
async def test_tick_fires_with_no_connections(self) -> None:
_, platform, _ = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(deadline_in_seconds=0.05)
await store.save(session)
await scheduler.sync_deadline(session)
# Nobody ever connects: the consumer must still fire the tick,
# which plays for the first player.
result = await _wait_for(
lambda: _plays_is(store, session.id, 1),
)
self.assertIsNotNone(result, "deadline never fired")
@async_test
async def test_no_deadline_nothing_enqueued(self) -> None:
_, platform, _ = make_platform()
session = _started_session() # no deadline_in_seconds option
await platform.game_store.save(session)
await platform.scheduler.sync_deadline(session)
self.assertIsNone(await platform.game_store.next_deadline())
async def _plays_is(store, game_id: str, count: int) -> Optional[GameSession]:
session = await store.load(game_id)
if session is not None and len(session.state["plays"]) == count:
return session
return None
class ProcessDueTest(unittest.TestCase):
"""Direct ``process_due`` behaviour: engine revalidation and idempotency."""
@async_test
async def test_processing_twice_is_a_no_op(self) -> None:
# Simulates a worker dying after firing but before removing the
# entry: another worker re-delivers the same entry. The engine's
# token has moved on, so the second delivery is stale.
_, platform, _ = make_platform()
store = platform.game_store
scheduler = platform.scheduler
session = _started_session(deadline_in_seconds=3600)
await store.save(session)
deadline = DummyEngine().next_deadline(session)
assert deadline is not None
member = encode({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
})
await scheduler.process_due(member)
await scheduler.process_due(member)
result = await store.load(session.id)
assert result is not None
# Fired exactly once: one play, not two.
self.assertEqual(["alice"], result.state["plays"])
@async_test
async def test_stale_entry_is_discarded(self) -> None:
# A tick enqueued before a play landed in time: the token has
# moved, so the entry must not fire.
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
session = _started_session(deadline_in_seconds=3600)
session.state["plays"].append("alice") # a play landed in time
await store.save(session)
member = encode({
"game_id": session.id,
"kind": "tick",
"token": "tick:0", # not the live token ("tick:1")
})
await store.add_deadline(member, due_at=0.0)
await scheduler.process_due(member)
result = await store.load(session.id)
assert result is not None
self.assertEqual(["alice"], result.state["plays"])
# The entry was removed after processing.
self.assertNotIn(member, await store.due_deadlines(float("inf")))
@async_test
async def test_entry_for_expired_game_is_dropped(self) -> None:
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
member = encode({
"game_id": "dl-gone",
"kind": "tick",
"token": "tick:0",
})
await store.add_deadline(member, due_at=0.0)
await scheduler.process_due(member)
self.assertNotIn(member, await store.due_deadlines(float("inf")))
@async_test
async def test_malformed_entry_is_dropped(self) -> None:
_, platform, _ = make_platform()
scheduler = platform.scheduler
store = platform.game_store
await store.add_deadline("not json", due_at=0.0)
await scheduler.process_due("not json")
self.assertNotIn("not json", await store.due_deadlines(float("inf")))
@async_test
async def test_finished_match_is_persisted_on_tick(self) -> None:
# A tick that completes the match writes the result to Postgres.
from tavolo.platform.models import Match
_, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
scheduler = platform.scheduler
store = platform.game_store
session = _started_session(target=1, deadline_in_seconds=3600)
await store.save(session)
deadline = DummyEngine().next_deadline(session)
assert deadline is not None
member = encode({
"game_id": session.id,
"kind": deadline.kind,
"token": deadline.token,
})
with ctx:
await scheduler.process_due(member)
self.assertEqual(1, await Match.all().count())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,73 @@
"""Unit tests for the chess-style Elo math in :mod:`tavolo.platform.elo`."""
from __future__ import annotations
import unittest
from tavolo.platform.elo import (
INITIAL_RATING,
K_FACTOR,
expected_score,
match_delta,
team_rating,
)
class ExpectedScoreTest(unittest.TestCase):
def test_equal_ratings_give_even_odds(self) -> None:
self.assertAlmostEqual(0.5, expected_score(1500, 1500))
def test_higher_rating_is_favoured(self) -> None:
self.assertGreater(expected_score(1700, 1500), 0.5)
self.assertLess(expected_score(1500, 1700), 0.5)
def test_scores_sum_to_one(self) -> None:
self.assertAlmostEqual(
1.0, expected_score(1600, 1400) + expected_score(1400, 1600)
)
def test_four_hundred_points_is_ten_to_one(self) -> None:
self.assertAlmostEqual(10 / 11, expected_score(1900, 1500))
class TeamRatingTest(unittest.TestCase):
def test_mean_of_members(self) -> None:
self.assertEqual(1600, team_rating([1500, 1700]))
def test_empty_team_rejected(self) -> None:
with self.assertRaises(ValueError):
team_rating([])
class MatchDeltaTest(unittest.TestCase):
def test_equal_teams_exchange_half_k(self) -> None:
delta = match_delta([1500, 1500], [1500, 1500], winner_team=0)
self.assertEqual(K_FACTOR // 2, delta)
def test_favourite_gains_less_than_underdog(self) -> None:
favourite = match_delta([1700, 1700], [1500, 1500], winner_team=0)
underdog = match_delta([1500, 1500], [1700, 1700], winner_team=0)
self.assertGreater(underdog, favourite)
self.assertGreater(favourite, 0)
def test_losing_side_loses_the_winners_gain(self) -> None:
# Zero-sum: the losers' delta is the negation of the winners'.
win = match_delta([1600, 1500], [1400, 1500], winner_team=0)
loss = match_delta([1600, 1500], [1400, 1500], winner_team=1)
self.assertEqual(-win, -abs(win)) # winner gains
# Losing the same pairing costs K * E, winning gains K * (1 - E);
# both are computed from the same expectation, so loss = win - K.
self.assertEqual(win - K_FACTOR, loss)
def test_team_average_decides_not_individual_ratings(self) -> None:
# [1700, 1300] averages 1500, same as [1500, 1500].
mixed = match_delta([1700, 1300], [1500, 1500], winner_team=0)
even = match_delta([1500, 1500], [1500, 1500], winner_team=0)
self.assertEqual(even, mixed)
def test_initial_rating_constant(self) -> None:
self.assertEqual(1500, INITIAL_RATING)
self.assertEqual(32, K_FACTOR)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,204 @@
"""Game lobby route tests via kaya's ASGI transport, on the DummyEngine."""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from helpers import async_test, make_platform, oidc_user
class GamesRouteTest(unittest.TestCase):
@async_test
async def test_create_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.post("/api/games", json={})
self.assertEqual(401, response.status_code)
self.assertEqual({"error": "unauthenticated"}, response.json())
@async_test
async def test_create_and_read_lobby(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post(
"/api/games", json={"options": {"target": 5}}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("lobby", body["phase"])
self.assertEqual(1, body["seats_open"])
self.assertEqual(5, body["target"])
self.assertEqual("dummy", body["game_type"])
self.assertEqual("A", body["players"][0]["team"])
self.assertEqual(6, len(body["join_code"]))
game_id = body["id"]
with oidc_user(platform.oidc, "alice"):
snapshot = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, snapshot.status_code)
snap = snapshot.json()
self.assertEqual(game_id, snap["id"])
self.assertEqual("dummy", snap["game_type"])
self.assertTrue(snap["viewer_seated"])
with oidc_user(platform.oidc, "mallory"):
forbidden = await client.get(f"/api/games/{game_id}")
self.assertEqual(403, forbidden.status_code)
@async_test
async def test_join_starts_game(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
with oidc_user(platform.oidc, "bob"):
started = await client.post("/api/games/join", json={"code": code})
self.assertEqual(200, started.status_code)
state = started.json()
# The second join started the match, so the response is the
# personalized view rather than the lobby payload.
self.assertTrue(state["started"])
self.assertFalse(state["finished"])
self.assertEqual(0, state["plays"])
self.assertTrue(state["viewer_seated"])
@async_test
async def test_create_rejects_bad_options(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
zero = await client.post(
"/api/games", json={"options": {"target": 0}}
)
text = await client.post(
"/api/games", json={"options": {"target": "three"}}
)
non_object = await client.post(
"/api/games", json={"options": [1, 2]}
)
self.assertEqual(400, zero.status_code)
self.assertEqual(400, text.status_code)
self.assertEqual(400, non_object.status_code)
@async_test
async def test_join_errors(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
code = created.json()["join_code"]
with oidc_user(platform.oidc, "bob"):
unknown = await client.post("/api/games/join", json={"code": "ZZZZZZ"})
self.assertEqual(404, unknown.status_code)
with oidc_user(platform.oidc, "alice"):
duplicate = await client.post("/api/games/join", json={"code": code})
self.assertEqual(409, duplicate.status_code)
with oidc_user(platform.oidc, "bob"):
missing = await client.post("/api/games/join", json={})
self.assertEqual(400, missing.status_code)
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
with oidc_user(platform.oidc, "erin"):
late = await client.post("/api/games/join", json={"code": code})
self.assertEqual(409, late.status_code)
@async_test
async def test_get_unknown_game(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/games/does-not-exist")
self.assertEqual(404, response.status_code)
class GameTypesRouteTest(unittest.TestCase):
@async_test
async def test_lists_available_game_types(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/game-types")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(["dummy"], [g["id"] for g in results])
self.assertEqual("Dummy game", results[0]["name"])
self.assertTrue(results[0]["description"])
self.assertEqual(2, results[0]["min_players"])
self.assertEqual(2, results[0]["max_players"])
self.assertIn("target", results[0]["options_schema"]["properties"])
@async_test
async def test_create_defaults_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post("/api/games", json={})
self.assertEqual(201, created.status_code)
self.assertEqual("dummy", created.json()["game_type"])
@async_test
async def test_create_with_explicit_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post(
"/api/games", json={"game_type": "dummy"}
)
self.assertEqual(201, created.status_code)
body = created.json()
self.assertEqual("dummy", body["game_type"])
with oidc_user(platform.oidc, "alice"):
snapshot = await client.get(f"/api/games/{body['id']}")
self.assertEqual("dummy", snapshot.json()["game_type"])
@async_test
async def test_create_rejects_unknown_game_type(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
unknown = await client.post("/api/games", json={"game_type": "briscola"})
non_string = await client.post("/api/games", json={"game_type": 42})
self.assertEqual(400, unknown.status_code)
self.assertEqual(400, non_string.status_code)
class MeRouteTest(unittest.TestCase):
@async_test
async def test_me_authenticated(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me")
self.assertEqual(200, response.status_code)
self.assertEqual({"sub": "alice", "name": "alice"}, response.json())
@async_test
async def test_me_unauthenticated(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me")
self.assertEqual(401, response.status_code)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,392 @@
"""Match-statistics tests: Postgres persistence and the stats endpoints."""
from __future__ import annotations
import unittest
import uuid
from datetime import datetime, timezone
from httpx import ASGITransport, AsyncClient
from tavolo.platform.elo import INITIAL_RATING
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
from tavolo.platform.stats import save_match_result
from helpers import DummyEngine, async_test, make_platform, oidc_user, use_db
def _finished_session(target: int = 2):
"""A started dummy session one play short of completion."""
from tavolo.platform import GameSession, Seat
engine = DummyEngine()
session = GameSession(
id="stats-game",
game_type=engine.id,
join_code="STATS1",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine.create(session, {"target": target})
engine.join(session, "bob", "bob")
engine.handle_action(session, "alice", "play", {})
return engine, session
class SaveMatchResultTest(unittest.TestCase):
@async_test
async def test_finished_match_is_persisted_once(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
self.assertTrue(engine.is_finished(session))
with ctx:
await save_match_result(session, engine)
await save_match_result(session, engine) # idempotent
self.assertEqual(1, await Match.all().count())
self.assertEqual(2, await MatchPlayer.all().count())
match = await Match.all().first()
assert match is not None
# The game type travels from the session onto the row; the
# engine's summary is stored verbatim as the result.
self.assertEqual("dummy", match.game_type)
self.assertEqual("alice", match.result["winner"])
self.assertEqual(2, match.result["plays"])
winners = await MatchPlayer.filter(won=True)
self.assertEqual({"alice"}, {p.user_sub for p in winners})
scores = {p.user_sub: p.score for p in await MatchPlayer.all()}
self.assertEqual({"alice": 2.0, "bob": 0.0}, scores)
@async_test
async def test_finished_match_updates_elo_ratings(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
with ctx:
await save_match_result(session, engine)
ratings = {
row.user_sub: row for row in await PlayerRating.all()
}
self.assertEqual(2, len(ratings))
# Two players at 1500: winner gains K/2, loser loses it.
self.assertEqual(INITIAL_RATING + 16, ratings["alice"].rating)
self.assertEqual(1, ratings["alice"].matches_played)
self.assertEqual(INITIAL_RATING - 16, ratings["bob"].rating)
self.assertEqual(1, ratings["bob"].matches_played)
# The per-match delta is recorded on each participation row.
deltas = {
p.user_sub: p.elo_delta for p in await MatchPlayer.all()
}
self.assertEqual({"alice": 16, "bob": -16}, deltas)
@async_test
async def test_elo_ratings_accumulate_across_matches(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
from tavolo.platform import GameSession, Seat
engine2, session2 = DummyEngine(), GameSession(
id="stats-game-2",
game_type="dummy",
join_code="STATS2",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine2.create(session2, {"target": 2})
engine2.join(session2, "bob", "bob")
# Bob wins the second match.
engine2.handle_action(session2, "bob", "play", {})
engine2.handle_action(session2, "bob", "play", {})
with ctx:
await save_match_result(session, engine)
await save_match_result(session2, engine2)
ratings = {
row.user_sub: row.rating for row in await PlayerRating.all()
}
# Match 1: even teams, alice wins (+16/-16). Match 2: alice
# is now the favourite (1516 vs 1484), so losing costs 17.
self.assertEqual(INITIAL_RATING - 1, ratings["alice"])
self.assertEqual(INITIAL_RATING + 1, ratings["bob"])
bob = await PlayerRating.get(user_sub="bob")
self.assertEqual(2, bob.matches_played)
@async_test
async def test_unfinished_match_is_not_persisted(self) -> None:
_, _, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
with ctx:
await save_match_result(session, engine)
self.assertEqual(0, await Match.all().count())
async def _seed_two_matches(
tortoise_mixin, game_types: tuple = ("dummy", "dummy")
) -> None:
ctx = await use_db(tortoise_mixin)
with ctx:
for index, (winner, finished) in enumerate(
[
("alice", datetime(2026, 1, 1, 10, tzinfo=timezone.utc)),
("bob", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
]
):
match = await Match.create(
id=uuid.uuid4(),
game_type=game_types[index],
started_at=datetime(2025, 12, 31, tzinfo=timezone.utc),
finished_at=finished,
result={"winner": winner, "plays": 3 + index},
)
for seat, sub in enumerate(("alice", "bob")):
await MatchPlayer.create(
id=uuid.uuid4(),
match=match,
user_sub=sub,
display_name=sub,
seat=seat,
team="A" if seat == 0 else "B",
won=(sub == winner),
score=2.0 + index if sub == winner else 1.0,
)
class StatsRouteTest(unittest.TestCase):
@async_test
async def test_my_matches_newest_first(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
self.assertEqual(2, len(results))
self.assertEqual("bob", results[0]["result"]["winner"]) # newest first
self.assertFalse(results[0]["you_won"])
self.assertTrue(results[1]["you_won"])
self.assertEqual(2, len(results[0]["players"]))
self.assertIn("next_cursor", response.json())
@async_test
async def test_my_matches_pagination(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
first = await client.get("/api/me/matches?limit=1")
cursor = first.json()["next_cursor"]
self.assertIsNotNone(cursor)
second = await client.get(f"/api/me/matches?limit=1&cursor={cursor}")
self.assertEqual(1, len(first.json()["results"]))
self.assertEqual(1, len(second.json()["results"]))
self.assertNotEqual(
first.json()["results"][0]["id"],
second.json()["results"][0]["id"],
)
@async_test
async def test_my_matches_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/matches")
self.assertEqual(401, response.status_code)
@async_test
async def test_leaderboard_aggregates(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
by_sub = {row["user_sub"]: row for row in response.json()["results"]}
self.assertEqual(2, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"]) # alice won match 1
self.assertEqual(3.0, by_sub["alice"]["points"]) # 2.0 + 1.0
self.assertEqual(1, by_sub["bob"]["wins"]) # bob won match 2
self.assertEqual(4.0, by_sub["bob"]["points"]) # 1.0 + 3.0
# Bob leads on points after tying Alice on wins.
self.assertEqual("bob", response.json()["results"][0]["user_sub"])
@async_test
async def test_leaderboard_includes_elo_and_sorts_by_it(self) -> None:
app, _, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
# Alice outranks everyone despite Bob leading on points.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="dummy",
rating=1600,
matches_played=2,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1600, by_sub["alice"]["elo"])
# Players without a rating row report the initial rating.
self.assertEqual(INITIAL_RATING, by_sub["bob"]["elo"])
# Elo outranks wins/points.
self.assertEqual("alice", results[0]["user_sub"])
@async_test
async def test_leaderboard_elo_scoped_by_game_type(self) -> None:
app, platform, tortoise_mixin = make_platform(
engines=(DummyEngine(), SecondEngine())
)
await _seed_two_matches(tortoise_mixin)
ctx = await use_db(tortoise_mixin)
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="dummy",
rating=1516,
matches_played=1,
)
# Bob's rating in another game must not leak into the
# dummy leaderboard.
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="second",
rating=1800,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/leaderboard?game_type=dummy")
self.assertEqual(200, response.status_code)
results = response.json()["results"]
by_sub = {row["user_sub"]: row for row in results}
self.assertEqual(1516, by_sub["bob"]["elo"])
self.assertEqual(INITIAL_RATING, by_sub["alice"]["elo"])
self.assertEqual("second", platform.registry.all()[1].id)
@async_test
async def test_my_matches_include_elo_delta(self) -> None:
app, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
engine, session = _finished_session()
engine.handle_action(session, "alice", "play", {})
with ctx:
await save_match_result(session, engine)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/matches")
self.assertEqual(200, response.status_code)
players = {
p["user_sub"]: p
for p in response.json()["results"][0]["players"]
}
self.assertEqual(16, players["alice"]["elo_delta"])
self.assertEqual(-16, players["bob"]["elo_delta"])
self.assertEqual(16, response.json()["results"][0]["your_elo_delta"])
@async_test
async def test_my_ratings_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/me/ratings")
self.assertEqual(401, response.status_code)
@async_test
async def test_my_ratings_returns_only_own_rows(self) -> None:
app, platform, tortoise_mixin = make_platform()
ctx = await use_db(tortoise_mixin)
with ctx:
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="alice",
game_type="dummy",
rating=1516,
matches_played=1,
)
await PlayerRating.create(
id=uuid.uuid4(),
user_sub="bob",
game_type="dummy",
rating=1484,
matches_played=1,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
response = await client.get("/api/me/ratings")
self.assertEqual(200, response.status_code)
self.assertEqual(
[{"game_type": "dummy", "rating": 1516, "matches_played": 1}],
response.json()["results"],
)
class SecondEngine(DummyEngine):
id = "second"
name = "Second game"
class GameTypeFilterTest(unittest.TestCase):
"""Stats endpoints scope results by the match's game type."""
@async_test
async def test_my_matches_filter_by_game_type(self) -> None:
# The second seed names a game the registry does not know; rows are
# written directly, so this only exercises the SQL filter.
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
all_matches = await client.get("/api/me/matches")
scoped = await client.get("/api/me/matches?game_type=dummy")
unknown = await client.get("/api/me/matches?game_type=briscola")
self.assertEqual(2, len(all_matches.json()["results"]))
self.assertEqual(
{"dummy", "other_game"},
{m["game_type"] for m in all_matches.json()["results"]},
)
scoped_results = scoped.json()["results"]
self.assertEqual(1, len(scoped_results))
self.assertEqual("dummy", scoped_results[0]["game_type"])
self.assertEqual(400, unknown.status_code)
@async_test
async def test_leaderboard_filter_by_game_type(self) -> None:
app, platform, tortoise_mixin = make_platform()
await _seed_two_matches(tortoise_mixin, game_types=("dummy", "other_game"))
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
scoped = await client.get("/api/leaderboard?game_type=dummy")
unknown = await client.get("/api/leaderboard?game_type=briscola")
self.assertEqual(200, scoped.status_code)
by_sub = {row["user_sub"]: row for row in scoped.json()["results"]}
# Only the first match counts: one match per player, alice won.
self.assertEqual(1, by_sub["alice"]["matches"])
self.assertEqual(1, by_sub["alice"]["wins"])
self.assertEqual(0, by_sub["bob"]["wins"])
self.assertEqual(400, unknown.status_code)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,142 @@
"""In-memory game store behaviour (the Redis store shares this interface)."""
from __future__ import annotations
import asyncio
import unittest
from tavolo.platform import GameSession, Seat
from tavolo.platform.registry import GameRegistry
from tavolo.platform.store import InMemoryGameStore
from helpers import DummyEngine, async_test
def _registry() -> GameRegistry:
return GameRegistry([DummyEngine()])
def _session(game_id: str = "g1", code: str = "CODE01") -> GameSession:
engine = DummyEngine()
session = GameSession(
id=game_id,
game_type=engine.id,
join_code=code,
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="alice", team="A")],
)
engine.create(session, {"target": 5})
return session
class InMemoryGameStoreTest(unittest.TestCase):
@async_test
async def test_save_load_roundtrip(self) -> None:
store = InMemoryGameStore(_registry())
session = _session()
await store.save(session)
loaded = await store.load("g1")
self.assertIsNotNone(loaded)
assert loaded is not None
self.assertEqual("CODE01", loaded.join_code)
self.assertEqual("dummy", loaded.game_type)
self.assertEqual(5, loaded.state["target"])
self.assertEqual(["alice"], [p.user_sub for p in loaded.players])
# The loaded state is a deserialized copy, not the same object.
self.assertIsNot(loaded.state, session.state)
@async_test
async def test_unknown_game_type_rejected(self) -> None:
store = InMemoryGameStore(_registry())
session = _session()
session.game_type = "nope"
with self.assertRaises(Exception):
await store.save(session)
@async_test
async def test_load_missing_returns_none(self) -> None:
store = InMemoryGameStore(_registry())
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(_registry())
await store.save(_session())
found = await store.find_by_code("code01") # case-insensitive
self.assertIsNotNone(found)
assert found is not None
self.assertEqual("g1", found.id)
@async_test
async def test_load_returns_a_copy(self) -> None:
store = InMemoryGameStore(_registry())
await store.save(_session())
first = await store.load("g1")
assert first is not None
first.state["target"] = 999
second = await store.load("g1")
assert second is not None
self.assertEqual(5, second.state["target"])
@async_test
async def test_publish_reaches_subscriber(self) -> None:
store = InMemoryGameStore(_registry())
await store.save(_session())
received = []
async with store.subscribe("g1") as events:
await store.publish("g1")
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(_registry())
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
)
@async_test
async def test_deadline_queue(self) -> None:
store = InMemoryGameStore(_registry())
self.assertIsNone(await store.next_deadline())
self.assertEqual([], await store.due_deadlines(now=100.0))
await store.add_deadline("b", due_at=50.0)
await store.add_deadline("a", due_at=10.0)
await store.add_deadline("c", due_at=200.0)
# Re-adding an existing member only updates its due time.
await store.add_deadline("b", due_at=60.0)
self.assertEqual(10.0, await store.next_deadline())
self.assertEqual(["a"], await store.due_deadlines(now=10.0))
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
# Due entries come out in due-time order and stay queued until removed.
self.assertEqual(["a", "b"], await store.due_deadlines(now=100.0))
await store.remove_deadline("a")
await store.remove_deadline("a") # removing twice is a no-op
self.assertEqual(60.0, await store.next_deadline())
self.assertEqual(["b"], await store.due_deadlines(now=100.0))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,158 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations
import unittest
from httpx import ASGITransport, AsyncClient
from httpx_ws import WebSocketDisconnect, aconnect_ws
from httpx_ws.transport import ASGIWebSocketTransport
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
class WebSocketTest(unittest.TestCase):
async def _started_game(self, client: AsyncClient, oidc, target: int = 3) -> dict:
"""Create a game and seat both players; return the started state."""
with oidc_user(oidc, "alice"):
created = await client.post(
"/api/games", json={"options": {"target": target}}
)
code = created.json()["join_code"]
with oidc_user(oidc, "bob"):
response = await client.post("/api/games/join", json={"code": code})
return response.json()
@async_test
async def test_move_updates_all_connections(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob"), make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
first = await bob_ws.receive_json()
self.assertEqual("state", first["type"])
self.assertEqual("dummy", first["game"]["game_type"])
self.assertEqual(0, first["game"]["plays"])
self.assertEqual(game_id, first["game"]["id"])
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
alice_first = await alice_ws.receive_json()
self.assertEqual("state", alice_first["type"])
await bob_ws.send_json({"action": "play"})
bob_update = await bob_ws.receive_json()
alice_update = await alice_ws.receive_json()
for update in (bob_update, alice_update):
self.assertEqual("state", update["type"])
self.assertEqual(1, update["game"]["plays"])
@async_test
async def test_unknown_action_returns_error(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "dance"})
error = await bob_ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("illegal_move", error["code"])
@async_test
async def test_state_action_resyncs(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "state"})
resent = await bob_ws.receive_json()
self.assertEqual("state", resent["type"])
@async_test
async def test_game_over_broadcast(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
# target 1: the first play ends the match.
state = await self._started_game(client, platform.oidc, target=1)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice"), make_user("bob")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
await alice_ws.receive_json()
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as bob_ws:
await bob_ws.receive_json()
await bob_ws.send_json({"action": "play"})
# Both connections see the final state...
alice_final = await alice_ws.receive_json()
bob_final = await bob_ws.receive_json()
self.assertTrue(alice_final["game"]["finished"])
self.assertTrue(bob_final["game"]["finished"])
# ...followed by the game_over announcement.
alice_over = await alice_ws.receive_json()
bob_over = await bob_ws.receive_json()
self.assertEqual("game_over", alice_over["type"])
self.assertEqual("game_over", bob_over["type"])
@async_test
async def test_unknown_game_is_closed(self) -> None:
app, _, _ = make_platform()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/no-such-game", ws_client):
pass
self.assertEqual(4404, caught.exception.code)
@async_test
async def test_non_player_is_closed(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(4403, caught.exception.code)
@async_test
async def test_unauthenticated_is_closed(self) -> None:
app, _, _ = make_platform()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(4401, caught.exception.code)
if __name__ == "__main__":
unittest.main()