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>