Compare commits

...
19 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 6970babc7e A link counts as shared when a preview app fetches a page, not when Meta's crawlers or robots.txt checks pass by
meta-externalagent and meta-webindexer render the whole site for Meta's
index and were counted as Facebook previews, one per script and stylesheet;
facebookexternalhit's robots.txt checks and Discord's refetches of a card
image it already holds counted too. Google Messages previews are named as
such rather than as iMessage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-11-06 20:02:44 -05:00
Eric WagonerandClaude Opus 5.5 82107f73eb 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 game began, can still be answered; <slug>-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:21:24 -04:00
Eric WagonerandClaude Opus 5.5 21de94869d 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 f003301541 The host has a hand on the other seat: once the game begins, Seats makes a transfer phrase for the other player, which gives the seat a new token when claimed, and gives the seat of a player who cannot go on to a housecarl, who plays the move it owes at once; the player's token opens nothing while it stands in, their page goes to the gallery with the reason and a claim box, and a rematch holds their seat without the housecarl; 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 a2d3841cfe 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:02 -04:00
Eric WagonerandClaude Opus 5.5 a2bdf7a825 The keeper's door asks for a key only root and the game's 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:28 -04:00
Eric WagonerandClaude Opus 5.5 8bbc41cc87 The keeper can bring back a seat a browser lost: a new token on the ledger, the old one refused, and a link that carries it in the fragment, minted by hnefatafl-reseat.sh through a /keeper door Caddy does not proxy and that turns away anything Caddy forwarded; the join page takes a linked seat only once the server accepts it and never over a seat this browser holds that still opens, a spectator at a game in play is told both ways back, the hall asks before forgetting a game in play, and the server's tests now run with the rest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:24:31 -04:00
Eric WagonerandClaude Opus 5.5 4b6cf01949 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 2b76b9bd9b 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 d32488b803 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:06 -04:00
Eric WagonerandClaude Opus 5.5 50178f0105 The family list carries the new taglines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-11 10:34:39 -04:00
Eric WagonerandClaude Opus 5.5 6f774780df The about panel's tabs are Origins and Games so far
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-11 10:34:09 -04:00
Eric WagonerandClaude Opus 5.5 9afe9333c2 An editing pass over the words around the game
The hall, the guide, the rules page, the lobby and the report slip lose
most of their semicolons, the statement-colon lines and the guide
narrating itself. The tagline is one sentence, 'a labor of love' is gone
from the about link, and the shared parts read as Waving Hands' do.

Corrected along the way: the about panel no longer says players are shown
only what the rules let them see (nothing in hnefatafl is hidden); the
game was played for centuries before chess, not a thousand years; the
report slip's example no longer mentions points; and a watcher is no
longer told that "your defender" was taken. The move story reads "Ada
moved an attacker from d1 to d4" rather than "Ada: an attacker...".

The end-of-game reasons are unchanged: ledgers record them verbatim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-11 10:25:14 -04:00
Eric WagonerandClaude Opus 5.5 7f66be8016 A credibility pass over ee5690e..HEAD: traffic.py parses each request once and counts visits one way, the deploy notes and skills agree with the scripts, and a few edit leftovers in the hall are tidied
traffic.py gives identical counts before and after over 41 days of the
host's access logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-10-11 10:17:01 -04:00
Eric WagonerandClaude Opus 5.5 57ae338cdb The rollup and the backup check in once, when they end, with the run's duration
The rollup finishes in two seconds on the shared host, and its "in progress"
and "ok" check-ins reached Sentry in either order; when "ok" landed first,
the late start was never closed and the monitor reported a timeout ten
minutes later. One check-in at the end cannot race, and a run that hangs
still shows: nothing arrives and the night is marked missed.

Fixes TAFL-1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-10-10 17:44:43 -04:00
Eric WagonerandClaude Opus 5.5 cc9d7330aa Pages show the hall-wide notice, when there is one, at the top
The keeper's hall-announce writes one file on the shared host; the site
serves it at /hall/announcement.json, and the banner shows it with the time
in the reader's own clock, dismissable one notice at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 13:03:36 -04:00
Eric WagonerandClaude Opus 5.5 1d5ec9931a The tally's comment describes the tally, not where its look came from
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 14:47:01 -04:00
Eric WagonerandClaude Opus 5.5 8be3963b22 dev:all stops both sides when the server's port is taken
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 13:43:15 -04:00
Eric WagonerandClaude Opus 5.5 d1f3966c0b npm run dev:all starts the server and the site together
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 12:37:56 -04:00
40 changed files with 1040 additions and 162 deletions

No files matched your search

