From Hnefatafl's second credibility pass: traffic.py parses each request once and counts visits one way, the rollup's check-in has one comment, and the hall's tally drops its leftovers

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:18:10 -04:00
co-authored by Claude Opus 5.5
parent 169ec4ad55
commit f5b7416a9e
7 changed files with 63 additions and 57 deletions
+5 -5
View File
@@ -15,11 +15,11 @@ LOG="/var/log/__SLUG__/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/.__SLUG__-sentry-cron-rollup 2>/dev/null || true) CRON_URL=$(cat /root/.__SLUG__-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" \
+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
@@ -436,7 +436,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);
@@ -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;
+3 -5
View File
@@ -39,21 +39,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.namesSeated, label: t.namesSeated === 1 ? 'name at the table' : 'names at the table' }, { n: t.namesSeated, label: t.namesSeated === 1 ? 'name at the table' : 'names at the table' },
{ n: t.turnsPlayed, label: 'turns recorded' }, { n: t.turnsPlayed, label: 'turns recorded' },
{ n: hours(t.minutesAtTable), label: 'at the table' } { n: hours(t.minutesAtTable), label: 'at the table' }
]; ];
const told = new Set<string>([]); const onCards = new Set<string>([]);
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 bot seated` }, { n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot 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.` };
@@ -651,5 +650,4 @@
margin: 0.2rem 0 0; margin: 0.2rem 0 0;
max-width: 22em; max-width: 22em;
} }
</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;
} }
} }