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:
@@ -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()
|
||||
Reference in New Issue
Block a user