From f5b7416a9eaf6d4d5bd5345bc1bfca5e74d485d4 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sun, 11 Oct 2026 10:18:10 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh --- template/deploy/__SLUG__-rollup.sh | 10 +-- template/deploy/traffic.py | 75 +++++++++---------- template/server/src/rooms.ts | 2 +- .../src/lib/components/Announcement.svelte | 11 ++- template/src/lib/components/Hall.svelte | 8 +- template/src/lib/components/Tally.svelte | 2 +- template/src/lib/family.ts | 12 +-- 7 files changed, 63 insertions(+), 57 deletions(-) diff --git a/template/deploy/__SLUG__-rollup.sh b/template/deploy/__SLUG__-rollup.sh index 685bd9b..6e476a0 100755 --- a/template/deploy/__SLUG__-rollup.sh +++ b/template/deploy/__SLUG__-rollup.sh @@ -15,11 +15,11 @@ LOG="/var/log/__SLUG__/rollup.log" DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}" SCHEDULE="12 0 * * *" 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: 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. +# 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" \ diff --git a/template/deploy/traffic.py b/template/deploy/traffic.py index 2e288c0..aa1daa3 100644 --- a/template/deploy/traffic.py +++ b/template/deploy/traffic.py @@ -46,6 +46,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 +75,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 +95,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,12 +138,7 @@ 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 @@ -151,7 +150,7 @@ def scan(day, logs=LOGS): 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 +160,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) diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 81274f3..3fb8382 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -436,7 +436,7 @@ export class Rooms { * 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, token: string | undefined, raw: unknown, resolved?: unknown): void { + submit(room: Room, 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); diff --git a/template/src/lib/components/Announcement.svelte b/template/src/lib/components/Announcement.svelte index 18f343f..9c5431a 100644 --- a/template/src/lib/components/Announcement.svelte +++ b/template/src/lib/components/Announcement.svelte @@ -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; + 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; diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index bbd95b5..63bd274 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -39,21 +39,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.namesSeated, label: t.namesSeated === 1 ? 'name at the table' : 'names at the table' }, { n: t.turnsPlayed, label: 'turns recorded' }, { n: hours(t.minutesAtTable), label: 'at the table' } ]; - const told = new Set([]); + const onCards = new Set([]); 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 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' }] : []), - ...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.` }; @@ -651,5 +650,4 @@ margin: 0.2rem 0 0; max-width: 22em; } - diff --git a/template/src/lib/components/Tally.svelte b/template/src/lib/components/Tally.svelte index 4e978e6..7bb4936 100644 --- a/template/src/lib/components/Tally.svelte +++ b/template/src/lib/components/Tally.svelte @@ -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); diff --git a/template/src/lib/family.ts b/template/src/lib/family.ts index 596072d..5958b16 100644 --- a/template/src/lib/family.ts +++ b/template/src/lib/family.ts @@ -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 { 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; } }