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 Wagoner
2026-09-23 09:50:06 -04:00
co-authored by Claude Fable 5.1
parent 75c2dc9241
commit 8e7376d59a
19 changed files with 488 additions and 80 deletions
+49 -5
View File
@@ -7,14 +7,18 @@ import { randomBytes } from 'node:crypto';
import { chooseBotTurn } from '../../src/lib/game/bot';
import { resolveTurn } from '../../src/lib/game/resolve';
import { GESTURES, type Gesture } from '../../src/lib/game/spells';
import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
import { viewFor, type RoomView } from '../../src/lib/game/view';
import { createGame, inDuel, SPECTATOR, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
import { viewFor, type ChatLine, type RoomView } from '../../src/lib/game/view';
import type { LedgerLine, Store } from './store';
/** A dozen seats at most: a turn resolves only when every seat has moved, and the slowest sets the pace. */
const SEAT_IDS: WizardId[] = 'ABCDEFGHJKLM'.split('');
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura', 'Bellamy', 'Hesper', 'Lucan', 'Wynne'];
export const MAX_SEATS = SEAT_IDS.length;
export { SPECTATOR };
const CHAT_MAX_LENGTH = 300;
/** Lines of talk kept and sent; the ledger keeps them all. */
const CHAT_KEEP = 200;
export interface Seat {
id: WizardId;
@@ -31,6 +35,7 @@ export interface Room {
state: GameState | null;
/** Inputs received for the turn in progress, by seat. */
pending: Record<WizardId, TurnInput>;
chat: ChatLine[];
/** Bumped on every change a client might want to see. */
seq: number;
updatedAt: number;
@@ -67,6 +72,8 @@ export function normalizeCode(raw: string): string {
export class Rooms {
private rooms = new Map<string, Room>();
private listeners = new Map<string, Set<(room: Room) => void>>();
/** Sockets watching from the gallery, by room. */
private gallery = new Map<string, number>();
constructor(private store: Store) {
for (const id of store.roomIds()) {
@@ -117,7 +124,7 @@ export class Rooms {
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`);
let id = newCode();
while (this.rooms.has(id)) id = newCode();
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() };
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
this.rooms.set(id, room);
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
const seat = this.sit(room, name, false);
@@ -185,6 +192,19 @@ export class Rooms {
if (room.state?.over) this.commit(room, { t: 'over', at: Date.now(), winner: room.state.over.winner, reason: room.state.over.reason });
}
/** Table talk, from a seat to everyone at the table and in the gallery. */
say(room: Room, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token);
const text = String(raw ?? '')
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, CHAT_MAX_LENGTH);
if (!text) throw new RoomError('Nothing to say.');
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text });
}
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
view(room: Room, seatId: WizardId): RoomView {
return {
roomId: room.id,
@@ -194,7 +214,9 @@ export class Rooms {
host: room.seats[0]?.id ?? seatId,
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
waitingOn: waitingOn(room),
state: room.state ? viewFor(room.state, seatId) : null
state: room.state ? viewFor(room.state, seatId) : null,
chat: room.chat,
audience: this.gallery.get(room.id) ?? 0
};
}
@@ -204,6 +226,24 @@ export class Rooms {
return () => this.listeners.get(roomId)?.delete(fn);
}
/** Subscribe from the gallery; the table is told when its audience changes. */
watch(room: Room, fn: (room: Room) => void): () => void {
const unsubscribe = this.subscribe(room.id, fn);
this.gallery.set(room.id, (this.gallery.get(room.id) ?? 0) + 1);
room.seq += 1;
this.notify(room);
return () => {
unsubscribe();
this.gallery.set(room.id, Math.max(0, (this.gallery.get(room.id) ?? 1) - 1));
room.seq += 1;
this.notify(room);
};
}
audience(room: Room): number {
return this.gallery.get(room.id) ?? 0;
}
/** Write to the ledger, then apply; the file is the truth a restart returns to. */
private commit(room: Room, line: LedgerLine): void {
this.store.append(room.id, line);
@@ -248,13 +288,17 @@ function apply(room: Room, line: LedgerLine): void {
break;
case 'over':
break;
case 'chat':
room.chat.push({ id: line.id, text: line.text, at: line.at });
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
break;
}
}
function replay(id: string, lines: LedgerLine[]): Room | null {
const first = lines[0];
if (!first || first.t !== 'room') return null;
const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, seq: lines.length, updatedAt: first.createdAt };
const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
for (const line of lines) {
apply(room, line);
if (line.t === 'turn') room.updatedAt = line.at;