Production: the tafl droplet at 209.97.148.244, tafl.kestrelsnest.social, and the Sentry project

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 13:17:09 -04:00
co-authored by Claude Fable 5.1
parent 0d2f54fe02
commit fc62034290
13 changed files with 29 additions and 27 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ description: The weekly Hnefatafl operations pulse — game server health, error
One command on the droplet prints everything; read it as a short report.
ssh root@__IP__ hnefatafl-pulse.sh
ssh root@209.97.148.244 hnefatafl-pulse.sh
- **game server**: whether the service and Caddy are up, restarts and
journal errors in the last seven days with the last three error lines.
+5 -5
View File
@@ -7,7 +7,7 @@ description: Work the Hnefatafl reports desk — fetch players' bug reports, sho
Players file reports from the Report button in a room's masthead (seated
or from the gallery). Each lands in `/var/lib/hnefatafl/feedback.jsonl`
on the droplet (__IP__) pinned with `roomId`, `turn` (the turn
on the droplet (209.97.148.244) pinned with `roomId`, `turn` (the turn
being written when it was filed) and `seq` (the ledger's length then),
enough to replay the game to the moment. Replies live in the same file
and appear under the report in the player's hall. This desk fetches,
@@ -34,7 +34,7 @@ Every report and every player's answer rings a Sentry issue (project
hnefatafl, fingerprinted per report or per line); the desk's own
replies ring nothing.
Fetch: `ssh root@__IP__ 'cat /var/lib/hnefatafl/feedback.jsonl'`
Fetch: `ssh root@209.97.148.244 'cat /var/lib/hnefatafl/feedback.jsonl'`
## a) Fetch and b) display
@@ -47,7 +47,7 @@ to hear his players' voices; never compress their words into a table.
## c) Process an unanswered report
1. **Replay to the pin.** `scp root@__IP__:/var/lib/hnefatafl/rooms/<roomId>.jsonl <scratchpad>/`
1. **Replay to the pin.** `scp root@209.97.148.244:/var/lib/hnefatafl/rooms/<roomId>.jsonl <scratchpad>/`
and replay it with the engine as `deploy/replay-ledgers.ts` does:
`game.create(names, start.seed, start.rules ?? 1)` then
`game.resolve(state, line.inputs)` per turn line, printing the state
@@ -70,8 +70,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 __IP__`.
5. **Reply.** `bash deploy/report-reply.sh __IP__ <reportId> <status> "text"`.
corrected behaviour. Deploy with `deploy/deploy.sh 209.97.148.244`.
5. **Reply.** `bash deploy/report-reply.sh 209.97.148.244 <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
was. The desk's voice is warm and specific.
+1 -1
View File
@@ -10,7 +10,7 @@ host glancing over the hall.
## Gather
ssh root@__IP__ hnefatafl-visitors.sh [YYYY-MM-DD]
ssh root@209.97.148.244 hnefatafl-visitors.sh [YYYY-MM-DD]
Default is today, UTC; Eric is in US Eastern, so an evening at his desk
spills into the next UTC day. Run yesterday too when the hour is early.
+6 -6
View File
@@ -21,13 +21,13 @@ uptime and upgrades independent of anything else.
## Current production
- Droplet: `hnefatafl` (nyc3, s-1vcpu-512mb-10gb, tag `hnefatafl`), IP __IP__
- URLs: https://hnefatafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's
DNS lives) and https://hnefatafl.__IP__.sslip.io (always works, zero DNS).
- Everyday deploy: `deploy/deploy.sh __IP__`
- Droplet: `hnefatafl` (nyc3, s-1vcpu-512mb-10gb, tag `hnefatafl`), IP 209.97.148.244
- URLs: https://tafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's
DNS lives) and https://hnefatafl.209.97.148.244.sslip.io (always works, zero DNS).
- Everyday deploy: `deploy/deploy.sh 209.97.148.244`
- 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 __IP__ <id> <status> "text"` answers one.
`deploy/report-reply.sh 209.97.148.244 <id> <status> "text"` answers one.
## New droplet from scratch
@@ -35,7 +35,7 @@ uptime and upgrades independent of anything else.
--size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \
--ssh-keys <your-key-ids> --tag-name hnefatafl --wait`
2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
"bash /root/setup-droplet.sh 'hnefatafl.kestrelsnest.social, hnefatafl.<ip>.sslip.io'"`
"bash /root/setup-droplet.sh 'tafl.kestrelsnest.social, hnefatafl.<ip>.sslip.io'"`
(point the A record at the new IP first, or leave the real name out until it is).
3. `scp deploy/setup-server.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> "bash /root/setup-server.sh"`
4. `deploy/deploy.sh <ip>`
+1 -1
View File
@@ -57,7 +57,7 @@ for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
elif path == "/guide": pages["guide"] += 1
elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1
ref = " ".join(h.get("Referer", [""]))
if ref and "hnefatafl.kestrelsnest.social" not in ref:
if ref and "tafl.kestrelsnest.social" not in ref:
refs[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
if m and (ip, m.group(1)) not in seen_campaign:
+1 -1
View File
@@ -13,7 +13,7 @@ Environment=KEEPER_TZ=America/New_York
Environment=HOST=127.0.0.1
Environment=DATA_DIR=/var/lib/hnefatafl/rooms
# Create the Sentry project (the Sentry MCP can) and paste its DSN here; empty means no error reporting.
Environment=SENTRY_DSN=__SENTRY_DSN__
Environment=SENTRY_DSN=https://2e3589c41ea45a58049740d3af64face@o4509525984149504.ingest.us.sentry.io/4512136791130112
ExecStart=/opt/hnefatafl/app/server/node_modules/.bin/tsx src/index.ts
Restart=always
RestartSec=3
+1 -1
View File
@@ -3,7 +3,7 @@
# to a local folder, and write a digest beside them for reading.
# deploy/pull-reports.sh [host] [folder] (default ~/Desktop/hnefatafl-reports)
set -euo pipefail
HOST="${1:-__IP__}"
HOST="${1:-209.97.148.244}"
OUT="${2:-$HOME/Desktop/hnefatafl-reports}"
mkdir -p "$OUT/images"
scp -q "root@$HOST:/var/lib/hnefatafl/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl"
+2 -2
View File
@@ -19,9 +19,9 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN"
# The Slack action takes the channel's ID as well as its name; the
# project's error detector is looked up by project id; a workflow is bound
# to its detectors by PUT after creation.
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-__SENTRY_SLACK_INTEGRATION__}"
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}"
CHANNEL_ID="${SLACK_CHANNEL_ID:-__SLACK_CHANNEL_ID__}"
PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}"
PROJECT_ID="${SENTRY_PROJECT_ID:-4512136791130112}"
echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)"
DETECTOR_ID=$(curl -s "$API/detectors/?project=$PROJECT_ID" -H "$AUTH" | python3 -c '
+4 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# One-time droplet setup. Copy this script and Caddyfile.tmpl to the droplet
# and run ON the droplet as root:
# bash setup-droplet.sh 'hnefatafl.kestrelsnest.social, hnefatafl.<droplet-ip>.sslip.io'
# bash setup-droplet.sh 'tafl.kestrelsnest.social, hnefatafl.<droplet-ip>.sslip.io'
# The argument is the Caddy site address line: one name, or several
# separated by commas. Every name must already resolve to this droplet.
# Hnefatafl is a static site: Caddy serves the built files and terminates
@@ -28,7 +28,9 @@ chmod -R g+rX /opt/hnefatafl
# Caddy: the site and the game server's paths, from the template beside this script.
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
# Validation as root touches the access log; Caddy runs as caddy and must own it.
chown -R caddy:caddy /var/lib/caddy
systemctl restart caddy
# Firewall: ssh + web only.
ufw allow OpenSSH
+1 -1
View File
@@ -6,7 +6,7 @@
# deploy/verify-ledgers.sh <droplet-ip-or-host> [https://site]
set -euo pipefail
HOST="${1:?usage: verify-ledgers.sh <droplet-ip-or-host> [https://site]}"
SITE="${2:-https://hnefatafl.kestrelsnest.social}"
SITE="${2:-https://tafl.kestrelsnest.social}"
DIR="$(mktemp -d)"
trap 'rm -rf "$DIR"' EXIT
rsync -az "root@$HOST:/var/lib/hnefatafl/rooms/" "$DIR/"
+1 -1
View File
@@ -57,7 +57,7 @@ for path in sorted(glob.glob("/var/lib/caddy/access.log*")):
elif path_only.startswith("/join/"):
joins[path_only.split("/")[2].upper()] += 1
ref = " ".join(req.get("headers", {}).get("Referer", [""]))
if ref and "hnefatafl.kestrelsnest.social" not in ref:
if ref and "tafl.kestrelsnest.social" not in ref:
referrers[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
if m:
+1 -1
View File
@@ -31,7 +31,7 @@ import { Store } from './store';
const PORT = Number(process.env.PORT ?? '8789');
const HOST = process.env.HOST ?? '127.0.0.1';
const DATA_DIR = process.env.DATA_DIR ?? '../data/rooms';
const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://hnefatafl.kestrelsnest.social').replace(/\/$/, '');
const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://tafl.kestrelsnest.social').replace(/\/$/, '');
/** 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';
+4 -4
View File
@@ -7,20 +7,20 @@
<meta name="theme-color" content="#121a20" />
<title>Hnefatafl</title>
<meta name="description" content="Hnefatafl, the Viking board game, free in the browser: play the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<link rel="canonical" href="https://hnefatafl.kestrelsnest.social/" />
<link rel="canonical" href="https://tafl.kestrelsnest.social/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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 the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta property="og:url" content="https://hnefatafl.kestrelsnest.social/" />
<meta property="og:image" content="https://hnefatafl.kestrelsnest.social/og.png" />
<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 the bot or a friend, by Copenhagen rules or Linnaeus's Tablut." />
<meta name="twitter:image" content="https://hnefatafl.kestrelsnest.social/og.png" />
<meta name="twitter:image" content="https://tafl.kestrelsnest.social/og.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">