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:
+6
-7
@@ -105,11 +105,11 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
||||
/// A refused WebSocket handshake is invisible to the browser: the close
|
||||
/// 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.
|
||||
/// transient network problem. Anonymous users and non-players are
|
||||
/// welcome as spectators, so the only deliberate refusal is an unknown
|
||||
/// game.
|
||||
pub enum GameEntry {
|
||||
/// Not logged in (`401` from `GET /api/games/{id}`).
|
||||
Unauthenticated,
|
||||
/// Unknown game (`404`) or the user is not seated (`403`).
|
||||
/// Unknown game (`404`): the id is wrong or the game's TTL expired.
|
||||
Unavailable,
|
||||
/// The API could not be asked (or answered unexpectedly): treat the
|
||||
/// connection loss as transient and retry.
|
||||
@@ -117,12 +117,11 @@ pub enum GameEntry {
|
||||
}
|
||||
|
||||
/// Classify a failed game connection via `GET /api/games/{game_id}`,
|
||||
/// mirroring the statuses used to refuse the websocket upgrade.
|
||||
/// mirroring the status used to refuse the websocket upgrade.
|
||||
pub async fn probe_game(game_id: &str) -> GameEntry {
|
||||
match Request::get(&format!("/api/games/{game_id}")).send().await {
|
||||
Ok(resp) => match resp.status() {
|
||||
401 => GameEntry::Unauthenticated,
|
||||
403 | 404 => GameEntry::Unavailable,
|
||||
404 => GameEntry::Unavailable,
|
||||
_ => GameEntry::Transient,
|
||||
},
|
||||
Err(_) => GameEntry::Transient,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Clipboard copy button, shared by the join-code and watch-link actions.
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
|
||||
/// A button that copies `text` to the clipboard and shows "Copied!" for a
|
||||
/// moment. `label` is the idle caption.
|
||||
#[component(inline_props)]
|
||||
pub fn CopyButton(text: String, label: String) -> View {
|
||||
let copied = create_signal(false);
|
||||
// Held so the reset timer is cancelled if the button is rebuilt (e.g.
|
||||
// another player joins) while the "Copied!" feedback is showing:
|
||||
// firing it afterwards would set a disposed signal and panic.
|
||||
let reset_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
|
||||
Rc::new(RefCell::new(None));
|
||||
on_cleanup({
|
||||
let reset_timer = reset_timer.clone();
|
||||
move || {
|
||||
reset_timer.borrow_mut().take();
|
||||
}
|
||||
});
|
||||
|
||||
let on_click = {
|
||||
let text = text.clone();
|
||||
move |_| {
|
||||
if let Some(win) = web_sys::window() {
|
||||
let _ = win.navigator().clipboard().write_text(&text);
|
||||
}
|
||||
copied.set(true);
|
||||
*reset_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
|
||||
1500,
|
||||
move || {
|
||||
copied.set(false);
|
||||
},
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
button(class="btn btn-outline-light", on:click=on_click) {
|
||||
(move || if copied.get() { "Copied!".to_string() } else { label.clone() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod card;
|
||||
pub mod copy;
|
||||
pub mod nav;
|
||||
pub mod summary;
|
||||
pub mod toast;
|
||||
|
||||
@@ -260,7 +260,14 @@ pub fn hand_summary_modal(
|
||||
let totals_view = totals(&game);
|
||||
let title = format!("Hand {} — results", summary.hand);
|
||||
|
||||
let action = if already_acked {
|
||||
let action = if game.spectator {
|
||||
// Spectators cannot acknowledge; they just watch the summary.
|
||||
view! {
|
||||
p(class="text-secondary small mb-0") {
|
||||
"Spectating — the next hand is dealt when every player is ready."
|
||||
}
|
||||
}
|
||||
} else if already_acked {
|
||||
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
||||
view! {
|
||||
button(class="btn btn-warning", disabled=true) { (waiting_text) }
|
||||
|
||||
+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!({
|
||||
|
||||
+73
-48
@@ -1,6 +1,5 @@
|
||||
//! Live game page: table view over the websocket.
|
||||
use std::cell::Cell;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
@@ -8,6 +7,7 @@ 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,6 +19,14 @@ fn send_play(socket: Signal<Option<GameSocket>>, card: String, capture: Option<V
|
||||
}
|
||||
}
|
||||
|
||||
/// The absolute, shareable URL of this game's page, for the clipboard.
|
||||
fn watch_link(id: &str) -> String {
|
||||
let origin = web_sys::window()
|
||||
.and_then(|w| w.location().origin().ok())
|
||||
.unwrap_or_default();
|
||||
format!("{origin}/game/{id}")
|
||||
}
|
||||
|
||||
/// Panel for one player seat (name, team, hidden card count, stats).
|
||||
fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
||||
let Some(player) = game.players.iter().find(|p| p.seat == seat).cloned() else {
|
||||
@@ -154,11 +162,6 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
let alive = alive.clone();
|
||||
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
|
||||
@@ -407,38 +410,16 @@ 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.
|
||||
/// counts render correctly. Spectators (no seat, no join code) get a
|
||||
/// "not started yet" note and the watch link instead.
|
||||
#[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 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 watch_link = watch_link(&game.id);
|
||||
|
||||
let players = game
|
||||
.players
|
||||
@@ -472,18 +453,39 @@ 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") { "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" })
|
||||
}
|
||||
h1(class="h4 mb-3") {
|
||||
(if spectator { "Game not started yet" } else { "Waiting for players" })
|
||||
}
|
||||
(share)
|
||||
ul(class="list-group list-group-flush text-start mb-3") {
|
||||
(players)
|
||||
(open_seats)
|
||||
@@ -536,7 +538,8 @@ fn table_view(
|
||||
selected: Signal<Option<String>>,
|
||||
now: Signal<f64>,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand.
|
||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||
// hand, so they watch from seat 0's perspective.
|
||||
let viewer_seat = game
|
||||
.players
|
||||
.iter()
|
||||
@@ -622,18 +625,41 @@ fn table_view(
|
||||
}
|
||||
});
|
||||
|
||||
// Spectators have no hand: render the pivot seat as a regular panel
|
||||
// so its captured count and scope stay visible where the hand would be.
|
||||
let bottom = if game.spectator {
|
||||
seat_panel(game.clone(), viewer_seat, "bottom")
|
||||
} else {
|
||||
view! {
|
||||
div(class="seat-bottom") {
|
||||
div(class="seat-stats own-stats") {
|
||||
(my_captured) " captured · " (my_scope) " scope"
|
||||
}
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let spectator_badge = game.spectator.then(|| view! {
|
||||
span(class="spectator-badge") { "Spectating" }
|
||||
});
|
||||
let watch_link = watch_link(&game.id);
|
||||
|
||||
view! {
|
||||
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)
|
||||
@@ -646,13 +672,7 @@ fn table_view(
|
||||
}
|
||||
}
|
||||
(right)
|
||||
div(class="seat-bottom") {
|
||||
div(class="seat-stats own-stats") {
|
||||
(my_captured) " captured · " (my_scope) " scope"
|
||||
}
|
||||
div(class="hand") { (hand_cards) }
|
||||
(hint)
|
||||
}
|
||||
(bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -713,6 +733,8 @@ 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)
|
||||
@@ -720,6 +742,9 @@ 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") {
|
||||
@@ -728,7 +753,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" }
|
||||
a(class="btn btn-outline-light", href="/history") { "My matches" }
|
||||
(my_matches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,15 @@ 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 {
|
||||
|
||||
Reference in New Issue
Block a user