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} +
👁 You watch from the Peanut Gallery. Waiting for the boards to flip…
+ {:else}Waiting for {net.hostId} to flip the boards…
{/if} + {/if}