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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 09:47:26 -04:00
co-authored by Claude Fable 5.1
parent f68b3cef06
commit 13a6fc2603
5 changed files with 128 additions and 12 deletions
+27 -3
View File
@@ -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" });
+36 -3
View File
@@ -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<string, Room>();
@@ -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 });
+9 -1
View File
@@ -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");