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:
9 files changed
+446
-4
No files matched your search
@@ -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