Compare commits

..
20 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
Eric WagonerandClaude Opus 5.5 f21b2c3b16 The keeper can say a word at a table through the keeper's door, signed as the keeper and heard whether or not the gallery may talk, so a call answered too late, after the duel began, can still be answered; waving-hands-say.sh sends it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-20 13:16:57 -04:00
Eric WagonerandClaude Opus 5.5 745b8e99b9 The host is asked before beginning while a seat is still held for someone on their way, since the seat goes once the game begins; and the gallery says the game began before they arrived, so whoever was called and finds the table under way knows why there is no seat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-20 13:04:28 -04:00
Eric WagonerandClaude Opus 5.5 f98bfba107 The host has a hand on the other seats: once the duel begins, Seats makes a transfer phrase for another wizard's seat, which gives it a new token when claimed, and gives the seat of a wizard who cannot go on to a bot, which duels the turn it owes at once and keeps gestures already sealed; the wizard's token opens nothing while it stands in, their page goes to the gallery with the reason and a claim box, the roster says a bot duels for them, and a rematch holds their seat without the stand-in; the server pings every socket and closes one a dead browser left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 19:04:33 -04:00
Eric WagonerandClaude Opus 5.5 a673ea9766 A seat link replaces a held seat only when the server refuses the held token, not when the connection drops, so a link cannot take a working seat away on a bad connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:32:10 -04:00
Eric WagonerandClaude Opus 5.5 21d9c12f48 The keeper's door asks for a key only root and the waving-hands user can read, so another service on the host cannot re-seat anyone; the deploy makes the key once and the reseat script sends it from a file descriptor; and a seat link is taken out of the address bar with the browser's own call, since the page reads it before the router starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:28:25 -04:00
Eric WagonerandClaude Opus 5.5 b51934ecd6 The keeper can bring back a seat a browser lost: a new token on the ledger, the old one refused and any phrase it minted voided, and a link that carries it in the fragment, minted by waving-hands-reseat.sh through a /keeper door Caddy does not proxy; the join page keeps a linked seat only once the server takes it and never over a seat that still opens, a watcher at a duel in play is told both ways back, the hall asks before forgetting a duel in play, and the server's rooms have tests of their own
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:24:20 -04:00
Eric WagonerandClaude Opus 5.5 fbe110a7bb The family list carries the kit's current pitches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 09:29:58 -04:00
Eric WagonerandClaude Opus 5.5 6572a1ad92 The hosting line says what it promises: chipping in unlocks nothing, and nothing is held back without it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 09:23:44 -04:00
Eric WagonerandClaude Fable 5.1 30abb71e26 The nightly rollup and backup say by their exit status whether they managed, and the deploy leaves the cron file off a host the hall schedules
On the shared host the hall's hall-nightly.sh (game-kit, gamehall/ops) runs
both scripts for every game and checks in with Sentry Crons once for all,
so this game's own check-in files are absent there and its exit status is
the word. A box of its own keeps its cron and check-ins as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 10:07:05 -04:00
Eric WagonerandClaude Opus 5.5 7317096c70 Words that fit a table of any size
The rules page, the offline page, the guide, two spell descriptions and
the share description no longer assume a duel has exactly two wizards:
every wizard chooses, attacks go at an opponent, the last wizard
standing wins, and blindness and invisibility speak of other wizards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-10 19:00:42 -04:00
Eric WagonerandClaude Opus 5.5 6f9d4de92d An editing pass over the words around the game, and three corrections in the guide
The guide, the hall, the room page and the explanations after a turn
lose their closing aphorisms, most of their semicolons, the
statement-colon-example lines and the page narrating itself. The
tagline is one sentence, and 'a labor of love' is gone from the about
link. The chronicle's imagery and the in-world names stay.

Corrected along the way: a counter-spell does stop wound spells and
lightning (the guide said it did not); a shield is not the only defence
against a newly summoned creature; on the first turn a knife can hurt
you; a palm is no defence against a fireball; a goblin needs five turns
to match one; and only the offline duel lives in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-10 18:51:59 -04:00
Eric WagonerandClaude Opus 5.5 673f7c84d9 The guide's figures show the framed hands, the chronicle in order, and the phone's Seal button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-10 18:42:34 -04:00
Eric WagonerandClaude Opus 5.5 ee630276b3 Easier to make a move, and plainer words around the game
From feedback on the BoardGameGeek thread. The hands are framed and
labelled as yours, so they no longer read as sitting under your
opponent's columns. A move can be typed: the first letter goes to the
left hand, the second to the right, K stabs, a dash rests, Backspace
takes one back and Enter seals. The chosen pair, what it will do, any
warning and the button now sit in one row that stays in view. A clap
with one hand is warned of before the turn is sealed. The button says
what the player does: Seal the turn. The chronicle reads oldest first
and scrolls to the newest line. Headings, tabs and notes lose the
label-and-colon construction and the abstract headings; the chronicle's
imagery stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-10 18:29:49 -04:00
Eric WagonerandClaude Opus 5.5 db682814ea The rollup's last line names the traffic it counted, and the rollup and the backup check in once, when they end
The closing summary still named the old parser's variables, so every run
wrote its row and then died on the print, and Sentry was told the night
failed. It reads the shared parser's counts now. The check-ins become one
at the end with the run's duration: a start and a finish seconds apart can
reach Sentry in either order, and a start that lands second is never closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-10-10 17:44:44 -04:00
53 changed files with 1260 additions and 447 deletions

No files matched your search

