Offer a Continue link for ongoing games in the lobby
A player who accidentally closes the game page can now get back to it from the lobby. Backend: - GameStore.list_ongoing() returns live unfinished sessions (newest first), skipping expired keys and finished matches; - GET /api/me/games lists the games the caller is seated in, with the game name/description, players, open seats and your seat (no join code); - store, platform route and app integration tests. Frontend: - MyGame/MyGamesPage model plus api::my_games(); - the lobby shows a "Your games" card with phase, seats, players and a Continue button linking to /game/<id>; hidden when empty or logged out.
This commit is contained in:
@@ -108,6 +108,34 @@ def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, An
|
||||
}
|
||||
|
||||
|
||||
def _ongoing_game_payload(
|
||||
platform: "Platform", session: GameSession, sub: str
|
||||
) -> Dict[str, Any]:
|
||||
"""One entry of the caller's ongoing-games list.
|
||||
|
||||
Reuses the lobby payload (players, open seats, option echo) but drops
|
||||
the join code — the Continue link only needs the id, and the game page
|
||||
shows the code to its players anyway.
|
||||
"""
|
||||
engine = platform.registry.require(session.game_type)
|
||||
payload = _lobby_payload(platform, session)
|
||||
payload.pop("join_code", None)
|
||||
payload["game_name"] = engine.name
|
||||
payload["game_description"] = engine.description
|
||||
payload["your_seat"] = next(
|
||||
(
|
||||
index
|
||||
for index, seat in enumerate(session.players)
|
||||
if seat.user_sub == sub
|
||||
),
|
||||
None,
|
||||
)
|
||||
payload["created_at"] = (
|
||||
session.created_at.isoformat() if session.created_at else None
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def register(app: KayaApp, platform: "Platform") -> None:
|
||||
@app.GET("/api/game-types")
|
||||
@operation(summary="List available games",
|
||||
@@ -341,3 +369,27 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
||||
await send_json(
|
||||
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
||||
)
|
||||
|
||||
@app.GET("/api/me/games")
|
||||
@operation(summary="List my ongoing games",
|
||||
description="Live games the caller is seated in — waiting "
|
||||
"lobbies and matches in progress or between "
|
||||
"hands — newest first, for the lobby's Continue "
|
||||
"list. Finished matches are not included.",
|
||||
tags=["games"],
|
||||
responses={
|
||||
200: {"description": "The caller's ongoing games"},
|
||||
401: {"description": "Authentication required"},
|
||||
})
|
||||
@require_auth(platform.oidc)
|
||||
async def my_games(ctx: HttpContext) -> None:
|
||||
user = current_user(platform.oidc, ctx)
|
||||
assert user is not None # enforced by @require_auth
|
||||
sessions = await platform.game_store.list_ongoing()
|
||||
await send_json(ctx, 200, {
|
||||
"results": [
|
||||
_ongoing_game_payload(platform, session, user.sub)
|
||||
for session in sessions
|
||||
if session.seated(user.sub)
|
||||
]
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ import contextlib
|
||||
import json
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from logging import getLogger
|
||||
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
||||
|
||||
@@ -72,6 +72,15 @@ def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def _created_at_key(session: GameSession) -> datetime:
|
||||
"""Sort key for :meth:`GameStore.list_ongoing` (newest first).
|
||||
|
||||
Sessions without a creation timestamp sort last; the epoch sentinel is
|
||||
timezone-aware so it never mixes with the stored UTC datetimes.
|
||||
"""
|
||||
return session.created_at or datetime.min.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _dt_from_json(value: Any) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
@@ -137,6 +146,15 @@ class GameStore(ABC):
|
||||
still retained for the final-board view do not.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||
"""Return up to ``limit`` live games that have not finished yet.
|
||||
|
||||
Newest ``created_at`` first. Sessions whose key has expired are
|
||||
skipped, and so are matches that are over but still retained for
|
||||
the final-board view.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||
"""Async context manager serializing mutations of one game."""
|
||||
@@ -241,6 +259,27 @@ class RedisGameStore(GameStore):
|
||||
_, count = await pipe.execute()
|
||||
return int(count)
|
||||
|
||||
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||
await self._redis.zremrangebyscore(
|
||||
ONGOING_GAMES_KEY, "-inf", time.time()
|
||||
)
|
||||
members = await self._redis.zrange(ONGOING_GAMES_KEY, 0, -1)
|
||||
sessions: List[GameSession] = []
|
||||
for member in members:
|
||||
game_id = (
|
||||
member.decode("utf-8") if isinstance(member, bytes) else str(member)
|
||||
)
|
||||
session = await self.load(game_id)
|
||||
if session is None:
|
||||
# The game key expired but the ongoing entry had not been
|
||||
# pruned yet: skip it.
|
||||
continue
|
||||
if self._registry.require(session.game_type).is_finished(session):
|
||||
continue
|
||||
sessions.append(session)
|
||||
sessions.sort(key=_created_at_key, reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||
pubsub = self._redis.pubsub()
|
||||
@@ -352,6 +391,18 @@ class InMemoryGameStore(GameStore):
|
||||
async def count_ongoing(self) -> int:
|
||||
return len(self._ongoing)
|
||||
|
||||
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||
sessions: List[GameSession] = []
|
||||
for game_id in list(self._ongoing):
|
||||
session = await self.load(game_id)
|
||||
if session is None:
|
||||
continue
|
||||
if self._registry.require(session.game_type).is_finished(session):
|
||||
continue
|
||||
sessions.append(session)
|
||||
sessions.sort(key=_created_at_key, reverse=True)
|
||||
return sessions[:limit]
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
@@ -188,6 +188,63 @@ class GamesRouteTest(unittest.TestCase):
|
||||
response = await client.get("/api/games/does-not-exist")
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
@async_test
|
||||
async def test_my_games_lists_only_my_ongoing_games(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||
anonymous = await client.get("/api/me/games")
|
||||
self.assertEqual(401, anonymous.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
mine = await client.post(
|
||||
"/api/games", json={"options": {"target": 5}}
|
||||
)
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
theirs = await client.post("/api/games", json={})
|
||||
self.assertEqual(201, mine.status_code)
|
||||
self.assertEqual(201, theirs.status_code)
|
||||
|
||||
with oidc_user(platform.oidc, "alice"):
|
||||
listed = await client.get("/api/me/games")
|
||||
self.assertEqual(200, listed.status_code)
|
||||
results = listed.json()["results"]
|
||||
self.assertEqual([mine.json()["id"]], [g["id"] for g in results])
|
||||
entry = results[0]
|
||||
self.assertEqual("dummy", entry["game_type"])
|
||||
self.assertEqual("Dummy game", entry["game_name"])
|
||||
self.assertTrue(entry["game_description"])
|
||||
self.assertEqual("lobby", entry["phase"])
|
||||
self.assertEqual(5, entry["target"])
|
||||
self.assertEqual(1, entry["seats_open"])
|
||||
self.assertEqual(0, entry["your_seat"])
|
||||
self.assertNotIn("join_code", entry)
|
||||
self.assertEqual("alice", entry["players"][0]["sub"])
|
||||
|
||||
# Bob's own game is the only one he sees; Alice's is not his.
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
bobs = await client.get("/api/me/games")
|
||||
self.assertEqual(
|
||||
[theirs.json()["id"]],
|
||||
[g["id"] for g in bobs.json()["results"]],
|
||||
)
|
||||
|
||||
# Once Bob joins Alice's game it appears for both, now in the
|
||||
# playing phase.
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
await client.post(
|
||||
"/api/games/join", json={"code": mine.json()["join_code"]}
|
||||
)
|
||||
with oidc_user(platform.oidc, "bob"):
|
||||
bobs = await client.get("/api/me/games")
|
||||
by_id = {g["id"]: g for g in bobs.json()["results"]}
|
||||
self.assertEqual(
|
||||
{mine.json()["id"], theirs.json()["id"]}, set(by_id)
|
||||
)
|
||||
self.assertEqual("playing", by_id[mine.json()["id"]]["phase"])
|
||||
self.assertEqual(1, by_id[mine.json()["id"]]["your_seat"])
|
||||
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
|
||||
|
||||
|
||||
class GameTypesRouteTest(unittest.TestCase):
|
||||
@async_test
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
from tavolo.platform import GameSession, Seat
|
||||
@@ -48,7 +49,7 @@ class _FakePipeline:
|
||||
return False
|
||||
|
||||
def set(self, key: str, value: str, ex: object = None) -> None:
|
||||
self._ops.append(("set", key))
|
||||
self._ops.append(("set", key, value))
|
||||
|
||||
def zadd(self, key: str, mapping: dict) -> None:
|
||||
self._ops.append(("zadd", key, mapping))
|
||||
@@ -88,6 +89,14 @@ class _FakeRedis:
|
||||
async def zrem(self, key: str, member: str) -> int:
|
||||
return cast(int, self.apply(("zrem", key, member)))
|
||||
|
||||
async def get(self, key: str) -> object:
|
||||
return self.keys.get(key)
|
||||
|
||||
async def zremrangebyscore(
|
||||
self, key: str, minimum: object, maximum: object
|
||||
) -> int:
|
||||
return cast(int, self.apply(("zremrangebyscore", key)))
|
||||
|
||||
async def zrange(
|
||||
self,
|
||||
name: str,
|
||||
@@ -113,7 +122,7 @@ class _FakeRedis:
|
||||
def apply(self, op: tuple) -> object:
|
||||
name, *args = op
|
||||
if name == "set":
|
||||
self.keys[args[0]] = "payload"
|
||||
self.keys[args[0]] = args[1]
|
||||
return True
|
||||
if name == "zadd":
|
||||
self.zsets.setdefault(args[0], {}).update(args[1])
|
||||
@@ -247,6 +256,28 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
||||
await store.save(other)
|
||||
self.assertEqual(0, await store.count_ongoing())
|
||||
|
||||
@async_test
|
||||
async def test_list_ongoing_newest_first_and_unfinished_only(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
old = _session("old", "CODE01")
|
||||
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
new = _session("new", "CODE02")
|
||||
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||
await store.save(old)
|
||||
await store.save(new)
|
||||
|
||||
self.assertEqual(
|
||||
["new", "old"], [s.id for s in await store.list_ongoing()]
|
||||
)
|
||||
self.assertEqual(
|
||||
["new"], [s.id for s in await store.list_ongoing(limit=1)]
|
||||
)
|
||||
|
||||
# A finished match retained for the final board is not listed.
|
||||
new.state["finished"] = True
|
||||
await store.save(new)
|
||||
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||
|
||||
@async_test
|
||||
async def test_deadline_queue(self) -> None:
|
||||
store = InMemoryGameStore(_registry())
|
||||
@@ -311,6 +342,33 @@ class RedisGameStoreTest(unittest.TestCase):
|
||||
self.assertEqual(1, await store.count_ongoing())
|
||||
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
|
||||
|
||||
@async_test
|
||||
async def test_list_ongoing_loads_unfinished_sessions(self) -> None:
|
||||
redis = _FakeRedis()
|
||||
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
|
||||
old = _session("old", "CODE01")
|
||||
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
new = _session("new", "CODE02")
|
||||
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||
await store.save(old)
|
||||
await store.save(new)
|
||||
|
||||
self.assertEqual(
|
||||
["new", "old"], [s.id for s in await store.list_ongoing()]
|
||||
)
|
||||
self.assertEqual(
|
||||
["new"], [s.id for s in await store.list_ongoing(limit=1)]
|
||||
)
|
||||
|
||||
# A finished match retained for the final board is not listed.
|
||||
new.state["finished"] = True
|
||||
await store.save(new)
|
||||
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||
|
||||
# An ongoing entry whose game key expired is skipped.
|
||||
redis.zsets[ONGOING_GAMES_KEY]["gone"] = time.time() + 100
|
||||
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||
|
||||
@async_test
|
||||
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
|
||||
redis = _FakeRedis()
|
||||
|
||||
Reference in New Issue
Block a user