Let anyone with the game URL watch as a read-only spectator
CI / Build and push docker image (push) Successful in 3m0s
CI / Build and push docker image (push) Successful in 3m0s
Anonymous users and logged-in non-players can now open /game/<id> 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.
This commit is contained in:
@@ -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/<id>`) 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/`):
|
||||
|
||||
+17
-11
@@ -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
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
@@ -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]},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+6
-7
@@ -105,11 +105,11 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, 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,
|
||||
|
||||
@@ -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<RefCell<Option<gloo_timers::callback::Timeout>>> =
|
||||
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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod card;
|
||||
pub mod copy;
|
||||
pub mod nav;
|
||||
pub mod summary;
|
||||
pub mod toast;
|
||||
|
||||
@@ -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) }
|
||||
|
||||
+34
-1
@@ -31,7 +31,7 @@ pub struct PlayerView {
|
||||
pub hand: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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!({
|
||||
|
||||
+73
-48
@@ -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<Option<GameSocket>>, card: String, capture: Option<V
|
||||
}
|
||||
}
|
||||
|
||||
/// The absolute, shareable URL of this game's page, for the clipboard.
|
||||
fn watch_link(id: &str) -> 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<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
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<RefCell<Option<gloo_timers::callback::Timeout>>> =
|
||||
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<Option<String>>,
|
||||
now: Signal<f64>,
|
||||
) -> 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<String>)>, game: Option<GameView>
|
||||
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<String>)>, game: Option<GameView>
|
||||
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<String>)>, game: Option<GameView>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user