From 13a6fc2603c8da86870bef3ce01834382b8c7d38 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 09:47:26 -0400 Subject: [PATCH] The host may let the Peanut Gallery talk A gallery line wears the eye and a name that no wizard at the table holds; the host opens or hushes it from the lobby or the mast, on the ledger, and the gallery is reined per address. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/server/src/index.ts | 30 +++++++++++++++++++--- packages/server/src/rooms.ts | 39 +++++++++++++++++++++++++--- packages/server/src/store.ts | 10 +++++++- packages/web/src/App.svelte | 47 ++++++++++++++++++++++++++++++++-- packages/web/src/net.svelte.ts | 14 +++++++--- 5 files changed, 128 insertions(+), 12 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d02a211..0187144 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,7 +8,8 @@ // {type:"makeTransfer"} mint a seat-transfer phrase // {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:"chat", text, name?} table talk to the room (name: from the gallery) +// {type:"galleryTalk", on} host lets the Peanut Gallery talk, or not // {type:"feedback", happened, expected} a surprise report, pinned to room+seq // {type:"myFeedback", seats} your reports + the wizards' replies // {type:"rollDie"} the tabletop D4, published as talk @@ -58,6 +59,9 @@ import { runningRooms, addAutomaton, addChat, + addGalleryChat, + setGalleryTalk, + GALLERY_MARK, driveOneAutomaton, makeTransferCode, redactFor, @@ -151,6 +155,8 @@ const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000); const KEEPER_ZONE = process.env.WIZWAR_KEEPER_TZ ?? "America/New_York"; const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, ""); const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000); +/** The gallery talks under a tighter rein than the table: no seat, no token, one address. */ +const galleryPerAddress = new SlidingLimit(30, 10 * 60 * 1000); 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 @@ -159,7 +165,8 @@ const NAME_MAX = 24; /** Player/room names: printable, trimmed, bounded. */ function cleanName(raw: unknown): string { - return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, NAME_MAX); + // The gallery's mark is not a name anyone may take. + return String(raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").replaceAll(GALLERY_MARK, "").trim().slice(0, NAME_MAX); } const port = Number(process.env.PORT ?? 8787); @@ -597,6 +604,7 @@ function roomInfo(room: Room) { challenge: room.challenge ?? null, keeper: KEEPER, keeperZone: KEEPER_ZONE, + galleryTalk: room.galleryTalk, colors: Object.fromEntries(room.colorChoices), bots: Object.fromEntries( // A mystery machine keeps its mood only while the game lives: once it @@ -972,12 +980,28 @@ wss.on("connection", (socket, req) => { } case "chat": { const room = session.roomId ? getRoom(session.roomId) : undefined; - if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + if (!room) return send(socket, { type: "error", message: "not in a room" }); + if (session.spectator) { + if (!galleryPerAddress.allow(session.address)) return send(socket, { type: "error", message: "the gallery has said plenty for now" }); + const said = addGalleryChat(room, cleanName(msg.name), String(msg.text ?? "")); + if ("error" in said) return send(socket, { type: "error", message: said.error }); + broadcast(room, () => ({ type: "chat", player: said.player, text: said.text, at: said.at })); + break; + } + if (!session.playerId) return send(socket, { type: "error", message: "not in a room" }); const result = addChat(room, session.playerId, String(msg.text ?? "")); if ("error" in result) return send(socket, { type: "error", message: result.error }); broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at })); break; } + case "galleryTalk": { + const room = session.roomId ? getRoom(session.roomId) : undefined; + if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); + const set = setGalleryTalk(room, session.playerId, msg.on === true); + if ("error" in set) return send(socket, { type: "error", message: set.error }); + broadcastRoomState(room); + break; + } case "makeTransfer": { 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 48d9c09..33bdaec 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -59,6 +59,8 @@ export interface Room { expected?: PlayerId[]; /** The table called the keeper of this site to a seat, and by whom. */ challenge?: { by: PlayerId; at: string }; + /** The host lets the Peanut Gallery talk at the table. */ + galleryTalk: boolean; } const rooms = new Map(); @@ -127,6 +129,7 @@ export function createRoom( events: [], chat: [], bots: new Map(), + galleryTalk: false, touchedAt: Date.now(), ...(rematch ? { expected: [...rematch.expected] } : {}), }; @@ -421,15 +424,42 @@ const CHAT_KEEP = 500; export function addChat(room: Room, playerId: PlayerId, rawText: string): { text: string; at: string } | { error: string } { if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" }; + return recordChat(room, playerId, rawText); +} + +/** The gallery's mark: a name wearing it speaks from the seats, not the table. */ +export const GALLERY_MARK = "\u{1F441}"; + +/** A word from the Peanut Gallery, when the host allows it. The speaker's + * name wears the gallery's mark, and may not be a name at the table. */ +export function addGalleryChat(room: Room, name: string, rawText: string): { player: PlayerId; text: string; at: string } | { error: string } { + if (!room.galleryTalk) return { error: "the gallery keeps quiet at this table" }; + if (!name) return { error: "sign your name first" }; + if (room.players.some((p) => p.toLowerCase() === name.toLowerCase())) return { error: "a wizard at the table has that name" }; + const player = `${GALLERY_MARK} ${name}`; + const said = recordChat(room, player, rawText); + return "error" in said ? said : { player, ...said }; +} + +/** Host only: open or close the gallery's mouth, on the ledger. */ +export function setGalleryTalk(room: Room, byId: PlayerId, on: boolean): { on: boolean } | { error: string } { + if (byId !== room.hostId) return { error: "only the host decides whether the gallery may talk" }; + if (room.galleryTalk === on) return { on }; + room.galleryTalk = on; + appendLine(room.id, { kind: "galleryTalk", on, by: byId, at: new Date().toISOString() }); + return { on }; +} + +function recordChat(room: Room, speaker: PlayerId, rawText: string): { text: string; at: string } | { error: string } { const text = rawText.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, CHAT_MAX_LENGTH); if (!text) return { error: "say something" }; const at = new Date().toISOString(); - room.chat.push({ player: playerId, text, at }); + room.chat.push({ player: speaker, text, at }); if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP); // Talk takes its place in the chronicle where it was said, so rejoining // players see it interwoven with the action, not sunk to the bottom. - room.events.push({ type: "tableTalk", player: playerId, text }); - appendLine(room.id, { kind: "chat", player: playerId, text, at }); + room.events.push({ type: "tableTalk", player: speaker, text }); + appendLine(room.id, { kind: "chat", player: speaker, text, at }); return { text, at }; } @@ -793,6 +823,7 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { events: [], chat: [], bots: new Map(), + galleryTalk: false, touchedAt: Date.now(), ...(meta.expected ? { expected: [...meta.expected] } : {}), ...(meta.expansion !== undefined ? { expansion: meta.expansion } : {}), @@ -829,6 +860,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { } else if (line.kind === "challenge") { room.challenge = { by: line.by, at: line.at }; if (!room.players.includes(KEEPER)) expectKeeper(room, KEEPER); + } else if (line.kind === "galleryTalk") { + room.galleryTalk = line.on; } else if (line.kind === "chat") { // File order preserves the interleaving with commands. room.chat.push({ player: line.player, text: line.text, at: line.at }); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index b7b3b53..0b309d0 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -30,6 +30,14 @@ export interface ChallengeLine { at: string; } +/** The host opened (or closed) the Peanut Gallery's mouth. */ +export interface GalleryTalkLine { + kind: "galleryTalk"; + on: boolean; + by: string; + at: string; +} + /** The finished table called for a rematch: the new room it moved to. */ export interface RematchLine { kind: "rematch"; @@ -91,7 +99,7 @@ export interface AbandonLine { at: string; } -export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine; +export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine; const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 891f068..20053f2 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1872,6 +1872,24 @@ {/snippet} +{#snippet gallerySayBox(extra: string)} + +{/snippet} + @@ -1909,8 +1927,12 @@ onclick={copyInvite}> {#if net.spectating}watching{:else}invite friends · room{/if} {net.roomId}{inviteCopied ? " — link copied!" : ""} - {#if net.audience > 0} - 👁 {net.audience} + {#if net.you === net.hostId && net.roomId && (net.audience > 0 || net.galleryTalk)} + + {:else if net.audience > 0} + 👁 {net.audience}{net.galleryTalk ? " 💬" : ""} {/if} {#if !net.spectating} @@ -2798,6 +2820,18 @@ Add computer wizard {/if} + {:else} + {@const galleryTalkLines = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)} +
+ {#each galleryTalkLines as line, i (i)} +
{line.text}
+ {/each} + {#if net.galleryTalk} + {@render gallerySayBox("lobby-say")} + {:else} +

👁 The gallery listens; the host has not opened its mouth.

+ {/if} +
{/if} {#if net.you === net.hostId} @@ -2805,6 +2839,10 @@ Include Expansion Set #1 — monsters & wands +
{#if net.players.length < 2}
Waiting for one more wizard
@@ -3450,6 +3488,8 @@ {/if} {#if !local.active && net.roomId && !net.spectating} {@render sayBox("", true)} + {:else if net.spectating && net.galleryTalk} + {@render gallerySayBox("")} {/if} @@ -3781,6 +3821,9 @@ padding: 0 0.3rem; } .mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; } + .mast-audience.as-button { background: none; border: 0; padding: 0; cursor: pointer; font: inherit; font-size: 0.85rem; } + .mast-audience.talking { color: #6b5a41; } + .gallery-say .say-name { width: 7rem; flex: 0 0 auto; } .mind-read { max-width: min(92vw, 46rem); } .caution-reason { padding: 0.15rem 0; } .caution-reason + .caution-reason { border-top: 1px dotted #4a4536; } diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index a84783c..2922e7a 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -385,6 +385,8 @@ class Net { keeper = $state(null); /** The keeper's time zone (IANA), for telling a caller what hour it is there. */ keeperZone = $state(null); + /** The host lets the Peanut Gallery talk. */ + galleryTalk = $state(false); view = $state(null); log = $state([]); error = $state(null); @@ -544,6 +546,7 @@ class Net { this.challenge = msg.challenge ?? null; if (typeof msg.keeper === "string") this.keeper = msg.keeper; if (typeof msg.keeperZone === "string") this.keeperZone = msg.keeperZone; + this.galleryTalk = msg.galleryTalk === true; if (this.you && this.token) { const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token }; localStorage.setItem(SEAT_KEY, JSON.stringify(seat)); @@ -656,7 +659,7 @@ class Net { this.roomIdPending = msg.roomId; break; case "chat": { - if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null; + if ((msg.player === this.you || this.spectating) && this.chatPending === msg.text) this.chatPending = null; this.lastTalkBy = msg.player; this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }]; this.chatCount += 1; @@ -834,12 +837,17 @@ class Net { } /** True when the line left; the composer keeps it otherwise. */ - sendChat(text: string): boolean { - if (!this.send({ type: "chat", text })) return false; + sendChat(text: string, galleryName?: string): boolean { + if (!this.send(galleryName ? { type: "chat", text, name: galleryName } : { type: "chat", text })) return false; this.chatPending = text; return true; } + /** Host only: let the Peanut Gallery talk at this table, or hush it. */ + setGalleryTalk(on: boolean): void { + this.send({ type: "galleryTalk", on }); + } + /** Call the keeper of this site to the table: a seat is held, and their phone rings. */ challengeKeeper(): void { this.send({ type: "challengeKeeper" });