Compare commits
15
Commits
cc9d7330aa
..
main
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable
+33
@@ -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
|
||||
@@ -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
|
||||
Executable
+18
@@ -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.$$
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+76
-1
@@ -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}`);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
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';
|
||||
@@ -28,7 +35,7 @@
|
||||
try {
|
||||
const res = await fetch(SOURCE, { cache: 'no-store' });
|
||||
const a: unknown = res.ok ? await res.json() : null;
|
||||
current = a && typeof a === 'object' && typeof (a as Announcement).text === 'string' && (a as Announcement).text && typeof (a as Announcement).id === 'string' ? (a as Announcement) : null;
|
||||
current = isAnnouncement(a) ? a : null;
|
||||
} catch {
|
||||
// No announcement reachable: none shown.
|
||||
}
|
||||
@@ -68,7 +75,7 @@
|
||||
});
|
||||
|
||||
function dismiss() {
|
||||
// Taken first: once dismissed, nothing is shown.
|
||||
// Read before dismissing: once dismissed, nothing is shown.
|
||||
const id = shown?.id;
|
||||
if (!id) return;
|
||||
dismissed = id;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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} 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} 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} 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// What the ledgers add up to: a few counts that are the game's own story,
|
||||
// large, on cards; then the rest in a column of figures; then the fine
|
||||
// print. Each game chooses its counts and words.
|
||||
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
|
||||
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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -21,6 +21,6 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts']
|
||||
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user