From 18877b81090fb629488a9009ac36ecf46ca89652 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Tue, 6 Oct 2026 09:41:12 +0000 Subject: [PATCH] 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/; hidden when empty or logged out. --- server/README.md | 1 + .../src/tavolo/platform/routes/games.py | 52 ++++++++++ .../src/tavolo/platform/store.py | 53 ++++++++++- .../tavolo-platform/tests/test_routes.py | 57 +++++++++++ .../tavolo-platform/tests/test_store.py | 62 +++++++++++- server/tests/test_routes_games.py | 46 +++++++++ web/src/api.rs | 14 +++ web/src/model.rs | 95 +++++++++++++++++++ web/src/pages/lobby.rs | 70 +++++++++++++- 9 files changed, 446 insertions(+), 4 deletions(-) diff --git a/server/README.md b/server/README.md index 2b26110..79dc994 100644 --- a/server/README.md +++ b/server/README.md @@ -279,6 +279,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, | `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}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code | +| `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included | | `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 | | `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) | diff --git a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py index 0c83f57..8938813 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py @@ -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) + ] + }) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/store.py b/server/packages/tavolo-platform/src/tavolo/platform/store.py index b6456a3..8e742a5 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/store.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/store.py @@ -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() diff --git a/server/packages/tavolo-platform/tests/test_routes.py b/server/packages/tavolo-platform/tests/test_routes.py index 44e7a97..89c8dc4 100644 --- a/server/packages/tavolo-platform/tests/test_routes.py +++ b/server/packages/tavolo-platform/tests/test_routes.py @@ -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 diff --git a/server/packages/tavolo-platform/tests/test_store.py b/server/packages/tavolo-platform/tests/test_store.py index 71108a2..d31c1b8 100644 --- a/server/packages/tavolo-platform/tests/test_store.py +++ b/server/packages/tavolo-platform/tests/test_store.py @@ -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() diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index ed1ff48..26ba57f 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -236,6 +236,52 @@ class GamesRouteTest(unittest.TestCase): self.assertEqual(400, text.status_code) self.assertEqual(400, huge.status_code) + @async_test + async def test_my_games_lists_my_ongoing_games(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={"game_type": "briscola"} + ) + game_id = created.json()["id"] + + with oidc_user("alice"): + listed = await client.get("/api/me/games") + self.assertEqual(200, listed.status_code) + # The app suite shares one in-memory store, so other tests' + # games may be listed too: pick ours by id. + by_id = {g["id"]: g for g in listed.json()["results"]} + self.assertIn(game_id, by_id) + entry = by_id[game_id] + self.assertEqual("briscola", entry["game_type"]) + self.assertEqual("Briscola", entry["game_name"]) + self.assertTrue(entry["game_description"]) + self.assertEqual("lobby", entry["phase"]) + self.assertEqual(1, entry["seats_open"]) + self.assertEqual(0, entry["your_seat"]) + self.assertNotIn("join_code", entry) + + # A logged-in non-player does not see the game. + with oidc_user("mallory"): + others = await client.get("/api/me/games") + self.assertNotIn( + game_id, [g["id"] for g in others.json()["results"]] + ) + + # Once the match starts it stays listed, for both players. + code = created.json()["join_code"] + with oidc_user("bob"): + await client.post("/api/games/join", json={"code": code}) + with oidc_user("bob"): + started = await client.get("/api/me/games") + bob_entry = next( + g for g in started.json()["results"] if g["id"] == game_id + ) + self.assertEqual("playing", bob_entry["phase"]) + self.assertEqual(1, bob_entry["your_seat"]) + self.assertEqual(0, bob_entry["seats_open"]) + @async_test async def test_get_unknown_game(self) -> None: transport = ASGITransport(app=app) diff --git a/web/src/api.rs b/web/src/api.rs index 3efde4a..40e37de 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -128,6 +128,20 @@ pub async fn probe_game(game_id: &str) -> GameEntry { } } +/// Fetch the live games the caller is seated in, for the lobby's +/// "Continue" list. +pub async fn my_games() -> Result, String> { + let resp = Request::get("/api/me/games") + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.ok() { + return Err(server_error(resp.status())); + } + let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?; + Ok(page.results) +} + pub async fn my_matches(cursor: Option<&str>) -> Result { let url = match cursor { Some(c) => format!("/api/me/matches?limit=10&cursor={c}"), diff --git a/web/src/model.rs b/web/src/model.rs index 800c366..d030f9f 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -498,6 +498,66 @@ pub struct PreviewPlayer { pub team: Option, } +/// One ongoing game the caller is seated in (GET /api/me/games): the +/// lobby payload plus the game's display name, for the "Continue" list. +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct MyGame { + pub id: String, + #[serde(default)] + pub game_type: String, + #[serde(default)] + pub game_name: String, + #[serde(default)] + pub game_description: String, + #[serde(default)] + pub phase: String, + #[serde(default)] + pub players: Vec, + #[serde(default)] + pub seats_open: Option, + #[serde(default)] + pub your_seat: Option, + #[serde(default)] + pub created_at: Option, +} + +impl MyGame { + /// Human-readable phase for the lobby list. + pub fn phase_label(&self) -> &'static str { + match self.phase.as_str() { + "lobby" => "Waiting for players", + "hand_end" => "Between hands", + _ => "In progress", + } + } + + /// Seated/total note, e.g. `"2/4 seated"`. + pub fn seats_text(&self) -> String { + match self.seats_open { + Some(open) => { + format!("{}/{} seated", self.players.len(), self.players.len() + open) + } + None => format!("{} seated", self.players.len()), + } + } + + /// The seated players' names, for the list subtitle. + pub fn players_text(&self) -> String { + self.players + .iter() + .map(|p| p.name.clone()) + .collect::>() + .join(", ") + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MyGamesPage { + #[serde(default)] + pub results: Vec, +} + /// One option to show in the join preview, rendered from the game's /// schema and the engine-echoed value. #[derive(Debug, Clone, PartialEq)] @@ -1218,6 +1278,41 @@ mod tests { assert_eq!("C", g.player_name_for_team("C")); } + #[test] + fn my_game_phase_and_seats_text() { + let g: MyGame = serde_json::from_value(json!({ + "id": "g1", + "game_type": "briscola_a_quattro", + "game_name": "Briscola a quattro", + "phase": "lobby", + "players": [ + {"name": "Alice", "seat": 0, "team": "A"}, + {"name": "Bob", "seat": 1, "team": "B"}, + ], + "seats_open": 2, + })) + .unwrap(); + assert_eq!("Waiting for players", g.phase_label()); + assert_eq!("2/4 seated", g.seats_text()); + assert_eq!("Alice, Bob", g.players_text()); + + let playing: MyGame = serde_json::from_value(json!({ + "id": "g2", + "game_type": "briscola", + "phase": "playing", + })) + .unwrap(); + assert_eq!("In progress", playing.phase_label()); + assert_eq!("0 seated", playing.seats_text()); + + let between: MyGame = serde_json::from_value(json!({ + "id": "g3", + "phase": "hand_end", + })) + .unwrap(); + assert_eq!("Between hands", between.phase_label()); + } + #[test] fn fallback_game_types_cover_the_registered_games() { let types = fallback_game_types(); diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index 2b8485d..73bf33f 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -16,7 +16,7 @@ use crate::components::card::card_back; use crate::components::toast::toast; use crate::model::{ fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview, - OptionField, OptionKind, PlayerRating, User, + MyGame, OptionField, OptionKind, PlayerRating, User, }; /// Human-readable label for a provider name ("google" -> "Google"). @@ -292,6 +292,8 @@ pub fn LobbyPage() -> View { let auth_providers = create_signal(Vec::::new()); // The player's Elo ratings, one row per game type played. let ratings = create_signal(Vec::::new()); + // Live games the player is seated in, for the "Continue" list. + let my_games = create_signal(Vec::::new()); let error = create_signal(Option::::None); let code = create_signal(String::new()); let game_types = create_signal(fallback_game_types()); @@ -386,6 +388,9 @@ pub fn LobbyPage() -> View { if let Ok(p) = api::my_ratings().await { ratings.set(p.results); } + if let Ok(games) = api::my_games().await { + my_games.set(games); + } }); } user.set(Some(me)); @@ -549,6 +554,69 @@ pub fn LobbyPage() -> View { "Play scopone scientifico and briscola with friends." } } + // Ongoing games: a "Continue" link back into a + // match whose page was accidentally closed. + (move || { + let games = my_games.get_clone(); + if games.is_empty() { + return view! {}; + } + let types = game_types.get_clone(); + let rows = games + .into_iter() + .map(|g| { + let name = if g.game_name.is_empty() { + types + .iter() + .find(|t| t.id == g.game_type) + .map(|t| t.name.clone()) + .unwrap_or_else(|| g.game_type.clone()) + } else { + g.game_name.clone() + }; + let phase = g.phase_label(); + let seats = g.seats_text(); + let players = g.players_text(); + let players_note = if players.is_empty() { + String::new() + } else { + format!(" · {players}") + }; + let href = format!("/game/{}", g.id); + view! { + li( + class="list-group-item d-flex flex-wrap \ + justify-content-between \ + align-items-center gap-2" + ) { + div { + div(class="fw-semibold") { (name) } + div(class="text-secondary small") { + (phase) " · " (seats) + (players_note) + } + } + a( + class="btn btn-sm btn-warning", + href=href, + ) { + "Continue" + } + } + } + }) + .collect::>(); + view! { + div(class="card mb-4") { + div(class="card-header") { "Your games" } + div(class="card-body p-0") { + ul(class="list-group list-group-flush") { + (rows) + } + } + } + } + }) div(class="row g-4") { div(class="col-lg-7") { div(class="card h-100") {