Files
waving-hands/src/lib/components/Hall.svelte
T

731 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { goto } from '$app/navigation';
import { loadFamily, type FamilyGame } from '$lib/family';
import { allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } 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);
let code = $state('');
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView | null>>({});
let aboutOpen = $state(false);
/** The ledgers' tally, fetched when the about panel opens. */
let tally = $state<Tally | null>(null);
$effect(() => {
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
});
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
/** The other games of Kestrel's Hall, for the about panel. */
let family = $state<FamilyGame[]>([]);
let hall = $state('');
$effect(() => {
void loadFamily('waving-hands').then((f) => {
family = f.games;
hall = f.hall;
});
});
/** Reports this browser's seats have filed, with the keeper's replies. */
let reports = $state<Report[]>([]);
let answers = $state<Record<string, string>>({});
let answerError = $state('');
// The desk is asked once per visit for every seat this browser holds.
$effect(() => {
const held = seats;
if (!held.length) return;
api.myReports(held.map((h) => ({ roomId: h.roomId, token: h.token })))
.then((r) => (reports = r.reports.sort((a, b) => (a.at < b.at ? 1 : -1))))
.catch(() => (reports = []));
});
async function answer(r: Report) {
const text = (answers[r.id] ?? '').trim();
const held = seats.find((h) => h.roomId === r.roomId);
if (!text || !held) return;
answerError = '';
try {
await api.answerReport(r.id, r.roomId, held.token, text);
r.thread.push({ at: new Date().toISOString(), text, from: 'player' });
answers[r.id] = '';
} catch (e) {
answerError = e instanceof Error ? e.message : 'The answer did not reach the keeper.';
}
}
/** The single-player duel saved in this browser, if one is under way. */
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. 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((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 = '';
try {
rememberName(name);
const remote = await Duel.createBotRoom(name.trim());
await goto(`/join/${remote.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The duel could not be opened.';
} finally {
busy = false;
}
}
async function playOffline() {
rememberName(name);
await goto('/play');
}
async function create() {
busy = true;
error = '';
try {
rememberName(name);
const remote = await Duel.createRoom(name.trim());
await goto(`/join/${remote.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The duel could not be opened.';
} finally {
busy = false;
}
}
function join(e: SubmitEvent) {
e.preventDefault();
rememberName(name);
const c = code.trim().toUpperCase();
if (c) void goto(`/join/${c}`);
}
function forget(roomId: string) {
forgetSeat(roomId);
seats = allSeats();
}
function status(held: { roomId: string; seat: string }): string {
const g = games[held.roomId];
if (g === undefined) return 'looking';
if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (the bot)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.state.over) {
if (g.state.over.winner === held.seat) return 'you won';
if (g.state.over.winner === null) return 'a draw';
return `${g.state.wizards[g.state.over.winner].name} won`;
}
const turn = `turn ${g.state.turn + 1}`;
if (g.waitingOn.includes(held.seat)) return `your move · ${turn} · against ${others.join(', ')}`;
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);
}
/* A few turns of a duel, as the ledger writes them. */
const sample = [
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
{ turn: 3, a: ['S', 'F'], b: ['F', 'D'], notes: { a0: 'Counter-spell' } },
{ turn: 4, a: ['F', 'S'], b: ['F', 'D'], notes: { b0: 'Paralysis', b1: 'Lightning bolt' } }
];
</script>
<section class="lid">
<div class="lid-head">
<h1>Waving Hands</h1>
<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>
<div class="lid-play">
<label class="name">
<span>Your wizard's name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
</label>
<button type="button" class="reveal primary" disabled={!ready || busy} onclick={playBot}>Play now against the bot</button>
<p class="muted small">
{#if local}
<button type="button" class="link" onclick={playOffline}>Continue your offline duel against {local.foe.name} (turn {local.turnNumber})</button>
{:else}
<button type="button" class="link" onclick={playOffline}>Or play offline, in this browser only</button>
{/if}
</p>
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Open a table</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
<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>
<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>
<div class="lid-look" aria-hidden="true">
<table class="sample">
<thead>
<tr><th></th><th colspan="2">Corwin</th><th colspan="2">Ysolde</th></tr>
</thead>
<tbody>
{#each sample as row (row.turn)}
<tr>
<td class="t">{row.turn}</td>
{#each ['a0', 'a1', 'b0', 'b1'] as cell, i (cell)}
{@const g = i < 2 ? row.a[i] : row.b[i - 2]}
{@const note = (row.notes as Record<string, string | undefined>)[cell]}
<td class:cast={!!note}><span class="letter">{g}</span>{#if note}<span class="note">{note}</span>{/if}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
<p class="caption">The ledger: every gesture both wizards have made, and the spells they finished.</p>
</div>
{#if seats.length || local}
<div class="ledger">
<div class="ledger-head">your duels</div>
{#if local}
<div class="ledger-row your-move">
<a class="ledger-resume" href="/play">
<span class="ledger-code">here</span>
<span class="ledger-info">offline · your move · turn {local.turnNumber} · against {local.foe.name}</span>
</a>
</div>
{/if}
{#each seats as held (held.roomId)}
<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)}{#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>
{/each}
{#if reports.length}
<div class="ledger-head reports-head">your reports to the keeper</div>
{#each reports as r (r.id)}
{@const last = r.thread[r.thread.length - 1]}
<div class="report-row">
<span class="ledger-code">{r.roomId}</span>
<div class="report-body">
<p class="report-text">“{r.happened}”{#if r.image}<span class="muted" title="with your screenshot"> (with a picture)</span>{/if}</p>
{#each r.thread as line, j (j)}
{#if line.from === 'player'}
<p class="report-reply yours">you: {line.text}</p>
{:else}
<p class="report-reply"><span class="report-status">{line.status}</span> {line.text}</p>
{/if}
{/each}
{#if !last}
<p class="report-reply pending">the keeper is studying the moment</p>
{:else if last.from === 'player'}
<p class="report-reply pending">your word is with the keeper</p>
{/if}
{#if r.thread.some((l) => l.from === 'desk')}
<form class="report-answer" onsubmit={(e) => { e.preventDefault(); void answer(r); }}>
<input type="text" bind:value={answers[r.id]} maxlength="2000" placeholder="answer the keeper" aria-label="answer the keeper" />
<button type="submit" class="quiet" disabled={!(answers[r.id] ?? '').trim()}>Send</button>
</form>
{/if}
</div>
</div>
{/each}
{#if answerError}<p class="warning">{answerError}</p>{/if}
{/if}
</div>
{/if}
<details class="about" id="about" bind:open={aboutOpen}>
<summary>about this game</summary>
<div class="about-body">
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
<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 tally}
<h3>The tally</h3>
<dl class="tally">
<dt>{tally.gamesFinished}</dt><dd>{tally.gamesFinished === 1 ? 'duel' : 'duels'} fought to a finish, of {tally.gamesBegun} begun at {tally.tablesOpened} {tally.tablesOpened === 1 ? 'table' : 'tables'}</dd>
<dt>{tally.namesSeated}</dt><dd>{tally.namesSeated === 1 ? 'name has' : 'names have'} taken a seat</dd>
<dt>{tally.turnsPlayed}</dt><dd>turns of gestures written; {tally.longestGameTurns} in the longest duel</dd>
<dt>{hours(tally.minutesAtTable)}</dt><dd>at the table, by the clock between reveals</dd>
<dt>{tally.gamesWithBot}</dt><dd>{tally.gamesWithBot === 1 ? 'duel' : 'duels'} with the bot seated; {tally.talkLines} {tally.talkLines === 1 ? 'line' : 'lines'} of table talk</dd>
{#each Object.entries(tally.byGame) as [label, n] (label)}
<dt>{n}</dt><dd>{label}</dd>
{/each}
</dl>
<p class="muted small">
{#if tally.firstGameAt}The first duel began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if}
Names are counted, not people: one wizard under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.
</p>
{/if}
{#if family.length}
<h3>More games in {#if hall}<a href={hall}>Kestrel's Hall</a>{:else}Kestrel's Hall{/if}</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="hosting">
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month; anyone who wants to chip in toward the hosting can at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Nothing is unlocked by it and nothing is withheld without it.
</p>
<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>
</section>
<style>
.lid {
width: 100%;
max-width: 54rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 8px;
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'head' 'play' 'look' 'ledger' 'about';
gap: 1.2rem 2.2rem;
}
@media (min-width: 901px) {
.lid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'about about';
}
}
.lid-head {
grid-area: head;
text-align: center;
}
h1 {
font-size: clamp(2.6rem, 6vw, 3.6rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.pitch {
font-style: italic;
font-size: 1.2rem;
margin-top: 0.3rem;
}
.tag {
color: var(--bone-dim);
max-width: 40em;
margin: 0.6rem auto 0.4rem;
font-size: 0.95rem;
}
.links {
font-size: 0.95rem;
color: var(--bone-faint);
}
.links a {
color: var(--frost);
}
.about-link {
font-style: italic;
text-decoration: underline dotted;
}
.lid-play {
grid-area: play;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.name {
display: block;
}
.name span {
display: block;
font-size: 0.8rem;
letter-spacing: 0.12em;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.name input {
width: 100%;
}
.reveal.primary {
width: 100%;
text-align: center;
}
.small {
font-size: 0.85rem;
margin-top: -0.4rem;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.joinform {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.claimform {
flex-wrap: wrap;
margin-top: -0.2rem;
}
.claimform input {
width: 15em;
}
.or {
color: var(--bone-dim);
font-style: italic;
font-size: 0.95rem;
}
.code {
width: 6.5em;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.15em;
font-weight: 600;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.45rem 0.9rem;
color: var(--bone);
}
.quiet:hover:not(:disabled) {
border-color: var(--bone);
}
.warning {
color: var(--blood);
}
.lid-look {
grid-area: look;
}
.sample {
width: 100%;
border-collapse: collapse;
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 6px;
overflow: hidden;
}
.sample th {
font-weight: 500;
font-style: italic;
padding: 0.4rem 0 0.2rem;
border-bottom: 1px solid var(--rule-strong);
}
.sample td {
text-align: center;
padding: 0.3rem 0.2rem;
border-bottom: 1px solid var(--rule);
line-height: 1.1;
}
.sample td.t {
width: 2rem;
color: var(--bone-faint);
font-size: 0.8rem;
}
.sample td:nth-child(3) {
border-right: 1px solid var(--rule-strong);
}
.sample .letter {
display: block;
font-size: 1.6rem;
}
.sample td.cast .letter {
color: var(--ember);
}
.sample .note {
display: block;
font-size: 0.65rem;
font-style: italic;
color: var(--ember);
}
.caption {
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.5rem;
text-align: center;
}
/* the games ledger */
.ledger {
grid-area: ledger;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
}
.ledger-head {
font-style: italic;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.ledger-row {
display: flex;
align-items: center;
gap: 0.4rem;
border-radius: 4px;
padding: 0.15rem 0.3rem;
}
.ledger-row.your-move {
background: rgba(240, 165, 58, 0.12);
}
.ledger-resume {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.7rem;
padding: 0.3rem 0.2rem;
color: var(--bone);
text-decoration: none;
font-size: 0.95rem;
}
.ledger-resume:hover .ledger-code {
text-decoration: underline;
}
.talk-new {
color: var(--frost);
}
.reports-head {
margin-top: 0.9rem;
}
.report-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.45rem 0;
border-top: 1px solid var(--rule);
}
.report-body {
min-width: 0;
flex: 1;
}
.report-text {
font-size: 0.95rem;
}
.report-reply {
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.2rem;
}
.report-reply.yours {
font-style: italic;
}
.report-reply.pending {
font-style: italic;
color: var(--bone-faint);
}
.report-status {
color: var(--ember);
font-style: italic;
}
.report-answer {
display: flex;
gap: 0.4rem;
margin-top: 0.4rem;
}
.report-answer input {
flex: 1;
min-width: 0;
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
min-width: 3.5em;
}
.ledger-info {
color: var(--bone-dim);
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
.ledger-forget {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.05rem;
padding: 0 0.3rem;
}
.ledger-forget:hover {
color: var(--blood);
}
.about {
grid-area: about;
border-top: 1px solid var(--rule);
padding-top: 0.6rem;
}
.about summary {
cursor: pointer;
font-style: italic;
color: var(--frost);
}
.about-body h3 {
font-size: 1rem;
font-style: italic;
margin: 0.9rem 0 0.2rem;
color: var(--bone);
}
.about-body .made {
font-size: 0.85rem;
color: var(--bone-faint);
}
.about-body {
max-width: 44em;
color: var(--bone-dim);
padding: 0.6rem 0 0.2rem;
}
.about-body p + p {
margin-top: 0.6rem;
}
.tally {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 0.9rem;
margin: 0.4rem 0 0.6rem;
}
.tally dt {
text-align: right;
font-weight: 500;
color: var(--bone);
}
.tally dd {
margin: 0;
}
.about-body em {
color: var(--bone);
}
.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>