The masthead, the scrim, the quiet button and the colophon are styled once in app.css instead of pasted into each page and slip with drifting paddings; the style blocks of the play page and the board are indented like the rest of the files; and comments in script and CSS say what the code does or guards, in plain words, where they had been written in the game's own voice

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-29 11:10:19 -04:00
1 parent 22979afd35
commit 42144c1cc9
19 files changed
+176 -293

No files matched your search

+94
View File
@@ -108,6 +108,100 @@ button:disabled {
background: #fff; background: #fff;
} }
/* The smaller reveal used in forms and slips. Qualified with the element so it
outranks a component's scoped `.small` text rule on the same button. */
button.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
/* A secondary action: an outlined button or link. */
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover:not(:disabled) {
border-color: var(--bone);
}
/* The page head on the play and join pages: title and standfirst on the left, actions on the right. */
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
.masthead h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.masthead .actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0;
min-width: 0;
max-width: 100%;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. The extra foot padding clears MobileBar. */
@media (max-width: 860px) {
.masthead {
flex-direction: column;
}
.colophon {
padding-bottom: 6rem;
}
}
/* A modal backdrop that centres its dialog. */
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
/* An italic prompt beside a control, e.g. "at" before a target list. */ /* An italic prompt beside a control, e.g. "at" before a target list. */
.field { .field {
display: inline-flex; display: inline-flex;
+5 -15
View File
@@ -10,10 +10,10 @@
</script> </script>
<script lang="ts"> <script lang="ts">
// The about panel: the game's story, its keeping and its company, a tab // A tabbed modal for the about content. Opened from the landing page's
// at a time rather than one long page. Opened from the hall's "about this // "about this game" link or the #about hash; closes on its button, a click
// game" link (and #about); closes with its button, a click outside it, or // on the backdrop, or Escape. Keeps the last-read tab while mounted, so
// Escape. The tab last read is the one it reopens on. // reopening shows it.
import { tick } from 'svelte'; import { tick } from 'svelte';
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props(); let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
@@ -41,7 +41,7 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
{#if open} {#if open}
<div class="about-scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}> <div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title"> <div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
<header> <header>
<h2 id="about-title">{title}</h2> <h2 id="about-title">{title}</h2>
@@ -71,16 +71,6 @@
{/if} {/if}
<style> <style>
/* Its own backdrop: not every game has another dialog to share one with. */
.about-scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.panel { .panel {
width: min(100%, 44rem); width: min(100%, 44rem);
max-height: 100%; max-height: 100%;
+6 -6
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
// The host's announcement, shown by every game of Kestrel's Hall: the keeper // The site-family announcement, shown by every game: the maintainer writes
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each // /var/lib/hall/announcement.json on the host (hall-announce), and each game's
// game's site serves it at /hall/announcement.json. It is checked when the page // site serves it at /hall/announcement.json. It is fetched on load, every few
// loads, every few minutes, and whenever the page is looked at again. A reader // minutes, and whenever the tab becomes visible again. Dismissal is per
// may dismiss one announcement; the next one still shows. // announcement id, so the next one still shows.
import { onMount } from 'svelte'; import { onMount } from 'svelte';
interface Announcement { interface Announcement {
@@ -88,7 +88,7 @@
{/if} {/if}
<style> <style>
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */ /* Fixed colours rather than the game's tokens, so the notice looks the same on every site that shows it. */
.hall-announcement { .hall-announcement {
position: sticky; position: sticky;
top: 0; top: 0;
+5 -5
View File
@@ -18,9 +18,9 @@
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me)); const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
const outcome = $derived(duel.state.over); const outcome = $derived(duel.state.over);
const won = $derived(outcome?.winner === duel.me); const won = $derived(outcome?.winner === duel.me);
/** Past three wizards the rail lists the others on a line each. */ /** With three or more opponents the rail shows a one-line roster instead of a status card each. */
const crowded = $derived(duel.others.length >= 3); const crowded = $derived(duel.others.length >= 3);
/** The button's word for what the player is about to do. */ /** The seal button's label for the current state. */
const sealLabel = $derived( const sealLabel = $derived(
duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn' duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn'
); );
@@ -420,7 +420,7 @@
margin: 1rem 0 0.6rem; margin: 1rem 0 0.6rem;
} }
/* Your hands, framed apart from the ledger's columns above them. */ /* Box the hand pickers so they read apart from the ledger table above. */
.hands { .hands {
background: var(--slate); background: var(--slate);
border: 1px solid var(--rule-strong); border: 1px solid var(--rule-strong);
@@ -428,7 +428,7 @@
padding: 0.7rem 0.9rem 0.9rem; padding: 0.7rem 0.9rem 0.9rem;
} }
/* The turn about to be sealed, kept in view while the hands and orders scroll. */ /* Sticky at the foot so the chosen pair and the seal button stay in view while the pickers scroll. */
.commit { .commit {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -507,7 +507,7 @@
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
/* On phones the bar at the foot of the screen carries the pair and the button. */ /* Narrow screens: MobileBar carries the pair and the button instead. */
.commit { .commit {
position: static; position: static;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
+17 -30
View File
@@ -18,14 +18,14 @@
$effect(() => { $effect(() => {
if (location.hash === '#about') aboutOpen = true; if (location.hash === '#about') aboutOpen = true;
}); });
/** The ledgers' tally, fetched when the about panel opens. */ /** Site-wide statistics, fetched the first time the about panel opens. */
let tally = $state<Tally | null>(null); let tally = $state<Tally | null>(null);
$effect(() => { $effect(() => {
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null)); 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`); 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. */ /** The tally split for the about panel: headline figures for the cards, every other count as a detail line. */
const told = $derived.by(() => { const told = $derived.by(() => {
const t = tally; const t = tally;
if (!t) return null; if (!t) return null;
@@ -36,19 +36,19 @@
{ n: t.byGame["duels won by a rival's surrender"] ?? 0, label: "won by a rival's surrender" }, { n: t.byGame["duels won by a rival's surrender"] ?? 0, label: "won by a rival's surrender" },
{ n: hours(t.minutesAtTable), label: 'at the table' } { n: hours(t.minutesAtTable), label: 'at the table' }
]; ];
const told = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]); const onCards = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
const details = [ const details = [
{ n: t.gamesBegun, label: `duels begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` }, { n: t.gamesBegun, label: `duels 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.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
{ n: t.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` }, { n: t.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` },
{ n: t.gamesWithBot, label: `duels with the bot seated` }, { n: t.gamesWithBot, label: `duels with the bot seated` },
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []), ...(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 })) ...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
]; ];
const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : ''; const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}The count is of names, so one player 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.` }; return { headline, details, note: `${first}The count is of names, so one player 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.` };
}); });
/** The other games of Kestrel's Hall, for the about panel. */ /** The sibling games listed in the about panel, and the family's landing page. */
let family = $state<FamilyGame[]>([]); let family = $state<FamilyGame[]>([]);
let hall = $state(''); let hall = $state('');
$effect(() => { $effect(() => {
@@ -57,12 +57,12 @@
hall = f.hall; hall = f.hall;
}); });
}); });
/** Reports this browser's seats have filed, with the keeper's replies. */ /** Bug reports filed from this browser's seats, with the maintainer's replies. */
let reports = $state<Report[]>([]); let reports = $state<Report[]>([]);
let answers = $state<Record<string, string>>({}); let answers = $state<Record<string, string>>({});
let answerError = $state(''); let answerError = $state('');
// The desk is asked once per visit for every seat this browser holds. // Fetch the reports for every held seat; re-runs when the held seats change.
$effect(() => { $effect(() => {
const held = seats; const held = seats;
if (!held.length) return; if (!held.length) return;
@@ -85,13 +85,12 @@
} }
} }
/** The single-player duel saved in this browser, if one is under way. */ /** The offline game saved in this browser, if one is unfinished. */
const local = $derived(duel.inProgress ? duel : null); const local = $derived(duel.inProgress ? duel : null);
const ready = $derived(name.trim().length > 0); const ready = $derived(name.trim().length > 0);
// Each held seat is looked up once, so the ledger can say whose move it // Each held seat is fetched once so the list can say whose move it is. A 403 or 404 marks
// is. A seat the server refuses outright is doubted; a second refusal in // the seat doubtful, and a second refusal in a row drops it; a network failure changes nothing.
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
$effect(() => { $effect(() => {
for (const held of seats) { for (const held of seats) {
if (held.roomId in games) continue; if (held.roomId in games) continue;
@@ -111,7 +110,7 @@
let phrase = $state(''); let phrase = $state('');
let claiming = $state(false); let claiming = $state(false);
/** Bring a seat from another device by the phrase it minted. */ /** Claim a seat transferred from another device, by its transfer phrase. */
async function claim(e: SubmitEvent) { async function claim(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
claiming = true; claiming = true;
@@ -167,7 +166,7 @@
if (c) void goto(`/join/${c}`); if (c) void goto(`/join/${c}`);
} }
/** A duel in play asks before it is forgotten: this browser may be the only thing that holds the seat. */ /** Forgetting an unfinished game asks first: this browser's token may be the only copy of the seat. */
let forgetting = $state<string | null>(null); let forgetting = $state<string | null>(null);
function forget(roomId: string) { function forget(roomId: string) {
if (!games[roomId]?.state?.over && forgetting !== roomId) { if (!games[roomId]?.state?.over && forgetting !== roomId) {
@@ -195,7 +194,7 @@
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`; 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. */ /** Chat lines posted since this browser last opened the room. */
function unread(held: { roomId: string }): number { function unread(held: { roomId: string }): number {
const g = games[held.roomId]; const g = games[held.roomId];
return g ? unreadTalk(held.roomId, g.chat.length) : 0; return g ? unreadTalk(held.roomId, g.chat.length) : 0;
@@ -206,7 +205,7 @@
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat); return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
} }
/* A few turns of a duel, as the ledger writes them. */ /** Fixed sample rows for the illustration table. */
const sample = [ const sample = [
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } }, { 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: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
@@ -332,7 +331,7 @@
</div> </div>
{/if} {/if}
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. --> <!-- The about panel's tabs. Their copy is this game's own, in its in-world voice by design. -->
{#snippet aboutGame()} {#snippet aboutGame()}
<div class="about-body"> <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>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>
@@ -500,18 +499,6 @@
font-weight: 600; 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 { .warning {
color: var(--blood); color: var(--blood);
} }
@@ -576,7 +563,7 @@
text-align: center; text-align: center;
} }
/* the games ledger */ /* The list of held seats, and the reports under it. */
.ledger { .ledger {
grid-area: ledger; grid-area: ledger;
border-top: 1px solid var(--rule-strong); border-top: 1px solid var(--rule-strong);
@@ -686,7 +673,7 @@
color: var(--ember); color: var(--ember);
} }
/* The question takes a line of its own under the duel on a narrow screen. */ /* The confirmation wraps onto its own line when the row is narrow. */
.ledger-row.asking { .ledger-row.asking {
flex-wrap: wrap; flex-wrap: wrap;
} }
-15
View File
@@ -88,16 +88,6 @@
{/if} {/if}
<style> <style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.slip { .slip {
width: min(100%, 30rem); width: min(100%, 30rem);
max-height: 100%; max-height: 100%;
@@ -168,11 +158,6 @@
margin-top: 0.9rem; margin-top: 0.9rem;
} }
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.link { .link {
background: none; background: none;
border: 0; border: 0;
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
// A crowded table's rail: every other wizard on one line, with the one // The rail for three or more opponents: each on one line, with the focused
// you are looking at opened out. Your own panel stays in full above. // one expanded. The player's own status card stays above in full.
import WizardStatus from './WizardStatus.svelte'; import WizardStatus from './WizardStatus.svelte';
import type { Duel } from '$lib/game/duel.svelte'; import type { Duel } from '$lib/game/duel.svelte';
import { seatColor } from '$lib/game/colors'; import { seatColor } from '$lib/game/colors';
+4 -10
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
// The host's hand on the other seats during a duel: a transfer phrase for a // Host-only controls over the other seats during a game: a transfer phrase
// wizard whose browser lost their seat, and a bot to duel in the seat of one // for a player whose browser lost its seat, and handing a seat to a bot until
// who cannot go on, until a phrase gives it back. // a phrase claims it back.
import type { Duel } from '$lib/game/duel.svelte'; import type { Duel } from '$lib/game/duel.svelte';
import type { WizardId } from '$lib/game/state'; import type { WizardId } from '$lib/game/state';
@@ -80,15 +80,9 @@
{/if} {/if}
<style> <style>
/* A definite row, so the slip's max-height resolves and it scrolls instead of overflowing the viewport. */
.scrim { .scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
grid-template-rows: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
} }
.slip { .slip {
+2 -10
View File
@@ -7,12 +7,12 @@
const remote = $derived(duel.remote); const remote = $derived(duel.remote);
const lines = $derived(remote?.chat ?? []); const lines = $derived(remote?.chat ?? []);
let draft = $state(''); let draft = $state('');
/** The gallery signs its lines; the name is the one the hall remembers. */ /** Spectators sign their lines; the default is the name saved in local storage. */
let galleryName = $state(playerName()); let galleryName = $state(playerName());
let sending = $state(false); let sending = $state(false);
let list = $state<HTMLElement | null>(null); let list = $state<HTMLElement | null>(null);
// New talk scrolls into view, as at any table. // Keep the list scrolled to the newest line.
$effect(() => { $effect(() => {
void lines.length; void lines.length;
void tick().then(() => { void tick().then(() => {
@@ -128,14 +128,6 @@
flex: 0 1 8rem; flex: 0 1 8rem;
} }
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.3rem 0.8rem;
color: var(--bone);
}
.quiet:disabled { .quiet:disabled {
opacity: 0.5; opacity: 0.5;
} }
+4 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts" module> <script lang="ts" module>
/** One count told: the number, and what it counts. */ /** One statistic: the figure and its label. */
export interface TallyLine { export interface TallyLine {
n: number | string; n: number | string;
label: string; label: string;
@@ -7,9 +7,9 @@
</script> </script>
<script lang="ts"> <script lang="ts">
// What the ledgers add up to: a few counts that are the game's own story, // A statistics panel: a few headline counts on large cards, the rest in a
// large, on cards; then the rest in a column of figures; then the fine // two-column list, then an optional note. The caller supplies the counts
// print. Each game chooses its counts and words. // and their labels.
let { headline, details, note, title = 'What the ledgers add up to' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } = let { headline, details, note, title = 'What the ledgers add up to' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
$props(); $props();
+4 -5
View File
@@ -1,7 +1,6 @@
// The other games of Kestrel's Hall, from the family list the kit keeps // The sibling games, from the family list the kit ships (family.json, one copy
// (family.json, one copy shipped with every game), so a player who liked one // per game), so a player can find the next game and the family's landing page.
// table can find the next, and the hall's front door. This game is left out // This game is left out of its own list.
// of its own list.
export interface FamilyGame { export interface FamilyGame {
slug: string; slug: string;
@@ -12,7 +11,7 @@ export interface FamilyGame {
origin: string; origin: string;
} }
/** The family list: the hall's front door and every game but this one. */ /** The family list: the landing page's URL and every game but this one. */
export interface Family { export interface Family {
hall: string; hall: string;
games: FamilyGame[]; games: FamilyGame[];
+4 -4
View File
@@ -19,7 +19,7 @@ function heldSeats(): Record<string, HeldSeat> {
} }
} }
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */ /** A seat carried in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of server logs. */
export function seatInLink(hash: string): HeldSeat | null { export function seatInLink(hash: string): HeldSeat | null {
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash); const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
return m ? { seat: m[1], token: m[2] } : null; return m ? { seat: m[1], token: m[2] } : null;
@@ -122,8 +122,7 @@ async function call<T>(method: string, path: string, body?: unknown): Promise<T>
type Seated = { seat: WizardId; token: string; view: RoomView }; type Seated = { seat: WizardId; token: string; view: RoomView };
/** A report to the keeper, with the exchange under it. */ /** Site-wide statistics. */
/** What the ledgers add up to. */
export interface Tally { export interface Tally {
tablesOpened: number; tablesOpened: number;
gamesBegun: number; gamesBegun: number;
@@ -138,6 +137,7 @@ export interface Tally {
byGame: Record<string, number>; byGame: Record<string, number>;
} }
/** A bug report, with the exchange of replies under it. */
export interface Report { export interface Report {
id: string; id: string;
at: string; at: string;
@@ -184,7 +184,7 @@ export const api = {
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text }) answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
}; };
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */ /** A socket that only ever signals "fetch the view again"; reconnects on its own. An empty token spectates. */
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void { export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
let socket: WebSocket | null = null; let socket: WebSocket | null = null;
let closed = false; let closed = false;
+7 -7
View File
@@ -219,7 +219,7 @@ export interface Remote extends HeldSeat {
started: boolean; started: boolean;
connected: boolean; connected: boolean;
error: string; error: string;
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */ /** Set when the server stopped accepting this seat's token while the page was open, with the reason; the page then spectates. */
lost: string; lost: string;
chat: ChatLine[]; chat: ChatLine[];
/** How many watch from the Peanut Gallery. */ /** How many watch from the Peanut Gallery. */
@@ -340,7 +340,7 @@ export class Duel {
/** Watching from the gallery, with no seat at the table. */ /** Watching from the gallery, with no seat at the table. */
spectating = $derived(this.remote?.spectating ?? false); spectating = $derived(this.remote?.spectating ?? false);
/** The gallery has no wizard of its own; it borrows the first seat for the fields that need one, and shows none of them. */ /** A spectator has no wizard of their own; borrow the first seat for the fields that need one (none of them are shown). */
you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]); you = $derived(this.state.wizards[this.me] ?? this.state.wizards[this.state.seats[0]]);
/** The opponent spells go at by default: the first still standing. */ /** The opponent spells go at by default: the first still standing. */
foeId = $derived(enemyOf(this.state, this.me)); foeId = $derived(enemyOf(this.state, this.me));
@@ -850,12 +850,12 @@ export class Duel {
return this.remoteAct((r) => api.setGalleryTalk(r.roomId, r.token, on), 'The gallery could not be told.'); 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. */ /** Invite the site maintainer to the seat held for them; the server notifies them. */
callKeeper(): Promise<boolean> { callKeeper(): Promise<boolean> {
return this.remoteAct((r) => api.challenge(r.roomId, r.token), 'The keeper could not be called.'); 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. */ /** Open a rematch room for the same players; the caller becomes its host. */
async rematch(): Promise<string> { async rematch(): Promise<string> {
const r = this.remote!; const r = this.remote!;
const next = await api.rematch(r.roomId, r.token); const next = await api.rematch(r.roomId, r.token);
@@ -943,7 +943,7 @@ export class Duel {
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open)); return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
} }
/** You opened this table, so the duel begins when you say. */ /** The viewer holds the host seat, so may begin the game. */
isHost = $derived(!!this.remote && this.remote.host === this.remote.seat); isHost = $derived(!!this.remote && this.remote.host === this.remote.seat);
/** Begin the duel with whoever is seated. */ /** Begin the duel with whoever is seated. */
@@ -1026,7 +1026,7 @@ export class Duel {
this.resetDrafts(); this.resetDrafts();
} }
/** Open a table on the server, seated as its host; it takes up to a dozen and begins when the host says. */ /** Create a room on the server with the caller as host; it takes up to `size` seats and starts when the host says. */
static async createRoom(name: string, size = 12): Promise<Remote> { static async createRoom(name: string, size = 12): Promise<Remote> {
const seated = await api.create(name, size); const seated = await api.create(name, size);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token }); rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
@@ -1052,7 +1052,7 @@ export class Duel {
return remoteFrom(await api.view(roomId, held.token), held.token); return remoteFrom(await api.view(roomId, held.token), held.token);
} }
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */ /** Open a room as a spectator: no seat and no token. */
static async watchRoom(roomId: string): Promise<Remote> { static async watchRoom(roomId: string): Promise<Remote> {
return remoteFrom(await api.watch(roomId), ''); return remoteFrom(await api.watch(roomId), '');
} }
+1 -1
View File
@@ -306,7 +306,7 @@ export function visibleHistory(state: GameState, viewer: WizardId, subject: Wiza
return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h)); return state.wizards[subject].history.map((h) => (hiddenFromViewer(h.hiddenFrom, viewer) ? { ...h, left: '-', right: '-' } : h));
} }
/** The gallery holds no seat: it is shown only what every wizard at the table could see. */ /** A spectator holds no seat and is shown only what every seated player could see. */
export const SPECTATOR: WizardId = ''; export const SPECTATOR: WizardId = '';
export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean { export function hiddenFromViewer(hiddenFrom: WizardId[] | undefined, viewer: WizardId): boolean {
+1 -1
View File
@@ -1,4 +1,4 @@
// How much of each table's talk this browser has read, so the hall can say what is new. // How many chat lines of each room this browser has read, so the landing page can count what is new.
const SEEN_KEY = 'wh:talk-seen'; const SEEN_KEY = 'wh:talk-seen';
+3 -3
View File
@@ -1,6 +1,6 @@
// The game's own lines for the hall's tally, read off a finished duel. Only // This game's lines for the site-wide tally, read off a finished game: only
// what the final state records for certain: who is dead, who yielded, and // what the final state records for certain (who is dead, who yielded, and
// how the duel was decided. // how the game was decided).
import type { GameState } from './state'; import type { GameState } from './state';
+4 -4
View File
@@ -15,7 +15,7 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
return view; return view;
} }
/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`), or from the keeper of the site. */ /** One chat line: from a seat, from a spectator when the host allows it (id SPECTATOR, signed with `name`), or from the site maintainer (`keeper`). */
export interface ChatLine { export interface ChatLine {
id: WizardId; id: WizardId;
text: string; text: string;
@@ -46,15 +46,15 @@ export interface RoomView {
audience: number; audience: number;
/** The host lets the gallery talk. */ /** The host lets the gallery talk. */
galleryTalk: boolean; galleryTalk: boolean;
/** The keeper of the site was called to a seat, and by whom. */ /** The site maintainer was invited to a held seat, and by whom. */
challenge: { by: WizardId; at: number } | null; challenge: { by: WizardId; at: number } | null;
/** Names with a seat held who have not yet sat: the last table's wizards, or the keeper. */ /** Names with a seat held who have not yet sat: the previous room's players, or the site maintainer. */
expected: string[]; expected: string[];
/** A finished table's rematch: where it went, and who called. */ /** A finished table's rematch: where it went, and who called. */
rematch: { to: string; by: WizardId } | null; rematch: { to: string; by: WizardId } | null;
/** The table this one is the rematch of. */ /** The table this one is the rematch of. */
rematchOf: string | null; rematchOf: string | null;
/** The keeper of the site, and where they live, for a table that calls them. */ /** The site maintainer's name and time zone, for rooms that invite them. */
keeper: string; keeper: string;
keeperZone: string; keeperZone: string;
} }
+13 -92
View File
@@ -13,22 +13,22 @@
let room = $state<Duel | null>(null); let room = $state<Duel | null>(null);
let name = $state(''); let name = $state('');
let joining = $state(false); let joining = $state(false);
/** The host asked to begin while a seat is still held for someone on their way. */ /** The host pressed Begin while a seat is still held for someone expected; confirm before going on. */
let beginAsked = $state(false); let beginAsked = $state(false);
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let copied = $state(false); let copied = $state(false);
let reporting = $state(false); let reporting = $state(false);
/** A phrase minted to carry this seat to another device, while it lasts. */ /** This seat's transfer phrase, until it expires. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false); let rematching = $state(false);
let seatsOpen = $state(false); let seatsOpen = $state(false);
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */ /** The server's reason for refusing this browser's seat; shown while spectating. */
let seatLost = $state(''); let seatLost = $state('');
let claimPhrase = $state(''); let claimPhrase = $state('');
let claiming = $state(false); let claiming = $state(false);
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */ /** The current hour in the site maintainer's time zone, in words, shown beside the challenge button. */
let minute = $state(0); let minute = $state(0);
$effect(() => { $effect(() => {
const id = setInterval(() => (minute += 1), 60_000); const id = setInterval(() => (minute += 1), 60_000);
@@ -76,18 +76,18 @@
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`); const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started); const lobby = $derived(room !== null && !room.remote?.started);
const watching = $derived(room?.spectating ?? false); const watching = $derived(room?.spectating ?? false);
/** A watcher may still sit while the table is laid and a seat is free. */ /** A spectator may still take a seat before the game starts, while one is free. */
const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size); const seatFree = $derived(!!room?.remote && !room.remote.started && room.remote.seats.length < room.remote.size);
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat: // Resume a held seat, else spectate. A link may carry a seat (#seat=...): it is stripped from the address
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that // bar at once, saved only once the server accepts it, and never replaces a held seat the server still
// still opens, so a link someone else sends cannot take a working seat away. // accepts, so a forwarded link cannot take a working seat.
$effect(() => { $effect(() => {
const id = roomId; const id = roomId;
room = null; room = null;
error = ''; error = '';
const sent = seatInLink(location.hash); const sent = seatInLink(location.hash);
// The browser's own call: this runs as the page first loads, before the router could take one. // The History API directly, not SvelteKit's replaceState: this runs on first load, before the router is ready.
if (sent) history.replaceState(history.state, '', location.pathname + location.search); if (sent) history.replaceState(history.state, '', location.pathname + location.search);
const held = seatFor(id); const held = seatFor(id);
const take = (s: HeldSeat) => const take = (s: HeldSeat) =>
@@ -96,9 +96,9 @@
trustSeat(id); trustSeat(id);
return remote; return remote;
}); });
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat. // Only a 403 on the held token lets the link's seat in; a dropped connection is not a lost seat.
const refused = (e: unknown) => e instanceof ServerError && e.status === 403; const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
// A held seat the server refuses, with no link to replace it, is watched from the gallery, with the reason. // A refused seat with no link to replace it falls back to spectating, keeping the server's reason.
const watchRefused = (e: Error) => { const watchRefused = (e: Error) => {
seatLost = e.message; seatLost = e.message;
return Duel.watchRoom(id); return Duel.watchRoom(id);
@@ -127,7 +127,7 @@
return stop; return stop;
}); });
// The seat stopped opening while the page was open (the host gave it to a bot): watch on from the gallery. // The seat was revoked while the page was open (the host gave it to a bot): switch to spectating.
$effect(() => { $effect(() => {
const r = room; const r = room;
const lost = r?.remote?.lost; const lost = r?.remote?.lost;
@@ -138,7 +138,7 @@
.catch((e: Error) => (error = e.message)); .catch((e: Error) => (error = e.message));
}); });
/** Sit back down by a phrase: the host's, or one this seat minted on another device. */ /** Claim a seat by transfer phrase: the host's, or one this seat made on another device. */
async function claim(e: SubmitEvent) { async function claim(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
claiming = true; claiming = true;
@@ -380,57 +380,6 @@
</footer> </footer>
<style> <style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0;
min-width: 0;
max-width: 100%;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.quiet.room b { .quiet.room b {
letter-spacing: 0.12em; letter-spacing: 0.12em;
font-weight: 600; font-weight: 600;
@@ -517,11 +466,6 @@
width: 14em; width: 14em;
} }
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.roster { .roster {
list-style: none; list-style: none;
margin: 0.9rem 0 0.6rem; margin: 0.9rem 0 0.6rem;
@@ -583,27 +527,4 @@
border-radius: 4px; border-radius: 4px;
word-break: break-all; word-break: break-all;
} }
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.masthead {
flex-direction: column;
}
.colophon {
padding-bottom: 6rem;
}
}
</style> </style>
-79
View File
@@ -90,33 +90,6 @@
</footer> </footer>
<style> <style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.standfirst .link { .standfirst .link {
margin-left: 0.5em; margin-left: 0.5em;
font-size: 0.8rem; font-size: 0.8rem;
@@ -134,30 +107,6 @@
width: 14em; width: 14em;
} }
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0;
min-width: 0;
max-width: 100%;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.confirm { .confirm {
max-width: 1280px; max-width: 1280px;
margin: 0 auto 0.75rem; margin: 0 auto 0.75rem;
@@ -174,32 +123,4 @@
gap: 0.6rem; gap: 0.6rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.colophon {
padding-bottom: 6rem;
}
.masthead {
flex-direction: column;
}
}
</style> </style>