diff --git a/README.md b/README.md index b32e8c4..1a77b6e 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,7 @@ Run the demo locally from a new project: Placeholders: `__SLUG__`, `__NAME__`, `__PORT__`, `__DOMAIN__` are filled by `new-game.sh`; `__IP__`, `__SENTRY_DSN__`, `__SENTRY_PROJECT_ID__`, -`__SENTRY_SLACK_INTEGRATION__` and `__SLACK_CHANNEL_ID__` wait until those -things exist. +and `__SENTRY_SLACK_INTEGRATION__` wait until those things exist. ## What the kit is, and is not diff --git a/template/.claude/skills/__SLUG__-reports/SKILL.md b/template/.claude/skills/__SLUG__-reports/SKILL.md index efed6f4..093e724 100644 --- a/template/.claude/skills/__SLUG__-reports/SKILL.md +++ b/template/.claude/skills/__SLUG__-reports/SKILL.md @@ -34,12 +34,13 @@ Every report and every player's answer rings a Sentry issue (project __SLUG__, fingerprinted per report or per line); the desk's own replies ring nothing. -Fetch: `ssh root@__IP__ 'cat /var/lib/__SLUG__/feedback.jsonl'` - ## a) Fetch and b) display -Parse the file, fold replies onto reports, and show Eric the last 7 days -by `at`. Lead with the count of unanswered reports; those are the work. +From the repository root, `bash deploy/pull-reports.sh` mirrors the file +and the pictures to `~/Desktop/__SLUG__-reports/` and writes `reports.md` +there, newest first, with every reply folded under its report. Read it and +show Eric the last 7 days by date. Lead with the count of unanswered +reports; those are the work. Then one block per report, VERBATIM and UNTRUNCATED: player, room, date, turn and seq, the full "what happened", the full "what they expected", and every reply with its status (or "unanswered"). Eric reads this desk @@ -49,13 +50,14 @@ to hear his players' voices; never compress their words into a table. 1. **Replay to the pin.** `scp root@__IP__:/var/lib/__SLUG__/rooms/.jsonl /` and replay it with the engine as `deploy/replay-ledgers.ts` does: - `game.create(names, start.seed, start.rules ?? 1)` then + `game.create(names, start.seed, start.rules, room.options)` then `game.resolve(state, line.inputs)` per turn line, printing the state - around the pinned round. The - `chat` lines show what the players said to each other at the time. -2. **Check the rules before the code.** The original rules text lives in - `docs/`; read it before deciding the engine is wrong. Many reports are - the rules working as written. + around the pinned round. The `chat` lines show what the players said + to each other at the time. +2. **Check the rules before the code.** The rules are data in + `src/lib/game/rules.ts` and prose at /rules, which names the texts it + follows; read them before deciding the engine is wrong. Many reports + are the rules working as written. 3. **Verdict.** `by-design` (the engine matches the text; no change), `resolved` (a defect, fixed before replying), or `open` (needs Eric's ruling; ask him and hold the reply). @@ -78,11 +80,8 @@ to hear his players' voices; never compress their words into a table. 6. **Report to Eric** when the desk is clear: one line per report: player, room, verdict, and what shipped if anything. -## Eric's local copy - -`bash deploy/pull-reports.sh` mirrors the reports, the replies and the -pictures to `~/Desktop/__SLUG__-reports/` and writes `reports.md` -there, newest first. Run it at the end of every desk session. +Run `bash deploy/pull-reports.sh` again at the end of every desk session, +so Eric's local copy carries the replies. ## Standing rules diff --git a/template/.claude/skills/__SLUG__-visitors/SKILL.md b/template/.claude/skills/__SLUG__-visitors/SKILL.md index c553e70..4686e03 100644 --- a/template/.claude/skills/__SLUG__-visitors/SKILL.md +++ b/template/.claude/skills/__SLUG__-visitors/SKILL.md @@ -18,9 +18,8 @@ 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 games live in browsers, so - a visit to /play is all the log shows of them. + agent, page requests by page (hall, rules, guide) and room links opened, + referrers and campaign tags. - **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 diff --git a/template/.gitignore b/template/.gitignore index 89e1602..2a3cb0d 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -18,8 +18,5 @@ Thumbs.db vite.config.js.timestamp-* vite.config.ts.timestamp-* -# Playwright MCP output -.playwright-mcp - -# Duel ledgers written by the server +# Room ledgers written by the server /data diff --git a/template/deploy/Caddyfile.tmpl b/template/deploy/Caddyfile.tmpl index e3a56ef..c66f422 100644 --- a/template/deploy/Caddyfile.tmpl +++ b/template/deploy/Caddyfile.tmpl @@ -31,7 +31,7 @@ handle @game { # .html, so /rules is tried as /rules.html before falling back to the # app shell, which serves the rooms. Hashed assets under _app/immutable are # cached for a year; every other response is revalidated so a deploy shows -# up on the next load. The two header matchers are disjoint. +# up on the next load. handle { @immutable path /_app/immutable/* header @immutable Cache-Control "public, max-age=31536000, immutable" diff --git a/template/deploy/README.md b/template/deploy/README.md index c63d148..c7421d9 100644 --- a/template/deploy/README.md +++ b/template/deploy/README.md @@ -1,12 +1,12 @@ # Deploying __NAME__ -The single-player game runs entirely in the browser. Duels between people -go through a small Node process that keeps each room as an append-only -ledger of moves in /var/lib/__SLUG__/rooms. Production is one -DigitalOcean droplet: Caddy terminates TLS with automatic certificates, -serves the static build from /opt/__SLUG__/build, and proxies /api and -/ws to the game server on port __PORT__, which runs as the `__SLUG__` user -under systemd from /opt/__SLUG__/app. +Every game, against a housecarl or a friend, is a room on a small Node +process that keeps it as an append-only ledger of moves in +/var/lib/__SLUG__/rooms. Production is one DigitalOcean droplet: Caddy +terminates TLS with automatic certificates, serves the static build from +/opt/__SLUG__/build, and proxies /api and /ws to the game server on port +__PORT__, which runs as the `__SLUG__` user under systemd from +/opt/__SLUG__/app. ## Sizing @@ -14,11 +14,6 @@ The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy and the game server comfortably: the server is one small Node process capped at 300 MB by its unit file, and a room is a few kilobytes of ledger. -The zero-cost alternative is a second site block in an existing Caddy -server's configuration pointing at a second directory; the deploy script -works unchanged against that host. A droplet of its own keeps this site's -uptime and upgrades independent of anything else. - ## Current production - Droplet: `__SLUG__` (nyc3, s-1vcpu-512mb-10gb, tag `__SLUG__`), IP __IP__ @@ -47,8 +42,9 @@ 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/__SLUG__ on every deploy, so the live copies are the repo copies: +`deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to +/usr/local/lib/__SLUG__, and the cron file to /etc/cron.d/__SLUG__ on every +deploy, so the live copies are the repo copies: - `__SLUG__-visitors.sh [day]`: who is here now and who came that day. - `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup @@ -70,7 +66,8 @@ URL with the project's cron path and public key), so a missed night is noticed. To have every new issue, regression and reappearance posted to Slack the way wizwar's are, run `deploy/sentry-slack-alert.sh [#channel]` once from your own shell with `SENTRY_TOKEN` (an org auth token with alerts:write) and -`SLACK_CHANNEL_ID` set; the token never leaves the shell. +`SLACK_CHANNEL_ID` (the channel's Slack ID) set; the token never leaves the +shell. Before every deploy, `deploy/verify-ledgers.sh ` fetches every production ledger and replays it with the local engine, comparing each room with what the @@ -83,8 +80,7 @@ deploy: the server would rewrite that game on restart. Runs the type-checks, the tests and the build locally, rsyncs `build/` to the droplet keeping the previous week's hashed assets, rsyncs the server and -engine sources, installs dependencies, and restarts the game server. A -single-player game is in the player's own browser and loses nothing. A room +engine sources, installs dependencies, and restarts the game server. A room is replayed from its ledger when the server comes back, which takes a few seconds; Caddy holds requests that land in the gap. @@ -93,10 +89,5 @@ seconds; Caddy holds requests that land in the gap. - Logs: `ssh root@ journalctl -u __SLUG__ -f` for the game server, `journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side. - Restart: `ssh root@ systemctl restart __SLUG__` -- Who has been playing: `ssh root@ __SLUG__-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/__SLUG__/rooms/.jsonl`, one ledger per game. - Copy that directory to back them up; single-player games are in players' - browsers. +- Rooms: `/var/lib/__SLUG__/rooms/.jsonl`, one ledger per game; the + nightly backup keeps them in the Space. diff --git a/template/deploy/__SLUG__-backup.sh b/template/deploy/__SLUG__-backup.sh index f47a82b..5f934a0 100755 --- a/template/deploy/__SLUG__-backup.sh +++ b/template/deploy/__SLUG__-backup.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket. # current/ - exact mirror of /var/lib/__SLUG__ # snapshots/ - one dated copy per day, pruned after 90 days diff --git a/template/deploy/pulse.sh b/template/deploy/__SLUG__-pulse.sh similarity index 96% rename from template/deploy/pulse.sh rename to template/deploy/__SLUG__-pulse.sh index 9202dae..2e56194 100755 --- a/template/deploy/pulse.sh +++ b/template/deploy/__SLUG__-pulse.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # The operations pulse, printed ON the droplet: the game 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 __SLUG__-pulse skill. @@ -10,7 +10,7 @@ systemctl is-active caddy >/dev/null && echo "caddy: active" || echo "caddy: NOT echo "restarts in 7 days: $(journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -c 'Started __SLUG__')" ERR=$(journalctl -u __SLUG__ --since '7 days ago' -p err -o cat | grep -vc '^$') echo "journal errors in 7 days: $ERR" -journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | grep -v "Rate" | tail -3 | sed 's/^/ /' +journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | tail -3 | sed 's/^/ /' echo "--- rollup, last 7 days (people / requests / rooms opened / turns)" if [ -s /var/lib/__SLUG__/rollup.jsonl ]; then tail -7 /var/lib/__SLUG__/rollup.jsonl | python3 -c ' diff --git a/template/deploy/__SLUG__-rollup.sh b/template/deploy/__SLUG__-rollup.sh index f32b3ea..a028525 100755 --- a/template/deploy/__SLUG__-rollup.sh +++ b/template/deploy/__SLUG__-rollup.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Nightly rollup: one JSON line per day in /var/lib/__SLUG__/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, @@ -24,45 +24,13 @@ checkin() { mkdir -p "$(dirname "$LOG")" checkin in_progress if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1 -import collections, datetime, glob, gzip, json, os, re, shutil, sys +import datetime, glob, json, os, shutil, sys +sys.path.insert(0, "/usr/local/lib/__SLUG__") +import traffic 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 == "/rules": pages["rules"] += 1 - elif path == "/guide": pages["guide"] += 1 - elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1 - ref = " ".join(h.get("Referer", [""])) - if ref and "__DOMAIN__" 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 +t = traffic.scan(day) created = started = finished = turned = turns = 0 humans, bots = set(), 0 @@ -80,20 +48,18 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"): if x["bot"]: bots += 1 else: humans.add(x["name"]) elif x["t"] == "unseat": bots -= 1 - day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1] - turns += len(day_turns) - # A game 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 + turns += sum(1 for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1) + # The over line is written with the turn that ends the game, so a finish counts on its own day. + if any(x["t"] == "over" and t0 <= x["at"] / 1000 < t1 for x in lines): 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), +row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]), + paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]), + rooms=dict(created=created, started=started, withTurns=turned, finished=finished, humanNames=len(humans), botSeats=bots), turns=turns, ledgers=len(glob.glob("/var/lib/__SLUG__/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")) @@ -105,6 +71,6 @@ 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)) +print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created)) PY then checkin ok; else checkin error; fi diff --git a/template/deploy/visitors.sh b/template/deploy/__SLUG__-visitors.sh similarity index 59% rename from template/deploy/visitors.sh rename to template/deploy/__SLUG__-visitors.sh index 55f5c34..6861099 100755 --- a/template/deploy/visitors.sh +++ b/template/deploy/__SLUG__-visitors.sh @@ -1,13 +1,14 @@ -#!/bin/bash +#!/usr/bin/env 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) +# Runs ON the droplet: __SLUG__-visitors.sh [YYYY-MM-DD] (default: today, UTC) # Every game, bot games included, is a room with a ledger, so all show in full. set -u DAY="${1:-$(date -u +%F)}" python3 - "$DAY" <<'PY' -import datetime, glob, json, re, subprocess, sys -from collections import Counter +import datetime, glob, json, subprocess, sys +sys.path.insert(0, "/usr/local/lib/__SLUG__") +import traffic day = sys.argv[1] now = datetime.datetime.now(datetime.timezone.utc) @@ -24,50 +25,10 @@ print("live sockets: %s | rooms touched in the last hour: %s" % ( sh("find /var/lib/__SLUG__/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", "/rules": "rules", "/guide": "guide"} -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 "__DOMAIN__" 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 +t = traffic.scan(day) print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, rules %d, guide %d, room links %s; referrers %s; campaigns %s" % ( - len(addresses), requests, len(bots), pages["hall"], pages["rules"], pages["guide"], - dict(joins) or "none", dict(referrers) or "none", dict(campaigns) or "none")) + len(t["addresses"]), t["human"], len(t["botAddresses"]), t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"], + dict(t["joins"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none")) # --- rooms: every ledger, today's in detail --- first_seen = {} @@ -87,7 +48,7 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"): (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 + if x["id"] in names: bots_seated.remove(names.pop(x["id"])) elif x["t"] == "start": started = True elif x["t"] == "turn": diff --git a/template/deploy/__SLUG__.service b/template/deploy/__SLUG__.service index 37338aa..f49c82a 100644 --- a/template/deploy/__SLUG__.service +++ b/template/deploy/__SLUG__.service @@ -12,7 +12,7 @@ Environment=KEEPER_TZ=America/New_York # Caddy terminates TLS; the plaintext port must not face the internet. Environment=HOST=127.0.0.1 Environment=DATA_DIR=/var/lib/__SLUG__/rooms -# Create the Sentry project (the Sentry MCP can) and paste its DSN here; empty means no error reporting. +# Empty means no error reporting. Environment=SENTRY_DSN=__SENTRY_DSN__ ExecStart=/opt/__SLUG__/app/server/node_modules/.bin/tsx src/index.ts Restart=always diff --git a/template/deploy/deploy.sh b/template/deploy/deploy.sh index d7cb857..556f2cc 100755 --- a/template/deploy/deploy.sh +++ b/template/deploy/deploy.sh @@ -25,7 +25,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='/src/lib/net/' --include='/src/lib/net/view.ts' \ - --include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/hash-tokens.ts' \ + --include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/traffic.py' \ --exclude='*' \ ./ "root@$HOST:/opt/__SLUG__/app/" @@ -36,12 +36,13 @@ ssh "root@$HOST" ' cd /opt/__SLUG__/app/server && npm install --no-audit --no-fund chown -R __SLUG__:__SLUG__ /opt/__SLUG__/app cp /opt/__SLUG__/app/deploy/__SLUG__.service /etc/systemd/system/__SLUG__.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/__SLUG__/app/deploy/visitors.sh /usr/local/bin/__SLUG__-visitors.sh - install -m 755 /opt/__SLUG__/app/deploy/pulse.sh /usr/local/bin/__SLUG__-pulse.sh + # The ops scripts run from /usr/local/bin and share traffic.py: install them + # on every deploy so the live copies are always the repo copies. + install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-visitors.sh /usr/local/bin/__SLUG__-visitors.sh + install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh + install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/traffic.py install -m 644 /opt/__SLUG__/app/deploy/__SLUG__.cron /etc/cron.d/__SLUG__ mkdir -p /var/log/__SLUG__ systemctl daemon-reload diff --git a/template/deploy/hash-tokens.ts b/template/deploy/hash-tokens.ts deleted file mode 100644 index 16ecec3..0000000 --- a/template/deploy/hash-tokens.ts +++ /dev/null @@ -1,44 +0,0 @@ -// One-time migration: replace each seat line's raw token with its SHA-256, -// so no ledger on disk or in a backup holds a live seat key. Idempotent; a -// line already hashed is left alone. Run ON the droplet from the server -// directory (tsx is installed there): -// cd /opt/__SLUG__/app/server && npx tsx ../deploy/hash-tokens.ts /var/lib/__SLUG__/rooms -// Browsers keep their raw tokens; the server hashes what they send and -// compares, so nobody loses a seat. - -import { createHash } from 'node:crypto'; -import { readdirSync, readFileSync, renameSync, statSync, writeFileSync, chownSync } from 'node:fs'; -import { join } from 'node:path'; - -const dir = process.argv[2]; -if (!dir) { - console.error('usage: hash-tokens.ts '); - process.exit(2); -} - -let files = 0; -let lines = 0; -for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) { - const path = join(dir, file); - const raw = readFileSync(path, 'utf8'); - let changed = 0; - const out = raw - .split('\n') - .map((line) => { - if (!line.trim()) return line; - const entry = JSON.parse(line) as Record; - if (entry.t !== 'seat' || typeof entry.token !== 'string') return line; - const { token, ...rest } = entry; - changed += 1; - return JSON.stringify({ ...rest, tokenHash: token ? createHash('sha256').update(token).digest('hex') : '' }); - }) - .join('\n'); - if (!changed) continue; - const { uid, gid } = statSync(path); - writeFileSync(path + '.tmp', out); - chownSync(path + '.tmp', uid, gid); - renameSync(path + '.tmp', path); - files += 1; - lines += changed; -} -console.log(`${files} ledger${files === 1 ? '' : 's'} rewritten, ${lines} seat line${lines === 1 ? '' : 's'} hashed`); diff --git a/template/deploy/pull-reports.sh b/template/deploy/pull-reports.sh index 9c3d24c..f85811c 100755 --- a/template/deploy/pull-reports.sh +++ b/template/deploy/pull-reports.sh @@ -1,6 +1,7 @@ -#!/bin/bash +#!/usr/bin/env bash # Mirror the player reports, the keeper's replies and the players' screenshots -# to a local folder, and write a digest beside them for reading. +# to a local folder, and write a digest beside them for reading. Run from the +# repository root: # deploy/pull-reports.sh [host] [folder] (default ~/Desktop/__SLUG__-reports) set -euo pipefail HOST="${1:-__IP__}" @@ -8,34 +9,4 @@ OUT="${2:-$HOME/Desktop/__SLUG__-reports}" mkdir -p "$OUT/images" scp -q "root@$HOST:/var/lib/__SLUG__/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl" rsync -aq "root@$HOST:/var/lib/__SLUG__/feedback-images/" "$OUT/images/" 2>/dev/null || true -python3 - "$OUT" <<'PY' -import json, sys, os -out = sys.argv[1] -reports, order = {}, [] -for raw in open(os.path.join(out, "feedback.jsonl"), encoding="utf8"): - raw = raw.strip() - if not raw: continue - line = json.loads(raw) - if "reportId" in line: - r = reports.get(line["reportId"]) - if not r: continue - if "image" in line: r["image"] = line["image"] - else: r["replies"].append(line) # the keeper's replies and the player's answers alike, in order - continue - reports[line["id"]] = dict(line, replies=[]); order.append(line["id"]) -lines = ["# __NAME__ reports", "", f"{len(order)} reports; newest first. Pictures are in `images/`.", ""] -for rid in reversed(order): - r = reports[rid] - lines.append(f"## {r.get('at','')[:16].replace('T',' ')} — {r.get('player','?')} in {r.get('roomId','?')} (turn {r.get('turn','?')}, seq {r.get('seq','?')}) — id {rid}") - lines.append("") - lines.append(f"**What happened:** {r.get('happened','').strip()}") - if r.get("expected","").strip(): lines.append(f"**What they expected:** {r['expected'].strip()}") - if r.get("image"): lines.append(f"**Picture:** ![{r['image']}](images/{r['image']})") - for rep in r["replies"]: - who = f"{r.get('player','the player')} answers" if rep.get("from") == "player" else f"**{rep.get('status','')}**" - lines.append(f"> {who} ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}") - if not r["replies"] or r["replies"][-1].get("from") == "player": lines.append("> _awaiting the keeper_") - lines.append("") -open(os.path.join(out, "reports.md"), "w", encoding="utf8").write("\n".join(lines)) -print(f"{len(order)} reports, {sum(1 for r in reports.values() if r.get('image'))} with pictures -> {out}/reports.md") -PY +npx tsx deploy/report-digest.ts "$OUT" diff --git a/template/deploy/replay-ledgers.ts b/template/deploy/replay-ledgers.ts index 0b56fdf..ef9cb42 100644 --- a/template/deploy/replay-ledgers.ts +++ b/template/deploy/replay-ledgers.ts @@ -40,7 +40,7 @@ async function main(): Promise { for (const line of lines) { if (line.t === 'seat') seats.push(line); else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1); - else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, options); + else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options); else if (line.t === 'turn' && state) { state = game.resolve(state, line.inputs); turns += 1; diff --git a/template/deploy/report-digest.ts b/template/deploy/report-digest.ts new file mode 100644 index 0000000..0691af4 --- /dev/null +++ b/template/deploy/report-digest.ts @@ -0,0 +1,32 @@ +// The reports digest: every report with its exchange, newest first, written +// as reports.md beside a mirrored feedback.jsonl. Used by deploy/pull-reports.sh. +// tsx deploy/report-digest.ts + +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Reports } from '../server/src/reports'; + +const dir = process.argv[2]; +if (!dir) { + console.error('usage: report-digest.ts '); + process.exit(2); +} + +const when = (iso: string) => iso.slice(0, 16).replace('T', ' '); +const reports = new Reports(dir).read().reverse(); +const lines = ['# __NAME__ reports', '', `${reports.length} reports; newest first. Pictures are in \`images/\`.`, '']; +for (const r of reports) { + lines.push(`## ${when(r.at)} — ${r.player} in ${r.roomId} (turn ${r.turn ?? '?'}, seq ${r.seq}) — id ${r.id}`, ''); + lines.push(`**What happened:** ${r.happened.trim()}`); + if (r.expected.trim()) lines.push(`**What they expected:** ${r.expected.trim()}`); + if (r.image) lines.push(`**Picture:** ![${r.image}](images/${r.image})`); + for (const line of r.thread) { + const who = line.from === 'player' ? `${r.player} answers` : `**${line.status}**`; + lines.push(`> ${who} (${when(line.at)}): ${line.text.trim()}`); + } + const last = r.thread[r.thread.length - 1]; + if (!last || last.from === 'player') lines.push('> _awaiting the keeper_'); + lines.push(''); +} +writeFileSync(join(dir, 'reports.md'), lines.join('\n')); +console.log(`${reports.length} reports, ${reports.filter((r) => r.image).length} with pictures -> ${join(dir, 'reports.md')}`); diff --git a/template/deploy/report-reply.sh b/template/deploy/report-reply.sh index 0582b45..18d3a67 100755 --- a/template/deploy/report-reply.sh +++ b/template/deploy/report-reply.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Answer a player's report; the reply appears under it in their hall. # deploy/report-reply.sh set -euo pipefail diff --git a/template/deploy/sentry-slack-alert.sh b/template/deploy/sentry-slack-alert.sh index 1ed1931..85c0e63 100755 --- a/template/deploy/sentry-slack-alert.sh +++ b/template/deploy/sentry-slack-alert.sh @@ -1,10 +1,10 @@ -#!/bin/bash +#!/usr/bin/env bash # Route every __NAME__ issue to a Slack channel: new issues (any level or # priority), regressions, and reappearances. Creates one Sentry workflow # via the alerts API. The token never leaves your shell: -# SENTRY_TOKEN=sntryu_... deploy/sentry-slack-alert.sh [#channel] -# The channel defaults to #__SLUG__-notifications (__SLACK_CHANNEL_ID__); pass -# SLACK_CHANNEL_ID with the channel name to route elsewhere. +# SENTRY_TOKEN=sntryu_... SLACK_CHANNEL_ID=C... deploy/sentry-slack-alert.sh [#channel] +# SLACK_CHANNEL_ID is the channel's Slack ID; the name defaults to +# #__SLUG__-notifications and is shown in Sentry beside the ID. # The token needs the alerts:write scope (an org auth token from # https://locallygrownnet.sentry.io/settings/auth-tokens/), and Slack must # already be connected to the org under Settings → Integrations. @@ -20,7 +20,8 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN" # project's error detector is looked up by project id; a workflow is bound # to its detectors by PUT after creation. SLACK_ID="${SENTRY_SLACK_INTEGRATION:-__SENTRY_SLACK_INTEGRATION__}" -CHANNEL_ID="${SLACK_CHANNEL_ID:-__SLACK_CHANNEL_ID__}" +: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}" +CHANNEL_ID="$SLACK_CHANNEL_ID" PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}" echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)" diff --git a/template/deploy/setup-droplet.sh b/template/deploy/setup-droplet.sh index b352c47..823aea9 100755 --- a/template/deploy/setup-droplet.sh +++ b/template/deploy/setup-droplet.sh @@ -4,8 +4,8 @@ # bash setup-droplet.sh '__DOMAIN__, __SLUG__..sslip.io' # The argument is the Caddy site address line: one name, or several # separated by commas. Every name must already resolve to this droplet. -# __NAME__ is a static site: Caddy serves the built files and terminates -# TLS. There is no application process to install or supervise. +# Caddy serves the built files and terminates TLS; setup-server.sh adds the +# game server behind it. set -euo pipefail HOST="${1:?usage: setup-droplet.sh }" diff --git a/template/deploy/traffic.py b/template/deploy/traffic.py new file mode 100644 index 0000000..6ee5bcc --- /dev/null +++ b/template/deploy/traffic.py @@ -0,0 +1,67 @@ +"""Caddy's access log for one day, people and bots apart. The visitors +digest and the nightly rollup both count through here, so they agree.""" + +import datetime +import glob +import gzip +import json +import os +import re +from collections import Counter + +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) +PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"} +SITE = "__DOMAIN__" +LOGS = "/var/lib/caddy/access*.log*" + + +def scan(day, logs=LOGS): + """Counts for one UTC day (YYYY-MM-DD): every request, page requests by + people, the addresses of people and of bots, requests by page, room links + opened, referrers, and campaign tags (each counted once per address).""" + d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc) + t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp() + t = dict(requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(), referrers=Counter(), campaigns=Counter()) + seen_campaign = set() + for f in sorted(glob.glob(logs)): + if os.path.getmtime(f) < t0: + continue + opener = gzip.open if f.endswith(".gz") else open + try: + with opener(f, "rt", errors="ignore") as fh: + for line in fh: + try: + r = json.loads(line) + except ValueError: + continue + if not (t0 <= r.get("ts", 0) < 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] + t["requests"] += 1 + if BOT.search(ua) or not ua: + t["bot"] += 1 + t["botAddresses"].add(ip) + continue + if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")): + continue + t["human"] += 1 + t["addresses"].add(ip) + if path in PAGES: + t["pages"][PAGES[path]] += 1 + elif path.startswith("/join/"): + t["joins"][path.split("/")[2].upper()] += 1 + ref = " ".join(h.get("Referer", [""])) + if ref and SITE not in ref: + t["referrers"][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))) + t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1 + except OSError: + continue + return t diff --git a/template/deploy/verify-ledgers.sh b/template/deploy/verify-ledgers.sh index 3e86dad..8eccb93 100755 --- a/template/deploy/verify-ledgers.sh +++ b/template/deploy/verify-ledgers.sh @@ -6,7 +6,7 @@ # deploy/verify-ledgers.sh [https://site] set -euo pipefail HOST="${1:?usage: verify-ledgers.sh [https://site]}" -# SITE in the environment overrides the name, for a machine whose DNS has not caught up with a new record. +# SITE overrides the public name: the sslip.io address, or a name not yet in DNS. SITE="${2:-${SITE:-https://__DOMAIN__}}" DIR="$(mktemp -d)" trap 'rm -rf "$DIR"' EXIT diff --git a/template/server/src/index.ts b/template/server/src/index.ts index 54b96a5..876fead 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -2,18 +2,24 @@ // websocket only to say "something changed, fetch the view again". // // POST /api/rooms {name, size?, options?} create a room and take seat A; options are the game's table choices +// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view // POST /api/rooms/:id/join {name} take the next seat // POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins -// POST /api/rooms/:id/begin {token} the host begins with the players seated so far // POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins -// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view +// POST /api/rooms/:id/begin {token} the host begins with the players seated so far // POST /api/rooms/:id/turn {token, input} this seat's move -// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room +// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room; +// {name, text} or from the gallery, signed, when the host allows it +// POST /api/rooms/:id/gallery {token, on} the host lets the Peanut Gallery talk, or hushes it +// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings +// POST /api/rooms/:id/rematch {token} a finished table opens the next one +// POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device +// POST /api/transfer {phrase} claim one // POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round // POST /api/reports/:id/image a screenshot for a report just filed +// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply // POST /api/reports/mine {seats: [{roomId, token}]} your reports and the keeper's replies // GET /api/tally what the ledgers add up to, for the hall -// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply // WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token, // a seat in the Peanut Gallery, counted for the table // @@ -48,10 +54,11 @@ process.on('unhandledRejection', (reason) => { Sentry.captureException(reason); }); -const rooms = new Rooms(game, new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ }); +const store = new Store(DATA_DIR); +const rooms = new Rooms(game, store, { name: KEEPER, zone: KEEPER_TZ }); /** Reports live beside the room ledgers, not among them. */ const reports = new Reports(dirname(DATA_DIR)); -const tallies = new Tallies(game, new Store(DATA_DIR)); +const tallies = new Tallies(game, store); /** 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); /** Table talk: a lively table says a few lines a minute, not hundreds. */ @@ -305,7 +312,6 @@ server.on('upgrade', (req, socket, head) => { }; const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange); ws.on('close', unsubscribe); - ws.send(JSON.stringify({ type: 'hello', seat: seatId })); }); }); diff --git a/template/server/src/reports.ts b/template/server/src/reports.ts index 4a36369..81996b5 100644 --- a/template/server/src/reports.ts +++ b/template/server/src/reports.ts @@ -7,39 +7,15 @@ import { randomBytes } from 'node:crypto'; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { SeatId } from '../../src/lib/game/spec'; +import type { Report } from '../../src/lib/net/view'; import { RoomError, type Room, type Seat } from './rooms'; -export const TEXT_MAX = 2000; +const TEXT_MAX = 2000; export const IMAGE_MAX_BYTES = 2_500_000; /** How long after filing a picture may still be attached. */ const IMAGE_WINDOW_MS = 60 * 60 * 1000; -export interface ReportLine { - at: string; - text: string; - from: 'desk' | 'player'; - status?: string; -} - -export interface Report { - id: string; - at: string; - roomId: string; - /** The reporter's name, or "(gallery)" for a watcher. */ - player: string; - seat: SeatId | null; - /** The round being written when the report was filed, and the ledger's length. */ - turn: number | null; - seq: number; - happened: string; - expected: string; - /** The whole exchange in order: the keeper's replies and the player's answers. */ - thread: ReportLine[]; - /** A screenshot's file name under images/, when one was sent. */ - image?: string; -} - -export function cleanText(raw: unknown): string { +function cleanText(raw: unknown): string { return String(raw ?? '') .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ') .trim() diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index e4f494f..6c0034c 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -22,7 +22,7 @@ export interface Seat { bot: boolean; } -export function hashToken(token: string): string { +function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } @@ -88,11 +88,11 @@ const TRANSFER_WORDS = [ const TRANSFER_TTL_MS = 10 * 60 * 1000; const TRANSFER_FAIL_LIMIT = 20; -export function normalizeCode(raw: string): string { +function normalizeCode(raw: string): string { return raw.trim().toUpperCase(); } -export function cleanName(raw: unknown): string { +function cleanName(raw: unknown): string { const name = String(raw ?? '') .replace(/\s+/g, ' ') .trim() @@ -106,6 +106,10 @@ export class Rooms { private listeners = new Map) => void>>(); /** Sockets watching from the gallery, by room. */ private gallery = new Map(); + /** Phrases waiting to be claimed, with the seat and the token they carry. */ + private transfers = new Map(); + private failedClaims = 0; + private failWindowStart = 0; constructor( private game: GameSpec, @@ -258,11 +262,6 @@ export class Rooms { return { roomId: next.room.id, seat: next.seat, token: next.token, created: true }; } - /** Phrases waiting to be claimed, with the seat and the token they carry. */ - private transfers = new Map(); - private failedClaims = 0; - private failWindowStart = 0; - /** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */ transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } { const seat = this.seatOf(room, token); @@ -474,7 +473,7 @@ export class Rooms { room.over = true; break; case 'seat': - room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot }); + room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot }); break; case 'galleryTalk': room.galleryTalk = line.on; @@ -493,7 +492,7 @@ export class Rooms { room.seats = room.seats.filter((s) => s.id !== line.id); break; case 'start': - room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, room.options); + room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options); break; case 'turn': if (!room.state) return; diff --git a/template/server/src/store.ts b/template/server/src/store.ts index e90af79..cc57110 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -4,19 +4,19 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import type { SeatId } from '../../src/lib/game/spec'; +import type { SeatId, TableOptions } from '../../src/lib/game/spec'; export type LedgerLine = /** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */ - | { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options?: Record } - /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */ - | { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean } + | { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions } + /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */ + | { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean } /** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */ | { t: 'input'; at: number; id: SeatId; input: unknown } /** A bot sent away by the host before the game began; its seat id is free again. */ | { t: 'unseat'; id: SeatId } - /** Ledgers from before revisions were recorded resolve under rules 1. */ - | { t: 'start'; seed: number; rules?: number } + /** The seed and the rules revision the game began under; a later fix keeps this revision's path for it. */ + | { t: 'start'; seed: number; rules: number } | { t: 'turn'; at: number; inputs: Record } /** Written after the turn that ends the game, for anything that reads ledgers without the engine. */ | { t: 'over'; at: number; winner: SeatId | null; reason: string } diff --git a/template/server/src/tally.ts b/template/server/src/tally.ts index b038885..51922ca 100644 --- a/template/server/src/tally.ts +++ b/template/server/src/tally.ts @@ -4,25 +4,9 @@ // the kit's; a game may add its own lines through GameSpec.tally. import type { GameSpec, SeatId } from '../../src/lib/game/spec'; +import type { Tally } from '../../src/lib/net/view'; import type { LedgerLine, Store } from './store'; -export interface Tally { - tablesOpened: number; - gamesBegun: number; - gamesFinished: number; - /** Distinct names of people who have taken a seat; a bot's name is not counted. */ - namesSeated: number; - turnsPlayed: number; - /** Minutes at the table: gaps under ten minutes between one move and the next, summed. */ - minutesAtTable: number; - longestGameTurns: number; - gamesWithBot: number; - talkLines: number; - firstGameAt: string | null; - /** The game's own lines, summed over finished games. */ - byGame: Record; -} - const GAP_LIMIT_MS = 10 * 60 * 1000; const CACHE_MS = 60 * 1000; @@ -66,7 +50,7 @@ export class Tallies { if (hasBot) t.gamesWithBot += 1; if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; try { - state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, lines[0].options); + state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, lines[0].options); } catch { state = null; } diff --git a/template/server/tsconfig.json b/template/server/tsconfig.json index c88f1f1..8ea97bd 100644 --- a/template/server/tsconfig.json +++ b/template/server/tsconfig.json @@ -15,11 +15,10 @@ "src/**/*.ts", "../src/lib/game/**/*.ts", "../src/lib/net/view.ts", - "../deploy/replay-ledgers.ts" + "../deploy/replay-ledgers.ts", + "../deploy/report-digest.ts" ], "exclude": [ - "../src/lib/game/*.test.ts", - "../src/lib/game/*.svelte.ts", - "../src/lib/game/test-helpers.ts" + "../src/lib/game/*.test.ts" ] } diff --git a/template/src/app.css b/template/src/app.css index c63ae91..2c7eabb 100644 --- a/template/src/app.css +++ b/template/src/app.css @@ -3,7 +3,6 @@ :root { --slate: #1c2830; --slate-deep: #121a20; - --slate-raised: #24333d; --bone: #e9e2d2; --bone-dim: #98a4ab; --bone-faint: #5d6b73; @@ -133,6 +132,10 @@ button:disabled { color: var(--bone-dim); } +.small { + font-size: 0.85rem; +} + .warning { color: var(--blood); } @@ -150,6 +153,58 @@ button:disabled { height: 1px; overflow: hidden; clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* The × that sends a bot away or forgets a game. */ +.dismiss { + background: none; + border: 0; + color: var(--bone-faint); + font-size: 1.1rem; + padding: 0 0.4rem; + line-height: 1; +} + +.dismiss:hover { + color: var(--blood); +} + +/* A page of reading beside the game: the rules, the guide. */ +.reading { + max-width: 46rem; + margin: 0 auto; + padding: 1.5rem var(--gutter) 4rem; +} + +.reading a { + color: var(--frost); +} + +.reading .back { + font-size: 0.95rem; +} + +.reading h1 { + font-size: 2.4rem; + font-weight: 300; + margin: 0.5rem 0 0.3rem; +} + +.reading .lede { + color: var(--bone-dim); + max-width: 40em; +} + +.reading section { + padding: 1.25rem 0 0.5rem; + border-top: 1px solid var(--rule); + margin-top: 1rem; +} + +.reading h2 { + font-size: 1.5rem; + margin-bottom: 0.7rem; } @media (prefers-reduced-motion: reduce) { diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index 5acb287..f6034d4 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -6,17 +6,16 @@ import { otherGames, type FamilyGame } from '$lib/family'; import Preferences from './Preferences.svelte'; import { prefs, setPref } from '$lib/prefs.svelte'; - import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client'; + import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client'; import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { unreadTalk } from '$lib/net/talk'; - import type { RoomView } from '$lib/net/view'; + import type { Report, RoomView, Tally } from '$lib/net/view'; import type { State } from '$lib/game'; import { game } from '$lib/game'; let name = $state(playerName()); let code = $state(''); - /** The host's choices for the next table, from the game's declared options. */ - // A new table starts from the preferences' table defaults; the hall's selectors set the same keys. + /** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */ const options = $derived>( Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default])) ); @@ -82,7 +81,7 @@ } } - // The desk is asked once per visit for every seat this browser holds. + // The desk is asked for every seat this browser holds, again whenever that set changes. $effect(() => { const held = seats; if (!held.length) return; @@ -163,8 +162,8 @@

