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
@@ -0,0 +1,37 @@
---
name: waving-hands-visitors
description: The Waving Hands visitors report — who is at the table right now, who came today and how far each duel got, and today's traffic. Use when Eric asks who has been playing, about new players or visitors, "anyone playing?", or wants a traffic report.
---
# Waving Hands visitors
One command, one screen, printed by the droplet. Read it for Eric like a
host glancing over the hall.
## Gather
ssh root@159.203.98.71 waving-hands-visitors.sh [YYYY-MM-DD]
Default is today, UTC; Eric is in US Eastern, so an evening at his desk
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, play, rules) and room links opened,
referrers and campaign tags. Single-player duels live in browsers, so
a visit to /play is all the log shows of them.
- **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
seats, state (lobby only / started, no turns / in play), turn count,
span, and the last turn's time.
## Read it
Lead with new names and whether anyone is at the table now. Then, for
each stranger, say how far they got: a room that is "lobby only" never
filled its seats; "started, no turns" means they sat and left; a span of
a few minutes with a handful of turns is a duel tried; many turns is a
duel played. Eric's own names appear too (he plays as whatever he last
typed); do not count him as a visitor.
+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, - 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. `journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
- Restart: `ssh root@<ip> systemctl restart waving-hands` - 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. - 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' Copy that directory to back them up; single-player games are in players'
browsers. 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. # it needs only its code and the engine. Ledgers live outside this tree.
rsync -az --delete --exclude='/server/node_modules' \ rsync -az --delete --exclude='/server/node_modules' \
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \ --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='*' \ --exclude='*' \
./ "root@$HOST:/opt/waving-hands/app/" ./ "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 cd /opt/waving-hands/app/server && npm install --no-audit --no-fund
chown -R waving-hands:waving-hands /opt/waving-hands/app 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 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 daemon-reload
systemctl enable --now waving-hands systemctl enable --now waving-hands
systemctl restart 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