+5 -1
View File
@@ -45,7 +45,11 @@ Around the two hands:
A duel against the bot is saved to the browser's local storage after every change, so it
survives reading the rules, a refresh, or a phone putting the tab to sleep. Starting a new one
asks first if one is under way. The seats this browser holds at shared duels are remembered the
same way and listed in the hall with whose move it is. Nothing is rendered on the server.
same way and listed in the hall with whose move it is; the hall asks before forgetting one
still in play. A player whose browser lost a seat (Safari's seven days, an app's own browser,
cleared data) can write to the keeper, whose `waving-hands-reseat.sh` gives the seat a new token
and prints a link carrying it in the fragment; the old token opens nothing after. Nothing is
rendered on the server.
## The duel server
+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
}
}
+8
View File
@@ -52,6 +52,14 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
/etc/cron.d/waving-hands on every deploy, so the live copies are the repo copies:
- `waving-hands-visitors.sh [day]`: who is here now and who came that day.
- `waving-hands-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
token for a player locked out of it, as a link to send them. It answers
through the duel server's `/keeper` door, which Caddy does not proxy and
which asks for the key in /etc/waving-hands/keeper.key, made by the deploy
and readable by root and the waving-hands user alone.
- `waving-hands-say.sh ROOM "text"`: a word from the keeper in a table's
talk, signed as the keeper, through the same door; it is heard whether or
not the gallery may talk, so a call that came too late can be answered.
- `waving-hands-pulse.sh`: the weekly health check (service, errors, rollup
trend, backup, box).
- `waving-hands-rollup.sh [day]`: one JSON line per day of counts, run
+20 -2
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/"
@@ -43,11 +43,29 @@ ssh "root@$HOST" '
install -m 755 /opt/waving-hands/app/deploy/pulse.sh /usr/local/bin/waving-hands-pulse.sh
install -m 755 /opt/waving-hands/app/deploy/waving-hands-rollup.sh /usr/local/bin/waving-hands-rollup.sh
install -m 755 /opt/waving-hands/app/deploy/waving-hands-backup.sh /usr/local/bin/waving-hands-backup.sh
install -m 644 /opt/waving-hands/app/deploy/waving-hands.cron /etc/cron.d/waving-hands
install -m 755 /opt/waving-hands/app/deploy/waving-hands-reseat.sh /usr/local/bin/waving-hands-reseat.sh
install -m 755 /opt/waving-hands/app/deploy/waving-hands-say.sh /usr/local/bin/waving-hands-say.sh
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
if [ ! -s /etc/waving-hands/keeper.key ]; then
install -d -m 750 -o root -g waving-hands /etc/waving-hands
(umask 037; openssl rand -hex 32 > /etc/waving-hands/keeper.key)
chown root:waving-hands /etc/waving-hands/keeper.key
fi
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
# of every game and checks in with Sentry once for all; a game on a box of
# its own schedules its own.
if [ -f /etc/cron.d/gamehall ]; then rm -f /etc/cron.d/waving-hands
else install -m 644 /opt/waving-hands/app/deploy/waving-hands.cron /etc/cron.d/waving-hands; fi
mkdir -p /var/log/waving-hands
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
+8 -2
View File
@@ -44,7 +44,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
continue
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
names = {}
names, seat_changes = {}, []
for x in lines[1:]:
if x["t"] == "seat":
(bots_seated if x["bot"] else humans).append(x["name"])
@@ -56,6 +56,10 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
elif x["t"] == "turn":
turns += 1
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
elif x["t"] == "standIn":
seat_changes.append("%s %s's seat to a bot" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
elif x["t"] == "reseat":
seat_changes.append("%s %s re-seated" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
elif x["t"] == "chat":
talk += 1
elif x["t"] == "over":
@@ -63,7 +67,7 @@ for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
for h in humans:
if h not in first_seen or created < first_seen[h]:
first_seen[h] = created
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk))
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk, seat_changes=seat_changes))
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
names = sorted({h for r in today for h in r["humans"]})
@@ -80,4 +84,6 @@ for r in today:
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
for change in r["seat_changes"]:
print(" %s" % change)
PY
+12 -3
View File
@@ -5,6 +5,8 @@
# Ledgers are append-only JSONL; the duel server never needs stopping.
# Sentry Crons check-in: /root/.waving-hands-sentry-cron holds the check-in
# URL (absent file = no check-ins). SCHEDULE must match /etc/cron.d/waving-hands.
# On a host the hall shares, hall-nightly.sh runs this and checks in once
# for every game, so the file is absent and the exit status is the word.
set -u
SCHEDULE="23 7 * * *"
BUCKET="kestrel-wizwar-backups"
@@ -14,25 +16,31 @@ LOG="/var/log/waving-hands/backup.log"
STAMP=$(date +%Y-%m-%d)
mkdir -p "$(dirname "$LOG")"
CRON_URL=$(cat /root/.waving-hands-sentry-cron 2>/dev/null || true)
# One check-in, when the run ends, carrying its duration: a start and a
# finish sent seconds apart can reach Sentry in either order, and a start
# that lands second is never closed. A run that never ends sends nothing,
# and Sentry marks the night missed.
START=$SECONDS
checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
-d "{\"status\":\"$1\",\"duration\":$((SECONDS - START)),\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true
}
if [ ! -s /root/.config/rclone/rclone.conf ]; then
echo "$(date -Is) skipped: rclone is not configured" >> "$LOG"
exit 0
exit 1
fi
status=0
{
echo "=== $(date -Is) backup start"
checkin in_progress
if rclone sync "$SRC" "spaces:$BUCKET/$PREFIX/current" --exclude 'rollup.jsonl' 2>&1 &&
rclone copy "$SRC" "spaces:$BUCKET/$PREFIX/snapshots/$STAMP" 2>&1; then
checkin ok
else
checkin error
status=1
fi
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
rclone lsf "spaces:$BUCKET/$PREFIX/snapshots/" --dirs-only 2>/dev/null | \
@@ -45,3 +53,4 @@ fi
done
echo "=== $(date -Is) backup done"
} >> "$LOG" 2>&1
exit $status
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# A way back for a player whose browser lost their seat: the seat gets a new
# token, the old one stops working, and the link printed carries the new one.
# Whether the one asking holds that seat is the keeper's call; send the link
# only to them. Runs ON the droplet, through the duel server's keeper door.
# waving-hands-reseat.sh ROOM who sits at the table, and when each wizard last moved or spoke
# waving-hands-reseat.sh ROOM SEAT re-seat SEAT and print the link
set -euo pipefail
ROOM="${1:?usage: waving-hands-reseat.sh ROOM [SEAT]}"
DOOR="http://127.0.0.1:8788/keeper/rooms/${ROOM^^}"
[ -s /etc/waving-hands/keeper.key ] || { echo "No keeper's key at /etc/waving-hands/keeper.key; a deploy makes it." >&2; exit 1; }
# The key goes as a header read from a file descriptor, never on a command line another user could see.
KEY="@/dev/fd/3"
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/waving-hands/keeper.key)")
if [ $# -lt 2 ]; then
curl -s -H "$KEY" "$DOOR" | python3 -c '
import datetime, json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
for s in r["seats"]:
when = "bot" if s["bot"] else ("a bot stands in; " if s.get("standIn") else "") + ("never moved or spoke" if s["lastActed"] is None else "last acted " + datetime.datetime.fromtimestamp(s["lastActed"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
'
else
curl -s -X POST -H "$KEY" -H 'content-type: application/json' -d "{\"seat\":\"${2^^}\"}" "$DOOR/reseat" | python3 -c '
import json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"])
print(r["link"])
'
fi
+11 -5
View File
@@ -8,7 +8,9 @@
# Cron: /etc/cron.d/waving-hands, installed by deploy.sh; SCHEDULE below
# must match it, because the Sentry monitor is told the same shape.
# Sentry Crons check-in: /root/.waving-hands-sentry-cron-rollup holds the
# check-in URL (absent = no check-ins).
# check-in URL (absent = no check-ins). On a host the hall shares,
# hall-nightly.sh runs this and checks in once for every game, so the
# file is absent and the exit status is the word.
set -u
OUT="/var/lib/waving-hands/rollup.jsonl"
LOG="/var/log/waving-hands/rollup.log"
@@ -16,13 +18,17 @@ DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
SCHEDULE="12 0 * * *"
CRON_URL=$(cat /root/.waving-hands-sentry-cron-rollup 2>/dev/null || true)
# The check-in carries the schedule: Sentry keeps a monitor only once told its shape.
# One check-in, when the run ends, carrying its duration: a start and a
# finish sent seconds apart can reach Sentry in either order, and a start
# that lands second is never closed. A run that never ends sends nothing,
# and Sentry marks the night missed.
START=$SECONDS
checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
-d "{\"status\":\"$1\",\"duration\":$((SECONDS - START)),\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true
}
mkdir -p "$(dirname "$LOG")"
checkin in_progress
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
import collections, datetime, glob, gzip, json, os, re, shutil, sys
day, out = sys.argv[1], sys.argv[2]
@@ -72,6 +78,6 @@ rows.sort(key=lambda r: r["day"])
with open(out + ".tmp", "w") as fh:
for r in rows: fh.write(json.dumps(r) + "\n")
os.replace(out + ".tmp", out)
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(hips), human, created))
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created))
PY
then checkin ok; else checkin error; fi
then checkin ok; else checkin error; exit 1; fi
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# A word from the keeper in a table's talk, signed as the keeper, whether or
# not the host lets the gallery talk: the answer to a call that came too late,
# or anything else the table should hear from the keeper. Runs ON the droplet,
# through the duel server's keeper door.
# waving-hands-say.sh ROOM "text"
set -euo pipefail
ROOM="${1:?usage: waving-hands-say.sh ROOM \"text\"}"
TEXT="${2:?usage: waving-hands-say.sh ROOM \"text\"}"
DOOR="http://127.0.0.1:8788/keeper/rooms/${ROOM^^}"
[ -s /etc/waving-hands/keeper.key ] || { echo "No keeper's key at /etc/waving-hands/keeper.key; a deploy makes it." >&2; exit 1; }
# The key goes as a header read from a file descriptor, never on a command line another user could see.
KEY="@/dev/fd/3"
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/waving-hands/keeper.key)")
BODY=$(TEXT="$TEXT" python3 -c 'import json, os; print(json.dumps({"text": os.environ["TEXT"]}))')
CODE=$(curl -s -o /tmp/waving-hands-say.$$ -w '%{http_code}' -X POST -H "$KEY" -H 'content-type: application/json' -d "$BODY" "$DOOR/say")
if [ "$CODE" = 204 ]; then echo "said at ${ROOM^^}"; else cat /tmp/waving-hands-say.$$ >&2; echo >&2; rm -f /tmp/waving-hands-say.$$; exit 1; fi
rm -f /tmp/waving-hands-say.$$
+77 -1
View File
@@ -14,6 +14,8 @@
// POST /api/rooms/:id/challenge {token} call the keeper of the site to a held seat; their phone rings
// POST /api/rooms/:id/rematch {token} a finished table opens another with the same company
// POST /api/rooms/:id/transfer {token} a four-word phrase that carries this seat to another device
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another wizard's seat, which re-seats it when claimed
// POST /api/rooms/:id/standin {token, seat} the host gives a wizard's seat to a bot for the rest of the duel
// POST /api/transfer {phrase} claim a phrase: the seat and its token, once
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
// a seat in the Peanut Gallery, counted for the table
@@ -23,10 +25,18 @@
// GET /api/tally what the ledgers add up to, for the hall
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
//
// Caddy serves the static site and proxies /api and /ws here.
// Caddy serves the static site and proxies /api and /ws here. The keeper's
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
// (deploy/waving-hands-reseat.sh sends it):
//
// GET /keeper/rooms/:id who sits at the table, and when each wizard last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { WebSocketServer, WebSocket } from 'ws';
import { RateLimit } from './ratelimit';
import { IMAGE_MAX_BYTES, Reports } from './reports';
@@ -41,6 +51,8 @@ const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
const KEEPER = process.env.KEEPER ?? 'the keeper';
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */
const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/waving-hands/keeper.key';
const BODY_LIMIT = 16 * 1024;
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
@@ -70,6 +82,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000);
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
const claims = new RateLimit(10, 10 * 60 * 1000);
const MAX_AUDIENCE = 30;
/** How often each socket is asked to answer; one silent through a whole round is a browser gone, and is closed. */
const PING_MS = Number(process.env.PING_MS ?? 30_000);
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS);
@@ -195,9 +209,44 @@ function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
});
}
/** The key the keeper's door asks for, read on each knock so a key written after the server started is honoured. */
function keeperKeyGiven(req: IncomingMessage): boolean {
let key: Buffer;
try {
key = Buffer.from(readFileSync(KEEPER_KEY_FILE, 'utf8').trim());
} catch {
return false;
}
const given = Buffer.from(String(req.headers['x-keeper-key'] ?? ''));
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
}
/**
* The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy
* forwarded is turned away besides, and so is one without the keeper's key.
*/
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
const room = rooms.get(parts[2] ?? '');
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
const { seat, token } = rooms.reseat(room, body.seat);
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
}
if (parts[3] === 'say' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
rooms.keeperSays(room, KEEPER, body.text);
return send(res, 204, {});
}
throw new RoomError('Not here.', 404);
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] === 'keeper') return handleKeeper(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
@@ -300,6 +349,14 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (action === 'transfer') {
return send(res, 200, rooms.transfer(room, typeof body.token === 'string' ? body.token : undefined));
}
if (action === 'phrase') {
return send(res, 200, rooms.hostPhrase(room, typeof body.token === 'string' ? body.token : undefined, body.seat));
}
if (action === 'standin') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.standIn(room, token, body.seat);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
throw new RoomError('Not here.', 404);
}
@@ -333,12 +390,31 @@ server.on('upgrade', (req, socket, head) => {
};
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe);
answered.set(ws, true);
ws.on('pong', () => answered.set(ws, true));
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
ws.on('error', () => ws.terminate());
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
});
});
/**
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
* answers by itself, finds it within two rounds.
*/
const answered = new WeakMap<WebSocket, boolean>();
setInterval(() => {
for (const ws of wss.clients) {
if (!answered.get(ws)) {
ws.terminate();
continue;
}
answered.set(ws, false);
ws.ping();
}
}, PING_MS).unref();
server.listen(PORT, HOST, () => {
console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
});
+123
View File
@@ -0,0 +1,123 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { Rooms } from './rooms';
import { chooseBotTurn } from '../../src/lib/game/bot';
import { Store } from './store';
import { SPECTATOR } from '../../src/lib/game/state';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** A table of three with Ann, who opened it, and Bo seated, the duel begun. */
function table() {
const rooms = new Rooms(new Store(dir));
const host = rooms.create('Ann', 3);
const guest = rooms.join(host.room, 'Bo');
rooms.begin(host.room, host.token);
return { rooms, room: host.room, ann: host.token, bo: guest.token };
}
describe('rooms', () => {
it("gives a seat a new token at the keeper's word: the old one opens nothing, and a restart keeps the change", () => {
const { rooms, room, bo } = table();
const phrase = rooms.transfer(room, bo).phrase;
const { seat, token } = rooms.reseat(room, 'B');
expect(seat.name).toBe('Bo');
expect(rooms.seatOf(room, token).id).toBe('B');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
// A phrase minted with the old token carries nothing now.
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
const reopened = new Rooms(new Store(dir)).get(room.id);
expect(rooms.seatOf(reopened, token).id).toBe('B');
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
});
it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => {
const { rooms, room } = table();
expect(room.galleryTalk).toBe(false);
rooms.keeperSays(room, 'Kestrel', 'I came too late for the seat; call me again.');
const said = { id: SPECTATOR, name: 'Kestrel', keeper: true, text: 'I came too late for the seat; call me again.' };
expect(room.chat.at(-1)).toMatchObject(said);
expect(new Rooms(new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
});
it('re-seats only a wizard, and says when each last acted', () => {
const { rooms, room, ann } = table();
expect(() => rooms.reseat(room, 'Z')).toThrow(/No wizard sits/);
rooms.submit(room, ann, { left: 'P', right: '-', casts: [] }, 0);
const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
expect(seats[0].lastActed).toBeGreaterThan(0);
expect(seats[1].lastActed).toBeNull();
});
});
describe('the host and the other seats', () => {
it("gives a wizard's seat to a bot, which duels the turn it was waiting on, and the wizard's token opens nothing", () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0);
rooms.standIn(room, ann, 'B');
expect(room.resolved).toBe(1);
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
});
it('keeps the gestures a wizard made before the bot stood in', () => {
const { rooms, room, ann, bo } = table();
const mine = { left: 'P', right: 'W', casts: [] } as const;
rooms.submit(room, bo, mine, 0);
rooms.standIn(room, ann, 'B');
rooms.submit(room, ann, chooseBotTurn(room.state!, 'A'), 0);
expect(room.state!.wizards.B.history.at(-1)).toMatchObject({ left: 'P', right: 'W' });
});
it("is the host's call alone, in a duel under way, for a wizard other than the host", () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the wizard who opened/);
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No wizard sits/);
const lobby = rooms.create('Ann', 2);
rooms.join(lobby.room, 'Bo');
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
});
it("gives the seat back by the host's phrase, with a new token, and a restart keeps both changes", () => {
const { rooms, room, ann, bo } = table();
rooms.standIn(room, ann, 'B');
expect(new Rooms(new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
const reopened = new Rooms(new Store(dir)).get(room.id);
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
});
it("re-seats a wizard whose browser lost the seat, by the host's phrase, once, and only the host makes one", () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the wizard who opened/);
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other wizard/);
const { phrase } = rooms.hostPhrase(room, ann, 'B');
expect(rooms.claim(phrase).token).not.toBe(bo);
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
});
it('voids a phrase for the seat once a bot stands in, and holds the wizard\'s seat at a rematch without the stand-in', () => {
const { rooms, room, ann } = table();
const { phrase } = rooms.hostPhrase(room, ann, 'B');
rooms.standIn(room, ann, 'B');
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
room.state = { ...room.state!, over: { winner: 'A', reason: 'test' } } as typeof room.state;
const next = rooms.get(rooms.rematch(room, ann).roomId);
expect(next.expected).toEqual(['Bo']);
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
});
});
+102 -10
View File
@@ -23,9 +23,12 @@ const CHAT_KEEP = 200;
export interface Seat {
id: WizardId;
name: string;
/** SHA-256 of the seat's token, hex; empty for a bot. */
/** SHA-256 of the seat's token, hex; empty for a bot. A seat a bot stands in for keeps the wizard's, which opens nothing while it does. */
tokenHash: string;
/** A bot plays this seat: one seated as a bot, or one standing in for a wizard. */
bot: boolean;
/** The host gave this wizard's seat to a bot; a phrase from the host, or the keeper, gives it back. */
standIn?: true;
}
export function hashToken(token: string): string {
@@ -153,7 +156,8 @@ export class Rooms {
seatOf(room: Room, token: string | undefined): Seat {
if (!token) throw new RoomError('That token opens no seat at this duel.', 403);
const given = Buffer.from(hashToken(token), 'hex');
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
if (seat?.standIn) throw new RoomError('The host gave your seat to a bot, which duels on in your place. Ask them for a transfer phrase to take it back.', 403);
if (!seat) throw new RoomError('That token opens no seat at this duel.', 403);
return seat;
}
@@ -199,6 +203,11 @@ export class Rooms {
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: cleanTalk(raw) });
}
/** The keeper of the site says a word at a table from the keeper's door, whether or not the gallery may talk: the answer to a call they could not take up. */
keeperSays(room: Room, keeper: string, raw: unknown): void {
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: keeper, text: cleanTalk(raw), keeper: true });
}
/**
* A seated wizard calls the keeper of the site to the table: a seat is
* held under the keeper's name and the call goes on the ledger. Once per
@@ -223,21 +232,38 @@ export class Rooms {
const caller = this.seatOf(room, token);
if (!room.state?.over) throw new RoomError('The duel is not over yet.', 409);
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
// A wizard a bot stood in for is still one of the company; the stand-in does not follow.
const others = room.seats.filter((s) => (!s.bot || s.standIn) && s.id !== caller.id).map((s) => s.name);
const next = this.open(caller.name, room.size, { rematchOf: room.id, expected: others });
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
for (const bot of room.seats.filter((s) => s.bot && !s.standIn)) this.sit(next.room, bot.name, true);
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
}
/** Phrases waiting to be claimed, with the seat and the token they carry. */
private transfers = new Map<string, { roomId: string; seatId: WizardId; token: string; expiresAt: number }>();
/** Phrases waiting to be claimed, with the seat and the token they carry; the host's carry none, and re-seat on claim. */
private transfers = new Map<string, { roomId: string; seatId: WizardId; token: string | null; expiresAt: number }>();
private failedClaims = 0;
private failWindowStart = 0;
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } {
const seat = this.seatOf(room, token);
return this.mint(room.id, seat.id, token!);
}
/**
* The host mints a phrase for another wizard's seat: for a player whose browser lost it, or to take it back
* from a bot standing in. Claimed, it gives the seat a new token, so the old one opens nothing after.
*/
hostPhrase(room: Room, token: string | undefined, seatId: unknown): { phrase: string; expiresAt: number } {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may make a phrase for another seat.', 403);
const seat = room.seats.find((s) => s.id === seatId && s.id !== host.id && (!s.bot || s.standIn));
if (!seat) throw new RoomError('No other wizard sits in that seat.', 404);
return this.mint(room.id, seat.id, null);
}
private mint(roomId: string, seatId: WizardId, token: string | null): { phrase: string; expiresAt: number } {
const now = Date.now();
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
@@ -246,7 +272,7 @@ export class Rooms {
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
} while (this.transfers.has(phrase));
const expiresAt = now + TRANSFER_TTL_MS;
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt });
this.transfers.set(phrase, { roomId, seatId, token, expiresAt });
return { phrase, expiresAt };
}
@@ -267,6 +293,10 @@ export class Rooms {
}
this.transfers.delete(phrase);
const room = this.get(t.roomId);
if (t.token === null) {
const { seat, token } = this.reseat(room, t.seatId);
return { roomId: room.id, seat: seat.id, token };
}
const seat = this.seatOf(room, t.token);
return { roomId: room.id, seat: seat.id, token: t.token };
}
@@ -307,6 +337,52 @@ export class Rooms {
return { seat, token };
}
/**
* A wizard's seat gets a new token, for a player whose browser lost the old one, or one taking it back from a
* bot standing in. Whether the one asking holds the seat is the keeper's judgment, or the host's in the phrase
* they made; this carries it out. The old token opens nothing after.
*/
reseat(room: Room, seatId: unknown): { seat: Seat; token: string } {
const seat = room.seats.find((s) => s.id === seatId && (!s.bot || s.standIn));
if (!seat) throw new RoomError('No wizard sits in that seat.', 404);
const token = newId(18);
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
this.voidPhrases(room, seat.id);
return { seat, token };
}
/**
* The host gives a wizard's seat to a bot, for a player who cannot go on: the bot plays it from the gestures the
* seat owes, and the wizard's token opens nothing until a phrase from the host gives it back.
*/
standIn(room: Room, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may give a seat to a bot.', 403);
if (!room.state || room.state.over) throw new RoomError('Only a duel under way needs a stand-in.', 409);
if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
if (!seat) throw new RoomError('No wizard sits in that seat.', 404);
this.commit(room, { t: 'standIn', at: Date.now(), id: seat.id, by: host.id });
this.voidPhrases(room, seat.id);
// The turn may have been waiting on this seat alone.
if (waitingOn(room).length === 0) this.resolve(room);
}
/** A phrase minted for a seat whose token has changed hands would carry a seat that no longer answers to it. */
private voidPhrases(room: Room, seatId: WizardId): void {
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seatId) this.transfers.delete(phrase);
}
/** The table as the keeper sees it before re-seating: who sits where, and when each wizard last moved or spoke. */
seatsForKeeper(room: Room): { id: WizardId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
const last = new Map<WizardId, number>();
for (const line of this.store.read(room.id)) {
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
}
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot && !s.standIn, standIn: !!s.standIn, lastActed: s.bot && !s.standIn ? null : (last.get(s.id) ?? null) }));
}
/** The host sends a bot away before the duel begins. People leave by not coming back. */
unseat(room: Room, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
@@ -337,8 +413,9 @@ export class Rooms {
private resolve(room: Room): void {
const state = room.state!;
const inputs: Record<WizardId, TurnInput> = { ...room.pending };
// Gestures a wizard made before a bot stood in for them stand.
for (const seat of room.seats) {
if (seat.bot && needsInput(state, seat.id)) inputs[seat.id] = chooseBotTurn(state, seat.id);
if (seat.bot && needsInput(state, seat.id) && !(seat.id in inputs)) inputs[seat.id] = chooseBotTurn(state, seat.id);
}
this.commit(room, { t: 'turn', at: Date.now(), inputs });
// A time stop owed to a bot resolves at once; a human's waits for them.
@@ -363,7 +440,7 @@ export class Rooms {
size: room.size,
me: seatId,
host: room.seats[0]?.id ?? seatId,
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, ...(s.standIn ? { standIn: true as const } : {}) })),
waitingOn: waitingOn(room),
resolved: room.resolved,
state: room.state ? viewFor(room.state, seatId) : null,
@@ -437,6 +514,21 @@ function apply(room: Room, line: LedgerLine): void {
case 'seat':
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
break;
case 'reseat': {
const seat = room.seats.find((s) => s.id === line.id);
if (!seat) break;
seat.tokenHash = line.tokenHash;
seat.bot = false;
delete seat.standIn;
break;
}
case 'standIn': {
const seat = room.seats.find((s) => s.id === line.id);
if (!seat) break;
seat.bot = true;
seat.standIn = true;
break;
}
case 'input':
room.pending[line.id] = line.input;
break;
@@ -465,7 +557,7 @@ function apply(room: Room, line: LedgerLine): void {
case 'over':
break;
case 'chat':
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}), ...(line.keeper ? { keeper: true as const } : {}) });
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
break;
}
+8 -1
View File
@@ -11,6 +11,13 @@ export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
| { t: 'seat'; id: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean }
/**
* A wizard's seat got a new token, from the keeper or a phrase the host made, for a player whose browser lost the
* old one; the old one opens nothing after. A seat a bot was standing in for is the wizard's again.
*/
| { t: 'reseat'; at: number; id: WizardId; tokenHash: string }
/** The host gave a wizard's seat to a bot, which duels in it from here on; a reseat gives it back. */
| { t: 'standIn'; at: number; id: WizardId; by: WizardId }
/** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { t: 'input'; at: number; id: WizardId; input: TurnInput }
/** A bot sent away by the host before the duel began; its seat id is free again. */
@@ -21,7 +28,7 @@ export type LedgerLine =
/** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */
| { t: 'over'; at: number; winner: WizardId | null; reason: string }
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
| { t: 'chat'; at: number; id: WizardId; text: string; name?: string }
| { t: 'chat'; at: number; id: WizardId; text: string; name?: string; keeper?: true }
/** The host let the gallery talk, or hushed it. */
| { t: 'galleryTalk'; at: number; on: boolean; by: WizardId }
/** A seat is held for the keeper of the site, called to the table. */
+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;
+3 -3
View File
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#121a20" />
<title>Waving Hands</title>
<meta name="description" content="A duel of gestures against a rival wizard, after Richard Bartle's 1977 pencil-and-paper game. Free to play in the browser." />
<meta name="description" content="A duel of gestures between wizards, two or a dozen, after Richard Bartle's 1977 pencil-and-paper game. Free to play in the browser." />
<link rel="canonical" href="https://hands.kestrelsnest.social/" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
@@ -14,7 +14,7 @@
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Waving Hands" />
<meta property="og:title" content="Waving Hands" />
<meta property="og:description" content="A duel of gestures against a rival wizard, after Richard Bartle's 1977 pencil-and-paper game. Free to play in the browser." />
<meta property="og:description" content="A duel of gestures between wizards, two or a dozen, after Richard Bartle's 1977 pencil-and-paper game. Free to play in the browser." />
<meta property="og:url" content="https://hands.kestrelsnest.social/" />
<meta property="og:image" content="https://hands.kestrelsnest.social/og.png" />
<meta property="og:image:width" content="1200" />
@@ -22,7 +22,7 @@
<meta property="og:image:alt" content="Waving Hands: a ledger of gesture letters beside the title and a snapping hand." />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Waving Hands" />
<meta name="twitter:description" content="A duel of gestures against a rival wizard, after Richard Bartle's 1977 pencil-and-paper game. Free to play in the browser." />
<meta name="twitter:description" content="A duel of gestures between wizards, two or a dozen, after Richard Bartle's 1977 pencil-and-paper game. Free to play in the browser." />
<meta name="twitter:image" content="https://hands.kestrelsnest.social/og.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+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;
+125 -18
View File
@@ -11,17 +11,46 @@
import { castKey, listText, tokensText, type Duel } from '$lib/game/duel.svelte';
import { seatColor } from '$lib/game/colors';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
import { MAGIC_GESTURES, type Gesture } from '$lib/game/spells';
let { duel }: { duel: Duel } = $props();
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 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'
);
/** Letters typed anywhere on the board fill the hands, left then right; Enter seals the turn. */
const KEYS: Record<string, Gesture> = { f: 'F', p: 'P', s: 'S', w: 'W', d: 'D', c: 'C', k: '>', '>': '>', '-': '-', '.': '-', ' ': '-' };
function onKey(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const el = e.target as HTMLElement | null;
if (el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName))) return;
if (outcome || duel.out || duel.moved || duel.spectating) return;
if (e.key === 'Enter') {
if (duel.ready) {
e.preventDefault();
duel.reveal();
}
return;
}
if (e.key === 'Backspace') {
e.preventDefault();
duel.untype();
return;
}
const g = KEYS[e.key.toLowerCase()];
if (g && duel.typeGesture(g)) e.preventDefault();
}
</script>
<svelte:window onkeydown={onKey} />
<main class="board">
<section class="play">
<Ledger {duel} />
@@ -54,7 +83,7 @@
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
<p class="threats"><span class="muted">{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.</span></p>
{:else}
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
<h2 id="move-heading">Your gestures for turn {duel.turnNumber}{#if duel.hasted}, twice over{/if}</h2>
{/if}
{#if duel.threats.length && !duel.timeStopped}
@@ -111,7 +140,7 @@
</label>
</div>
{#if duel.hasted}
<p class="haste-note">You are hastened: a second pair of gestures follows the first, and both take effect together.</p>
<p class="haste-note">You are hastened, so a second pair of gestures follows the first, and both take effect together.</p>
<div class="hands">
<HandPicker {duel} hand="left" set="haste" />
<HandPicker {duel} hand="right" set="haste" />
@@ -191,22 +220,35 @@
{#if duel.remote?.error}
<p class="warning">{duel.remote.error}</p>
{/if}
{#if duel.intentText && !duel.moved}
<p class="intent">{duel.intentText}</p>
{/if}
<div class="commit">
<div class="pair" aria-label="This turn's gestures">
{#each duel.activeSets as set (set)}
<span class="hand-pair">
<span class="which">left</span> <span class="letter" class:empty={duel.chosenGesture(set, 'left') === null}>{duel.drawn(set, 'left')}</span>
<span class="which">right</span> <span class="letter" class:empty={duel.chosenGesture(set, 'right') === null}>{duel.drawn(set, 'right')}</span>
</span>
{/each}
</div>
<div class="notes">
{#if duel.moved}
<p class="waiting">Your move is in. Waiting for {duel.awaitingText}.</p>
<p class="waiting">Your hands are still. Waiting for {duel.awaitingText}.</p>
{:else if duel.intentText}
<p class="intent">{duel.intentText}</p>
{/if}
{#if duel.surrendering}
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
{:else if duel.loneClap && !duel.moved}
<p class="warning">A clap needs both hands, and one hand clapping does nothing. Clap with both, or rest the other hand.</p>
{/if}
{#if duel.conflict}
<p class="warning">Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.</p>
<p class="warning">Those two spells share a gesture, and a gesture can finish only one spell. Let one of them pass.</p>
{/if}
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
</button>
{#if !duel.moved}
<p class="keys muted">Or type: F P S W D C, K to stab, a dash to rest; Backspace undoes, Enter seals.</p>
{/if}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>{sealLabel}</button>
</div>
</section>
{/if}
{/if}
@@ -378,8 +420,66 @@
margin: 1rem 0 0.6rem;
}
.move > .reveal {
margin-top: 0.8rem;
/* 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;
}
/* 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);
}
.pair {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.hand-pair {
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.which {
font-size: 0.75rem;
font-style: italic;
color: var(--bone-faint);
}
.pair .letter {
font-size: 1.5rem;
min-width: 1ch;
}
.pair .letter.empty {
color: var(--bone-faint);
}
.notes p {
margin: 0;
}
.notes p + p {
margin-top: 0.2rem;
}
.keys {
font-size: 0.8rem;
}
.verdict {
@@ -407,8 +507,16 @@
grid-template-columns: minmax(0, 1fr);
}
/* The sticky bar carries the reveal button on phones. */
.move > .reveal {
/* 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;
}
@@ -425,7 +533,6 @@
.waiting {
color: var(--frost);
margin-top: 0.8rem;
}
.reveal.again {
+13 -3
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import type { LogEntry } from '$lib/game/state';
let { log }: { log: LogEntry[] } = $props();
@@ -9,14 +10,23 @@
if (!groups.has(entry.turn)) groups.set(entry.turn, []);
groups.get(entry.turn)!.push(entry);
}
return [...groups.entries()].reverse();
return [...groups.entries()];
});
// Read top to bottom like a conversation; each new turn scrolls into view at the foot.
let box = $state<HTMLElement | null>(null);
$effect(() => {
void log.length;
void tick().then(() => {
if (box) box.scrollTop = box.scrollHeight;
});
});
</script>
<section class="chronicle">
<section class="chronicle" bind:this={box}>
<h2>Chronicle</h2>
{#if turns.length === 0}
<p class="muted">Nothing has happened yet. Choose a gesture for each hand and reveal them.</p>
<p class="muted">Nothing has happened yet. Choose a gesture for each hand and seal the turn.</p>
{/if}
{#each turns as [turn, entries] (turn)}
<article>
+51 -43
View File
@@ -18,14 +18,14 @@
$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;
@@ -36,19 +36,19 @@
{ 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,14 @@
if (c) void goto(`/join/${c}`);
}
/** 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) {
forgetting = roomId;
return;
}
forgetting = null;
forgetSeat(roomId);
seats = allSeats();
}
@@ -176,7 +182,7 @@
const g = games[held.roomId];
if (g === undefined) return 'looking';
if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (the bot)` : s.name));
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a bot stands in)` : s.bot ? `${s.name} (the bot)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.state.over) {
if (g.state.over.winner === held.seat) return 'you won';
@@ -188,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;
@@ -199,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' } },
@@ -211,9 +217,9 @@
<section class="lid">
<div class="lid-head">
<h1>Waving Hands</h1>
<p class="pitch">Duelling wizards. Two hands each. The right run of gestures is a spell.</p>
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture per hand and all reveal together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a></p>
<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" 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">
@@ -237,7 +243,7 @@
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form>
</div>
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
<p class="muted small">With a code you can take a seat if one is free, or watch from the Peanut Gallery.</p>
<form class="joinform claimform" onsubmit={claim}>
<span class="or">or bring a seat from another device</span>
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
@@ -264,7 +270,7 @@
{/each}
</tbody>
</table>
<p class="caption">The ledger: every gesture both wizards have made, and the spells they finished.</p>
<p class="caption">A few turns of a duel. Each row is a turn, each letter one hand's gesture, and a spell is named under the gesture that finished it.</p>
</div>
{#if seats.length || local}
@@ -279,12 +285,16 @@
</div>
{/if}
{#each seats as held (held.roomId)}
<div class="ledger-row" class:your-move={yourMove(held)}>
<div class="ledger-row" class:your-move={yourMove(held)} class:asking={forgetting === held.roomId}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a>
{#if forgetting === held.roomId}
<span class="forget-ask">This browser may be all that holds your seat. <button type="button" class="quiet" onclick={() => forget(held.roomId)}>Forget it</button> <button type="button" class="quiet" onclick={() => (forgetting = null)}>Keep it</button></span>
{:else}
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
{/if}
</div>
{/each}
{#if reports.length}
@@ -321,17 +331,17 @@
</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>
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
<p>It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
<p>This version keeps the game as written, with all forty spells, the same simultaneous turns and the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot reads your gestures as you would read its. The hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
<p>It is free and keeps no accounts. An offline duel stays in your own browser. Every other duel, against the bot or between people, lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
</div>
{/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}
@@ -346,18 +356,18 @@
</ul>
{/if}
<p class="hosting">
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month; anyone who wants to chip in toward the hosting can at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Nothing is unlocked by it and nothing is withheld without it.
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 the games are the same for everyone.
</p>
<p class="made">Waving Hands is Richard Bartle's, 1977.</p>
</div>
{/snippet}
{#snippet aboutWord()}
<div class="about-body">
<p>A rule read wrong, a bug, a duel you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
<p>If a rule reads wrong, something breaks, or you have a duel to tell of, write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
</div>
{/snippet}
<About bind:open={aboutOpen} title="About Waving Hands" tabs={[{ id: 'game', label: 'The game', body: aboutGame }, { id: 'tally', label: 'The tally', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
<About bind:open={aboutOpen} title="About Waving Hands" tabs={[{ id: 'game', label: 'Origins', body: aboutGame }, { id: 'tally', label: 'Duels so far', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
</section>
<style>
@@ -489,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);
}
@@ -565,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);
@@ -675,6 +673,16 @@
color: var(--ember);
}
/* The confirmation wraps onto its own line when the row is narrow. */
.ledger-row.asking {
flex-wrap: wrap;
}
.forget-ask {
font-size: 0.85em;
padding: 0 0.2rem 0.3rem;
}
.ledger-forget {
background: none;
border: 0;
+5 -5
View File
@@ -21,20 +21,20 @@
const hiddenPlans = $derived(duel.plans[hand].length - plans.length);
const whyForced = $derived(
duel.you.constraints.amnesia
? 'Amnesia: you must repeat last turn.'
? 'Amnesia holds this hand to last turn\'s gesture.'
: duel.you.constraints.paralysedHand === hand
? "Paralysed in last turn's position."
? "Paralysis has locked this hand in last turn's position."
: ''
);
</script>
<fieldset class="hand">
<legend>{hand === 'left' ? 'Left hand' : 'Right hand'}{#if set === 'haste'}<span class="again">, again</span>{/if}</legend>
<legend>{hand === 'left' ? 'Your left hand' : 'Your right hand'}{#if set === 'haste'}<span class="again">, again</span>{/if}</legend>
{#if forced}
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
{:else if charmed}
<p class="locked"><span class="letter big">?</span> Charmed: {duel.foe.name} chooses this hand's gesture.</p>
<p class="locked"><span class="letter big">?</span> {duel.foe.name} has charmed this hand and chooses its gesture.</p>
{:else}
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
{#each GESTURES as g (g)}
@@ -53,7 +53,7 @@
{/each}
</div>
{#if duel.you.constraints.fear && !duel.timeStopped}
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
<p class="hint">Fear keeps this hand from clapping, pointing, wriggling or snapping this turn.</p>
{/if}
{#if set === 'main' && duel.showPlans}
{#if duel.plans[hand].length === 0}
+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;
+1 -1
View File
@@ -36,7 +36,7 @@
{/each}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
{duel.out ? 'Out' : duel.moved ? 'Waiting' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Seal'}
</button>
{/if}
</div>
+2 -17
View File
@@ -61,7 +61,7 @@
<form class="slip" aria-labelledby="report-heading" onsubmit={send}>
<h2 id="report-heading">Something amiss?</h2>
{#if sent}
<p class="thanks">Recorded beside the room's ledger, with the turn. Thank you; the keeper will study the moment.{#if error} {error}{/if}</p>
<p class="thanks">Recorded beside the room's ledger, with the turn. Thank you. The keeper will look into it.{#if error} {error}{/if}</p>
{:else}
<label class="field">
<span>What happened?</span>
@@ -76,7 +76,7 @@
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label>
<p class="muted small">The room code and the turn ride along; the keeper can replay the duel to this moment.</p>
<p class="muted small">The room code and the turn are sent with it, so the keeper can replay the duel to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="reveal small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
@@ -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;
+5 -4
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';
@@ -20,8 +20,9 @@
if (!duel.remote?.started || duel.state.over) return '';
const w = duel.state.wizards[id];
if (!w.alive || w.surrendered) return 'out';
const bot = duel.remote.seats.find((s) => s.id === id)?.bot;
if (bot) return '';
const seat = duel.remote.seats.find((s) => s.id === id);
if (seat?.standIn) return 'a bot duels for them';
if (seat?.bot) return '';
return waitingOn.includes(id) ? 'writing' : 'moved';
}
</script>
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
// 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';
let { duel, open = $bindable(false) }: { duel: Duel; open?: boolean } = $props();
const people = $derived((duel.remote?.seats ?? []).filter((s) => s.id !== duel.me && (!s.bot || s.standIn)));
let asked = $state<WizardId | null>(null);
let phrase = $state<{ name: string; phrase: string; expiresAt: number } | null>(null);
let error = $state('');
function close() {
open = false;
asked = null;
error = '';
}
async function makePhrase(seat: WizardId, name: string) {
error = '';
try {
const made = await duel.hostPhrase(seat);
phrase = { name, ...made };
setTimeout(() => {
if (phrase?.phrase === made.phrase) phrase = null;
}, Math.max(0, made.expiresAt - Date.now()));
} catch (e) {
error = e instanceof Error ? e.message : 'The phrase could not be made.';
}
}
async function standIn(seat: WizardId) {
asked = null;
if (!(await duel.standIn(seat))) error = duel.remote?.error ?? '';
}
function phraseTitle(name: string, standIn: boolean): string {
const why = standIn ? `Brings ${name} back from the bot.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this duel, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
}
</script>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<div class="slip" role="dialog" aria-labelledby="seats-heading">
<h2 id="seats-heading">The other seats</h2>
<p class="muted small">As the host, you can bring a wizard back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which duels in it from this turn.</p>
{#if people.length === 0}
<p class="muted">Every other wizard at this table is a bot.</p>
{/if}
<ul>
{#each people as s (s.id)}
<li>
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot duels for them</span>{/if}</span>
<span class="ways">
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
{#if !s.standIn && !duel.state.over}
{#if asked === s.id}
<button type="button" class="reveal small" onclick={() => standIn(s.id)}>Yes, a bot plays for {s.name}</button>
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
{:else}
<button type="button" class="quiet" title="A bot duels in {s.name}'s seat from this turn, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
{/if}
{/if}
</span>
</li>
{/each}
</ul>
{#if phrase}
<p class="made">Give {phrase.name} this phrase to claim within ten minutes, in the hall or while watching this duel: <b class="phrase">{phrase.phrase}</b>. It works once, and their old link stops opening the seat.</p>
{/if}
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions"><button type="button" class="link" onclick={close}>close</button></div>
</div>
</div>
{/if}
<style>
/* A definite row, so the slip's max-height resolves and it scrolls instead of overflowing the viewport. */
.scrim {
grid-template-rows: minmax(0, 1fr);
}
.slip {
width: min(100%, 34rem);
max-height: 100%;
overflow-y: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.2rem 1.2rem;
}
h2 {
font-size: 1.3rem;
margin-bottom: 0.4rem;
}
ul {
list-style: none;
padding: 0;
margin: 0.8rem 0 0;
}
li {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.4rem 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--rule);
}
.note {
margin-left: 0.5em;
font-style: italic;
font-size: 0.9em;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.made {
margin-top: 0.8rem;
}
.phrase {
letter-spacing: 0.06em;
user-select: all;
}
.warning {
margin-top: 0.5rem;
}
.actions {
margin-top: 0.9rem;
}
</style>
+1 -1
View File
@@ -16,7 +16,7 @@
<details class="sheet" id="spell-sheet" bind:open={duel.sheetOpen}>
<summary>Spell sheet</summary>
<p class="muted intro">Gestures you have already made light up; select a spell to read what it does. A bracketed lower-case letter, such as (w, is that gesture made with both hands at once; C, the clap, is always two-handed. Surrender is both palms at once: never a spell. <a href="/rules">Full rules</a>.</p>
<p class="muted intro">Gestures you have already made light up. Select a spell to read what it does. A bracketed lower-case letter, such as (w, is that gesture made with both hands at once; C, the clap, is always two-handed. Surrender is both palms at once: never a spell. <a href="/rules">Full rules</a>.</p>
{#each groups as g (g.category)}
<h3>{g.title}</h3>
<ul>
+4 -12
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(() => {
@@ -49,7 +49,7 @@
<li class="muted">Nobody has said a word.</li>
{/if}
{#each lines as line, i (line.at + ':' + i)}
<li class:you={!line.name && line.id === duel.me}><em>{line.name ? `${line.name} (gallery)` : line.id === duel.me ? 'You' : duel.speaker(line.id)}</em> {line.text}</li>
<li class:you={!line.name && line.id === duel.me}><em>{line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === duel.me ? 'You' : duel.speaker(line.id)}</em> {line.text}</li>
{/each}
</ul>
{#if remote.spectating && remote.galleryTalk}
@@ -59,7 +59,7 @@
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form>
{:else if remote.spectating}
<p class="muted small">The gallery listens; the host has not let it talk.</p>
<p class="muted small">The host has not let the gallery talk at this table.</p>
{:else}
<form onsubmit={say}>
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
@@ -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;
}
+6 -6
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);
@@ -23,7 +23,7 @@
{/each}
</div>
{#if details.length}
<h3>The ledgers in detail</h3>
<h3>Every count</h3>
<dl class="ledger">
{#each details as d (d.label)}
<dt>{shown(d.n)}</dt><dd>{d.label}</dd>
+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[];
+12 -3
View File
@@ -19,6 +19,12 @@ function heldSeats(): Record<string, HeldSeat> {
}
}
/** 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;
}
export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null;
}
@@ -116,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;
@@ -132,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;
@@ -163,6 +169,9 @@ export const api = {
rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: WizardId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
/** The host's phrase for another wizard's seat; claimed, it re-seats them. */
hostPhrase: (roomId: string, token: string, seat: WizardId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
standIn: (roomId: string, token: string, seat: WizardId) => call<RoomView>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
claim: (phrase: string) => call<{ roomId: string; seat: WizardId; token: string }>('POST', '/api/transfer', { phrase }),
report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
@@ -175,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;
+63 -8
View File
@@ -4,7 +4,7 @@
import { tick } from 'svelte';
import { chooseBotTurn } from './bot';
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
import { api, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk';
import {
cellsOverlap,
@@ -219,6 +219,8 @@ export interface Remote extends HeldSeat {
started: boolean;
connected: boolean;
error: string;
/** 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. */
audience: number;
@@ -338,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));
@@ -696,6 +698,35 @@ export class Duel {
return list;
}
/** The hands a typed gesture fills, in the order they are filled: left then right, and the second pair after the first when hastened. */
private typingSlots(): { set: SetName; hand: Hand }[] {
return this.activeSets.flatMap((set) =>
HANDS.filter((h) => !this.forcedFor(set, h) && this.charmedHand !== h).map((hand) => ({ set, hand }))
);
}
/** A gesture typed on the keyboard goes to the first open hand. Returns false when no hand can take it. */
typeGesture(gesture: Gesture): boolean {
const slot = this.typingSlots().find((s) => this.sets[s.set][s.hand].gesture === null);
if (!slot || !this.allowed[slot.hand].includes(gesture)) return false;
this.sets[slot.set][slot.hand].gesture = gesture;
this.syncAll();
return true;
}
/** Backspace clears the last hand filled. */
untype(): void {
const slot = [...this.typingSlots()].reverse().find((s) => this.sets[s.set][s.hand].gesture !== null);
if (!slot) return;
this.sets[slot.set][slot.hand].gesture = null;
this.syncAll();
}
/** A clap with only one hand does nothing; said before the turn is sealed, not after. */
loneClap = $derived(
this.activeSets.some((set) => (this.gestureFor(set, 'left') === 'C') !== (this.gestureFor(set, 'right') === 'C'))
);
choose(set: SetName, hand: Hand, gesture: Gesture): void {
const draft = this.sets[set][hand];
draft.gesture = draft.gesture === gesture ? null : gesture;
@@ -819,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);
@@ -838,6 +869,26 @@ export class Duel {
return api.transfer(r.roomId, r.token);
}
/** Host only: a phrase for another wizard's seat, which gives it a new token when claimed. */
hostPhrase(seat: WizardId): Promise<{ phrase: string; expiresAt: number }> {
const r = this.remote!;
return api.hostPhrase(r.roomId, r.token, seat);
}
/** Host only: a bot duels in this wizard's seat for the rest of the duel, until a phrase gives it back. */
async standIn(seat: WizardId): Promise<boolean> {
const r = this.remote;
if (!r) return false;
r.error = '';
try {
this.apply(await api.standIn(r.roomId, r.token, seat));
return true;
} catch (e) {
r.error = e instanceof Error ? e.message : 'The seat could not be given to a bot.';
return false;
}
}
/** Say something to the whole table. Resolves false when the words did not arrive. */
async say(text: string): Promise<boolean> {
const r = this.remote;
@@ -884,12 +935,15 @@ export class Duel {
const refresh = () => {
fetchView()
.then((v) => this.apply(v))
.catch((e: Error) => (r.error = e.message));
.catch((e: Error) => {
if (e instanceof ServerError && e.status === 403 && !r.spectating) r.lost = e.message;
else r.error = e.message;
});
};
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. */
@@ -972,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 });
@@ -998,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), '');
}
@@ -1016,6 +1070,7 @@ function remoteFrom(view: RoomView, token: string): Remote {
started: view.state !== null,
connected: false,
error: '',
lost: '',
chat: view.chat,
audience: view.audience,
spectating: view.me === SPECTATOR,
+25
View File
@@ -49,3 +49,28 @@ describe('threats at a crowded table', () => {
expect(duel.targetLabel('C')).toBe('Cass · 8 HP · invisible');
});
});
describe('typing a move', () => {
it('fills the left hand, then the right, and backspace takes the last one back', () => {
const duel = new Duel(createGame({ A: 'You', B: 'Foe' }, 1));
expect(duel.typeGesture('S')).toBe(true);
expect(duel.typeGesture('P')).toBe(true);
expect(duel.sets.main.left.gesture).toBe('S');
expect(duel.sets.main.right.gesture).toBe('P');
expect(duel.typeGesture('W')).toBe(false);
duel.untype();
expect(duel.sets.main.right.gesture).toBeNull();
expect(duel.sets.main.left.gesture).toBe('S');
});
it('warns of a clap made with one hand before the turn is sealed', () => {
const duel = new Duel(createGame({ A: 'You', B: 'Foe' }, 1));
duel.typeGesture('C');
duel.typeGesture('W');
expect(duel.loneClap).toBe(true);
expect(duel.ready).toBe(true);
duel.untype();
duel.typeGesture('C');
expect(duel.loneClap).toBe(false);
});
});
+27 -27
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':
@@ -1028,7 +1028,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (facts.counterAimedAway) {
lessons.push({
key: 'counter-aim',
text: `A counter-spell protects its subject, not its caster: ${facts.counterAimedAway} cancelled spells aimed at them and shielded them from creatures, missiles and stabs. To stop what the other wizard casts at you, cast the counter-spell on yourself.`
text: `A counter-spell protects its subject, not its caster. This one guarded ${facts.counterAimedAway}, cancelling spells aimed at them and shielding them from creatures, missiles and stabs. To stop what the other wizard casts at you, cast the counter-spell on yourself.`
});
}
if (facts.fingerThroughCounter) {
@@ -1037,11 +1037,11 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
lessons.push({ key: 'counter-scope', text: 'A counter-spell stops every other spell cast at its subject that turn, even the subject\'s own, and shields them as well. It cannot stop finger of death or dispel magic.' });
}
if (facts.loneClap.length > 0) {
lessons.push({ key: 'lone-clap', text: `A clap needs both hands at once. ${facts.loneClap[0]}'s single-handed clap counts as nothing, and that hand's spells start from scratch.` });
lessons.push({ key: 'lone-clap', text: `A clap needs both hands at once, so ${facts.loneClap[0]}'s one-handed clap counted as nothing, and that hand's spells start from scratch.` });
}
if (facts.overlaps.length > 0) {
const o = facts.overlaps[0];
lessons.push({ key: 'overlap', text: `A gesture can serve more than one spell as long as each run is unbroken: ${o.name}'s ${o.spell} reused gestures from the earlier ${o.earlier}.` });
lessons.push({ key: 'overlap', text: `${o.name}'s ${o.spell} reused gestures from the earlier ${o.earlier}. A gesture can serve more than one spell as long as each run is unbroken.` });
}
if (facts.broken.length > 0) {
const b = facts.broken[0];
@@ -1049,10 +1049,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
if (facts.curedAndHurt.length > 0) {
const c = facts.curedAndHurt[0];
lessons.push({ key: 'cure-timing', text: `Cures land in the same turn as the damage: ${c.name} lost ${c.hurt} and regained ${c.healed} at once.` });
lessons.push({ key: 'cure-timing', text: `${c.name} lost ${c.hurt} and regained ${c.healed} in the same turn. Cures land together with the damage.` });
}
if (facts.newMonsterAttacked) {
lessons.push({ key: 'monster-same-turn', text: `A creature attacks on the very turn it is summoned: ${facts.newMonsterAttacked} struck at once. A shield that turn is the only defence.` });
lessons.push({ key: 'monster-same-turn', text: `${facts.newMonsterAttacked} struck on the very turn it was summoned. Only a shield, a counter-spell or protection from evil cast that same turn would have stopped it.` });
}
for (const l of lessons) {
if (state.lessonsShown.includes(l.key) || summary.lessons.length >= 2) continue;
+3 -3
View File
@@ -116,7 +116,7 @@ export const SPELLS: Spell[] = [
spell('shield', 'Shield', 'protection', ['P'], 'self', 'Blocks monsters, missiles and stabs against the subject this turn.'),
spell('remove_enchantment', 'Remove enchantment', 'protection', ['P-D-W-P'], 'enemy', 'Ends every enchantment on the subject. Destroys a monster after it attacks.'),
spell('magic_mirror', 'Magic mirror', 'protection', ['C-(w'], 'self', 'Spells cast at the subject this turn rebound on their caster. Undone by a counter-spell.'),
spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Every other spell cast at the subject this turn fails, and the subject is shielded. Cast it on yourself to stop what the other wizard casts at you. Cannot stop finger of death or dispel magic.'),
spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Every other spell cast at the subject this turn fails, and the subject is shielded. Cast it on yourself to stop what the others cast at you. Cannot stop finger of death or dispel magic.'),
spell('dispel_magic', 'Dispel magic', 'protection', ['C-D-P-W'], 'self', 'Every other spell this turn fails, all enchantments end, all monsters die after attacking. Shields the subject.'),
spell('raise_dead', 'Raise dead', 'protection', ['D-W-W-F-W-C'], 'self', 'Heals 5 damage and removes all enchantments from the subject.'),
spell('cure_light_wounds', 'Cure light wounds', 'protection', ['D-F-W'], 'self', 'Heals 1 damage.'),
@@ -150,8 +150,8 @@ export const SPELLS: Spell[] = [
spell('resist_cold', 'Resist cold', 'enchantment', ['S-S-F-P'], 'self', 'The subject ignores ice storms and ice elementals until dispelled. Destroys an ice elemental.'),
spell('disease', 'Disease', 'enchantment', ['D-S-F-F-F-C'], 'enemy', 'A fever takes the subject, who dies at the end of the fifth turn after this one unless cured by cure heavy wounds, remove enchantment or dispel magic.'),
spell('poison', 'Poison', 'enchantment', ['D-W-W-F-W-D'], 'enemy', 'As disease, but cure heavy wounds does not help.'),
spell('blindness', 'Blindness', 'enchantment', ['D-W-F-F-(d'], 'enemy', 'For three turns the subject cannot see the opponent’s gestures. Destroys a monster.'),
spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns the opponent cannot see the subject’s gestures and monsters cannot attack them. Destroys a monster.'),
spell('blindness', 'Blindness', 'enchantment', ['D-W-F-F-(d'], 'enemy', 'For three turns the subject cannot see other wizards’ gestures. Destroys a monster.'),
spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns other wizards cannot see the subject’s gestures, and monsters cannot attack them. Destroys a monster.'),
spell('haste', 'Haste', 'enchantment', ['P-W-P-W-W-C'], 'self', 'For the next three turns the subject makes two pairs of gestures a turn, both taking effect together.'),
spell('time_stop', 'Time stop', 'enchantment', ['S-P-P-C'], 'self', 'The subject at once takes an extra turn nobody else can see or resist.'),
spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subject’s next spell (this turn or the next three) is banked, to be released whenever they choose.'),
+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';
+7 -5
View File
@@ -15,12 +15,13 @@ 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`). */
/** 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;
at: number;
name?: string;
keeper?: true;
}
/** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */
@@ -33,7 +34,8 @@ export interface RoomView {
me: WizardId;
/** The wizard who opened the table and may begin the duel. */
host: WizardId;
seats: { id: WizardId; name: string; bot: boolean }[];
/** Who sits where; `standIn` marks a wizard's seat the host gave to a bot, which duels in it until they take it back. */
seats: { id: WizardId; name: string; bot: boolean; standIn?: true }[];
/** Seats that still have to move before the turn resolves. */
waitingOn: WizardId[];
/** Turns played so far, time stops included; gestures are sent with the count they were made against. */
@@ -44,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;
+26 -24
View File
@@ -6,14 +6,14 @@
<header>
<a class="back" href="/">Back to the hall</a>
<h1>How to play</h1>
<p class="lede">You are a wizard with two hands and a rival across the table. Every turn each of you writes down a gesture for each hand, and all are revealed at once. The right run of gestures on one hand is a spell. This page walks the room.</p>
<p class="lede">You are a wizard with two hands, and across the table sits one rival or several. Every turn each of you chooses a gesture for each hand, and all of them are shown at once. The right run of gestures on one hand is a spell. This guide goes through the screen from the hall to your first duel.</p>
<nav aria-label="Sections">
<a href="#hall">The hall</a>
<a href="#table">The table</a>
<a href="#ledger">The ledger</a>
<a href="#hands">Your hands</a>
<a href="#threats">What they may do</a>
<a href="#reveal">The reveal</a>
<a href="#reveal">Sealing the turn</a>
<a href="#rail">Health, creatures and the sheet</a>
<a href="#phone">On a phone</a>
<a href="#gallery">The Peanut Gallery and table talk</a>
@@ -27,7 +27,7 @@
<img src="/guide/hall.png" alt="The hall: the title, a field for your name, a button to play the bot, and buttons to open a table or join one by code." />
</figure>
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from a construct that reads your gestures as you read its. <strong>Open a table</strong> lays a table for up to a dozen wizards and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you.</p>
<p>Beneath, <em>your duels</em> lists every table this browser holds a seat at, and whose move it is. A duel waits for you as long as you like; nothing is lost by closing the page.</p>
<p>Beneath, <em>your duels</em> lists every table this browser holds a seat at, and whose move it is. A duel waits for you as long as you like, and nothing is lost by closing the page.</p>
</section>
<section id="table">
@@ -35,8 +35,8 @@
<figure>
<img src="/guide/table.png" alt="A table being laid: the invite link, a roster of seated wizards, an empty-seat count, and the host's button to begin." />
</figure>
<p>A table fills as wizards open the link. Whoever laid it is the host: they may seat a bot in any empty chair or send one away, and they begin the duel when the company suits them, from two wizards to twelve; a full table waits for them too. Once begun, the seats are closed and the ledger opens.</p>
<p>A duel between people is played by turns, whenever each of you has a moment. The page keeps watch and fills itself in when the others have moved; the masthead's <em>room</em> button copies the invite for latecomers who wish to watch.</p>
<p>A table fills as wizards open the link. Whoever laid it is the host. The host may seat a bot in any empty chair or send one away, and begins the duel when the company suits them, with anywhere from two wizards to twelve. Even a full table waits for the host. Once the duel begins, the seats are closed and the ledger opens.</p>
<p>A duel between people is played by turns, whenever each of you has a moment. When the others move, their turns appear without a reload. The <em>room</em> button at the top copies the invite link for anyone who arrives late and wants to watch.</p>
</section>
<section id="ledger">
@@ -44,8 +44,8 @@
<figure>
<img src="/guide/ledger.png" alt="The ledger: a table with a turn number down the left, two letter columns for each wizard, and spells named in ember beneath the gesture that finished them." />
</figure>
<p>This is the game, as Bartle drew it: one row a turn, two columns a wizard, one letter a hand. <strong>F</strong> wriggled fingers, <strong>P</strong> proffered palm, <strong>S</strong> snap, <strong>W</strong> wave, <strong>D</strong> pointed digit, <strong>C</strong> clap. A dagger is a stab; a dash, a hand that rested. A gesture written in ember finished a spell, and the spell is named beneath it.</p>
<p>The bottom row is the turn being written. Your letters appear as you choose them; your rivals' show as <strong>?</strong> until the reveal. A question mark in an older row means a gesture you were not allowed to see: you were blind, or they were invisible. With many wizards the ledger runs sideways; the turn and your own two columns stay pinned while you slide across to the rest, and a row of names above it turns the ledger toward any wizard. Pressing a warning or choosing a target does the same. Each wizard keeps one colour in the ledger, the warnings and the rail, so a name met in one place is known in the next.</p>
<p>The ledger is the game as Bartle drew it on paper. Each row is a turn, each wizard has two columns, and each letter is one hand's gesture. <strong>F</strong> is wriggled fingers, <strong>P</strong> a proffered palm, <strong>S</strong> a snap, <strong>W</strong> a wave, <strong>D</strong> a pointed digit and <strong>C</strong> a clap. A dagger is a stab, and a dash is a hand that rested. A gesture written in ember finished a spell, and the spell is named beneath it.</p>
<p>The bottom row is the turn being written. Your letters appear as you choose them, and your rivals' show as <strong>?</strong> until every hand is shown. A question mark in an older row is a gesture you were not allowed to see, because you were blind or they were invisible. With many wizards the ledger runs sideways. The turn number and your own two columns stay put while you slide across to the rest, and the row of names above the ledger jumps to any wizard. Pressing a warning or choosing a target jumps there too. Each wizard has one colour, used in the ledger, the warnings and the list of wizards alike.</p>
</section>
<section id="hands">
@@ -53,9 +53,10 @@
<figure>
<img src="/guide/hands.png" alt="Two panels of gesture buttons, left hand and right, each with a list beneath of spells under way and the gesture that continues them." />
</figure>
<p>Press a gesture for each hand. Beneath each hand is what that hand is part-way through: <em>D → F-W, Cure light wounds</em> means the digit you made last turn is the first gesture of that spell, and F then W would finish it. Press a line to make its next gesture; the diamond pins a spell so it stays at the top while you work toward it.</p>
<p>When a gesture finishes a spell, <em>Finishes</em> appears with the spell chosen and <em>at</em> naming the likeliest subject. Change either. A gesture can belong to more than one spell so long as each run is unbroken, which is how F-F-F followed by S-S-D-D casts a Paralysis and then a Fireball; but one gesture may finish only one spell, and the page will tell you when two of your choices would share one.</p>
<p>Some hands are not yours to move. A hand paralysed or charmed by a rival, or a turn under amnesia, shows its forced gesture locked. Both palms at once is surrender, and the page warns before you do it.</p>
<p>Press a gesture for each hand, or type them. The first letter you type goes to your left hand and the second to your right. K is a stab and a dash rests the hand. Backspace takes the last letter back, and Enter seals the turn. Your two letters wait beside the button in a row that stays in view however far you scroll.</p>
<p>Under each hand is a list of the spells that hand is part of the way through. <em>D → F-W, Cure light wounds</em> means the digit you made last turn starts that spell, and F then W would finish it. Press a line to make its next gesture. The diamond pins a spell to the top of the list while you work toward it.</p>
<p>When a gesture finishes a spell, <em>Finishes</em> shows which spell, and <em>at</em> shows the likeliest target. You can change either. A gesture can belong to more than one spell as long as each run is unbroken, which is how F-F-F followed by S-S-D-D casts a Paralysis and then a Fireball. But one gesture can finish only one spell, and you will be warned if two of your choices would share one.</p>
<p>Some hands are not yours to move. A hand paralysed or charmed by a rival, or a turn under amnesia, shows its forced gesture locked. Both palms at once is surrender, and you are warned before you commit to it.</p>
</section>
<section id="threats">
@@ -63,17 +64,17 @@
<figure>
<img src="/guide/threats.png" alt="Warnings above the hands: a rival's hand, the gestures already made, an arrow, and the gesture that would finish a hostile spell." />
</figure>
<p>Above the hands the page reads your rivals' ledgers for you. <em>Lucan, left: S → D, Missile</em> means Lucan's left hand snapped last turn, and one pointed digit would send a missile this turn. <em>Two gestures away</em> lists what could land the turn after. Press a warning to light those gestures in the ledger.</p>
<p>This is the whole art. A shield, a single palm, stops missiles, creatures and knives this turn. A counter-spell on yourself, W-W-S or W-P-P, stops every other spell aimed at you this turn and shields you besides. Neither stops a wound spell or a lightning bolt, and nothing but dispel magic stops the finger of death; against those, break their run with an anti-spell before it ends.</p>
<p>Above your hands is a list of what your rivals could cast soon, read from their gestures so far. <em>Lucan, left, S → D, Missile</em> means Lucan's left hand snapped last turn, and one pointed digit would send a missile this turn. <em>Two gestures away</em> lists what could land the turn after. Press a warning to light those gestures in the ledger.</p>
<p>Answering these warnings is most of the skill. A shield, a single palm, stops missiles, creatures and knives this turn, but not wound spells, lightning or fire. A counter-spell on yourself, W-W-S or W-P-P, stops every other spell cast at you this turn and shields you as well. Only finger of death and dispel magic get past it. Finger of death is stopped only by dispel magic, so the better answer is to break its run with an anti-spell before it ends.</p>
</section>
<section id="reveal">
<h2>The reveal</h2>
<h2>Sealing the turn</h2>
<figure>
<img src="/guide/result.png" alt="The result strip: each wizard's health before and after the turn, then the events of the turn in a line each." />
</figure>
<p><strong>Reveal gestures</strong> commits your turn. Against the bot the turn resolves at once; at a table it resolves when the last wizard has moved, and until then the button reads <em>Waiting</em>. Everything happens together: a shield made this turn stops a missile made this turn, a creature summoned this turn strikes this turn, and a cure lands in the same breath as the wound.</p>
<p>The strip above the hands says what changed: each wizard's health, then the events worth knowing, and the first time a rule surprises you, a line explaining it. The chronicle in the rail keeps the whole story.</p>
<p><strong>Seal the turn</strong> commits your two gestures. Nobody sees them until every wizard has sealed theirs, and then all the hands are shown at once. Against the bot this happens straight away. At a table it happens when the last wizard moves, and until then your button reads <em>Waiting</em>. Everything in a turn happens together. A shield made this turn stops a missile made this turn, a creature summoned this turn strikes this turn, and a cure lands in the same breath as the wound.</p>
<p>The strip above your hands sums up the turn. It gives each wizard's health and the events worth knowing, and the first time a rule comes up it adds a line explaining it. The chronicle at the side keeps the whole story, turn by turn.</p>
</section>
<section id="rail">
@@ -86,30 +87,31 @@
<img src="/guide/sheet.png" alt="The spell sheet, open at Fireball: its gestures, what it does, and buttons to plan it with the left or right hand." />
</figure>
</div>
<p>The rail shows every wizard: fifteen marks of health, the enchantments on them and for how long, and the creatures that serve them with hits left and damage dealt. Past three wizards the rail lists the others on a line each, with their health, what matters about their condition, and whether they have written their turn; press a line to open it out. Your own creatures take orders beneath your hands each turn and keep attacking what they last attacked until told otherwise.</p>
<p>The column at the side shows every wizard's fifteen marks of health, the enchantments on them and for how long, and the creatures that serve them. At a table of more than three, the other wizards get one line each, showing their health, anything important about their condition, and whether they have moved yet. Press a line to see the rest. Your own creatures take orders beneath your hands each turn and keep attacking what they last attacked until told otherwise.</p>
<p>The spell sheet lists all forty spells. Gestures you have already made light up along each sequence. Open a spell to read it, then <strong>Plan with left hand</strong> or <strong>right hand</strong> to pin it and make its next gesture, which is how a duel begins when nothing is yet under way.</p>
</section>
<section id="phone">
<h2>On a phone</h2>
<figure class="narrow">
<img src="/guide/phone.png" alt="The duel on a phone: the ledger, the result, the hands, and a bar fixed at the foot with every wizard's health, the chosen gestures and the reveal button." />
<img src="/guide/phone.png" alt="The duel on a phone: the ledger, the result, the hands, and a bar fixed at the foot with every wizard's health, the chosen gestures and the button that seals the turn." />
</figure>
<p>Everything is here, in one column. The bar at the foot keeps every wizard's health, your two chosen gestures and the reveal button in reach; after a reveal the page brings the ledger's last rows and the result into view. Lists are shorter, and <em>More spells</em> opens the rest.</p>
<p>On a phone everything sits in one column. A bar at the bottom of the screen keeps the wizards' health, your two gestures and the button that seals the turn within reach. After each turn the screen scrolls back to the ledger's last rows and the result. Lists are shorter, and <em>More spells</em> opens the rest.</p>
</section>
<section id="gallery">
<h2>The Peanut Gallery and table talk</h2>
<p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery: they see the ledger, the result of each turn and the chronicle, and the table is told how many are watching. The gallery is shown only what every wizard at the table could see; a gesture hidden from any one of them is a question mark to everyone in the gallery, and the turn being written shows nothing at all.</p>
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and everyone in the gallery, and it is kept with the duel; when you come back to a table, the hall says how many lines were said while you were away. The gallery listens, unless the host lets it talk; then a watcher signs a name and their lines read <em>Name (gallery)</em>.</p>
<p>Three more things a table can do. While it is laid, any seated wizard may <strong>challenge the keeper</strong>: a seat is held under the keeper's name, the keeper's phone rings, and the note beside the button says what hour it is where they live, so you know whether to wait or come back. When a duel is over, anyone at the table may call for <strong>a rematch</strong>: a new table of the same size with the same bots, seats held for everyone who was here. And <strong>Transfer seat</strong> in the masthead mints four words that bring your seat to another device, good for ten minutes; the hall on that device has a place to claim them.</p>
<p>A room's link is an invitation to sit while the table is laid and to watch once the duel has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery. They see the ledger, the result of each turn and the chronicle, and the table can see how many are watching. The gallery is shown only what every wizard at the table could see. A gesture hidden from any one wizard is a question mark to the whole gallery, and the turn being written shows nothing at all.</p>
<p><em>Table talk</em>, in the rail, is for the wizards seated. A line goes to everyone at the table and in the gallery, and it is kept with the duel. When you come back, the hall says how many lines were said while you were away. The gallery only listens unless the host lets it talk. Then a watcher signs a name, and their lines read <em>Name (gallery)</em>.</p>
<p>Before a duel begins, any seated wizard may <strong>challenge the keeper</strong>, who looks after this site. A seat is held under the keeper's name and the keeper's phone rings. The note beside the button gives the hour where the keeper lives, so you know whether to wait or come back later.</p>
<p>When a duel is over, anyone at the table may call for <strong>a rematch</strong>. That opens a new table of the same size with the same bots, and holds a seat for everyone who played. <strong>Transfer seat</strong> at the top of the page gives you four words that move your seat to another device. They work for ten minutes, and you enter them in the hall on the other device. If your browser has lost your seat altogether, ask the host: <strong>Seats</strong> at the top of their page makes the four words for you. Failing that, write to the keeper from the room's page for a link that brings the seat back. A wizard who cannot go on can have their seat given to a bot by the host, and four words from the host bring them back to it.</p>
</section>
<section id="first">
<h2>A first duel</h2>
<p>Open with a wave on each hand. From W-W you may turn either hand into a counter-spell (W-W-S), a protection from evil (W-W-P) or resist heat (W-W-F-P), and nobody can hurt you on the first turn. Watch the warnings: when a missile is one gesture away, a palm costs you one hand for one turn; when a fireball is one gesture away, only a counter-spell, a resistance, or a counter of your own will do.</p>
<p>Then choose a spell from the sheet and pin it to a hand, and keep the other hand for answering. Cause light wounds is three gestures, a fireball five, and summoning a goblin three; a goblin that lives for four turns has done a fireball's work. Read the rival's ledger every turn as they read yours. Both of you are bluffing, and the whole page is the paper.</p>
<p>The full rules are on the <a href="/rules">rules page</a>, with Bartle's text. If a rule here reads wrong to you, or the page misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>; the hall's <em>about this game</em> has the other ways to reach the keeper.</p>
<p>Open with a wave on each hand. From W-W either hand can go on to a counter-spell (W-W-S), protection from evil (W-W-P) or resist heat (W-W-F-P). On the first turn only a knife can hurt you. Then watch the warnings. When a missile is one gesture away, a palm on one hand stops it. When a fireball is one gesture away, a palm will not help, but a counter-spell on yourself or resist heat will.</p>
<p>Next, choose a spell from the sheet and pin it to one hand, and keep the other hand free for answering. Cause light wounds takes three gestures, a fireball five, and summoning a goblin three. A goblin left alone strikes every turn, so in five turns it does a fireball's damage. Read your rivals' ledgers every turn, because they are reading yours, and any of you can feint.</p>
<p>The full rules are on the <a href="/rules">rules page</a>, with Bartle's text. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>. The hall's <em>about this game</em> lists the other ways to reach the keeper.</p>
</section>
</main>
+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;
+107 -103
View File
@@ -3,8 +3,9 @@
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte';
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
import TableTalk from '$lib/components/TableTalk.svelte';
import { seatFor } from '$lib/game/client';
import { api, rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/game/client';
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase());
@@ -12,15 +13,22 @@
let room = $state<Duel | null>(null);
let name = $state('');
let joining = $state(false);
/** 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);
/** 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);
@@ -68,26 +76,46 @@
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.
// 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 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);
if (!held) {
name = playerName();
joining = true;
Duel.watchRoom(id)
.then((remote) => (room = new Duel(remote)))
.catch((e: Error) => (error = e.message));
return;
}
joining = false;
Duel.resumeRoom(id, held)
.then((remote) => (room = new Duel(remote)))
const take = (s: HeldSeat) =>
Duel.resumeRoom(id, s).then((remote) => {
rememberSeat(id, s);
trustSeat(id);
return remote;
});
// 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 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);
};
seatLost = '';
const arrive = held
? Duel.resumeRoom(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
: sent
? take(sent).catch(() => Duel.watchRoom(id))
: Duel.watchRoom(id);
joining = !held && !sent;
if (!held) name = playerName();
arrive
.then((remote) => {
room = new Duel(remote);
if (remote.spectating) joining = true;
})
.catch((e: Error) => (error = e.message));
});
@@ -99,6 +127,37 @@
return stop;
});
// 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;
if (!r || !lost) return;
seatLost = lost;
Duel.watchRoom(r.remote!.roomId)
.then((remote) => (room = new Duel(remote)))
.catch((e: Error) => (error = e.message));
});
/** 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;
error = '';
try {
const got = await api.claim(claimPhrase.trim());
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
trustSeat(got.roomId);
claimPhrase = '';
seatLost = '';
if (got.roomId === roomId) room = new Duel(await Duel.resumeRoom(roomId, { seat: got.seat, token: got.token }));
else await goto(`/join/${got.roomId}`);
} catch (err) {
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
} finally {
claiming = false;
}
}
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const r = room;
@@ -169,6 +228,9 @@
{#if !watching}
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if}
{#if room.isHost && room.remote?.started}
<button type="button" class="quiet" title="Bring a wizard back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button>
{/if}
{/if}
<a class="quiet" href="/guide">How to play</a>
<a class="quiet" href="/rules">Rules</a>
@@ -181,6 +243,7 @@
{#if room}
<ReportSlip duel={room} bind:open={reporting} />
{#if room.isHost}<SeatsSlip duel={room} bind:open={seatsOpen} />{/if}
{/if}
{#if error}
@@ -191,6 +254,22 @@
<p class="notice muted">On the other device, open the hall and claim this phrase within ten minutes: <b class="phrase">{phrase.phrase}</b></p>
{/if}
{#if room?.remote?.started && !seatLost && room.remote.seats.some((s) => s.standIn)}
<p class="notice muted">A bot duels for {room.remote.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
{/if}
{#if watching && room?.remote?.started && !room.state.over}
<div class="notice muted">
{#if seatLost}<p>{seatLost} Claim it here, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a>.</p>
{:else}{#if room.remote.expected.length > 0}<p>{room.speaker(room.remote.host)} began the duel before {room.remote.expected.join(' and ')} arrived, and the seat held for {room.remote.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
<p>Had a seat at this duel? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>{/if}
<form class="rename" onsubmit={claim}>
<input type="text" bind:value={claimPhrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
<button type="submit" class="quiet" disabled={claiming || claimPhrase.trim().split(/[\s-]+/).length < 4}>Claim the seat</button>
</form>
</div>
{/if}
{#if room?.remote?.started && room.state.over && !watching}
{@const r = room.remote}
<p class="notice muted">
@@ -199,7 +278,7 @@
<a href={`/join/${r.rematch.to}`}>Take your seat</a>
{:else}
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Duel again with this table</button>
<span>the same company, at a new table; seats are held for everyone here.</span>
<span>The same company sits down at a new table, with a seat held for everyone here.</span>
{/if}
</p>
{/if}
@@ -208,7 +287,7 @@
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>A wizard has opened this table and is gathering opponents. Choose a name and sit; the duel begins when the host says.</p>
<p>A wizard has opened this table and is gathering opponents. Choose a name and sit down. The duel begins when the host says.</p>
<form class="rename" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
@@ -229,12 +308,12 @@
{#if seatFree}
<p>{hostName} is gathering wizards. Sit down above, or watch from the gallery until the duel begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the duel begins when {hostName} says.</p>
<p>The table is full, so you watch from the Peanut Gallery. The duel begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {roomId}</p>
<h2>The table is laid</h2>
<p>Send this link, or the code. Whoever opens it takes a seat; the table holds a dozen, and the duel begins when you say. Anyone who arrives once it has begun watches from the Peanut Gallery.</p>
<p>Send this link, or the code. Whoever opens it takes a seat, up to a dozen, and the duel begins when you say. Anyone who arrives once it has begun watches from the Peanut Gallery.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
{/if}
<ul class="roster">
@@ -254,15 +333,19 @@
<li class="empty muted">{r.size - r.seats.length - r.expected.length} of {r.size} seats empty{#if r.audience > 0}; {r.audience} in the gallery{r.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
</ul>
{#if r.rematchOf}
<p class="muted">A rematch of table {r.rematchOf}: the same company, seats held for those not yet back.</p>
<p class="muted">This is a rematch of table {r.rematchOf}, with seats held for those not yet back.</p>
{/if}
{#if !watching}
<div class="ways">
{#if r.seats.length < r.size && room.isHost}
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => room?.begin()}>Begin the duel</button>
{#if room.isHost && beginAsked && r.expected.length > 0}
<span>A seat is held for {r.expected.join(' and ')}, who {r.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the duel begins.</span>
<button type="button" class="reveal small" onclick={() => room?.begin()}>Begin without them</button>
<button type="button" class="link" onclick={() => (beginAsked = false)}>wait</button>
{:else if room.isHost}
<button type="button" class="reveal small" disabled={r.seats.length < 2} onclick={() => (r.expected.length > 0 ? (beginAsked = true) : room?.begin())}>Begin the duel</button>
<span class="muted">{r.seats.length < 2 ? 'once a second wizard is seated' : `with ${r.seats.length} wizards`}</span>
{:else}
<span class="muted">{hostName} will begin the duel when the table is ready.</span>
@@ -280,7 +363,7 @@
<p class="muted small">{r.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {r.keeper} lives.{/if} The seat is held either way.</p>
</div>
{:else if r.challenge}
<p class="muted small">{r.keeper} has been called to the table by {room.speaker(r.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another duel in a new tab and play on. This table keeps its place in your hall until you come back.</p>
<p class="muted small">{r.keeper} has been called to the table by {room.speaker(r.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another duel in a new tab. This table keeps its place in your hall until you come back.</p>
{/if}
{/if}
{#if r.error}<p class="notice">{r.error}</p>{/if}
@@ -297,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;
@@ -434,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;
@@ -500,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>
+1 -80
View File
@@ -61,7 +61,7 @@
{:else}
<p class="standfirst">
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
{#if duel.state.turn === 0}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
{#if duel.state.turn === 0}Each turn, each wizard chooses one gesture per hand, and the gestures are shown together. The right run of gestures on one hand is a spell.{/if}
</p>
{/if}
</div>
@@ -90,33 +90,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);
}
.standfirst .link {
margin-left: 0.5em;
font-size: 0.8rem;
@@ -134,30 +107,6 @@
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 {
max-width: 1280px;
margin: 0 auto 0.75rem;
@@ -174,32 +123,4 @@
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;
}
}
</style>
+6 -6
View File
@@ -55,7 +55,7 @@
<header>
<a class="back" href="/">Back to the hall</a> · <a class="back" href="/guide">How to play</a>
<h1>How to duel</h1>
<p class="lede">Waving Hands is Richard Bartle's 1977 game of two wizards trading spells made of hand gestures. This page is a working reference; the original transcription is at the end.</p>
<p class="lede">Waving Hands is Richard Bartle's 1977 game of duelling wizards, who trade spells made of hand gestures. Two can play, or a whole table. This page is a working reference, and the original transcription is at the end.</p>
<nav aria-label="Sections">
<a href="#turn">A turn</a>
<a href="#gestures">Gestures</a>
@@ -71,7 +71,7 @@
<section id="turn">
<h2>A turn</h2>
<ol>
<li>Both wizards secretly choose a gesture for each hand. In this game you pick them, then choose which finished spells to cast and at whom.</li>
<li>Every wizard secretly chooses a gesture for each hand. In this game you pick them, then choose which finished spells to cast and at whom.</li>
<li>Both reveal at once. Every spell, stab and monster attack of the turn takes effect together, so a shield you make this turn stops a missile made this turn.</li>
<li>Damage and healing are added up, monsters that lost all their hit points are removed, and enchantments cast this turn bind next turn.</li>
</ol>
@@ -79,19 +79,19 @@
<section id="gestures">
<h2>Gestures</h2>
<p>Six magical gestures: <strong>F</strong> wriggled fingers, <strong>P</strong> proffered palm, <strong>S</strong> snap, <strong>W</strong> wave, <strong>D</strong> pointed digit and <strong>C</strong> clap. A clap needs both hands; a clap with one hand is nothing. A hand may instead <strong>stab</strong> with the knife (one point of damage, one stab a turn) or do <strong>nothing</strong>. Neither belongs to any spell, so a hand that stabs or rests starts its next spell from scratch.</p>
<p>There are six magical gestures. <strong>F</strong> is wriggled fingers, <strong>P</strong> a proffered palm, <strong>S</strong> a snap, <strong>W</strong> a wave, <strong>D</strong> a pointed digit and <strong>C</strong> a clap. A clap needs both hands, and a clap with one hand counts as nothing. A hand may instead <strong>stab</strong> with the knife (one point of damage, one stab a turn) or do <strong>nothing</strong>. Neither belongs to any spell, so a hand that stabs or rests starts its next spell from scratch.</p>
<p>A spell is a run of gestures made on consecutive turns by one hand. In the notation, a bracketed lower-case letter such as <strong>(w</strong> means that gesture made with both hands at once.</p>
<p>Both palms at once is <strong>surrender</strong>. It is not a spell and it ends the duel.</p>
</section>
<section id="overlap">
<h2>Overlapping spells</h2>
<p>A gesture can belong to more than one spell as long as each spell's run is unbroken. F-F-F is Paralysis; follow it with S-S-D-D and the last five gestures, F-S-S-D-D, are a Fireball. Another F after F-F-F is a second Paralysis. Each gesture can finish only one spell, so if two spells would end on the same gesture you choose one. Any finished spell may be let go harmlessly, except the storms, which rage wherever they are released.</p>
<p>A gesture can belong to more than one spell as long as each spell's run is unbroken. F-F-F is Paralysis. Follow it with S-S-D-D, and the last five gestures, F-S-S-D-D, are a Fireball. Another F after F-F-F is a second Paralysis. Each gesture can finish only one spell, so if two spells would end on the same gesture you choose one. Any finished spell may be let go harmlessly, except the storms, which rage wherever they are released.</p>
</section>
<section id="targets">
<h2>Targets and timing</h2>
<p>Every spell has a subject. Protection and summons are usually cast on yourself; attacks and enchantments on your opponent; but any spell may be aimed at any wizard or monster, and a spell aimed at something that no longer exists is lost.</p>
<p>Every spell has a subject. Protection and summons are usually cast on yourself, and attacks and enchantments on an opponent, but any spell may be aimed at any wizard or monster, and a spell aimed at something that no longer exists is lost.</p>
<p>Because everything is simultaneous, a monster killed the turn it appears still attacks that turn, a Resist heat cast in the same turn as a Fireball saves you, and a Remove enchantment cast in the same turn as a Fireball leaves you burned. <strong>Counter-spell</strong> stops every other spell cast at its subject that turn, except Finger of death and Dispel magic. <strong>Dispel magic</strong> stops every spell on the field, ends every enchantment and unmakes every monster after it attacks.</p>
</section>
@@ -110,7 +110,7 @@
<section id="winning">
<h2>Winning</h2>
<p>Each wizard has fifteen hit points and dies on losing them all. If both die in the same turn the duel is a draw. Surrender loses, unless the surrendering wizard's last gestures also killed the opponent.</p>
<p>Each wizard has fifteen hit points and dies on losing them all. The last wizard standing wins. If the last wizards standing all die in the same turn, the duel is a draw. Surrender loses, unless the surrendering wizard's last gestures also killed the last opponent.</p>
</section>
<section id="spells">
+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;
+12 -4
View File
@@ -14,7 +14,7 @@
"slug": "waving-hands",
"name": "Waving Hands",
"url": "https://hands.kestrelsnest.social",
"pitch": "Duelling wizards. Two hands each. The right run of gestures is a spell.",
"pitch": "Wizards duel with their hands, and the right run of gestures is a spell.",
"players": "2 to 12",
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
},
@@ -22,7 +22,7 @@
"slug": "hnefatafl",
"name": "Hnefatafl",
"url": "https://tafl.kestrelsnest.social",
"pitch": "The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.",
"pitch": "The Viking board game of a king breaking for safety and a ring of attackers closing in.",
"players": "2",
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
},
@@ -30,9 +30,17 @@
"slug": "dip",
"name": "Dip",
"url": "https://dip.kestrelsnest.social",
"pitch": "Seven powers. One map. Nobody has to keep their word.",
"players": "2 to 7",
"pitch": "Seven powers share one map, and none of them has to keep its word.",
"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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 82 KiB

+4 -2
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: {
@@ -22,6 +24,6 @@ export default defineConfig({
}
},
test: {
include: ['src/**/*.test.ts']
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
}
});