Compare commits

..
Author SHA1 Message Date
woggioni f59064e580 Upgrade kaya to 0.0.7
CI / Build and push docker image (push) Successful in 1m56s
2026-10-01 23:43:55 +08:00
woggioni a89bd06010 Support multiple OIDC providers
CI / Build and push docker image (push) Successful in 2m27s
kaya-oidc 0.0.6 can run several providers in one app (OIDCProviders),
each with its own login route and namespaced session state. Adopt it:
providers are configured in indexed, contiguous OIDC_<n>_* blocks
(0-based) with OIDC_<n>_PROVIDER naming the provider in its login URL
and session keys. The old unindexed OIDC_ISSUER/OIDC_CLIENT_ID/... names
are no longer read; startup fails with a rename hint when they are set.
With no OIDC variables at all, the single dev mock provider named
"default" is used, as before.

The platform now authenticates through OIDCProviders: auth.current_user
returns the session's user (sessions stay single-identity, so at most one
provider is logged in) and require_auth/route handlers use it, while the
websocket path keeps scanning for any provider's user. New routes expose
the configuration to the SPA and keep old links working: GET
/api/auth/providers lists the login URLs, GET /auth/login redirects to
the first provider and GET /auth/logout to whichever provider the
session is logged in with. The lobby renders one login button per
provider from that endpoint.

Update the env examples, compose and k8s manifests to the indexed scheme,
document the multiple-provider setup and the migration, and cover it with
config, auth and route tests.
2026-10-01 20:38:51 +08:00
woggioni 5eccaafadc Upgrade kaya to 0.0.6
kaya-oidc 0.0.6 namespaces its session state per provider as
oidc:{provider}:user (the default provider is "default", so the key is
oidc:default:user). The unprefixed oidc_user key is no longer written:
left unadapted, every websocket upgrade would be rejected with 401 and
traced requests would silently lose their user.id attribute. Adopt it
with a session_user helper on tavolo.platform.auth that finds any
provider's user, used by both get_ws_user and telemetry's
session_tag_hook. Websocket and route handlers keep their signatures.

kaya-otel 0.0.6 only enriches websocket spans with standard URL, network
and peer attributes (plus error.type), which needs no application change.

Regenerate the lock: the eight kaya pins move to 0.0.6 and, the wheels'
declared metadata being unchanged, nothing else moves. tavolo-platform
now floors kaya-core>=0.0.6 (it reads the new session layout) and
tavolo-app floors kaya-otel>=0.0.6.

As kaya documents, the session-key move signs existing logged-in users
out once; they simply log in again.
2026-10-01 20:38:21 +08:00
woggioni 6e9d1a4652 Wake deadline consumers on enqueue instead of polling every second
CI / Build and push docker image (push) Successful in 1m15s
2026-09-30 07:40:04 +08:00
woggioni 723b3b11cf Upgrade kaya to 0.0.5
CI / Build and push docker image (push) Successful in 1m20s
kaya 0.0.5 surfaces a vanished websocket peer as
kaya.core.WebSocketDisconnect instead of leaking granian's
RSGIProtocolClosed: receive() reports a close message so iteration ends
normally, sends raise the new exception, and KayaApp.handle_websocket
swallows it as normal termination. Adopt it: drop the granian-specific
workaround from the platform websocket handler, catch
WebSocketDisconnect in the send helper, and model the new semantics in
the abrupt-disconnect test (with coverage that a send on a vanished
peer is ignored).