__NAME__

One line that says what the game is.

Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.

- - + +
@@ -176,7 +175,7 @@
{#each game.options as o (o.key)} {@const chosen = o.choices.find((c) => c.value === options[o.key])} -
+
{/each} {#if reports.length} @@ -382,10 +381,6 @@ text-align: center; } - .small { - font-size: 0.85rem; - } - .ways { display: flex; flex-wrap: wrap; @@ -489,18 +484,6 @@ color: var(--frost); } - .ledger-forget { - background: none; - border: 0; - color: var(--bone-faint); - font-size: 1.1rem; - padding: 0 0.4rem; - } - - .ledger-forget:hover { - color: var(--blood); - } - .reports-head { margin-top: 0.9rem; } @@ -591,6 +574,7 @@ font-size: 0.85rem; color: var(--bone-faint); } + .family { margin: 0.4rem 0 0.8rem 1.2rem; padding: 0; @@ -599,6 +583,7 @@ .family li + li { margin-top: 0.3rem; } + .options { display: flex; flex-wrap: wrap; @@ -617,6 +602,7 @@ margin: 0.2rem 0 0; max-width: 22em; } + .tally { display: grid; grid-template-columns: max-content 1fr; @@ -632,13 +618,4 @@ .tally dd { margin: 0; } - .link-like { - background: none; - border: 0; - padding: 0; - font: inherit; - color: inherit; - text-decoration: underline; - cursor: pointer; - } diff --git a/template/src/lib/components/Lobby.svelte b/template/src/lib/components/Lobby.svelte index 7936f26..301a482 100644 --- a/template/src/lib/components/Lobby.svelte +++ b/template/src/lib/components/Lobby.svelte @@ -2,15 +2,11 @@ import TableTalk from './TableTalk.svelte'; import type { Room } from '$lib/net/room.svelte'; - let { room, link }: { room: Room; link: string } = $props(); + let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props(); const v = $derived(room.view); const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host'); - const seatFree = $derived(v.seats.length < v.size); - /** A seat is free for a newcomer once the held ones are counted. */ - const seatOpen = $derived(v.seats.length + v.expected.length < v.size); const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase())); - let copied = $state(false); /** The hour where the keeper lives, in words, so a caller knows whether to wait. */ let minute = $state(0); @@ -31,22 +27,12 @@ return ''; } }); - - async function copyLink() { - try { - await navigator.clipboard.writeText(link); - copied = true; - setTimeout(() => (copied = false), 1600); - } catch { - // The link is on screen to copy by hand. - } - }
{#if room.spectating}

At the table

- {#if seatFree} + {#if room.seatEmpty}

{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.

{:else}

The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.

@@ -62,7 +48,7 @@
  • {s.name} {s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost} - {/if} + {/if}
  • {/each} {#each v.expected as name (name)} @@ -78,7 +64,7 @@ {/if} {#if !room.spectating}
    - {#if seatFree && room.isHost} + {#if room.seatEmpty && room.isHost} {/if} {#if room.isHost} @@ -94,13 +80,13 @@ Let the Peanut Gallery talk at the table {/if} - {#if v.keeper && !v.challenge && !keeperSeated && seatOpen} + {#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
    -

    {v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.

    +

    {v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.

    {:else if v.challenge} -

    {v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.

    +

    {v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.

    {/if} {/if} {#if room.error}

    {room.error}

    {/if} @@ -148,20 +134,6 @@ font-weight: 500; } - .unseat { - background: none; - border: 0; - color: var(--bone-faint); - font-size: 1.1rem; - padding: 0 0.3rem; - margin-left: 0.3rem; - line-height: 1; - } - - .unseat:hover { - color: var(--blood); - } - .ways { display: flex; flex-wrap: wrap; @@ -203,8 +175,7 @@ max-width: 38em; } - .small { - font-size: 0.85rem; + .note { margin-top: 0.4rem; } diff --git a/template/src/lib/components/Preferences.svelte b/template/src/lib/components/Preferences.svelte index 952d0ae..bdd2d3f 100644 --- a/template/src/lib/components/Preferences.svelte +++ b/template/src/lib/components/Preferences.svelte @@ -1,6 +1,7 @@ __NAME__: how to play -
    +
    {#if room}Back to your game · {/if}Back to the hall · The rules

    How to play

    @@ -57,31 +57,6 @@