The credibility pass on Hnefatafl, ported to the template: the ops scripts count traffic through one parser, the reports digest is one program, the plaintext-token fallback and its migration are gone, and the stylesheet holds each shared rule once

From the hnefatafl repo's pass of the same day, everything that touched a
kit-shared file. The visitors digest and the nightly rollup share
deploy/traffic.py, installed to /usr/local/lib/<slug>; the rollup writes
the finished-games count it computed behind "and False". pull-reports.sh
and the reports skill both use deploy/report-digest.ts. The seat line
requires its token hash and the start line its rules revision; the
migration for ledgers written before hashing goes with them. The route
table in server/src/index.ts lists every route; Report, ReportLine and
Tally are declared once in view.ts for both sides; exports nobody
imported are exports no more.

In the client: .small, the × that dismisses, and the frame of the
reading pages are in app.css once; the preferences panel shares the
report slip's modal shape; the room store gains seatEmpty and
seatUnheld, and the lobby and the join page read those instead of
three spellings of their own. The room's moved and awaiting fields
stay: the demo board reads them, and simultaneous rounds are the
contract.

The deploy README no longer describes a browser-only game; the visitors
skill no longer names a /play route; the reports skill's replay call
carries the room's options. The Slack channel id is passed in the
environment rather than filled in as a placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
This commit is contained in:
Eric Wagoner
2026-09-23 20:12:37 -04:00
co-authored by Claude Fable 5.1
parent 6c42e55f59
commit 33ac0ec808
41 changed files with 421 additions and 583 deletions
+1 -2
View File
@@ -43,8 +43,7 @@ Run the demo locally from a new project:
Placeholders: `__SLUG__`, `__NAME__`, `__PORT__`, `__DOMAIN__` are filled by Placeholders: `__SLUG__`, `__NAME__`, `__PORT__`, `__DOMAIN__` are filled by
`new-game.sh`; `__IP__`, `__SENTRY_DSN__`, `__SENTRY_PROJECT_ID__`, `new-game.sh`; `__IP__`, `__SENTRY_DSN__`, `__SENTRY_PROJECT_ID__`,
`__SENTRY_SLACK_INTEGRATION__` and `__SLACK_CHANNEL_ID__` wait until those and `__SENTRY_SLACK_INTEGRATION__` wait until those things exist.
things exist.
## What the kit is, and is not ## What the kit is, and is not
@@ -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 __SLUG__, fingerprinted per report or per line); the desk's own
replies ring nothing. replies ring nothing.
Fetch: `ssh root@__IP__ 'cat /var/lib/__SLUG__/feedback.jsonl'`
## a) Fetch and b) display ## a) Fetch and b) display
Parse the file, fold replies onto reports, and show Eric the last 7 days From the repository root, `bash deploy/pull-reports.sh` mirrors the file
by `at`. Lead with the count of unanswered reports; those are the work. 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, Then one block per report, VERBATIM and UNTRUNCATED: player, room, date,
turn and seq, the full "what happened", the full "what they expected", turn and seq, the full "what happened", the full "what they expected",
and every reply with its status (or "unanswered"). Eric reads this desk 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/<roomId>.jsonl <scratchpad>/` 1. **Replay to the pin.** `scp root@__IP__:/var/lib/__SLUG__/rooms/<roomId>.jsonl <scratchpad>/`
and replay it with the engine as `deploy/replay-ledgers.ts` does: 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 `game.resolve(state, line.inputs)` per turn line, printing the state
around the pinned round. The around the pinned round. The `chat` lines show what the players said
`chat` lines show what the players said to each other at the time. to each other at the time.
2. **Check the rules before the code.** The original rules text lives in 2. **Check the rules before the code.** The rules are data in
`docs/`; read it before deciding the engine is wrong. Many reports are `src/lib/game/rules.ts` and prose at /rules, which names the texts it
the rules working as written. 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), 3. **Verdict.** `by-design` (the engine matches the text; no change),
`resolved` (a defect, fixed before replying), or `open` (needs Eric's `resolved` (a defect, fixed before replying), or `open` (needs Eric's
ruling; ask him and hold the reply). 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: 6. **Report to Eric** when the desk is clear: one line per report:
player, room, verdict, and what shipped if anything. player, room, verdict, and what shipped if anything.
## Eric's local copy Run `bash deploy/pull-reports.sh` again at the end of every desk session,
so Eric's local copy carries the replies.
`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.
## Standing rules ## Standing rules
@@ -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 - **as of / live sockets / rooms touched in the last hour**: the "right
now" line. One socket at a quiet hour is Eric. now" line. One socket at a quiet hour is Eric.
- **traffic**: Caddy's access log for the day, bots split out by user - **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, agent, page requests by page (hall, rules, guide) and room links opened,
referrers and campaign tags. Single-player games live in browsers, so referrers and campaign tags.
a visit to /play is all the log shows of them.
- **players today / NEW today**: names seated in rooms created today; - **players today / NEW today**: names seated in rooms created today;
NEW means the name's first room ever is today. NEW means the name's first room ever is today.
- **rooms today**: one line each: time, code, humans vs bots, empty - **rooms today**: one line each: time, code, humans vs bots, empty
+1 -4
View File
@@ -18,8 +18,5 @@ Thumbs.db
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Playwright MCP output # Room ledgers written by the server
.playwright-mcp
# Duel ledgers written by the server
/data /data
+1 -1
View File
@@ -31,7 +31,7 @@ handle @game {
# <route>.html, so /rules is tried as /rules.html before falling back to the # <route>.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 # 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 # 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 { handle {
@immutable path /_app/immutable/* @immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable" header @immutable Cache-Control "public, max-age=31536000, immutable"
+15 -24
View File
@@ -1,12 +1,12 @@
# Deploying __NAME__ # Deploying __NAME__
The single-player game runs entirely in the browser. Duels between people Every game, against a housecarl or a friend, is a room on a small Node
go through a small Node process that keeps each room as an append-only process that keeps it as an append-only ledger of moves in
ledger of moves in /var/lib/__SLUG__/rooms. Production is one /var/lib/__SLUG__/rooms. Production is one DigitalOcean droplet: Caddy
DigitalOcean droplet: Caddy terminates TLS with automatic certificates, terminates TLS with automatic certificates, serves the static build from
serves the static build from /opt/__SLUG__/build, and proxies /api and /opt/__SLUG__/build, and proxies /api and /ws to the game server on port
/ws to the game server on port __PORT__, which runs as the `__SLUG__` user __PORT__, which runs as the `__SLUG__` user under systemd from
under systemd from /opt/__SLUG__/app. /opt/__SLUG__/app.
## Sizing ## 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 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. 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 ## Current production
- Droplet: `__SLUG__` (nyc3, s-1vcpu-512mb-10gb, tag `__SLUG__`), IP __IP__ - 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 ## Operations scripts on the droplet
`deploy.sh` installs these to /usr/local/bin and the cron file to `deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to
/etc/cron.d/__SLUG__ on every deploy, so the live copies are the repo copies: /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__-visitors.sh [day]`: who is here now and who came that day.
- `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup - `__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 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 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 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 <ip>` fetches every production Before every deploy, `deploy/verify-ledgers.sh <ip>` fetches every production
ledger and replays it with the local engine, comparing each room with what the 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 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 the droplet keeping the previous week's hashed assets, rsyncs the server and
engine sources, installs dependencies, and restarts the game server. A engine sources, installs dependencies, and restarts the game server. A room
single-player game is in the player's own browser and loses nothing. A room
is replayed from its ledger when the server comes back, which takes a few is replayed from its ledger when the server comes back, which takes a few
seconds; Caddy holds requests that land in the gap. 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@<ip> journalctl -u __SLUG__ -f` for the game server, - Logs: `ssh root@<ip> journalctl -u __SLUG__ -f` for the game server,
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side. `journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
- Restart: `ssh root@<ip> systemctl restart __SLUG__` - Restart: `ssh root@<ip> systemctl restart __SLUG__`
- Who has been playing: `ssh root@<ip> __SLUG__-visitors.sh [YYYY-MM-DD]` - Rooms: `/var/lib/__SLUG__/rooms/<CODE>.jsonl`, one ledger per game; the
prints today's visitors from Caddy's log (people and bots apart, by page), nightly backup keeps them in the Space.
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/<CODE>.jsonl`, one ledger per game.
Copy that directory to back them up; single-player games are in players'
browsers.
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket. # Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket.
# current/ - exact mirror of /var/lib/__SLUG__ # current/ - exact mirror of /var/lib/__SLUG__
# snapshots/ - one dated copy per day, pruned after 90 days # snapshots/ - one dated copy per day, pruned after 90 days
@@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# The operations pulse, printed ON the droplet: the game server's health, # 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, # 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. # 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__')" 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 '^$') ERR=$(journalctl -u __SLUG__ --since '7 days ago' -p err -o cat | grep -vc '^$')
echo "journal errors in 7 days: $ERR" 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)" echo "--- rollup, last 7 days (people / requests / rooms opened / turns)"
if [ -s /var/lib/__SLUG__/rollup.jsonl ]; then if [ -s /var/lib/__SLUG__/rollup.jsonl ]; then
tail -7 /var/lib/__SLUG__/rollup.jsonl | python3 -c ' tail -7 /var/lib/__SLUG__/rollup.jsonl | python3 -c '
+12 -46
View File
@@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# Nightly rollup: one JSON line per day in /var/lib/__SLUG__/rollup.jsonl: # 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, # 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, # with referrers and the campaign tags links carry), the rooms opened,
@@ -24,45 +24,13 @@ checkin() {
mkdir -p "$(dirname "$LOG")" mkdir -p "$(dirname "$LOG")"
checkin in_progress checkin in_progress
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1 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] day, out = sys.argv[1], sys.argv[2]
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc) d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp() 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) t = traffic.scan(day)
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
created = started = finished = turned = turns = 0 created = started = finished = turned = turns = 0
humans, bots = set(), 0 humans, bots = set(), 0
@@ -80,20 +48,18 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
if x["bot"]: bots += 1 if x["bot"]: bots += 1
else: humans.add(x["name"]) else: humans.add(x["name"])
elif x["t"] == "unseat": bots -= 1 elif x["t"] == "unseat": bots -= 1
day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1] turns += sum(1 for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1)
turns += len(day_turns) # The over line is written with the turn that ends the game, so a finish counts on its own day.
# A game finished today: its last turn is today's and the engine would say so; approximate by replay-free means: if any(x["t"] == "over" and t0 <= x["at"] / 1000 < t1 for x in lines): finished += 1
# 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("/") disk = shutil.disk_usage("/")
mem = {} mem = {}
for line in open("/proc/meminfo"): for line in open("/proc/meminfo"):
k, v = line.split(":", 1); mem[k] = int(v.split()[0]) k, v = line.split(":", 1); mem[k] = int(v.split()[0])
load = os.getloadavg()[1] load = os.getloadavg()[1]
row = dict(day=day, requests=req, human=human, bot=bot, humanAddresses=len(hips), botAddresses=len(bips), row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]),
paths=dict(pages), roomLinks=sum(joins.values()), referrers=dict(refs), campaigns=dict(campaigns), 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, humanNames=len(humans), botSeats=bots), 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")), 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)), 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")) 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: with open(out + ".tmp", "w") as fh:
for r in rows: fh.write(json.dumps(r) + "\n") for r in rows: fh.write(json.dumps(r) + "\n")
os.replace(out + ".tmp", out) 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 PY
then checkin ok; else checkin error; fi then checkin ok; else checkin error; fi
@@ -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 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. # 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. # Every game, bot games included, is a room with a ledger, so all show in full.
set -u set -u
DAY="${1:-$(date -u +%F)}" DAY="${1:-$(date -u +%F)}"
python3 - "$DAY" <<'PY' python3 - "$DAY" <<'PY'
import datetime, glob, json, re, subprocess, sys import datetime, glob, json, subprocess, sys
from collections import Counter sys.path.insert(0, "/usr/local/lib/__SLUG__")
import traffic
day = sys.argv[1] day = sys.argv[1]
now = datetime.datetime.now(datetime.timezone.utc) 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"))) sh("find /var/lib/__SLUG__/rooms -name '*.jsonl' -mmin -60 2>/dev/null | wc -l")))
# --- traffic: Caddy's access log, people and bots apart --- # --- 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) t = traffic.scan(day)
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
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" % ( 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"], len(t["addresses"]), t["human"], len(t["botAddresses"]), t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"],
dict(joins) or "none", dict(referrers) or "none", dict(campaigns) or "none")) dict(t["joins"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none"))
# --- rooms: every ledger, today's in detail --- # --- rooms: every ledger, today's in detail ---
first_seen = {} 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"]) (bots_seated if x["bot"] else humans).append(x["name"])
names[x["id"]] = x["name"] names[x["id"]] = x["name"]
elif x["t"] == "unseat": 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": elif x["t"] == "start":
started = True started = True
elif x["t"] == "turn": elif x["t"] == "turn":
+1 -1
View File
@@ -12,7 +12,7 @@ Environment=KEEPER_TZ=America/New_York
# Caddy terminates TLS; the plaintext port must not face the internet. # Caddy terminates TLS; the plaintext port must not face the internet.
Environment=HOST=127.0.0.1 Environment=HOST=127.0.0.1
Environment=DATA_DIR=/var/lib/__SLUG__/rooms 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__ Environment=SENTRY_DSN=__SENTRY_DSN__
ExecStart=/opt/__SLUG__/app/server/node_modules/.bin/tsx src/index.ts ExecStart=/opt/__SLUG__/app/server/node_modules/.bin/tsx src/index.ts
Restart=always Restart=always
+6 -5
View File
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
# it needs only its code and the engine. Ledgers live outside this tree. # it needs only its code and the engine. Ledgers live outside this tree.
rsync -az --delete --exclude='/server/node_modules' \ rsync -az --delete --exclude='/server/node_modules' \
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \ --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='*' \ --exclude='*' \
./ "root@$HOST:/opt/__SLUG__/app/" ./ "root@$HOST:/opt/__SLUG__/app/"
@@ -36,12 +36,13 @@ ssh "root@$HOST" '
cd /opt/__SLUG__/app/server && npm install --no-audit --no-fund cd /opt/__SLUG__/app/server && npm install --no-audit --no-fund
chown -R __SLUG__:__SLUG__ /opt/__SLUG__/app chown -R __SLUG__:__SLUG__ /opt/__SLUG__/app
cp /opt/__SLUG__/app/deploy/__SLUG__.service /etc/systemd/system/__SLUG__.service 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 # The ops scripts run from /usr/local/bin and share traffic.py: install them
# every deploy so the live copies are always the repo copies. # 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/__SLUG__-visitors.sh /usr/local/bin/__SLUG__-visitors.sh
install -m 755 /opt/__SLUG__/app/deploy/pulse.sh /usr/local/bin/__SLUG__-pulse.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__-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 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__ install -m 644 /opt/__SLUG__/app/deploy/__SLUG__.cron /etc/cron.d/__SLUG__
mkdir -p /var/log/__SLUG__ mkdir -p /var/log/__SLUG__
systemctl daemon-reload systemctl daemon-reload
-44
View File
@@ -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 <ledger-dir>');
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<string, unknown>;
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`);
+4 -33
View File
@@ -1,6 +1,7 @@
#!/bin/bash #!/usr/bin/env bash
# Mirror the player reports, the keeper's replies and the players' screenshots # 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) # deploy/pull-reports.sh [host] [folder] (default ~/Desktop/__SLUG__-reports)
set -euo pipefail set -euo pipefail
HOST="${1:-__IP__}" HOST="${1:-__IP__}"
@@ -8,34 +9,4 @@ OUT="${2:-$HOME/Desktop/__SLUG__-reports}"
mkdir -p "$OUT/images" mkdir -p "$OUT/images"
scp -q "root@$HOST:/var/lib/__SLUG__/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl" 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 rsync -aq "root@$HOST:/var/lib/__SLUG__/feedback-images/" "$OUT/images/" 2>/dev/null || true
python3 - "$OUT" <<'PY' npx tsx deploy/report-digest.ts "$OUT"
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
+1 -1
View File
@@ -40,7 +40,7 @@ async function main(): Promise<void> {
for (const line of lines) { for (const line of lines) {
if (line.t === 'seat') seats.push(line); 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 === '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) { else if (line.t === 'turn' && state) {
state = game.resolve(state, line.inputs); state = game.resolve(state, line.inputs);
turns += 1; turns += 1;
+32
View File
@@ -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 <folder>
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 <folder>');
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')}`);
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
# Answer a player's report; the reply appears under it in their hall. # Answer a player's report; the reply appears under it in their hall.
# deploy/report-reply.sh <host> <reportId> <resolved|by-design|open> <text...> # deploy/report-reply.sh <host> <reportId> <resolved|by-design|open> <text...>
set -euo pipefail set -euo pipefail
+6 -5
View File
@@ -1,10 +1,10 @@
#!/bin/bash #!/usr/bin/env bash
# Route every __NAME__ issue to a Slack channel: new issues (any level or # Route every __NAME__ issue to a Slack channel: new issues (any level or
# priority), regressions, and reappearances. Creates one Sentry workflow # priority), regressions, and reappearances. Creates one Sentry workflow
# via the alerts API. The token never leaves your shell: # via the alerts API. The token never leaves your shell:
# SENTRY_TOKEN=sntryu_... deploy/sentry-slack-alert.sh [#channel] # SENTRY_TOKEN=sntryu_... SLACK_CHANNEL_ID=C... deploy/sentry-slack-alert.sh [#channel]
# The channel defaults to #__SLUG__-notifications (__SLACK_CHANNEL_ID__); pass # SLACK_CHANNEL_ID is the channel's Slack ID; the name defaults to
# SLACK_CHANNEL_ID with the channel name to route elsewhere. # #__SLUG__-notifications and is shown in Sentry beside the ID.
# The token needs the alerts:write scope (an org auth token from # The token needs the alerts:write scope (an org auth token from
# https://locallygrownnet.sentry.io/settings/auth-tokens/), and Slack must # https://locallygrownnet.sentry.io/settings/auth-tokens/), and Slack must
# already be connected to the org under Settings → Integrations. # 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 # project's error detector is looked up by project id; a workflow is bound
# to its detectors by PUT after creation. # to its detectors by PUT after creation.
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-__SENTRY_SLACK_INTEGRATION__}" 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__}" PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}"
echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)" echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)"
+2 -2
View File
@@ -4,8 +4,8 @@
# bash setup-droplet.sh '__DOMAIN__, __SLUG__.<droplet-ip>.sslip.io' # bash setup-droplet.sh '__DOMAIN__, __SLUG__.<droplet-ip>.sslip.io'
# The argument is the Caddy site address line: one name, or several # The argument is the Caddy site address line: one name, or several
# separated by commas. Every name must already resolve to this droplet. # separated by commas. Every name must already resolve to this droplet.
# __NAME__ is a static site: Caddy serves the built files and terminates # Caddy serves the built files and terminates TLS; setup-server.sh adds the
# TLS. There is no application process to install or supervise. # game server behind it.
set -euo pipefail set -euo pipefail
HOST="${1:?usage: setup-droplet.sh <hostname>}" HOST="${1:?usage: setup-droplet.sh <hostname>}"
+67
View File
@@ -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
+1 -1
View File
@@ -6,7 +6,7 @@
# deploy/verify-ledgers.sh <droplet-ip-or-host> [https://site] # deploy/verify-ledgers.sh <droplet-ip-or-host> [https://site]
set -euo pipefail set -euo pipefail
HOST="${1:?usage: verify-ledgers.sh <droplet-ip-or-host> [https://site]}" HOST="${1:?usage: verify-ledgers.sh <droplet-ip-or-host> [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__}}" SITE="${2:-${SITE:-https://__DOMAIN__}}"
DIR="$(mktemp -d)" DIR="$(mktemp -d)"
trap 'rm -rf "$DIR"' EXIT trap 'rm -rf "$DIR"' EXIT
+13 -7
View File
@@ -2,18 +2,24 @@
// websocket only to say "something changed, fetch the view again". // 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 // 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/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/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 // 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/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/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed // POST /api/reports/:id/image <image bytes> 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 // 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 // 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, // 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 // a seat in the Peanut Gallery, counted for the table
// //
@@ -48,10 +54,11 @@ process.on('unhandledRejection', (reason) => {
Sentry.captureException(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. */ /** Reports live beside the room ledgers, not among them. */
const reports = new Reports(dirname(DATA_DIR)); 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. */ /** 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 doors = new RateLimit(40, 60 * 60 * 1000);
/** Table talk: a lively table says a few lines a minute, not hundreds. */ /** 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); const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe); ws.on('close', unsubscribe);
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
}); });
}); });
+3 -27
View File
@@ -7,39 +7,15 @@ import { randomBytes } from 'node:crypto';
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { SeatId } from '../../src/lib/game/spec'; import type { SeatId } from '../../src/lib/game/spec';
import type { Report } from '../../src/lib/net/view';
import { RoomError, type Room, type Seat } from './rooms'; 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; export const IMAGE_MAX_BYTES = 2_500_000;
/** How long after filing a picture may still be attached. */ /** How long after filing a picture may still be attached. */
const IMAGE_WINDOW_MS = 60 * 60 * 1000; const IMAGE_WINDOW_MS = 60 * 60 * 1000;
export interface ReportLine { function cleanText(raw: unknown): string {
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 {
return String(raw ?? '') return String(raw ?? '')
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ') .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ')
.trim() .trim()
+9 -10
View File
@@ -22,7 +22,7 @@ export interface Seat {
bot: boolean; bot: boolean;
} }
export function hashToken(token: string): string { function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
@@ -88,11 +88,11 @@ const TRANSFER_WORDS = [
const TRANSFER_TTL_MS = 10 * 60 * 1000; const TRANSFER_TTL_MS = 10 * 60 * 1000;
const TRANSFER_FAIL_LIMIT = 20; const TRANSFER_FAIL_LIMIT = 20;
export function normalizeCode(raw: string): string { function normalizeCode(raw: string): string {
return raw.trim().toUpperCase(); return raw.trim().toUpperCase();
} }
export function cleanName(raw: unknown): string { function cleanName(raw: unknown): string {
const name = String(raw ?? '') const name = String(raw ?? '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim() .trim()
@@ -106,6 +106,10 @@ export class Rooms<State, Input> {
private listeners = new Map<string, Set<(room: Room<State>) => void>>(); private listeners = new Map<string, Set<(room: Room<State>) => void>>();
/** Sockets watching from the gallery, by room. */ /** Sockets watching from the gallery, by room. */
private gallery = new Map<string, number>(); private gallery = new Map<string, number>();
/** Phrases waiting to be claimed, with the seat and the token they carry. */
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
private failedClaims = 0;
private failWindowStart = 0;
constructor( constructor(
private game: GameSpec<State, Input>, private game: GameSpec<State, Input>,
@@ -258,11 +262,6 @@ export class Rooms<State, Input> {
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true }; 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<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
private failedClaims = 0;
private failWindowStart = 0;
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */ /** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } { transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } {
const seat = this.seatOf(room, token); const seat = this.seatOf(room, token);
@@ -474,7 +473,7 @@ export class Rooms<State, Input> {
room.over = true; room.over = true;
break; break;
case 'seat': 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; break;
case 'galleryTalk': case 'galleryTalk':
room.galleryTalk = line.on; room.galleryTalk = line.on;
@@ -493,7 +492,7 @@ export class Rooms<State, Input> {
room.seats = room.seats.filter((s) => s.id !== line.id); room.seats = room.seats.filter((s) => s.id !== line.id);
break; break;
case 'start': 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; break;
case 'turn': case 'turn':
if (!room.state) return; if (!room.state) return;
+6 -6
View File
@@ -4,19 +4,19 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; 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 = export type LedgerLine =
/** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */ /** `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<string, string> } | { 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. Ledgers written before hashing carry `token` and are read once more. */ /** 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; token?: string; bot: boolean } | { 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. */ /** 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 } | { 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. */ /** A bot sent away by the host before the game began; its seat id is free again. */
| { t: 'unseat'; id: SeatId } | { t: 'unseat'; id: SeatId }
/** Ledgers from before revisions were recorded resolve under rules 1. */ /** 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: 'start'; seed: number; rules: number }
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> } | { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */ /** 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 } | { t: 'over'; at: number; winner: SeatId | null; reason: string }
+2 -18
View File
@@ -4,25 +4,9 @@
// the kit's; a game may add its own lines through GameSpec.tally. // the kit's; a game may add its own lines through GameSpec.tally.
import type { GameSpec, SeatId } from '../../src/lib/game/spec'; import type { GameSpec, SeatId } from '../../src/lib/game/spec';
import type { Tally } from '../../src/lib/net/view';
import type { LedgerLine, Store } from './store'; 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<string, number>;
}
const GAP_LIMIT_MS = 10 * 60 * 1000; const GAP_LIMIT_MS = 10 * 60 * 1000;
const CACHE_MS = 60 * 1000; const CACHE_MS = 60 * 1000;
@@ -66,7 +50,7 @@ export class Tallies<State, Input> {
if (hasBot) t.gamesWithBot += 1; if (hasBot) t.gamesWithBot += 1;
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
try { 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 { } catch {
state = null; state = null;
} }
+3 -4
View File
@@ -15,11 +15,10 @@
"src/**/*.ts", "src/**/*.ts",
"../src/lib/game/**/*.ts", "../src/lib/game/**/*.ts",
"../src/lib/net/view.ts", "../src/lib/net/view.ts",
"../deploy/replay-ledgers.ts" "../deploy/replay-ledgers.ts",
"../deploy/report-digest.ts"
], ],
"exclude": [ "exclude": [
"../src/lib/game/*.test.ts", "../src/lib/game/*.test.ts"
"../src/lib/game/*.svelte.ts",
"../src/lib/game/test-helpers.ts"
] ]
} }
+56 -1
View File
@@ -3,7 +3,6 @@
:root { :root {
--slate: #1c2830; --slate: #1c2830;
--slate-deep: #121a20; --slate-deep: #121a20;
--slate-raised: #24333d;
--bone: #e9e2d2; --bone: #e9e2d2;
--bone-dim: #98a4ab; --bone-dim: #98a4ab;
--bone-faint: #5d6b73; --bone-faint: #5d6b73;
@@ -133,6 +132,10 @@ button:disabled {
color: var(--bone-dim); color: var(--bone-dim);
} }
.small {
font-size: 0.85rem;
}
.warning { .warning {
color: var(--blood); color: var(--blood);
} }
@@ -150,6 +153,58 @@ button:disabled {
height: 1px; height: 1px;
overflow: hidden; overflow: hidden;
clip: rect(0 0 0 0); 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) { @media (prefers-reduced-motion: reduce) {
+11 -34
View File
@@ -6,17 +6,16 @@
import { otherGames, type FamilyGame } from '$lib/family'; import { otherGames, type FamilyGame } from '$lib/family';
import Preferences from './Preferences.svelte'; import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.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 { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { unreadTalk } from '$lib/net/talk'; 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 type { State } from '$lib/game';
import { game } from '$lib/game'; import { game } from '$lib/game';
let name = $state(playerName()); let name = $state(playerName());
let code = $state(''); let code = $state('');
/** The host's choices for the next table, from the game's declared options. */ /** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */
// A new table starts from the preferences' table defaults; the hall's selectors set the same keys.
const options = $derived<Record<string, string>>( const options = $derived<Record<string, string>>(
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])) 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(() => { $effect(() => {
const held = seats; const held = seats;
if (!held.length) return; if (!held.length) return;
@@ -163,7 +162,7 @@
<h1>__NAME__</h1> <h1>__NAME__</h1>
<p class="pitch">One line that says what the game is.</p> <p class="pitch">One line that says what the game is.</p>
<p class="tag">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.</p> <p class="tag">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.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game a labor of love</a> · <button type="button" class="link-like" onclick={() => (prefsOpen = true)}>preferences</button></p> <p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} /> <Preferences bind:open={prefsOpen} />
</div> </div>
@@ -176,7 +175,7 @@
<div class="options"> <div class="options">
{#each game.options as o (o.key)} {#each game.options as o (o.key)}
{@const chosen = o.choices.find((c) => c.value === options[o.key])} {@const chosen = o.choices.find((c) => c.value === options[o.key])}
<div class="option-block"> <div>
<label class="option"> <label class="option">
<span>{o.label}</span> <span>{o.label}</span>
<select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}> <select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}>
@@ -198,7 +197,7 @@
</form> </form>
</div> </div>
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p> <p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
<form class="joinform claimform" onsubmit={claim}> <form class="joinform" onsubmit={claim}>
<span class="or">or bring a seat from another device</span> <span class="or">or bring a seat from another device</span>
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" /> <input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button> <button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
@@ -220,7 +219,7 @@
<span class="ledger-code">{held.roomId}</span> <span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span> <span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a> </a>
<button type="button" class="ledger-forget" title="forget this game" onclick={() => forget(held.roomId)}>×</button> <button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
</div> </div>
{/each} {/each}
{#if reports.length} {#if reports.length}
@@ -382,10 +381,6 @@
text-align: center; text-align: center;
} }
.small {
font-size: 0.85rem;
}
.ways { .ways {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -489,18 +484,6 @@
color: var(--frost); 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 { .reports-head {
margin-top: 0.9rem; margin-top: 0.9rem;
} }
@@ -591,6 +574,7 @@
font-size: 0.85rem; font-size: 0.85rem;
color: var(--bone-faint); color: var(--bone-faint);
} }
.family { .family {
margin: 0.4rem 0 0.8rem 1.2rem; margin: 0.4rem 0 0.8rem 1.2rem;
padding: 0; padding: 0;
@@ -599,6 +583,7 @@
.family li + li { .family li + li {
margin-top: 0.3rem; margin-top: 0.3rem;
} }
.options { .options {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -617,6 +602,7 @@
margin: 0.2rem 0 0; margin: 0.2rem 0 0;
max-width: 22em; max-width: 22em;
} }
.tally { .tally {
display: grid; display: grid;
grid-template-columns: max-content 1fr; grid-template-columns: max-content 1fr;
@@ -632,13 +618,4 @@
.tally dd { .tally dd {
margin: 0; margin: 0;
} }
.link-like {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
</style> </style>
+8 -37
View File
@@ -2,15 +2,11 @@
import TableTalk from './TableTalk.svelte'; import TableTalk from './TableTalk.svelte';
import type { Room } from '$lib/net/room.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 v = $derived(room.view);
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host'); 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())); 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. */ /** The hour where the keeper lives, in words, so a caller knows whether to wait. */
let minute = $state(0); let minute = $state(0);
@@ -31,22 +27,12 @@
return ''; 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.
}
}
</script> </script>
<section class="lobby"> <section class="lobby">
{#if room.spectating} {#if room.spectating}
<h2>At the table</h2> <h2>At the table</h2>
{#if seatFree} {#if room.seatEmpty}
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p> <p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
{:else} {:else}
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p> <p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
@@ -62,7 +48,7 @@
<li> <li>
<span class="seat-name">{s.name}</span> <span class="seat-name">{s.name}</span>
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost} <span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost}
<button type="button" class="unseat" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span> <button type="button" class="dismiss" title="Send this bot away" aria-label="Send {s.name} away" onclick={() => room.unseat(s.id)}>×</button>{/if}</span>
</li> </li>
{/each} {/each}
{#each v.expected as name (name)} {#each v.expected as name (name)}
@@ -78,7 +64,7 @@
{/if} {/if}
{#if !room.spectating} {#if !room.spectating}
<div class="ways"> <div class="ways">
{#if seatFree && room.isHost} {#if room.seatEmpty && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button> <button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if} {/if}
{#if room.isHost} {#if room.isHost}
@@ -94,13 +80,13 @@
Let the Peanut Gallery talk at the table Let the Peanut Gallery talk at the table
</label> </label>
{/if} {/if}
{#if v.keeper && !v.challenge && !keeperSeated && seatOpen} {#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
<div class="keeper"> <div class="keeper">
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button> <button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
<p class="muted small">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p> <p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
</div> </div>
{:else if v.challenge} {:else if v.challenge}
<p class="muted small">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;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.</p> <p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;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.</p>
{/if} {/if}
{/if} {/if}
{#if room.error}<p class="warning">{room.error}</p>{/if} {#if room.error}<p class="warning">{room.error}</p>{/if}
@@ -148,20 +134,6 @@
font-weight: 500; 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 { .ways {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -203,8 +175,7 @@
max-width: 38em; max-width: 38em;
} }
.small { .note {
font-size: 0.85rem;
margin-top: 0.4rem; margin-top: 0.4rem;
} }
</style> </style>
+11 -15
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
// The preferences panel: every declared setting, kit and game, as a // The preferences panel: every declared setting, kit and game, as a
// control. Opened from the masthead; closes with its button or Escape. // control. Opened from the masthead; closes with its button, a click
// outside it, or Escape.
import { PREFERENCES, prefs, setPref } from '$lib/prefs.svelte'; import { PREFERENCES, prefs, setPref } from '$lib/prefs.svelte';
let { open = $bindable(false) }: { open?: boolean } = $props(); let { open = $bindable(false) }: { open?: boolean } = $props();
@@ -13,7 +14,7 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
{#if open} {#if open}
<div class="scrim" role="presentation" onclick={() => (open = false)}></div> <div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title"> <div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
<h2 id="prefs-title">Preferences</h2> <h2 id="prefs-title">Preferences</h2>
<p class="muted small">Kept in this browser, and nowhere else.</p> <p class="muted small">Kept in this browser, and nowhere else.</p>
@@ -37,28 +38,27 @@
{/each} {/each}
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button> <button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
</div> </div>
</div>
{/if} {/if}
<style> <style>
.scrim { .scrim {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 20; z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
} }
.panel { .panel {
position: fixed; width: min(100%, 26rem);
z-index: 21; max-height: 100%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 26rem);
max-height: 90vh;
overflow: auto; overflow: auto;
background: var(--slate); background: var(--slate);
border: 1px solid var(--rule-strong); border: 1px solid var(--rule-strong);
border-radius: 8px; border-radius: 6px;
padding: 1.2rem 1.4rem 1.4rem; padding: 1.2rem 1.4rem 1.4rem;
} }
@@ -87,10 +87,6 @@
margin-top: 0.3rem; margin-top: 0.3rem;
} }
.small {
font-size: 0.85rem;
}
.quiet { .quiet {
margin-top: 0.8rem; margin-top: 0.8rem;
} }
+3 -17
View File
@@ -38,12 +38,11 @@
async function send(e: SubmitEvent) { async function send(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
const r = room;
if (!happened.trim() || busy) return; if (!happened.trim() || busy) return;
busy = true; busy = true;
error = ''; error = '';
try { try {
const { id } = await api.report(r.roomId, r.token, happened.trim(), expected.trim()); const { id } = await api.report(room.roomId, room.token, happened.trim(), expected.trim());
if (image) await api.reportImage(id, image).catch(() => (error = 'The report went; the picture did not.')); if (image) await api.reportImage(id, image).catch(() => (error = 'The report went; the picture did not.'));
sent = true; sent = true;
setTimeout(close, 2200); setTimeout(close, 2200);
@@ -75,7 +74,7 @@
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} /> <input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if} {#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label> </label>
<p class="muted small">The room code and the round ride along; the keeper can replay the game to this moment.</p> <p class="muted small note">The room code and the round ride along; the keeper can replay the game to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if} {#if error}<p class="warning">{error}</p>{/if}
<div class="actions"> <div class="actions">
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button> <button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
@@ -119,7 +118,6 @@
margin-top: 0.7rem; margin-top: 0.7rem;
} }
.picture input { .picture input {
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -132,13 +130,11 @@
margin-top: 0.3rem; margin-top: 0.3rem;
} }
.small { .note {
font-size: 0.85rem;
margin-top: 0.6rem; margin-top: 0.6rem;
} }
.warning { .warning {
color: var(--blood);
margin-top: 0.4rem; margin-top: 0.4rem;
} }
@@ -152,14 +148,4 @@
gap: 1rem; gap: 1rem;
margin-top: 0.9rem; margin-top: 0.9rem;
} }
.link {
background: none;
border: 0;
color: var(--bone-dim);
text-decoration: underline dotted;
padding: 0;
font: inherit;
}
</style> </style>
+2 -3
View File
@@ -57,7 +57,7 @@
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button> <button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form> </form>
{:else if room.spectating} {:else if room.spectating}
<p class="muted small">The gallery listens; the host has not let it talk.</p> <p class="muted small note">The gallery listens; the host has not let it talk.</p>
{:else} {:else}
<form onsubmit={say}> <form onsubmit={say}>
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" /> <input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
@@ -125,8 +125,7 @@
flex: 0 1 8rem; flex: 0 1 8rem;
} }
.small { .note {
font-size: 0.85rem;
margin-top: 0.4rem; margin-top: 0.4rem;
} }
</style> </style>
+13 -2
View File
@@ -34,13 +34,24 @@ export interface TableOption {
/** The host's choices for a table, by option key; only keys the game declares get through. */ /** The host's choices for a table, by option key; only keys the game declares get through. */
export type TableOptions = Record<string, string>; export type TableOptions = Record<string, string>;
/** A setting the game adds to the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */
export interface Preference {
key: string;
label: string;
/** A toggle holds a boolean; a choice holds one of its choices' values. */
kind: 'toggle' | 'choice';
choices?: { value: string; label: string }[];
default: boolean | string;
note?: string;
}
export interface GameSpec<State, Input> { export interface GameSpec<State, Input> {
/** Seats in table order; the table takes at most this many. */ /** Seats in table order; the table takes at most this many. */
seatIds: SeatId[]; seatIds: SeatId[];
/** What the host may choose when opening a table; empty for a game with one way to play. */ /** What the host may choose when opening a table; empty for a game with one way to play. */
options?: TableOption[]; options?: TableOption[];
/** The game's own settings for the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */ /** The game's own settings for the preferences panel. */
preferences?: { key: string; label: string; kind: 'toggle' | 'choice'; choices?: { value: string; label: string }[]; default: boolean | string; note?: string }[]; preferences?: Preference[];
minSeats: number; minSeats: number;
/** Names the server draws for bots, in preference order. */ /** Names the server draws for bots, in preference order. */
botNames: string[]; botNames: string[];
+13 -39
View File
@@ -2,7 +2,7 @@
// browser holds, remembered so a shared link never has to carry a token. // browser holds, remembered so a shared link never has to carry a token.
import type { SeatId, TableOptions } from '$lib/game/spec'; import type { SeatId, TableOptions } from '$lib/game/spec';
import type { RoomView } from './view'; import type { Report, RoomView, Tally } from './view';
export interface HeldSeat { export interface HeldSeat {
seat: SeatId; seat: SeatId;
@@ -41,7 +41,7 @@ export function forgetSeat(roomId: string): void {
/** /**
* A seat the server refused is doubted, not dropped: a restarting server, a * A seat the server refused is doubted, not dropped: a restarting server, a
* stale answer or the wrong backend all answer with ignorance, and ignorance * stale answer or the wrong backend all answer with ignorance, and ignorance
* is not deletion. Only the SECOND refusal in a row costs the seat. * is not deletion. Only the second refusal in a row costs the seat.
*/ */
const DOUBT_KEY = '__SLUG__:doubted'; const DOUBT_KEY = '__SLUG__:doubted';
@@ -103,44 +103,20 @@ export class ServerError extends Error {
} }
} }
async function call<T>(method: string, path: string, body?: unknown): Promise<T> { /** The server's answer as JSON, or its refusal as a ServerError with the reason it gave. */
const res = await fetch(path, { async function fetchJson<T>(path: string, init: RequestInit): Promise<T> {
method, const res = await fetch(path, init);
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
});
const data = (await res.json().catch(() => ({}))) as { error?: string }; const data = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status); if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
return data as T; return data as T;
} }
/** A report to the keeper, with the exchange under it. */ function call<T>(method: string, path: string, body?: unknown): Promise<T> {
export interface Report { return fetchJson<T>(path, {
id: string; method,
at: string; headers: body ? { 'content-type': 'application/json' } : undefined,
roomId: string; body: body ? JSON.stringify(body) : undefined
player: string; });
seat: SeatId | null;
turn: number | null;
happened: string;
expected: string;
thread: { at: string; text: string; from: 'desk' | 'player'; status?: string }[];
image?: string;
}
/** What the ledgers add up to. */
export interface Tally {
tablesOpened: number;
gamesBegun: number;
gamesFinished: number;
namesSeated: number;
turnsPlayed: number;
minutesAtTable: number;
longestGameTurns: number;
gamesWithBot: number;
talkLines: number;
firstGameAt: string | null;
byGame: Record<string, number>;
} }
export function makeApi<State, Input>() { export function makeApi<State, Input>() {
@@ -167,10 +143,8 @@ export function makeApi<State, Input>() {
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }), claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
report: (roomId: string, token: string, happened: string, expected: string) => report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }), call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
reportImage: async (id: string, file: File) => { reportImage: (id: string, file: File) =>
const res = await fetch(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file }); fetchJson<void>(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file }),
if (!res.ok) throw new ServerError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? `The server answered ${res.status}.`, res.status);
},
tally: () => call<Tally>('GET', '/api/tally'), tally: () => call<Tally>('GET', '/api/tally'),
myReports: (seats: { roomId: string; token: string }[]) => call<{ reports: Report[] }>('POST', '/api/reports/mine', { seats }), myReports: (seats: { roomId: string; token: string }[]) => call<{ reports: Report[] }>('POST', '/api/reports/mine', { seats }),
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text }) answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
+7 -4
View File
@@ -1,6 +1,6 @@
// The reactive room a page shows: the seat this browser holds (or the // The reactive room a page shows: the seat this browser holds (or the
// gallery), the table, the talk, and the game's view. It knows the game only // gallery), the table, the talk, and the game's view. It knows the game only
// through GameSpec; a game's own store wraps or extends this one. // through GameSpec.
import { game } from '$lib/game'; import { game } from '$lib/game';
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec'; import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
@@ -32,7 +32,7 @@ export function rememberName(raw: string): void {
} }
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */ /** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
export function listText(names: string[]): string { function listText(names: string[]): string {
if (names.length <= 1) return names[0] ?? ''; if (names.length <= 1) return names[0] ?? '';
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
} }
@@ -57,13 +57,16 @@ export class Room {
started = $derived(this.view.state !== null); started = $derived(this.view.state !== null);
state = $derived(this.view.state); state = $derived(this.view.state);
isHost = $derived(!this.spectating && this.view.host === this.view.me); isHost = $derived(!this.spectating && this.view.host === this.view.me);
others = $derived(this.view.seats.filter((s) => s.id !== this.me));
/** Your move is in and the round waits on someone else. */ /** Your move is in and the round waits on someone else. */
moved = $derived(this.started && !this.view.over && !this.spectating && !this.view.waitingOn.includes(this.me)); moved = $derived(this.started && !this.view.over && !this.spectating && !this.view.waitingOn.includes(this.me));
yourMove = $derived(this.started && !this.view.over && this.view.waitingOn.includes(this.me));
/** Names of the seats the round is waiting on, yours aside. */ /** Names of the seats the round is waiting on, yours aside. */
awaiting = $derived(this.view.waitingOn.filter((id) => id !== this.me).map((id) => this.nameOf(id))); awaiting = $derived(this.view.waitingOn.filter((id) => id !== this.me).map((id) => this.nameOf(id)));
awaitingText = $derived(listText(this.awaiting)); awaitingText = $derived(listText(this.awaiting));
others = $derived(this.view.seats.filter((s) => s.id !== this.me)); /** A chair at the table is empty, held for someone or not. */
seatEmpty = $derived(!this.started && this.view.seats.length < this.view.size);
/** An empty chair not held for anyone: a stranger may sit. */
seatUnheld = $derived(this.seatEmpty && this.view.seats.length + this.view.expected.length < this.view.size);
nameOf(id: SeatId): string { nameOf(id: SeatId): string {
return this.view.seats.find((s) => s.id === id)?.name ?? (this.view.state ? game.nameOf(this.view.state, id) : id); return this.view.seats.find((s) => s.id === id)?.name ?? (this.view.state ? game.nameOf(this.view.state, id) : id);
+45
View File
@@ -46,3 +46,48 @@ export interface RoomView<State> {
keeper: string; keeper: string;
keeperZone: string; keeperZone: string;
} }
/** One line under a report: the keeper's reply, with its status, or the player's answer. */
export interface ReportLine {
at: string;
text: string;
from: 'desk' | 'player';
status?: string;
}
/** A report to the keeper, with the exchange under it. */
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;
}
/** What the ledgers add up to, for the hall's about panel. */
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<string, number>;
}
+3 -12
View File
@@ -4,19 +4,10 @@
// panel renders every declaration. Nothing here leaves the browser. // panel renders every declaration. Nothing here leaves the browser.
import { game } from '$lib/game'; import { game } from '$lib/game';
import type { Preference } from '$lib/game/spec';
export interface Preference {
key: string;
label: string;
/** A toggle holds a boolean; a choice holds one of its choices' values. */
kind: 'toggle' | 'choice';
choices?: { value: string; label: string }[];
default: boolean | string;
note?: string;
}
/** The settings every game in the kit shares. */ /** The settings every game in the kit shares. */
export const KIT_PREFERENCES: Preference[] = [ const KIT_PREFERENCES: Preference[] = [
{ key: 'motion', label: 'Motion', kind: 'choice', choices: [{ value: 'full', label: 'pieces move and fade' }, { value: 'reduced', label: 'changes appear at once' }], default: 'full' }, { key: 'motion', label: 'Motion', kind: 'choice', choices: [{ value: 'full', label: 'pieces move and fade' }, { value: 'reduced', label: 'changes appear at once' }], default: 'full' },
{ key: 'confirmMoves', label: 'Confirm a move before it is sent', kind: 'toggle', default: false } { key: 'confirmMoves', label: 'Confirm a move before it is sent', kind: 'toggle', default: false }
]; ];
@@ -54,7 +45,7 @@ function load(): Record<string, boolean | string> {
} }
/** The live preferences; read them where they matter, set them through setPref. */ /** The live preferences; read them where they matter, set them through setPref. */
export const prefs = $state<Record<string, boolean | string>>(typeof localStorage === 'undefined' ? defaults() : load()); export const prefs = $state<Record<string, boolean | string>>(load());
export function setPref(key: string, value: boolean | string): void { export function setPref(key: string, value: boolean | string): void {
prefs[key] = value; prefs[key] = value;
+2 -38
View File
@@ -7,14 +7,14 @@
/** Opened from a table, the page offers the way back to it and carries it to its cross-links. */ /** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase()); const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
const withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path)); const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
</script> </script>
<svelte:head> <svelte:head>
<title>__NAME__: how to play</title> <title>__NAME__: how to play</title>
</svelte:head> </svelte:head>
<main class="guide"> <main class="reading">
<header> <header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a> {#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
<h1>How to play</h1> <h1>How to play</h1>
@@ -57,31 +57,6 @@
</main> </main>
<style> <style>
.guide {
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
a {
color: var(--frost);
}
.back {
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.lede {
color: var(--bone-dim);
max-width: 40em;
}
nav { nav {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -90,17 +65,6 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
section {
padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule);
margin-top: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.7rem;
}
p + p { p + p {
margin-top: 0.7rem; margin-top: 0.7rem;
} }
+6 -8
View File
@@ -22,10 +22,8 @@
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false); let rematching = $state(false);
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`); const link = $derived(`${location.origin}/join/${roomId}`);
const watching = $derived(room?.spectating ?? false); const watching = $derived(room?.spectating ?? false);
/** A watcher may still sit while the table is laid and a seat is free. */
const seatFree = $derived(!!room && !room.started && room.view.seats.length < room.view.size);
// Return to a held seat, or take a place in the gallery and offer one. // Return to a held seat, or take a place in the gallery and offer one.
$effect(() => { $effect(() => {
@@ -157,12 +155,12 @@
</p> </p>
{/if} {/if}
{#if joining && seatFree} {#if joining && room?.seatEmpty}
<section class="seat"> <section class="seat">
<p class="eyebrow">room {roomId}</p> <p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2> <h2>Take a seat</h2>
<p>A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.</p> <p>A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.</p>
<form class="rename" onsubmit={takeSeat}> <form class="sit" onsubmit={takeSeat}>
<label class="field"> <label class="field">
<span>Your name</span> <span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required /> <input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
@@ -174,7 +172,7 @@
{/if} {/if}
{#if room && !room.started} {#if room && !room.started}
<Lobby {room} {link} /> <Lobby {room} {link} {copied} {copyLink} />
{:else if room} {:else if room}
<Board {room} /> <Board {room} />
{:else if !error} {:else if !error}
@@ -259,7 +257,7 @@
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
.rename { .sit {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
@@ -278,7 +276,7 @@
font-style: italic; font-style: italic;
} }
.rename input { .sit input {
width: 14em; width: 14em;
} }
+3 -39
View File
@@ -4,16 +4,16 @@
// where you can, so the page and the engine follow one source. // where you can, so the page and the engine follow one source.
import { page } from '$app/state'; import { page } from '$app/state';
/** Opened from a table, the page offers the way back to it. */ /** Opened from a table, the page offers the way back to it and carries it to its cross-links. */
const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase()); const room = $derived((page.url.searchParams.get('room') ?? '').toUpperCase());
const withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path)); const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
</script> </script>
<svelte:head> <svelte:head>
<title>__NAME__: the rules</title> <title>__NAME__: the rules</title>
</svelte:head> </svelte:head>
<main class="rules"> <main class="reading">
<header> <header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a> {#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
<h1>The rules</h1> <h1>The rules</h1>
@@ -39,42 +39,6 @@
</main> </main>
<style> <style>
.rules {
max-width: 46rem;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
a {
color: var(--frost);
}
.back {
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
margin: 0.5rem 0 0.3rem;
}
.lede {
color: var(--bone-dim);
max-width: 40em;
}
section {
padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule);
margin-top: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.7rem;
}
.word { .word {
margin-top: 2rem; margin-top: 2rem;
color: var(--bone-faint); color: var(--bone-faint);