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:
woggioni committed 2026-10-06 17:41:44 +08:00
1 parent b821274f0b
commit 18877b8109
9 files changed
+446 -4

No files matched your search

@@ -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()