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
@@ -25,7 +25,8 @@ spills into the next UTC day. Run yesterday too when the hour is early.
NEW means the name's first room ever is today.
- **rooms today**: one line each: time, code, humans vs bots, empty
seats, state (lobby only / started, no turns / in play), turn count,
span, and the last turn's time.
span, the last turn's time, and how many lines of table talk were said.
Talk is a sign people came together; a bot game has none.
## Read it
+3 -1
View File
@@ -22,7 +22,9 @@ people has a four-letter room code; `/join/CODE` is both the invite link and the
played. Turns are taken whenever each player has a moment, and the page refreshes itself when
the other wizard moves. An empty seat can be given to the bot. Each turn, choose a gesture per
hand, pick which completed spell (if any) each hand casts and at whom, and reveal. The rules
are at `/rules`, and a walk through the page with screenshots at `/guide`.
are at `/rules`, and a walk through the page with screenshots at `/guide`. A room's link opened
without a seat is the Peanut Gallery: a read-only view showing only what every wizard could see.
Seated wizards can talk to the table; the talk is a ledger line like any other.
Around the two hands:
+7 -4
View File
@@ -81,7 +81,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
except Exception:
continue
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
humans, bots_seated, started, turns, last, over = [], [], False, 0, created, None
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
names = {}
for x in lines[1:]:
if x["t"] == "seat":
@@ -92,12 +92,14 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
elif x["t"] == "turn":
turns += 1
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
elif x["t"] == "chat":
talk += 1
elif x["t"] == "over":
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
for h in humans:
if h not in first_seen or created < first_seen[h]:
first_seen[h] = created
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over))
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk))
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
names = sorted({h for r in today for h in r["humans"]})
@@ -110,7 +112,8 @@ for r in today:
span = ("%d min" % round((r["last"] - r["created"]).total_seconds() / 60)) if r["turns"] else ""
seated = "+".join(r["humans"]) + ((" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "")
empty = r["size"] - len(r["humans"]) - len(r["bots"])
print(" %s %s %s%s %s %d turns %s last turn %s" % (
print(" %s %s %s%s %s %d turns %s last turn %s%s" % (
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never"))
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
PY
+28 -11
View File
@@ -5,9 +5,11 @@
// POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the duel begins)
// POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far
// GET /api/rooms/:id?token= the view for that seat
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
// POST /api/rooms/:id/turn {token, input} this seat's move
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
// a seat in the Peanut Gallery, counted for the table
//
// Caddy serves the static site and proxies /api and /ws here.
@@ -15,7 +17,7 @@ import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import { RateLimit } from './ratelimit';
import { RoomError, Rooms } from './rooms';
import { RoomError, Rooms, SPECTATOR } from './rooms';
import { Store } from './store';
const PORT = Number(process.env.PORT ?? 8788);
@@ -35,10 +37,14 @@ process.on('unhandledRejection', (reason) => {
const rooms = new Rooms(new Store(DATA_DIR));
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
const doors = new RateLimit(40, 60 * 60 * 1000);
/** Table talk: a lively table says a few lines a minute, not hundreds. */
const voices = new RateLimit(240, 60 * 60 * 1000);
const MAX_AUDIENCE = 30;
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS);
doors.prune();
voices.prune();
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
}, 60 * 60 * 1000).unref();
@@ -95,8 +101,9 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const room = rooms.get(parts[2] ?? '');
const action = parts[3];
if (!action && req.method === 'GET') {
const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined);
return send(res, 200, rooms.view(room, seat.id));
const token = url.searchParams.get('token');
if (!token) return send(res, 200, rooms.view(room, SPECTATOR));
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
const body = await readBody(req);
@@ -122,6 +129,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'say') {
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.say(room, token, body.text);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
throw new RoomError('Not here.', 404);
}
@@ -139,17 +153,20 @@ server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname !== '/ws') return socket.destroy();
let seatId: string;
let roomId: string;
let room: ReturnType<Rooms['get']>;
try {
roomId = url.searchParams.get('room') ?? '';
seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id;
room = rooms.get(url.searchParams.get('room') ?? '');
const token = url.searchParams.get('token');
seatId = token ? rooms.seatOf(room, token).id : SPECTATOR;
if (seatId === SPECTATOR && rooms.audience(room) >= MAX_AUDIENCE) throw new RoomError('The gallery is full.', 429);
} catch {
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
const unsubscribe = rooms.subscribe(roomId, (room) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq }));
});
const onChange = (changed: ReturnType<Rooms['get']>) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: changed.seq }));
};
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe);
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
});
+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;
+3 -1
View File
@@ -12,7 +12,9 @@ export type LedgerLine =
| { t: 'start'; seed: number }
| { t: 'turn'; at: number; inputs: Record<WizardId, TurnInput> }
/** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */
| { t: 'over'; at: number; winner: WizardId | null; reason: string };
| { t: 'over'; at: number; winner: WizardId | null; reason: string }
/** A line of table talk from a seated wizard. */
| { t: 'chat'; at: number; id: WizardId; text: string };
export class Store {
constructor(private dir: string) {
+11 -2
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">
{#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)}
+16 -2
View File
@@ -7,14 +7,17 @@
</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>
{#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>
@@ -23,6 +26,7 @@
<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;
}
+7
View File
@@ -16,6 +16,7 @@
<a href="#reveal">The reveal</a>
<a href="#rail">Health, creatures and the sheet</a>
<a href="#phone">On a phone</a>
<a href="#gallery">The Peanut Gallery and table talk</a>
<a href="#first">A first duel</a>
</nav>
</header>
@@ -97,6 +98,12 @@
<p>Everything is here, in one column. The bar at the foot keeps every wizard's health, your two chosen gestures and the reveal button in reach; after a reveal the page brings the ledger's last rows and the result into view. Lists are shorter, and <em>More spells</em> opens the rest.</p>
</section>
<section id="gallery">
<h2>The Peanut Gallery and table talk</h2>
<p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery: they see the ledger, the result of each turn and the chronicle, and the table is told how many are watching. The gallery is shown only what every wizard at the table could see; a gesture hidden from any one of them is a question mark to everyone in the gallery, and the turn being written shows nothing at all.</p>
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and everyone in the gallery, and it is kept with the duel; when you come back to a table, the hall says how many lines were said while you were away. The gallery listens and does not speak.</p>
</section>
<section id="first">
<h2>A first duel</h2>
<p>Open with a wave on each hand. From W-W you may turn either hand into a counter-spell (W-W-S), a protection from evil (W-W-P) or resist heat (W-W-F-P), and nobody can hurt you on the first turn. Watch the warnings: when a missile is one gesture away, a palm costs you one hand for one turn; when a fireball is one gesture away, only a counter-spell, a resistance, or a counter of your own will do.</p>
+53 -9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import TableTalk from '$lib/components/TableTalk.svelte';
import { seatFor } from '$lib/game/client';
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
@@ -15,8 +16,11 @@
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started);
const watching = $derived(room?.spectating ?? false);
/** A watcher may still sit while the table is laid and a seat is free. */
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
// Return to a held seat, or offer one.
// Return to a held seat, or take a place in the gallery and offer one.
$effect(() => {
const id = roomId;
room = null;
@@ -25,6 +29,9 @@
if (!held) {
name = playerName();
joining = true;
Duel.watchRoom(id)
.then((remote) => (room = new Duel(remote)))
.catch((e: Error) => (error = e.message));
return;
}
joining = false;
@@ -84,7 +91,13 @@
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if room?.remote?.started}
{#if room?.remote?.started && watching}
<p class="standfirst">
From the Peanut Gallery you watch
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each} duel.
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
</p>
{:else if room?.remote?.started}
<p class="standfirst">
You are <em>{room.you.name}</em>, duelling
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}.
@@ -97,8 +110,11 @@
<nav class="actions">
{#if room}
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
<span class="muted">invite friends · room</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
<span class="muted">{watching ? 'watching · room' : 'invite friends · room'}</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
</button>
{#if room.remote && room.remote.audience > 0}
<span class="audience" title="the Peanut Gallery">{room.remote.audience} in the gallery</span>
{/if}
{/if}
<a class="quiet" href="/guide">How to play</a>
<a class="quiet" href="/rules">Rules</a>
@@ -110,11 +126,11 @@
<p class="notice">{error}</p>
{/if}
{#if joining}
{#if joining && seatFree}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>A wizard has opened this table and is gathering opponents.</p>
<p>A wizard has opened this table and is gathering opponents. Choose a name and sit; the duel begins when the host says.</p>
<form class="rename" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
@@ -122,15 +138,27 @@
</label>
<button type="submit" class="reveal small" disabled={busy}>Sit down</button>
</form>
<p class="muted">Just watching? You are in the Peanut Gallery until you sit.</p>
</section>
{:else if room && lobby}
{/if}
{#if room && lobby}
{@const r = room.remote!}
{@const hostName = r.seats.find((s) => s.id === r.host)?.name ?? 'the host'}
<section class="lobby">
{#if watching}
<h2>At the table</h2>
{#if seatFree}
<p>{hostName} is gathering wizards. Sit down above, or watch from the gallery until the duel begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the duel begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {roomId}</p>
<h2>The table is laid</h2>
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen.</p>
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen. Anyone who arrives once the duel has begun watches from the Peanut Gallery.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
{/if}
<ul class="roster">
{#each r.seats as s (s.id)}
<li>
@@ -138,8 +166,9 @@
<span class="muted">{s.id === r.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === r.seat ? ', you' : ''}</span>
</li>
{/each}
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty</li>
<li class="empty muted">{r.size - r.seats.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{/if}</li>
</ul>
{#if !watching}
<div class="ways">
{#if r.seats.length < r.size}
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
@@ -151,12 +180,14 @@
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
{/if}
</div>
{/if}
{#if r.error}<p class="notice">{r.error}</p>{/if}
<div class="lobby-talk"><TableTalk duel={room} /></div>
</section>
{:else if room}
<Board duel={room} />
{:else if !error}
<p class="notice muted">Finding your seat.</p>
<p class="notice muted">{joining ? 'Finding the table.' : 'Finding your seat.'}</p>
{/if}
<footer class="colophon">
@@ -217,6 +248,19 @@
font-weight: 600;
}
.audience {
align-self: center;
font-size: 0.85rem;
font-style: italic;
color: var(--bone-faint);
white-space: nowrap;
}
.lobby-talk {
max-width: 28em;
margin-top: 1rem;
}
.eyebrow {
font-size: 0.85rem;
letter-spacing: 0.12em;