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
+12 -3
View File
@@ -4,6 +4,7 @@
import Ledger from './Ledger.svelte';
import MobileBar from './MobileBar.svelte';
import SpellSheet from './SpellSheet.svelte';
import TableTalk from './TableTalk.svelte';
import TurnSummary from './TurnSummary.svelte';
import WizardStatus from './WizardStatus.svelte';
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
@@ -24,7 +25,7 @@
{#if outcome}
<div class="verdict" id="verdict" class:won>
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
<h2>{duel.spectating ? (outcome.winner === null ? 'A draw.' : `${duel.state.wizards[outcome.winner].name} wins.`) : won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
<p>{outcome.reason}</p>
{#if duel.remote}
<a class="reveal again" href="/">Back to the hall</a>
@@ -33,7 +34,12 @@
{/if}
</div>
{:else}
{#if duel.out}
{#if duel.spectating}
<section class="move out" aria-labelledby="move-heading">
<h2 id="move-heading">You watch from the Peanut Gallery</h2>
<p class="threats muted">{duel.awaitingText ? `${duel.awaitingText} ${duel.awaiting.length === 1 ? 'is' : 'are'} still writing turn ${duel.turnNumber}.` : `Turn ${duel.turnNumber} is being written.`} The gallery sees only what every wizard at the table can see.</p>
</section>
{:else if duel.out}
<section class="move out" aria-labelledby="move-heading">
<h2 id="move-heading">{duel.you.alive ? 'You have yielded' : 'You have fallen'}</h2>
<p class="threats muted">The duel goes on without you. Watch the ledger fill; {listText(duel.others.filter((id) => duel.state.wizards[id].alive && !duel.state.wizards[id].surrendered).map((id) => duel.state.wizards[id].name))} still stand.</p>
@@ -200,10 +206,13 @@
</section>
<aside class="rail">
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
{#if !duel.spectating}
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
{/if}
{#each duel.others as id (id)}
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
{/each}
<TableTalk {duel} />
<SpellSheet {duel} />
<Chronicle log={duel.state.log} />
</aside>
+14 -2
View File
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { allSeats, api, forgetSeat } from '$lib/game/client';
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte';
import { unreadTalk } from '$lib/game/talk';
import type { RoomView } from '$lib/game/view';
let name = $state(playerName() || duel.you.name);
@@ -92,6 +93,12 @@
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
}
/** Lines of table talk said since this browser last looked in. */
function unread(held: { roomId: string }): number {
const g = games[held.roomId];
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
}
function yourMove(held: { roomId: string; seat: string }): boolean {
const g = games[held.roomId];
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
@@ -109,7 +116,7 @@
<section class="lid">
<div class="lid-head">
<h1>Waving Hands</h1>
<p class="pitch">Two wizards. Two hands each. The right run of gestures is a spell.</p>
<p class="pitch">Duelling wizards. Two hands each. The right run of gestures is a spell.</p>
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture per hand and all reveal together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a></p>
</div>
@@ -135,6 +142,7 @@
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form>
</div>
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
{#if error}<p class="warning">{error}</p>{/if}
</div>
@@ -174,7 +182,7 @@
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a>
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
</div>
@@ -428,6 +436,10 @@
text-decoration: underline;
}
.talk-new {
color: var(--frost);
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
+13 -5
View File
@@ -2,7 +2,7 @@
import type { Duel } from '$lib/game/duel.svelte';
import { glyph } from '$lib/game/glyphs';
import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
import type { HistoryEntry, WizardId } from '$lib/game/state';
import { hiddenFromViewer, type HistoryEntry, type WizardId } from '$lib/game/state';
let { duel }: { duel: Duel } = $props();
@@ -21,8 +21,8 @@
cells: Record<WizardId, Placed[]>;
}
/** You first, then the others in table order. */
const columns = $derived<WizardId[]>([duel.me, ...duel.others]);
/** You first, then the others in table order; the gallery sees the table as laid. */
const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [duel.me, ...duel.others]);
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
const history = who === duel.me ? duel.you.history : duel.visible(who);
@@ -30,7 +30,7 @@
const out: Placed[] = [];
history.forEach((entry, index) => {
if (entry.turn === turn && phases.includes(entry.phase)) {
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(duel.me) });
out.push({ entry, index, hidden: hiddenFromViewer(raw[index].hiddenFrom, duel.me) });
}
});
return out;
@@ -110,7 +110,15 @@
{/each}
</tr>
{/each}
{#if drafting}
{#if drafting && duel.spectating}
<tr class="draft">
<td class="turn">{duel.turnNumber}</td>
{#each columns as who (who)}
<td class="cell foe"><span class="letter placeholder">?</span></td>
<td class="cell foe"><span class="letter placeholder">?</span></td>
{/each}
</tr>
{:else if drafting}
<tr class="draft" class:stopped={duel.timeStopped}>
<td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td>
{#each ['left', 'right'] as const as hand (hand)}
+24 -10
View File
@@ -7,22 +7,26 @@
</script>
{#if !duel.state.over}
<div class="bar">
<div class="bar" class:watching={duel.spectating}>
<div class="health" class:many={duel.others.length > 1}>
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>
{#if !duel.spectating}<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>{/if}
{#each duel.others as id (id)}
{@const w = duel.state.wizards[id]}
<span class:gone={!w.alive || w.surrendered}><em>{w.name}</em> {w.hp}/{MAX_HP}</span>
{/each}
</div>
<div class="gestures" aria-label="Chosen gestures">
{#each HANDS as hand (hand)}
<span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span>
{/each}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
</button>
{#if duel.spectating}
<span class="gallery">the gallery</span>
{:else}
<div class="gestures" aria-label="Chosen gestures">
{#each HANDS as hand (hand)}
<span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span>
{/each}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
</button>
{/if}
</div>
{/if}
@@ -49,6 +53,16 @@
}
}
.bar.watching {
grid-template-columns: 1fr auto;
}
.gallery {
font-size: 0.8rem;
font-style: italic;
color: var(--bone-faint);
}
.health {
display: flex;
flex-direction: column;
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import { tick } from 'svelte';
import type { Duel } from '$lib/game/duel.svelte';
let { duel }: { duel: Duel } = $props();
const remote = $derived(duel.remote);
const lines = $derived(remote?.chat ?? []);
let draft = $state('');
let sending = $state(false);
let list = $state<HTMLElement | null>(null);
// New talk scrolls into view, as at any table.
$effect(() => {
void lines.length;
void tick().then(() => {
if (list) list.scrollTop = list.scrollHeight;
});
});
async function say(e: SubmitEvent) {
e.preventDefault();
const text = draft.trim();
if (!text || sending) return;
sending = true;
if (await duel.say(text)) draft = '';
sending = false;
}
</script>
{#if remote}
<section class="talk" aria-label="Table talk">
<h2>Table talk{#if remote.audience > 0}<span class="audience">{remote.audience} in the gallery</span>{/if}</h2>
<ul bind:this={list}>
{#if lines.length === 0}
<li class="muted">Nobody has said a word.</li>
{/if}
{#each lines as line, i (line.at + ':' + i)}
<li class:you={line.id === duel.me}><em>{line.id === duel.me ? 'You' : duel.speaker(line.id)}</em> {line.text}</li>
{/each}
</ul>
{#if remote.spectating}
<p class="muted small">The gallery listens; only seated wizards speak.</p>
{:else}
<form onsubmit={say}>
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
<button type="submit" class="quiet" disabled={!draft.trim() || sending}>Say</button>
</form>
{/if}
</section>
{/if}
<style>
.talk {
padding: 0.75rem 0;
border-top: 1px solid var(--rule);
}
h2 {
font-size: 1.1rem;
font-style: italic;
margin-bottom: 0.4rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
}
.audience {
font-size: 0.8rem;
font-style: normal;
color: var(--bone-faint);
}
ul {
list-style: none;
margin: 0;
padding: 0;
max-height: 12rem;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.4;
}
li + li {
margin-top: 0.2rem;
}
li em {
color: var(--bone-dim);
}
li.you em {
color: var(--frost);
}
form {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
input {
flex: 1;
min-width: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.3rem 0.8rem;
color: var(--bone);
}
.quiet:disabled {
opacity: 0.5;
}
.small {
font-size: 0.85rem;
margin-top: 0.4rem;
}
</style>
+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;
}