Let anyone with the game URL watch as a read-only spectator
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:
2026-10-06 12:51:53 +08:00
parent adc3d340b1
commit b5219062f2
22 changed files with 453 additions and 185 deletions
+17 -11
View File
@@ -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