Preview a match by join code before joining
CI / Build and push docker image (push) Successful in 1m22s
CI / Build and push docker image (push) Successful in 1m22s
GET /api/games/preview returns the lobby behind a code plus the game's name and engine-echoed options. The lobby debounces the lookup as a complete code is typed and shows the game, seated players, open seats and option badges rendered from the game's schema, disabling Join when the lobby is full or the match has already started.
This commit is contained in:
8 files changed
+580
-11
No files matched your search
@@ -191,6 +191,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
|
||||
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
|
||||
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
||||
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
||||
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
|
||||
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
||||
|
||||
@@ -201,7 +201,13 @@ class GameEngine(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||
"""Game-specific fields of the lobby payload (options echo, phase)."""
|
||||
"""Game-specific fields of the lobby payload (options echo, phase).
|
||||
|
||||
Echo the creation options under the same keys as
|
||||
:attr:`options_schema`, plus ``phase``, so clients can render a
|
||||
generic pre-join preview (``GET /api/games/preview``) from the
|
||||
schema without game-specific code.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
|
||||
@@ -24,7 +24,14 @@ 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
|
||||
from ..http import (
|
||||
JsonRequestError,
|
||||
extract_query_params,
|
||||
read_json,
|
||||
read_json_optional,
|
||||
send_error,
|
||||
send_json,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..mixin import Platform
|
||||
@@ -79,6 +86,18 @@ def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[
|
||||
}
|
||||
|
||||
|
||||
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
|
||||
"""The join-code preview: the lobby payload plus the game's name and
|
||||
description, so a client can render a pre-join summary without a
|
||||
second lookup."""
|
||||
engine = platform.registry.require(session.game_type)
|
||||
return {
|
||||
**_lobby_payload(platform, session),
|
||||
"game_name": engine.name,
|
||||
"game_description": engine.description,
|
||||
}
|
||||
|
||||
|
||||
def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/game-types")
|
||||
@operation(summary="List available games",
|
||||
@@ -262,6 +281,33 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
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/preview")
|
||||
@operation(summary="Preview a game by join code",
|
||||
description="Looks up a game by its join code without "
|
||||
"joining, for the lobby's pre-join summary: game "
|
||||
"name and description, seated players, open "
|
||||
"seats and the game-specific options echoed by "
|
||||
"its engine.",
|
||||
tags=["games"],
|
||||
responses={
|
||||
200: {"description": "The lobby preview"},
|
||||
400: {"description": "Missing code"},
|
||||
401: {"description": "Authentication required"},
|
||||
404: {"description": "Unknown join code"},
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def preview_game(ctx: HttpContext) -> None:
|
||||
values = extract_query_params(ctx.query_string).get("code")
|
||||
code = values[0].strip() if values else ""
|
||||
if not code:
|
||||
await send_error(ctx, 400, "code is required")
|
||||
return
|
||||
session = await platform.game_store.find_by_code(code)
|
||||
if session is None:
|
||||
await send_error(ctx, 404, "unknown join code")
|
||||
return
|
||||
await send_json(ctx, 200, _preview_payload(platform, session))
|
||||
|
||||
@app.GET("/api/games/${game_id}")
|
||||
@operation(summary="Get a game snapshot",
|
||||
description="Only seated players may read a game; the view is "
|
||||
|
||||
@@ -69,6 +69,56 @@ class GamesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(0, state["plays"])
|
||||
self.assertTrue(state["viewer_seated"])
|
||||
|
||||
@async_test
|
||||
async def test_preview_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/games/preview?code=ABC123")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_preview_lobby_and_started_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={"options": {"target": 5}}
|
||||
)
|
||||
code = created.json()["join_code"]
|
||||
|
||||
with oidc_user(platform.oidc, "mallory"):
|
||||
missing = await client.get("/api/games/preview")
|
||||
unknown = await client.get("/api/games/preview?code=ZZZZZZ")
|
||||
self.assertEqual(400, missing.status_code)
|
||||
self.assertEqual(404, unknown.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "mallory"):
|
||||
preview = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, preview.status_code)
|
||||
body = preview.json()
|
||||
self.assertEqual("dummy", body["game_type"])
|
||||
self.assertEqual("Dummy game", body["game_name"])
|
||||
self.assertTrue(body["game_description"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(5, body["target"])
|
||||
self.assertEqual(1, body["seats_open"])
|
||||
self.assertEqual("alice", body["players"][0]["sub"])
|
||||
|
||||
# Join codes are case-insensitive.
|
||||
with oidc_user(platform.oidc, "mallory"):
|
||||
lower = await client.get(f"/api/games/preview?code={code.lower()}")
|
||||
self.assertEqual(200, lower.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
with oidc_user(platform.oidc, "mallory"):
|
||||
started = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, started.status_code)
|
||||
self.assertEqual("playing", started.json()["phase"])
|
||||
self.assertEqual(0, started.json()["seats_open"])
|
||||
|
||||
@async_test
|
||||
async def test_create_rejects_bad_options(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
|
||||
@@ -116,6 +116,55 @@ class GamesRouteTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(400, invalid.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_preview_join_code(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
with oidc_user("alice"):
|
||||
created = await client.post(
|
||||
"/api/games",
|
||||
json={
|
||||
"options": {
|
||||
"target_score": 16,
|
||||
"napola": False,
|
||||
"asso_pigliatutto": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
code = created.json()["join_code"]
|
||||
|
||||
response = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(401, response.status_code)
|
||||
|
||||
with oidc_user("bob"):
|
||||
missing = await client.get("/api/games/preview")
|
||||
unknown = await client.get("/api/games/preview?code=ZZZZZZ")
|
||||
self.assertEqual(400, missing.status_code)
|
||||
self.assertEqual(404, unknown.status_code)
|
||||
|
||||
with oidc_user("bob"):
|
||||
preview = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, preview.status_code)
|
||||
body = preview.json()
|
||||
self.assertEqual("scopone_scientifico", body["game_type"])
|
||||
self.assertEqual("Scopone scientifico", body["game_name"])
|
||||
self.assertTrue(body["game_description"])
|
||||
self.assertEqual("lobby", body["phase"])
|
||||
self.assertEqual(16, body["target_score"])
|
||||
self.assertFalse(body["napola"])
|
||||
self.assertTrue(body["asso_pigliatutto"])
|
||||
self.assertEqual(3, body["seats_open"])
|
||||
self.assertEqual(["alice"], [p["sub"] for p in body["players"]])
|
||||
|
||||
for player in ("bob", "carol", "dave"):
|
||||
with oidc_user(player):
|
||||
await client.post("/api/games/join", json={"code": code})
|
||||
with oidc_user("bob"):
|
||||
started = await client.get(f"/api/games/preview?code={code}")
|
||||
self.assertEqual(200, started.status_code)
|
||||
self.assertEqual("playing", started.json()["phase"])
|
||||
self.assertEqual(0, started.json()["seats_open"])
|
||||
|
||||
@async_test
|
||||
async def test_join_errors(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
|
||||
Reference in new issue
Block a user