A visitors digest for the droplet, in wizwar's shape

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-22 21:53:31 -04:00
co-authored by Claude Fable 5.1
parent 40fcf31faa
commit 098dadf482
4 changed files with 154 additions and 1 deletions
+4
View File
@@ -60,6 +60,10 @@ seconds; Caddy holds requests that land in the gap.
- Logs: `ssh root@<ip> journalctl -u waving-hands -f` for the duel server,
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
- Restart: `ssh root@<ip> systemctl restart waving-hands`
- Who has been playing: `ssh root@<ip> waving-hands-visitors.sh [YYYY-MM-DD]`
prints today's visitors from Caddy's log (people and bots apart, by page),
every room opened today with how far it got, and the names seated, marking
those seen for the first time.
- Rooms: `/var/lib/waving-hands/rooms/<CODE>.jsonl`, one ledger per duel.
Copy that directory to back them up; single-player games are in players'
browsers.
+2 -1
View File
@@ -18,7 +18,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
# it needs only its code and the engine. Ledgers live outside this tree.
rsync -az --delete --exclude='/server/node_modules' \
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \
--include='/deploy/' --include='/deploy/waving-hands.service' \
--include='/deploy/' --include='/deploy/waving-hands.service' --include='/deploy/visitors.sh' \
--exclude='*' \
./ "root@$HOST:/opt/waving-hands/app/"
@@ -29,6 +29,7 @@ ssh "root@$HOST" '
cd /opt/waving-hands/app/server && npm install --no-audit --no-fund
chown -R waving-hands:waving-hands /opt/waving-hands/app
cp /opt/waving-hands/app/deploy/waving-hands.service /etc/systemd/system/waving-hands.service
install -m 755 /opt/waving-hands/app/deploy/visitors.sh /usr/local/bin/waving-hands-visitors.sh
systemctl daemon-reload
systemctl enable --now waving-hands
systemctl restart waving-hands
+111
View File
@@ -0,0 +1,111 @@
#!/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 = [], [], False, 0, created
for x in lines[1:]:
if x["t"] == "seat":
(bots_seated if x["bot"] else humans).append(x["name"])
elif x["t"] == "start":
started = True
elif x["t"] == "turn":
turns += 1
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
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))
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:
state = "lobby only" if not r["started"] else ("started, no turns" if not r["turns"] 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" % (
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"))
PY