+2 -1
View File
@@ -72,7 +72,8 @@ to hear his players' voices; never compress their words into a table.
passing `rules` to `createGame`). Old ledgers then replay as their
players saw them; new games get the fix.
Either way add a test in `src/lib/game/*.test.ts` pinning the
corrected behaviour. Deploy with `deploy/deploy.sh 159.203.115.252`.
corrected behaviour. Deploy with
`SITE=https://tafl.159.203.115.252.sslip.io deploy/deploy.sh 159.203.115.252`.
5. **Reply.** `bash deploy/report-reply.sh 159.203.115.252 <reportId> <status> "text"`.
Pass the id as ONE clean argument. Write to the PLAYER: name what you
replayed, cite the rule, and say plainly what was wrong or why nothing
+6 -3
View File
@@ -17,9 +17,12 @@ spills into the next UTC day. Run yesterday too when the hour is early.
- **as of / live sockets / rooms touched in the last hour**: the "right
now" line. One socket at a quiet hour is Eric.
- **traffic**: Caddy's access log for the day, bots split out by user
agent, page requests by page (hall, rules, guide) and room links opened,
referrers and campaign tags.
- **traffic**: Caddy's access log for the day, people and bots apart. A
bot is known by its user agent, or by its address: scanners ask for
paths no game serves, monitors come back all day and never play. Then
page requests by page (hall, rules, guide), rooms opened from the site
("rooms opened here"), room links opened, the apps a link was previewed
in ("shared on"), referrers and campaign tags.
- **players today / NEW today**: names seated in rooms created today;
NEW means the name's first room ever is today.
- **rooms today**: one line each: time, code, humans vs bots, empty
+1
View File
@@ -14,6 +14,7 @@ guide here departs from it in one way, walking the page in words and one
drawn figure rather than screenshots.
npm install && (cd server && npm install)
npm run dev:all # server and site together; Ctrl-C stops both
npm run server # the game server on port 8789
npm run dev -- --host
+7
View File
@@ -1,4 +1,11 @@
__HOST__ {
# The hall-wide notice (hall-announce on the host): one file, served by every game's site.
handle_path /hall/* {
root * /var/lib/hall
header Cache-Control "no-store"
file_server
}
root * /opt/hnefatafl/build
encode gzip zstd
+7 -2
View File
@@ -26,8 +26,8 @@ dip 8790.
- URLs: https://tafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's
DNS lives) and https://tafl.159.203.115.252.sslip.io (always works, zero DNS).
- Everyday deploy: `SITE=https://tafl.159.203.115.252.sslip.io deploy/deploy.sh 159.203.115.252`
(this Mac's resolver caches the old *.kestrelsnest.social wildcard, so the gate reads the sslip name).
- Moved here from its own droplet (209.97.148.244) on 2026-09-25.
(the determinism gate reads the ledgers from `SITE`; the sslip name answers wherever
the real name does not resolve to this host yet).
- Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/hnefatafl/feedback.jsonl
and the screenshots to ~/Desktop/hnefatafl-reports with a digest;
`deploy/report-reply.sh 159.203.115.252 <id> <status> "text"` answers one.
@@ -53,6 +53,11 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
deploy, so the live copies are the repo copies:
- `hnefatafl-visitors.sh [day]`: who is here now and who came that day.
- `hnefatafl-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.
- `hnefatafl-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.
- `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup
trend, backup, box).
- `hnefatafl-rollup.sh [day]`: one JSON line per day of counts, run
+13 -1
View File
@@ -42,8 +42,20 @@ ssh "root@$HOST" '
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-pulse.sh /usr/local/bin/hnefatafl-pulse.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-reseat.sh /usr/local/bin/hnefatafl-reseat.sh
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-say.sh /usr/local/bin/hnefatafl-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/hnefatafl/keeper.key ]; then
install -d -m 750 -o root -g hnefatafl /etc/hnefatafl
(umask 037; openssl rand -hex 32 > /etc/hnefatafl/keeper.key)
chown root:hnefatafl /etc/hnefatafl/keeper.key
fi
install -m 644 -D /opt/hnefatafl/app/deploy/traffic.py /usr/local/lib/hnefatafl/traffic.py
install -m 644 /opt/hnefatafl/app/deploy/hnefatafl.cron /etc/cron.d/hnefatafl
# 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/hnefatafl
else install -m 644 /opt/hnefatafl/app/deploy/hnefatafl.cron /etc/cron.d/hnefatafl; fi
mkdir -p /var/log/hnefatafl
systemctl daemon-reload
systemctl enable --now hnefatafl
+12 -3
View File
@@ -5,6 +5,8 @@
# Ledgers are append-only JSONL; the game server never needs stopping.
# Sentry Crons check-in: /root/.hnefatafl-sentry-cron holds the check-in
# URL (absent file = no check-ins). SCHEDULE must match /etc/cron.d/hnefatafl.
# 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/hnefatafl/backup.log"
STAMP=$(date +%Y-%m-%d)
mkdir -p "$(dirname "$LOG")"
CRON_URL=$(cat /root/.hnefatafl-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 game server's keeper door.
# hnefatafl-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
# hnefatafl-reseat.sh ROOM SEAT re-seat SEAT and print the link
set -euo pipefail
ROOM="${1:?usage: hnefatafl-reseat.sh ROOM [SEAT]}"
DOOR="http://127.0.0.1:8789/keeper/rooms/${ROOM^^}"
[ -s /etc/hnefatafl/keeper.key ] || { echo "No keeper's key at /etc/hnefatafl/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/hnefatafl/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 housecarl 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,21 +8,27 @@
# Cron: /etc/cron.d/hnefatafl, installed by deploy.sh; SCHEDULE below
# must match it, because the Sentry monitor is told the same shape.
# Sentry Crons check-in: /root/.hnefatafl-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/hnefatafl/rollup.jsonl"
LOG="/var/log/hnefatafl/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
SCHEDULE="12 0 * * *"
CRON_URL=$(cat /root/.hnefatafl-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 and the schedule
# (Sentry keeps a monitor only once told its shape). 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 datetime, glob, json, os, shutil, sys
sys.path.insert(0, "/usr/local/lib/hnefatafl")
@@ -74,4 +80,4 @@ with open(out + ".tmp", "w") as fh:
os.replace(out + ".tmp", out)
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 game server's keeper door.
# hnefatafl-say.sh ROOM "text"
set -euo pipefail
ROOM="${1:?usage: hnefatafl-say.sh ROOM \"text\"}"
TEXT="${2:?usage: hnefatafl-say.sh ROOM \"text\"}"
DOOR="http://127.0.0.1:8789/keeper/rooms/${ROOM^^}"
[ -s /etc/hnefatafl/keeper.key ] || { echo "No keeper's key at /etc/hnefatafl/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/hnefatafl/keeper.key)")
BODY=$(TEXT="$TEXT" python3 -c 'import json, os; print(json.dumps({"text": os.environ["TEXT"]}))')
CODE=$(curl -s -o /tmp/hnefatafl-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/hnefatafl-say.$$ >&2; echo >&2; rm -f /tmp/hnefatafl-say.$$; exit 1; fi
rm -f /tmp/hnefatafl-say.$$
+8 -2
View File
@@ -42,7 +42,7 @@ for f in glob.glob("/var/lib/hnefatafl/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,12 +56,16 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"):
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
elif x["t"] == "chat":
talk += 1
elif x["t"] == "standIn":
seat_changes.append("%s %s's seat to a housecarl" % (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"] == "over":
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
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"]})
@@ -78,4 +82,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
+1 -1
View File
@@ -4,7 +4,7 @@
# own port, service user, data directory and access log, and this script
# touches nothing that belongs to another game. Copy it and Caddyfile.tmpl
# to the host and run ON it as root; safe to run again.
# bash setup-droplet.sh 'tafl.kestrelsnest.social, hnefatafl.<host-ip>.sslip.io'
# bash setup-droplet.sh 'tafl.kestrelsnest.social, tafl.<host-ip>.sslip.io'
# The argument is this game's Caddy site address line: one name, or several
# separated by commas. Every name must already resolve to this host.
set -euo pipefail
+44 -41
View File
@@ -12,11 +12,13 @@ from collections import Counter, defaultdict
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
# Link previews: the app that fetched the page to show it in a conversation. The people
# who click such a link arrive with no referrer, so this is the best sign of where it went.
# Meta's crawlers (meta-externalagent, meta-webindexer) name facebook.com too but share nothing.
UNFURL = [
("Slack", re.compile(r"slack", re.I)),
("Discord", re.compile(r"discord", re.I)),
("Google Messages", re.compile(r"googlemessages", re.I)),
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
("Facebook", re.compile(r"facebookexternalhit|facebot|meta-externalagent", re.I)),
("Facebook", re.compile(r"facebookexternalhit|facebot", re.I)),
("WhatsApp", re.compile(r"whatsapp", re.I)),
("Telegram", re.compile(r"telegram", re.I)),
("Reddit", re.compile(r"redditbot", re.I)),
@@ -46,6 +48,28 @@ def unfurled_by(ua):
return None
def parse(r):
"""The parts of one log line the counts read."""
q = r.get("request", {})
h = q.get("headers", {})
uri = q.get("uri", "")
return dict(
ts=r.get("ts", 0), status=r.get("status", 0), method=q.get("method"), uri=uri, path=uri.split("?")[0],
ip=q.get("client_ip") or q.get("remote_ip") or "?",
ua=" ".join(h.get("User-Agent", [""])), referer=" ".join(h.get("Referer", [""])),
)
def visit_starts(times):
"""When each visit began: requests more than VISIT_GAP apart are separate visits."""
starts, last = [], None
for ts in sorted(times):
if last is None or ts - last > VISIT_GAP:
starts.append(ts)
last = ts
return starts
def scan(day, logs=LOGS):
"""Counts for one UTC day (YYYY-MM-DD): every request, page requests by
people, the addresses of people and of bots, requests by page, room links
@@ -53,8 +77,8 @@ def scan(day, logs=LOGS):
referrers, and campaign tags (each counted once per address).
A request is a bot's when its browser says so. An address is a bot's for
the whole day when it asks for a scanner's paths, or comes back for pages
three visits or more without ever opening or joining a game: a monitor,
the whole day when it asks for a scanner's paths, or comes back three
visits or more without ever opening or joining a game: a monitor,
not a person. A monitor that takes a new address every visit gives itself
away by keeping time: one browser, page loads at a steady interval, four
visits or more, never a game."""
@@ -73,55 +97,37 @@ def scan(day, logs=LOGS):
except ValueError:
continue
if t0 <= r.get("ts", 0) < t1:
requests.append(r)
requests.append(parse(r))
except OSError:
continue
# First pass: what each address did all day under a browser's name, to find scanners and monitors.
seen = defaultdict(lambda: dict(times=[], played=False, scanner=False))
for r in requests:
q = r.get("request", {})
ip = q.get("client_ip") or q.get("remote_ip") or "?"
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
if BOT.search(ua) or not ua:
if BOT.search(r["ua"]) or not r["ua"]:
continue
path = q.get("uri", "").split("?")[0]
a = seen[ip]
a["times"].append(r.get("ts", 0))
path = r["path"]
a = seen[r["ip"]]
a["times"].append(r["ts"])
if not path.startswith("/_app/") and SCANNER.search(path):
a["scanner"] = True
if path.startswith(("/join/", "/api/rooms")) or path == "/ws":
a["played"] = True
def visits(times):
n, last = 0, None
for ts in sorted(times):
if last is None or ts - last > VISIT_GAP:
n += 1
last = ts
return n
def keeps_time(times):
starts, last = [], None
for ts in sorted(times):
if last is None or ts - last > VISIT_GAP:
starts.append(ts)
last = ts
gaps = [b - a for a, b in zip(starts, starts[1:])]
starts = visit_starts(times)
if len(starts) < 4:
return False
gaps = [b - a for a, b in zip(starts, starts[1:])]
typical = sorted(gaps)[len(gaps) // 2]
return all(0.75 * typical <= g <= 1.25 * typical for g in gaps)
scanners = {ip for ip, a in seen.items() if a["scanner"]}
monitors = {ip for ip, a in seen.items() if not (a["scanner"] or a["played"]) and visits(a["times"]) >= 3}
monitors = {ip for ip, a in seen.items() if not (a["scanner"] or a["played"]) and len(visit_starts(a["times"])) >= 3}
by_browser = defaultdict(list)
for r in requests:
q = r.get("request", {})
ip = q.get("client_ip") or q.get("remote_ip") or "?"
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
if ua and not BOT.search(ua) and q.get("uri", "").split("?")[0] in PAGES and not seen[ip]["played"]:
by_browser[ua].append((r.get("ts", 0), ip))
if r["ua"] and not BOT.search(r["ua"]) and r["path"] in PAGES and not seen[r["ip"]]["played"]:
by_browser[r["ua"]].append((r["ts"], r["ip"]))
for hits in by_browser.values():
if keeps_time([ts for ts, _ in hits]):
monitors |= {ip for _, ip in hits}
@@ -134,24 +140,21 @@ def scan(day, logs=LOGS):
seen_campaign = set()
seen_share = set()
for r in requests:
q = r.get("request", {})
h = q.get("headers", {})
ua = " ".join(h.get("User-Agent", [""]))
ip = q.get("client_ip") or q.get("remote_ip") or "?"
uri = q.get("uri", "")
path = uri.split("?")[0]
ip, ua, uri, path = r["ip"], r["ua"], r["uri"], r["path"]
t["requests"] += 1
if ip in posing or BOT.search(ua) or not ua:
t["bot"] += 1
t["botAddresses"].add(ip)
service = unfurled_by(ua)
# One share per app and page: a preview is often fetched twice.
if service and (service, path) not in seen_share:
# One share per app and page: a preview is often fetched twice. Only a
# page counts: an app that already holds a preview fetches its card
# image again to show it, and robots.txt and scripts are not shared.
if service and (path in PAGES or path.startswith("/join/")) and (service, path) not in seen_share:
seen_share.add((service, path))
t["shared"][service] += 1
continue
# A room opened from the site itself never passes through /join: count the API's answer.
if q.get("method") == "POST" and path == "/api/rooms" and 200 <= r.get("status", 0) < 300:
if r["method"] == "POST" and path == "/api/rooms" and 200 <= r["status"] < 300:
t["opened"] += 1
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
continue
@@ -161,7 +164,7 @@ def scan(day, logs=LOGS):
t["pages"][PAGES[path]] += 1
elif path.startswith("/join/"):
t["joins"][path.split("/")[2].upper()] += 1
ref = " ".join(h.get("Referer", [""]))
ref = r["referer"]
if ref and not OWN.search(ref):
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
+26
View File
@@ -37,6 +37,28 @@ README.
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four
words good for ten minutes and one use; the hall's *bring a seat from
another device* claims them. The browser that claims holds the seat too.
- **The keeper can bring a seat back**: a player whose browser lost the
seat (Safari's seven days, an app's own browser, cleared data) writes to
the keeper, and `<slug>-reseat.sh ROOM SEAT` on the host gives the seat
a new token and prints a link carrying it in the fragment. The old token
opens nothing after. A link is kept only once the server takes it, and
never over a seat the browser holds that still opens. The script answers
through the game server's `/keeper` door, which Caddy does not proxy and
which asks for the key in `/etc/<slug>/keeper.key`, made by the deploy and
readable by root and the game's user alone. A spectator at a game in play
is told both ways back, and the hall asks before forgetting a game in play.
- **The host has a hand on the other seats**: once the game begins, *Seats*
in the host's masthead makes a transfer phrase for another person's seat
(claimed, it gives the seat a new token, as the keeper's link does), and
gives the seat of a player who cannot go on to a housecarl, who plays it
from the move the seat owes and does not speak in the person's name.
The person's token opens nothing while one stands in, with the reason;
their page goes to the gallery, where a claim box takes the host's
phrase. A rematch holds the person's seat and leaves the stand-in out.
- **A dead browser's socket is closed**: the server pings every socket on
the protocol's own ping, which browsers answer by themselves, and closes
one silent for a whole round, so a crashed tab does not linger in the
gallery's count.
- **Held seats are doubted before they are dropped**: the hall forgets a
seat only on the second refusal in a row from the server. A restart, a
stale answer or the wrong backend answer with ignorance, and ignorance
@@ -123,6 +145,10 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
today, every room opened and how far it got. Names seated for the first
time are marked NEW.
- `<slug>-reseat.sh ROOM [SEAT]`: who sits at a table and when each last
acted; with a seat, a new token for a player locked out, as a link.
- `<slug>-say.sh ROOM "text"`: a word from the keeper in a table's talk,
signed as the keeper, whether or not the gallery may talk.
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
the rollup's last week, the backup's last word, the box's vitals.
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:all": "node scripts/dev-all.mjs",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
+39
View File
@@ -0,0 +1,39 @@
// The game server and the site together, for playing locally: one terminal,
// each line marked with its side, and Ctrl-C (or either one stopping or
// failing to get its port) stops both.
import { spawn } from 'node:child_process';
const sides = [
['server', ['run', 'server']],
['site', ['run', 'dev', '--', ...process.argv.slice(2)]]
];
const children = sides.map(([name, args]) => {
const child = spawn('npm', args, { stdio: ['inherit', 'pipe', 'pipe'] });
for (const stream of [child.stdout, child.stderr]) {
let rest = '';
stream.on('data', (chunk) => {
const lines = (rest + chunk).split('\n');
rest = lines.pop();
for (const line of lines) {
console.log(`[${name}] ${line}`);
// A watcher outlives the crash it watches, so a port already taken would leave one side running alone.
if (line.includes('EADDRINUSE')) {
console.log(`[${name}] its port is taken: is another copy already running?`);
stop(1);
}
}
});
}
child.on('exit', (code) => stop(code ?? 0));
return child;
});
let stopping = false;
function stop(code) {
if (stopping) return;
stopping = true;
for (const c of children) if (c.exitCode === null) c.kill('SIGTERM');
process.exitCode = code;
}
process.on('SIGINT', () => stop(0));
process.on('SIGTERM', () => stop(0));
+76 -1
View File
@@ -14,6 +14,8 @@
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
// POST /api/rooms/:id/rematch {token} a finished table opens the next one
// POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another person's seat, which re-seats it when claimed
// POST /api/rooms/:id/standin {token, seat} the host gives a person's seat to a bot for the rest of the game
// POST /api/transfer {phrase} claim one
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
@@ -23,10 +25,18 @@
// 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
//
// 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/hnefatafl-reseat.sh sends it):
//
// GET /keeper/rooms/:id who sits at the table, and when each person 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 { dirname } from 'node:path';
import { WebSocketServer, WebSocket } from 'ws';
import { game } from '../../src/lib/game';
@@ -43,6 +53,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://tafl.kestrelsnest.social'
/** 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/hnefatafl/keeper.key';
const BODY_LIMIT = 16 * 1024;
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
@@ -72,6 +84,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);
@@ -184,9 +198,44 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s
throw new RoomError('Not here.', 404);
}
/** 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, 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') {
@@ -268,6 +317,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (action === 'transfer') {
return send(res, 200, rooms.transfer(room, token));
}
if (action === 'phrase') {
return send(res, 200, rooms.hostPhrase(room, token, body.seat));
}
if (action === 'standin') {
rooms.standIn(room, token, body.seat);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'report') {
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
const seat = token ? rooms.seatOf(room, token) : null;
@@ -313,11 +369,30 @@ 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());
});
});
/**
* 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(`hnefatafl server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
});
+116
View File
@@ -0,0 +1,116 @@
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 { game } from '../../src/lib/game';
import { Rooms } from './rooms';
import { Store } from './store';
import { SPECTATOR } from '../../src/lib/game/spec';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** A table of two: Ann, who opened it, and Bo, the game begun. */
function table() {
const rooms = new Rooms(game, new Store(dir));
const host = rooms.create('Ann', 2);
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("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, 'I came too late for the seat; call me again.');
const said = { id: SPECTATOR, name: 'the keeper', keeper: true, text: 'I came too late for the seat; call me again.' };
expect(room.chat.at(-1)).toMatchObject(said);
expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
});
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(game, new Store(dir)).get(room.id);
expect(rooms.seatOf(reopened, token).id).toBe('B');
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
});
it('re-seats only a person, and says when each person last acted', () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/);
const mover = rooms.waitingOn(room)[0];
rooms.submit(room, mover === 'A' ? ann : bo, game.botInput(room.state!, mover), 0);
const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
expect(seats.find((s) => s.id === mover)!.lastActed).toBeGreaterThan(0);
expect(seats.find((s) => s.id !== mover)!.lastActed).toBeNull();
});
});
describe('the host and the other seat', () => {
it("gives a person's seat to a housecarl, who plays it whenever it owes the move, and the person's token opens nothing", () => {
const { rooms, room, ann, bo } = table();
rooms.standIn(room, ann, 'B');
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a housecarl/);
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
// Whoever moved first, the housecarl has answered, and the move is Ann's.
expect(rooms.waitingOn(room)).toEqual(['A']);
const before = room.resolved;
rooms.submit(room, ann, game.botInput(room.state!, 'A'), room.resolved);
expect(room.resolved).toBe(before + 2);
expect(rooms.waitingOn(room)).toEqual(['A']);
});
it("is the host's call alone, in a game under way, for the other person", () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/);
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No person 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(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
expect(claimed.seat).toBe('B');
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
const reopened = new Rooms(game, 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 person 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 player who opened/);
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other person/);
const { phrase } = rooms.hostPhrase(room, ann, 'B');
const claimed = rooms.claim(phrase);
expect(claimed.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 housecarl stands 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/);
});
});
+103 -11
View File
@@ -17,9 +17,12 @@ export { SPECTATOR };
export interface Seat {
id: SeatId;
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 person'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 person. */
bot: boolean;
/** The host gave this person's seat to a bot; a phrase from the host, or the keeper, gives it back. */
standIn?: true;
}
function hashToken(token: string): string {
@@ -108,8 +111,8 @@ export class Rooms<State, Input> {
private listeners = new Map<string, Set<(room: Room<State>) => void>>();
/** Sockets watching from the gallery, by room. */
private gallery = new Map<string, number>();
/** Phrases waiting to be claimed, with the seat and the token they carry. */
private transfers = new Map<string, { roomId: string; seatId: SeatId; 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: SeatId; token: string | null; expiresAt: number }>();
private failedClaims = 0;
private failWindowStart = 0;
@@ -168,7 +171,8 @@ export class Rooms<State, Input> {
seatOf(room: Room<State>, token: string | undefined): Seat {
if (!token) throw new RoomError('That token opens no seat at this game.', 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 housecarl, who plays 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 game.', 403);
return seat;
}
@@ -225,6 +229,11 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
}
/** 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<State>, raw: unknown): void {
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: this.keeper.name, text: this.cleanTalk(raw), keeper: true });
}
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
@@ -257,9 +266,10 @@ export class Rooms<State, Input> {
const caller = this.seatOf(room, token);
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game 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 person 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, room.options, { 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 };
}
@@ -267,6 +277,22 @@ export class Rooms<State, Input> {
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
transfer(room: Room<State>, 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 person's seat: for a player whose browser lost it, or to take it back
* from a housecarl standing in. Claimed, it gives the seat a new token, so the old one opens nothing after.
*/
hostPhrase(room: Room<State>, 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 player 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 person sits in that seat.', 404);
return this.mint(room.id, seat.id, null);
}
private mint(roomId: string, seatId: SeatId, 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);
@@ -275,7 +301,7 @@ export class Rooms<State, Input> {
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 };
}
@@ -296,6 +322,10 @@ export class Rooms<State, Input> {
}
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 };
}
@@ -350,6 +380,52 @@ export class Rooms<State, Input> {
return { seat, token };
}
/**
* A person'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<State>, 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 person 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 person's seat to a bot, for a player who cannot go on: the bot plays it from the next move
* the seat owes, and the person's token opens nothing until a phrase from the host gives it back.
*/
standIn(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may give a seat to a housecarl.', 403);
if (!room.state || this.game.over(room.state)) throw new RoomError('Only a game 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 person 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 move may have been this seat's.
if (this.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<State>, seatId: SeatId): 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 person last moved or spoke. */
seatsForKeeper(room: Room<State>): { id: SeatId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
const last = new Map<SeatId, 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 game begins. People leave by not coming back. */
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
@@ -366,7 +442,7 @@ export class Rooms<State, Input> {
* arrives after its round was played (a slow connection, a second click, a stale tab) is refused
* rather than taken as the move for the next round.
*/
submit(room: Room<State>, token: string | undefined, raw: unknown, resolved?: unknown): void {
submit(room: Room<State>, token: string | undefined, raw: unknown, resolved: unknown): void {
const seat = this.seatOf(room, token);
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
@@ -383,7 +459,8 @@ export class Rooms<State, Input> {
private resolve(room: Room<State>): void {
const inputs: Record<SeatId, unknown> = { ...room.pending };
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
// A move the person made before a bot stood in for them stands.
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id) && !(seat.id in inputs)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
this.commit(room, { t: 'turn', at: Date.now(), inputs });
this.driveBots(room);
}
@@ -420,7 +497,7 @@ export class Rooms<State, Input> {
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: this.waitingOn(room),
turn: room.state ? this.game.turn(room.state) : null,
resolved: room.resolved,
@@ -486,6 +563,21 @@ export class Rooms<State, Input> {
case 'seat':
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, 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 'galleryTalk':
room.galleryTalk = line.on;
break;
@@ -512,7 +604,7 @@ export class Rooms<State, Input> {
room.resolved += 1;
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[]; options: TableOptions }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */
| { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean }
/**
* A person'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 housecarl was standing in for is the person's again.
*/
| { t: 'reseat'; at: number; id: SeatId; tokenHash: string }
/** The host gave a person's seat to a housecarl, who plays it from here on; a reseat gives it back. */
| { t: 'standIn'; at: number; id: SeatId; by: SeatId }
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { t: 'input'; at: number; id: SeatId; input: unknown }
/** A bot sent away by the host before the game began; its seat id is free again. */
@@ -21,7 +28,7 @@ export type LedgerLine =
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
| { t: 'over'; at: number; winner: SeatId | 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: SeatId; text: string; name?: string }
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; keeper?: true }
/** The host let the gallery talk, or hushed it. */
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
/** A seat is held for the keeper of the site, called to the table. */
+3 -3
View File
@@ -6,21 +6,21 @@
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#14181a" />
<title>Hnefatafl</title>
<meta name="description" content="Hnefatafl, the Viking board game, free in the browser: play a housecarl or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta name="description" content="Hnefatafl, the Viking board game, free in the browser. Play a housecarl or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<link rel="canonical" href="https://tafl.kestrelsnest.social/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Hnefatafl" />
<meta property="og:title" content="Hnefatafl" />
<meta property="og:description" content="Hnefatafl, the Viking board game, free in the browser: play a housecarl or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta property="og:description" content="Hnefatafl, the Viking board game, free in the browser. Play a housecarl or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta property="og:url" content="https://tafl.kestrelsnest.social/" />
<meta property="og:image" content="https://tafl.kestrelsnest.social/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hnefatafl" />
<meta name="twitter:description" content="Hnefatafl, the Viking board game, free in the browser: play a housecarl or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta name="twitter:description" content="Hnefatafl, the Viking board game, free in the browser. Play a housecarl or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta name="twitter:image" content="https://tafl.kestrelsnest.social/og.png" />
%sveltekit.head%
</head>
+126
View File
@@ -0,0 +1,126 @@
<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.
import { onMount } from 'svelte';
interface Announcement {
/** Names this announcement, so dismissing it does not hide the next. */
id: string;
text: string;
/** When what it announces happens, told in each reader's own time. */
at?: string;
/** After this it is no longer shown. */
until?: string;
}
/** An empty file, `{}`, is how the keeper takes an announcement down. */
function isAnnouncement(a: unknown): a is Announcement {
if (!a || typeof a !== 'object') return false;
const { id, text } = a as Partial<Announcement>;
return typeof id === 'string' && typeof text === 'string' && text !== '';
}
const SOURCE = '/hall/announcement.json';
const CHECK_MS = 5 * 60_000;
const DISMISSED = 'hall:dismissed';
let current = $state<Announcement | null>(null);
let dismissed = $state<string | null>(null);
let now = $state(Date.now());
async function check() {
try {
const res = await fetch(SOURCE, { cache: 'no-store' });
const a: unknown = res.ok ? await res.json() : null;
current = isAnnouncement(a) ? a : null;
} catch {
// No announcement reachable: none shown.
}
now = Date.now();
}
onMount(() => {
try {
dismissed = localStorage.getItem(DISMISSED);
} catch {
// Without storage a dismissal lasts this visit.
}
void check();
const poll = setInterval(check, CHECK_MS);
const tick = setInterval(() => (now = Date.now()), 30_000);
const looked = () => {
if (document.visibilityState === 'visible') void check();
};
document.addEventListener('visibilitychange', looked);
return () => {
clearInterval(poll);
clearInterval(tick);
document.removeEventListener('visibilitychange', looked);
};
});
const shown = $derived(current && current.id !== dismissed && !(current.until && Date.parse(current.until) < now) ? current : null);
/** When it happens, in the reader's own clock: "in 25 minutes, at 7:00 AM", or the day and time further off. */
const when = $derived.by(() => {
const t = shown?.at ? Date.parse(shown.at) : NaN;
if (!Number.isFinite(t)) return '';
const minutes = Math.round((t - now) / 60_000);
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
return minutes >= -15 ? ' (now)' : '';
});
function dismiss() {
// Read before dismissing: once dismissed, nothing is shown.
const id = shown?.id;
if (!id) return;
dismissed = id;
try {
localStorage.setItem(DISMISSED, id);
} catch {
// The dismissal still holds for this visit.
}
}
</script>
{#if shown}
<div class="hall-announcement" role="status">
<span>{shown.text}{when}</span>
<button type="button" aria-label="Dismiss this announcement" onclick={dismiss}>×</button>
</div>
{/if}
<style>
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
.hall-announcement {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.45rem 2.4rem 0.45rem 1rem;
background: #f2c45a;
color: #1d1a12;
font: 500 0.95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif;
text-align: center;
box-shadow: 0 1px 4px #0005;
}
button {
position: absolute;
right: 0.5rem;
background: none;
border: 0;
color: inherit;
font-size: 1.3rem;
line-height: 1;
padding: 0.2rem 0.5rem;
cursor: pointer;
}
</style>
+3 -2
View File
@@ -106,13 +106,14 @@
const side = sideOfPly(ply);
const yours = !!mySide && mySide === side;
const who = yours ? 'You' : nameOfSide(side);
const theirs = !!mySide && !yours;
const moverPiece = ply === g.moves.length ? (g.cells[m.to] ?? '.') : '.';
const moverWord = moverPiece !== '.' ? pieceWord(moverPiece) : side === 'attackers' ? 'an attacker' : 'a piece';
let text = `${who}: ${moverWord} from ${label(m.from)} to ${label(m.to)}.`;
let text = `${who} moved ${moverWord} from ${label(m.from)} to ${label(m.to)}.`;
for (const c of m.captured) {
const anvil = beyond(size, m.to, c);
const kingFell = side === 'attackers' && !g.cells.includes('k') && ply === g.moves.length;
const victim = kingFell ? 'the king' : side === 'attackers' ? (yours ? 'a defender' : 'your defender') : yours ? 'an attacker' : 'your attacker';
const victim = kingFell ? 'the king' : side === 'attackers' ? (theirs ? 'your defender' : 'a defender') : theirs ? 'your attacker' : 'an attacker';
const against =
anvil === -1
? 'the edge'
+34 -19
View File
@@ -2,6 +2,7 @@
// The hall: the landing page every game in the kit shares. Name, play the
// bot, open a table, join by code, the ledger of your tables, and the
// about panel with the rules, the guide and a way to reach the keeper.
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { loadFamily, type FamilyGame } from '$lib/family';
import About from './About.svelte';
@@ -29,7 +30,7 @@
let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false);
$effect(() => {
onMount(() => {
if (location.hash === '#about') aboutOpen = true;
});
/** The ledgers' tally, fetched when the about panel opens. */
@@ -43,21 +44,20 @@
const told = $derived.by(() => {
const t = tally;
if (!t) return null;
const headline = [
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' },
{ n: t.byGame["won by the king's escape"] ?? 0, label: "won by the king's escape" },
{ n: t.byGame['won by taking the king'] ?? 0, label: 'won by taking the king' },
{ n: hours(t.minutesAtTable), label: 'at the table' }
];
const told = new Set<string>(["won by the king's escape", 'won by taking the king']);
const onCards = new Set<string>(["won by the king's escape", 'won by taking the king']);
const details = [
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
{ n: t.gamesWithBot, label: `games with a housecarl seated` },
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a housecarl seated' : 'games with a housecarl seated' },
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label }))
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
];
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
@@ -146,7 +146,14 @@
if (c) void goto(`/join/${c}`);
}
/** A game in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
let forgetting = $state<string | null>(null);
function forget(roomId: string) {
if (!games[roomId]?.over && forgetting !== roomId) {
forgetting = roomId;
return;
}
forgetting = null;
forgetSeat(roomId);
seats = allSeats();
}
@@ -155,7 +162,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} (a housecarl)` : s.name));
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a housecarl stands in)` : s.bot ? `${s.name} (a housecarl)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.over) {
if (g.over.winner === held.seat) return 'you won';
@@ -195,9 +202,9 @@
<section class="lid">
<div class="lid-head">
<h1>Hnefatafl</h1>
<p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p>
<p class="tag">Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach safety. Play a housecarl, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p>
<p class="tag">Hnefatafl was played across the Norse world for centuries before chess arrived. Every piece moves like a rook and is taken by being caught between two enemies. The attackers must capture the king, and the king must reach safety. Play a housecarl, or open a table for a friend and play by turns, by Copenhagen rules on the big board or as Tablut, the older game Linnaeus wrote down in 1732.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
</div>
@@ -231,7 +238,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" 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" />
@@ -253,7 +260,11 @@
<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="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
{/if}
</div>
{/each}
{#if reports.length}
@@ -293,9 +304,9 @@
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
{#snippet aboutGame()}
<div class="about-body">
<p>Hnefatafl is the tafl game of the Vikings, carried by them from Scandinavia to Britain, Ireland, Iceland and beyond, and played until chess arrived in the eleventh and twelfth centuries. No rules from that age survive; the game was reconstructed in modern times from a Sámi cousin, Tablut, whose rules the botanist Carl Linnaeus jotted down on his journey through Lapland in 1732.</p>
<p>This table plays Copenhagen, the modern tournament ruleset, on the eleven-by-eleven board, and Tablut on the nine-by-nine one, with the older board showing its age. The rules it follows are on the <a href="/rules">rules page</a>. The game is in the public domain; the board and pieces here are drawn, not borrowed.</p>
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.</p>
<p>Hnefatafl is the tafl game of the Vikings, carried by them from Scandinavia to Britain, Ireland, Iceland and beyond, and played until chess arrived in the eleventh and twelfth centuries. No rules from that age survive. The game was reconstructed in modern times from a Sámi cousin, Tablut, whose rules the botanist Carl Linnaeus jotted down on his journey through Lapland in 1732.</p>
<p>This table plays Copenhagen, the modern tournament ruleset, on the eleven-by-eleven board, and Tablut on the nine-by-nine one, with the older board showing its age. The rules it follows are on the <a href="/rules">rules page</a>. The game is in the public domain, and the board and pieces here were drawn for this site.</p>
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move.</p>
</div>
{/snippet}
{#snippet aboutTally()}
@@ -315,18 +326,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 nothing is held back without it.
</p>
<p class="made">Hnefatafl belongs to everyone; it is a thousand years old.</p>
<p class="made">Hnefatafl belongs to everyone. It is more than a thousand years old.</p>
</div>
{/snippet}
{#snippet aboutWord()}
<div class="about-body">
<p>A rule read wrong, a bug, a game you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
<p>If a rule reads wrong, something breaks, or you have a game to tell of, write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
</div>
{/snippet}
<About bind:open={aboutOpen} title="About Hnefatafl" tabs={[{ id: 'game', label: 'The game', body: aboutGame }, { id: 'tally', label: 'The tally', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
<About bind:open={aboutOpen} title="About Hnefatafl" tabs={[{ id: 'game', label: 'Origins', body: aboutGame }, { id: 'tally', label: 'Games so far', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
</section>
<style>
@@ -478,6 +489,11 @@
background: rgba(240, 165, 58, 0.08);
}
.forget-ask {
font-size: 0.85em;
flex: none;
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
@@ -627,5 +643,4 @@
margin: 0 auto;
border-radius: 4px;
}
</style>
+11 -5
View File
@@ -5,6 +5,8 @@
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
const v = $derived(room.view);
/** The host asked to begin while a seat is still held for someone on their way. */
let beginAsked = $state(false);
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
@@ -35,7 +37,7 @@
{#if room.seatEmpty}
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
<p>The table is full, so you watch from the Peanut Gallery. The game begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {v.roomId}</p>
@@ -60,15 +62,19 @@
<li class="empty muted">{v.size - v.seats.length - v.expected.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{v.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
</ul>
{#if v.rematchOf}
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
<p class="muted">This is a rematch of table {v.rematchOf}, with seats held for those not yet back.</p>
{/if}
{#if !room.spectating}
<div class="ways">
{#if room.seatEmpty && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a housecarl</button>
{/if}
{#if room.isHost}
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => room.begin()}>Begin</button>
{#if room.isHost && beginAsked && v.expected.length > 0}
<span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span>
<button type="button" class="commit 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="commit small" disabled={v.seats.length < 2} onclick={() => (v.expected.length > 0 ? (beginAsked = true) : room.begin())}>Begin</button>
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
{:else}
<span class="muted">{hostName} will begin when the table is ready.</span>
@@ -86,7 +92,7 @@
<p class="muted small note">{v.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 {v.keeper} lives.{/if} The seat is held either way.</p>
</div>
{:else if v.challenge}
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.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 housecarl, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.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 housecarl, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
{/if}
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
+3 -3
View File
@@ -59,7 +59,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 round. 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>
@@ -67,14 +67,14 @@
</label>
<label class="field">
<span>What did you expect?</span>
<textarea bind:value={expected} rows="2" maxlength="2000" placeholder="e.g. a point for me"></textarea>
<textarea bind:value={expected} rows="2" maxlength="2000" placeholder="e.g. the defender on e5 taken"></textarea>
</label>
<label class="field picture">
<span>A screenshot, if you have one</span>
<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 note">The room code and the round ride along; the keeper can replay the game to this moment.</p>
<p class="muted small note">The room code and the turn are sent with it, so the keeper can replay the game to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
// The host's hand on the other seats during a game: a transfer phrase for a
// player whose browser lost their seat, and a housecarl to play the seat of one
// who cannot go on, until a phrase gives it back.
import type { SeatId } from '$lib/game/spec';
import type { Room } from '$lib/net/room.svelte';
let { room, open = $bindable(false) }: { room: Room; open?: boolean } = $props();
const people = $derived(room.view.seats.filter((s) => s.id !== room.me && (!s.bot || s.standIn)));
let asked = $state<SeatId | 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: SeatId, name: string) {
error = '';
try {
const made = await room.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: SeatId) {
asked = null;
if (!(await room.standIn(seat))) error = room.error;
}
function phraseTitle(name: string, standIn: boolean): string {
const why = standIn ? `Brings ${name} back from the housecarl.` : `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 table, 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 player back to a seat their browser lost, or give the seat of one who cannot go on to a housecarl, who plays it at once.</p>
{#if people.length === 0}
<p class="muted">Nobody else at this table is a person.</p>
{/if}
<ul>
{#each people as s (s.id)}
<li>
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a housecarl plays 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 && !room.view.over}
{#if asked === s.id}
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a housecarl plays for {s.name}</button>
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
{:else}
<button type="button" class="quiet" title="A housecarl plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a housecarl</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 table: <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>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
.slip {
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>
+2 -2
View File
@@ -47,7 +47,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 === room.me}><em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
<li class:you={!line.name && line.id === room.me}><em>{line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
{/each}
</ul>
{#if room.spectating && room.view.galleryTalk}
@@ -57,7 +57,7 @@
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form>
{:else if room.spectating}
<p class="muted small note">The gallery listens; the host has not let it talk.</p>
<p class="muted small note">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" />
+4 -4
View File
@@ -7,10 +7,10 @@
</script>
<script lang="ts">
// What the ledgers add up to, told as Wiz-War tells it: 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 } =
// 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 }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title: string } =
$props();
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
+7 -5
View File
@@ -1,7 +1,7 @@
// 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.
// (family.json, one copy shipped with every game): the hall's front door, and
// the next table for a player who liked this one. This game is left out of its
// own list.
export interface FamilyGame {
slug: string;
@@ -18,13 +18,15 @@ export interface Family {
games: FamilyGame[];
}
const NONE: Family = { hall: '', games: [] };
export async function loadFamily(self: string): Promise<Family> {
try {
const res = await fetch('/family.json', { cache: 'no-cache' });
if (!res.ok) return { hall: '', games: [] };
if (!res.ok) return NONE;
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
} catch {
return { hall: '', games: [] };
return NONE;
}
}
+2 -2
View File
@@ -46,7 +46,7 @@ export interface State {
const SIDES: { value: Side; label: string; note: string }[] = [
{ value: 'defenders', label: 'the defenders, with the king', note: 'Fewer pieces, and the king must reach safety before the ring closes.' },
{ value: 'attackers', label: 'the attackers', note: 'Twice the pieces, and the first move; close every road and take the king.' }
{ value: 'attackers', label: 'the attackers', note: 'Twice the pieces and the first move. Close every road and take the king.' }
];
/** The sentences a game ends on. The tally counts finished games by them, so they are written here and nowhere else. */
@@ -208,7 +208,7 @@ export const game: GameSpec<State, Input> = {
label: 'Rules',
choices: [
{ value: 'tablut', label: 'Tablut, 9 by 9, after Linnaeus', note: 'The older game. The king escapes to any edge square, and away from the throne two attackers take him like any piece.' },
{ value: 'copenhagen', label: 'Copenhagen, 11 by 11', note: 'The modern tournament game. The king escapes to a corner and falls only to four attackers, three beside the throne.' }
{ value: 'copenhagen', label: 'Copenhagen, 11 by 11', note: 'The modern tournament game. The king escapes to a corner and falls only to four attackers, or three beside the throne.' }
],
default: DEFAULT_SET
},
+9
View File
@@ -19,6 +19,12 @@ function heldSeats(): Record<string, HeldSeat> {
}
}
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
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;
}
@@ -140,6 +146,9 @@ export function makeApi<State, Input>() {
rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: SeatId | 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 person's seat; claimed, it re-seats them. */
hostPhrase: (roomId: string, token: string, seat: SeatId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
standIn: (roomId: string, token: string, seat: SeatId) => call<View>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; 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 }),
+17 -2
View File
@@ -5,7 +5,7 @@
import { game } from '$lib/game';
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
import type { Input, State } from '$lib/game';
import { makeApi, rememberSeat, watchRoom, type HeldSeat } from './client';
import { makeApi, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk';
import type { RoomView } from './view';
@@ -38,6 +38,8 @@ export class Room {
error = $state('');
/** A move is on its way to the server. */
sending = $state(false);
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
lost = $state('');
constructor(view: RoomView<State>, token: string) {
this.view = view;
@@ -72,7 +74,10 @@ export class Room {
const refresh = () => {
fetchView()
.then((v) => this.apply(v))
.catch((e: Error) => (this.error = e.message));
.catch((e: Error) => {
if (e instanceof ServerError && e.status === 403 && !this.spectating) this.lost = e.message;
else this.error = e.message;
});
};
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
}
@@ -134,6 +139,16 @@ export class Room {
return api.transfer(this.roomId, this.token);
}
/** Host only: a phrase for another person's seat, which gives it a new token when claimed. */
hostPhrase(seat: SeatId): Promise<{ phrase: string; expiresAt: number }> {
return api.hostPhrase(this.roomId, this.token, seat);
}
/** Host only: a housecarl plays this person's seat for the rest of the game, until a phrase gives it back. */
standIn(seat: SeatId): Promise<boolean> {
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a housecarl.');
}
async submit(input: Input): Promise<boolean> {
this.sending = true;
try {
+4 -1
View File
@@ -9,6 +9,8 @@ export interface ChatLine {
text: string;
at: number;
name?: string;
/** Said by the keeper of the site, through the keeper's door. */
keeper?: true;
}
export interface RoomView<State> {
@@ -20,7 +22,8 @@ export interface RoomView<State> {
me: SeatId;
/** The player who opened the table and may begin the game. */
host: SeatId;
seats: { id: SeatId; name: string; bot: boolean }[];
/** Who sits where; `standIn` marks a person's seat the host gave to a bot, which plays it until they take it back. */
seats: { id: SeatId; name: string; bot: boolean; standIn?: true }[];
/** Seats that still have to move before the round resolves. */
waitingOn: SeatId[];
/** Rounds played so far; a move is sent with the count it was made against. */
+2
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import '../app.css';
import Announcement from '$lib/components/Announcement.svelte';
let { children } = $props();
</script>
<Announcement />
{@render children()}
+15 -14
View File
@@ -1,5 +1,5 @@
<script lang="ts">
// How to play: a walk through the page, from the hall to a first game.
// How to play: the screen, from the hall to a first game.
import { page } from '$app/state';
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
@@ -15,9 +15,9 @@
<header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
<h1>How to play</h1>
<p class="lede">A walk through the page, from the hall to a first game. The game's own rules are on <a href={withRoom('/rules')}>the rules page</a>.</p>
<p class="lede">You hold one side of a Viking battle. The defenders guard a king who wants to slip out to safety, and the attackers ring them round and try to take him. This guide goes through the screen from the hall to your first game. The game's own rules are on <a href={withRoom('/rules')}>the rules page</a>.</p>
<nav>
<a href="#hall">The hall</a> · <a href="#table">The table</a> · <a href="#board">The board</a> · <a href="#capture">A capture</a> · <a href="#gallery">The Peanut Gallery and table talk</a> ·
<a href="#hall">The hall</a> · <a href="#table">The table</a> · <a href="#board">The board</a> · <a href="#capture">How a capture works</a> · <a href="#gallery">The Peanut Gallery and table talk</a> ·
<a href="#first">A first game</a>
</nav>
</header>
@@ -25,7 +25,7 @@
<section id="hall">
<h2>The hall</h2>
<p>
Give your name and choose the rules: <em>Copenhagen</em>, the balanced modern game on the big board, or <em>Tablut</em>, the smaller and older one. Choose which side you play.
Give your name and choose the rules. <em>Copenhagen</em> is the balanced modern game on the big board, and <em>Tablut</em> is the smaller and older one. Then choose which side you play.
<em>Play now against a housecarl</em> seats a housecarl opposite you and begins. <em>Open a table</em> gives you a four-letter code and a link for a friend. The tables this browser
holds a seat at are listed below, with whose move it is.
</p>
@@ -35,16 +35,17 @@
<h2>The table</h2>
<p>
Whoever opened the table is its host, and begins the game once a second player has sat or a housecarl has been seated. A friend who opens the link takes the other seat. The
masthead's <em>Transfer seat</em> gives four words that bring your seat to another device, and when a game is over anyone at the table may call for a rematch.
masthead's <em>Transfer seat</em> gives four words that bring your seat to another device, and when a game is over anyone at the table may call for a rematch. During a game the host's <em>Seats</em> makes the same four words for the other player, if their browser loses the seat, and can hand that seat to a housecarl when they cannot go on. Their four words bring them back.
</p>
</section>
<section id="board">
<h2>The board</h2>
<p>
Tap one of your pieces and its reachable squares light up; tap a square to move there. The last move is shaded, a square a piece was just taken from shows red until the next
move, and the strip above the board says whose move it is and what your side is for. The record beside the board lists the moves; click one to see it on the board. The attackers are the dark pieces and move first; the defenders are the pale pieces around the crowned king on the throne. In Copenhagen the four corners are marked:
only the king may enter them, and reaching one wins. In Tablut the board is older and plainer, and the king needs only the edge.
The attackers are the dark pieces, and they move first. The defenders are the pale pieces around the crowned king on the throne. Tap one of your pieces and the squares it
can reach light up, then tap one of those squares to move there. The last move is shaded, and a square a piece was just taken from shows red until the next move. The strip
above the board says whose move it is and what your side is trying to do. The record beside the board lists the moves, and clicking one shows it on the board. In Copenhagen
the four corners are marked. Only the king may enter them, and reaching one wins. In Tablut the board is older and plainer, and the king needs only to reach the edge.
</p>
</section>
@@ -59,7 +60,7 @@
<circle cx="75" cy="15" r="9" fill="#2a2320" stroke="#0d0a08" stroke-width="1.2" />
<path d="M62 4 h11 l-3 -3 m3 3 l-3 3" fill="none" stroke="#9cd0dc" stroke-width="1.4" />
</svg>
<figcaption>The right-hand attacker arrives; the pale defender is caught between two enemies and is taken. Only the mover captures: a piece may step into that gap safely on its own turn.</figcaption>
<figcaption>The right-hand attacker arrives, and the pale defender, caught between two enemies, is taken. Only the piece that moves makes a capture, so a piece can step into such a gap on its own turn and stay safe.</figcaption>
</figure>
</section>
@@ -67,7 +68,7 @@
<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 game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery.
<em>Table talk</em> is for the players seated, unless the host lets the gallery talk; then a watcher signs a name and their lines are marked as the gallery's. Nothing in
<em>Table talk</em> is for the players seated, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. Nothing in
hnefatafl is hidden, so the gallery sees the whole board.
</p>
</section>
@@ -75,13 +76,13 @@
<section id="first">
<h2>A first game</h2>
<p>
Play the defenders first. The king wants an open row or column to a corner; the attackers want to close every road while they close in. Pieces are taken by being sandwiched,
never by moving between two enemies yourself, so a piece can step into a gap safely. Keep the king off the edge in Copenhagen until a corner is in reach, and remember that the
attackers cannot take him against the edge: a king on the edge with his pieces around him is a fort.
Play the defenders first. The king wants an open row or column to a corner, and the attackers want to block every road while they close in. In Copenhagen, keep the king off
the edge until a corner is in reach. The attackers cannot take him against the edge, though, and a king on the edge, walled in by his own pieces with a move still to make,
wins the game as an edge fort.
</p>
</section>
<p class="word">Something on this page behave unexpectedly? The <em>Report</em> button in a room's masthead tells the keeper, pinned to the moment.</p>
<p class="word">If something behaves unexpectedly, the <em>Report</em> button in a room's masthead tells the keeper, along with the moment it happened.</p>
</main>
<style>
+92 -8
View File
@@ -4,9 +4,10 @@
import Board from '$lib/components/Board.svelte';
import Lobby from '$lib/components/Lobby.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte';
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
import Preferences from '$lib/components/Preferences.svelte';
import { seatFor } from '$lib/net/client';
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase());
@@ -21,20 +22,53 @@
/** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false);
let seatsOpen = $state(false);
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
let seatLost = $state('');
let claimPhrase = $state('');
let claiming = $state(false);
const link = $derived(`${location.origin}/join/${roomId}`);
const watching = $derived(room?.spectating ?? false);
// Return to a held seat, or take a place in the gallery and offer one.
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
// still opens, so a link someone else sends cannot take a working seat away.
$effect(() => {
const id = roomId;
room = null;
error = '';
const sent = seatInLink(location.hash);
// The browser's own call: this runs as the page first loads, before the router could take one.
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
const held = seatFor(id);
const arrive = held ? Room.resume(id, held) : Room.watch(id);
joining = !held;
const take = (s: HeldSeat) =>
Room.resume(id, s).then((r) => {
rememberSeat(id, s);
trustSeat(id);
return r;
});
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
// A held seat the server refuses, with no link to replace it, is watched from the gallery, with the reason.
const watchRefused = (e: Error) => {
seatLost = e.message;
return Room.watch(id);
};
seatLost = '';
const arrive = held
? Room.resume(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
: sent
? take(sent).catch(() => Room.watch(id))
: Room.watch(id);
joining = !held && !sent;
if (!held) name = playerName();
arrive.then((r) => (room = r)).catch((e: Error) => (error = e.message));
arrive
.then((r) => {
room = r;
if (r.spectating) joining = true;
})
.catch((e: Error) => (error = e.message));
});
// Keep the view fresh for as long as the room is on screen.
@@ -44,6 +78,36 @@
return r.connect();
});
// The seat stopped opening while the page was open (the host gave it to a housecarl): watch on from the gallery.
$effect(() => {
const r = room;
if (!r?.lost) return;
seatLost = r.lost;
Room.watch(r.roomId)
.then((w) => (room = w))
.catch((e: Error) => (error = e.message));
});
/** Sit back down by a phrase: the host's, or one this seat minted 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 = await Room.resume(roomId, { seat: got.seat, token: got.token });
else await goto(`/join/${got.roomId}`);
} catch (err) {
error = err instanceof Error ? err.message : 'The phrase could not be claimed.';
} finally {
claiming = false;
}
}
async function takeSeat(e: SubmitEvent) {
e.preventDefault();
busy = true;
@@ -119,6 +183,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.started}
<button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a housecarl" onclick={() => (seatsOpen = true)}>Seats</button>
{/if}
{/if}
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
@@ -133,6 +200,7 @@
<Preferences bind:open={prefsOpen} />
{#if room}
<ReportSlip {room} bind:open={reporting} />
{#if room.isHost}<SeatsSlip {room} bind:open={seatsOpen} />{/if}
{/if}
{#if error}
@@ -150,7 +218,7 @@
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
{:else}
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch 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}
@@ -159,7 +227,7 @@
<section class="seat">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.</p>
<p>A player has opened this table and is gathering company. Choose a name and sit down. The game begins when the host says.</p>
<form class="sit" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
@@ -171,6 +239,22 @@
</section>
{/if}
{#if room?.started && !seatLost && room.view.seats.some((s) => s.standIn)}
<p class="notice muted">A housecarl plays for {room.view.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?.started && !room.view.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.view.expected.length > 0}<p>{room.nameOf(room.view.host)} began the game before {room.view.expected.join(' and ')} arrived, and the seat held for {room.view.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
<p>Had a seat at this table? 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="sit" 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 && !room.started}
<Lobby {room} {link} {copied} {copyLink} />
{:else if room}
+16 -16
View File
@@ -17,10 +17,10 @@
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
<h1>The rules</h1>
<p class="lede">
Hnefatafl is the tafl family's best-known member: a Viking board game played across Scandinavia and the North Atlantic from the fourth century until chess displaced it. No
Hnefatafl is the best-known member of the tafl family, a Viking board game played across Scandinavia and the North Atlantic from the fourth century until chess displaced it. No
medieval rules survive. This table plays <em>Copenhagen</em>, the ruleset settled by the Copenhagen Hnefatafl Club in 2012 and used for tournament play since, on the
eleven-by-eleven board. It also offers <em>Tablut</em>, the nine-by-nine Sámi game whose rules Carl Linnaeus wrote down in his Lapland journal in 1732: the only version of
the game with a historical rules text, gaps and all.
eleven-by-eleven board. It also offers <em>Tablut</em>, the nine-by-nine Sámi game whose rules Carl Linnaeus wrote down in his Lapland journal in 1732. Tablut is the only
version of the game with a historical rules text, gaps and all.
</p>
</header>
@@ -36,8 +36,8 @@
<h2>Moving</h2>
<p>
Every piece moves like a rook in chess: any number of empty squares along a row or column, never diagonally, never over another piece. Only the king may stop on the throne, and
in Copenhagen only the king may enter the four marked corners; in Tablut the corners are ordinary squares. In Copenhagen an ordinary piece may pass across the empty throne;
in Tablut it may not.
in Copenhagen only the king may enter the four marked corners. In Tablut the corners are ordinary squares. In Copenhagen an ordinary piece may pass across the empty throne,
and in Tablut it may not.
</p>
</section>
@@ -45,16 +45,16 @@
<h2>Capturing</h2>
<p>
A piece is captured when the moving piece closes it between itself and another enemy piece on the opposite side, along a row or column. A piece may safely move between two
enemies: only the mover captures. The empty throne stands in for an enemy piece, and in Copenhagen so do the four marked corners, so a piece pinned against one of them by a single enemy is taken. The
enemies, because only the moving side captures. The empty throne stands in for an enemy piece, and in Copenhagen so do the four marked corners, so a piece pinned against one of them by a single enemy is taken. The
king takes part in captures like any piece.
</p>
<p>
<strong>The king</strong> is harder to take. In Copenhagen he falls only when surrounded on all four sides by attackers, or on three sides with the empty throne as the
fourth; on the throne itself he needs all four. He cannot be captured against the edge of the board. In Tablut, away from the throne he is taken between two attackers like
any piece; on the throne he needs four, and beside it three with the throne as the fourth.
fourth. On the throne itself he needs all four. He cannot be captured against the edge of the board. In Tablut, away from the throne he is taken between two attackers like
any piece. On the throne he needs four, and beside it three with the throne as the fourth.
</p>
<p>
<strong>Shieldwall</strong> (Copenhagen only): two or more pieces standing along the edge are captured together when the moving piece closes the row at one end, an enemy or a
<strong>Shieldwall</strong> (Copenhagen only). Two or more pieces standing along the edge are captured together when the moving piece closes the row at one end, an enemy or a
corner closes the other, and each piece in the row has an enemy directly in front of it. The king standing in such a row is not taken, but the pieces beside him are.
</p>
</section>
@@ -62,25 +62,25 @@
<section id="winning">
<h2>Winning</h2>
<ul>
<li>The king reaches a corner (Copenhagen) or the edge (Tablut): the defenders win.</li>
<li>The king is captured: the attackers win.</li>
<li>The defenders win when the king reaches a corner (Copenhagen) or the edge (Tablut).</li>
<li>The attackers win when the king is captured.</li>
<li>
<strong>Edge fort</strong> (Copenhagen): the king stands on the edge, walled in by his own pieces so that no attacker can reach him, and still has a move. The defenders
<strong>Edge fort</strong> (Copenhagen). The king stands on the edge, walled in by his own pieces so that no attacker can reach him, and still has a move. The defenders
win.
</li>
<li>
<strong>Encirclement</strong> (Copenhagen): the attackers have closed a ring around every remaining defender and the king, so that none of them can ever reach an edge. The
<strong>Encirclement</strong> (Copenhagen). The attackers have closed a ring around every remaining defender and the king, so that none of them can ever reach an edge. The
attackers win.
</li>
<li>A side with no legal move loses.</li>
<li>Perpetual repetition is forbidden: when a position stands for the third time, the defenders lose.</li>
<li>Perpetual repetition is forbidden. When a position stands for the third time, the defenders lose.</li>
</ul>
</section>
<section id="tablut">
<h2>Tablut, after Linnaeus</h2>
<p>
Linnaeus's notes are in Latin, incomplete, and were mistranslated for two centuries; the confusion produced the edge-escape and corner-escape families of modern hnefatafl.
Linnaeus's notes are in Latin, incomplete, and were mistranslated for two centuries.
This table follows the reading now common among reconstructions: nine by nine; sixteen attackers, whom Linnaeus called the Muscovites, against eight defenders, the Swedes,
and their king; the king escapes by reaching any edge square; no ordinary piece may enter or cross the throne; the empty throne is hostile to both sides; the king is armed;
and away from the throne the king is captured by two attackers, like any piece. There are no shieldwalls, edge forts or encirclement in Tablut.
@@ -117,7 +117,7 @@
</p>
</section>
<p class="word">If this page reads a rule differently from the text it follows, that is a bug: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
<p class="word">If this page reads a rule differently from the text it follows, that is a bug. Please <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
</main>
<style>
+3 -3
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,7 +30,7 @@
"slug": "dip",
"name": "Dip",
"url": "https://dip.kestrelsnest.social",
"pitch": "Seven powers. One map. Nobody has to keep their word.",
"pitch": "Seven powers share one map, and none of them has to keep its word.",
"players": "2 to 7",
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
}
+1 -1
View File
@@ -21,6 +21,6 @@ export default defineConfig({
}
},
test: {
include: ['src/**/*.test.ts']
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
}
});