diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4bdb84f..cc490a4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -9,6 +9,8 @@ // {type:"claimTransfer", code} claim a seat on a new device // {type:"catchUp", sinceSeq} replay of moves missed while away // {type:"chat", text} table talk to the room +// {type:"watch", roomId} join the Peanut Gallery: nameless, read-only +// {type:"leave"} detach this socket from table or gallery // {type:"myGames", seats} summaries for held seats // {type:"stats"} the engagement tally // {type:"hotseatReport", ...} anonymous hotseat game counts @@ -19,6 +21,8 @@ // {type:"events", events} redacted for this recipient // {type:"state", view, seq} redacted full view (after every change) // {type:"chat", player, text, at} one line of table talk +// {type:"watching", roomId} you are seated in the gallery +// {type:"audience", count} how many watch from the gallery // {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"} // {type:"error", message} @@ -44,6 +48,7 @@ import { roomCount, runCommand, seatTokenValid, + SPECTATOR, startGame, summarize, viewForPlayer, @@ -59,6 +64,7 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create const MAX_COMMAND_BYTES = 16384; // serialized game command const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy +const MAX_AUDIENCE = 30; // gallery seats per room const NAME_MAX = 24; /** Player/room names: printable, trimmed, bounded. */ @@ -144,6 +150,8 @@ interface Session { socket: WebSocket; playerId: PlayerId | null; roomId: string | null; + /** In the Peanut Gallery: nameless, read-only, counted but never listed. */ + spectator: boolean; /** The raw seat token this connection authenticated with (memory only). */ token: string | null; claimFails: number; @@ -171,6 +179,25 @@ function send(socket: WebSocket, message: unknown): void { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); } +function audienceCount(room: Room): number { + let n = 0; + for (const s of sessions) if (s.roomId === room.id && s.spectator) n++; + return n; +} + +function broadcastAudience(room: Room): void { + broadcast(room, () => ({ type: "audience", count: audienceCount(room) })); +} + +/** A watcher leaves the gallery (to sit down, watch elsewhere, or vanish). */ +function leaveGallery(session: Session): void { + if (!session.spectator) return; + session.spectator = false; + const room = session.roomId ? getRoom(session.roomId) : undefined; + session.roomId = null; + if (room) broadcastAudience(room); +} + function roomInfo(room: Room) { return { type: "room", @@ -178,6 +205,7 @@ function roomInfo(room: Room) { players: room.players, hostId: room.hostId, started: room.state !== null, + audience: audienceCount(room), colors: Object.fromEntries(room.colorChoices), bots: Object.fromEntries( // A mystery machine keeps its mood only while the game lives: once it @@ -194,9 +222,10 @@ function roomInfo(room: Room) { function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void { for (const s of sessions) { - if (s.roomId === room.id && s.playerId) { - send(s.socket, makeMessage(s.playerId)); - } + if (s.roomId !== room.id) continue; + // The gallery hears everything too, redacted for the nameless viewer. + if (s.playerId) send(s.socket, makeMessage(s.playerId)); + else if (s.spectator) send(s.socket, makeMessage(SPECTATOR)); } } @@ -283,14 +312,17 @@ wss.on("connection", (socket) => { return; } const session: Session = { - socket, playerId: null, roomId: null, token: null, claimFails: 0, + socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0, bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0, roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, }; sessions.add(session); send(socket, { type: "welcome", game: "wizwar" }); - socket.on("close", () => sessions.delete(session)); + socket.on("close", () => { + sessions.delete(session); + leaveGallery(session); // an emptier gallery is news to the table + }); socket.on("message", (data) => { if (!underRateLimit(session)) { @@ -313,6 +345,7 @@ wss.on("connection", (socket) => { return send(socket, { type: "error", message: "no new rooms right now — try again later" }); } session.roomsCreated++; + leaveGallery(session); const { room, token } = createRoom(name); session.playerId = name; session.roomId = room.id; @@ -329,6 +362,7 @@ wss.on("connection", (socket) => { if (!room) return send(socket, { type: "error", message: "no such room" }); const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null); if ("error" in result) return send(socket, { type: "error", message: result.error }); + leaveGallery(session); session.playerId = name; session.roomId = room.id; session.token = result.token; @@ -343,6 +377,39 @@ wss.on("connection", (socket) => { runBots(room); break; } + case "watch": { + // The Peanut Gallery: no name, no seat, no ledger line — a pure + // reader of the public broadcast, counted but never identified. + const roomId = String(msg.roomId ?? "").trim().toUpperCase().slice(0, 8); + if (!roomId) return send(socket, { type: "error", message: "roomId required" }); + const room = getRoom(roomId); + if (!room) return send(socket, { type: "error", message: "no such room" }); + if (audienceCount(room) >= MAX_AUDIENCE) { + return send(socket, { type: "error", message: "the gallery is packed — try again later" }); + } + leaveGallery(session); // switching galleries updates the old room's count + session.playerId = null; + session.token = null; + session.spectator = true; + session.roomId = room.id; + send(socket, { type: "watching", roomId: room.id }); + send(socket, roomInfo(room)); + if (room.state) { + send(socket, { type: "events", events: redactFor(room.events, SPECTATOR), replayed: true }); + send(socket, { type: "state", view: viewForPlayer(room, SPECTATOR), seq: room.log.length }); + } + broadcastAudience(room); + break; + } + case "leave": { + // Walk away from the table or the gallery: the seat itself (and + // its token) survives for a later resume; only this socket detaches. + leaveGallery(session); + session.playerId = null; + session.roomId = null; + session.token = null; + break; + } case "addBot": { const room = session.roomId ? getRoom(session.roomId) : undefined; if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index be243a3..19a158b 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -361,6 +361,12 @@ export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null { return room.state ? viewFor(room.state, playerId) : null; } +/** The Peanut Gallery's viewer id: the empty name can never hold a seat + * (joins reject blank names), so a view built for it shows public knowledge + * only — no hand, no ward, no ambushes, no boobytrap truths — and event + * redaction drops everything marked visibleTo a player. */ +export const SPECTATOR: PlayerId = ""; + export interface CatchUpStep { seq: number; actor: PlayerId; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 8065bb7..6e123c7 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1160,9 +1160,14 @@ {:else if net.roomId} - room {net.roomId} - - + {net.spectating ? "watching" : "room"} {net.roomId} + {#if net.audience > 0} + 👁 {net.audience} + {/if} + {#if !net.spectating} + + {/if} + {/if} +
or play here, passing the device: @@ -1636,6 +1646,9 @@ {/each} + {#if net.spectating} +

👁 You watch from the Peanut Gallery. Waiting for the boards to flip…

+ {:else}
{#each [0, 1, 2, 3, 4, 5] as c (c)} {@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]} @@ -1681,6 +1694,7 @@ {:else}

Waiting for {net.hostId} to flip the boards…

{/if} + {/if}
{:else if view} @@ -1894,7 +1908,7 @@ onclick={() => local.rollTableDie()}>🎲 roll the die
{/if} - {#if !local.active && net.roomId} + {#if !local.active && net.roomId && !net.spectating}
{ e.preventDefault(); const t = chatDraft.trim(); @@ -2191,6 +2205,9 @@ {/if}
+ {#if net.spectating} + 👁 You watch from the Peanut Gallery — hands stay secret, even from you. + {/if} {#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)} >({}); roomBots = $state>({}); you = $state(null); + /** Seated in the Peanut Gallery: watching nameless, read-only. */ + spectating = $state(false); + /** How many watch from the gallery (0 hides the count). */ + audience = $state(0); view = $state(null); log = $state([]); error = $state(null); @@ -291,14 +295,19 @@ class Net { this.status = "connected"; // Mid-game reconnects (the socket dropped, not the page) walk straight // back to the table; otherwise the lobby ledger is the front door. - const saved = localStorage.getItem(SEAT_KEY); - if (saved && this.roomId) { - try { - const seat = JSON.parse(saved) as { name: string; roomId: string; token: string }; - if (seat.roomId === this.roomId) { - this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token }); - } - } catch { localStorage.removeItem(SEAT_KEY); } + if (this.spectating && this.roomId) { + // A dropped gallery socket rejoins the gallery, not a seat. + this.send({ type: "watch", roomId: this.roomId }); + } else { + const saved = localStorage.getItem(SEAT_KEY); + if (saved && this.roomId) { + try { + const seat = JSON.parse(saved) as { name: string; roomId: string; token: string }; + if (seat.roomId === this.roomId) { + this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token }); + } + } catch { localStorage.removeItem(SEAT_KEY); } + } } this.refreshGames(); }; @@ -320,10 +329,18 @@ class Net { } break; } + case "watching": + this.spectating = true; + this.roomId = msg.roomId; + break; + case "audience": + this.audience = msg.count ?? 0; + break; case "room": this.roomId = msg.roomId; this.roomColors = msg.colors ?? {}; this.roomBots = msg.bots ?? {}; + this.audience = msg.audience ?? 0; if (this.you && this.token) { const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token }; localStorage.setItem(SEAT_KEY, JSON.stringify(seat)); @@ -335,7 +352,7 @@ class Net { break; case "state": { this.view = msg.view; - if (typeof msg.seq === "number" && this.roomId) { + if (typeof msg.seq === "number" && this.roomId && !this.spectating) { this.currentSeq = msg.seq; // Only the FIRST state after arriving carries a gap worth // announcing. Later states were watched live: a caught-up @@ -423,11 +440,20 @@ class Net { create(name: string): void { this.you = name; + this.spectating = false; this.send({ type: "create", name }); } + /** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */ + watch(roomId: string): void { + this.you = null; + this.log = []; + this.send({ type: "watch", roomId: roomId.toUpperCase() }); + } + join(roomId: string, name: string): void { this.you = name; + this.spectating = false; this.roomIdPending = roomId.toUpperCase(); const existing = this.seats.find( (s) => s.roomId === this.roomIdPending && s.name === name, @@ -438,6 +464,7 @@ class Net { /** Sit back down at a remembered seat. */ resume(seat: Seat): void { this.you = seat.name; + this.spectating = false; this.token = seat.token; this.roomIdPending = seat.roomId; this.log = []; @@ -540,6 +567,7 @@ class Net { /** Forget the remembered seat and return to the lobby. */ leave(): void { + this.send({ type: "leave" }); // detach server-side too (frees a gallery seat) localStorage.removeItem(SEAT_KEY); this.roomId = null; this.roomIdPending = null; @@ -548,6 +576,8 @@ class Net { this.players = []; this.log = []; this.token = null; + this.spectating = false; + this.audience = 0; } start(expansion: boolean): void {