Files
waving-hands/deploy/visitors.sh
T
Eric WagonerandClaude Fable 5.1 5c79a65319 A crowded table: keyed threats, a compact roster, a ledger selector, host-only starts
Threats are keyed by seat as well as hand and spell, so two opponents
building the same spell no longer crash the board (regression test).
Past three wizards the rail lists the others on a line each with health,
condition and whether they have moved; a row of names turns the ledger
toward any wizard, as do pressing a warning or choosing a target; each
seat keeps one colour across the ledger, the warnings and the rail. The
result strip folds untouched wizards into one line. A full table no
longer begins by itself: the host begins at every size and may send a
bot away. Target lists carry health and condition, and an intent
sentence says what the reveal will do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-09-23 11:21:13 -04:00

122 lines
6.1 KiB
Bash
Executable File

#!/bin/bash
# The visitors digest: who is at the table right now and who came today,
# the rooms opened and how far each got, with today's traffic so far.
# Runs ON the droplet: visitors.sh [YYYY-MM-DD] (default: today, UTC)
# Single-player duels live in players' browsers, so they show only as
# visits to /play; duels between people show in full from their ledgers.
set -u
DAY="${1:-$(date -u +%F)}"
python3 - "$DAY" <<'PY'
import datetime, glob, json, re, subprocess, sys
from collections import Counter
day = sys.argv[1]
now = datetime.datetime.now(datetime.timezone.utc)
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
def sh(cmd):
try:
return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip()
except Exception:
return "?"
print("live sockets: %s | rooms touched in the last hour: %s" % (
sh("ss -tn state established '( sport = :8788 )' | tail -n +2 | wc -l"),
sh("find /var/lib/waving-hands/rooms -name '*.jsonl' -mmin -60 2>/dev/null | wc -l")))
# --- traffic: Caddy's access log, people and bots apart ---
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|preview|fetch|curl|python|go-http|headless|scan|monitor|uptime", re.I)
PAGES = {"/": "hall", "/play": "play", "/rules": "rules"}
addresses, bots = set(), set()
pages, joins, referrers, campaigns, agents = Counter(), Counter(), Counter(), Counter(), Counter()
requests = 0
for path in sorted(glob.glob("/var/lib/caddy/access.log*")):
try:
with open(path) as f:
for line in f:
try:
x = json.loads(line)
except Exception:
continue
ts = datetime.datetime.fromtimestamp(x["ts"], datetime.timezone.utc)
if ts.strftime("%F") != day:
continue
req = x["request"]
ua = " ".join(req.get("headers", {}).get("User-Agent", [""]))
ip = req.get("remote_ip") or req.get("client_ip", "?")
uri = req.get("uri", "")
path_only = uri.split("?")[0]
if BOT.search(ua) or not ua:
bots.add(ip)
continue
if path_only.startswith("/_app/") or path_only.startswith("/api/") or path_only == "/ws" or path_only.endswith((".png", ".ico", ".txt")):
continue
requests += 1
addresses.add(ip)
if path_only in PAGES:
pages[PAGES[path_only]] += 1
elif path_only.startswith("/join/"):
joins[path_only.split("/")[2].upper()] += 1
ref = " ".join(req.get("headers", {}).get("Referer", [""]))
if ref and "hands.kestrelsnest.social" not in ref:
referrers[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
if m:
campaigns[m.group(1) + "=" + m.group(2)[:24]] += 1
agents[re.sub(r"\(.*?\)", "", ua)[:40].strip()] += 1
except OSError:
continue
print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, play %d, rules %d, room links %s; referrers %s; campaigns %s" % (
len(addresses), requests, len(bots), pages["hall"], pages["play"], pages["rules"],
dict(joins) or "none", dict(referrers) or "none", dict(campaigns) or "none"))
# --- rooms: every ledger, today's in detail ---
first_seen = {}
rooms = []
for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"):
try:
lines = [json.loads(l) for l in open(f) if l.strip()]
meta = lines[0]
assert meta["t"] == "room"
except Exception:
continue
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
names = {}
for x in lines[1:]:
if x["t"] == "seat":
(bots_seated if x["bot"] else humans).append(x["name"])
names[x["id"]] = x["name"]
elif x["t"] == "unseat":
bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None
elif x["t"] == "start":
started = True
elif x["t"] == "turn":
turns += 1
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
elif x["t"] == "chat":
talk += 1
elif x["t"] == "over":
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
for h in humans:
if h not in first_seen or created < first_seen[h]:
first_seen[h] = created
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk))
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
names = sorted({h for r in today for h in r["humans"]})
new = [n for n in names if first_seen[n].strftime("%F") == day]
print("players today: %s | NEW today: %s" % (", ".join(names) or "none", ", ".join(new) or "none"))
print("rooms today: %d (of %d ever)" % (len(today), len(rooms)))
for r in today:
idle_h = (now - r["last"]).total_seconds() / 3600
state = "lobby only" if not r["started"] else ("started, no turns" if not r["turns"] else (r["over"] or ("abandoned" if idle_h > 12 else "in play")))
span = ("%d min" % round((r["last"] - r["created"]).total_seconds() / 60)) if r["turns"] else ""
seated = "+".join(r["humans"]) + ((" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "")
empty = r["size"] - len(r["humans"]) - len(r["bots"])
print(" %s %s %s%s %s %d turns %s last turn %s%s" % (
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
PY