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:
+34
-1
@@ -31,7 +31,7 @@ pub struct PlayerView {
|
||||
pub hand: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
||||
pub struct Scores {
|
||||
#[serde(rename = "A")]
|
||||
pub a: i32,
|
||||
@@ -112,11 +112,16 @@ 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,
|
||||
@@ -880,11 +885,39 @@ 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!({
|
||||
|
||||
Reference in New Issue
Block a user