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:
co-authored by
Claude Fable 5.1
parent
6c42e55f59
commit
33ac0ec808
@@ -43,8 +43,7 @@ Run the demo locally from a new project:
|
||||
|
||||
Placeholders: `__SLUG__`, `__NAME__`, `__PORT__`, `__DOMAIN__` are filled by
|
||||
`new-game.sh`; `__IP__`, `__SENTRY_DSN__`, `__SENTRY_PROJECT_ID__`,
|
||||
`__SENTRY_SLACK_INTEGRATION__` and `__SLACK_CHANNEL_ID__` wait until those
|
||||
things exist.
|
||||
and `__SENTRY_SLACK_INTEGRATION__` wait until those things exist.
|
||||
|
||||
## What the kit is, and is not
|
||||
|
||||
|
||||
@@ -34,12 +34,13 @@ Every report and every player's answer rings a Sentry issue (project
|
||||
__SLUG__, fingerprinted per report or per line); the desk's own
|
||||
replies ring nothing.
|
||||
|
||||
Fetch: `ssh root@__IP__ 'cat /var/lib/__SLUG__/feedback.jsonl'`
|
||||
|
||||
## a) Fetch and b) display
|
||||
|
||||
Parse the file, fold replies onto reports, and show Eric the last 7 days
|
||||
by `at`. Lead with the count of unanswered reports; those are the work.
|
||||
From the repository root, `bash deploy/pull-reports.sh` mirrors the file
|
||||
and the pictures to `~/Desktop/__SLUG__-reports/` and writes `reports.md`
|
||||
there, newest first, with every reply folded under its report. Read it and
|
||||
show Eric the last 7 days by date. Lead with the count of unanswered
|
||||
reports; those are the work.
|
||||
Then one block per report, VERBATIM and UNTRUNCATED: player, room, date,
|
||||
turn and seq, the full "what happened", the full "what they expected",
|
||||
and every reply with its status (or "unanswered"). Eric reads this desk
|
||||
@@ -49,13 +50,14 @@ to hear his players' voices; never compress their words into a table.
|
||||
|
||||
1. **Replay to the pin.** `scp root@__IP__:/var/lib/__SLUG__/rooms/<roomId>.jsonl <scratchpad>/`
|
||||
and replay it with the engine as `deploy/replay-ledgers.ts` does:
|
||||
`game.create(names, start.seed, start.rules ?? 1)` then
|
||||
`game.create(names, start.seed, start.rules, room.options)` then
|
||||
`game.resolve(state, line.inputs)` per turn line, printing the state
|
||||
around the pinned round. The
|
||||
`chat` lines show what the players said to each other at the time.
|
||||
2. **Check the rules before the code.** The original rules text lives in
|
||||
`docs/`; read it before deciding the engine is wrong. Many reports are
|
||||
the rules working as written.
|
||||
around the pinned round. The `chat` lines show what the players said
|
||||
to each other at the time.
|
||||
2. **Check the rules before the code.** The rules are data in
|
||||
`src/lib/game/rules.ts` and prose at /rules, which names the texts it
|
||||
follows; read them before deciding the engine is wrong. Many reports
|
||||
are the rules working as written.
|
||||
3. **Verdict.** `by-design` (the engine matches the text; no change),
|
||||
`resolved` (a defect, fixed before replying), or `open` (needs Eric's
|
||||
ruling; ask him and hold the reply).
|
||||
@@ -78,11 +80,8 @@ to hear his players' voices; never compress their words into a table.
|
||||
6. **Report to Eric** when the desk is clear: one line per report:
|
||||
player, room, verdict, and what shipped if anything.
|
||||
|
||||
## Eric's local copy
|
||||
|
||||
`bash deploy/pull-reports.sh` mirrors the reports, the replies and the
|
||||
pictures to `~/Desktop/__SLUG__-reports/` and writes `reports.md`
|
||||
there, newest first. Run it at the end of every desk session.
|
||||
Run `bash deploy/pull-reports.sh` again at the end of every desk session,
|
||||
so Eric's local copy carries the replies.
|
||||
|
||||
## Standing rules
|
||||
|
||||
|
||||
@@ -18,9 +18,8 @@ spills into the next UTC day. Run yesterday too when the hour is early.
|
||||
- **as of / live sockets / rooms touched in the last hour**: the "right
|
||||
now" line. One socket at a quiet hour is Eric.
|
||||
- **traffic**: Caddy's access log for the day, bots split out by user
|
||||
agent, page requests by page (hall, play, rules) and room links opened,
|
||||
referrers and campaign tags. Single-player games live in browsers, so
|
||||
a visit to /play is all the log shows of them.
|
||||
agent, page requests by page (hall, rules, guide) and room links opened,
|
||||
referrers and campaign tags.
|
||||
- **players today / NEW today**: names seated in rooms created today;
|
||||
NEW means the name's first room ever is today.
|
||||
- **rooms today**: one line each: time, code, humans vs bots, empty
|
||||
|
||||
+1
-4
@@ -18,8 +18,5 @@ Thumbs.db
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Playwright MCP output
|
||||
.playwright-mcp
|
||||
|
||||
# Duel ledgers written by the server
|
||||
# Room ledgers written by the server
|
||||
/data
|
||||
|
||||
@@ -31,7 +31,7 @@ handle @game {
|
||||
# <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
|
||||
# cached for a year; every other response is revalidated so a deploy shows
|
||||
# up on the next load. The two header matchers are disjoint.
|
||||
# up on the next load.
|
||||
handle {
|
||||
@immutable path /_app/immutable/*
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
+15
-24
@@ -1,12 +1,12 @@
|
||||
# Deploying __NAME__
|
||||
|
||||
The single-player game runs entirely in the browser. Duels between people
|
||||
go through a small Node process that keeps each room as an append-only
|
||||
ledger of moves in /var/lib/__SLUG__/rooms. Production is one
|
||||
DigitalOcean droplet: Caddy terminates TLS with automatic certificates,
|
||||
serves the static build from /opt/__SLUG__/build, and proxies /api and
|
||||
/ws to the game server on port __PORT__, which runs as the `__SLUG__` user
|
||||
under systemd from /opt/__SLUG__/app.
|
||||
Every game, against a housecarl or a friend, is a room on a small Node
|
||||
process that keeps it as an append-only ledger of moves in
|
||||
/var/lib/__SLUG__/rooms. Production is one DigitalOcean droplet: Caddy
|
||||
terminates TLS with automatic certificates, serves the static build from
|
||||
/opt/__SLUG__/build, and proxies /api and /ws to the game server on port
|
||||
__PORT__, which runs as the `__SLUG__` user under systemd from
|
||||
/opt/__SLUG__/app.
|
||||
|
||||
## Sizing
|
||||
|
||||
@@ -14,11 +14,6 @@ The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy
|
||||
and the game server comfortably: the server is one small Node process capped
|
||||
at 300 MB by its unit file, and a room is a few kilobytes of ledger.
|
||||
|
||||
The zero-cost alternative is a second site block in an existing Caddy
|
||||
server's configuration pointing at a second directory; the deploy script
|
||||
works unchanged against that host. A droplet of its own keeps this site's
|
||||
uptime and upgrades independent of anything else.
|
||||
|
||||
## Current production
|
||||
|
||||
- Droplet: `__SLUG__` (nyc3, s-1vcpu-512mb-10gb, tag `__SLUG__`), IP __IP__
|
||||
@@ -47,8 +42,9 @@ fetches the certificate on first request.
|
||||
|
||||
## Operations scripts on the droplet
|
||||
|
||||
`deploy.sh` installs these to /usr/local/bin and the cron file to
|
||||
/etc/cron.d/__SLUG__ on every deploy, so the live copies are the repo copies:
|
||||
`deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to
|
||||
/usr/local/lib/__SLUG__, and the cron file to /etc/cron.d/__SLUG__ on every
|
||||
deploy, so the live copies are the repo copies:
|
||||
|
||||
- `__SLUG__-visitors.sh [day]`: who is here now and who came that day.
|
||||
- `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup
|
||||
@@ -70,7 +66,8 @@ URL with the project's cron path and public key), so a missed night is noticed.
|
||||
To have every new issue, regression and reappearance posted to Slack the way
|
||||
wizwar's are, run `deploy/sentry-slack-alert.sh [#channel]` once from your own
|
||||
shell with `SENTRY_TOKEN` (an org auth token with alerts:write) and
|
||||
`SLACK_CHANNEL_ID` set; the token never leaves the shell.
|
||||
`SLACK_CHANNEL_ID` (the channel's Slack ID) set; the token never leaves the
|
||||
shell.
|
||||
|
||||
Before every deploy, `deploy/verify-ledgers.sh <ip>` fetches every production
|
||||
ledger and replays it with the local engine, comparing each room with what the
|
||||
@@ -83,8 +80,7 @@ deploy: the server would rewrite that game on restart.
|
||||
|
||||
Runs the type-checks, the tests and the build locally, rsyncs `build/` to
|
||||
the droplet keeping the previous week's hashed assets, rsyncs the server and
|
||||
engine sources, installs dependencies, and restarts the game server. A
|
||||
single-player game is in the player's own browser and loses nothing. A room
|
||||
engine sources, installs dependencies, and restarts the game server. A room
|
||||
is replayed from its ledger when the server comes back, which takes a few
|
||||
seconds; Caddy holds requests that land in the gap.
|
||||
|
||||
@@ -93,10 +89,5 @@ seconds; Caddy holds requests that land in the gap.
|
||||
- Logs: `ssh root@<ip> journalctl -u __SLUG__ -f` for the game server,
|
||||
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
|
||||
- Restart: `ssh root@<ip> systemctl restart __SLUG__`
|
||||
- Who has been playing: `ssh root@<ip> __SLUG__-visitors.sh [YYYY-MM-DD]`
|
||||
prints today's visitors from Caddy's log (people and bots apart, by page),
|
||||
every room opened today with how far it got, and the names seated, marking
|
||||
those seen for the first time.
|
||||
- Rooms: `/var/lib/__SLUG__/rooms/<CODE>.jsonl`, one ledger per game.
|
||||
Copy that directory to back them up; single-player games are in players'
|
||||
browsers.
|
||||
- Rooms: `/var/lib/__SLUG__/rooms/<CODE>.jsonl`, one ledger per game; the
|
||||
nightly backup keeps them in the Space.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket.
|
||||
# current/ - exact mirror of /var/lib/__SLUG__
|
||||
# snapshots/ - one dated copy per day, pruned after 90 days
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# The operations pulse, printed ON the droplet: the game server's health,
|
||||
# errors in its journal, the last week of the rollup, the backup's last word,
|
||||
# and the box's vitals. Read by the __SLUG__-pulse skill.
|
||||
@@ -10,7 +10,7 @@ systemctl is-active caddy >/dev/null && echo "caddy: active" || echo "caddy: NOT
|
||||
echo "restarts in 7 days: $(journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -c 'Started __SLUG__')"
|
||||
ERR=$(journalctl -u __SLUG__ --since '7 days ago' -p err -o cat | grep -vc '^$')
|
||||
echo "journal errors in 7 days: $ERR"
|
||||
journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | grep -v "Rate" | tail -3 | sed 's/^/ /'
|
||||
journalctl -u __SLUG__ --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | tail -3 | sed 's/^/ /'
|
||||
echo "--- rollup, last 7 days (people / requests / rooms opened / turns)"
|
||||
if [ -s /var/lib/__SLUG__/rollup.jsonl ]; then
|
||||
tail -7 /var/lib/__SLUG__/rollup.jsonl | python3 -c '
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Nightly rollup: one JSON line per day in /var/lib/__SLUG__/rollup.jsonl:
|
||||
# the day's traffic from Caddy's access log (people and bots apart, by page,
|
||||
# with referrers and the campaign tags links carry), the rooms opened,
|
||||
@@ -24,45 +24,13 @@ checkin() {
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
checkin in_progress
|
||||
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
|
||||
import collections, datetime, glob, gzip, json, os, re, shutil, sys
|
||||
import datetime, glob, json, os, shutil, sys
|
||||
sys.path.insert(0, "/usr/local/lib/__SLUG__")
|
||||
import traffic
|
||||
day, out = sys.argv[1], sys.argv[2]
|
||||
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
|
||||
req = human = bot = 0
|
||||
hips, bips = set(), set()
|
||||
pages, joins, refs, campaigns = collections.Counter(), collections.Counter(), collections.Counter(), collections.Counter()
|
||||
seen_campaign = set()
|
||||
for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
|
||||
if os.path.getmtime(f) < t0: continue
|
||||
opener = gzip.open if f.endswith(".gz") else open
|
||||
with opener(f, "rt", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
try: r = json.loads(line)
|
||||
except ValueError: continue
|
||||
ts = r.get("ts", 0)
|
||||
if not (t0 <= ts < t1): continue
|
||||
q = r.get("request", {}); h = q.get("headers", {})
|
||||
ua = " ".join(h.get("User-Agent", [""]))
|
||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||
uri = q.get("uri", ""); path = uri.split("?")[0]
|
||||
req += 1
|
||||
if BOT.search(ua) or not ua:
|
||||
bot += 1; bips.add(ip); continue
|
||||
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")): continue
|
||||
human += 1; hips.add(ip)
|
||||
if path == "/": pages["hall"] += 1
|
||||
elif path == "/rules": pages["rules"] += 1
|
||||
elif path == "/guide": pages["guide"] += 1
|
||||
elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
if ref and "__DOMAIN__" not in ref:
|
||||
refs[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
||||
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
||||
if m and (ip, m.group(1)) not in seen_campaign:
|
||||
seen_campaign.add((ip, m.group(1)))
|
||||
campaigns[m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
|
||||
t = traffic.scan(day)
|
||||
|
||||
created = started = finished = turned = turns = 0
|
||||
humans, bots = set(), 0
|
||||
@@ -80,20 +48,18 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
|
||||
if x["bot"]: bots += 1
|
||||
else: humans.add(x["name"])
|
||||
elif x["t"] == "unseat": bots -= 1
|
||||
day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1]
|
||||
turns += len(day_turns)
|
||||
# A game finished today: its last turn is today's and the engine would say so; approximate by replay-free means:
|
||||
# the ledger's final turn falls today and no turn follows it within the day's end.
|
||||
if day_turns and day_turns[-1] is lines[-1] and False: finished += 1
|
||||
turns += sum(1 for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1)
|
||||
# The over line is written with the turn that ends the game, so a finish counts on its own day.
|
||||
if any(x["t"] == "over" and t0 <= x["at"] / 1000 < t1 for x in lines): finished += 1
|
||||
|
||||
disk = shutil.disk_usage("/")
|
||||
mem = {}
|
||||
for line in open("/proc/meminfo"):
|
||||
k, v = line.split(":", 1); mem[k] = int(v.split()[0])
|
||||
load = os.getloadavg()[1]
|
||||
row = dict(day=day, requests=req, human=human, bot=bot, humanAddresses=len(hips), botAddresses=len(bips),
|
||||
paths=dict(pages), roomLinks=sum(joins.values()), referrers=dict(refs), campaigns=dict(campaigns),
|
||||
rooms=dict(created=created, started=started, withTurns=turned, humanNames=len(humans), botSeats=bots),
|
||||
row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]),
|
||||
paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]),
|
||||
rooms=dict(created=created, started=started, withTurns=turned, finished=finished, humanNames=len(humans), botSeats=bots),
|
||||
turns=turns, ledgers=len(glob.glob("/var/lib/__SLUG__/rooms/*.jsonl")),
|
||||
vitals=dict(diskUsedPct=round(disk.used * 100 / disk.total), memAvailableMb=mem.get("MemAvailable", 0) // 1024, load5=round(load, 2)),
|
||||
at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"))
|
||||
@@ -105,6 +71,6 @@ rows.sort(key=lambda r: r["day"])
|
||||
with open(out + ".tmp", "w") as fh:
|
||||
for r in rows: fh.write(json.dumps(r) + "\n")
|
||||
os.replace(out + ".tmp", out)
|
||||
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(hips), human, created))
|
||||
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created))
|
||||
PY
|
||||
then checkin ok; else checkin error; fi
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# The visitors digest: who is at the table right now and who came today,
|
||||
# the rooms opened and how far each got, with today's traffic so far.
|
||||
# Runs ON the droplet: visitors.sh [YYYY-MM-DD] (default: today, UTC)
|
||||
# Runs ON the droplet: __SLUG__-visitors.sh [YYYY-MM-DD] (default: today, UTC)
|
||||
# Every game, bot games included, is a room with a ledger, so all show in full.
|
||||
set -u
|
||||
DAY="${1:-$(date -u +%F)}"
|
||||
python3 - "$DAY" <<'PY'
|
||||
import datetime, glob, json, re, subprocess, sys
|
||||
from collections import Counter
|
||||
import datetime, glob, json, subprocess, sys
|
||||
sys.path.insert(0, "/usr/local/lib/__SLUG__")
|
||||
import traffic
|
||||
|
||||
day = sys.argv[1]
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
@@ -24,50 +25,10 @@ print("live sockets: %s | rooms touched in the last hour: %s" % (
|
||||
sh("find /var/lib/__SLUG__/rooms -name '*.jsonl' -mmin -60 2>/dev/null | wc -l")))
|
||||
|
||||
# --- traffic: Caddy's access log, people and bots apart ---
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|preview|fetch|curl|python|go-http|headless|scan|monitor|uptime", re.I)
|
||||
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
|
||||
addresses, bots = set(), set()
|
||||
pages, joins, referrers, campaigns, agents = Counter(), Counter(), Counter(), Counter(), Counter()
|
||||
requests = 0
|
||||
for path in sorted(glob.glob("/var/lib/caddy/access.log*")):
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
try:
|
||||
x = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
ts = datetime.datetime.fromtimestamp(x["ts"], datetime.timezone.utc)
|
||||
if ts.strftime("%F") != day:
|
||||
continue
|
||||
req = x["request"]
|
||||
ua = " ".join(req.get("headers", {}).get("User-Agent", [""]))
|
||||
ip = req.get("remote_ip") or req.get("client_ip", "?")
|
||||
uri = req.get("uri", "")
|
||||
path_only = uri.split("?")[0]
|
||||
if BOT.search(ua) or not ua:
|
||||
bots.add(ip)
|
||||
continue
|
||||
if path_only.startswith("/_app/") or path_only.startswith("/api/") or path_only == "/ws" or path_only.endswith((".png", ".ico", ".txt")):
|
||||
continue
|
||||
requests += 1
|
||||
addresses.add(ip)
|
||||
if path_only in PAGES:
|
||||
pages[PAGES[path_only]] += 1
|
||||
elif path_only.startswith("/join/"):
|
||||
joins[path_only.split("/")[2].upper()] += 1
|
||||
ref = " ".join(req.get("headers", {}).get("Referer", [""]))
|
||||
if ref and "__DOMAIN__" not in ref:
|
||||
referrers[re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
||||
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
||||
if m:
|
||||
campaigns[m.group(1) + "=" + m.group(2)[:24]] += 1
|
||||
agents[re.sub(r"\(.*?\)", "", ua)[:40].strip()] += 1
|
||||
except OSError:
|
||||
continue
|
||||
t = traffic.scan(day)
|
||||
print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, rules %d, guide %d, room links %s; referrers %s; campaigns %s" % (
|
||||
len(addresses), requests, len(bots), pages["hall"], pages["rules"], pages["guide"],
|
||||
dict(joins) or "none", dict(referrers) or "none", dict(campaigns) or "none"))
|
||||
len(t["addresses"]), t["human"], len(t["botAddresses"]), t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"],
|
||||
dict(t["joins"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none"))
|
||||
|
||||
# --- rooms: every ledger, today's in detail ---
|
||||
first_seen = {}
|
||||
@@ -87,7 +48,7 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
|
||||
(bots_seated if x["bot"] else humans).append(x["name"])
|
||||
names[x["id"]] = x["name"]
|
||||
elif x["t"] == "unseat":
|
||||
bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None
|
||||
if x["id"] in names: bots_seated.remove(names.pop(x["id"]))
|
||||
elif x["t"] == "start":
|
||||
started = True
|
||||
elif x["t"] == "turn":
|
||||
@@ -12,7 +12,7 @@ Environment=KEEPER_TZ=America/New_York
|
||||
# Caddy terminates TLS; the plaintext port must not face the internet.
|
||||
Environment=HOST=127.0.0.1
|
||||
Environment=DATA_DIR=/var/lib/__SLUG__/rooms
|
||||
# Create the Sentry project (the Sentry MCP can) and paste its DSN here; empty means no error reporting.
|
||||
# Empty means no error reporting.
|
||||
Environment=SENTRY_DSN=__SENTRY_DSN__
|
||||
ExecStart=/opt/__SLUG__/app/server/node_modules/.bin/tsx src/index.ts
|
||||
Restart=always
|
||||
|
||||
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
|
||||
# it needs only its code and the engine. Ledgers live outside this tree.
|
||||
rsync -az --delete --exclude='/server/node_modules' \
|
||||
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \
|
||||
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/hash-tokens.ts' \
|
||||
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/traffic.py' \
|
||||
--exclude='*' \
|
||||
./ "root@$HOST:/opt/__SLUG__/app/"
|
||||
|
||||
@@ -36,12 +36,13 @@ ssh "root@$HOST" '
|
||||
cd /opt/__SLUG__/app/server && npm install --no-audit --no-fund
|
||||
chown -R __SLUG__:__SLUG__ /opt/__SLUG__/app
|
||||
cp /opt/__SLUG__/app/deploy/__SLUG__.service /etc/systemd/system/__SLUG__.service
|
||||
# Cron runs the rollup and the backup from /usr/local/bin: install them on
|
||||
# every deploy so the live copies are always the repo copies.
|
||||
install -m 755 /opt/__SLUG__/app/deploy/visitors.sh /usr/local/bin/__SLUG__-visitors.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/pulse.sh /usr/local/bin/__SLUG__-pulse.sh
|
||||
# The ops scripts run from /usr/local/bin and share traffic.py: install them
|
||||
# on every deploy so the live copies are always the repo copies.
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-visitors.sh /usr/local/bin/__SLUG__-visitors.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh
|
||||
install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/traffic.py
|
||||
install -m 644 /opt/__SLUG__/app/deploy/__SLUG__.cron /etc/cron.d/__SLUG__
|
||||
mkdir -p /var/log/__SLUG__
|
||||
systemctl daemon-reload
|
||||
|
||||
@@ -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`);
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Mirror the player reports, the keeper's replies and the players' screenshots
|
||||
# to a local folder, and write a digest beside them for reading.
|
||||
# to a local folder, and write a digest beside them for reading. Run from the
|
||||
# repository root:
|
||||
# deploy/pull-reports.sh [host] [folder] (default ~/Desktop/__SLUG__-reports)
|
||||
set -euo pipefail
|
||||
HOST="${1:-__IP__}"
|
||||
@@ -8,34 +9,4 @@ OUT="${2:-$HOME/Desktop/__SLUG__-reports}"
|
||||
mkdir -p "$OUT/images"
|
||||
scp -q "root@$HOST:/var/lib/__SLUG__/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl"
|
||||
rsync -aq "root@$HOST:/var/lib/__SLUG__/feedback-images/" "$OUT/images/" 2>/dev/null || true
|
||||
python3 - "$OUT" <<'PY'
|
||||
import json, sys, os
|
||||
out = sys.argv[1]
|
||||
reports, order = {}, []
|
||||
for raw in open(os.path.join(out, "feedback.jsonl"), encoding="utf8"):
|
||||
raw = raw.strip()
|
||||
if not raw: continue
|
||||
line = json.loads(raw)
|
||||
if "reportId" in line:
|
||||
r = reports.get(line["reportId"])
|
||||
if not r: continue
|
||||
if "image" in line: r["image"] = line["image"]
|
||||
else: r["replies"].append(line) # the keeper's replies and the player's answers alike, in order
|
||||
continue
|
||||
reports[line["id"]] = dict(line, replies=[]); order.append(line["id"])
|
||||
lines = ["# __NAME__ reports", "", f"{len(order)} reports; newest first. Pictures are in `images/`.", ""]
|
||||
for rid in reversed(order):
|
||||
r = reports[rid]
|
||||
lines.append(f"## {r.get('at','')[:16].replace('T',' ')} — {r.get('player','?')} in {r.get('roomId','?')} (turn {r.get('turn','?')}, seq {r.get('seq','?')}) — id {rid}")
|
||||
lines.append("")
|
||||
lines.append(f"**What happened:** {r.get('happened','').strip()}")
|
||||
if r.get("expected","").strip(): lines.append(f"**What they expected:** {r['expected'].strip()}")
|
||||
if r.get("image"): lines.append(f"**Picture:** ![{r['image']}](images/{r['image']})")
|
||||
for rep in r["replies"]:
|
||||
who = f"{r.get('player','the player')} answers" if rep.get("from") == "player" else f"**{rep.get('status','')}**"
|
||||
lines.append(f"> {who} ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}")
|
||||
if not r["replies"] or r["replies"][-1].get("from") == "player": lines.append("> _awaiting the keeper_")
|
||||
lines.append("")
|
||||
open(os.path.join(out, "reports.md"), "w", encoding="utf8").write("\n".join(lines))
|
||||
print(f"{len(order)} reports, {sum(1 for r in reports.values() if r.get('image'))} with pictures -> {out}/reports.md")
|
||||
PY
|
||||
npx tsx deploy/report-digest.ts "$OUT"
|
||||
|
||||
@@ -40,7 +40,7 @@ async function main(): Promise<void> {
|
||||
for (const line of lines) {
|
||||
if (line.t === 'seat') seats.push(line);
|
||||
else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, options);
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options);
|
||||
else if (line.t === 'turn' && state) {
|
||||
state = game.resolve(state, line.inputs);
|
||||
turns += 1;
|
||||
|
||||
@@ -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:** `);
|
||||
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,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Answer a player's report; the reply appears under it in their hall.
|
||||
# deploy/report-reply.sh <host> <reportId> <resolved|by-design|open> <text...>
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# Route every __NAME__ issue to a Slack channel: new issues (any level or
|
||||
# priority), regressions, and reappearances. Creates one Sentry workflow
|
||||
# via the alerts API. The token never leaves your shell:
|
||||
# SENTRY_TOKEN=sntryu_... deploy/sentry-slack-alert.sh [#channel]
|
||||
# The channel defaults to #__SLUG__-notifications (__SLACK_CHANNEL_ID__); pass
|
||||
# SLACK_CHANNEL_ID with the channel name to route elsewhere.
|
||||
# SENTRY_TOKEN=sntryu_... SLACK_CHANNEL_ID=C... deploy/sentry-slack-alert.sh [#channel]
|
||||
# SLACK_CHANNEL_ID is the channel's Slack ID; the name defaults to
|
||||
# #__SLUG__-notifications and is shown in Sentry beside the ID.
|
||||
# The token needs the alerts:write scope (an org auth token from
|
||||
# https://locallygrownnet.sentry.io/settings/auth-tokens/), and Slack must
|
||||
# already be connected to the org under Settings → Integrations.
|
||||
@@ -20,7 +20,8 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN"
|
||||
# project's error detector is looked up by project id; a workflow is bound
|
||||
# to its detectors by PUT after creation.
|
||||
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-__SENTRY_SLACK_INTEGRATION__}"
|
||||
CHANNEL_ID="${SLACK_CHANNEL_ID:-__SLACK_CHANNEL_ID__}"
|
||||
: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
|
||||
CHANNEL_ID="$SLACK_CHANNEL_ID"
|
||||
PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}"
|
||||
echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)"
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
# bash setup-droplet.sh '__DOMAIN__, __SLUG__.<droplet-ip>.sslip.io'
|
||||
# The argument is the Caddy site address line: one name, or several
|
||||
# separated by commas. Every name must already resolve to this droplet.
|
||||
# __NAME__ is a static site: Caddy serves the built files and terminates
|
||||
# TLS. There is no application process to install or supervise.
|
||||
# Caddy serves the built files and terminates TLS; setup-server.sh adds the
|
||||
# game server behind it.
|
||||
set -euo pipefail
|
||||
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@
|
||||
# deploy/verify-ledgers.sh <droplet-ip-or-host> [https://site]
|
||||
set -euo pipefail
|
||||
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__}}"
|
||||
DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$DIR"' EXIT
|
||||
|
||||
@@ -2,18 +2,24 @@
|
||||
// websocket only to say "something changed, fetch the view again".
|
||||
//
|
||||
// POST /api/rooms {name, size?, options?} create a room and take seat A; options are the game's table choices
|
||||
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
||||
// POST /api/rooms/:id/join {name} take the next seat
|
||||
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
|
||||
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
||||
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
||||
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
||||
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
||||
// POST /api/rooms/:id/turn {token, input} this seat's move
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room;
|
||||
// {name, text} or from the gallery, signed, when the host allows it
|
||||
// POST /api/rooms/:id/gallery {token, on} the host lets the Peanut Gallery talk, or hushes it
|
||||
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
|
||||
// POST /api/rooms/:id/rematch {token} a finished table opens the next one
|
||||
// POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device
|
||||
// POST /api/transfer {phrase} claim one
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
|
||||
// POST /api/reports/:id/image <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
|
||||
// GET /api/tally what the ledgers add up to, for the hall
|
||||
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
||||
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
|
||||
// a seat in the Peanut Gallery, counted for the table
|
||||
//
|
||||
@@ -48,10 +54,11 @@ process.on('unhandledRejection', (reason) => {
|
||||
Sentry.captureException(reason);
|
||||
});
|
||||
|
||||
const rooms = new Rooms(game, new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ });
|
||||
const store = new Store(DATA_DIR);
|
||||
const rooms = new Rooms(game, store, { name: KEEPER, zone: KEEPER_TZ });
|
||||
/** Reports live beside the room ledgers, not among them. */
|
||||
const reports = new Reports(dirname(DATA_DIR));
|
||||
const tallies = new Tallies(game, new Store(DATA_DIR));
|
||||
const tallies = new Tallies(game, store);
|
||||
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
|
||||
const doors = new RateLimit(40, 60 * 60 * 1000);
|
||||
/** Table talk: a lively table says a few lines a minute, not hundreds. */
|
||||
@@ -305,7 +312,6 @@ server.on('upgrade', (req, socket, head) => {
|
||||
};
|
||||
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
|
||||
ws.on('close', unsubscribe);
|
||||
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,39 +7,15 @@ import { randomBytes } from 'node:crypto';
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { SeatId } from '../../src/lib/game/spec';
|
||||
import type { Report } from '../../src/lib/net/view';
|
||||
import { RoomError, type Room, type Seat } from './rooms';
|
||||
|
||||
export const TEXT_MAX = 2000;
|
||||
const TEXT_MAX = 2000;
|
||||
export const IMAGE_MAX_BYTES = 2_500_000;
|
||||
/** How long after filing a picture may still be attached. */
|
||||
const IMAGE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export interface ReportLine {
|
||||
at: string;
|
||||
text: string;
|
||||
from: 'desk' | 'player';
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
at: string;
|
||||
roomId: string;
|
||||
/** The reporter's name, or "(gallery)" for a watcher. */
|
||||
player: string;
|
||||
seat: SeatId | null;
|
||||
/** The round being written when the report was filed, and the ledger's length. */
|
||||
turn: number | null;
|
||||
seq: number;
|
||||
happened: string;
|
||||
expected: string;
|
||||
/** The whole exchange in order: the keeper's replies and the player's answers. */
|
||||
thread: ReportLine[];
|
||||
/** A screenshot's file name under images/, when one was sent. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export function cleanText(raw: unknown): string {
|
||||
function cleanText(raw: unknown): string {
|
||||
return String(raw ?? '')
|
||||
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ')
|
||||
.trim()
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface Seat {
|
||||
bot: boolean;
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
@@ -88,11 +88,11 @@ const TRANSFER_WORDS = [
|
||||
const TRANSFER_TTL_MS = 10 * 60 * 1000;
|
||||
const TRANSFER_FAIL_LIMIT = 20;
|
||||
|
||||
export function normalizeCode(raw: string): string {
|
||||
function normalizeCode(raw: string): string {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function cleanName(raw: unknown): string {
|
||||
function cleanName(raw: unknown): string {
|
||||
const name = String(raw ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
@@ -106,6 +106,10 @@ export class Rooms<State, Input> {
|
||||
private listeners = new Map<string, Set<(room: Room<State>) => void>>();
|
||||
/** Sockets watching from the gallery, by room. */
|
||||
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(
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } {
|
||||
const seat = this.seatOf(room, token);
|
||||
@@ -474,7 +473,7 @@ export class Rooms<State, Input> {
|
||||
room.over = true;
|
||||
break;
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
|
||||
break;
|
||||
case 'galleryTalk':
|
||||
room.galleryTalk = line.on;
|
||||
@@ -493,7 +492,7 @@ export class Rooms<State, Input> {
|
||||
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||
break;
|
||||
case 'start':
|
||||
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, room.options);
|
||||
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options);
|
||||
break;
|
||||
case 'turn':
|
||||
if (!room.state) return;
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { SeatId } from '../../src/lib/game/spec';
|
||||
import type { SeatId, TableOptions } from '../../src/lib/game/spec';
|
||||
|
||||
export type LedgerLine =
|
||||
/** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */
|
||||
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options?: Record<string, string> }
|
||||
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
|
||||
| { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
||||
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions }
|
||||
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */
|
||||
| { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean }
|
||||
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
||||
| { t: 'input'; at: number; id: SeatId; input: unknown }
|
||||
/** A bot sent away by the host before the game began; its seat id is free again. */
|
||||
| { t: 'unseat'; id: SeatId }
|
||||
/** Ledgers from before revisions were recorded resolve under rules 1. */
|
||||
| { t: 'start'; seed: number; rules?: number }
|
||||
/** The seed and the rules revision the game began under; a later fix keeps this revision's path for it. */
|
||||
| { t: 'start'; seed: number; rules: number }
|
||||
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
||||
/** 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 }
|
||||
|
||||
@@ -4,25 +4,9 @@
|
||||
// the kit's; a game may add its own lines through GameSpec.tally.
|
||||
|
||||
import type { GameSpec, SeatId } from '../../src/lib/game/spec';
|
||||
import type { Tally } from '../../src/lib/net/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
export interface Tally {
|
||||
tablesOpened: number;
|
||||
gamesBegun: number;
|
||||
gamesFinished: number;
|
||||
/** Distinct names of people who have taken a seat; a bot's name is not counted. */
|
||||
namesSeated: number;
|
||||
turnsPlayed: number;
|
||||
/** Minutes at the table: gaps under ten minutes between one move and the next, summed. */
|
||||
minutesAtTable: number;
|
||||
longestGameTurns: number;
|
||||
gamesWithBot: number;
|
||||
talkLines: number;
|
||||
firstGameAt: string | null;
|
||||
/** The game's own lines, summed over finished games. */
|
||||
byGame: Record<string, number>;
|
||||
}
|
||||
|
||||
const GAP_LIMIT_MS = 10 * 60 * 1000;
|
||||
const CACHE_MS = 60 * 1000;
|
||||
|
||||
@@ -66,7 +50,7 @@ export class Tallies<State, Input> {
|
||||
if (hasBot) t.gamesWithBot += 1;
|
||||
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
||||
try {
|
||||
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, lines[0].options);
|
||||
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, lines[0].options);
|
||||
} catch {
|
||||
state = null;
|
||||
}
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
"src/**/*.ts",
|
||||
"../src/lib/game/**/*.ts",
|
||||
"../src/lib/net/view.ts",
|
||||
"../deploy/replay-ledgers.ts"
|
||||
"../deploy/replay-ledgers.ts",
|
||||
"../deploy/report-digest.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../src/lib/game/*.test.ts",
|
||||
"../src/lib/game/*.svelte.ts",
|
||||
"../src/lib/game/test-helpers.ts"
|
||||
"../src/lib/game/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+56
-1
@@ -3,7 +3,6 @@
|
||||
:root {
|
||||
--slate: #1c2830;
|
||||
--slate-deep: #121a20;
|
||||
--slate-raised: #24333d;
|
||||
--bone: #e9e2d2;
|
||||
--bone-dim: #98a4ab;
|
||||
--bone-faint: #5d6b73;
|
||||
@@ -133,6 +132,10 @@ button:disabled {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
}
|
||||
@@ -150,6 +153,58 @@ button:disabled {
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The × that sends a bot away or forgets a game. */
|
||||
.dismiss {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-faint);
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dismiss:hover {
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
/* A page of reading beside the game: the rules, the guide. */
|
||||
.reading {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--gutter) 4rem;
|
||||
}
|
||||
|
||||
.reading a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.reading .back {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.reading h1 {
|
||||
font-size: 2.4rem;
|
||||
font-weight: 300;
|
||||
margin: 0.5rem 0 0.3rem;
|
||||
}
|
||||
|
||||
.reading .lede {
|
||||
color: var(--bone-dim);
|
||||
max-width: 40em;
|
||||
}
|
||||
|
||||
.reading section {
|
||||
padding: 1.25rem 0 0.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.reading h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -6,17 +6,16 @@
|
||||
import { otherGames, type FamilyGame } from '$lib/family';
|
||||
import Preferences from './Preferences.svelte';
|
||||
import { prefs, setPref } from '$lib/prefs.svelte';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
|
||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
import { unreadTalk } from '$lib/net/talk';
|
||||
import type { RoomView } from '$lib/net/view';
|
||||
import type { Report, RoomView, Tally } from '$lib/net/view';
|
||||
import type { State } from '$lib/game';
|
||||
import { game } from '$lib/game';
|
||||
|
||||
let name = $state(playerName());
|
||||
let code = $state('');
|
||||
/** The host's choices for the next table, from the game's declared options. */
|
||||
// A new table starts from the preferences' table defaults; the hall's selectors set the same keys.
|
||||
/** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */
|
||||
const options = $derived<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]))
|
||||
);
|
||||
@@ -82,7 +81,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The desk is asked once per visit for every seat this browser holds.
|
||||
// The desk is asked for every seat this browser holds, again whenever that set changes.
|
||||
$effect(() => {
|
||||
const held = seats;
|
||||
if (!held.length) return;
|
||||
@@ -163,7 +162,7 @@
|
||||
<h1>__NAME__</h1>
|
||||
<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="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} />
|
||||
</div>
|
||||
|
||||
@@ -176,7 +175,7 @@
|
||||
<div class="options">
|
||||
{#each game.options as o (o.key)}
|
||||
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
||||
<div class="option-block">
|
||||
<div>
|
||||
<label class="option">
|
||||
<span>{o.label}</span>
|
||||
<select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}>
|
||||
@@ -198,7 +197,7 @@
|
||||
</form>
|
||||
</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>
|
||||
<form class="joinform claimform" onsubmit={claim}>
|
||||
<form class="joinform" onsubmit={claim}>
|
||||
<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" />
|
||||
<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-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>
|
||||
<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>
|
||||
{/each}
|
||||
{#if reports.length}
|
||||
@@ -382,10 +381,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ways {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -489,18 +484,6 @@
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.ledger-forget {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-faint);
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.ledger-forget:hover {
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.reports-head {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
@@ -591,6 +574,7 @@
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
|
||||
.family {
|
||||
margin: 0.4rem 0 0.8rem 1.2rem;
|
||||
padding: 0;
|
||||
@@ -599,6 +583,7 @@
|
||||
.family li + li {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -617,6 +602,7 @@
|
||||
margin: 0.2rem 0 0;
|
||||
max-width: 22em;
|
||||
}
|
||||
|
||||
.tally {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
@@ -632,13 +618,4 @@
|
||||
.tally dd {
|
||||
margin: 0;
|
||||
}
|
||||
.link-like {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
|
||||
let { room, link }: { room: Room; link: string } = $props();
|
||||
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
|
||||
|
||||
const v = $derived(room.view);
|
||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||
const seatFree = $derived(v.seats.length < v.size);
|
||||
/** A seat is free for a newcomer once the held ones are counted. */
|
||||
const seatOpen = $derived(v.seats.length + v.expected.length < v.size);
|
||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||
let copied = $state(false);
|
||||
|
||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
||||
let minute = $state(0);
|
||||
@@ -31,22 +27,12 @@
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1600);
|
||||
} catch {
|
||||
// The link is on screen to copy by hand.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="lobby">
|
||||
{#if room.spectating}
|
||||
<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>
|
||||
{:else}
|
||||
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
|
||||
@@ -62,7 +48,7 @@
|
||||
<li>
|
||||
<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}
|
||||
<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>
|
||||
{/each}
|
||||
{#each v.expected as name (name)}
|
||||
@@ -78,7 +64,7 @@
|
||||
{/if}
|
||||
{#if !room.spectating}
|
||||
<div class="ways">
|
||||
{#if seatFree && room.isHost}
|
||||
{#if room.seatEmpty && room.isHost}
|
||||
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
|
||||
{/if}
|
||||
{#if room.isHost}
|
||||
@@ -94,13 +80,13 @@
|
||||
Let the Peanut Gallery talk at the table
|
||||
</label>
|
||||
{/if}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && seatOpen}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
|
||||
<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>
|
||||
<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} 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} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
|
||||
</div>
|
||||
{:else if v.challenge}
|
||||
<p class="muted small">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
|
||||
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
@@ -148,20 +134,6 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unseat {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-faint);
|
||||
font-size: 1.1rem;
|
||||
padding: 0 0.3rem;
|
||||
margin-left: 0.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.unseat:hover {
|
||||
color: var(--blood);
|
||||
}
|
||||
|
||||
.ways {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -203,8 +175,7 @@
|
||||
max-width: 38em;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
.note {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
// 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';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
@@ -13,7 +14,7 @@
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#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">
|
||||
<h2 id="prefs-title">Preferences</h2>
|
||||
<p class="muted small">Kept in this browser, and nowhere else.</p>
|
||||
@@ -37,28 +38,27 @@
|
||||
{/each}
|
||||
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
z-index: 21;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(92vw, 26rem);
|
||||
max-height: 90vh;
|
||||
width: min(100%, 26rem);
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 1.2rem 1.4rem 1.4rem;
|
||||
}
|
||||
|
||||
@@ -87,10 +87,6 @@
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.quiet {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -38,12 +38,11 @@
|
||||
|
||||
async function send(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const r = room;
|
||||
if (!happened.trim() || busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
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.'));
|
||||
sent = true;
|
||||
setTimeout(close, 2200);
|
||||
@@ -75,7 +74,7 @@
|
||||
<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}
|
||||
</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}
|
||||
<div class="actions">
|
||||
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
|
||||
@@ -119,7 +118,6 @@
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
|
||||
.picture input {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@@ -132,13 +130,11 @@
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
.note {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--blood);
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
@@ -152,14 +148,4 @@
|
||||
gap: 1rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--bone-dim);
|
||||
text-decoration: underline dotted;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
|
||||
</form>
|
||||
{: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}
|
||||
<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" />
|
||||
@@ -125,8 +125,7 @@
|
||||
flex: 0 1 8rem;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
.note {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,13 +34,24 @@ export interface TableOption {
|
||||
/** The host's choices for a table, by option key; only keys the game declares get through. */
|
||||
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> {
|
||||
/** Seats in table order; the table takes at most this many. */
|
||||
seatIds: SeatId[];
|
||||
/** What the host may choose when opening a table; empty for a game with one way to play. */
|
||||
options?: TableOption[];
|
||||
/** The game's own settings for the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */
|
||||
preferences?: { key: string; label: string; kind: 'toggle' | 'choice'; choices?: { value: string; label: string }[]; default: boolean | string; note?: string }[];
|
||||
/** The game's own settings for the preferences panel. */
|
||||
preferences?: Preference[];
|
||||
minSeats: number;
|
||||
/** Names the server draws for bots, in preference order. */
|
||||
botNames: string[];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// browser holds, remembered so a shared link never has to carry a token.
|
||||
|
||||
import type { SeatId, TableOptions } from '$lib/game/spec';
|
||||
import type { RoomView } from './view';
|
||||
import type { Report, RoomView, Tally } from './view';
|
||||
|
||||
export interface HeldSeat {
|
||||
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
|
||||
* 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';
|
||||
|
||||
@@ -103,44 +103,20 @@ export class ServerError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
/** The server's answer as JSON, or its refusal as a ServerError with the reason it gave. */
|
||||
async function fetchJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, init);
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** A report to the keeper, with the exchange under it. */
|
||||
export interface Report {
|
||||
id: string;
|
||||
at: string;
|
||||
roomId: string;
|
||||
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>;
|
||||
function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
return fetchJson<T>(path, {
|
||||
method,
|
||||
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
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 }),
|
||||
report: (roomId: string, token: string, happened: string, expected: string) =>
|
||||
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
|
||||
reportImage: async (id: string, file: File) => {
|
||||
const res = await fetch(`/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);
|
||||
},
|
||||
reportImage: (id: string, file: File) =>
|
||||
fetchJson<void>(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file }),
|
||||
tally: () => call<Tally>('GET', '/api/tally'),
|
||||
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 })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// through GameSpec; a game's own store wraps or extends this one.
|
||||
// through GameSpec.
|
||||
|
||||
import { game } from '$lib/game';
|
||||
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". */
|
||||
export function listText(names: string[]): string {
|
||||
function listText(names: string[]): string {
|
||||
if (names.length <= 1) return names[0] ?? '';
|
||||
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
||||
}
|
||||
@@ -57,13 +57,16 @@ export class Room {
|
||||
started = $derived(this.view.state !== null);
|
||||
state = $derived(this.view.state);
|
||||
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. */
|
||||
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. */
|
||||
awaiting = $derived(this.view.waitingOn.filter((id) => id !== this.me).map((id) => this.nameOf(id)));
|
||||
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 {
|
||||
return this.view.seats.find((s) => s.id === id)?.name ?? (this.view.state ? game.nameOf(this.view.state, id) : id);
|
||||
|
||||
@@ -46,3 +46,48 @@ export interface RoomView<State> {
|
||||
keeper: 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>;
|
||||
}
|
||||
|
||||
@@ -4,19 +4,10 @@
|
||||
// panel renders every declaration. Nothing here leaves the browser.
|
||||
|
||||
import { game } from '$lib/game';
|
||||
|
||||
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;
|
||||
}
|
||||
import type { Preference } from '$lib/game/spec';
|
||||
|
||||
/** 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: '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. */
|
||||
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 {
|
||||
prefs[key] = value;
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
/** 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 withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path));
|
||||
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>__NAME__: how to play</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="guide">
|
||||
<main class="reading">
|
||||
<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>
|
||||
<h1>How to play</h1>
|
||||
@@ -57,31 +57,6 @@
|
||||
</main>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -90,17 +65,6 @@
|
||||
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 {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,8 @@
|
||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||
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);
|
||||
/** 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.
|
||||
$effect(() => {
|
||||
@@ -157,12 +155,12 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if joining && seatFree}
|
||||
{#if joining && room?.seatEmpty}
|
||||
<section class="seat">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<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>
|
||||
<form class="rename" onsubmit={takeSeat}>
|
||||
<form class="sit" onsubmit={takeSeat}>
|
||||
<label class="field">
|
||||
<span>Your name</span>
|
||||
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
|
||||
@@ -174,7 +172,7 @@
|
||||
{/if}
|
||||
|
||||
{#if room && !room.started}
|
||||
<Lobby {room} {link} />
|
||||
<Lobby {room} {link} {copied} {copyLink} />
|
||||
{:else if room}
|
||||
<Board {room} />
|
||||
{:else if !error}
|
||||
@@ -259,7 +257,7 @@
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.rename {
|
||||
.sit {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -278,7 +276,7 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rename input {
|
||||
.sit input {
|
||||
width: 14em;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
// where you can, so the page and the engine follow one source.
|
||||
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 withRoom = $derived((path: string) => (room ? `${path}?room=${room}` : path));
|
||||
const withRoom = (path: string) => (room ? `${path}?room=${room}` : path);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>__NAME__: the rules</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="rules">
|
||||
<main class="reading">
|
||||
<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>
|
||||
<h1>The rules</h1>
|
||||
@@ -39,42 +39,6 @@
|
||||
</main>
|
||||
|
||||
<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 {
|
||||
margin-top: 2rem;
|
||||
color: var(--bone-faint);
|
||||
|
||||
Reference in New Issue
Block a user