A credibility pass over the whole repo: the board's mechanics in board.ts, one copy of each helper and stylesheet rule, the kit's plumbing this game never used removed, and the ops scripts counting traffic through one parser
The engine and the bot no longer import each other: geometry, movement
and captures live in src/lib/game/board.ts and both use it. The escape
test, the four directions, the king's neighbours and the enumeration of
a side's moves each exist once; the tally counts by the named end
sentences rather than by regex over them; exports nobody imports are
exports no more. The tests pin the bot's opening move and the three-
beside-the-throne capture they named but never exercised.
In the client: .small, the word-as-button, the × that dismisses, the
visually-hidden rule and the frame of the reading pages are in app.css
once; the preferences panel and the report slip share one modal shape;
the room store drops the simultaneous-round fields this game never read
and gains seatEmpty and seatUnheld, which the lobby and the join page
read instead of three spellings of their own. The wire shapes for
reports and the tally are declared in view.ts for both sides. The
artwork component is re-indented for the top level it lives at.
On the server and in deploy: the plaintext-token fallback and its
migration script guarded ledgers this game never wrote; the seat line
now requires the hash and the start line the rules revision. The route
table lists every route. The visitors digest and the nightly rollup
count Caddy's log through deploy/traffic.py, and the rollup writes the
finished-games count it had been computing behind "and False". The
reports digest is one program, deploy/report-digest.ts, that
pull-reports.sh and the desk skill both use. The deploy README, the
visitors skill and the reports skill no longer describe a browser-only
game, a /play route or a rules text in docs/; conventions.md carries
the kit's Preferences and tally sections.
Kit-shared files touched, to port back: server/src/{index,rooms,store,
tally,reports}.ts, deploy/{deploy.sh,replay-ledgers.ts,pull-reports.sh,
traffic.py,report-digest.ts,*-rollup.sh,*-visitors.sh,*-pulse.sh},
src/lib/net/{client.ts,room.svelte.ts,view.ts}, Preferences.svelte,
ReportSlip.svelte, TableTalk.svelte.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6d041e3a63
commit
ee5690ebda
@@ -31,7 +31,7 @@ handle @game {
|
||||
# <route>.html, so /rules is tried as /rules.html before falling back to the
|
||||
# app shell, which serves the rooms. Hashed assets under _app/immutable are
|
||||
# cached for a year; every other response is revalidated so a deploy shows
|
||||
# up on the next load. The two header matchers are disjoint.
|
||||
# up on the next load.
|
||||
handle {
|
||||
@immutable path /_app/immutable/*
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
+15
-24
@@ -1,12 +1,12 @@
|
||||
# Deploying Hnefatafl
|
||||
|
||||
The single-player game runs entirely in the browser. Duels between people
|
||||
go through a small Node process that keeps each room as an append-only
|
||||
ledger of moves in /var/lib/hnefatafl/rooms. Production is one
|
||||
DigitalOcean droplet: Caddy terminates TLS with automatic certificates,
|
||||
serves the static build from /opt/hnefatafl/build, and proxies /api and
|
||||
/ws to the game server on port 8789, which runs as the `hnefatafl` user
|
||||
under systemd from /opt/hnefatafl/app.
|
||||
Every game, against a housecarl or a friend, is a room on a small Node
|
||||
process that keeps it as an append-only ledger of moves in
|
||||
/var/lib/hnefatafl/rooms. Production is one DigitalOcean droplet: Caddy
|
||||
terminates TLS with automatic certificates, serves the static build from
|
||||
/opt/hnefatafl/build, and proxies /api and /ws to the game server on port
|
||||
8789, which runs as the `hnefatafl` user under systemd from
|
||||
/opt/hnefatafl/app.
|
||||
|
||||
## Sizing
|
||||
|
||||
@@ -14,11 +14,6 @@ The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy
|
||||
and the game server comfortably: the server is one small Node process capped
|
||||
at 300 MB by its unit file, and a room is a few kilobytes of ledger.
|
||||
|
||||
The zero-cost alternative is a second site block in an existing Caddy
|
||||
server's configuration pointing at a second directory; the deploy script
|
||||
works unchanged against that host. A droplet of its own keeps this site's
|
||||
uptime and upgrades independent of anything else.
|
||||
|
||||
## Current production
|
||||
|
||||
- Droplet: `hnefatafl` (nyc3, s-1vcpu-512mb-10gb, tag `hnefatafl`), IP 209.97.148.244
|
||||
@@ -47,8 +42,9 @@ fetches the certificate on first request.
|
||||
|
||||
## Operations scripts on the droplet
|
||||
|
||||
`deploy.sh` installs these to /usr/local/bin and the cron file to
|
||||
/etc/cron.d/hnefatafl on every deploy, so the live copies are the repo copies:
|
||||
`deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to
|
||||
/usr/local/lib/hnefatafl, and the cron file to /etc/cron.d/hnefatafl on every
|
||||
deploy, so the live copies are the repo copies:
|
||||
|
||||
- `hnefatafl-visitors.sh [day]`: who is here now and who came that day.
|
||||
- `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup
|
||||
@@ -70,7 +66,8 @@ URL with the project's cron path and public key), so a missed night is noticed.
|
||||
To have every new issue, regression and reappearance posted to Slack the way
|
||||
wizwar's are, run `deploy/sentry-slack-alert.sh [#channel]` once from your own
|
||||
shell with `SENTRY_TOKEN` (an org auth token with alerts:write) and
|
||||
`SLACK_CHANNEL_ID` set; the token never leaves the shell.
|
||||
`SLACK_CHANNEL_ID` (the channel's Slack ID) set; the token never leaves the
|
||||
shell.
|
||||
|
||||
Before every deploy, `deploy/verify-ledgers.sh <ip>` fetches every production
|
||||
ledger and replays it with the local engine, comparing each room with what the
|
||||
@@ -83,8 +80,7 @@ deploy: the server would rewrite that game on restart.
|
||||
|
||||
Runs the type-checks, the tests and the build locally, rsyncs `build/` to
|
||||
the droplet keeping the previous week's hashed assets, rsyncs the server and
|
||||
engine sources, installs dependencies, and restarts the game server. A
|
||||
single-player game is in the player's own browser and loses nothing. A room
|
||||
engine sources, installs dependencies, and restarts the game server. A room
|
||||
is replayed from its ledger when the server comes back, which takes a few
|
||||
seconds; Caddy holds requests that land in the gap.
|
||||
|
||||
@@ -93,10 +89,5 @@ seconds; Caddy holds requests that land in the gap.
|
||||
- Logs: `ssh root@<ip> journalctl -u hnefatafl -f` for the game server,
|
||||
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
|
||||
- Restart: `ssh root@<ip> systemctl restart hnefatafl`
|
||||
- Who has been playing: `ssh root@<ip> hnefatafl-visitors.sh [YYYY-MM-DD]`
|
||||
prints today's visitors from Caddy's log (people and bots apart, by page),
|
||||
every room opened today with how far it got, and the names seated, marking
|
||||
those seen for the first time.
|
||||
- Rooms: `/var/lib/hnefatafl/rooms/<CODE>.jsonl`, one ledger per game.
|
||||
Copy that directory to back them up; single-player games are in players'
|
||||
browsers.
|
||||
- Rooms: `/var/lib/hnefatafl/rooms/<CODE>.jsonl`, one ledger per game; the
|
||||
nightly backup keeps them in the Space.
|
||||
|
||||
Binary file not shown.
+6
-5
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
|
||||
# it needs only its code and the engine. Ledgers live outside this tree.
|
||||
rsync -az --delete --exclude='/server/node_modules' \
|
||||
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \
|
||||
--include='/deploy/' --include='/deploy/hnefatafl.service' --include='/deploy/hnefatafl.cron' --include='/deploy/*.sh' --include='/deploy/hash-tokens.ts' \
|
||||
--include='/deploy/' --include='/deploy/hnefatafl.service' --include='/deploy/hnefatafl.cron' --include='/deploy/*.sh' --include='/deploy/traffic.py' \
|
||||
--exclude='*' \
|
||||
./ "root@$HOST:/opt/hnefatafl/app/"
|
||||
|
||||
@@ -36,12 +36,13 @@ ssh "root@$HOST" '
|
||||
cd /opt/hnefatafl/app/server && npm install --no-audit --no-fund
|
||||
chown -R hnefatafl:hnefatafl /opt/hnefatafl/app
|
||||
cp /opt/hnefatafl/app/deploy/hnefatafl.service /etc/systemd/system/hnefatafl.service
|
||||
# Cron runs the rollup and the backup from /usr/local/bin: install them on
|
||||
# every deploy so the live copies are always the repo copies.
|
||||
install -m 755 /opt/hnefatafl/app/deploy/visitors.sh /usr/local/bin/hnefatafl-visitors.sh
|
||||
install -m 755 /opt/hnefatafl/app/deploy/pulse.sh /usr/local/bin/hnefatafl-pulse.sh
|
||||
# The ops scripts run from /usr/local/bin and share traffic.py: install them
|
||||
# on every deploy so the live copies are always the repo copies.
|
||||
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-visitors.sh /usr/local/bin/hnefatafl-visitors.sh
|
||||
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 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
|
||||
mkdir -p /var/log/hnefatafl
|
||||
systemctl daemon-reload
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// One-time migration: replace each seat line's raw token with its SHA-256,
|
||||
// so no ledger on disk or in a backup holds a live seat key. Idempotent; a
|
||||
// line already hashed is left alone. Run ON the droplet from the server
|
||||
// directory (tsx is installed there):
|
||||
// cd /opt/hnefatafl/app/server && npx tsx ../deploy/hash-tokens.ts /var/lib/hnefatafl/rooms
|
||||
// Browsers keep their raw tokens; the server hashes what they send and
|
||||
// compares, so nobody loses a seat.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdirSync, readFileSync, renameSync, statSync, writeFileSync, chownSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const dir = process.argv[2];
|
||||
if (!dir) {
|
||||
console.error('usage: hash-tokens.ts <ledger-dir>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let files = 0;
|
||||
let lines = 0;
|
||||
for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) {
|
||||
const path = join(dir, file);
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
let changed = 0;
|
||||
const out = raw
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (!line.trim()) return line;
|
||||
const entry = JSON.parse(line) as Record<string, unknown>;
|
||||
if (entry.t !== 'seat' || typeof entry.token !== 'string') return line;
|
||||
const { token, ...rest } = entry;
|
||||
changed += 1;
|
||||
return JSON.stringify({ ...rest, tokenHash: token ? createHash('sha256').update(token).digest('hex') : '' });
|
||||
})
|
||||
.join('\n');
|
||||
if (!changed) continue;
|
||||
const { uid, gid } = statSync(path);
|
||||
writeFileSync(path + '.tmp', out);
|
||||
chownSync(path + '.tmp', uid, gid);
|
||||
renameSync(path + '.tmp', path);
|
||||
files += 1;
|
||||
lines += changed;
|
||||
}
|
||||
console.log(`${files} ledger${files === 1 ? '' : 's'} rewritten, ${lines} seat line${lines === 1 ? '' : 's'} hashed`);
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket.
|
||||
# current/ - exact mirror of /var/lib/hnefatafl
|
||||
# snapshots/ - one dated copy per day, pruned after 90 days
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# The operations pulse, printed ON the droplet: the game server's health,
|
||||
# errors in its journal, the last week of the rollup, the backup's last word,
|
||||
# and the box's vitals. Read by the hnefatafl-pulse skill.
|
||||
@@ -10,7 +10,7 @@ systemctl is-active caddy >/dev/null && echo "caddy: active" || echo "caddy: NOT
|
||||
echo "restarts in 7 days: $(journalctl -u hnefatafl --since '7 days ago' -o cat | grep -c 'Started hnefatafl')"
|
||||
ERR=$(journalctl -u hnefatafl --since '7 days ago' -p err -o cat | grep -vc '^$')
|
||||
echo "journal errors in 7 days: $ERR"
|
||||
journalctl -u hnefatafl --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | grep -v "Rate" | tail -3 | sed 's/^/ /'
|
||||
journalctl -u hnefatafl --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | tail -3 | sed 's/^/ /'
|
||||
echo "--- rollup, last 7 days (people / requests / rooms opened / turns)"
|
||||
if [ -s /var/lib/hnefatafl/rollup.jsonl ]; then
|
||||
tail -7 /var/lib/hnefatafl/rollup.jsonl | python3 -c '
|
||||
+12
-46
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Nightly rollup: one JSON line per day in /var/lib/hnefatafl/rollup.jsonl:
|
||||
# the day's traffic from Caddy's access log (people and bots apart, by page,
|
||||
# with referrers and the campaign tags links carry), the rooms opened,
|
||||
@@ -24,45 +24,13 @@ checkin() {
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
checkin in_progress
|
||||
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
|
||||
import collections, datetime, glob, gzip, json, os, re, shutil, sys
|
||||
import datetime, glob, json, os, shutil, sys
|
||||
sys.path.insert(0, "/usr/local/lib/hnefatafl")
|
||||
import traffic
|
||||
day, out = sys.argv[1], sys.argv[2]
|
||||
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
|
||||
req = human = bot = 0
|
||||
hips, bips = set(), set()
|
||||
pages, joins, refs, campaigns = collections.Counter(), collections.Counter(), collections.Counter(), collections.Counter()
|
||||
seen_campaign = set()
|
||||
for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
|
||||
if os.path.getmtime(f) < t0: continue
|
||||
opener = gzip.open if f.endswith(".gz") else open
|
||||
with opener(f, "rt", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
try: r = json.loads(line)
|
||||
except ValueError: continue
|
||||
ts = r.get("ts", 0)
|
||||
if not (t0 <= ts < t1): continue
|
||||
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]
|
||||
req += 1
|
||||
if BOT.search(ua) or not ua:
|
||||
bot += 1; bips.add(ip); continue
|
||||
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")): continue
|
||||
human += 1; hips.add(ip)
|
||||
if path == "/": pages["hall"] += 1
|
||||
elif path == "/rules": pages["rules"] += 1
|
||||
elif path == "/guide": pages["guide"] += 1
|
||||
elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
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:
|
||||
seen_campaign.add((ip, m.group(1)))
|
||||
campaigns[m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
|
||||
t = traffic.scan(day)
|
||||
|
||||
created = started = finished = turned = turns = 0
|
||||
humans, bots = set(), 0
|
||||
@@ -80,20 +48,18 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"):
|
||||
if x["bot"]: bots += 1
|
||||
else: humans.add(x["name"])
|
||||
elif x["t"] == "unseat": bots -= 1
|
||||
day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1]
|
||||
turns += len(day_turns)
|
||||
# A game finished today: its last turn is today's and the engine would say so; approximate by replay-free means:
|
||||
# the ledger's final turn falls today and no turn follows it within the day's end.
|
||||
if day_turns and day_turns[-1] is lines[-1] and False: finished += 1
|
||||
turns += sum(1 for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1)
|
||||
# The over line is written with the turn that ends the game, so a finish counts on its own day.
|
||||
if any(x["t"] == "over" and t0 <= x["at"] / 1000 < t1 for x in lines): finished += 1
|
||||
|
||||
disk = shutil.disk_usage("/")
|
||||
mem = {}
|
||||
for line in open("/proc/meminfo"):
|
||||
k, v = line.split(":", 1); mem[k] = int(v.split()[0])
|
||||
load = os.getloadavg()[1]
|
||||
row = dict(day=day, requests=req, human=human, bot=bot, humanAddresses=len(hips), botAddresses=len(bips),
|
||||
paths=dict(pages), roomLinks=sum(joins.values()), referrers=dict(refs), campaigns=dict(campaigns),
|
||||
rooms=dict(created=created, started=started, withTurns=turned, humanNames=len(humans), botSeats=bots),
|
||||
row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]),
|
||||
paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]),
|
||||
rooms=dict(created=created, started=started, withTurns=turned, finished=finished, humanNames=len(humans), botSeats=bots),
|
||||
turns=turns, ledgers=len(glob.glob("/var/lib/hnefatafl/rooms/*.jsonl")),
|
||||
vitals=dict(diskUsedPct=round(disk.used * 100 / disk.total), memAvailableMb=mem.get("MemAvailable", 0) // 1024, load5=round(load, 2)),
|
||||
at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"))
|
||||
@@ -105,6 +71,6 @@ rows.sort(key=lambda r: r["day"])
|
||||
with open(out + ".tmp", "w") as fh:
|
||||
for r in rows: fh.write(json.dumps(r) + "\n")
|
||||
os.replace(out + ".tmp", out)
|
||||
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(hips), human, created))
|
||||
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created))
|
||||
PY
|
||||
then checkin ok; else checkin error; fi
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# The visitors digest: who is at the table right now and who came today,
|
||||
# the rooms opened and how far each got, with today's traffic so far.
|
||||
# Runs ON the droplet: visitors.sh [YYYY-MM-DD] (default: today, UTC)
|
||||
# Runs ON the droplet: hnefatafl-visitors.sh [YYYY-MM-DD] (default: today, UTC)
|
||||
# Every game, bot games included, is a room with a ledger, so all show in full.
|
||||
set -u
|
||||
DAY="${1:-$(date -u +%F)}"
|
||||
python3 - "$DAY" <<'PY'
|
||||
import datetime, glob, json, re, subprocess, sys
|
||||
from collections import Counter
|
||||
import datetime, glob, json, subprocess, sys
|
||||
sys.path.insert(0, "/usr/local/lib/hnefatafl")
|
||||
import traffic
|
||||
|
||||
day = sys.argv[1]
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
@@ -24,50 +25,10 @@ print("live sockets: %s | rooms touched in the last hour: %s" % (
|
||||
sh("find /var/lib/hnefatafl/rooms -name '*.jsonl' -mmin -60 2>/dev/null | wc -l")))
|
||||
|
||||
# --- traffic: Caddy's access log, people and bots apart ---
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|preview|fetch|curl|python|go-http|headless|scan|monitor|uptime", re.I)
|
||||
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
|
||||
addresses, bots = set(), set()
|
||||
pages, joins, referrers, campaigns, agents = Counter(), Counter(), Counter(), Counter(), Counter()
|
||||
requests = 0
|
||||
for path in sorted(glob.glob("/var/lib/caddy/access.log*")):
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
try:
|
||||
x = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
ts = datetime.datetime.fromtimestamp(x["ts"], datetime.timezone.utc)
|
||||
if ts.strftime("%F") != day:
|
||||
continue
|
||||
req = x["request"]
|
||||
ua = " ".join(req.get("headers", {}).get("User-Agent", [""]))
|
||||
ip = req.get("remote_ip") or req.get("client_ip", "?")
|
||||
uri = req.get("uri", "")
|
||||
path_only = uri.split("?")[0]
|
||||
if BOT.search(ua) or not ua:
|
||||
bots.add(ip)
|
||||
continue
|
||||
if path_only.startswith("/_app/") or path_only.startswith("/api/") or path_only == "/ws" or path_only.endswith((".png", ".ico", ".txt")):
|
||||
continue
|
||||
requests += 1
|
||||
addresses.add(ip)
|
||||
if path_only in PAGES:
|
||||
pages[PAGES[path_only]] += 1
|
||||
elif path_only.startswith("/join/"):
|
||||
joins[path_only.split("/")[2].upper()] += 1
|
||||
ref = " ".join(req.get("headers", {}).get("Referer", [""]))
|
||||
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:
|
||||
campaigns[m.group(1) + "=" + m.group(2)[:24]] += 1
|
||||
agents[re.sub(r"\(.*?\)", "", ua)[:40].strip()] += 1
|
||||
except OSError:
|
||||
continue
|
||||
t = traffic.scan(day)
|
||||
print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, rules %d, guide %d, room links %s; referrers %s; campaigns %s" % (
|
||||
len(addresses), requests, len(bots), pages["hall"], pages["rules"], pages["guide"],
|
||||
dict(joins) or "none", dict(referrers) or "none", dict(campaigns) or "none"))
|
||||
len(t["addresses"]), t["human"], len(t["botAddresses"]), t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"],
|
||||
dict(t["joins"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none"))
|
||||
|
||||
# --- rooms: every ledger, today's in detail ---
|
||||
first_seen = {}
|
||||
@@ -87,7 +48,7 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"):
|
||||
(bots_seated if x["bot"] else humans).append(x["name"])
|
||||
names[x["id"]] = x["name"]
|
||||
elif x["t"] == "unseat":
|
||||
bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None
|
||||
if x["id"] in names: bots_seated.remove(names.pop(x["id"]))
|
||||
elif x["t"] == "start":
|
||||
started = True
|
||||
elif x["t"] == "turn":
|
||||
@@ -12,7 +12,7 @@ Environment=KEEPER_TZ=America/New_York
|
||||
# Caddy terminates TLS; the plaintext port must not face the internet.
|
||||
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.
|
||||
# Empty means no error reporting.
|
||||
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
|
||||
|
||||
+4
-33
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Mirror the player reports, the keeper's replies and the players' screenshots
|
||||
# to a local folder, and write a digest beside them for reading.
|
||||
# to a local folder, and write a digest beside them for reading. Run from the
|
||||
# repository root:
|
||||
# deploy/pull-reports.sh [host] [folder] (default ~/Desktop/hnefatafl-reports)
|
||||
set -euo pipefail
|
||||
HOST="${1:-209.97.148.244}"
|
||||
@@ -8,34 +9,4 @@ 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"
|
||||
rsync -aq "root@$HOST:/var/lib/hnefatafl/feedback-images/" "$OUT/images/" 2>/dev/null || true
|
||||
python3 - "$OUT" <<'PY'
|
||||
import json, sys, os
|
||||
out = sys.argv[1]
|
||||
reports, order = {}, []
|
||||
for raw in open(os.path.join(out, "feedback.jsonl"), encoding="utf8"):
|
||||
raw = raw.strip()
|
||||
if not raw: continue
|
||||
line = json.loads(raw)
|
||||
if "reportId" in line:
|
||||
r = reports.get(line["reportId"])
|
||||
if not r: continue
|
||||
if "image" in line: r["image"] = line["image"]
|
||||
else: r["replies"].append(line) # the keeper's replies and the player's answers alike, in order
|
||||
continue
|
||||
reports[line["id"]] = dict(line, replies=[]); order.append(line["id"])
|
||||
lines = ["# Hnefatafl reports", "", f"{len(order)} reports; newest first. Pictures are in `images/`.", ""]
|
||||
for rid in reversed(order):
|
||||
r = reports[rid]
|
||||
lines.append(f"## {r.get('at','')[:16].replace('T',' ')} — {r.get('player','?')} in {r.get('roomId','?')} (turn {r.get('turn','?')}, seq {r.get('seq','?')}) — id {rid}")
|
||||
lines.append("")
|
||||
lines.append(f"**What happened:** {r.get('happened','').strip()}")
|
||||
if r.get("expected","").strip(): lines.append(f"**What they expected:** {r['expected'].strip()}")
|
||||
if r.get("image"): lines.append(f"**Picture:** ![{r['image']}](images/{r['image']})")
|
||||
for rep in r["replies"]:
|
||||
who = f"{r.get('player','the player')} answers" if rep.get("from") == "player" else f"**{rep.get('status','')}**"
|
||||
lines.append(f"> {who} ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}")
|
||||
if not r["replies"] or r["replies"][-1].get("from") == "player": lines.append("> _awaiting the keeper_")
|
||||
lines.append("")
|
||||
open(os.path.join(out, "reports.md"), "w", encoding="utf8").write("\n".join(lines))
|
||||
print(f"{len(order)} reports, {sum(1 for r in reports.values() if r.get('image'))} with pictures -> {out}/reports.md")
|
||||
PY
|
||||
npx tsx deploy/report-digest.ts "$OUT"
|
||||
|
||||
@@ -40,7 +40,7 @@ async function main(): Promise<void> {
|
||||
for (const line of lines) {
|
||||
if (line.t === 'seat') seats.push(line);
|
||||
else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, options);
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options);
|
||||
else if (line.t === 'turn' && state) {
|
||||
state = game.resolve(state, line.inputs);
|
||||
turns += 1;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// The reports digest: every report with its exchange, newest first, written
|
||||
// as reports.md beside a mirrored feedback.jsonl. Used by deploy/pull-reports.sh.
|
||||
// tsx deploy/report-digest.ts <folder>
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Reports } from '../server/src/reports';
|
||||
|
||||
const dir = process.argv[2];
|
||||
if (!dir) {
|
||||
console.error('usage: report-digest.ts <folder>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const when = (iso: string) => iso.slice(0, 16).replace('T', ' ');
|
||||
const reports = new Reports(dir).read().reverse();
|
||||
const lines = ['# Hnefatafl reports', '', `${reports.length} reports; newest first. Pictures are in \`images/\`.`, ''];
|
||||
for (const r of reports) {
|
||||
lines.push(`## ${when(r.at)} — ${r.player} in ${r.roomId} (turn ${r.turn ?? '?'}, seq ${r.seq}) — id ${r.id}`, '');
|
||||
lines.push(`**What happened:** ${r.happened.trim()}`);
|
||||
if (r.expected.trim()) lines.push(`**What they expected:** ${r.expected.trim()}`);
|
||||
if (r.image) lines.push(`**Picture:** `);
|
||||
for (const line of r.thread) {
|
||||
const who = line.from === 'player' ? `${r.player} answers` : `**${line.status}**`;
|
||||
lines.push(`> ${who} (${when(line.at)}): ${line.text.trim()}`);
|
||||
}
|
||||
const last = r.thread[r.thread.length - 1];
|
||||
if (!last || last.from === 'player') lines.push('> _awaiting the keeper_');
|
||||
lines.push('');
|
||||
}
|
||||
writeFileSync(join(dir, 'reports.md'), lines.join('\n'));
|
||||
console.log(`${reports.length} reports, ${reports.filter((r) => r.image).length} with pictures -> ${join(dir, 'reports.md')}`);
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Answer a player's report; the reply appears under it in their hall.
|
||||
# deploy/report-reply.sh <host> <reportId> <resolved|by-design|open> <text...>
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Route every Hnefatafl issue to a Slack channel: new issues (any level or
|
||||
# priority), regressions, and reappearances. Creates one Sentry workflow
|
||||
# via the alerts API. The token never leaves your shell:
|
||||
# SENTRY_TOKEN=sntryu_... deploy/sentry-slack-alert.sh [#channel]
|
||||
# The channel defaults to #hnefatafl-notifications (__SLACK_CHANNEL_ID__); pass
|
||||
# SLACK_CHANNEL_ID with the channel name to route elsewhere.
|
||||
# SENTRY_TOKEN=sntryu_... SLACK_CHANNEL_ID=C... deploy/sentry-slack-alert.sh [#channel]
|
||||
# SLACK_CHANNEL_ID is the channel's Slack ID; the name defaults to
|
||||
# #hnefatafl-notifications and is shown in Sentry beside the ID.
|
||||
# The token needs the alerts:write scope (an org auth token from
|
||||
# https://locallygrownnet.sentry.io/settings/auth-tokens/), and Slack must
|
||||
# already be connected to the org under Settings → Integrations.
|
||||
@@ -20,7 +20,8 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN"
|
||||
# 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:-345334}"
|
||||
CHANNEL_ID="${SLACK_CHANNEL_ID:-__SLACK_CHANNEL_ID__}"
|
||||
: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
|
||||
CHANNEL_ID="$SLACK_CHANNEL_ID"
|
||||
PROJECT_ID="${SENTRY_PROJECT_ID:-4512136791130112}"
|
||||
echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)"
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
# 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
|
||||
# TLS. There is no application process to install or supervise.
|
||||
# Caddy serves the built files and terminates TLS; setup-server.sh adds the
|
||||
# game server behind it.
|
||||
set -euo pipefail
|
||||
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Caddy's access log for one day, people and bots apart. The visitors
|
||||
digest and the nightly rollup both count through here, so they agree."""
|
||||
|
||||
import datetime
|
||||
import glob
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
|
||||
SITE = "tafl.kestrelsnest.social"
|
||||
LOGS = "/var/lib/caddy/access*.log*"
|
||||
|
||||
|
||||
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
|
||||
opened, referrers, and campaign tags (each counted once per address)."""
|
||||
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
||||
t = dict(requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(), referrers=Counter(), campaigns=Counter())
|
||||
seen_campaign = set()
|
||||
for f in sorted(glob.glob(logs)):
|
||||
if os.path.getmtime(f) < t0:
|
||||
continue
|
||||
opener = gzip.open if f.endswith(".gz") else open
|
||||
try:
|
||||
with opener(f, "rt", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (t0 <= r.get("ts", 0) < t1):
|
||||
continue
|
||||
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]
|
||||
t["requests"] += 1
|
||||
if BOT.search(ua) or not ua:
|
||||
t["bot"] += 1
|
||||
t["botAddresses"].add(ip)
|
||||
continue
|
||||
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
|
||||
continue
|
||||
t["human"] += 1
|
||||
t["addresses"].add(ip)
|
||||
if path in PAGES:
|
||||
t["pages"][PAGES[path]] += 1
|
||||
elif path.startswith("/join/"):
|
||||
t["joins"][path.split("/")[2].upper()] += 1
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
if ref and SITE not in ref:
|
||||
t["referrers"][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:
|
||||
seen_campaign.add((ip, m.group(1)))
|
||||
t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
|
||||
except OSError:
|
||||
continue
|
||||
return t
|
||||
@@ -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 in the environment overrides the name, for a machine whose DNS has not caught up with a new record.
|
||||
# SITE overrides the public name: the sslip.io address, or a name not yet in DNS.
|
||||
SITE="${2:-${SITE:-https://tafl.kestrelsnest.social}}"
|
||||
DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$DIR"' EXIT
|
||||
|
||||
Reference in New Issue
Block a user