From b5219062f2e0af39186fff4d07a2451b4a8a94fd Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Tue, 6 Oct 2026 02:11:30 +0000 Subject: [PATCH] Let anyone with the game URL watch as a read-only spectator Anonymous users and logged-in non-players can now open /game/ and follow the match: they see the table, the score and each player's captured cards and scope, but never a hand and never the join code. Spectator actions are rejected with a forbidden error. Backend (tavolo-platform / tavolo-scopone): - /ws/games/{id} accepts anonymous and non-seated connections as spectators; only unknown games are refused. The state payload carries spectator: true and withholds join_code unless the viewer is seated. - GET /api/games/{id} is public and returns the same personalized (player) or read-only (spectator) view. - GameEngine.view_for/game_over_view take Optional[str] (None = spectator) and are documented to hide privileged information for non-seated viewers. Frontend (Sycamore): - Parse the spectator flag and render a spectator table: card backs plus captured/scope for every seat, no clickable hand, "Spectating" badge. - A pre-start spectator sees a "game not started yet" waiting room with the watch link and no join code. - "Copy watch link" buttons in the waiting room and the game HUD. - The hand-end summary carries no acknowledge button for spectators and the game-over overlay hides "My matches". Tests and docs updated. --- README.md | 9 ++ server/README.md | 28 ++-- .../src/tavolo/platform/engine.py | 26 +++- .../src/tavolo/platform/routes/games.py | 39 +++--- .../tavolo-platform/src/tavolo/platform/ws.py | 77 ++++++----- .../packages/tavolo-platform/tests/helpers.py | 4 +- .../tavolo-platform/tests/test_routes.py | 18 ++- .../packages/tavolo-platform/tests/test_ws.py | 79 ++++++------ .../src/tavolo/scopone/engine.py | 9 +- .../src/tavolo/scopone/plugin.py | 4 +- .../src/tavolo/scopone/state.py | 2 +- .../tavolo-scopone/tests/test_engine.py | 21 +++ .../tavolo-scopone/tests/test_plugin.py | 11 ++ server/tests/test_routes_games.py | 30 ++++- server/tests/test_websocket.py | 48 +++++-- web/src/api.rs | 13 +- web/src/components/copy.rs | 45 +++++++ web/src/components/mod.rs | 1 + web/src/components/summary.rs | 9 +- web/src/model.rs | 35 ++++- web/src/pages/game.rs | 121 +++++++++++------- web/style.css | 9 ++ 22 files changed, 453 insertions(+), 185 deletions(-) create mode 100644 web/src/components/copy.rs diff --git a/README.md b/README.md index 1f5a2fa..9ab0a32 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,15 @@ player does not move — disconnected or fallen asleep — the server plays a random legal card for them (randomizing among the legal captures when the rules require a capture), so one absent player cannot stall the table. +## Watching + +Anyone who has the game URL (`/game/`) can watch, logged in or not: +spectators see the table, the score and every player's captured cards and +scope, but never a hand and never the join code. The "Copy watch link" +button in the waiting room and in the game HUD copies the URL to share. +A spectator who opens the URL before the match starts sees a "game not +started yet" waiting room. + ## Development Backend (from `server/`): diff --git a/server/README.md b/server/README.md index dec9e6f..f67fcad 100644 --- a/server/README.md +++ b/server/README.md @@ -262,7 +262,8 @@ existed): ## REST API All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, -`/api/game-types` and `/api/leaderboard` require authentication. +`/api/game-types`, `/api/leaderboard` and the game snapshot +(`GET /api/games/{id}`) require authentication. | Method | Path | Description | |---|---|---| @@ -270,32 +271,36 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, | `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/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/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=`) | ## WebSocket protocol -Connect to `/ws/games/{game_id}` with your session cookie. Only seated -players are accepted; the upgrade is refused before the handshake -completes with an HTTP status mirroring `GET /api/games/{id}`: `401` -unauthenticated, `404` unknown game, `403` not seated. (Browsers report -a refused handshake as an abnormal close — there is no close code to -read.) +Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone +who knows the game id is accepted: seated players get their personalized +view, everyone else — anonymous users and authenticated non-players — is +a read-only **spectator**. Only an unknown game id is refused before the +handshake completes, with `404`. (Browsers report a refused handshake as +an abnormal close — there is no close code to read.) Server → client messages are JSON objects with a `type`: - `{"type": "state", "game": {...}}` — personalized game view. Your own hand is included; other players expose only their card counts. - `your_turn: true` is present when it is your move. + Spectators get no hand at all, `spectator: true` marks the view and + `join_code` is omitted for them. `your_turn: true` is present when it + is your move. - `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}` - `{"type": "error", "code": "illegal_move", "message": "..."}` Client → server messages are JSON objects with an `action`. The platform handles `state` (resend the snapshot) itself; every other action is dispatched to the session's game engine with the rest of the message -as its payload. For scopone scientifico: +as its payload. Spectators may only send `state`/`sync`: any other +action is answered with an `error` whose `code` is `forbidden`. For +scopone scientifico: ```json {"action": "play", "card": "07D", "capture": ["02D", "05C"]} @@ -314,7 +319,8 @@ as its payload. For scopone scientifico: after `HAND_ACK_TIMEOUT_SECONDS`. - `state` asks for a fresh snapshot. -After every accepted move the new state is broadcast to all four players. +After every accepted move the new state is broadcast to every connected +client, spectators included. ### Turn timeout diff --git a/server/packages/tavolo-platform/src/tavolo/platform/engine.py b/server/packages/tavolo-platform/src/tavolo/platform/engine.py index 3fee068..5f24c2a 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/engine.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/engine.py @@ -191,12 +191,17 @@ class GameEngine(ABC): """ @abstractmethod - def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]: - """The personalized game view for one player. + def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]: + """The personalized game view for one viewer. - Must hide information the player is not entitled to (e.g. other - players' hands). The platform merges in the envelope fields - (``id``, ``join_code``, ``game_type``) itself. + Must hide information the viewer is not entitled to (e.g. other + players' hands). ``user_sub`` is ``None`` for a spectator — an + anonymous user or an authenticated user who is not seated — and + the view must then contain no privileged information either (no + hand, no legal moves: only what everyone at the table can see). + The platform merges in the envelope fields (``id``, + ``join_code`` for seated players, ``game_type``, ``spectator``) + itself. """ @abstractmethod @@ -248,6 +253,13 @@ class GameEngine(ABC): ``kind``/``token`` no longer match the live state. """ - def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]: - """Extra fields merged into the ``game_over`` websocket message.""" + def game_over_view( + self, session: GameSession, user_sub: Optional[str] + ) -> Dict[str, Any]: + """Extra fields merged into the ``game_over`` websocket message. + + ``user_sub`` is ``None`` for a spectator (see :meth:`view_for`); + the default fields are public, so the default implementation + ignores it. + """ return {} 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 792f30f..8b2f7e5 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/routes/games.py @@ -16,7 +16,7 @@ import secrets import uuid from datetime import datetime, timezone from logging import getLogger -from typing import TYPE_CHECKING, Any, Dict +from typing import TYPE_CHECKING, Any, Dict, Optional from kaya.core import HttpContext, KayaApp from kaya.openapi import operation @@ -76,14 +76,21 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any] } -def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]: +def _view_payload( + platform: "Platform", session: GameSession, sub: Optional[str] +) -> Dict[str, Any]: engine = platform.registry.require(session.game_type) - return { + seated = sub is not None and session.seated(sub) + payload: Dict[str, Any] = { "id": session.id, - "join_code": session.join_code, "game_type": session.game_type, - **engine.view_for(session, sub), + "spectator": not seated, } + if seated: + # The join code is a credential: only players get it. + payload["join_code"] = session.join_code + payload.update(engine.view_for(session, sub)) + return payload def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]: @@ -310,24 +317,24 @@ def register(app: KayaApp, platform: "Platform") -> None: @app.GET("/api/games/${game_id}") @operation(summary="Get a game snapshot", - description="Only seated players may read a game; the view is " - "personalized by the engine (e.g. hidden hands).", + description="Anyone who knows the game id may read a game. " + "Seated players get the personalized view (their " + "own hand plus the join code); everyone else — " + "anonymous users and authenticated non-players — " + "gets the read-only spectator view: no hands, no " + "join code, just what is public at the table.", tags=["games"], responses={ - 200: {"description": "The personalized game state"}, - 401: {"description": "Authentication required"}, - 403: {"description": "Not a player in this game"}, + 200: {"description": "The personalized (player) or " + "read-only (spectator) game state"}, 404: {"description": "Game not found"}, }) - @require_auth(platform.oidc) async def get_game(ctx: HttpContext, game_id: str) -> None: session = await platform.game_store.load(game_id) if session is None: await send_error(ctx, 404, "game not found") return user = current_user(platform.oidc, ctx) - assert user is not None - if not session.seated(user.sub): - await send_error(ctx, 403, "forbidden") - return - await send_json(ctx, 200, _view_payload(platform, session, user.sub)) + await send_json( + ctx, 200, _view_payload(platform, session, user.sub if user else None) + ) diff --git a/server/packages/tavolo-platform/src/tavolo/platform/ws.py b/server/packages/tavolo-platform/src/tavolo/platform/ws.py index 6841ad0..bdab680 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/ws.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/ws.py @@ -2,12 +2,21 @@ Clients connect to ``/ws/games/{game_id}`` using their session cookie (the OIDC login stores the user in the session, which the session mixin -loads onto the websocket). Only seated players are accepted. +loads onto the websocket). Anyone who knows the game id is accepted: +seated players get their personalized view, everyone else — anonymous +users and authenticated users who are not seated — is a read-only +**spectator** receiving the view without privileged information (no +hands, no legal moves). The game view carries ``spectator: true`` for +them and never carries the join code. Spectator actions other than +``state``/``sync`` are rejected with a ``forbidden`` error; seating is +re-checked under the game lock so a viewer who joins mid-connection +immediately gains (and, if they somehow left, would lose) the right to +act. -The platform owns the connection lifecycle — authentication, seat -check, the subscription/publish fan-out and the message envelope — and -delegates the game-specific actions to the engine registered for the -session's ``game_type``. +The platform owns the connection lifecycle — authentication, the +subscription/publish fan-out and the message envelope — and delegates +the game-specific actions to the engine registered for the session's +``game_type``. Protocol -------- @@ -48,7 +57,7 @@ import asyncio import json from contextlib import suppress from logging import getLogger -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Optional from kaya.core import KayaApp, WebSocket, WebSocketDisconnect @@ -83,17 +92,21 @@ async def _reject(ws: WebSocket, status: int) -> None: await ws.close(status) -def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]: +def _state_message( + platform: "Platform", session: GameSession, sub: Optional[str] +) -> Dict[str, Any]: engine = platform.registry.require(session.game_type) - return { - "type": "state", - "game": { - "id": session.id, - "join_code": session.join_code, - "game_type": session.game_type, - **engine.view_for(session, sub), - }, + seated = sub is not None and session.seated(sub) + payload: Dict[str, Any] = { + "id": session.id, + "game_type": session.game_type, + "spectator": not seated, } + if seated: + # The join code is a credential: only players get it. + payload["join_code"] = session.join_code + payload.update(engine.view_for(session, sub)) + return {"type": "state", "game": payload} def register(app: KayaApp, platform: "Platform") -> None: @@ -101,24 +114,19 @@ def register(app: KayaApp, platform: "Platform") -> None: @app.websocket("/ws/games/${game_id}") async def game_socket(ws: WebSocket, game_id: str) -> None: + # Anonymous and non-seated viewers are welcome as read-only + # spectators; only an unknown game id is refused. user = auth.get_ws_user(ws) - if user is None: - log.debug("websocket %s rejected: no authenticated user", game_id) - await _reject(ws, 401) - return + sub: Optional[str] = user.sub if user is not None else None session = await platform.game_store.load(game_id) if session is None: log.debug("websocket rejected: unknown game %s", game_id) await _reject(ws, 404) return - if not session.seated(user.sub): - log.debug("websocket %s rejected: %s is not seated", game_id, user.sub) - await _reject(ws, 403) - return await ws.accept() - log.info("%s connected to game %s", user.sub, game_id) + log.info("%s connected to game %s", sub or "spectator", game_id) send_lock = asyncio.Lock() @@ -132,7 +140,7 @@ def register(app: KayaApp, platform: "Platform") -> None: # ends momentarily. pass - await send(_state_message(platform, session, user.sub)) + await send(_state_message(platform, session, sub)) # Backstop: make sure the current deadline is queued even if its # entry was lost (e.g. the queue was flushed while the game lived # on thanks to its sliding TTL). @@ -140,7 +148,7 @@ def register(app: KayaApp, platform: "Platform") -> None: async with platform.game_store.subscribe(game_id) as events: forward = asyncio.create_task( - _forward(platform, events, game_id, user.sub, send) + _forward(platform, events, game_id, sub, send) ) try: async for message in ws: @@ -149,19 +157,19 @@ def register(app: KayaApp, platform: "Platform") -> None: if message.kind != "text" or not isinstance(message.data, str): await send(_error("expected a text frame with a JSON object")) continue - await _handle_message(platform, send, game_id, user.sub, message.data) + await _handle_message(platform, send, game_id, sub, message.data) finally: forward.cancel() with suppress(asyncio.CancelledError): await forward - log.debug("%s disconnected from game %s", user.sub, game_id) + log.debug("%s disconnected from game %s", sub or "spectator", game_id) async def _forward( platform: "Platform", events, game_id: str, - sub: str, + sub: Optional[str], send: Send, ) -> None: async for _ in events: @@ -181,7 +189,7 @@ async def _forward( async def _handle_message( - platform: "Platform", send: Send, game_id: str, sub: str, raw: str + platform: "Platform", send: Send, game_id: str, sub: Optional[str], raw: str ) -> None: try: data = json.loads(raw) @@ -210,7 +218,7 @@ async def _handle_action( platform: "Platform", send: Send, game_id: str, - sub: str, + sub: Optional[str], action: str, data: Dict[str, Any], ) -> None: @@ -219,6 +227,13 @@ async def _handle_action( if session is None: await send(_error("game not found", code="not_found")) return + # Re-check seating under the lock: an anonymous spectator stays a + # spectator forever, while a logged-in viewer who joins the game + # mid-connection becomes a player and may act from then on. + if sub is None or not session.seated(sub): + log.debug("game %s: rejected %r from a spectator", game_id, action) + await send(_error("spectators cannot act on the game", code="forbidden")) + return engine = platform.registry.require(session.game_type) payload = {key: value for key, value in data.items() if key != "action"} try: diff --git a/server/packages/tavolo-platform/tests/helpers.py b/server/packages/tavolo-platform/tests/helpers.py index 06841cf..b7f0b21 100644 --- a/server/packages/tavolo-platform/tests/helpers.py +++ b/server/packages/tavolo-platform/tests/helpers.py @@ -138,7 +138,7 @@ class DummyEngine(GameEngine): state["finished"] = True state["winner"] = user_sub - def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]: + def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]: state = session.state return { "started": state["started"], @@ -146,7 +146,7 @@ class DummyEngine(GameEngine): "winner": state["winner"], "plays": len(state["plays"]), "target": state["target"], - "viewer_seated": session.seated(user_sub), + "viewer_seated": user_sub is not None and session.seated(user_sub), } def lobby_view(self, session: GameSession) -> Dict[str, Any]: diff --git a/server/packages/tavolo-platform/tests/test_routes.py b/server/packages/tavolo-platform/tests/test_routes.py index ad4855a..44e7a97 100644 --- a/server/packages/tavolo-platform/tests/test_routes.py +++ b/server/packages/tavolo-platform/tests/test_routes.py @@ -44,10 +44,24 @@ class GamesRouteTest(unittest.TestCase): self.assertEqual(game_id, snap["id"]) self.assertEqual("dummy", snap["game_type"]) self.assertTrue(snap["viewer_seated"]) + self.assertFalse(snap["spectator"]) + self.assertIn("join_code", snap) + # A logged-in non-player is a read-only spectator: the view + # marks it and the join code is withheld. with oidc_user(platform.oidc, "mallory"): - forbidden = await client.get(f"/api/games/{game_id}") - self.assertEqual(403, forbidden.status_code) + watched = await client.get(f"/api/games/{game_id}") + self.assertEqual(200, watched.status_code) + spect = watched.json() + self.assertFalse(spect["viewer_seated"]) + self.assertTrue(spect["spectator"]) + self.assertNotIn("join_code", spect) + + # Anonymous users may watch too. + anonymous = await client.get(f"/api/games/{game_id}") + self.assertEqual(200, anonymous.status_code) + self.assertTrue(anonymous.json()["spectator"]) + self.assertNotIn("join_code", anonymous.json()) @async_test async def test_join_starts_game(self) -> None: diff --git a/server/packages/tavolo-platform/tests/test_ws.py b/server/packages/tavolo-platform/tests/test_ws.py index c6f8c65..7327384 100644 --- a/server/packages/tavolo-platform/tests/test_ws.py +++ b/server/packages/tavolo-platform/tests/test_ws.py @@ -11,7 +11,6 @@ from httpx_ws.transport import ASGIWebSocketTransport from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage from helpers import async_test, make_platform, make_user, oidc_user, ws_users -from tavolo.platform import GameSession, Seat class AbruptCloseSocket(WebSocket): @@ -117,6 +116,9 @@ class WebSocketTest(unittest.TestCase): self.assertEqual("dummy", first["game"]["game_type"]) self.assertEqual(0, first["game"]["plays"]) self.assertEqual(game_id, first["game"]["id"]) + # Seated players get the join code; spectators don't. + self.assertIn("join_code", first["game"]) + self.assertFalse(first["game"]["spectator"]) async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws: alice_first = await alice_ws.receive_json() @@ -237,7 +239,7 @@ class WebSocketTest(unittest.TestCase): self.assertEqual(404, caught.exception.code) @async_test - async def test_non_player_is_closed(self) -> None: + async def test_non_player_watches_as_spectator(self) -> None: app, platform, _ = make_platform() api_transport = ASGITransport(app=app) async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: @@ -247,21 +249,38 @@ class WebSocketTest(unittest.TestCase): ws_transport = ASGIWebSocketTransport(app=app) async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: with ws_users([make_user("mallory")]): - with self.assertRaises(HttpxWebSocketDisconnect) as caught: - async with aconnect_ws(f"/ws/games/{game_id}", ws_client): - pass - self.assertEqual(403, caught.exception.code) + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual("state", first["type"]) + self.assertTrue(first["game"]["spectator"]) + self.assertFalse(first["game"]["viewer_seated"]) + self.assertNotIn("join_code", first["game"]) + # Spectators may not act. + await ws.send_json({"action": "play"}) + error = await ws.receive_json() + self.assertEqual("error", error["type"]) + self.assertEqual("forbidden", error["code"]) @async_test - async def test_unauthenticated_is_closed(self) -> None: - app, _, _ = make_platform() - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([]): - with self.assertRaises(HttpxWebSocketDisconnect) as caught: - async with aconnect_ws("/ws/games/whatever", ws_client): - pass - self.assertEqual(401, caught.exception.code) + async def test_anonymous_user_watches_as_spectator(self) -> None: + app, platform, _ = make_platform() + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_game(client, platform.oidc) + game_id = state["id"] + + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual("state", first["type"]) + self.assertTrue(first["game"]["spectator"]) + self.assertNotIn("join_code", first["game"]) + await ws.send_json({"action": "play"}) + error = await ws.receive_json() + self.assertEqual("error", error["type"]) + self.assertEqual("forbidden", error["code"]) class RejectionStatusTest(unittest.TestCase): @@ -271,33 +290,19 @@ class RejectionStatusTest(unittest.TestCase): ``accept()`` as the HTTP status of the refused upgrade; a WebSocket close code like 4401 is not a valid status and panics the worker (``InvalidStatusCode``, and with ``panic = "abort"`` the whole app - dies). Drive the rejections through the app with a recording socket - so the invariant is checked without a real server. + dies). Drive the rejection through the app with a recording socket + so the invariant is checked without a real server. Spectators are + accepted, so the only pre-accept rejection left is an unknown game. """ @async_test - async def test_pre_accept_rejections_are_http_statuses(self) -> None: - app, platform, _ = make_platform() - session = GameSession( - id="seated-game", - game_type="dummy", - join_code="SEAT01", - creator_sub="alice", - players=[Seat(user_sub="alice", display_name="Alice")], - ) - engine = platform.registry.require("dummy") - engine.create(session, {}) - await platform.game_store.save(session) - cases = [ - ("unauthenticated", [], "/ws/games/whatever", 401), - ("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404), - ("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403), - ] - for label, users, path, expected in cases: - ws = RecordingSocket(path) + async def test_unknown_game_rejection_is_an_http_status(self) -> None: + app, _, _ = make_platform() + for label, users in (("anonymous", []), ("logged in", [make_user("alice")])): + ws = RecordingSocket("/ws/games/no-such-game") with ws_users(users): await app.handle_websocket(ws) - self.assertEqual([expected], ws.close_codes, label) + self.assertEqual([404], ws.close_codes, label) for code in ws.close_codes: self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status") diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py b/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py index 8df3c2e..0994bab 100644 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/engine.py @@ -528,13 +528,14 @@ def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]: return points, details -def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]: +def state_for_player(state: ScoponeState, sub: Optional[str]) -> Dict[str, object]: """Serialize ``state`` hiding other players' hands. Hands are reduced to a count, except for the requesting player's own - hand. Non-seated viewers simply see no hand at all. The platform - merges the session envelope (``id``, ``join_code``, ``game_type``) - into the view itself. + hand. Non-seated viewers — including spectators, for whom ``sub`` is + ``None`` — simply see no hand at all. The platform merges the session + envelope (``id``, ``join_code`` for seated players, ``game_type``, + ``spectator``) into the view itself. """ viewer = state.player_for(sub) players: List[Dict[str, object]] = [] diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py b/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py index cd55333..6ab9c1e 100644 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/plugin.py @@ -171,13 +171,13 @@ class ScoponeEngine(GameEngine): else: raise IllegalMove(f"unknown action: {action!r}") - def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]: + def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]: return engine.state_for_player(session.state, user_sub) def is_finished(self, session: GameSession) -> bool: return session.state.phase == PHASE_FINISHED - def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]: + def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]: state: ScoponeState = session.state return { "scores": {"A": state.scores[0], "B": state.scores[1]}, diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/state.py b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py index f1edb69..2971fbb 100644 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/state.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py @@ -239,7 +239,7 @@ class ScoponeState: # -- helpers ---------------------------------------------------------- - def player_for(self, sub: str) -> Optional[PlayerState]: + def player_for(self, sub: Optional[str]) -> Optional[PlayerState]: for player in self.players: if player.sub == sub: return player diff --git a/server/packages/tavolo-scopone/tests/test_engine.py b/server/packages/tavolo-scopone/tests/test_engine.py index 53c159f..f5188ab 100644 --- a/server/packages/tavolo-scopone/tests/test_engine.py +++ b/server/packages/tavolo-scopone/tests/test_engine.py @@ -574,6 +574,27 @@ class MatchFlowTest(unittest.TestCase): self.assertEqual(["07C"], view["table"]) self.assertTrue(view.get("your_turn")) + def test_state_for_player_spectator_sees_public_info_only(self) -> None: + state = make_state( + [["02D", "03C"], ["04D"], ["05D"], ["06D"]], + table=["07C"], + captured=[["01D"], [], ["02C"], ["03B"]], + scope=[1, 0, 2, 0], + ) + view = engine.state_for_player(state, None) + for player in view["players"]: + self.assertNotIn("hand", player) + self.assertNotIn("your_turn", view) + self.assertNotIn("legal_moves", view) + # The public table state is still fully visible. + self.assertEqual(["07C"], view["table"]) + self.assertEqual({"A": 0, "B": 0}, view["scores"]) + players = {p["seat"]: p for p in view["players"]} + self.assertEqual(1, players[0]["captured_count"]) + self.assertEqual(1, players[0]["scope"]) + self.assertEqual(2, players[2]["scope"]) + self.assertEqual(1, players[1]["cards_left"]) + def test_legal_moves_only_for_player_on_turn(self) -> None: state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]], table=["07C"]) diff --git a/server/packages/tavolo-scopone/tests/test_plugin.py b/server/packages/tavolo-scopone/tests/test_plugin.py index 1016d2f..9307802 100644 --- a/server/packages/tavolo-scopone/tests/test_plugin.py +++ b/server/packages/tavolo-scopone/tests/test_plugin.py @@ -160,6 +160,17 @@ class ViewTest(unittest.TestCase): self.assertNotIn("join_code", view) self.assertNotIn("game_type", view) + def test_view_for_spectator_hides_all_hands(self) -> None: + engine = ScoponeEngine() + session = _started(engine) + view = engine.view_for(session, None) + for player in view["players"]: + self.assertNotIn("hand", player) + self.assertNotIn("your_turn", view) + self.assertNotIn("legal_moves", view) + self.assertIn("table", view) + self.assertEqual({"A": 0, "B": 0}, view["scores"]) + def test_lobby_view(self) -> None: engine = ScoponeEngine() session = _session(engine, target_score=16) diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index 9253b7d..704d02e 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -39,9 +39,19 @@ class GamesRouteTest(unittest.TestCase): self.assertEqual(200, snapshot.status_code) self.assertEqual("alice", snapshot.json()["players"][0]["sub"]) + # A logged-in non-player is a read-only spectator: no join + # code, and the view is marked as a spectator view. with oidc_user("mallory"): - forbidden = await client.get(f"/api/games/{game_id}") - self.assertEqual(403, forbidden.status_code) + watched = await client.get(f"/api/games/{game_id}") + self.assertEqual(200, watched.status_code) + self.assertTrue(watched.json()["spectator"]) + self.assertNotIn("join_code", watched.json()) + + # Anonymous users may watch too. + anonymous = await client.get(f"/api/games/{game_id}") + self.assertEqual(200, anonymous.status_code) + self.assertTrue(anonymous.json()["spectator"]) + self.assertNotIn("join_code", anonymous.json()) @async_test async def test_join_fills_seats_and_starts_game(self) -> None: @@ -61,6 +71,7 @@ class GamesRouteTest(unittest.TestCase): started = await client.post("/api/games/join", json={"code": code}) self.assertEqual(200, started.status_code) state = started.json() + game_id = state["id"] self.assertEqual("playing", state["phase"]) self.assertEqual(4, len(state["players"])) self.assertEqual([], state["table"]) @@ -72,6 +83,21 @@ class GamesRouteTest(unittest.TestCase): self.assertNotIn("hand", state["players"][0]) self.assertEqual(1, state["turn"]) + # A spectator sees the table, the score and each player's + # captured count and scope — but no hand at all. + watched = await client.get(f"/api/games/{game_id}") + self.assertEqual(200, watched.status_code) + spect = watched.json() + self.assertTrue(spect["spectator"]) + self.assertNotIn("join_code", spect) + self.assertEqual([], spect["table"]) + self.assertEqual({"A": 0, "B": 0}, spect["scores"]) + for participant in spect["players"]: + self.assertNotIn("hand", participant) + self.assertEqual(10, participant["cards_left"]) + self.assertEqual(0, participant["captured_count"]) + self.assertEqual(0, participant["scope"]) + @async_test async def test_create_napola_option(self) -> None: transport = ASGITransport(app=app) diff --git a/server/tests/test_websocket.py b/server/tests/test_websocket.py index b904bf3..bf2e553 100644 --- a/server/tests/test_websocket.py +++ b/server/tests/test_websocket.py @@ -134,7 +134,7 @@ class WebSocketTest(unittest.TestCase): self.assertEqual(404, caught.exception.code) @async_test - async def test_non_player_is_closed(self) -> None: + async def test_non_player_watches_as_spectator(self) -> None: api_transport = ASGITransport(app=app) async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: state = await self._started_game(client) @@ -143,20 +143,42 @@ class WebSocketTest(unittest.TestCase): ws_transport = ASGIWebSocketTransport(app=app) async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: with ws_users([make_user("mallory")]): - with self.assertRaises(WebSocketDisconnect) as caught: - async with aconnect_ws(f"/ws/games/{game_id}", ws_client): - pass - self.assertEqual(403, caught.exception.code) + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual("state", first["type"]) + self.assertTrue(first["game"]["spectator"]) + self.assertNotIn("join_code", first["game"]) + self.assertIn("table", first["game"]) + self.assertEqual({"A": 0, "B": 0}, first["game"]["scores"]) + for player in first["game"]["players"]: + self.assertNotIn("hand", player) + # Spectators may not act. + await ws.send_json({"action": "play", "card": "01D"}) + error = await ws.receive_json() + self.assertEqual("error", error["type"]) + self.assertEqual("forbidden", error["code"]) @async_test - async def test_unauthenticated_is_closed(self) -> None: - ws_transport = ASGIWebSocketTransport(app=app) - async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: - with ws_users([]): - with self.assertRaises(WebSocketDisconnect) as caught: - async with aconnect_ws("/ws/games/whatever", ws_client): - pass - self.assertEqual(401, caught.exception.code) + async def test_anonymous_user_watches_as_spectator(self) -> None: + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_game(client) + game_id = state["id"] + + ws_transport = ASGIWebSocketTransport(app=app) + async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: + with ws_users([]): + async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws: + first = await ws.receive_json() + self.assertEqual("state", first["type"]) + self.assertTrue(first["game"]["spectator"]) + self.assertNotIn("join_code", first["game"]) + for player in first["game"]["players"]: + self.assertNotIn("hand", player) + await ws.send_json({"action": "play", "card": "01D"}) + error = await ws.receive_json() + self.assertEqual("error", error["type"]) + self.assertEqual("forbidden", error["code"]) async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str: diff --git a/web/src/api.rs b/web/src/api.rs index 3c13b0c..2cea385 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -105,11 +105,11 @@ pub async fn join_preview(code: &str) -> Result, String> { /// A refused WebSocket handshake is invisible to the browser: the close /// event reports an abnormal closure (1006) with no HTTP status, so the /// game page asks the REST API to tell a deliberate rejection from a -/// transient network problem. +/// transient network problem. Anonymous users and non-players are +/// welcome as spectators, so the only deliberate refusal is an unknown +/// game. pub enum GameEntry { - /// Not logged in (`401` from `GET /api/games/{id}`). - Unauthenticated, - /// Unknown game (`404`) or the user is not seated (`403`). + /// Unknown game (`404`): the id is wrong or the game's TTL expired. Unavailable, /// The API could not be asked (or answered unexpectedly): treat the /// connection loss as transient and retry. @@ -117,12 +117,11 @@ pub enum GameEntry { } /// Classify a failed game connection via `GET /api/games/{game_id}`, -/// mirroring the statuses used to refuse the websocket upgrade. +/// mirroring the status used to refuse the websocket upgrade. pub async fn probe_game(game_id: &str) -> GameEntry { match Request::get(&format!("/api/games/{game_id}")).send().await { Ok(resp) => match resp.status() { - 401 => GameEntry::Unauthenticated, - 403 | 404 => GameEntry::Unavailable, + 404 => GameEntry::Unavailable, _ => GameEntry::Transient, }, Err(_) => GameEntry::Transient, diff --git a/web/src/components/copy.rs b/web/src/components/copy.rs new file mode 100644 index 0000000..3778f4c --- /dev/null +++ b/web/src/components/copy.rs @@ -0,0 +1,45 @@ +//! Clipboard copy button, shared by the join-code and watch-link actions. +use std::cell::RefCell; +use std::rc::Rc; + +use sycamore::prelude::*; + +/// A button that copies `text` to the clipboard and shows "Copied!" for a +/// moment. `label` is the idle caption. +#[component(inline_props)] +pub fn CopyButton(text: String, label: String) -> View { + let copied = create_signal(false); + // Held so the reset timer is cancelled if the button is rebuilt (e.g. + // another player joins) while the "Copied!" feedback is showing: + // firing it afterwards would set a disposed signal and panic. + let reset_timer: Rc>> = + Rc::new(RefCell::new(None)); + on_cleanup({ + let reset_timer = reset_timer.clone(); + move || { + reset_timer.borrow_mut().take(); + } + }); + + let on_click = { + let text = text.clone(); + move |_| { + if let Some(win) = web_sys::window() { + let _ = win.navigator().clipboard().write_text(&text); + } + copied.set(true); + *reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new( + 1500, + move || { + copied.set(false); + }, + )); + } + }; + + view! { + button(class="btn btn-outline-light", on:click=on_click) { + (move || if copied.get() { "Copied!".to_string() } else { label.clone() }) + } + } +} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index 106d69f..4842663 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,4 +1,5 @@ pub mod card; +pub mod copy; pub mod nav; pub mod summary; pub mod toast; diff --git a/web/src/components/summary.rs b/web/src/components/summary.rs index 0e66c05..5260dc9 100644 --- a/web/src/components/summary.rs +++ b/web/src/components/summary.rs @@ -260,7 +260,14 @@ pub fn hand_summary_modal( let totals_view = totals(&game); let title = format!("Hand {} — results", summary.hand); - let action = if already_acked { + let action = if game.spectator { + // Spectators cannot acknowledge; they just watch the summary. + view! { + p(class="text-secondary small mb-0") { + "Spectating — the next hand is dealt when every player is ready." + } + } + } else if already_acked { let waiting_text = format!("Waiting for {}…", waiting.join(", ")); view! { button(class="btn btn-warning", disabled=true) { (waiting_text) } diff --git a/web/src/model.rs b/web/src/model.rs index 3060a14..f04b298 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -31,7 +31,7 @@ pub struct PlayerView { pub hand: Option>, } -#[derive(Debug, Clone, Copy, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] pub struct Scores { #[serde(rename = "A")] pub a: i32, @@ -112,11 +112,16 @@ pub struct HandSummary { #[allow(dead_code)] pub struct GameView { pub id: String, + /// The lobby join code; withheld by the server from spectators. #[serde(default)] pub join_code: String, /// Which card game this match is (id from /api/game-types). #[serde(default)] pub game_type: String, + /// Whether the viewer is watching without a seat: no hand, no join + /// code, and actions are rejected by the server. + #[serde(default)] + pub spectator: bool, /// Whether the napola rule is scored in this match. #[serde(default = "default_true")] pub napola: bool, @@ -880,11 +885,39 @@ mod tests { .unwrap(); assert_eq!("playing", g.phase); assert!(g.asso_pigliatutto); + assert!(!g.spectator); assert_eq!(None, g.players[0].hand); assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand); assert_eq!(Some(true), g.your_turn); } + #[test] + fn game_view_parses_spectator_snapshot() { + // A spectator payload: no hand anywhere, no join code, but the + // public table state is present. + let g: GameView = serde_json::from_value(json!({ + "id": "11111111-2222-3333-4444-555555555555", + "game_type": "scopone_scientifico", + "spectator": true, + "phase": "playing", + "target_score": 11, + "scores": {"A": 2, "B": 1}, + "table": ["07C"], + "players": [ + {"sub": "alice", "name": "Alice", "seat": 0, "team": "A", + "cards_left": 5, "captured_count": 12, "scope": 1}, + ], + })) + .unwrap(); + assert!(g.spectator); + assert_eq!("", g.join_code); + assert_eq!(Some(Scores { a: 2, b: 1 }), g.scores); + assert_eq!(vec!["07C".to_string()], g.table); + assert_eq!(None, g.players[0].hand); + assert_eq!(12, g.players[0].captured_count); + assert_eq!(1, g.players[0].scope); + } + #[test] fn move_view_parses_pigliatutto() { let mv: MoveView = serde_json::from_value(json!({ diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index 0994915..6edfcb5 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -1,6 +1,5 @@ //! Live game page: table view over the websocket. use std::cell::Cell; -use std::cell::RefCell; use std::rc::Rc; use sycamore::prelude::*; @@ -8,6 +7,7 @@ use wasm_bindgen_futures::spawn_local; use crate::api; use crate::components::card::{card_back, card_img}; +use crate::components::copy::CopyButton; use crate::components::summary::{hand_summary_modal, summary_rows}; use crate::components::toast::toast; use crate::model::{card_label, hand_sort_key, GameView, MoveView, PlayerView, Scores, ServerMessage}; @@ -19,6 +19,14 @@ fn send_play(socket: Signal>, card: String, capture: Option String { + let origin = web_sys::window() + .and_then(|w| w.location().origin().ok()) + .unwrap_or_default(); + format!("{origin}/game/{id}") +} + /// Panel for one player seat (name, team, hidden card count, stats). fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View { let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else { @@ -154,11 +162,6 @@ fn start_connect(id: Rc, ctx: ConnCtx, alive: Rc>) { let alive = alive.clone(); spawn_local(async move { match api::probe_game(&id).await { - api::GameEntry::Unauthenticated => { - ctx.fatal.set(true); - ctx.error - .set(Some("Session expired — please log in again.".to_string())); - } api::GameEntry::Unavailable => { ctx.fatal.set(true); ctx.error @@ -407,38 +410,16 @@ pub fn GamePage(id: String) -> View { /// Lobby view while waiting for the remaining players. The seat count /// comes from the payload (`seats_open`), so games with other player -/// counts render correctly. +/// counts render correctly. Spectators (no seat, no join code) get a +/// "not started yet" note and the watch link instead. #[component(inline_props)] fn LobbyView(game: GameView) -> View { let seats_open = game .seats_open .unwrap_or_else(|| 4usize.saturating_sub(game.players.len())); + let spectator = game.spectator; let join_code = game.join_code.clone(); - let copied = create_signal(false); - // Held so the reset timer is cancelled if the waiting room is rebuilt - // (e.g. another player joins) while the "Copied!" feedback is showing: - // firing it afterwards would set a disposed signal and panic. - let reset_timer: Rc>> = - Rc::new(RefCell::new(None)); - on_cleanup({ - let reset_timer = reset_timer.clone(); - move || { - reset_timer.borrow_mut().take(); - } - }); - - let copy = { - let join_code = join_code.clone(); - move |_| { - if let Some(win) = web_sys::window() { - let _ = win.navigator().clipboard().write_text(&join_code); - } - copied.set(true); - *reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(1500, move || { - copied.set(false); - })); - } - }; + let watch_link = watch_link(&game.id); let players = game .players @@ -472,18 +453,39 @@ fn LobbyView(game: GameView) -> View { }; let percent_style = format!("width: {percent}%"); + // The server withholds the join code from spectators; they get a + // "not started yet" note and can share the watch link instead. + let share = if spectator { + view! { + p(class="text-secondary") { + "This game hasn't started yet — waiting for the players to take their seats." + } + div(class="d-grid mb-4") { + CopyButton(text=watch_link, label="Copy watch link".to_string()) + } + } + } else { + // Separate clones: each interpolation/prop captures its own copy. + let display_code = join_code.clone(); + let copy_code = join_code.clone(); + view! { + p(class="text-secondary") { "Share this join code with your friends:" } + p(class="code-display") { (display_code) } + div(class="d-grid gap-2 mb-4") { + CopyButton(text=copy_code, label="Copy code".to_string()) + CopyButton(text=watch_link, label="Copy watch link".to_string()) + } + } + }; + view! { div(class="container page-shell py-4") { div(class="card status-card") { div(class="card-body p-4") { - h1(class="h4 mb-3") { "Waiting for players" } - p(class="text-secondary") { "Share this join code with your friends:" } - p(class="code-display") { (join_code) } - div(class="d-grid mb-4") { - button(class="btn btn-outline-light", on:click=copy) { - (move || if copied.get() { "Copied!" } else { "Copy code" }) - } + h1(class="h4 mb-3") { + (if spectator { "Game not started yet" } else { "Waiting for players" }) } + (share) ul(class="list-group list-group-flush text-start mb-3") { (players) (open_seats) @@ -536,7 +538,8 @@ fn table_view( selected: Signal>, now: Signal, ) -> View { - // Own seat: the only player entry carrying a hand. + // Own seat: the only player entry carrying a hand. Spectators have no + // hand, so they watch from seat 0's perspective. let viewer_seat = game .players .iter() @@ -622,18 +625,41 @@ fn table_view( } }); + // Spectators have no hand: render the pivot seat as a regular panel + // so its captured count and scope stay visible where the hand would be. + let bottom = if game.spectator { + seat_panel(game.clone(), viewer_seat, "bottom") + } else { + view! { + div(class="seat-bottom") { + div(class="seat-stats own-stats") { + (my_captured) " captured · " (my_scope) " scope" + } + div(class="hand") { (hand_cards) } + (hint) + } + } + }; + + let spectator_badge = game.spectator.then(|| view! { + span(class="spectator-badge") { "Spectating" } + }); + let watch_link = watch_link(&game.id); + view! { div(class="table-wrap") { div(class="hud") { a(href="/") { "← Lobby" } span { "Hand " (hand_number) } (rule_badge) + (spectator_badge) span(class="hud-scores") { "Team A " (scores.a) " — " (scores.b) " Team B (target " (target_score) ")" } span(class=turn_cls) { (turn_note) } (countdown) + CopyButton(text=watch_link, label="Copy watch link".to_string()) } div(class="table-grid") { (top) @@ -646,13 +672,7 @@ fn table_view( } } (right) - div(class="seat-bottom") { - div(class="seat-stats own-stats") { - (my_captured) " captured · " (my_scope) " scope" - } - div(class="hand") { (hand_cards) } - (hint) - } + (bottom) } } } @@ -713,6 +733,8 @@ fn game_over_view(over: Option<(Scores, Option)>, game: Option Some((scores, winner)) => { let winner = winner.unwrap_or_else(|| "?".to_string()); let line = format!("Team {winner} wins {} – {}", scores.a, scores.b); + // Spectators have no match history to look at. + let spectator = game.as_ref().is_some_and(|g| g.spectator); // Explain the final hand's scoring before the result. let final_summary = game .and_then(|g| g.last_hand) @@ -720,6 +742,9 @@ fn game_over_view(over: Option<(Scores, Option)>, game: Option let rows = summary_rows(s); view! { (rows) } }); + let my_matches = (!spectator).then(|| view! { + a(class="btn btn-outline-light", href="/history") { "My matches" } + }); view! { div(class="overlay") { div(class="picker summary-panel") { @@ -728,7 +753,7 @@ fn game_over_view(over: Option<(Scores, Option)>, game: Option p(class="final-score") { (line) } div(class="gameover-actions") { a(class="btn btn-warning", href="/") { "Back to lobby" } - a(class="btn btn-outline-light", href="/history") { "My matches" } + (my_matches) } } } diff --git a/web/style.css b/web/style.css index 5bd04bb..965e028 100644 --- a/web/style.css +++ b/web/style.css @@ -266,6 +266,15 @@ body { margin-left: 0.25rem; } +.spectator-badge { + background: var(--panel-light); + color: var(--muted); + font-weight: 700; + border-radius: 999px; + padding: 0.15rem 0.6rem; + border: 1px solid var(--muted); +} + /* ---------- overlays ---------- */ .overlay {