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:
co-authored by
Claude Opus 5.5
parent
57ae338cdb
commit
7f66be8016
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user