Compare commits

..
6 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 9de0a55d54 The root address is rewritten to index.html, since try_files passes the root over as a directory and served the shell in place of the hall
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 11:22:33 -04:00
Eric WagonerandClaude Fable 5.1 42144c1cc9 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
2026-10-29 11:10:19 -04:00
Eric WagonerandClaude Fable 5.1 22979afd35 The ledger's box is trimmed after each turn to the header and as many of the newest whole rows as fit under its cap, and the header stacks above every body cell, so a long duel no longer shows the oldest visible row cut in half under the sticky header; the cap allows a few more rows on a tall screen; and the guide's picture of the hall is the hall as it is now
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 11:10:19 -04:00
Eric WagonerandClaude Fable 5.1 4c224a8e22 The deploy renders the Caddy site file from the template every time, as it installs the scripts, so a change to the site file ships with the build that needs it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 10:35:32 -04:00
Eric WagonerandClaude Fable 5.1 26d9b7f5d6 The hall, the rules and the guide are rendered at build time and served as whole pages, so a reader without scripts, a crawler or view-source sees the text and not an empty shell; the app shell Caddy serves for a room address is shell.html, since a fallback named index.html overwrote the hall's own page; and the family list carries Wyrm and Dip's larger maps
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 10:34:06 -04:00
Eric WagonerandClaude Fable 5.1 fa5e9cfc08 The chronicle's enchantment lines no longer all run image, colon, consequence; each keeps its picture and its exact fact in its own sentence shape, and the about panel's tally heading and two of its closing flourishes are said plainly, since a page of one sentence pattern reads as written by a machine
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 10:30:41 -04:00
30 changed files with 474 additions and 524 deletions

No files matched your search

