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
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 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"`.
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
+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
now" line. One socket at a quiet hour is Eric.
- **traffic**: Caddy's access log for the day, bots split out by user
agent, page requests by page (hall, rules, guide) and room links opened,
referrers and campaign tags.
- **traffic**: Caddy's access log for the day, people and bots apart. A
bot is known by its user agent, or by its address: scanners ask for
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;
NEW means the name's first room ever is today.
- **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
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`
(this Mac's resolver caches the old *.kestrelsnest.social wildcard, so the gate reads the sslip name).
- Moved here from its own droplet (209.97.148.244) on 2026-09-25.
(the determinism gate reads the ledgers from `SITE`; the sslip name answers wherever
the real name does not resolve to this host yet).
- 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 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)}"
SCHEDULE="12 0 * * *"
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: 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" \
+1 -1
View File
@@ -4,7 +4,7 @@
# own port, service user, data directory and access log, and this script
# 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.
# 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
# separated by commas. Every name must already resolve to this host.
set -euo pipefail
+37 -38
View File
@@ -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)
+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
* 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);
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);
+9 -2
View File
@@ -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<Announcement>;
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;
+5 -6
View File
@@ -2,6 +2,7 @@
// 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
// about panel with the rules, the guide and a way to reach the keeper.
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { loadFamily, type FamilyGame } from '$lib/family';
import About from './About.svelte';
@@ -29,7 +30,7 @@
let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false);
$effect(() => {
onMount(() => {
if (location.hash === '#about') aboutOpen = true;
});
/** The ledgers' tally, fetched when the about panel opens. */
@@ -43,21 +44,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.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: 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 = [
{ 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 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' }] : []),
...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.` };
@@ -627,5 +627,4 @@
margin: 0 auto;
border-radius: 4px;
}
</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,
// 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);
+7 -5
View File
@@ -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<Family> {
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;
}
}