Let anyone with the game URL watch as a read-only spectator
CI / Build and push docker image (push) Successful in 3m0s
CI / Build and push docker image (push) Successful in 3m0s
Anonymous users and logged-in non-players can now open /game/<id> and
follow the match: they see the table, the score and each player's
captured cards and scope, but never a hand and never the join code.
Spectator actions are rejected with a forbidden error.
Backend (tavolo-platform / tavolo-scopone):
- /ws/games/{id} accepts anonymous and non-seated connections as
spectators; only unknown games are refused. The state payload carries
spectator: true and withholds join_code unless the viewer is seated.
- GET /api/games/{id} is public and returns the same personalized
(player) or read-only (spectator) view.
- GameEngine.view_for/game_over_view take Optional[str] (None = spectator)
and are documented to hide privileged information for non-seated
viewers.
Frontend (Sycamore):
- Parse the spectator flag and render a spectator table: card backs plus
captured/scope for every seat, no clickable hand, "Spectating" badge.
- A pre-start spectator sees a "game not started yet" waiting room with
the watch link and no join code.
- "Copy watch link" buttons in the waiting room and the game HUD.
- The hand-end summary carries no acknowledge button for spectators and
the game-over overlay hides "My matches".
Tests and docs updated.
This commit is contained in:
22 files changed
+453
-185
No files matched your search
@@ -528,13 +528,14 @@ def hand_points(state: ScoponeState) -> Tuple[List[int], Dict[str, object]]:
|
||||
return points, details
|
||||
|
||||
|
||||
def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
|
||||
def state_for_player(state: ScoponeState, sub: Optional[str]) -> Dict[str, object]:
|
||||
"""Serialize ``state`` hiding other players' hands.
|
||||
|
||||
Hands are reduced to a count, except for the requesting player's own
|
||||
hand. Non-seated viewers simply see no hand at all. The platform
|
||||
merges the session envelope (``id``, ``join_code``, ``game_type``)
|
||||
into the view itself.
|
||||
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.
|
||||
"""
|
||||
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: str) -> Dict[str, Any]:
|
||||
def view_for(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
return engine.state_for_player(session.state, user_sub)
|
||||
|
||||
def is_finished(self, session: GameSession) -> bool:
|
||||
return session.state.phase == PHASE_FINISHED
|
||||
|
||||
def game_over_view(self, session: GameSession, user_sub: str) -> Dict[str, Any]:
|
||||
def game_over_view(self, session: GameSession, user_sub: Optional[str]) -> Dict[str, Any]:
|
||||
state: ScoponeState = session.state
|
||||
return {
|
||||
"scores": {"A": state.scores[0], "B": state.scores[1]},
|
||||
|
||||
@@ -239,7 +239,7 @@ class ScoponeState:
|
||||
|
||||
# -- helpers ----------------------------------------------------------
|
||||
|
||||
def player_for(self, sub: str) -> Optional[PlayerState]:
|
||||
def player_for(self, sub: Optional[str]) -> Optional[PlayerState]:
|
||||
for player in self.players:
|
||||
if player.sub == sub:
|
||||
return player
|
||||
|
||||
@@ -574,6 +574,27 @@ 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,6 +160,17 @@ 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)
|
||||
|
||||
Reference in new issue
Block a user