+4 -1
View File
@@ -43,7 +43,10 @@ __HOST__ {
header @immutable Cache-Control "public, max-age=31536000, immutable"
@mutable not path /_app/immutable/*
header @mutable Cache-Control "no-cache"
try_files {path} {path}.html /index.html
# try_files passes the root over as a directory and would fall to the shell; the hall's
# own page is index.html.
rewrite / /index.html
try_files {path} {path}.html /shell.html
file_server
}
}
+7 -1
View File
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
# it needs only its code and the engine. Ledgers live outside this tree.
rsync -az --delete --exclude='/server/node_modules' \
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \
--include='/deploy/' --include='/deploy/waving-hands.service' --include='/deploy/waving-hands.cron' --include='/deploy/*.sh' --include='/deploy/hash-tokens.ts' --include='/deploy/traffic.py' \
--include='/deploy/' --include='/deploy/waving-hands.service' --include='/deploy/waving-hands.cron' --include='/deploy/*.sh' --include='/deploy/Caddyfile.tmpl' --include='/deploy/hash-tokens.ts' --include='/deploy/traffic.py' \
--exclude='*' \
./ "root@$HOST:/opt/waving-hands/app/"
@@ -60,6 +60,12 @@ ssh "root@$HOST" '
systemctl daemon-reload
systemctl enable --now waving-hands
systemctl restart waving-hands
# The site file is rendered from the template on every deploy, with the address line the box was set up with.
ADDR=$(head -1 /etc/caddy/sites/waving-hands.caddy | sed "s/ {\$//")
sed "s|__HOST__|$ADDR|" /opt/waving-hands/app/deploy/Caddyfile.tmpl > /etc/caddy/sites/waving-hands.caddy
caddy validate --config /etc/caddy/Caddyfile >/dev/null
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
chown -R caddy:caddy /var/lib/caddy
systemctl reload caddy
sleep 1
systemctl --no-pager -l status waving-hands | head -3
+94
View File
@@ -108,6 +108,100 @@ button:disabled {
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. */
.field {
display: inline-flex;
+5 -15
View File
@@ -10,10 +10,10 @@
</script>
<script lang="ts">
// The about panel: the game's story, its keeping and its company, a tab
// at a time rather than one long page. Opened from the hall's "about this
// game" link (and #about); closes with its button, a click outside it, or
// Escape. The tab last read is the one it reopens on.
// A tabbed modal for the about content. Opened from the landing page's
// "about this game" link or the #about hash; closes on its button, a click
// on the backdrop, or Escape. Keeps the last-read tab while mounted, so
// reopening shows it.
import { tick } from 'svelte';
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
@@ -41,7 +41,7 @@
<svelte:window onkeydown={onKey} />
{#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">
<header>
<h2 id="about-title">{title}</h2>
@@ -71,16 +71,6 @@
{/if}
<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 {
width: min(100%, 44rem);
max-height: 100%;
+6 -6
View File
@@ -1,9 +1,9 @@
<script lang="ts">
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
// game's site serves it at /hall/announcement.json. It is checked when the page
// loads, every few minutes, and whenever the page is looked at again. A reader
// may dismiss one announcement; the next one still shows.
// The site-family announcement, shown by every game: the maintainer writes
// /var/lib/hall/announcement.json on the host (hall-announce), and each game's
// site serves it at /hall/announcement.json. It is fetched on load, every few
// minutes, and whenever the tab becomes visible again. Dismissal is per
// announcement id, so the next one still shows.
import { onMount } from 'svelte';
interface Announcement {
@@ -88,7 +88,7 @@
{/if}
<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 {
position: sticky;
top: 0;
+173 -173
View File
@@ -18,9 +18,9 @@
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
const outcome = $derived(duel.state.over);
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);
/** The button's word for what the player is about to do. */
/** The seal button's label for the current state. */
const sealLabel = $derived(
duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Seal the turn'
);
@@ -275,260 +275,260 @@
<style>
details.next summary {
cursor: pointer;
list-style: none;
}
cursor: pointer;
list-style: none;
}
details.next summary::before {
content: '\25B8 ';
color: var(--bone-faint);
}
content: '\25B8 ';
color: var(--bone-faint);
}
details.next[open] summary::before {
content: '\25BE ';
}
content: '\25BE ';
}
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
.play {
min-width: 0;
}
min-width: 0;
}
.rail {
min-width: 0;
}
min-width: 0;
}
.dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-right: 0.3rem;
vertical-align: baseline;
}
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
margin-right: 0.3rem;
vertical-align: baseline;
}
.intent {
margin: 0.6rem 0 0.2rem;
font-style: italic;
color: var(--bone);
}
margin: 0.6rem 0 0.2rem;
font-style: italic;
color: var(--bone);
}
.move {
padding: 0.6rem 0 1.5rem;
}
padding: 0.6rem 0 1.5rem;
}
.move h2 {
font-size: 1.3rem;
margin-bottom: 0.3rem;
}
font-size: 1.3rem;
margin-bottom: 0.3rem;
}
.threats {
font-size: 0.9rem;
margin-bottom: 0.7rem;
}
font-size: 0.9rem;
margin-bottom: 0.7rem;
}
.threats ul {
list-style: none;
margin: 0.15rem 0 0.4rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.5rem;
}
list-style: none;
margin: 0.15rem 0 0.4rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.5rem;
}
.threat {
background: var(--slate);
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.5rem;
display: inline-flex;
gap: 0.5rem;
align-items: baseline;
color: var(--bone-dim);
}
background: var(--slate);
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.5rem;
display: inline-flex;
gap: 0.5rem;
align-items: baseline;
color: var(--bone-dim);
}
.threat.soon {
color: var(--bone);
}
color: var(--bone);
}
.threat:hover,
.threat.active {
border-color: var(--frost);
}
.threat.active {
border-color: var(--frost);
}
.threat.active {
background: rgba(140, 200, 224, 0.12);
}
background: rgba(140, 200, 224, 0.12);
}
.thand {
font-style: italic;
font-size: 0.8rem;
}
font-style: italic;
font-size: 0.8rem;
}
.tseq {
letter-spacing: 0.04em;
}
letter-spacing: 0.04em;
}
.tseq strong {
font-weight: 600;
color: var(--ember);
}
font-weight: 600;
color: var(--ember);
}
.prefs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.5rem;
margin-top: 0.7rem;
}
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.5rem;
margin-top: 0.7rem;
}
.pref {
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--bone-dim);
}
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--bone-dim);
}
.hands {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem 2rem;
}
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem 2rem;
}
.orders {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 0.9rem;
}
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 0.9rem;
}
.warning {
color: var(--blood);
margin-top: 0.8rem;
}
color: var(--blood);
margin-top: 0.8rem;
}
.haste-note {
color: var(--frost);
font-size: 0.95rem;
margin: 1rem 0 0.6rem;
}
color: var(--frost);
font-size: 0.95rem;
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 {
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 0.7rem 0.9rem 0.9rem;
}
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
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 {
position: sticky;
bottom: 0;
z-index: 4;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem 1rem;
margin-top: 0.9rem;
padding: 0.6rem 0.2rem;
background: var(--slate-deep);
border-top: 1px solid var(--rule-strong);
}
position: sticky;
bottom: 0;
z-index: 4;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem 1rem;
margin-top: 0.9rem;
padding: 0.6rem 0.2rem;
background: var(--slate-deep);
border-top: 1px solid var(--rule-strong);
}
.pair {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.hand-pair {
display: flex;
align-items: baseline;
gap: 0.35rem;
}
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.which {
font-size: 0.75rem;
font-style: italic;
color: var(--bone-faint);
}
font-size: 0.75rem;
font-style: italic;
color: var(--bone-faint);
}
.pair .letter {
font-size: 1.5rem;
min-width: 1ch;
}
font-size: 1.5rem;
min-width: 1ch;
}
.pair .letter.empty {
color: var(--bone-faint);
}
color: var(--bone-faint);
}
.notes p {
margin: 0;
}
margin: 0;
}
.notes p + p {
margin-top: 0.2rem;
}
margin-top: 0.2rem;
}
.keys {
font-size: 0.8rem;
}
font-size: 0.8rem;
}
.verdict {
padding: 1.5rem 0;
}
padding: 1.5rem 0;
}
.verdict h2 {
font-size: 2rem;
font-style: italic;
color: var(--blood);
}
font-size: 2rem;
font-style: italic;
color: var(--blood);
}
.verdict.won h2 {
color: var(--ember);
}
color: var(--ember);
}
.verdict p {
margin: 0.3rem 0 0.8rem;
color: var(--bone-dim);
}
margin: 0.3rem 0 0.8rem;
color: var(--bone-dim);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
/* On phones the bar at the foot of the screen carries the pair and the button. */
.commit {
position: static;
grid-template-columns: minmax(0, 1fr);
border-top: 0;
}
grid-template-columns: minmax(0, 1fr);
}
/* Narrow screens: MobileBar carries the pair and the button instead. */
.commit {
position: static;
grid-template-columns: minmax(0, 1fr);
border-top: 0;
}
.commit .pair,
.commit .reveal,
.keys {
display: none;
}
.commit .pair,
.commit .reveal,
.keys {
display: none;
}
.rail {
order: 2;
border-top: 1px solid var(--rule-strong);
margin-top: 1rem;
}
order: 2;
border-top: 1px solid var(--rule-strong);
margin-top: 1rem;
}
.hands {
grid-template-columns: 1fr;
}
grid-template-columns: 1fr;
}
}
.waiting {
+22 -35
View File
@@ -18,37 +18,37 @@
$effect(() => {
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);
$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. */
/** The tally split for the about panel: headline figures for the cards, every other count as a detail line. */
const told = $derived.by(() => {
const t = tally;
if (!t) return null;
const headline = [
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'duel fought to a finish' : 'duels fought to a finish' },
{ n: t.byGame['duels won with every rival slain'] ?? 0, label: 'won with every rival slain' },
{ 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' }
];
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 = [
{ 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.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` },
{ 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' }] : []),
...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' })}. ` : '';
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 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 hall = $state('');
$effect(() => {
@@ -57,12 +57,12 @@
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 answers = $state<Record<string, string>>({});
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(() => {
const held = seats;
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 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.
// Each held seat is fetched once so the list can say whose move it is. A 403 or 404 marks
// the seat doubtful, and a second refusal in a row drops it; a network failure changes nothing.
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
@@ -111,7 +110,7 @@
let phrase = $state('');
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) {
e.preventDefault();
claiming = true;
@@ -167,7 +166,7 @@
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);
function forget(roomId: string) {
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}`;
}
/** 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 {
const g = games[held.roomId];
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
@@ -206,7 +205,7 @@
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 = [
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
@@ -220,7 +219,7 @@
<h1>Waving Hands</h1>
<p class="pitch">Wizards duel with their hands, and 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 for each hand, and all are shown together. A Fireball is F-S-S-D-D on one hand, and 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></p>
<p class="links" id="about"><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></p>
</div>
<div class="lid-play">
@@ -332,7 +331,7 @@
</div>
{/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()}
<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>
@@ -342,7 +341,7 @@
{/snippet}
{#snippet aboutTally()}
{#if told}
<TallyBoard title="How much love the duel is getting" headline={told.headline} details={told.details} note={told.note} />
<TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
{:else}
<p class="muted">Counting…</p>
{/if}
@@ -358,7 +357,7 @@
{/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, and anyone who wants to chip in toward the hosting can do so at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.
</p>
<p class="made">Waving Hands is Richard Bartle's, 1977.</p>
</div>
@@ -500,18 +499,6 @@
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);
}
@@ -576,7 +563,7 @@
text-align: center;
}
/* the games ledger */
/* The list of held seats, and the reports under it. */
.ledger {
grid-area: ledger;
border-top: 1px solid var(--rule-strong);
@@ -686,7 +673,7 @@
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 {
flex-wrap: wrap;
}
+48 -10
View File
@@ -22,7 +22,7 @@
cells: Record<WizardId, Placed[]>;
}
/** You first, then the others in table order; the gallery sees the table as laid. */
/** Column order: the viewer first, then the others in seat order; a spectator gets no column of their own. */
const columns = $derived<WizardId[]>(duel.spectating ? duel.others : [duel.me, ...duel.others]);
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
@@ -68,9 +68,42 @@
return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to;
}
// The stylesheet caps the box at a share of the viewport, which is rarely a whole number of
// rows, so after each turn the cap is trimmed to the header plus as many of the newest rows
// as fit under it, and the box is scrolled to the newest. Older rows then sit wholly under
// the sticky header rather than showing a cut-off sliver. The second header row sticks at
// the measured height of the first, so the two meet exactly whatever the font or wrapping.
// Row heights change with the viewport and the web font, so this is redone on resize and
// once fonts are in.
function fitRows() {
if (!scroller) return;
scroller.style.maxHeight = '';
const names = scroller.querySelector('thead tr');
if (names) scroller.style.setProperty('--names-row', `${names.getBoundingClientRect().height}px`);
const cap = parseFloat(getComputedStyle(scroller).maxHeight);
if (Number.isFinite(cap)) {
const head = scroller.querySelector('thead')?.getBoundingClientRect().height ?? 0;
const chrome = scroller.offsetHeight - scroller.clientHeight;
let inner = head;
for (const row of [...scroller.querySelectorAll('tbody tr')].reverse()) {
const h = row.getBoundingClientRect().height;
if (inner > head && inner + h + chrome > cap) break;
inner += h;
}
scroller.style.maxHeight = `${Math.ceil(inner + chrome)}px`;
}
scroller.scrollTop = scroller.scrollHeight;
}
$effect(() => {
void turns;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
fitRows();
});
$effect(() => {
window.addEventListener('resize', fitRows);
void document.fonts?.ready.then(fitRows);
return () => window.removeEventListener('resize', fitRows);
});
const crowded = $derived(duel.others.length >= 3);
@@ -221,7 +254,7 @@
}
.scroller {
max-height: clamp(170px, 30vh, 400px);
max-height: clamp(170px, 38vh, 420px);
overflow: auto;
border-top: 1px solid var(--rule-strong);
border-bottom: 1px solid var(--rule-strong);
@@ -259,16 +292,13 @@
left: 7.4rem;
}
thead th.pin-left,
thead th.pin-right {
z-index: 3;
}
thead th.gone {
text-decoration: line-through;
color: var(--bone-dim);
}
/* The header must stack above every body cell, including the pinned ones (z-index 1 and 2 below),
or a row scrolled under it shows its turn number through the header while its letters are hidden. */
thead th {
position: sticky;
top: 0;
@@ -277,7 +307,7 @@
text-align: center;
padding: 0.5rem 0 0.15rem;
font-size: 1.05rem;
z-index: 1;
z-index: 3;
}
thead th.you,
@@ -287,7 +317,8 @@
}
thead tr.hands th {
top: 2.2rem;
/* The measured height of the row above (set by fitRows), so the two sticky rows meet with no gap. */
top: var(--names-row, 2.2rem);
font-size: 0.8rem;
font-weight: 400;
color: var(--bone-dim);
@@ -312,6 +343,13 @@
padding-top: 0.6rem;
}
/* The header's own pinned cells sit above the rest of the header, which slides under them sideways. */
thead th.turn,
thead th.pin-left,
thead th.pin-right {
z-index: 4;
}
.rownote {
display: block;
font-size: 0.6rem;
-15
View File
@@ -88,16 +88,6 @@
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.slip {
width: min(100%, 30rem);
max-height: 100%;
@@ -168,11 +158,6 @@
margin-top: 0.9rem;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.link {
background: none;
border: 0;
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
// A crowded table's rail: every other wizard on one line, with the one
// you are looking at opened out. Your own panel stays in full above.
// The rail for three or more opponents: each on one line, with the focused
// one expanded. The player's own status card stays above in full.
import WizardStatus from './WizardStatus.svelte';
import type { Duel } from '$lib/game/duel.svelte';
import { seatColor } from '$lib/game/colors';
+4 -10
View File
@@ -1,7 +1,7 @@
<script lang="ts">
// The host's hand on the other seats during a duel: a transfer phrase for a
// wizard whose browser lost their seat, and a bot to duel in the seat of one
// who cannot go on, until a phrase gives it back.
// Host-only controls over the other seats during a game: a transfer phrase
// for a player whose browser lost its seat, and handing a seat to a bot until
// a phrase claims it back.
import type { Duel } from '$lib/game/duel.svelte';
import type { WizardId } from '$lib/game/state';
@@ -80,15 +80,9 @@
{/if}
<style>
/* A definite row, so the slip's max-height resolves and it scrolls instead of overflowing the viewport. */
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
.slip {
+2 -10
View File
@@ -7,12 +7,12 @@
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. */
/** Spectators sign their lines; the default is the name saved in local storage. */
let galleryName = $state(playerName());
let sending = $state(false);
let list = $state<HTMLElement | null>(null);
// New talk scrolls into view, as at any table.
// Keep the list scrolled to the newest line.
$effect(() => {
void lines.length;
void tick().then(() => {
@@ -128,14 +128,6 @@
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 {
opacity: 0.5;
}
+5 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts" module>
/** One count told: the number, and what it counts. */
/** One statistic: the figure and its label. */
export interface TallyLine {
n: number | string;
label: string;
@@ -7,10 +7,10 @@
</script>
<script lang="ts">
// What the ledgers add up to: a few counts that are the game's own story,
// large, on cards; then the rest in a column of figures; then the fine
// print. Each game chooses its counts and words.
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
// A statistics panel: a few headline counts on large cards, the rest in a
// two-column list, then an optional note. The caller supplies the counts
// and their labels.
let { headline, details, note, title = 'What the ledgers add up to' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
$props();
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
+4 -5
View File
@@ -1,7 +1,6 @@
// The other games of Kestrel's Hall, from the family list the kit keeps
// (family.json, one copy shipped with every game), so a player who liked one
// table can find the next, and the hall's front door. This game is left out
// of its own list.
// The sibling games, from the family list the kit ships (family.json, one copy
// per game), so a player can find the next game and the family's landing page.
// This game is left out of its own list.
export interface FamilyGame {
slug: string;
@@ -12,7 +11,7 @@ export interface FamilyGame {
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 {
hall: string;
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 {
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
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 };
/** A report to the keeper, with the exchange under it. */
/** What the ledgers add up to. */
/** Site-wide statistics. */
export interface Tally {
tablesOpened: number;
gamesBegun: number;
@@ -138,6 +137,7 @@ export interface Tally {
byGame: Record<string, number>;
}
/** A bug report, with the exchange of replies under it. */
export interface Report {
id: 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 })
};
/** 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 {
let socket: WebSocket | null = null;
let closed = false;
+7 -7
View File
@@ -219,7 +219,7 @@ export interface Remote extends HeldSeat {
started: boolean;
connected: boolean;
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;
chat: ChatLine[];
/** How many watch from the Peanut Gallery. */
@@ -340,7 +340,7 @@ export class Duel {
/** Watching from the gallery, with no seat at the table. */
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]]);
/** The opponent spells go at by default: the first still standing. */
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.');
}
/** 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> {
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> {
const r = this.remote!;
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));
}
/** 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);
/** Begin the duel with whoever is seated. */
@@ -1026,7 +1026,7 @@ export class Duel {
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> {
const seated = await api.create(name, size);
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);
}
/** 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> {
return remoteFrom(await api.watch(roomId), '');
}
+22 -22
View File
@@ -312,7 +312,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
record(w, l2, r2, 'haste');
entries[id].push(w.history.length - 1);
}
if (w.surrendered) log(`${w.name} lowers both hands and shows empty palms: a surrender.`, 'death');
if (w.surrendered) log(`${w.name} lowers both hands and shows empty palms in surrender.`, 'death');
}
// ---- 2. Which completed spells are actually loosed ----
@@ -336,7 +336,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0];
if (!completion) continue;
if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) {
log(`${w.name}'s quick lightning bolt sputters: it has already been used this duel.`, 'fizzle');
log(`${w.name}'s quick lightning bolt sputters and dies, spent earlier in this duel.`, 'fizzle');
continue;
}
const cells = usedCells(completion, choice.hand, index + 1);
@@ -404,7 +404,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const countered = new Set<TargetId>();
const dispels = live().filter((c) => c.spell.id === 'dispel_magic');
if (dispels.length > 0) {
log('A wave of stillness rolls across the field. Dispel magic: every other spell fails, every enchantment ends, and every creature will fade after this turn.', 'spell');
log('A wave of stillness rolls across the field as the magic is dispelled. Every other spell fails, every enchantment ends, and every creature will fade after this turn.', 'spell');
for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled';
for (const d of dispels) shielded.set(d.target, 'dispel magic');
for (const id of seats) clearEnchantments(W[id]);
@@ -476,7 +476,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (chosen) {
chosen.permanent = true;
extendedNow.add(id);
log(`${w.name}'s ${chosen.spell.name} sets hard, like wax cooling: it will not fade.`, 'spell');
log(`${w.name}'s ${chosen.spell.name} sets hard, like wax cooling, and will not fade.`, 'spell');
}
}
}
@@ -545,7 +545,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
};
if (fireStorm && iceStorm) {
fireStorm = iceStorm = false;
log('Fire storm meets ice storm: a great hiss of steam, and the two storms cancel. Nothing is harmed.', 'fizzle');
log('Fire storm meets ice storm in a great hiss of steam, and the two cancel. Nothing is harmed.', 'fizzle');
}
if (fireStorm && elementals('ice_elemental').length > 0) {
fireStorm = false;
@@ -621,14 +621,14 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (wiz) {
wiz.pending.amnesia = true;
if (c.permanent) wiz.permanent.amnesia = true;
log(`A grey haze drifts behind ${wiz.name}'s eyes: next turn the hands will repeat what they just did${c.permanent ? ', and every turn after' : ''}.`);
log(`A grey haze drifts behind ${wiz.name}'s eyes. Next turn the hands will repeat what they just did${c.permanent ? ', and every turn after' : ''}.`);
} else if (mon) mon.pending.forcedTarget = orders.get(mon.id);
break;
case 'confusion':
if (wiz) {
wiz.pending.confusion = true;
if (c.permanent) wiz.permanent.confusion = true;
log(`The field tilts and swims around ${wiz.name}: next turn one hand will go astray${c.permanent ? ', and every turn after' : ''}.`);
log(`The field tilts and swims around ${wiz.name}. One hand will go astray next turn${c.permanent ? ', and every turn after' : ''}.`);
} else if (mon) {
const candidates = [...seats.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)];
const t = pick(candidates as TargetId[]);
@@ -641,7 +641,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const hand = c.choice.chosenHand ?? pick(HANDS);
wiz.pending.charmed = { hand, by: c.caster };
if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster };
log(`A warm, easy trust settles on ${wiz.name}: the ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
log(`A warm, easy trust comes over ${wiz.name}, whose ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
} else {
c.nullified = 'monsters cannot be charmed as people';
log(`The charm slides off ${target}, finding no mind to hold.`, 'fizzle');
@@ -651,7 +651,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (mon && !isElemental(mon.kind)) {
mon.owner = c.caster;
orders.set(mon.id, input(c.caster).monsterOrders?.[mon.id] ?? enemyOf(state, c.caster));
log(`${mon.name} turns, blinks, and pads over to ${W[c.caster].name}'s side: it serves a new master.`);
log(`${mon.name} turns, blinks, and pads over to ${W[c.caster].name}'s side to serve a new master.`);
} else {
c.nullified = 'not a charmable monster';
log(`The charm slides off ${target} and takes no hold.`, 'fizzle');
@@ -662,10 +662,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left';
wiz.pending.paralysedHand = hand;
if (c.permanent) wiz.permanent.paralysedHand = hand;
log(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand: it will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`);
log(`A cold stiffness creeps into ${wiz.name}'s ${hand} hand, which will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`);
} else if (mon && !isElemental(mon.kind)) {
mon.pending.skipAttack = true;
log(`${mon.name}'s limbs stiffen: it will stand paralysed next turn.`);
log(`${mon.name}'s limbs stiffen, and it will stand paralysed next turn.`);
} else {
c.nullified = 'elementals cannot be paralysed';
}
@@ -686,7 +686,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'protection_from_evil':
if (wiz) wiz.protectionTurns = duration(4);
else if (mon) mon.protectionTurns = duration(4);
log(`A pale circle of light rises around ${target}: protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`);
log(`A pale circle of light rises around ${target}, protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`);
break;
case 'resist_heat':
if (wiz) wiz.resistHeat = true;
@@ -696,7 +696,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'resist_cold':
if (wiz) wiz.resistCold = true;
else if (mon) mon.resistCold = true;
log(`A warm rosy aura settles over ${target}, who now resists cold.`);
log(`A warm rosy aura wraps ${target}, who now resists cold.`);
break;
case 'disease':
if (wiz) {
@@ -707,13 +707,13 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'poison':
if (wiz) {
wiz.poisonTurns = 6;
log(`${wiz.name} goes grey about the lips: poison is in the blood. Unless cured, ${wiz.name} will die at the end of turn ${state.turn + 6}.`);
log(`${wiz.name} goes grey about the lips as poison takes the blood. Unless cured, ${wiz.name} will die at the end of turn ${state.turn + 6}.`);
} else c.nullified = 'monsters do not sicken';
break;
case 'blindness':
if (wiz) {
wiz.blindTurns = duration(4);
log(`A milky film clouds ${wiz.name}'s eyes: blind${c.permanent ? forever : ' for three turns'}.`);
log(`A milky film clouds ${wiz.name}'s eyes and leaves them blind${c.permanent ? forever : ' for three turns'}.`);
} else if (mon) destroyBefore(mon, 'blinded and unmade');
break;
case 'invisibility':
@@ -725,25 +725,25 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'haste':
if (wiz) {
wiz.hasteTurns = duration(4);
log(`The air about ${wiz.name} quickens; everyone else seems to move through honey. Hastened${c.permanent ? forever : ' for the next three turns'}: two pairs of gestures a turn.`);
log(`The air about ${wiz.name} quickens; everyone else seems to move through honey. Hastened${c.permanent ? forever : ' for the next three turns'}, ${wiz.name} makes two pairs of gestures a turn.`);
} else c.nullified = 'monsters cannot be hastened';
break;
case 'time_stop':
if (wiz) {
state.timeStops.push(wiz.id);
log(`The moment catches on ${wiz.name} like a snagged thread: when this turn ends, time will stop, and ${wiz.name} alone will move.`);
log(`The moment catches on ${wiz.name} like a snagged thread. When this turn ends, time will stop, and ${wiz.name} alone will move.`);
} else c.nullified = 'only wizards can stop time';
break;
case 'delayed_effect':
if (wiz) {
wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4);
if (!bankedNow.has(wiz.id)) log(`A hollow of waiting opens around ${wiz.name}'s hands: the next spell will be caught and held.`);
if (!bankedNow.has(wiz.id)) log(`A hollow of waiting opens around ${wiz.name}'s hands, ready to catch and hold the next spell.`);
} else c.nullified = 'only wizards can delay a spell';
break;
case 'permanency':
if (wiz) {
wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4;
if (!extendedNow.has(wiz.id)) log(`Something steady and unhurried settles on ${wiz.name}: the next enchantment will be permanent.`);
if (!extendedNow.has(wiz.id)) log(`Something steady and unhurried takes hold of ${wiz.name}, and the next enchantment will be permanent.`);
} else c.nullified = 'only a wizard can hold a permanency';
break;
}
@@ -756,10 +756,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const mon = monsterOf(c.target);
if (wiz) {
clearEnchantments(wiz);
log(`Threads of old magic lift from ${wiz.name} and drift away: every enchantment is gone.`);
log(`Threads of old magic lift from ${wiz.name} and drift away. Every enchantment is gone.`);
} else if (mon && c.spell.id === 'remove_enchantment') {
destroyedAfterAttack.add(mon.id);
log(`${mon.name} begins to fray at the edges: it will be unmade once it has attacked.`);
log(`${mon.name} begins to fray at the edges, and will be unmade once it has attacked.`);
}
}
@@ -820,7 +820,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
heal(t, 2, 'Cure heavy wounds');
if (isWizard(t) && W[t].diseaseTurns !== null) {
W[t].diseaseTurns = null;
log(`${W[t].name}'s fever breaks, and the flush fades from the skin: the disease is cured.`);
log(`${W[t].name}'s fever breaks, and the flush fades from the skin. The disease is cured.`);
}
break;
case 'raise_dead':
+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));
}
/** 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 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';
+3 -3
View File
@@ -1,6 +1,6 @@
// The game's own lines for the hall's tally, read off a finished duel. Only
// what the final state records for certain: who is dead, who yielded, and
// how the duel was decided.
// 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
// how the game was decided).
import type { GameState } from './state';
+4 -4
View File
@@ -15,7 +15,7 @@ export function viewFor(state: GameState, viewer: WizardId): GameState {
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 {
id: WizardId;
text: string;
@@ -46,15 +46,15 @@ export interface RoomView {
audience: number;
/** The host lets the gallery talk. */
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;
/** 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[];
/** A finished table's rematch: where it went, and who called. */
rematch: { to: string; by: WizardId } | null;
/** The table this one is the rematch of. */
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;
keeperZone: string;
}
+3 -2
View File
@@ -1,4 +1,5 @@
// The duel lives in the browser and is saved to local storage, so nothing is rendered on the
// server. Each route is prerendered as an empty shell that the client fills in.
// The duel lives in the browser and is saved to local storage, so by default a route is
// prerendered as an empty shell that the client fills in. The hall, the rules and the guide
// turn rendering on in their own +page.ts, since their text is the same for everyone.
export const ssr = false;
export const prerender = true;
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+13 -92
View File
@@ -13,22 +13,22 @@
let room = $state<Duel | null>(null);
let name = $state('');
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 busy = $state(false);
let error = $state('');
let copied = $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 rematching = $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 claimPhrase = $state('');
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);
$effect(() => {
const id = setInterval(() => (minute += 1), 60_000);
@@ -76,18 +76,18 @@
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started);
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);
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
// still opens, so a link someone else sends cannot take a working seat away.
// Resume a held seat, else spectate. A link may carry a seat (#seat=...): it is stripped from the address
// bar at once, saved only once the server accepts it, and never replaces a held seat the server still
// accepts, so a forwarded link cannot take a working seat.
$effect(() => {
const id = roomId;
room = null;
error = '';
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);
const held = seatFor(id);
const take = (s: HeldSeat) =>
@@ -96,9 +96,9 @@
trustSeat(id);
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;
// 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) => {
seatLost = e.message;
return Duel.watchRoom(id);
@@ -127,7 +127,7 @@
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(() => {
const r = room;
const lost = r?.remote?.lost;
@@ -138,7 +138,7 @@
.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) {
e.preventDefault();
claiming = true;
@@ -380,57 +380,6 @@
</footer>
<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 {
letter-spacing: 0.12em;
font-weight: 600;
@@ -517,11 +466,6 @@
width: 14em;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.roster {
list-style: none;
margin: 0.9rem 0 0.6rem;
@@ -583,27 +527,4 @@
border-radius: 4px;
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>
+19 -98
View File
@@ -90,116 +90,37 @@
</footer>
<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 {
margin-left: 0.5em;
font-size: 0.8rem;
}
margin-left: 0.5em;
font-size: 0.8rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.4rem;
}
.rename input {
width: 14em;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.4rem;
}
.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);
}
.rename input {
width: 14em;
}
.confirm {
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
.confirm p {
color: var(--blood);
margin-bottom: 0.5rem;
}
color: var(--blood);
margin-bottom: 0.5rem;
}
.choices {
display: flex;
gap: 0.6rem;
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;
}
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+9 -1
View File
@@ -31,8 +31,16 @@
"name": "Dip",
"url": "https://dip.kestrelsnest.social",
"pitch": "Seven powers share one map, and none of them has to keep its word.",
"players": "2 to 7",
"players": "2 to 10, depending on the map",
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
},
{
"slug": "wyrm",
"name": "Wyrm",
"url": "https://wyrm.kestrelsnest.social",
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
"players": "2",
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 106 KiB

+3 -1
View File
@@ -11,7 +11,9 @@ export default defineConfig({
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
// A static build: the duel runs entirely in the browser, so Caddy serves files and nothing else.
adapter: adapter({ fallback: 'index.html' })
// The fallback is the empty shell Caddy serves for a room address; it is not index.html, so the
// hall's own prerendered page is not overwritten by it.
adapter: adapter({ fallback: 'shell.html' })
})
],
server: {