Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59064e580
|
||
|
|
a89bd06010
|
||
|
|
5eccaafadc
|
||
|
|
6e9d1a4652
|
||
|
|
723b3b11cf
|
||
|
|
ed850aa73c
|
No files matched your search
@@ -52,15 +52,6 @@ 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/`):
|
||||||
|
|||||||
+11
-17
@@ -262,8 +262,7 @@ 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`, `/api/leaderboard` and the game snapshot
|
`/api/game-types` and `/api/leaderboard` require authentication.
|
||||||
(`GET /api/games/{id}`) require authentication.
|
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -271,36 +270,32 @@ 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}` | 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/games/{id}` | Personalized snapshot (only your own hand is visible) |
|
||||||
| `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}`; the session cookie is optional. Anyone
|
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
|
||||||
who knows the game id is accepted: seated players get their personalized
|
players are accepted; the upgrade is refused before the handshake
|
||||||
view, everyone else — anonymous users and authenticated non-players — is
|
completes with an HTTP status mirroring `GET /api/games/{id}`: `401`
|
||||||
a read-only **spectator**. Only an unknown game id is refused before the
|
unauthenticated, `404` unknown game, `403` not seated. (Browsers report
|
||||||
handshake completes, with `404`. (Browsers report a refused handshake as
|
a refused handshake as an abnormal close — there is no close code to
|
||||||
an abnormal close — there is no close code to read.)
|
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.
|
||||||
Spectators get no hand at all, `spectator: true` marks the view and
|
`your_turn: true` is present when it is your move.
|
||||||
`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. Spectators may only send `state`/`sync`: any other
|
as its payload. For scopone scientifico:
|
||||||
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"]}
|
||||||
@@ -319,8 +314,7 @@ 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 every connected
|
After every accepted move the new state is broadcast to all four players.
|
||||||
client, spectators included.
|
|
||||||
|
|
||||||
### Turn timeout
|
### Turn timeout
|
||||||
|
|
||||||
|
|||||||
@@ -191,17 +191,12 @@ class GameEngine(ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||||
"""The personalized game view for one viewer.
|
"""The personalized game view for one player.
|
||||||
|
|
||||||
Must hide information the viewer is not entitled to (e.g. other
|
Must hide information the player is not entitled to (e.g. other
|
||||||
players' hands). ``user_sub`` is ``None`` for a spectator — an
|
players' hands). The platform merges in the envelope fields
|
||||||
anonymous user or an authenticated user who is not seated — and
|
(``id``, ``join_code``, ``game_type``) itself.
|
||||||
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
|
||||||
@@ -253,13 +248,6 @@ class GameEngine(ABC):
|
|||||||
``kind``/``token`` no longer match the live state.
|
``kind``/``token`` no longer match the live state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def game_over_view(
|
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||||
self, session: GameSession, user_sub: Optional[str]
|
"""Extra fields merged into the ``game_over`` websocket message."""
|
||||||
) -> 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, Optional
|
from typing import TYPE_CHECKING, Any, Dict
|
||||||
|
|
||||||
from kaya.core import HttpContext, KayaApp
|
from kaya.core import HttpContext, KayaApp
|
||||||
from kaya.openapi import operation
|
from kaya.openapi import operation
|
||||||
@@ -76,21 +76,14 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _view_payload(
|
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
||||||
platform: "Platform", session: GameSession, sub: Optional[str]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
engine = platform.registry.require(session.game_type)
|
engine = platform.registry.require(session.game_type)
|
||||||
seated = sub is not None and session.seated(sub)
|
return {
|
||||||
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,
|
||||||
"spectator": not seated,
|
**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 payload
|
|
||||||
|
|
||||||
|
|
||||||
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
|
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
|
||||||
@@ -317,24 +310,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="Anyone who knows the game id may read a game. "
|
description="Only seated players may read a game; the view is "
|
||||||
"Seated players get the personalized view (their "
|
"personalized by the engine (e.g. hidden hands).",
|
||||||
"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 (player) or "
|
200: {"description": "The personalized game state"},
|
||||||
"read-only (spectator) game state"},
|
401: {"description": "Authentication required"},
|
||||||
|
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)
|
||||||
await send_json(
|
assert user is not None
|
||||||
ctx, 200, _view_payload(platform, session, user.sub if user else 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))
|
||||||
@@ -2,21 +2,12 @@
|
|||||||
|
|
||||||
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). Anyone who knows the game id is accepted:
|
loads onto the websocket). Only seated players are 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, the
|
The platform owns the connection lifecycle — authentication, seat
|
||||||
subscription/publish fan-out and the message envelope — and delegates
|
check, the subscription/publish fan-out and the message envelope — and
|
||||||
the game-specific actions to the engine registered for the session's
|
delegates the game-specific actions to the engine registered for the
|
||||||
``game_type``.
|
session's ``game_type``.
|
||||||
|
|
||||||
Protocol
|
Protocol
|
||||||
--------
|
--------
|
||||||
@@ -57,7 +48,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, Optional
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
|
||||||
|
|
||||||
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
|
from kaya.core import KayaApp, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
@@ -92,21 +83,17 @@ async def _reject(ws: WebSocket, status: int) -> None:
|
|||||||
await ws.close(status)
|
await ws.close(status)
|
||||||
|
|
||||||
|
|
||||||
def _state_message(
|
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
|
||||||
platform: "Platform", session: GameSession, sub: Optional[str]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
engine = platform.registry.require(session.game_type)
|
engine = platform.registry.require(session.game_type)
|
||||||
seated = sub is not None and session.seated(sub)
|
return {
|
||||||
payload: Dict[str, Any] = {
|
"type": "state",
|
||||||
"id": session.id,
|
"game": {
|
||||||
"game_type": session.game_type,
|
"id": session.id,
|
||||||
"spectator": not seated,
|
"join_code": session.join_code,
|
||||||
|
"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:
|
||||||
@@ -114,19 +101,24 @@ 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)
|
||||||
sub: Optional[str] = user.sub if user is not None else None
|
if user is 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", sub or "spectator", game_id)
|
log.info("%s connected to game %s", user.sub, game_id)
|
||||||
|
|
||||||
send_lock = asyncio.Lock()
|
send_lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -140,7 +132,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
# ends momentarily.
|
# ends momentarily.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await send(_state_message(platform, session, sub))
|
await send(_state_message(platform, session, user.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).
|
||||||
@@ -148,7 +140,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, sub, send)
|
_forward(platform, events, game_id, user.sub, send)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
async for message in ws:
|
async for message in ws:
|
||||||
@@ -157,19 +149,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, sub, message.data)
|
await _handle_message(platform, send, game_id, user.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", sub or "spectator", game_id)
|
log.debug("%s disconnected from game %s", user.sub, game_id)
|
||||||
|
|
||||||
|
|
||||||
async def _forward(
|
async def _forward(
|
||||||
platform: "Platform",
|
platform: "Platform",
|
||||||
events,
|
events,
|
||||||
game_id: str,
|
game_id: str,
|
||||||
sub: Optional[str],
|
sub: str,
|
||||||
send: Send,
|
send: Send,
|
||||||
) -> None:
|
) -> None:
|
||||||
async for _ in events:
|
async for _ in events:
|
||||||
@@ -189,7 +181,7 @@ async def _forward(
|
|||||||
|
|
||||||
|
|
||||||
async def _handle_message(
|
async def _handle_message(
|
||||||
platform: "Platform", send: Send, game_id: str, sub: Optional[str], raw: str
|
platform: "Platform", send: Send, game_id: str, sub: str, raw: str
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
data = json.loads(raw)
|
||||||
@@ -218,7 +210,7 @@ async def _handle_action(
|
|||||||
platform: "Platform",
|
platform: "Platform",
|
||||||
send: Send,
|
send: Send,
|
||||||
game_id: str,
|
game_id: str,
|
||||||
sub: Optional[str],
|
sub: str,
|
||||||
action: str,
|
action: str,
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -227,13 +219,6 @@ 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: Optional[str]) -> Dict[str, Any]:
|
def view_for(self, session: GameSession, user_sub: 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": user_sub is not None and session.seated(user_sub),
|
"viewer_seated": session.seated(user_sub),
|
||||||
}
|
}
|
||||||
|
|
||||||
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
def lobby_view(self, session: GameSession) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -44,24 +44,10 @@ 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"):
|
||||||
watched = await client.get(f"/api/games/{game_id}")
|
forbidden = await client.get(f"/api/games/{game_id}")
|
||||||
self.assertEqual(200, watched.status_code)
|
self.assertEqual(403, forbidden.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,6 +11,7 @@ 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):
|
||||||
@@ -116,9 +117,6 @@ 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()
|
||||||
@@ -239,7 +237,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_watches_as_spectator(self) -> None:
|
async def test_non_player_is_closed(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:
|
||||||
@@ -249,38 +247,21 @@ 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")]):
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
||||||
first = await ws.receive_json()
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||||
self.assertEqual("state", first["type"])
|
pass
|
||||||
self.assertTrue(first["game"]["spectator"])
|
self.assertEqual(403, caught.exception.code)
|
||||||
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_anonymous_user_watches_as_spectator(self) -> None:
|
async def test_unauthenticated_is_closed(self) -> None:
|
||||||
app, platform, _ = make_platform()
|
app, _, _ = make_platform()
|
||||||
api_transport = ASGITransport(app=app)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
state = await self._started_game(client, platform.oidc)
|
with ws_users([]):
|
||||||
game_id = state["id"]
|
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
|
||||||
|
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
pass
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
self.assertEqual(401, caught.exception.code)
|
||||||
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):
|
||||||
@@ -290,19 +271,33 @@ 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 rejection through the app with a recording socket
|
dies). Drive the rejections through the app with a recording socket
|
||||||
so the invariant is checked without a real server. Spectators are
|
so the invariant is checked without a real server.
|
||||||
accepted, so the only pre-accept rejection left is an unknown game.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
|
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
|
||||||
app, _, _ = make_platform()
|
app, platform, _ = make_platform()
|
||||||
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
|
session = GameSession(
|
||||||
ws = RecordingSocket("/ws/games/no-such-game")
|
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)
|
||||||
with ws_users(users):
|
with ws_users(users):
|
||||||
await app.handle_websocket(ws)
|
await app.handle_websocket(ws)
|
||||||
self.assertEqual([404], ws.close_codes, label)
|
self.assertEqual([expected], 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,14 +528,13 @@ def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
|
|||||||
return points, details
|
return points, details
|
||||||
|
|
||||||
|
|
||||||
def state_for_player(state: ScoponeState, sub: Optional[str]) -> Dict[str, object]:
|
def state_for_player(state: ScoponeState, sub: 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 — including spectators, for whom ``sub`` is
|
hand. Non-seated viewers simply see no hand at all. The platform
|
||||||
``None`` — simply see no hand at all. The platform merges the session
|
merges the session envelope (``id``, ``join_code``, ``game_type``)
|
||||||
envelope (``id``, ``join_code`` for seated players, ``game_type``,
|
into the view itself.
|
||||||
``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: Optional[str]) -> Dict[str, Any]:
|
def view_for(self, session: GameSession, user_sub: 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: Optional[str]) -> Dict[str, Any]:
|
def game_over_view(self, session: GameSession, user_sub: 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: Optional[str]) -> Optional[PlayerState]:
|
def player_for(self, sub: 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,27 +574,6 @@ 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,17 +160,6 @@ 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,19 +39,9 @@ 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"):
|
||||||
watched = await client.get(f"/api/games/{game_id}")
|
forbidden = await client.get(f"/api/games/{game_id}")
|
||||||
self.assertEqual(200, watched.status_code)
|
self.assertEqual(403, forbidden.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:
|
||||||
@@ -71,7 +61,6 @@ 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"])
|
||||||
@@ -83,21 +72,6 @@ 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_watches_as_spectator(self) -> None:
|
async def test_non_player_is_closed(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,42 +143,20 @@ 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")]):
|
||||||
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||||
first = await ws.receive_json()
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
|
||||||
self.assertEqual("state", first["type"])
|
pass
|
||||||
self.assertTrue(first["game"]["spectator"])
|
self.assertEqual(403, caught.exception.code)
|
||||||
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_anonymous_user_watches_as_spectator(self) -> None:
|
async def test_unauthenticated_is_closed(self) -> None:
|
||||||
api_transport = ASGITransport(app=app)
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
state = await self._started_game(client)
|
with ws_users([]):
|
||||||
game_id = state["id"]
|
with self.assertRaises(WebSocketDisconnect) as caught:
|
||||||
|
async with aconnect_ws("/ws/games/whatever", ws_client):
|
||||||
ws_transport = ASGIWebSocketTransport(app=app)
|
pass
|
||||||
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
self.assertEqual(401, caught.exception.code)
|
||||||
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:
|
||||||
|
|||||||
+7
-6
@@ -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. Anonymous users and non-players are
|
/// transient network problem.
|
||||||
/// welcome as spectators, so the only deliberate refusal is an unknown
|
|
||||||
/// game.
|
|
||||||
pub enum GameEntry {
|
pub enum GameEntry {
|
||||||
/// Unknown game (`404`): the id is wrong or the game's TTL expired.
|
/// Not logged in (`401` from `GET /api/games/{id}`).
|
||||||
|
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,11 +117,12 @@ 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 status used to refuse the websocket upgrade.
|
/// mirroring the statuses 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() {
|
||||||
404 => GameEntry::Unavailable,
|
401 => GameEntry::Unauthenticated,
|
||||||
|
403 | 404 => GameEntry::Unavailable,
|
||||||
_ => GameEntry::Transient,
|
_ => GameEntry::Transient,
|
||||||
},
|
},
|
||||||
Err(_) => GameEntry::Transient,
|
Err(_) => GameEntry::Transient,
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
//! 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,5 +1,4 @@
|
|||||||
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,14 +260,7 @@ 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 game.spectator {
|
let action = if already_acked {
|
||||||
// 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) }
|
||||||
|
|||||||
+1
-34
@@ -31,7 +31,7 @@ pub struct PlayerView {
|
|||||||
pub hand: Option<Vec<String>>,
|
pub hand: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
pub struct Scores {
|
pub struct Scores {
|
||||||
#[serde(rename = "A")]
|
#[serde(rename = "A")]
|
||||||
pub a: i32,
|
pub a: i32,
|
||||||
@@ -112,16 +112,11 @@ 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,
|
||||||
@@ -885,39 +880,11 @@ 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!({
|
||||||
|
|||||||
+48
-73
@@ -1,5 +1,6 @@
|
|||||||
//! 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::*;
|
||||||
@@ -7,7 +8,6 @@ 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,14 +19,6 @@ 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 {
|
||||||
@@ -162,6 +154,11 @@ 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
|
||||||
@@ -410,16 +407,38 @@ 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. Spectators (no seat, no join code) get a
|
/// counts render correctly.
|
||||||
/// "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 watch_link = watch_link(&game.id);
|
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 players = game
|
let players = game
|
||||||
.players
|
.players
|
||||||
@@ -453,39 +472,18 @@ 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") {
|
h1(class="h4 mb-3") { "Waiting for players" }
|
||||||
(if spectator { "Game not started yet" } else { "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" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(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)
|
||||||
@@ -538,8 +536,7 @@ 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. Spectators have no
|
// Own seat: the only player entry carrying a hand.
|
||||||
// hand, so they watch from seat 0's perspective.
|
|
||||||
let viewer_seat = game
|
let viewer_seat = game
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
@@ -625,41 +622,18 @@ 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)
|
||||||
@@ -672,7 +646,13 @@ fn table_view(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
(right)
|
(right)
|
||||||
(bottom)
|
div(class="seat-bottom") {
|
||||||
|
div(class="seat-stats own-stats") {
|
||||||
|
(my_captured) " captured · " (my_scope) " scope"
|
||||||
|
}
|
||||||
|
div(class="hand") { (hand_cards) }
|
||||||
|
(hint)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,8 +713,6 @@ 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)
|
||||||
@@ -742,9 +720,6 @@ 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") {
|
||||||
@@ -753,7 +728,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" }
|
||||||
(my_matches)
|
a(class="btn btn-outline-light", href="/history") { "My matches" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,15 +266,6 @@ 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