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
122 lines
6.1 KiB
Bash
Executable File
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
|