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:
co-authored by
Claude Fable 5.1
parent
f68b3cef06
commit
13a6fc2603
@@ -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" });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -1872,6 +1872,24 @@
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
{#snippet gallerySayBox(extra: string)}
|
||||
<form class="say-box gallery-say {extra}" onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const t = chatDraft.trim();
|
||||
const who = name.trim();
|
||||
if (!t || !who) return;
|
||||
setPref("wizardName", who);
|
||||
if (net.sendChat(t, who)) chatDraft = "";
|
||||
else net.flash("Not sent — the table is out of reach; your words wait in the box");
|
||||
}}>
|
||||
{#if net.chatPending}<span class="sending">sending…</span>{/if}
|
||||
<input class="say-name" bind:value={name} maxlength="20" placeholder="your name" aria-label="your name in the gallery" />
|
||||
<input class="say-input" bind:value={chatDraft} maxlength="300"
|
||||
placeholder="say something from the gallery…" aria-label="gallery talk" />
|
||||
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim() || !name.trim()}>say</button>
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
|
||||
<svelte:window onkeydown={onBoardKey} />
|
||||
|
||||
@@ -1909,8 +1927,12 @@
|
||||
onclick={copyInvite}>
|
||||
{#if net.spectating}watching{:else}<span class="mast-invite">invite friends · </span>room{/if} <b>{net.roomId}</b>{inviteCopied ? " — link copied!" : ""}
|
||||
</button>
|
||||
{#if net.audience > 0}
|
||||
<span class="mast-audience" title="the Peanut Gallery">👁 {net.audience}</span>
|
||||
{#if net.you === net.hostId && net.roomId && (net.audience > 0 || net.galleryTalk)}
|
||||
<button class="mast-audience as-button" class:talking={net.galleryTalk}
|
||||
title={net.galleryTalk ? "the Peanut Gallery may talk — click to hush it" : "the Peanut Gallery listens — click to let it talk"}
|
||||
onclick={() => net.setGalleryTalk(!net.galleryTalk)}>👁 {net.audience}{net.galleryTalk ? " 💬" : ""}</button>
|
||||
{:else if net.audience > 0}
|
||||
<span class="mast-audience" title={net.galleryTalk ? "the Peanut Gallery, allowed to talk" : "the Peanut Gallery"}>👁 {net.audience}{net.galleryTalk ? " 💬" : ""}</span>
|
||||
{/if}
|
||||
{#if !net.spectating}
|
||||
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
||||
@@ -2798,6 +2820,18 @@
|
||||
Add computer wizard</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const galleryTalkLines = net.log.filter((l) => l.text.startsWith("\u{1F4AC}")).slice(-6)}
|
||||
<div class="lobby-talk">
|
||||
{#each galleryTalkLines as line, i (i)}
|
||||
<div class="lobby-line">{line.text}</div>
|
||||
{/each}
|
||||
{#if net.galleryTalk}
|
||||
{@render gallerySayBox("lobby-say")}
|
||||
{:else}
|
||||
<p class="waiting">👁 The gallery listens; the host has not opened its mouth.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if net.you === net.hostId}
|
||||
@@ -2805,6 +2839,10 @@
|
||||
<input type="checkbox" bind:checked={withExpansion} />
|
||||
Include Expansion Set #1 — monsters & wands
|
||||
</label>
|
||||
<label class="check option">
|
||||
<input type="checkbox" checked={net.galleryTalk} onchange={(e) => net.setGalleryTalk(e.currentTarget.checked)} />
|
||||
Let the Peanut Gallery talk at the table
|
||||
</label>
|
||||
<div class="ready">
|
||||
{#if net.players.length < 2}
|
||||
<div class="ready-note">Waiting for one more wizard</div>
|
||||
@@ -3450,6 +3488,8 @@
|
||||
{/if}
|
||||
{#if !local.active && net.roomId && !net.spectating}
|
||||
{@render sayBox("", true)}
|
||||
{:else if net.spectating && net.galleryTalk}
|
||||
{@render gallerySayBox("")}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -385,6 +385,8 @@ class Net {
|
||||
keeper = $state<string | null>(null);
|
||||
/** The keeper's time zone (IANA), for telling a caller what hour it is there. */
|
||||
keeperZone = $state<string | null>(null);
|
||||
/** The host lets the Peanut Gallery talk. */
|
||||
galleryTalk = $state(false);
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<LogLine[]>([]);
|
||||
error = $state<string | null>(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" });
|
||||
|
||||
Reference in New Issue
Block a user