Gallery talk, the keeper's bell, rematches, seats that travel by phrase, and seats doubted before dropped

Brought over from the game kit, where they were ported from Wiz-War. The
host may let the Peanut Gallery talk; a watcher signs a name no seat
holds. A seated wizard may challenge Kestrel, the keeper: a seat is held,
the call rings Sentry once per table, and the lobby says what hour it is
where the keeper lives. A finished table may call for a rematch with the
same company and bots, seats held. Transfer seat mints four words good
for ten minutes and one use; the hall claims them. The hall forgets a
held seat only on the second refusal in a row.

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 12:27:21 -04:00
co-authored by Claude Fable 5.1
parent 5b169d7131
commit e5c240d970
11 changed files with 576 additions and 32 deletions
+62 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { allSeats, api, forgetSeat, type Report } from '$lib/game/client';
import { otherGames, type FamilyGame } from '$lib/family';
import { allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report } 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';
@@ -12,6 +13,11 @@
let seats = $state(allSeats());
let games = $state<Record<string, RoomView | null>>({});
let aboutOpen = $state(false);
/** The other games of the Kestrel's Nest, for the about panel. */
let family = $state<FamilyGame[]>([]);
$effect(() => {
void otherGames('waving-hands').then((g) => (family = g));
});
/** Reports this browser's seats have filed, with the keeper's replies. */
let reports = $state<Report[]>([]);
let answers = $state<Record<string, string>>({});
@@ -44,21 +50,44 @@
const local = $derived(duel.inProgress ? duel : null);
const ready = $derived(name.trim().length > 0);
// Each held seat is looked up once, so the ledger can say whose move it is.
// Each held seat is looked up once, so the ledger can say whose move it
// is. A seat the server refuses outright is doubted; a second refusal in
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
games[held.roomId] = null;
api.view(held.roomId, held.token)
.then((v) => {
trustSeat(held.roomId);
games[held.roomId] = v;
})
.catch(() => {
.catch((e: unknown) => {
games[held.roomId] = null;
if (e instanceof ServerError && (e.status === 403 || e.status === 404) && doubtSeat(held.roomId)) seats = allSeats();
});
}
});
let phrase = $state('');
let claiming = $state(false);
/** Bring a seat from another device by the phrase it minted. */
async function claim(e: SubmitEvent) {
e.preventDefault();
claiming = true;
error = '';
try {
const got = await api.claim(phrase.trim());
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
await goto(`/join/${got.roomId}`);
} catch (err) {
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
} finally {
claiming = false;
}
}
async function playBot() {
busy = true;
error = '';
@@ -170,6 +199,11 @@
</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>
<form class="joinform claimform" onsubmit={claim}>
<span class="or">or bring a seat from another device</span>
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
</form>
{#if error}<p class="warning">{error}</p>{/if}
</div>
@@ -256,6 +290,14 @@
<p>It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
<h3>Send word</h3>
<p>A rule read wrong, a bug, a duel you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
{#if family.length}
<h3>More games at the Kestrel's Nest</h3>
<ul class="family">
{#each family as g (g.slug)}
<li><a href={g.url}>{g.name}</a>: {g.pitch} <span class="muted">{g.players} players; {g.origin}.</span></li>
{/each}
</ul>
{/if}
<p class="made">Waving Hands is Richard Bartle's, 1977. This page was made by Eric and a very enthusiastic AI, 2026.</p>
</div>
</details>
@@ -367,6 +409,15 @@
gap: 0.5rem;
}
.claimform {
flex-wrap: wrap;
margin-top: -0.2rem;
}
.claimform input {
width: 15em;
}
.or {
color: var(--bone-dim);
font-style: italic;
@@ -620,4 +671,12 @@
.about-body a {
color: var(--frost);
}
.family {
margin: 0.4rem 0 0.8rem 1.2rem;
padding: 0;
}
.family li + li {
margin-top: 0.3rem;
}
</style>
+27 -4
View File
@@ -1,12 +1,14 @@
<script lang="ts">
import { tick } from 'svelte';
import type { Duel } from '$lib/game/duel.svelte';
import { NAME_MAX, playerName, rememberName, 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('');
/** The gallery signs its lines; the name is the one the hall remembers. */
let galleryName = $state(playerName());
let sending = $state(false);
let list = $state<HTMLElement | null>(null);
@@ -26,6 +28,17 @@
if (await duel.say(text)) draft = '';
sending = false;
}
async function sayFromGallery(e: SubmitEvent) {
e.preventDefault();
const text = draft.trim();
const name = galleryName.trim();
if (!text || !name || sending) return;
sending = true;
rememberName(name);
if (await duel.sayFromGallery(name, text)) draft = '';
sending = false;
}
</script>
{#if remote}
@@ -36,11 +49,17 @@
<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>
<li class:you={!line.name && line.id === duel.me}><em>{line.name ? `${line.name} (gallery)` : 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>
{#if remote.spectating && remote.galleryTalk}
<form onsubmit={sayFromGallery}>
<input type="text" class="name" bind:value={galleryName} maxlength={NAME_MAX} placeholder="Your name" aria-label="Your name in the gallery" autocomplete="nickname" />
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something from the gallery" aria-label="Say something from the gallery" autocomplete="off" />
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form>
{:else if remote.spectating}
<p class="muted small">The gallery listens; the host has not let it talk.</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" />
@@ -105,6 +124,10 @@
min-width: 0;
}
input.name {
flex: 0 1 8rem;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
+56
View File
@@ -38,6 +38,54 @@ export function forgetSeat(roomId: string): void {
}
}
/**
* A seat the server refused is doubted, not dropped: a restarting server, a
* stale answer or the wrong backend all answer with ignorance, and ignorance
* is not deletion. Only the SECOND refusal in a row costs the seat.
*/
const DOUBT_KEY = 'wh:doubted';
function doubted(): Record<string, true> {
try {
return JSON.parse(localStorage.getItem(DOUBT_KEY) ?? '{}') as Record<string, true>;
} catch {
return {};
}
}
/** Note a refusal; true when it is the second in a row and the seat is forgotten. */
export function doubtSeat(roomId: string): boolean {
const all = doubted();
if (all[roomId]) {
forgetSeat(roomId);
delete all[roomId];
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify(all));
} catch {
// Nothing to note without storage.
}
return true;
}
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify({ ...all, [roomId]: true }));
} catch {
// Nothing to note without storage.
}
return false;
}
/** The server answered for this seat: any doubt is cleared. */
export function trustSeat(roomId: string): void {
const all = doubted();
if (!all[roomId]) return;
delete all[roomId];
try {
localStorage.setItem(DOUBT_KEY, JSON.stringify(all));
} catch {
// Nothing to clear without storage.
}
}
export function rememberSeat(roomId: string, held: HeldSeat): void {
try {
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
@@ -93,6 +141,14 @@ export const api = {
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 word from the gallery, signed with a name, when the host allows it. */
sayFromGallery: (roomId: string, name: string, text: string) => call<RoomView>('POST', `/api/rooms/${roomId}/say`, { name, text }),
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<RoomView>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
challenge: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/challenge`, { token }),
rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: WizardId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
claim: (phrase: string) => call<{ roomId: string; seat: WizardId; token: string }>('POST', '/api/transfer', { phrase }),
report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
reportImage: async (id: string, file: File) => {
+64 -2
View File
@@ -222,8 +222,15 @@ export interface Remote extends HeldSeat {
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
/** Watching from the gallery: no seat, no hands, no voice. */
/** Watching from the gallery: no seat, no hands, no voice unless the host allows it. */
spectating: boolean;
galleryTalk: boolean;
challenge: RoomView['challenge'];
expected: string[];
rematch: RoomView['rematch'];
rematchOf: string | null;
keeper: string;
keeperZone: string;
}
export class Duel {
@@ -786,6 +793,49 @@ export class Duel {
return this.remote?.seats.find((s) => s.id === id)?.name ?? this.state.wizards[id]?.name ?? id;
}
private async remoteAct(fn: (r: Remote) => Promise<RoomView>, failure: string): Promise<boolean> {
const r = this.remote;
if (!r) return false;
r.error = '';
try {
this.apply(await fn(r));
return true;
} catch (e) {
r.error = e instanceof Error ? e.message : failure;
return false;
}
}
/** A word from the gallery, signed; only when the host has let the gallery talk. */
sayFromGallery(name: string, text: string): Promise<boolean> {
if (!this.remote?.spectating) return Promise.resolve(false);
return this.remoteAct((r) => api.sayFromGallery(r.roomId, name, text), 'The table did not hear you.');
}
/** Host only: let the Peanut Gallery talk at this table, or hush it. */
setGalleryTalk(on: boolean): Promise<boolean> {
return this.remoteAct((r) => api.setGalleryTalk(r.roomId, r.token, on), 'The gallery could not be told.');
}
/** Call the keeper of the site to a held seat; their phone rings. */
callKeeper(): Promise<boolean> {
return this.remoteAct((r) => api.challenge(r.roomId, r.token), 'The keeper could not be called.');
}
/** A finished table opens another with the same company; the caller is seated as its host. */
async rematch(): Promise<string> {
const r = this.remote!;
const next = await api.rematch(r.roomId, r.token);
if (next.created && next.seat && next.token) rememberSeat(next.roomId, { seat: next.seat, token: next.token });
return next.roomId;
}
/** A phrase that brings this seat to another device, good for ten minutes. */
transfer(): Promise<{ phrase: string; expiresAt: number }> {
const r = this.remote!;
return api.transfer(r.roomId, r.token);
}
/** Say something to the whole table. Resolves false when the words did not arrive. */
async say(text: string): Promise<boolean> {
const r = this.remote;
@@ -809,6 +859,11 @@ export class Duel {
r.waitingOn = view.waitingOn;
r.chat = view.chat;
r.audience = view.audience;
r.galleryTalk = view.galleryTalk;
r.challenge = view.challenge;
r.expected = view.expected;
r.rematch = view.rematch;
r.rematchOf = view.rematchOf;
markTalkSeen(r.roomId, view.chat.length);
const wasStarted = r.started;
r.started = view.state !== null;
@@ -960,7 +1015,14 @@ function remoteFrom(view: RoomView, token: string): Remote {
error: '',
chat: view.chat,
audience: view.audience,
spectating: view.me === SPECTATOR
spectating: view.me === SPECTATOR,
galleryTalk: view.galleryTalk,
challenge: view.challenge,
expected: view.expected,
rematch: view.rematch,
rematchOf: view.rematchOf,
keeper: view.keeper,
keeperZone: view.keeperZone
};
}
+15 -1
View File
@@ -15,11 +15,12 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
return view;
}
/** One line of table talk. Only seated wizards speak; the gallery listens. */
/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`). */
export interface ChatLine {
id: WizardId;
text: string;
at: number;
name?: string;
}
/** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */
@@ -39,4 +40,17 @@ export interface RoomView {
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
audience: number;
/** The host lets the gallery talk. */
galleryTalk: boolean;
/** The keeper of the site was called to a seat, and by whom. */
challenge: { by: WizardId; at: number } | null;
/** Names with a seat held who have not yet sat: the last table's wizards, or the keeper. */
expected: string[];
/** A finished table's rematch: where it went, and who called. */
rematch: { to: string; by: WizardId } | null;
/** The table this one is the rematch of. */
rematchOf: string | null;
/** The keeper of the site, and where they live, for a table that calls them. */
keeper: string;
keeperZone: string;
}
+2 -1
View File
@@ -101,7 +101,8 @@
<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>
<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, unless the host lets it talk; then a watcher signs a name and their lines read <em>Name (gallery)</em>.</p>
<p>Three more things a table can do. While it is laid, any seated wizard may <strong>challenge the keeper</strong>: a seat is held under the keeper's name, the keeper's phone rings, and the note beside the button says what hour it is where they live, so you know whether to wait or come back. When a duel is over, anyone at the table may call for <strong>a rematch</strong>: a new table of the same size with the same bots, seats held for everyone who was here. And <strong>Transfer seat</strong> in the masthead mints four words that bring your seat to another device, good for ten minutes; the hall on that device has a place to claim them.</p>
</section>
<section id="first">
+117 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte';
@@ -15,6 +16,54 @@
let error = $state('');
let copied = $state(false);
let reporting = $state(false);
/** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false);
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
let minute = $state(0);
$effect(() => {
const id = setInterval(() => (minute += 1), 60_000);
return () => clearInterval(id);
});
const keeperHour = $derived.by(() => {
void minute;
const zone = room?.remote?.keeperZone;
if (!zone) return '';
try {
const hour = Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: zone }).format(new Date())) % 24;
if (hour === 0) return 'midnight';
if (hour === 12) return 'noon';
const word = ['twelve', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven'][hour % 12];
const part = hour < 5 ? 'in the small hours' : hour < 12 ? 'in the morning' : hour < 18 ? 'in the afternoon' : 'in the evening';
return `${word} ${part}`;
} catch {
return '';
}
});
async function transfer() {
if (!room) return;
try {
phrase = await room.transfer();
setTimeout(() => (phrase = null), Math.max(0, phrase.expiresAt - Date.now()));
} catch (e) {
error = e instanceof Error ? e.message : 'The seat could not be offered.';
}
}
/** Call for a rematch, or take the seat held at one already called. */
async function rematch() {
if (!room) return;
rematching = true;
try {
await goto(`/join/${await room.rematch()}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The rematch could not be called.';
} finally {
rematching = false;
}
}
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started);
@@ -115,7 +164,10 @@
<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>
<span class="audience" title="the Peanut Gallery">{room.remote.audience} in the gallery{room.remote.galleryTalk ? ', talking' : ''}</span>
{/if}
{#if !watching}
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if}
{/if}
<a class="quiet" href="/guide">How to play</a>
@@ -135,6 +187,23 @@
<p class="notice">{error}</p>
{/if}
{#if phrase}
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
{/if}
{#if room?.remote?.started && room.state.over && !watching}
{@const r = room.remote}
<p class="notice muted">
{#if r.rematch}
{room.speaker(r.rematch.by)} called for a rematch at table {r.rematch.to}.
<a href={`/join/${r.rematch.to}`}>Take your seat</a>
{:else}
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Duel again with this table</button>
<span>the same company, at a new table; seats are held for everyone here.</span>
{/if}
</p>
{/if}
{#if joining && seatFree}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
@@ -176,8 +245,17 @@
<button type="button" class="unseat" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room?.unseat(s.id)}>×</button>{/if}</span>
</li>
{/each}
<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>
{#each r.expected as name (name)}
<li>
<span class="seat-name">{name}</span>
<span class="muted">{r.challenge && name.toLowerCase() === r.keeper.toLowerCase() ? 'called to the table; a seat is held' : 'from the last table; a seat is held'}</span>
</li>
{/each}
<li class="empty muted">{r.size - r.seats.length - r.expected.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{r.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
</ul>
{#if r.rematchOf}
<p class="muted">A rematch of table {r.rematchOf}: the same company, seats held for those not yet back.</p>
{/if}
{#if !watching}
<div class="ways">
{#if r.seats.length < r.size && room.isHost}
@@ -190,6 +268,20 @@
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
{/if}
</div>
{#if room.isHost}
<label class="switch muted">
<input type="checkbox" checked={r.galleryTalk} onchange={(e) => room?.setGalleryTalk(e.currentTarget.checked)} />
Let the Peanut Gallery talk at the table
</label>
{/if}
{#if r.keeper && !r.challenge && !r.seats.some((s) => s.name.toLowerCase() === r.keeper.toLowerCase()) && r.seats.length + r.expected.length < r.size}
<div class="keeper">
<button type="button" class="quiet" title="A seat is held for {r.keeper}, who keeps this site, and their phone rings" onclick={() => room?.callKeeper()}>Challenge {r.keeper}, the keeper</button>
<p class="muted small">{r.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {r.keeper} lives.{/if} The seat is held either way.</p>
</div>
{:else if r.challenge}
<p class="muted small">{r.keeper} has been called to the table by {room.speaker(r.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another duel in a new tab and play on. This table keeps its place in your hall until you come back.</p>
{/if}
{/if}
{#if r.error}<p class="notice">{r.error}</p>{/if}
<div class="lobby-talk"><TableTalk duel={room} /></div>
@@ -274,6 +366,29 @@
margin-top: 1rem;
}
.switch {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: 0.9rem;
}
.keeper {
margin-top: 0.8rem;
max-width: 38em;
}
.small {
font-size: 0.85rem;
margin-top: 0.4rem;
}
.phrase {
letter-spacing: 0.06em;
user-select: all;
}
.eyebrow {
font-size: 0.85rem;
letter-spacing: 0.12em;