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);