Regenerate the lock: the eight kaya pins move to 0.0.5 (declared
metadata is unchanged) and pip-compile also bumps PyJWT to 2.15.1, a
security release with no breaking changes for kaya-oidc's jwt.decode
usage. tavolo-platform now floors kaya-core>=0.0.5 (it imports
WebSocketDisconnect) and tavolo-app floors kaya-otel>=0.0.5.
2026-09-30 06:38:02 +08:00
woggioni ed850aa73c Sort the player's hand by rank then suit 2026-09-30 00:58:31 +08:00
22 changed files with 185 additions and 453 deletions
-9
View File
@@ -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
rules require a capture), so one absent player cannot stall the table.
## Watching
Anyone who has the game URL (`/game/<id>`) can watch, logged in or not:
spectators see the table, the score and every player's captured cards and
scope, but never a hand and never the join code. The "Copy watch link"
button in the waiting room and in the game HUD copies the URL to share.
A spectator who opens the URL before the match starts sees a "game not
started yet" waiting room.
## Development
Backend (from `server/`):
+11 -17
View File
@@ -262,8 +262,7 @@ existed):
## REST API
All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
`/api/game-types`, `/api/leaderboard` and the game snapshot
(`GET /api/games/{id}`) require authentication.
`/api/game-types` and `/api/leaderboard` require authentication.
| 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/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
| `GET` | `/api/games/{id}` | 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/ratings` | The caller's Elo rating per game type |
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
## WebSocket protocol
Connect to `/ws/games/{game_id}`; the session cookie is optional. Anyone
who knows the game id is accepted: seated players get their personalized
view, everyone else — anonymous users and authenticated non-players — is
a read-only **spectator**. Only an unknown game id is refused before the
handshake completes, with `404`. (Browsers report a refused handshake as
an abnormal close — there is no close code to read.)
Connect to `/ws/games/{game_id}` with your session cookie. Only seated
players are accepted; the upgrade is refused before the handshake
completes with an HTTP status mirroring `GET /api/games/{id}`: `401`
unauthenticated, `404` unknown game, `403` not seated. (Browsers report
a refused handshake as an abnormal close — there is no close code to
read.)
Server → client messages are JSON objects with a `type`:
- `{"type": "state", "game": {...}}` — personalized game view. Your own
hand is included; other players expose only their card counts.
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.
`your_turn: true` is present when it is your move.
- `{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}`
- `{"type": "error", "code": "illegal_move", "message": "..."}`
Client → server messages are JSON objects with an `action`. The
platform handles `state` (resend the snapshot) itself; every other action
is dispatched to the session's game engine with the rest of the message
as its payload. Spectators may only send `state`/`sync`: any other
action is answered with an `error` whose `code` is `forbidden`. For
scopone scientifico:
as its payload. For scopone scientifico:
```json
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
@@ -319,8 +314,7 @@ scopone scientifico:
after `HAND_ACK_TIMEOUT_SECONDS`.
- `state` asks for a fresh snapshot.
After every accepted move the new state is broadcast to every connected
client, spectators included.
After every accepted move the new state is broadcast to all four players.
### Turn timeout
@@ -191,17 +191,12 @@ class GameEngine(ABC):
"""
@abstractmethod
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
"""The personalized game view for one viewer.
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""The personalized game view for one player.
Must hide information the viewer is not entitled to (e.g. other
players' hands). ``user_sub`` is ``None`` for a spectator — an
anonymous user or an authenticated user who is not seated — and
the view must then contain no privileged information either (no
hand, no legal moves: only what everyone at the table can see).
The platform merges in the envelope fields (``id``,
``join_code`` for seated players, ``game_type``, ``spectator``)
itself.
Must hide information the player is not entitled to (e.g. other
players' hands). The platform merges in the envelope fields
(``id``, ``join_code``, ``game_type``) itself.
"""
@abstractmethod
@@ -253,13 +248,6 @@ class GameEngine(ABC):
``kind``/``token`` no longer match the live state.
"""
def game_over_view(
self, session: GameSession, user_sub: Optional[str]
) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message.
``user_sub`` is ``None`` for a spectator (see :meth:`view_for`);
the default fields are public, so the default implementation
ignores it.
"""
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
"""Extra fields merged into the ``game_over`` websocket message."""
return {}
@@ -16,7 +16,7 @@ import secrets
import uuid
from datetime import datetime, timezone
from logging import getLogger
from typing import TYPE_CHECKING, Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict
from kaya.core import HttpContext, KayaApp
from kaya.openapi import operation
@@ -76,21 +76,14 @@ def _lobby_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]
}
def _view_payload(
platform: "Platform", session: GameSession, sub: Optional[str]
) -> Dict[str, Any]:
def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
seated = sub is not None and session.seated(sub)
payload: Dict[str, Any] = {
return {
"id": session.id,
"join_code": session.join_code,
"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]:
@@ -317,24 +310,24 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot",
description="Anyone who knows the game id may read a game. "
"Seated players get the personalized view (their "
"own hand plus the join code); everyone else — "
"anonymous users and authenticated non-players — "
"gets the read-only spectator view: no hands, no "
"join code, just what is public at the table.",
description="Only seated players may read a game; the view is "
"personalized by the engine (e.g. hidden hands).",
tags=["games"],
responses={
200: {"description": "The personalized (player) or "
"read-only (spectator) game state"},
200: {"description": "The personalized game state"},
401: {"description": "Authentication required"},
403: {"description": "Not a player in this game"},
404: {"description": "Game not found"},
})
@require_auth(platform.oidc)
async def get_game(ctx: HttpContext, game_id: str) -> None:
session = await platform.game_store.load(game_id)
if session is None:
await send_error(ctx, 404, "game not found")
return
user = current_user(platform.oidc, ctx)
await send_json(
ctx, 200, _view_payload(platform, session, user.sub if user else None)
)
assert user is not None
if not session.seated(user.sub):
await send_error(ctx, 403, "forbidden")
return
await send_json(ctx, 200, _view_payload(platform, session, user.sub))
@@ -2,21 +2,12 @@
Clients connect to ``/ws/games/{game_id}`` using their session cookie
(the OIDC login stores the user in the session, which the session mixin
loads onto the websocket). 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.
loads onto the websocket). Only seated players are accepted.
The platform owns the connection lifecycle — authentication, the
subscription/publish fan-out and the message envelope — and delegates
the game-specific actions to the engine registered for the session's
``game_type``.
The platform owns the connection lifecycle — authentication, seat
check, the subscription/publish fan-out and the message envelope — and
delegates the game-specific actions to the engine registered for the
session's ``game_type``.
Protocol
--------
@@ -57,7 +48,7 @@ import asyncio
import json
from contextlib import suppress
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
@@ -92,21 +83,17 @@ async def _reject(ws: WebSocket, status: int) -> None:
await ws.close(status)
def _state_message(
platform: "Platform", session: GameSession, sub: Optional[str]
) -> Dict[str, Any]:
def _state_message(platform: "Platform", session: GameSession, sub: str) -> Dict[str, Any]:
engine = platform.registry.require(session.game_type)
seated = sub is not None and session.seated(sub)
payload: Dict[str, Any] = {
"id": session.id,
"game_type": session.game_type,
"spectator": not seated,
return {
"type": "state",
"game": {
"id": session.id,
"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:
@@ -114,19 +101,24 @@ def register(app: KayaApp, platform: "Platform") -> None:
@app.websocket("/ws/games/${game_id}")
async def game_socket(ws: WebSocket, game_id: str) -> None:
# Anonymous and non-seated viewers are welcome as read-only
# spectators; only an unknown game id is refused.
user = auth.get_ws_user(ws)
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)
if session is None:
log.debug("websocket rejected: unknown game %s", game_id)
await _reject(ws, 404)
return
if not session.seated(user.sub):
log.debug("websocket %s rejected: %s is not seated", game_id, user.sub)
await _reject(ws, 403)
return
await ws.accept()
log.info("%s connected to game %s", sub or "spectator", game_id)
log.info("%s connected to game %s", user.sub, game_id)
send_lock = asyncio.Lock()
@@ -140,7 +132,7 @@ def register(app: KayaApp, platform: "Platform") -> None:
# ends momentarily.
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
# entry was lost (e.g. the queue was flushed while the game lived
# 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:
forward = asyncio.create_task(
_forward(platform, events, game_id, sub, send)
_forward(platform, events, game_id, user.sub, send)
)
try:
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):
await send(_error("expected a text frame with a JSON object"))
continue
await _handle_message(platform, send, game_id, sub, message.data)
await _handle_message(platform, send, game_id, user.sub, message.data)
finally:
forward.cancel()
with suppress(asyncio.CancelledError):
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(
platform: "Platform",
events,
game_id: str,
sub: Optional[str],
sub: str,
send: Send,
) -> None:
async for _ in events:
@@ -189,7 +181,7 @@ async def _forward(
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:
try:
data = json.loads(raw)
@@ -218,7 +210,7 @@ async def _handle_action(
platform: "Platform",
send: Send,
game_id: str,
sub: Optional[str],
sub: str,
action: str,
data: Dict[str, Any],
) -> None:
@@ -227,13 +219,6 @@ async def _handle_action(
if session is None:
await send(_error("game not found", code="not_found"))
return
# Re-check seating under the lock: an anonymous spectator stays a
# spectator forever, while a logged-in viewer who joins the game
# mid-connection becomes a player and may act from then on.
if sub is None or not session.seated(sub):
log.debug("game %s: rejected %r from a spectator", game_id, action)
await send(_error("spectators cannot act on the game", code="forbidden"))
return
engine = platform.registry.require(session.game_type)
payload = {key: value for key, value in data.items() if key != "action"}
try:
@@ -138,7 +138,7 @@ class DummyEngine(GameEngine):
state["finished"] = True
state["winner"] = user_sub
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
def view_for(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
state = session.state
return {
"started": state["started"],
@@ -146,7 +146,7 @@ class DummyEngine(GameEngine):
"winner": state["winner"],
"plays": len(state["plays"]),
"target": state["target"],
"viewer_seated": 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]:
@@ -44,24 +44,10 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(game_id, snap["id"])
self.assertEqual("dummy", snap["game_type"])
self.assertTrue(snap["viewer_seated"])
self.assertFalse(snap["spectator"])
self.assertIn("join_code", snap)
# A logged-in non-player is a read-only spectator: the view
# marks it and the join code is withheld.
with oidc_user(platform.oidc, "mallory"):
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spect = watched.json()
self.assertFalse(spect["viewer_seated"])
self.assertTrue(spect["spectator"])
self.assertNotIn("join_code", spect)
# Anonymous users may watch too.
anonymous = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, anonymous.status_code)
self.assertTrue(anonymous.json()["spectator"])
self.assertNotIn("join_code", anonymous.json())
forbidden = await client.get(f"/api/games/{game_id}")
self.assertEqual(403, forbidden.status_code)
@async_test
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 helpers import async_test, make_platform, make_user, oidc_user, ws_users
from tavolo.platform import GameSession, Seat
class AbruptCloseSocket(WebSocket):
@@ -116,9 +117,6 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual("dummy", first["game"]["game_type"])
self.assertEqual(0, first["game"]["plays"])
self.assertEqual(game_id, first["game"]["id"])
# Seated players get the join code; spectators don't.
self.assertIn("join_code", first["game"])
self.assertFalse(first["game"]["spectator"])
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as alice_ws:
alice_first = await alice_ws.receive_json()
@@ -239,7 +237,7 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual(404, caught.exception.code)
@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()
api_transport = ASGITransport(app=app)
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)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertFalse(first["game"]["viewer_seated"])
self.assertNotIn("join_code", first["game"])
# Spectators may not act.
await ws.send_json({"action": "play"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(403, caught.exception.code)
@async_test
async def test_anonymous_user_watches_as_spectator(self) -> None:
app, platform, _ = make_platform()
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client, platform.oidc)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
await ws.send_json({"action": "play"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
async def test_unauthenticated_is_closed(self) -> None:
app, _, _ = make_platform()
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(401, caught.exception.code)
class RejectionStatusTest(unittest.TestCase):
@@ -290,19 +271,33 @@ class RejectionStatusTest(unittest.TestCase):
``accept()`` as the HTTP status of the refused upgrade; a WebSocket
close code like 4401 is not a valid status and panics the worker
(``InvalidStatusCode``, and with ``panic = "abort"`` the whole app
dies). Drive the rejection through the app with a recording socket
so the invariant is checked without a real server. Spectators are
accepted, so the only pre-accept rejection left is an unknown game.
dies). Drive the rejections through the app with a recording socket
so the invariant is checked without a real server.
"""
@async_test
async def test_unknown_game_rejection_is_an_http_status(self) -> None:
app, _, _ = make_platform()
for label, users in (("anonymous", []), ("logged in", [make_user("alice")])):
ws = RecordingSocket("/ws/games/no-such-game")
async def test_pre_accept_rejections_are_http_statuses(self) -> None:
app, platform, _ = make_platform()
session = GameSession(
id="seated-game",
game_type="dummy",
join_code="SEAT01",
creator_sub="alice",
players=[Seat(user_sub="alice", display_name="Alice")],
)
engine = platform.registry.require("dummy")
engine.create(session, {})
await platform.game_store.save(session)
cases = [
("unauthenticated", [], "/ws/games/whatever", 401),
("unknown game", [make_user("alice")], "/ws/games/no-such-game", 404),
("not seated", [make_user("mallory")], f"/ws/games/{session.id}", 403),
]
for label, users, path, expected in cases:
ws = RecordingSocket(path)
with ws_users(users):
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:
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
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.
Hands are reduced to a count, except for the requesting player's own
hand. Non-seated viewers — including spectators, for whom ``sub`` is
``None`` — simply see no hand at all. The platform merges the session
envelope (``id``, ``join_code`` for seated players, ``game_type``,
``spectator``) into the view itself.
hand. Non-seated viewers simply see no hand at all. The platform
merges the session envelope (``id``, ``join_code``, ``game_type``)
into the view itself.
"""
viewer = state.player_for(sub)
players: List[Dict[str, object]] = []
@@ -171,13 +171,13 @@ class ScoponeEngine(GameEngine):
else:
raise IllegalMove(f"unknown action: {action!r}")
def view_for(self, session: GameSession, user_sub: 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)
def is_finished(self, session: GameSession) -> bool:
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
return {
"scores": {"A": state.scores[0], "B": state.scores[1]},
@@ -239,7 +239,7 @@ class ScoponeState:
# -- helpers ----------------------------------------------------------
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
def player_for(self, sub: str) -> Optional[PlayerState]:
for player in self.players:
if player.sub == sub:
return player
@@ -574,27 +574,6 @@ class MatchFlowTest(unittest.TestCase):
self.assertEqual(["07C"], view["table"])
self.assertTrue(view.get("your_turn"))
def test_state_for_player_spectator_sees_public_info_only(self) -> None:
state = make_state(
[["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"],
captured=[["01D"], [], ["02C"], ["03B"]],
scope=[1, 0, 2, 0],
)
view = engine.state_for_player(state, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
# The public table state is still fully visible.
self.assertEqual(["07C"], view["table"])
self.assertEqual({"A": 0, "B": 0}, view["scores"])
players = {p["seat"]: p for p in view["players"]}
self.assertEqual(1, players[0]["captured_count"])
self.assertEqual(1, players[0]["scope"])
self.assertEqual(2, players[2]["scope"])
self.assertEqual(1, players[1]["cards_left"])
def test_legal_moves_only_for_player_on_turn(self) -> None:
state = make_state([["02D", "03C"], ["04D"], ["05D"], ["06D"]],
table=["07C"])
@@ -160,17 +160,6 @@ class ViewTest(unittest.TestCase):
self.assertNotIn("join_code", view)
self.assertNotIn("game_type", view)
def test_view_for_spectator_hides_all_hands(self) -> None:
engine = ScoponeEngine()
session = _started(engine)
view = engine.view_for(session, None)
for player in view["players"]:
self.assertNotIn("hand", player)
self.assertNotIn("your_turn", view)
self.assertNotIn("legal_moves", view)
self.assertIn("table", view)
self.assertEqual({"A": 0, "B": 0}, view["scores"])
def test_lobby_view(self) -> None:
engine = ScoponeEngine()
session = _session(engine, target_score=16)
+2 -28
View File
@@ -39,19 +39,9 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(200, snapshot.status_code)
self.assertEqual("alice", snapshot.json()["players"][0]["sub"])
# A logged-in non-player is a read-only spectator: no join
# code, and the view is marked as a spectator view.
with oidc_user("mallory"):
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
self.assertTrue(watched.json()["spectator"])
self.assertNotIn("join_code", watched.json())
# Anonymous users may watch too.
anonymous = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, anonymous.status_code)
self.assertTrue(anonymous.json()["spectator"])
self.assertNotIn("join_code", anonymous.json())
forbidden = await client.get(f"/api/games/{game_id}")
self.assertEqual(403, forbidden.status_code)
@async_test
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})
self.assertEqual(200, started.status_code)
state = started.json()
game_id = state["id"]
self.assertEqual("playing", state["phase"])
self.assertEqual(4, len(state["players"]))
self.assertEqual([], state["table"])
@@ -83,21 +72,6 @@ class GamesRouteTest(unittest.TestCase):
self.assertNotIn("hand", state["players"][0])
self.assertEqual(1, state["turn"])
# A spectator sees the table, the score and each player's
# captured count and scope — but no hand at all.
watched = await client.get(f"/api/games/{game_id}")
self.assertEqual(200, watched.status_code)
spect = watched.json()
self.assertTrue(spect["spectator"])
self.assertNotIn("join_code", spect)
self.assertEqual([], spect["table"])
self.assertEqual({"A": 0, "B": 0}, spect["scores"])
for participant in spect["players"]:
self.assertNotIn("hand", participant)
self.assertEqual(10, participant["cards_left"])
self.assertEqual(0, participant["captured_count"])
self.assertEqual(0, participant["scope"])
@async_test
async def test_create_napola_option(self) -> None:
transport = ASGITransport(app=app)
+13 -35
View File
@@ -134,7 +134,7 @@ class WebSocketTest(unittest.TestCase):
self.assertEqual(404, caught.exception.code)
@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)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client)
@@ -143,42 +143,20 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
self.assertIn("table", first["game"])
self.assertEqual({"A": 0, "B": 0}, first["game"]["scores"])
for player in first["game"]["players"]:
self.assertNotIn("hand", player)
# Spectators may not act.
await ws.send_json({"action": "play", "card": "01D"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass
self.assertEqual(403, caught.exception.code)
@async_test
async def test_anonymous_user_watches_as_spectator(self) -> None:
api_transport = ASGITransport(app=app)
async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client:
state = await self._started_game(client)
game_id = state["id"]
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual("state", first["type"])
self.assertTrue(first["game"]["spectator"])
self.assertNotIn("join_code", first["game"])
for player in first["game"]["players"]:
self.assertNotIn("hand", player)
await ws.send_json({"action": "play", "card": "01D"})
error = await ws.receive_json()
self.assertEqual("error", error["type"])
self.assertEqual("forbidden", error["code"])
async def test_unauthenticated_is_closed(self) -> None:
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]):
with self.assertRaises(WebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client):
pass
self.assertEqual(401, caught.exception.code)
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
+7 -6
View File
@@ -105,11 +105,11 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
/// A refused WebSocket handshake is invisible to the browser: the close
/// event reports an abnormal closure (1006) with no HTTP status, so the
/// game page asks the REST API to tell a deliberate rejection from a
/// transient network problem. Anonymous users and non-players are
/// welcome as spectators, so the only deliberate refusal is an unknown
/// game.
/// transient network problem.
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,
/// The API could not be asked (or answered unexpectedly): treat the
/// connection loss as transient and retry.
@@ -117,11 +117,12 @@ pub enum GameEntry {
}
/// 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 {
match Request::get(&format!("/api/games/{game_id}")).send().await {
Ok(resp) => match resp.status() {
404 => GameEntry::Unavailable,
401 => GameEntry::Unauthenticated,
403 | 404 => GameEntry::Unavailable,
_ => GameEntry::Transient,
},
Err(_) => GameEntry::Transient,
-45
View File
@@ -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
View File
@@ -1,5 +1,4 @@
pub mod card;
pub mod copy;
pub mod nav;
pub mod summary;
pub mod toast;
+1 -8
View File
@@ -260,14 +260,7 @@ pub fn hand_summary_modal(
let totals_view = totals(&game);
let title = format!("Hand {} — results", summary.hand);
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 action = if already_acked {
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
view! {
button(class="btn btn-warning", disabled=true) { (waiting_text) }
+1 -34
View File
@@ -31,7 +31,7 @@ pub struct PlayerView {
pub hand: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Scores {
#[serde(rename = "A")]
pub a: i32,
@@ -112,16 +112,11 @@ pub struct HandSummary {
#[allow(dead_code)]
pub struct GameView {
pub id: String,
/// The lobby join code; withheld by the server from spectators.
#[serde(default)]
pub join_code: String,
/// Which card game this match is (id from /api/game-types).
#[serde(default)]
pub game_type: String,
/// Whether the viewer is watching without a seat: no hand, no join
/// code, and actions are rejected by the server.
#[serde(default)]
pub spectator: bool,
/// Whether the napola rule is scored in this match.
#[serde(default = "default_true")]
pub napola: bool,
@@ -885,39 +880,11 @@ mod tests {
.unwrap();
assert_eq!("playing", g.phase);
assert!(g.asso_pigliatutto);
assert!(!g.spectator);
assert_eq!(None, g.players[0].hand);
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
assert_eq!(Some(true), g.your_turn);
}
#[test]
fn game_view_parses_spectator_snapshot() {
// A spectator payload: no hand anywhere, no join code, but the
// public table state is present.
let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555",
"game_type": "scopone_scientifico",
"spectator": true,
"phase": "playing",
"target_score": 11,
"scores": {"A": 2, "B": 1},
"table": ["07C"],
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A",
"cards_left": 5, "captured_count": 12, "scope": 1},
],
}))
.unwrap();
assert!(g.spectator);
assert_eq!("", g.join_code);
assert_eq!(Some(Scores { a: 2, b: 1 }), g.scores);
assert_eq!(vec!["07C".to_string()], g.table);
assert_eq!(None, g.players[0].hand);
assert_eq!(12, g.players[0].captured_count);
assert_eq!(1, g.players[0].scope);
}
#[test]
fn move_view_parses_pigliatutto() {
let mv: MoveView = serde_json::from_value(json!({
+48 -73
View File
@@ -1,5 +1,6 @@
//! Live game page: table view over the websocket.
use std::cell::Cell;
use std::cell::RefCell;
use std::rc::Rc;
use sycamore::prelude::*;
@@ -7,7 +8,6 @@ use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::card::{card_back, card_img};
use crate::components::copy::CopyButton;
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{card_label, hand_sort_key, GameView, MoveView, PlayerView, Scores, ServerMessage};
@@ -19,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).
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
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();
spawn_local(async move {
match api::probe_game(&id).await {
api::GameEntry::Unauthenticated => {
ctx.fatal.set(true);
ctx.error
.set(Some("Session expired — please log in again.".to_string()));
}
api::GameEntry::Unavailable => {
ctx.fatal.set(true);
ctx.error
@@ -410,16 +407,38 @@ pub fn GamePage(id: String) -> View {
/// Lobby view while waiting for the remaining players. The seat count
/// comes from the payload (`seats_open`), so games with other player
/// counts render correctly. Spectators (no seat, no join code) get a
/// "not started yet" note and the watch link instead.
/// counts render correctly.
#[component(inline_props)]
fn LobbyView(game: GameView) -> View {
let seats_open = game
.seats_open
.unwrap_or_else(|| 4usize.saturating_sub(game.players.len()));
let spectator = game.spectator;
let join_code = game.join_code.clone();
let 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
.players
@@ -453,39 +472,18 @@ fn LobbyView(game: GameView) -> View {
};
let percent_style = format!("width: {percent}%");
// The server withholds the join code from spectators; they get a
// "not started yet" note and can share the watch link instead.
let share = if spectator {
view! {
p(class="text-secondary") {
"This game hasn't started yet — waiting for the players to take their seats."
}
div(class="d-grid mb-4") {
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
}
} else {
// Separate clones: each interpolation/prop captures its own copy.
let display_code = join_code.clone();
let copy_code = join_code.clone();
view! {
p(class="text-secondary") { "Share this join code with your friends:" }
p(class="code-display") { (display_code) }
div(class="d-grid gap-2 mb-4") {
CopyButton(text=copy_code, label="Copy code".to_string())
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
}
};
view! {
div(class="container page-shell py-4") {
div(class="card status-card") {
div(class="card-body p-4") {
h1(class="h4 mb-3") {
(if spectator { "Game not started yet" } else { "Waiting for players" })
h1(class="h4 mb-3") { "Waiting for players" }
p(class="text-secondary") { "Share this join code with your friends:" }
p(class="code-display") { (join_code) }
div(class="d-grid mb-4") {
button(class="btn btn-outline-light", on:click=copy) {
(move || if copied.get() { "Copied!" } else { "Copy code" })
}
}
(share)
ul(class="list-group list-group-flush text-start mb-3") {
(players)
(open_seats)
@@ -538,8 +536,7 @@ fn table_view(
selected: Signal<Option<String>>,
now: Signal<f64>,
) -> View {
// Own seat: the only player entry carrying a hand. Spectators have no
// hand, so they watch from seat 0's perspective.
// Own seat: the only player entry carrying a hand.
let viewer_seat = game
.players
.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! {
div(class="table-wrap") {
div(class="hud") {
a(href="/") { "← Lobby" }
span { "Hand " (hand_number) }
(rule_badge)
(spectator_badge)
span(class="hud-scores") {
"Team A " (scores.a) " — " (scores.b) " Team B (target "
(target_score) ")"
}
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
}
div(class="table-grid") {
(top)
@@ -672,7 +646,13 @@ fn table_view(
}
}
(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)) => {
let winner = winner.unwrap_or_else(|| "?".to_string());
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
// Spectators have no match history to look at.
let spectator = game.as_ref().is_some_and(|g| g.spectator);
// Explain the final hand's scoring before the result.
let final_summary = game
.and_then(|g| g.last_hand)
@@ -742,9 +720,6 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
let rows = summary_rows(s);
view! { (rows) }
});
let my_matches = (!spectator).then(|| view! {
a(class="btn btn-outline-light", href="/history") { "My matches" }
});
view! {
div(class="overlay") {
div(class="picker summary-panel") {
@@ -753,7 +728,7 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
p(class="final-score") { (line) }
div(class="gameover-actions") {
a(class="btn btn-warning", href="/") { "Back to lobby" }
(my_matches)
a(class="btn btn-outline-light", href="/history") { "My matches" }
}
}
}
-9
View File
@@ -266,15 +266,6 @@ body {
margin-left: 0.25rem;
}
.spectator-badge {
background: var(--panel-light);
color: var(--muted);
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
border: 1px solid var(--muted);
}
/* ---------- overlays ---------- */
.overlay {