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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user