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