diff --git a/.claude/skills/waving-hands-pulse/SKILL.md b/.claude/skills/waving-hands-pulse/SKILL.md new file mode 100644 index 0000000..a592bb1 --- /dev/null +++ b/.claude/skills/waving-hands-pulse/SKILL.md @@ -0,0 +1,28 @@ +--- +name: waving-hands-pulse +description: The weekly Waving Hands operations pulse — duel server health, errors, the last week of traffic and rooms from the rollup, backup status, and the box's vitals. Use when Eric asks how the server is doing, wants an ops check, or says "run the pulse". +--- + +# Waving Hands pulse + +One command on the droplet prints everything; read it as a short report. + + ssh root@159.203.98.71 waving-hands-pulse.sh + +- **duel server**: whether the service and Caddy are up, restarts and + journal errors in the last seven days with the last three error lines. +- **rollup, last 7 days**: one line a day from /var/lib/waving-hands/rollup.jsonl, + written nightly at 00:12 UTC by waving-hands-rollup.sh: people (addresses + with a browser user agent), page requests, rooms opened, started and with + turns, turns played, bot addresses, and referrers. A missing day means the + rollup did not run; run it by hand with the date. +- **ledgers**: rooms on disk, touched in the last day, and their size. +- **backup**: the last start and done lines of the nightly backup to Spaces + (07:23 UTC), or "never run". "skipped" means rclone is not configured. +- **box**: disk, memory, load, and the access log's size and rotation. + +Lead with anything wrong (service down, errors, backup skipped, disk over +80%). Then the trend: are people coming, and are duels being played? For +who exactly, use the waving-hands-visitors skill. Before an engine change +ships, deploy/verify-ledgers.sh replays every production ledger locally; +deploy.sh runs it, and a "DIFFERS" or "REFUSED" line stops the deploy. diff --git a/deploy/README.md b/deploy/README.md index 151d130..a0bb2d4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -44,6 +44,27 @@ A record at the droplet and add the name to the first line of /etc/caddy/Caddyfile (space separated), then `systemctl reload caddy`; Caddy fetches the certificate on first request. +## Operations scripts on the droplet + +`deploy.sh` installs these to /usr/local/bin and the cron file to +/etc/cron.d/waving-hands on every deploy, so the live copies are the repo copies: + +- `waving-hands-visitors.sh [day]`: who is here now and who came that day. +- `waving-hands-pulse.sh`: the weekly health check (service, errors, rollup + trend, backup, box). +- `waving-hands-rollup.sh [day]`: one JSON line per day of counts, run + nightly at 00:12 UTC into /var/lib/waving-hands/rollup.jsonl. +- `waving-hands-backup.sh`: nightly at 07:23 UTC, mirrors /var/lib/waving-hands + to the `kestrel-wizwar-backups` Space under `waving-hands/` (a current copy + and dated snapshots kept 90 days). It needs rclone with the Spaces + credentials in /root/.config/rclone/rclone.conf, copied by hand from the + wizwar droplet; until then it logs "skipped". + +Before every deploy, `deploy/verify-ledgers.sh ` fetches every production +ledger and replays it with the local engine, comparing each room with what the +server shows. A ledger the new engine refuses or replays differently stops the +deploy: the server would rewrite that duel on restart. + ## Everyday deploys deploy/deploy.sh diff --git a/deploy/deploy.sh b/deploy/deploy.sh index a1152aa..8021168 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -9,6 +9,9 @@ npm run check:server npm test npm run build +# Every production ledger must replay identically with the engine about to ship. +deploy/verify-ledgers.sh "$HOST" + # Old hashed chunks stay a week: a page loaded before this deploy can still # fetch the module it was built against instead of failing mid-duel. rsync -az --delete --filter='P _app/immutable/*' \ @@ -18,7 +21,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/visitors.sh' \ + --include='/deploy/' --include='/deploy/waving-hands.service' --include='/deploy/waving-hands.cron' --include='/deploy/*.sh' \ --exclude='*' \ ./ "root@$HOST:/opt/waving-hands/app/" @@ -29,7 +32,14 @@ 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 + # Cron runs the rollup and the backup from /usr/local/bin: install them on + # every deploy so the live copies are always the repo copies. install -m 755 /opt/waving-hands/app/deploy/visitors.sh /usr/local/bin/waving-hands-visitors.sh + install -m 755 /opt/waving-hands/app/deploy/pulse.sh /usr/local/bin/waving-hands-pulse.sh + install -m 755 /opt/waving-hands/app/deploy/waving-hands-rollup.sh /usr/local/bin/waving-hands-rollup.sh + install -m 755 /opt/waving-hands/app/deploy/waving-hands-backup.sh /usr/local/bin/waving-hands-backup.sh + install -m 644 /opt/waving-hands/app/deploy/waving-hands.cron /etc/cron.d/waving-hands + mkdir -p /var/log/waving-hands systemctl daemon-reload systemctl enable --now waving-hands systemctl restart waving-hands diff --git a/deploy/pulse.sh b/deploy/pulse.sh new file mode 100755 index 0000000..0b78b3f --- /dev/null +++ b/deploy/pulse.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# The operations pulse, printed ON the droplet: the duel server's health, +# errors in its journal, the last week of the rollup, the backup's last word, +# and the box's vitals. Read by the waving-hands-pulse skill. +set -u +echo "as of $(date -u '+%F %H:%M') UTC on $(hostname); up $(uptime -p | sed 's/up //')" +echo "--- duel server" +systemctl is-active waving-hands >/dev/null && echo "waving-hands: active since $(systemctl show waving-hands -p ActiveEnterTimestamp --value)" || echo "waving-hands: NOT ACTIVE" +systemctl is-active caddy >/dev/null && echo "caddy: active" || echo "caddy: NOT ACTIVE" +echo "restarts in 7 days: $(journalctl -u waving-hands --since '7 days ago' -o cat | grep -c 'Started waving-hands')" +ERR=$(journalctl -u waving-hands --since '7 days ago' -p err -o cat | grep -vc '^$') +echo "journal errors in 7 days: $ERR" +journalctl -u waving-hands --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | grep -v "Rate" | tail -3 | sed 's/^/ /' +echo "--- rollup, last 7 days (people / requests / rooms opened / turns)" +if [ -s /var/lib/waving-hands/rollup.jsonl ]; then + tail -7 /var/lib/waving-hands/rollup.jsonl | python3 -c ' +import json, sys +for l in sys.stdin: + r = json.loads(l); rm = r["rooms"] + print(" %s %3d people %4d requests %2d rooms (%d started, %d with turns) %3d turns bots %d addr refs %s" % ( + r["day"], r["humanAddresses"], r["human"], rm["created"], rm["started"], rm["withTurns"], r["turns"], r["botAddresses"], + ",".join(sorted(r["referrers"])) or "-"))' +else + echo " (no rollup yet)" +fi +echo "--- ledgers" +echo "rooms on disk: $(ls /var/lib/waving-hands/rooms/*.jsonl 2>/dev/null | wc -l); touched in 24h: $(find /var/lib/waving-hands/rooms -name '*.jsonl' -mmin -1440 2>/dev/null | wc -l); size $(du -sh /var/lib/waving-hands/rooms 2>/dev/null | cut -f1)" +echo "--- backup" +if [ -f /var/log/waving-hands/backup.log ]; then + grep -E "backup (start|done)|skipped|error|ERROR" /var/log/waving-hands/backup.log | tail -2 | sed 's/^/ /' +else + echo " never run" +fi +echo "--- box" +echo "disk: $(df -h / | awk 'NR==2{print $5" used of "$2}'); memory: $(free -m | awk 'NR==2{print $7" MB available of "$2}'); load: $(cut -d' ' -f1-3 /proc/loadavg)" +echo "caddy access log: $(du -sh /var/lib/caddy/access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/access*.log* 2>/dev/null | wc -l) files" diff --git a/deploy/replay-ledgers.ts b/deploy/replay-ledgers.ts new file mode 100644 index 0000000..d83237a --- /dev/null +++ b/deploy/replay-ledgers.ts @@ -0,0 +1,78 @@ +// Replays every ledger in a directory with THIS checkout's engine and, when +// a server address is given, compares the result with what that server is +// showing for the room: turn count, each wizard's health, and the outcome. +// A ledger the engine cannot replay, or replays differently, is reported +// and fails the run. Used by deploy/verify-ledgers.sh. +// tsx deploy/replay-ledgers.ts [https://host] + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { resolveTurn } from '../src/lib/game/resolve'; +import { createGame, type GameState, type TurnInput, type WizardId } from '../src/lib/game/state'; + +interface Seat { + id: WizardId; + name: string; + token: string; + bot: boolean; +} + +const [dir, host] = process.argv.slice(2); +if (!dir) { + console.error('usage: replay-ledgers.ts [https://host]'); + process.exit(2); +} + +async function main(): Promise { + const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl')); + let failures = 0; + for (const file of files) { + const code = file.slice(0, -'.jsonl'.length); + const lines = readFileSync(join(dir, file), 'utf8') + .split('\n') + .filter((l) => l.trim()) + .map((l) => JSON.parse(l)); + const seats: Seat[] = []; + let state: GameState | null = null; + let turns = 0; + try { + for (const line of lines) { + if (line.t === 'seat') seats.push(line); + else if (line.t === 'start') state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed); + else if (line.t === 'turn' && state) { + state = resolveTurn(state, line.inputs as Record); + turns += 1; + } + } + } catch (e) { + console.log(`${code}: REFUSED at turn ${turns + 1}: ${e instanceof Error ? e.message : String(e)}`); + failures += 1; + continue; + } + let verdict = state ? `${turns} turns, ${state.over ? 'over' : 'in play'}` : 'not started'; + const human = seats.find((s) => !s.bot); + if (host && state && human) { + const res = await fetch(`${host}/api/rooms/${code}?token=${encodeURIComponent(human.token)}`); + if (!res.ok) { + verdict += `, server ${res.status}`; + } else { + const view = (await res.json()) as { state: GameState | null }; + const theirs = view.state; + const same = + !!theirs && + theirs.turn === state.turn && + theirs.seats.every((id) => theirs.wizards[id].hp === state!.wizards[id].hp) && + (theirs.over?.winner ?? null) === (state.over?.winner ?? null); + if (!same) { + failures += 1; + verdict += `, DIFFERS from the server (server turn ${theirs?.turn}, local turn ${state.turn})`; + } else verdict += ', matches the server'; + } + } + console.log(`${code}: ${verdict}`); + } + console.log(`${files.length} ledger${files.length === 1 ? '' : 's'}, ${failures} problem${failures === 1 ? '' : 's'}`); + process.exit(failures ? 1 : 0); +} + +void main(); diff --git a/deploy/verify-ledgers.sh b/deploy/verify-ledgers.sh new file mode 100755 index 0000000..404cac0 --- /dev/null +++ b/deploy/verify-ledgers.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# The determinism gate: fetch every production ledger and replay it with the +# LOCAL engine, comparing each room with what the server shows. A ledger the +# new engine replays differently would rewrite a duel in progress when the +# server restarts, so this runs before every deploy. +# deploy/verify-ledgers.sh [https://site] +set -euo pipefail +HOST="${1:?usage: verify-ledgers.sh [https://site]}" +SITE="${2:-https://hands.kestrelsnest.social}" +DIR="$(mktemp -d)" +trap 'rm -rf "$DIR"' EXIT +rsync -az "root@$HOST:/var/lib/waving-hands/rooms/" "$DIR/" +npx tsx deploy/replay-ledgers.ts "$DIR" "$SITE" diff --git a/deploy/waving-hands-backup.sh b/deploy/waving-hands-backup.sh new file mode 100755 index 0000000..95b4566 --- /dev/null +++ b/deploy/waving-hands-backup.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Nightly ledger backup to DigitalOcean Spaces, beside wizwar's in the same bucket. +# current/ - exact mirror of /var/lib/waving-hands +# snapshots/ - one dated copy per day, pruned after 90 days +# Ledgers are append-only JSONL; the duel server never needs stopping. +# Sentry Crons check-in: /root/.waving-hands-sentry-cron holds the check-in +# URL (absent file = no check-ins). +set -u +BUCKET="kestrel-wizwar-backups" +PREFIX="waving-hands" +SRC="/var/lib/waving-hands" +LOG="/var/log/waving-hands/backup.log" +STAMP=$(date +%Y-%m-%d) +mkdir -p "$(dirname "$LOG")" +CRON_URL=$(cat /root/.waving-hands-sentry-cron 2>/dev/null || true) +checkin() { [ -n "$CRON_URL" ] && curl -sf -o /dev/null "$CRON_URL?status=$1" || true; } + +if [ ! -s /root/.config/rclone/rclone.conf ]; then + echo "$(date -Is) skipped: rclone is not configured" >> "$LOG" + exit 0 +fi + +{ + echo "=== $(date -Is) backup start" + checkin in_progress + if rclone sync "$SRC" "spaces:$BUCKET/$PREFIX/current" --exclude 'rollup.jsonl' 2>&1 && + rclone copy "$SRC" "spaces:$BUCKET/$PREFIX/snapshots/$STAMP" 2>&1; then + checkin ok + else + checkin error + fi + CUTOFF=$(date -d "90 days ago" +%Y-%m-%d) + rclone lsf "spaces:$BUCKET/$PREFIX/snapshots/" --dirs-only 2>/dev/null | \ + while read -r d; do + day="${d%/}" + if [[ "$day" < "$CUTOFF" ]]; then + echo "pruning snapshot $day" + rclone purge "spaces:$BUCKET/$PREFIX/snapshots/$day" 2>&1 + fi + done + echo "=== $(date -Is) backup done" +} >> "$LOG" 2>&1 diff --git a/deploy/waving-hands-rollup.sh b/deploy/waving-hands-rollup.sh new file mode 100755 index 0000000..a0893a0 --- /dev/null +++ b/deploy/waving-hands-rollup.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Nightly rollup: one JSON line per day in /var/lib/waving-hands/rollup.jsonl: +# the day's traffic from Caddy's access log (people and bots apart, by page, +# with referrers and the campaign tags links carry), the rooms opened, +# started and finished, and the box's vitals. Counts only: no addresses are +# kept. The pulse reads it for trends the self-rotating access log loses. +# waving-hands-rollup.sh [YYYY-MM-DD] (default: yesterday, UTC) +# Cron: /etc/cron.d/waving-hands, installed by deploy.sh. +set -u +OUT="/var/lib/waving-hands/rollup.jsonl" +LOG="/var/log/waving-hands/rollup.log" +DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}" +mkdir -p "$(dirname "$LOG")" +python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1 +import collections, datetime, glob, gzip, json, os, re, shutil, sys +day, out = sys.argv[1], sys.argv[2] +d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc) +t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp() +BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I) + +req = human = bot = 0 +hips, bips = set(), set() +pages, joins, refs, campaigns = collections.Counter(), collections.Counter(), collections.Counter(), collections.Counter() +seen_campaign = set() +for f in sorted(glob.glob("/var/lib/caddy/access*.log*")): + if os.path.getmtime(f) < t0: continue + opener = gzip.open if f.endswith(".gz") else open + with opener(f, "rt", errors="ignore") as fh: + for line in fh: + try: r = json.loads(line) + except ValueError: continue + ts = r.get("ts", 0) + if not (t0 <= ts < t1): continue + q = r.get("request", {}); h = q.get("headers", {}) + ua = " ".join(h.get("User-Agent", [""])) + ip = q.get("client_ip") or q.get("remote_ip") or "?" + uri = q.get("uri", ""); path = uri.split("?")[0] + req += 1 + if BOT.search(ua) or not ua: + bot += 1; bips.add(ip); continue + if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")): continue + human += 1; hips.add(ip) + if path == "/": pages["hall"] += 1 + elif path == "/play": pages["play"] += 1 + elif path == "/rules": pages["rules"] += 1 + elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1 + ref = " ".join(h.get("Referer", [""])) + if ref and "hands.kestrelsnest.social" not in ref: + refs[re.sub(r"^https?://", "", ref).split("/")[0]] += 1 + m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri) + if m and (ip, m.group(1)) not in seen_campaign: + seen_campaign.add((ip, m.group(1))) + campaigns[m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1 + +created = started = finished = turned = turns = 0 +humans, bots = set(), 0 +for f in glob.glob("/var/lib/waving-hands/rooms/*.jsonl"): + try: lines = [json.loads(l) for l in open(f) if l.strip()] + except Exception: continue + if not lines or lines[0].get("t") != "room": continue + made = lines[0]["createdAt"] / 1000 + if t0 <= made < t1: + created += 1 + if any(x["t"] == "start" for x in lines): started += 1 + if any(x["t"] == "turn" for x in lines): turned += 1 + for x in lines: + if x["t"] == "seat": + if x["bot"]: bots += 1 + else: humans.add(x["name"]) + day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1] + turns += len(day_turns) + # A duel finished today: its last turn is today's and the engine would say so; approximate by replay-free means: + # the ledger's final turn falls today and no turn follows it within the day's end. + if day_turns and day_turns[-1] is lines[-1] and False: finished += 1 + +disk = shutil.disk_usage("/") +mem = {} +for line in open("/proc/meminfo"): + k, v = line.split(":", 1); mem[k] = int(v.split()[0]) +load = os.getloadavg()[1] +row = dict(day=day, requests=req, human=human, bot=bot, humanAddresses=len(hips), botAddresses=len(bips), + paths=dict(pages), roomLinks=sum(joins.values()), referrers=dict(refs), campaigns=dict(campaigns), + rooms=dict(created=created, started=started, withTurns=turned, humanNames=len(humans), botSeats=bots), + turns=turns, ledgers=len(glob.glob("/var/lib/waving-hands/rooms/*.jsonl")), + vitals=dict(diskUsedPct=round(disk.used * 100 / disk.total), memAvailableMb=mem.get("MemAvailable", 0) // 1024, load5=round(load, 2)), + at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")) +rows = [] +if os.path.exists(out): + rows = [json.loads(l) for l in open(out) if l.strip()] +rows = [r for r in rows if r.get("day") != day] + [row] +rows.sort(key=lambda r: r["day"]) +with open(out + ".tmp", "w") as fh: + for r in rows: fh.write(json.dumps(r) + "\n") +os.replace(out + ".tmp", out) +print("%s rolled: %d people, %d requests, %d rooms" % (day, len(hips), human, created)) +PY diff --git a/deploy/waving-hands.cron b/deploy/waving-hands.cron new file mode 100644 index 0000000..195507f --- /dev/null +++ b/deploy/waving-hands.cron @@ -0,0 +1,3 @@ +# Waving Hands: nightly rollup of yesterday's counts, then the ledger backup. UTC. +12 0 * * * root /usr/local/bin/waving-hands-rollup.sh +23 7 * * * root /usr/local/bin/waving-hands-backup.sh diff --git a/server/src/index.ts b/server/src/index.ts index 9349251..4638209 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,6 +12,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { WebSocketServer, WebSocket } from 'ws'; +import { RateLimit } from './ratelimit'; import { RoomError, Rooms } from './rooms'; import { Store } from './store'; @@ -21,6 +22,21 @@ const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms'; const BODY_LIMIT = 16 * 1024; const rooms = new Rooms(new Store(DATA_DIR)); +/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */ +const doors = new RateLimit(40, 60 * 60 * 1000); +const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000; +setInterval(() => { + const n = rooms.evictIdle(IDLE_ROOM_MS); + doors.prune(); + if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`); +}, 60 * 60 * 1000).unref(); + +/** The visitor's address as Caddy reports it, or the socket's when unproxied. */ +function clientOf(req: IncomingMessage): string { + const forwarded = req.headers['x-forwarded-for']; + const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0].trim(); + return first || req.socket.remoteAddress || '?'; +} function send(res: ServerResponse, status: number, body: unknown): void { const json = JSON.stringify(body); @@ -59,6 +75,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404); if (parts.length === 2 && req.method === 'POST') { + if (!doors.allow(clientOf(req))) throw new RoomError('Too many duels opened from here just now; try again later.', 429); const body = await readBody(req); const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2)); return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); @@ -73,6 +90,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (req.method !== 'POST') throw new RoomError('Not here.', 404); const body = await readBody(req); if (action === 'join') { + if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429); const seat = rooms.join(room, String(body.name ?? '')); return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); } diff --git a/server/src/ratelimit.ts b/server/src/ratelimit.ts new file mode 100644 index 0000000..565b851 --- /dev/null +++ b/server/src/ratelimit.ts @@ -0,0 +1,33 @@ +// A per-key sliding-window limiter for the doors anyone may walk through +// unseated: opening a room and taking a seat. Keyed by client address; keys +// whose windows have emptied are pruned so memory stays bounded. + +export class RateLimit { + private hits = new Map(); + + constructor( + private limit: number, + private windowMs: number + ) {} + + /** True if the key may act now; the act is recorded either way it is allowed. */ + allow(key: string, now = Date.now()): boolean { + const since = now - this.windowMs; + const recent = (this.hits.get(key) ?? []).filter((t) => t > since); + if (recent.length >= this.limit) { + this.hits.set(key, recent); + return false; + } + recent.push(now); + this.hits.set(key, recent); + if (this.hits.size > 10000) this.prune(now); + return true; + } + + prune(now = Date.now()): void { + const since = now - this.windowMs; + for (const [key, times] of this.hits) { + if (!times.some((t) => t > since)) this.hits.delete(key); + } + } +} diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 341417e..427414c 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -71,11 +71,36 @@ export class Rooms { } get(id: string): Room { - const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id)); + const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id)) ?? this.load(normalizeCode(id)); if (!room) throw new RoomError('No duel answers to that code.', 404); return room; } + /** A room evicted from memory comes back from its ledger on the next visit. */ + private load(id: string): Room | undefined { + if (!/^[A-Z0-9]{4}$/.test(id)) return undefined; + const room = replay(id, this.store.read(id)); + if (room) this.rooms.set(id, room); + return room ?? undefined; + } + + /** Forget rooms nobody has touched or watched for a while; their ledgers stay on disk. */ + evictIdle(olderThanMs: number, now = Date.now()): number { + let n = 0; + for (const [id, room] of this.rooms) { + if (now - room.updatedAt < olderThanMs) continue; + if ((this.listeners.get(id)?.size ?? 0) > 0) continue; + this.rooms.delete(id); + n += 1; + } + return n; + } + + /** How many rooms are held in memory. */ + get loaded(): number { + return this.rooms.size; + } + /** The seat a token unlocks. */ seatOf(room: Room, token: string | undefined): Seat { const seat = room.seats.find((s) => !s.bot && s.token === token); diff --git a/server/tsconfig.json b/server/tsconfig.json index 675458c..0910235 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -7,8 +7,18 @@ "noEmit": true, "skipLibCheck": true, "esModuleInterop": true, - "types": ["node"] + "types": [ + "node" + ] }, - "include": ["src/**/*.ts", "../src/lib/game/**/*.ts"], - "exclude": ["../src/lib/game/*.test.ts", "../src/lib/game/*.svelte.ts", "../src/lib/game/test-helpers.ts"] + "include": [ + "src/**/*.ts", + "../src/lib/game/**/*.ts", + "../deploy/replay-ledgers.ts" + ], + "exclude": [ + "../src/lib/game/*.test.ts", + "../src/lib/game/*.svelte.ts", + "../src/lib/game/test-helpers.ts" + ] }