A credibility pass over ee5690e..HEAD: traffic.py parses each request once and counts visits one way, the deploy notes and skills agree with the scripts, and a few edit leftovers in the hall are tidied

traffic.py gives identical counts before and after over 41 days of the
host's access logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
This commit is contained in:
Eric Wagoner
2026-10-11 10:17:01 -04:00
co-authored by Claude Opus 5.5
parent 57ae338cdb
commit 7f66be8016
11 changed files with 76 additions and 65 deletions
+2 -1
View File
@@ -72,7 +72,8 @@ to hear his players' voices; never compress their words into a table.
passing `rules` to `createGame`). Old ledgers then replay as their passing `rules` to `createGame`). Old ledgers then replay as their
players saw them; new games get the fix. players saw them; new games get the fix.
Either way add a test in `src/lib/game/*.test.ts` pinning the 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"`. 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 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 replayed, cite the rule, and say plainly what was wrong or why nothing
+6 -3
View File
@@ -17,9 +17,12 @@ spills into the next UTC day. Run yesterday too when the hour is early.
- **as of / live sockets / rooms touched in the last hour**: the "right - **as of / live sockets / rooms touched in the last hour**: the "right
now" line. One socket at a quiet hour is Eric. now" line. One socket at a quiet hour is Eric.
- **traffic**: Caddy's access log for the day, bots split out by user - **traffic**: Caddy's access log for the day, people and bots apart. A
agent, page requests by page (hall, rules, guide) and room links opened, bot is known by its user agent, or by its address: scanners ask for
referrers and campaign tags. 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; - **players today / NEW today**: names seated in rooms created today;
NEW means the name's first room ever is today. NEW means the name's first room ever is today.
- **rooms today**: one line each: time, code, humans vs bots, empty - **rooms today**: one line each: time, code, humans vs bots, empty
+2 -2
View File
@@ -26,8 +26,8 @@ dip 8790.
- URLs: https://tafl.kestrelsnest.social (A record at Hover, where kestrelsnest.social's - 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). 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` - 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). (the determinism gate reads the ledgers from `SITE`; the sslip name answers wherever
- Moved here from its own droplet (209.97.148.244) on 2026-09-25. the real name does not resolve to this host yet).
- Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/hnefatafl/feedback.jsonl - Players' reports: `deploy/pull-reports.sh` mirrors /var/lib/hnefatafl/feedback.jsonl
and the screenshots to ~/Desktop/hnefatafl-reports with a digest; and the screenshots to ~/Desktop/hnefatafl-reports with a digest;
`deploy/report-reply.sh 159.203.115.252 <id> <status> "text"` answers one. `deploy/report-reply.sh 159.203.115.252 <id> <status> "text"` answers one.
+5 -5
View File
@@ -15,11 +15,11 @@ LOG="/var/log/hnefatafl/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}" DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
SCHEDULE="12 0 * * *" SCHEDULE="12 0 * * *"
CRON_URL=$(cat /root/.hnefatafl-sentry-cron-rollup 2>/dev/null || true) 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
# One check-in, when the run ends, carrying its duration: a start and a # (Sentry keeps a monitor only once told its shape). A start and a finish
# finish sent seconds apart can reach Sentry in either order, and a start # sent seconds apart can reach Sentry in either order, and a start that lands
# that lands second is never closed. A run that never ends sends nothing, # second is never closed. A run that never ends sends nothing, and Sentry
# and Sentry marks the night missed. # marks the night missed.
START=$SECONDS START=$SECONDS
checkin() { checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \ [ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
+1 -1
View File
@@ -4,7 +4,7 @@
# own port, service user, data directory and access log, and this script # own port, service user, data directory and access log, and this script
# touches nothing that belongs to another game. Copy it and Caddyfile.tmpl # 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. # 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 # 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. # separated by commas. Every name must already resolve to this host.
set -euo pipefail set -euo pipefail
+37 -38
View File
@@ -46,6 +46,28 @@ def unfurled_by(ua):
return None 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): def scan(day, logs=LOGS):
"""Counts for one UTC day (YYYY-MM-DD): every request, page requests by """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 people, the addresses of people and of bots, requests by page, room links
@@ -53,8 +75,8 @@ def scan(day, logs=LOGS):
referrers, and campaign tags (each counted once per address). 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 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 the whole day when it asks for a scanner's paths, or comes back three
three visits or more without ever opening or joining a game: a monitor, 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 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 away by keeping time: one browser, page loads at a steady interval, four
visits or more, never a game.""" visits or more, never a game."""
@@ -73,55 +95,37 @@ def scan(day, logs=LOGS):
except ValueError: except ValueError:
continue continue
if t0 <= r.get("ts", 0) < t1: if t0 <= r.get("ts", 0) < t1:
requests.append(r) requests.append(parse(r))
except OSError: except OSError:
continue continue
# First pass: what each address did all day under a browser's name, to find scanners and monitors. # 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)) seen = defaultdict(lambda: dict(times=[], played=False, scanner=False))
for r in requests: for r in requests:
q = r.get("request", {}) if BOT.search(r["ua"]) or not r["ua"]:
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:
continue continue
path = q.get("uri", "").split("?")[0] path = r["path"]
a = seen[ip] a = seen[r["ip"]]
a["times"].append(r.get("ts", 0)) a["times"].append(r["ts"])
if not path.startswith("/_app/") and SCANNER.search(path): if not path.startswith("/_app/") and SCANNER.search(path):
a["scanner"] = True a["scanner"] = True
if path.startswith(("/join/", "/api/rooms")) or path == "/ws": if path.startswith(("/join/", "/api/rooms")) or path == "/ws":
a["played"] = True 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): def keeps_time(times):
starts, last = [], None starts = visit_starts(times)
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:])]
if len(starts) < 4: if len(starts) < 4:
return False return False
gaps = [b - a for a, b in zip(starts, starts[1:])]
typical = sorted(gaps)[len(gaps) // 2] typical = sorted(gaps)[len(gaps) // 2]
return all(0.75 * typical <= g <= 1.25 * typical for g in gaps) return all(0.75 * typical <= g <= 1.25 * typical for g in gaps)
scanners = {ip for ip, a in seen.items() if a["scanner"]} 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) by_browser = defaultdict(list)
for r in requests: for r in requests:
q = r.get("request", {}) if r["ua"] and not BOT.search(r["ua"]) and r["path"] in PAGES and not seen[r["ip"]]["played"]:
ip = q.get("client_ip") or q.get("remote_ip") or "?" by_browser[r["ua"]].append((r["ts"], r["ip"]))
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))
for hits in by_browser.values(): for hits in by_browser.values():
if keeps_time([ts for ts, _ in hits]): if keeps_time([ts for ts, _ in hits]):
monitors |= {ip for _, ip in hits} monitors |= {ip for _, ip in hits}
@@ -134,12 +138,7 @@ def scan(day, logs=LOGS):
seen_campaign = set() seen_campaign = set()
seen_share = set() seen_share = set()
for r in requests: for r in requests:
q = r.get("request", {}) ip, ua, uri, path = r["ip"], r["ua"], r["uri"], r["path"]
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 t["requests"] += 1
if ip in posing or BOT.search(ua) or not ua: if ip in posing or BOT.search(ua) or not ua:
t["bot"] += 1 t["bot"] += 1
@@ -151,7 +150,7 @@ def scan(day, logs=LOGS):
t["shared"][service] += 1 t["shared"][service] += 1
continue continue
# A room opened from the site itself never passes through /join: count the API's answer. # 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 t["opened"] += 1
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")): if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
continue continue
@@ -161,7 +160,7 @@ def scan(day, logs=LOGS):
t["pages"][PAGES[path]] += 1 t["pages"][PAGES[path]] += 1
elif path.startswith("/join/"): elif path.startswith("/join/"):
t["joins"][path.split("/")[2].upper()] += 1 t["joins"][path.split("/")[2].upper()] += 1
ref = " ".join(h.get("Referer", [""])) ref = r["referer"]
if ref and not OWN.search(ref): if ref and not OWN.search(ref):
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1 t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri) m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
+1 -1
View File
@@ -366,7 +366,7 @@ export class Rooms<State, Input> {
* arrives after its round was played (a slow connection, a second click, a stale tab) is refused * 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. * 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); const seat = this.seatOf(room, token);
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409); 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); if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
+9 -2
View File
@@ -16,6 +16,13 @@
until?: string; 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 SOURCE = '/hall/announcement.json';
const CHECK_MS = 5 * 60_000; const CHECK_MS = 5 * 60_000;
const DISMISSED = 'hall:dismissed'; const DISMISSED = 'hall:dismissed';
@@ -28,7 +35,7 @@
try { try {
const res = await fetch(SOURCE, { cache: 'no-store' }); const res = await fetch(SOURCE, { cache: 'no-store' });
const a: unknown = res.ok ? await res.json() : null; 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 { } catch {
// No announcement reachable: none shown. // No announcement reachable: none shown.
} }
@@ -68,7 +75,7 @@
}); });
function dismiss() { function dismiss() {
// Taken first: once dismissed, nothing is shown. // Read before dismissing: once dismissed, nothing is shown.
const id = shown?.id; const id = shown?.id;
if (!id) return; if (!id) return;
dismissed = id; dismissed = id;
+5 -6
View File
@@ -2,6 +2,7 @@
// The hall: the landing page every game in the kit shares. Name, play the // 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 // 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. // about panel with the rules, the guide and a way to reach the keeper.
import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { loadFamily, type FamilyGame } from '$lib/family'; import { loadFamily, type FamilyGame } from '$lib/family';
import About from './About.svelte'; import About from './About.svelte';
@@ -29,7 +30,7 @@
let seats = $state(allSeats()); let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({}); let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false); let aboutOpen = $state(false);
$effect(() => { onMount(() => {
if (location.hash === '#about') aboutOpen = true; if (location.hash === '#about') aboutOpen = true;
}); });
/** The ledgers' tally, fetched when the about panel opens. */ /** The ledgers' tally, fetched when the about panel opens. */
@@ -43,21 +44,20 @@
const told = $derived.by(() => { const told = $derived.by(() => {
const t = tally; const t = tally;
if (!t) return null; if (!t) return null;
const headline = [ const headline = [
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' }, { 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 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: t.byGame['won by taking the king'] ?? 0, label: 'won by taking the king' },
{ n: hours(t.minutesAtTable), label: 'at the table' } { 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 = [ const details = [
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` }, { 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.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.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' }] : []), ...(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' })}. ` : ''; 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.` }; 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.` };
@@ -627,5 +627,4 @@
margin: 0 auto; margin: 0 auto;
border-radius: 4px; border-radius: 4px;
} }
</style> </style>
+1 -1
View File
@@ -10,7 +10,7 @@
// What the ledgers add up to: a few counts that are the game's own story, // 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 // large, on cards; then the rest in a column of figures; then the fine
// print. Each game chooses its counts and words. // 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(); $props();
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n); const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
+7 -5
View File
@@ -1,7 +1,7 @@
// The other games of Kestrel's Hall, from the family list the kit keeps // 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 // (family.json, one copy shipped with every game): the hall's front door, and
// table can find the next, and the hall's front door. This game is left out // the next table for a player who liked this one. This game is left out of its
// of its own list. // own list.
export interface FamilyGame { export interface FamilyGame {
slug: string; slug: string;
@@ -18,13 +18,15 @@ export interface Family {
games: FamilyGame[]; games: FamilyGame[];
} }
const NONE: Family = { hall: '', games: [] };
export async function loadFamily(self: string): Promise<Family> { export async function loadFamily(self: string): Promise<Family> {
try { try {
const res = await fetch('/family.json', { cache: 'no-cache' }); 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[] }; const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) }; return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
} catch { } catch {
return { hall: '', games: [] }; return NONE;
} }
} }