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
|
random legal card for them (randomizing among the legal captures when the
|
||||||
rules require a capture), so one absent player cannot stall the table.
|
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
|
## Development
|
||||||
|
|
||||||
Backend (from `server/`):
|
Backend (from `server/`):
|
||||||
|
|||||||
+17
-11
@@ -262,7 +262,8 @@ existed):
|
|||||||
## REST API
|
## REST API
|
||||||
|
|
||||||
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
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 |
|
| 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` | 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 |
|
| `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/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/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/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=`) |
|
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
||||||
|
|
||||||
## WebSocket protocol
|
## WebSocket protocol
|
||||||
|
|
||||||
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone
|
||||||
players are accepted; the upgrade is refused before the handshake
|
who knows the game id is accepted: seated players get their personalized
|
||||||
completes with an HTTP status mirroring `GET /api/games/{id}`: `401`
|
view, everyone else — anonymous users and authenticated non-players — is
|
||||||
unauthenticated, `404` unknown game, `403` not seated. (Browsers report
|
a read-only **spectator**. Only an unknown game id is refused before the
|
||||||
a refused handshake as an abnormal close — there is no close code to
|
handshake completes, with `404`. (Browsers report a refused handshake as
|
||||||
read.)
|
an abnormal close — there is no close code to read.)
|
||||||
|
|
||||||
Server → client messages are JSON objects with a `type`:
|
Server → client messages are JSON objects with a `type`:
|
||||||
|
|
||||||
- `{"type": "state", "game": {...}}` — personalized game view. Your own
|
- `{"type": "state", "game": {...}}` — personalized game view. Your own
|
||||||
hand is included; other players expose only their card counts.
|
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": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
|
||||||
- `{"type": "error", "code": "illegal_move", "message": "..."}`
|
- `{"type": "error", "code": "illegal_move", "message": "..."}`
|
||||||
|
|
||||||
Client → server messages are JSON objects with an `action`. The
|
Client → server messages are JSON objects with an `action`. The
|
||||||
platform handles `state` (resend the snapshot) itself; every other action
|
platform handles `state` (resend the snapshot) itself; every other action
|
||||||
is dispatched to the session's game engine with the rest of the message
|
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
|
```json
|
||||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||||
@@ -314,7 +319,8 @@ as its payload. For scopone scientifico:
|
|||||||
after `HAND_ACK_TIMEOUT_SECONDS`.
|
after `HAND_ACK_TIMEOUT_SECONDS`.
|
||||||
- `state` asks for a fresh snapshot.
|
- `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
|
### Turn timeout
|
||||||
|
|
||||||
|
|||||||
@@ -191,12 +191,17 @@ class GameEngine(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
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]:
|
||||||
"""The personalized game view for one player.
|
"""The personalized game view for one viewer.
|
||||||
|
|
||||||
Must hide information the player is not entitled to (e.g. other
|
Must hide information the viewer is not entitled to (e.g. other
|
||||||
players' hands). The platform merges in the envelope fields
|
players' hands). ``user_sub`` is ``None`` for a spectator — an
|
||||||
(``id``, ``join_code``, ``game_type``) itself.
|
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
|
@abstractmethod
|
||||||
@@ -248,6 +253,13 @@ class GameEngine(ABC):
|
|||||||
``kind``/``token`` no longer match the live state.
|
``kind``/``token`` no longer match the live state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
def game_over_view(
|
||||||
"""Extra fields merged into the ``game_over`` websocket message."""
|
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 {}
|
return {}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import secrets
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from logging import getLogger
|
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.core import HttpContext, KayaApp
|
||||||
from kaya.openapi import operation
|
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)
|
engine = platform.registry.require(session.game_type)
|
||||||
return {
|
seated = sub is not None and session.seated(sub)
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
"id": session.id,
|
"id": session.id,
|
||||||
"join_code": session.join_code,
|
|
||||||
"game_type": session.game_type,
|
"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]:
|
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}")
|
@app.GET("/api/games/${game_id}")
|
||||||
@operation(summary="Get a game snapshot",
|
@operation(summary="Get a game snapshot",
|
||||||
description="Only seated players may read a game; the view is "
|
description="Anyone who knows the game id may read a game. "
|
||||||
"personalized by the engine (e.g. hidden hands).",
|
"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"],
|
tags=["games"],
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "The personalized game state"},
|
200: {"description": "The personalized (player) or "
|
||||||
401: {"description": "Authentication required"},
|
"read-only (spectator) game state"},
|
||||||
403: {"description": "Not a player in this game"},
|
|
||||||
404: {"description": "Game not found"},
|
404: {"description": "Game not found"},
|
||||||
})
|
})
|
||||||
@require_auth(platform.oidc)
|
|
||||||
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
async def get_game(ctx: HttpContext, game_id: str) -> None:
|
||||||
session = await platform.game_store.load(game_id)
|
session = await platform.game_store.load(game_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
await send_error(ctx, 404, "game not found")
|
await send_error(ctx, 404, "game not found")
|
||||||
return
|
return
|
||||||
user = current_user(platform.oidc, ctx)
|
user = current_user(platform.oidc, ctx)
|
||||||
assert user is not None
|
await send_json(
|
||||||
if not session.seated(user.sub):
|
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
||||||
await send_error(ctx, 403, "forbidden")
|
)
|
||||||
return
|
|
||||||
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
|
|
||||||
|
|||||||
@@ -2,12 +2,21 @@
|
|||||||
|
|
||||||
Clients connect to ``/ws/games/{game_id}`` using their session cookie
|
Clients connect to ``/ws/games/{game_id}`` using their session cookie
|
||||||
(the OIDC login stores the user in the session, which the session mixin
|
(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
|
The platform owns the connection lifecycle — authentication, the
|
||||||
check, the subscription/publish fan-out and the message envelope — and
|
subscription/publish fan-out and the message envelope — and delegates
|
||||||
delegates the game-specific actions to the engine registered for the
|
the game-specific actions to the engine registered for the session's
|
||||||
session's ``game_type``.
|
``game_type``.
|
||||||
|
|
||||||
Protocol
|
Protocol
|
||||||
--------
|
--------
|
||||||
@@ -48,7 +57,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from logging import getLogger
|
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
|
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
@@ -83,17 +92,21 @@ async def _reject(ws: WebSocket, status: int) -> None:
|
|||||||
await ws.close(status)
|
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)
|
engine = platform.registry.require(session.game_type)
|
||||||
return {
|
seated = sub is not None and session.seated(sub)
|
||||||
"type": "state",
|
payload: Dict[str, Any] = {
|
||||||
"game": {
|
"id": session.id,
|
||||||
"id": session.id,
|
"game_type": session.game_type,
|
||||||
"join_code": session.join_code,
|
"spectator": not seated,
|
||||||
"game_type": session.game_type,
|
|
||||||
**engine.view_for(session, sub),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
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:
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
@@ -101,24 +114,19 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
|
|
||||||
@app.websocket("/ws/games/${game_id}")
|
@app.websocket("/ws/games/${game_id}")
|
||||||
async def game_socket(ws: WebSocket, game_id: str) -> None:
|
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)
|
user = auth.get_ws_user(ws)
|
||||||
if user is None:
|
sub: Optional[str] = user.sub if user is not None else None
|
||||||
log.debug("websocket %s rejected: no authenticated user", game_id)
|
|
||||||
await _reject(ws, 401)
|
|
||||||
return
|
|
||||||
|
|
||||||
session = await platform.game_store.load(game_id)
|
session = await platform.game_store.load(game_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
log.debug("websocket rejected: unknown game %s", game_id)
|
log.debug("websocket rejected: unknown game %s", game_id)
|
||||||
await _reject(ws, 404)
|
await _reject(ws, 404)
|
||||||
return
|
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()
|
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()
|
send_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -132,7 +140,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
# ends momentarily.
|
# ends momentarily.
|
||||||
pass
|
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
|
# Backstop: make sure the current deadline is queued even if its
|
||||||
# entry was lost (e.g. the queue was flushed while the game lived
|
# entry was lost (e.g. the queue was flushed while the game lived
|
||||||
# on thanks to its sliding TTL).
|
# 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:
|
async with platform.game_store.subscribe(game_id) as events:
|
||||||
forward = asyncio.create_task(
|
forward = asyncio.create_task(
|
||||||
_forward(platform, events, game_id, user.sub, send)
|
_forward(platform, events, game_id, sub, send)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async for message in ws:
|
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):
|
if message.kind != "text" or not isinstance(message.data, str):
|
||||||
await send(_error("expected a text frame with a JSON object"))
|
await send(_error("expected a text frame with a JSON object"))
|
||||||
continue
|
continue
|
||||||
await _handle_message(platform, send, game_id, user.sub, message.data)
|
await _handle_message(platform, send, game_id, sub, message.data)
|
||||||
finally:
|
finally:
|
||||||
forward.cancel()
|
forward.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await forward
|
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(
|
async def _forward(
|
||||||
platform: "Platform",
|
platform: "Platform",
|
||||||
events,
|
events,
|
||||||
game_id: str,
|
game_id: str,
|
||||||
sub: str,
|
sub: Optional[str],
|
||||||
send: Send,
|
send: Send,
|
||||||
) -> None:
|
) -> None:
|
||||||
async for _ in events:
|
async for _ in events:
|
||||||
@@ -181,7 +189,7 @@ async def _forward(
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_message(
|
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:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
@@ -210,7 +218,7 @@ async def _handle_action(
|
|||||||
platform: "Platform",
|
platform: "Platform",
|
||||||
send: Send,
|
send: Send,
|
||||||
game_id: str,
|
game_id: str,
|
||||||
sub: str,
|
sub: Optional[str],
|
||||||
action: str,
|
action: str,
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -219,6 +227,13 @@ async def _handle_action(
|
|||||||
if session is None:
|
if session is None:
|
||||||
await send(_error("game not found", code="not_found"))
|
await send(_error("game not found", code="not_found"))
|
||||||
return
|
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)
|
engine = platform.registry.require(session.game_type)
|
||||||
payload = {key: value for key, value in data.items() if key != "action"}
|
payload = {key: value for key, value in data.items() if key != "action"}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class DummyEngine(GameEngine):
|
|||||||
state["finished"] = True
|
state["finished"] = True
|
||||||
state["winner"] = user_sub
|
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
|
state = session.state
|
||||||
return {
|
return {
|
||||||
"started": state["started"],
|
"started": state["started"],
|
||||||
@@ -146,7 +146,7 @@ class DummyEngine(GameEngine):
|
|||||||
"winner": state["winner"],
|
"winner": state["winner"],
|
||||||
"plays": len(state["plays"]),
|
"plays": len(state["plays"]),
|
||||||
"target": state["target"],
|
"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]:
|
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(game_id, snap["id"])
|
||||||
self.assertEqual("dummy", snap["game_type"])
|
self.assertEqual("dummy", snap["game_type"])
|
||||||
self.assertTrue(snap["viewer_seated"])
|
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"):
|
with oidc_user(platform.oidc, "mallory"):
|
||||||
forbidden = await client.get(f"/api/games/{game_id}")
|
watched = await client.get(f"/api/games/{game_id}")
|
||||||
self.assertEqual(403, forbidden.status_code)
|
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_test
|
||||||
async def test_join_starts_game(self) -> None:
|
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 kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage
|
||||||
|
|
||||||
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
from helpers import async_test, make_platform, make_user, oidc_user, ws_users
|
||||||
from tavolo.platform import GameSession, Seat
|
|
||||||
|
|
||||||
|
|
||||||
class AbruptCloseSocket(WebSocket):
|
class AbruptCloseSocket(WebSocket):
|
||||||
@@ -117,6 +116,9 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual("dummy", first["game"]["game_type"])
|
self.assertEqual("dummy", first["game"]["game_type"])
|
||||||
self.assertEqual(0, first["game"]["plays"])
|
self.assertEqual(0, first["game"]["plays"])
|
||||||
self.assertEqual(game_id, first["game"]["id"])
|
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:
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
|
||||||
alice_first = await alice_ws.receive_json()
|
alice_first = await alice_ws.receive_json()
|
||||||
@@ -237,7 +239,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual(404, caught.exception.code)
|
self.assertEqual(404, caught.exception.code)
|
||||||
|
|
||||||
@async_test
|
@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()
|
app, platform, _ = make_platform()
|
||||||
api_transport = ASGITransport(app=app)
|
api_transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
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)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
with ws_users([make_user("mallory")]):
|
with ws_users([make_user("mallory")]):
|
||||||
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
first = await ws.receive_json()
|
||||||
pass
|
self.assertEqual("state", first["type"])
|
||||||
self.assertEqual(403, caught.exception.code)
|
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_test
|
||||||
async def test_unauthenticated_is_closed(self) -> None:
|
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
||||||
app, _, _ = make_platform()
|
app, platform, _ = make_platform()
|
||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
api_transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
with ws_users([]):
|
state = await self._started_game(client, platform.oidc)
|
||||||
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
game_id = state["id"]
|
||||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
|
||||||
pass
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
self.assertEqual(401, caught.exception.code)
|
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):
|
class RejectionStatusTest(unittest.TestCase):
|
||||||
@@ -271,33 +290,19 @@ class RejectionStatusTest(unittest.TestCase):
|
|||||||
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
|
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
|
||||||
close code like 4401 is not a valid status and panics the worker
|
close code like 4401 is not a valid status and panics the worker
|
||||||
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
|
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
|
||||||
dies). Drive the rejections through the app with a recording socket
|
dies). Drive the rejection through the app with a recording socket
|
||||||
so the invariant is checked without a real server.
|
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_test
|
||||||
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
|
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
|
||||||
app, platform, _ = make_platform()
|
app, _, _ = make_platform()
|
||||||
session = GameSession(
|
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
|
||||||
id="seated-game",
|
ws = RecordingSocket("/ws/games/no-such-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)
|
|
||||||
with ws_users(users):
|
with ws_users(users):
|
||||||
await app.handle_websocket(ws)
|
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:
|
for code in ws.close_codes:
|
||||||
self.assertTrue(100 <= code <= 599, f"{label}: {code} is not an HTTP status")
|
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
|
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.
|
"""Serialize ``state`` hiding other players' hands.
|
||||||
|
|
||||||
Hands are reduced to a count, except for the requesting player's own
|
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
|
hand. Non-seated viewers — including spectators, for whom ``sub`` is
|
||||||
merges the session envelope (``id``, ``join_code``, ``game_type``)
|
``None`` — simply see no hand at all. The platform merges the session
|
||||||
into the view itself.
|
envelope (``id``, ``join_code`` for seated players, ``game_type``,
|
||||||
|
``spectator``) into the view itself.
|
||||||
"""
|
"""
|
||||||
viewer = state.player_for(sub)
|
viewer = state.player_for(sub)
|
||||||
players: List[Dict[str, object]] = []
|
players: List[Dict[str, object]] = []
|
||||||
|
|||||||
@@ -171,13 +171,13 @@ class ScoponeEngine(GameEngine):
|
|||||||
else:
|
else:
|
||||||
raise IllegalMove(f"unknown action: {action!r}")
|
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)
|
return engine.state_for_player(session.state, user_sub)
|
||||||
|
|
||||||
def is_finished(self, session: GameSession) -> bool:
|
def is_finished(self, session: GameSession) -> bool:
|
||||||
return session.state.phase == PHASE_FINISHED
|
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
|
state: ScoponeState = session.state
|
||||||
return {
|
return {
|
||||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ class ScoponeState:
|
|||||||
|
|
||||||
# -- helpers ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
def player_for(self, sub: str) -> Optional[PlayerState]:
|
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
|
||||||
for player in self.players:
|
for player in self.players:
|
||||||
if player.sub == sub:
|
if player.sub == sub:
|
||||||
return player
|
return player
|
||||||
|
|||||||
@@ -574,6 +574,27 @@ class MatchFlowTest(unittest.TestCase):
|
|||||||
self.assertEqual(["07C"], view["table"])
|
self.assertEqual(["07C"], view["table"])
|
||||||
self.assertTrue(view.get("your_turn"))
|
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:
|
def test_legal_moves_only_for_player_on_turn(self) -> None:
|
||||||
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
|
||||||
table=["07C"])
|
table=["07C"])
|
||||||
|
|||||||
@@ -160,6 +160,17 @@ class ViewTest(unittest.TestCase):
|
|||||||
self.assertNotIn("join_code", view)
|
self.assertNotIn("join_code", view)
|
||||||
self.assertNotIn("game_type", 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:
|
def test_lobby_view(self) -> None:
|
||||||
engine = ScoponeEngine()
|
engine = ScoponeEngine()
|
||||||
session = _session(engine, target_score=16)
|
session = _session(engine, target_score=16)
|
||||||
|
|||||||
@@ -39,9 +39,19 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(200, snapshot.status_code)
|
self.assertEqual(200, snapshot.status_code)
|
||||||
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
|
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"):
|
with oidc_user("mallory"):
|
||||||
forbidden = await client.get(f"/api/games/{game_id}")
|
watched = await client.get(f"/api/games/{game_id}")
|
||||||
self.assertEqual(403, forbidden.status_code)
|
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_test
|
||||||
async def test_join_fills_seats_and_starts_game(self) -> None:
|
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})
|
started = await client.post("/api/games/join", json={"code": code})
|
||||||
self.assertEqual(200, started.status_code)
|
self.assertEqual(200, started.status_code)
|
||||||
state = started.json()
|
state = started.json()
|
||||||
|
game_id = state["id"]
|
||||||
self.assertEqual("playing", state["phase"])
|
self.assertEqual("playing", state["phase"])
|
||||||
self.assertEqual(4, len(state["players"]))
|
self.assertEqual(4, len(state["players"]))
|
||||||
self.assertEqual([], state["table"])
|
self.assertEqual([], state["table"])
|
||||||
@@ -72,6 +83,21 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertNotIn("hand", state["players"][0])
|
self.assertNotIn("hand", state["players"][0])
|
||||||
self.assertEqual(1, state["turn"])
|
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_test
|
||||||
async def test_create_napola_option(self) -> None:
|
async def test_create_napola_option(self) -> None:
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual(404, caught.exception.code)
|
self.assertEqual(404, caught.exception.code)
|
||||||
|
|
||||||
@async_test
|
@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)
|
api_transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
state = await self._started_game(client)
|
state = await self._started_game(client)
|
||||||
@@ -143,20 +143,42 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
with ws_users([make_user("mallory")]):
|
with ws_users([make_user("mallory")]):
|
||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
first = await ws.receive_json()
|
||||||
pass
|
self.assertEqual("state", first["type"])
|
||||||
self.assertEqual(403, caught.exception.code)
|
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_test
|
||||||
async def test_unauthenticated_is_closed(self) -> None:
|
async def test_anonymous_user_watches_as_spectator(self) -> None:
|
||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
api_transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
||||||
with ws_users([]):
|
state = await self._started_game(client)
|
||||||
with self.assertRaises(WebSocketDisconnect) as caught:
|
game_id = state["id"]
|
||||||
async with aconnect_ws("/ws/games/whatever", ws_client):
|
|
||||||
pass
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
self.assertEqual(401, caught.exception.code)
|
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:
|
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
|
/// A refused WebSocket handshake is invisible to the browser: the close
|
||||||
/// event reports an abnormal closure (1006) with no HTTP status, so the
|
/// 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
|
/// 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 {
|
pub enum GameEntry {
|
||||||
/// Not logged in (`401` from `GET /api/games/{id}`).
|
/// Unknown game (`404`): the id is wrong or the game's TTL expired.
|
||||||
Unauthenticated,
|
|
||||||
/// Unknown game (`404`) or the user is not seated (`403`).
|
|
||||||
Unavailable,
|
Unavailable,
|
||||||
/// The API could not be asked (or answered unexpectedly): treat the
|
/// The API could not be asked (or answered unexpectedly): treat the
|
||||||
/// connection loss as transient and retry.
|
/// connection loss as transient and retry.
|
||||||
@@ -117,12 +117,11 @@ pub enum GameEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a failed game connection via `GET /api/games/{game_id}`,
|
/// 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 {
|
pub async fn probe_game(game_id: &str) -> GameEntry {
|
||||||
match Request::get(&format!("/api/games/{game_id}")).send().await {
|
match Request::get(&format!("/api/games/{game_id}")).send().await {
|
||||||
Ok(resp) => match resp.status() {
|
Ok(resp) => match resp.status() {
|
||||||
401 => GameEntry::Unauthenticated,
|
404 => GameEntry::Unavailable,
|
||||||
403 | 404 => GameEntry::Unavailable,
|
|
||||||
_ => GameEntry::Transient,
|
_ => GameEntry::Transient,
|
||||||
},
|
},
|
||||||
Err(_) => 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 card;
|
||||||
|
pub mod copy;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|||||||
@@ -260,7 +260,14 @@ pub fn hand_summary_modal(
|
|||||||
let totals_view = totals(&game);
|
let totals_view = totals(&game);
|
||||||
let title = format!("Hand {} — results", summary.hand);
|
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(", "));
|
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
||||||
view! {
|
view! {
|
||||||
button(class="btn btn-warning", disabled=true) { (waiting_text) }
|
button(class="btn btn-warning", disabled=true) { (waiting_text) }
|
||||||
|
|||||||
+34
-1
@@ -31,7 +31,7 @@ pub struct PlayerView {
|
|||||||
pub hand: Option<Vec<String>>,
|
pub hand: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
||||||
pub struct Scores {
|
pub struct Scores {
|
||||||
#[serde(rename = "A")]
|
#[serde(rename = "A")]
|
||||||
pub a: i32,
|
pub a: i32,
|
||||||
@@ -112,11 +112,16 @@ pub struct HandSummary {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct GameView {
|
pub struct GameView {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// The lobby join code; withheld by the server from spectators.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub join_code: String,
|
pub join_code: String,
|
||||||
/// Which card game this match is (id from /api/game-types).
|
/// Which card game this match is (id from /api/game-types).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub game_type: String,
|
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.
|
/// Whether the napola rule is scored in this match.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub napola: bool,
|
pub napola: bool,
|
||||||
@@ -880,11 +885,39 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!("playing", g.phase);
|
assert_eq!("playing", g.phase);
|
||||||
assert!(g.asso_pigliatutto);
|
assert!(g.asso_pigliatutto);
|
||||||
|
assert!(!g.spectator);
|
||||||
assert_eq!(None, g.players[0].hand);
|
assert_eq!(None, g.players[0].hand);
|
||||||
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
|
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
|
||||||
assert_eq!(Some(true), g.your_turn);
|
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]
|
#[test]
|
||||||
fn move_view_parses_pigliatutto() {
|
fn move_view_parses_pigliatutto() {
|
||||||
let mv: MoveView = serde_json::from_value(json!({
|
let mv: MoveView = serde_json::from_value(json!({
|
||||||
|
|||||||
+73
-48
@@ -1,6 +1,5 @@
|
|||||||
//! Live game page: table view over the websocket.
|
//! Live game page: table view over the websocket.
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
@@ -8,6 +7,7 @@ use wasm_bindgen_futures::spawn_local;
|
|||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::card::{card_back, card_img};
|
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::summary::{hand_summary_modal, summary_rows};
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{card_label, hand_sort_key, GameView, MoveView, PlayerView, Scores, ServerMessage};
|
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).
|
/// Panel for one player seat (name, team, hidden card count, stats).
|
||||||
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||||
let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else {
|
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();
|
let alive = alive.clone();
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::probe_game(&id).await {
|
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 => {
|
api::GameEntry::Unavailable => {
|
||||||
ctx.fatal.set(true);
|
ctx.fatal.set(true);
|
||||||
ctx.error
|
ctx.error
|
||||||
@@ -407,38 +410,16 @@ pub fn GamePage(id: String) -> View {
|
|||||||
|
|
||||||
/// Lobby view while waiting for the remaining players. The seat count
|
/// Lobby view while waiting for the remaining players. The seat count
|
||||||
/// comes from the payload (`seats_open`), so games with other player
|
/// 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)]
|
#[component(inline_props)]
|
||||||
fn LobbyView(game: GameView) -> View {
|
fn LobbyView(game: GameView) -> View {
|
||||||
let seats_open = game
|
let seats_open = game
|
||||||
.seats_open
|
.seats_open
|
||||||
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
|
||||||
|
let spectator = game.spectator;
|
||||||
let join_code = game.join_code.clone();
|
let join_code = game.join_code.clone();
|
||||||
let copied = create_signal(false);
|
let watch_link = watch_link(&game.id);
|
||||||
// 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 players = game
|
let players = game
|
||||||
.players
|
.players
|
||||||
@@ -472,18 +453,39 @@ fn LobbyView(game: GameView) -> View {
|
|||||||
};
|
};
|
||||||
let percent_style = format!("width: {percent}%");
|
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! {
|
view! {
|
||||||
div(class="container page-shell py-4") {
|
div(class="container page-shell py-4") {
|
||||||
div(class="card status-card") {
|
div(class="card status-card") {
|
||||||
div(class="card-body p-4") {
|
div(class="card-body p-4") {
|
||||||
h1(class="h4 mb-3") { "Waiting for players" }
|
h1(class="h4 mb-3") {
|
||||||
p(class="text-secondary") { "Share this join code with your friends:" }
|
(if spectator { "Game not started yet" } else { "Waiting for players" })
|
||||||
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" })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
(share)
|
||||||
ul(class="list-group list-group-flush text-start mb-3") {
|
ul(class="list-group list-group-flush text-start mb-3") {
|
||||||
(players)
|
(players)
|
||||||
(open_seats)
|
(open_seats)
|
||||||
@@ -536,7 +538,8 @@ fn table_view(
|
|||||||
selected: Signal<Option<String>>,
|
selected: Signal<Option<String>>,
|
||||||
now: Signal<f64>,
|
now: Signal<f64>,
|
||||||
) -> View {
|
) -> 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
|
let viewer_seat = game
|
||||||
.players
|
.players
|
||||||
.iter()
|
.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! {
|
view! {
|
||||||
div(class="table-wrap") {
|
div(class="table-wrap") {
|
||||||
div(class="hud") {
|
div(class="hud") {
|
||||||
a(href="/") { "← Lobby" }
|
a(href="/") { "← Lobby" }
|
||||||
span { "Hand " (hand_number) }
|
span { "Hand " (hand_number) }
|
||||||
(rule_badge)
|
(rule_badge)
|
||||||
|
(spectator_badge)
|
||||||
span(class="hud-scores") {
|
span(class="hud-scores") {
|
||||||
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
||||||
(target_score) ")"
|
(target_score) ")"
|
||||||
}
|
}
|
||||||
span(class=turn_cls) { (turn_note) }
|
span(class=turn_cls) { (turn_note) }
|
||||||
(countdown)
|
(countdown)
|
||||||
|
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||||
}
|
}
|
||||||
div(class="table-grid") {
|
div(class="table-grid") {
|
||||||
(top)
|
(top)
|
||||||
@@ -646,13 +672,7 @@ fn table_view(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(right)
|
(right)
|
||||||
div(class="seat-bottom") {
|
(bottom)
|
||||||
div(class="seat-stats own-stats") {
|
|
||||||
(my_captured) " captured · " (my_scope) " scope"
|
|
||||||
}
|
|
||||||
div(class="hand") { (hand_cards) }
|
|
||||||
(hint)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -713,6 +733,8 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
|||||||
Some((scores, winner)) => {
|
Some((scores, winner)) => {
|
||||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||||
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
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.
|
// Explain the final hand's scoring before the result.
|
||||||
let final_summary = game
|
let final_summary = game
|
||||||
.and_then(|g| g.last_hand)
|
.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);
|
let rows = summary_rows(s);
|
||||||
view! { (rows) }
|
view! { (rows) }
|
||||||
});
|
});
|
||||||
|
let my_matches = (!spectator).then(|| view! {
|
||||||
|
a(class="btn btn-outline-light", href="/history") { "My matches" }
|
||||||
|
});
|
||||||
view! {
|
view! {
|
||||||
div(class="overlay") {
|
div(class="overlay") {
|
||||||
div(class="picker summary-panel") {
|
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) }
|
p(class="final-score") { (line) }
|
||||||
div(class="gameover-actions") {
|
div(class="gameover-actions") {
|
||||||
a(class="btn btn-warning", href="/") { "Back to lobby" }
|
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;
|
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 ---------- */
|
/* ---------- overlays ---------- */
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
|
|||||||
Reference in New Issue
Block a user