The Peanut Gallery and table talk

A room's link without a seat is now a place in the gallery: a tokenless
view that withholds any gesture hidden from anyone at the table, counted
for the table and the lobby. Seated wizards can talk; each line is a
ledger line, shown in the rail and in the lobby, and the hall counts
what was said while you were away. The hall's pitch no longer says two
wizards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-23 09:50:06 -04:00
1 parent 75c2dc9241
commit 8e7376d59a
19 files changed
+488 -80

No files matched your search

+7 -3
View File
@@ -74,10 +74,13 @@ export const api = {
addBot: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/bot`, { token }),
begin: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/begin`, { token }),
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input })
/** The gallery's view: no token, no seat, nothing any wizard could not see. */
watch: (roomId: string) => call<RoomView>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
say: (roomId: string, token: string, text: string) => call<RoomView>('POST', `/api/rooms/${roomId}/say`, { token, text })
};
/** A socket that only ever says "fetch the view again"; reconnects on its own. */
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
let socket: WebSocket | null = null;
let closed = false;
@@ -85,7 +88,8 @@ export function watchRoom(roomId: string, token: string, onUpdate: () => void, o
const open = () => {
if (closed) return;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}&token=${encodeURIComponent(token)}`);
const seat = token ? `&token=${encodeURIComponent(token)}` : '';
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}${seat}`);
socket.onopen = () => {
delay = 1000;
onStatus(true);
+48 -5
View File
@@ -5,6 +5,7 @@
import { tick } from 'svelte';
import { chooseBotTurn } from './bot';
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk';
import {
cellsOverlap,
completableIn,
@@ -33,6 +34,7 @@ import {
} from './spells';
import {
NOWHERE,
SPECTATOR,
createGame,
enemyOf,
inDuel,
@@ -44,7 +46,7 @@ import {
type TurnInput,
type WizardId
} from './state';
import type { RoomView } from './view';
import type { ChatLine, RoomView } from './view';
/** The seats of a duel played in this browser against the bot. */
const LOCAL_ME: WizardId = 'A';
@@ -214,6 +216,11 @@ export interface Remote extends HeldSeat {
started: boolean;
connected: boolean;
error: string;
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
/** Watching from the gallery: no seat, no hands, no voice. */
spectating: boolean;
}
export class Duel {
@@ -249,7 +256,7 @@ export class Duel {
if (initial && 'roomId' in initial) {
this.remote = initial;
this.me = initial.seat;
this.state = createGame({ [initial.seat]: 'You' });
this.state = createGame({ [initial.seat]: initial.spectating ? 'The gallery' : 'You' });
} else if (initial) {
this.state = initial;
} else if (saved) {
@@ -313,7 +320,10 @@ export class Duel {
/** A duel worth asking about before it is thrown away. */
inProgress = $derived(this.state.turn > 0 && !this.state.over);
you = $derived(this.state.wizards[this.me]);
/** Watching from the gallery, with no seat at the table. */
spectating = $derived(this.remote?.spectating ?? false);
/** The gallery has no wizard of its own; it borrows the first seat for the fields that need one, and shows none of them. */
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
/** The opponent spells go at by default: the first still standing. */
foeId = $derived(enemyOf(this.state, this.me));
foe = $derived(this.state.wizards[this.foeId]);
@@ -723,6 +733,25 @@ export class Duel {
}
}
/** What a seat is called, for a line of talk. */
speaker(id: WizardId): string {
return this.remote?.seats.find((s) => s.id === id)?.name ?? this.state.wizards[id]?.name ?? id;
}
/** Say something to the whole table. Resolves false when the words did not arrive. */
async say(text: string): Promise<boolean> {
const r = this.remote;
if (!r || r.spectating) return false;
r.error = '';
try {
this.apply(await api.say(r.roomId, r.token, text));
return true;
} catch (e) {
r.error = e instanceof Error ? e.message : 'The table did not hear you.';
return false;
}
}
/** Take a fresh view from the server; a new turn clears the drafts. */
apply(view: RoomView): void {
const r = this.remote!;
@@ -730,6 +759,9 @@ export class Duel {
r.size = view.size;
r.host = view.host;
r.waitingOn = view.waitingOn;
r.chat = view.chat;
r.audience = view.audience;
markTalkSeen(r.roomId, view.chat.length);
const wasStarted = r.started;
r.started = view.state !== null;
if (!view.state) return;
@@ -742,8 +774,11 @@ export class Duel {
connect(): () => void {
const r = this.remote;
if (!r) return () => {};
const fetchView = () => (r.spectating ? api.watch(r.roomId) : api.view(r.roomId, r.token));
const refresh = () => {
api.view(r.roomId, r.token).then((v) => this.apply(v)).catch((e: Error) => (r.error = e.message));
fetchView()
.then((v) => this.apply(v))
.catch((e: Error) => (r.error = e.message));
};
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
}
@@ -844,6 +879,11 @@ export class Duel {
static async resumeRoom(roomId: string, held: HeldSeat): Promise<Remote> {
return remoteFrom(await api.view(roomId, held.token), held.token);
}
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
static async watchRoom(roomId: string): Promise<Remote> {
return remoteFrom(await api.watch(roomId), '');
}
}
function remoteFrom(view: RoomView, token: string): Remote {
@@ -857,7 +897,10 @@ function remoteFrom(view: RoomView, token: string): Remote {
waitingOn: view.waitingOn,
started: view.state !== null,
connected: false,
error: ''
error: '',
chat: view.chat,
audience: view.audience,
spectating: view.me === SPECTATOR
};
}
+9 -3
View File
@@ -288,7 +288,13 @@ export function beingName(state: GameState, id: TargetId | undefined): string {
*/
export function visibleHistory(state: GameState, viewer: WizardId, subject: WizardId): HistoryEntry[] {
if (viewer === subject) return state.wizards[subject].history;
return state.wizards[subject].history.map((h) =>
h.hiddenFrom.includes(viewer) ? { ...h, left: '-', right: '-' } : h
);
return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
}
/** The gallery holds no seat: it is shown only what every wizard at the table could see. */
export const SPECTATOR: WizardId = '';
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
if (!hiddenFrom?.length) return false;
return viewer === SPECTATOR || hiddenFrom.includes(viewer);
}
+25
View File
@@ -0,0 +1,25 @@
// How much of each table's talk this browser has read, so the hall can say what is new.
const SEEN_KEY = 'wh:talk-seen';
function seen(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') as Record<string, number>;
} catch {
return {};
}
}
export function markTalkSeen(roomId: string, count: number): void {
try {
const all = seen();
if ((all[roomId] ?? 0) >= count) return;
localStorage.setItem(SEEN_KEY, JSON.stringify({ ...all, [roomId]: count }));
} catch {
// Without storage every line reads as new, which is the safe side.
}
}
export function unreadTalk(roomId: string, count: number): number {
return Math.max(0, count - (seen()[roomId] ?? 0));
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { SPECTATOR, createGame } from './state';
import { viewFor } from './view';
/** Three wizards; B's first gestures were hidden from C alone. */
function tableWithASecret() {
const s = createGame({ A: 'Aldric', B: 'Bryn', C: 'Cass' }, 1);
s.wizards.B.history.push({ left: 'P', right: 'S', turn: 0, phase: 'main', hiddenFrom: ['C'] });
s.log.push({ turn: 0, kind: 'gesture', text: 'Bryn: left proffered palm, right snap.', hiddenFrom: ['C'] });
s.log.push({ turn: 0, kind: 'info', text: 'Nothing else happened.' });
return s;
}
describe('the view a room sends', () => {
it('shows a hidden gesture to the wizards who could see it', () => {
const view = viewFor(tableWithASecret(), 'A');
expect(view.wizards.B.history[0].left).toBe('P');
expect(view.log).toHaveLength(2);
});
it('withholds it from the wizard it was hidden from', () => {
const view = viewFor(tableWithASecret(), 'C');
expect(view.wizards.B.history[0].left).toBe('-');
expect(view.log.map((l) => l.text)).toEqual(['Nothing else happened.']);
});
it('withholds from the gallery anything hidden from anyone', () => {
const view = viewFor(tableWithASecret(), SPECTATOR);
expect(view.wizards.B.history[0].left).toBe('-');
expect(view.log.map((l) => l.text)).toEqual(['Nothing else happened.']);
});
});
+14 -3
View File
@@ -3,7 +3,7 @@
// describe those gestures are withheld too. Spells cast are always visible, as
// the rules say.
import { visibleHistory, type GameState, type WizardId } from './state';
import { hiddenFromViewer, visibleHistory, type GameState, type WizardId } from './state';
export function viewFor(state: GameState, viewer: WizardId): GameState {
const view = structuredClone(state);
@@ -11,16 +11,24 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
if (id === viewer) continue;
view.wizards[id].history = visibleHistory(state, viewer, id);
}
view.log = view.log.filter((l) => !l.hiddenFrom?.includes(viewer));
view.log = view.log.filter((l) => !hiddenFromViewer(l.hiddenFrom, viewer));
return view;
}
/** What a seated player is told by the server. Other seats' moves and tokens never leave it. */
/** One line of table talk. Only seated wizards speak; the gallery listens. */
export interface ChatLine {
id: WizardId;
text: string;
at: number;
}
/** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */
export interface RoomView {
roomId: string;
seq: number;
/** The most seats the table will take. */
size: number;
/** The viewer's seat, or SPECTATOR for the gallery. */
me: WizardId;
/** The wizard who opened the table and may begin the duel. */
host: WizardId;
@@ -28,4 +36,7 @@ export interface RoomView {
/** Seats that still have to move before the turn resolves. */
waitingOn: WizardId[];
state: GameState | null;
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
}