diff --git a/.claude/skills/waving-hands-visitors/SKILL.md b/.claude/skills/waving-hands-visitors/SKILL.md new file mode 100644 index 0000000..e15fb78 --- /dev/null +++ b/.claude/skills/waving-hands-visitors/SKILL.md @@ -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. diff --git a/deploy/README.md b/deploy/README.md index 5f325fe..151d130 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -60,6 +60,10 @@ seconds; Caddy holds requests that land in the gap. - Logs: `ssh root@ 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@ systemctl restart waving-hands` +- Who has been playing: `ssh root@ 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/.jsonl`, one ledger per duel. Copy that directory to back them up; single-player games are in players' browsers. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index be09a44..a1152aa 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -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 diff --git a/deploy/visitors.sh b/deploy/visitors.sh new file mode 100755 index 0000000..507f61e --- /dev/null +++ b/deploy/visitors.sh @@ -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