Files
hnefatafl/src/lib/components/Hall.svelte
T
Eric WagonerandClaude Opus 5.5 fcf25ff4e5 The tally is told as Wiz-War tells it: the game's own story on cards, then the ledgers in detail
A few counts that are the game's story (games finished, how they were
won, hours at the table) stand large on cards in the accent color, the
rest follow in a column of figures, then the fine print: Tally.svelte,
shared through the kit, with each game choosing its counts and words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 20:48:07 -04:00

632 lines
20 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">
// The hall: the landing page every game in the kit shares. Name, play the
// bot, open a table, join by code, the ledger of your tables, and the
// about panel with the rules, the guide and a way to reach the keeper.
import { goto } from '$app/navigation';
import { loadFamily, type FamilyGame } from '$lib/family';
import About from './About.svelte';
import TallyBoard from './Tally.svelte';
import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { unreadTalk } from '$lib/net/talk';
import type { Report, RoomView, Tally } from '$lib/net/view';
import { createGame, game, type State } from '$lib/game';
import BoardArtwork from './BoardArtwork.svelte';
let name = $state(playerName());
let code = $state('');
/** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */
const options = $derived<Record<string, string>>(
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
);
let prefsOpen = $state(false);
const opening = $derived(createGame({ preview: 'Defenders', opponent: 'Attackers' }, 0, undefined, options));
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false);
$effect(() => {
if (location.hash === '#about') aboutOpen = true;
});
/** 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 tally as the about panel tells it: this game's own story on cards, the rest in detail. */
const told = $derived.by(() => {
const t = tally;
if (!t) return null;
const headline = [
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' },
{ n: t.byGame["won by the king's escape"] ?? 0, label: "won by the king's escape" },
{ n: t.byGame['won by taking the king'] ?? 0, label: 'won by taking the king' },
{ n: hours(t.minutesAtTable), label: 'at the table' }
];
const told = new Set<string>(["won by the king's escape", 'won by taking the king']);
const details = [
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
{ n: t.gamesWithBot, label: `games with a housecarl seated` },
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label }))
];
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
});
/** The other games of Kestrel's Hall, for the about panel. */
let family = $state<FamilyGame[]>([]);
let hall = $state('');
$effect(() => {
void loadFamily('hnefatafl').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('');
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;
}
}
// The desk is asked for every seat this browser holds, again whenever that set changes.
$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 open(make: () => Promise<Room>) {
busy = true;
error = '';
try {
rememberName(name);
const room = await make();
await goto(`/join/${room.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The table 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} (a housecarl)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.over) {
if (g.over.winner === held.seat) return 'you won';
if (g.over.winner === null) return 'a draw';
return `${game.nameOf(g.state, g.over.winner)} won`;
}
const round = `round ${g.turn}`;
if (g.waitingOn.includes(held.seat)) return `your move · ${round} · against ${others.join(', ')}`;
return `waiting on ${g.waitingOn.map((id) => game.nameOf(g.state!, id)).join(', ')} · ${round}`;
}
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.over && g.waitingOn.includes(held.seat);
}
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.';
}
}
</script>
<section class="lid">
<div class="lid-head">
<h1>Hnefatafl</h1>
<p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p>
<p class="tag">Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach safety. Play a housecarl, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.</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> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
</div>
<div class="lid-play">
<label class="name">
<span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
</label>
{#if game.options?.length}
<div class="options">
{#each game.options as o (o.key)}
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
<div>
<label class="option">
<span>{o.label}</span>
<select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}>
{#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{#if chosen?.note}<p class="option-note muted small">{chosen.note}</p>{/if}
</div>
{/each}
</div>
{/if}
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against a housecarl</button>
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), game.seatIds.length, options))}>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" 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">
<div class="still"><BoardArtwork g={opening} decorative /></div>
</div>
{#if seats.length}
<div class="ledger">
<div class="ledger-head">your games</div>
{#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="dismiss" title="forget this game" 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}
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
{#snippet aboutGame()}
<div class="about-body">
<p>Hnefatafl is the tafl game of the Vikings, carried by them from Scandinavia to Britain, Ireland, Iceland and beyond, and played until chess arrived in the eleventh and twelfth centuries. No rules from that age survive; the game was reconstructed in modern times from a Sámi cousin, Tablut, whose rules the botanist Carl Linnaeus jotted down on his journey through Lapland in 1732.</p>
<p>This table plays Copenhagen, the modern tournament ruleset, on the eleven-by-eleven board, and Tablut on the nine-by-nine one, with the older board showing its age. The rules it follows are on the <a href="/rules">rules page</a>. The game is in the public domain; the board and pieces here are drawn, not borrowed.</p>
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.</p>
</div>
{/snippet}
{#snippet aboutTally()}
{#if told}
<TallyBoard title="How much love the board is getting" headline={told.headline} details={told.details} note={told.note} />
{:else}
<p class="muted">Counting…</p>
{/if}
{/snippet}
{#snippet aboutFamily()}
<div class="about-body">{#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">Hnefatafl belongs to everyone; it is a thousand years old. This page was made by Eric and a very enthusiastic AI, 2026.</p>
</div>
{/snippet}
{#snippet aboutWord()}
<div class="about-body">
<p>A rule read wrong, a bug, a game 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>
</div>
{/snippet}
<About bind:open={aboutOpen} title="About Hnefatafl" tabs={[{ id: 'game', label: 'The game', body: aboutGame }, { id: 'tally', label: 'The tally', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
</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';
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';
}
}
.lid-head {
grid-area: head;
text-align: center;
}
h1 {
font-size: clamp(2.6rem, 6vw, 3.6rem);
font-weight: 300;
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%;
}
.commit.primary {
width: 100%;
text-align: center;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.joinform {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.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;
}
.lid-look {
grid-area: look;
display: grid;
place-items: center;
min-height: 8rem;
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 6px;
}
/* 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.08);
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
.ledger-resume {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.9rem;
min-width: 0;
color: inherit;
text-decoration: none;
padding: 0.25rem 0;
}
.ledger-resume:hover .ledger-code {
color: var(--frost);
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
flex-shrink: 0;
}
.ledger-info {
color: var(--bone-dim);
font-size: 0.95rem;
overflow-wrap: anywhere;
}
.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;
}
/* about: the panel's own words; About.svelte is the dialog around them */
.about-body {
max-width: 44em;
color: var(--bone-dim);
padding: 0.6rem 0 0.2rem;
}
.about-body p + p {
margin-top: 0.6rem;
}
.about-body a {
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);
}
.family {
margin: 0.4rem 0 0.8rem 1.2rem;
padding: 0;
}
.family li + li {
margin-top: 0.3rem;
}
.options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.2rem;
margin: 0.2rem 0 0.6rem;
}
.option {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.option-note {
margin: 0.2rem 0 0;
max-width: 22em;
}
.still {
width: 100%;
max-width: 18rem;
display: block;
margin: 0 auto;
border-radius: 4px;
}
</style>