Compare commits
8
Commits
2b76b9bd9b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6970babc7e | ||
|
|
82107f73eb | ||
|
|
21de94869d | ||
|
|
f003301541 | ||
|
|
a2d3841cfe | ||
|
|
a2bdf7a825 | ||
|
|
8bbc41cc87 | ||
|
|
4b6cf01949 |
No files matched your search
@@ -53,6 +53,11 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
|
|||||||
deploy, so the live copies are the repo copies:
|
deploy, so the live copies are the repo copies:
|
||||||
|
|
||||||
- `hnefatafl-visitors.sh [day]`: who is here now and who came that day.
|
- `hnefatafl-visitors.sh [day]`: who is here now and who came that day.
|
||||||
|
- `hnefatafl-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
|
||||||
|
token for a player locked out of it, as a link to send them.
|
||||||
|
- `hnefatafl-say.sh ROOM "text"`: a word from the keeper in a table's talk,
|
||||||
|
signed as the keeper, through the same door; it is heard whether or not
|
||||||
|
the gallery may talk, so a call that came too late can be answered.
|
||||||
- `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup
|
- `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup
|
||||||
trend, backup, box).
|
trend, backup, box).
|
||||||
- `hnefatafl-rollup.sh [day]`: one JSON line per day of counts, run
|
- `hnefatafl-rollup.sh [day]`: one JSON line per day of counts, run
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ ssh "root@$HOST" '
|
|||||||
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-pulse.sh /usr/local/bin/hnefatafl-pulse.sh
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-pulse.sh /usr/local/bin/hnefatafl-pulse.sh
|
||||||
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh
|
||||||
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh
|
||||||
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-reseat.sh /usr/local/bin/hnefatafl-reseat.sh
|
||||||
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-say.sh /usr/local/bin/hnefatafl-say.sh
|
||||||
|
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
|
||||||
|
if [ ! -s /etc/hnefatafl/keeper.key ]; then
|
||||||
|
install -d -m 750 -o root -g hnefatafl /etc/hnefatafl
|
||||||
|
(umask 037; openssl rand -hex 32 > /etc/hnefatafl/keeper.key)
|
||||||
|
chown root:hnefatafl /etc/hnefatafl/keeper.key
|
||||||
|
fi
|
||||||
install -m 644 -D /opt/hnefatafl/app/deploy/traffic.py /usr/local/lib/hnefatafl/traffic.py
|
install -m 644 -D /opt/hnefatafl/app/deploy/traffic.py /usr/local/lib/hnefatafl/traffic.py
|
||||||
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
|
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
|
||||||
# of every game and checks in with Sentry once for all; a game on a box of
|
# of every game and checks in with Sentry once for all; a game on a box of
|
||||||
|
|||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A way back for a player whose browser lost their seat: the seat gets a new
|
||||||
|
# token, the old one stops working, and the link printed carries the new one.
|
||||||
|
# Whether the one asking holds that seat is the keeper's call; send the link
|
||||||
|
# only to them. Runs ON the droplet, through the game server's keeper door.
|
||||||
|
# hnefatafl-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
|
||||||
|
# hnefatafl-reseat.sh ROOM SEAT re-seat SEAT and print the link
|
||||||
|
set -euo pipefail
|
||||||
|
ROOM="${1:?usage: hnefatafl-reseat.sh ROOM [SEAT]}"
|
||||||
|
DOOR="http://127.0.0.1:8789/keeper/rooms/${ROOM^^}"
|
||||||
|
[ -s /etc/hnefatafl/keeper.key ] || { echo "No keeper's key at /etc/hnefatafl/keeper.key; a deploy makes it." >&2; exit 1; }
|
||||||
|
# The key goes as a header read from a file descriptor, never on a command line another user could see.
|
||||||
|
KEY="@/dev/fd/3"
|
||||||
|
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/hnefatafl/keeper.key)")
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
curl -s -H "$KEY" "$DOOR" | python3 -c '
|
||||||
|
import datetime, json, sys
|
||||||
|
r = json.load(sys.stdin)
|
||||||
|
if "error" in r: sys.exit(r["error"])
|
||||||
|
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
|
||||||
|
for s in r["seats"]:
|
||||||
|
when = "bot" if s["bot"] else ("a housecarl stands in; " if s.get("standIn") else "") + ("never moved or spoke" if s["lastActed"] is None else "last acted " + datetime.datetime.fromtimestamp(s["lastActed"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
|
||||||
|
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
|
||||||
|
'
|
||||||
|
else
|
||||||
|
curl -s -X POST -H "$KEY" -H 'content-type: application/json' -d "{\"seat\":\"$2\"}" "$DOOR/reseat" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
r = json.load(sys.stdin)
|
||||||
|
if "error" in r: sys.exit(r["error"])
|
||||||
|
print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"])
|
||||||
|
print(r["link"])
|
||||||
|
'
|
||||||
|
fi
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A word from the keeper in a table's talk, signed as the keeper, whether or
|
||||||
|
# not the host lets the gallery talk: the answer to a call that came too late,
|
||||||
|
# or anything else the table should hear from the keeper. Runs ON the droplet,
|
||||||
|
# through the game server's keeper door.
|
||||||
|
# hnefatafl-say.sh ROOM "text"
|
||||||
|
set -euo pipefail
|
||||||
|
ROOM="${1:?usage: hnefatafl-say.sh ROOM \"text\"}"
|
||||||
|
TEXT="${2:?usage: hnefatafl-say.sh ROOM \"text\"}"
|
||||||
|
DOOR="http://127.0.0.1:8789/keeper/rooms/${ROOM^^}"
|
||||||
|
[ -s /etc/hnefatafl/keeper.key ] || { echo "No keeper's key at /etc/hnefatafl/keeper.key; a deploy makes it." >&2; exit 1; }
|
||||||
|
# The key goes as a header read from a file descriptor, never on a command line another user could see.
|
||||||
|
KEY="@/dev/fd/3"
|
||||||
|
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/hnefatafl/keeper.key)")
|
||||||
|
BODY=$(TEXT="$TEXT" python3 -c 'import json, os; print(json.dumps({"text": os.environ["TEXT"]}))')
|
||||||
|
CODE=$(curl -s -o /tmp/hnefatafl-say.$$ -w '%{http_code}' -X POST -H "$KEY" -H 'content-type: application/json' -d "$BODY" "$DOOR/say")
|
||||||
|
if [ "$CODE" = 204 ]; then echo "said at ${ROOM^^}"; else cat /tmp/hnefatafl-say.$$ >&2; echo >&2; rm -f /tmp/hnefatafl-say.$$; exit 1; fi
|
||||||
|
rm -f /tmp/hnefatafl-say.$$
|
||||||
@@ -42,7 +42,7 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"):
|
|||||||
continue
|
continue
|
||||||
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
|
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
|
||||||
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
|
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
|
||||||
names = {}
|
names, seat_changes = {}, []
|
||||||
for x in lines[1:]:
|
for x in lines[1:]:
|
||||||
if x["t"] == "seat":
|
if x["t"] == "seat":
|
||||||
(bots_seated if x["bot"] else humans).append(x["name"])
|
(bots_seated if x["bot"] else humans).append(x["name"])
|
||||||
@@ -56,12 +56,16 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"):
|
|||||||
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
|
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
|
||||||
elif x["t"] == "chat":
|
elif x["t"] == "chat":
|
||||||
talk += 1
|
talk += 1
|
||||||
|
elif x["t"] == "standIn":
|
||||||
|
seat_changes.append("%s %s's seat to a housecarl" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
|
||||||
|
elif x["t"] == "reseat":
|
||||||
|
seat_changes.append("%s %s re-seated" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
|
||||||
elif x["t"] == "over":
|
elif x["t"] == "over":
|
||||||
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
|
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
|
||||||
for h in humans:
|
for h in humans:
|
||||||
if h not in first_seen or created < first_seen[h]:
|
if h not in first_seen or created < first_seen[h]:
|
||||||
first_seen[h] = created
|
first_seen[h] = created
|
||||||
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk))
|
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk, seat_changes=seat_changes))
|
||||||
|
|
||||||
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
|
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
|
||||||
names = sorted({h for r in today for h in r["humans"]})
|
names = sorted({h for r in today for h in r["humans"]})
|
||||||
@@ -78,4 +82,6 @@ for r in today:
|
|||||||
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
|
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
|
||||||
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
|
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
|
||||||
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
|
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
|
||||||
|
for change in r["seat_changes"]:
|
||||||
|
print(" %s" % change)
|
||||||
PY
|
PY
|
||||||
+7
-3
@@ -12,11 +12,13 @@ from collections import Counter, defaultdict
|
|||||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||||
# Link previews: the app that fetched the page to show it in a conversation. The people
|
# Link previews: the app that fetched the page to show it in a conversation. The people
|
||||||
# who click such a link arrive with no referrer, so this is the best sign of where it went.
|
# who click such a link arrive with no referrer, so this is the best sign of where it went.
|
||||||
|
# Meta's crawlers (meta-externalagent, meta-webindexer) name facebook.com too but share nothing.
|
||||||
UNFURL = [
|
UNFURL = [
|
||||||
("Slack", re.compile(r"slack", re.I)),
|
("Slack", re.compile(r"slack", re.I)),
|
||||||
("Discord", re.compile(r"discord", re.I)),
|
("Discord", re.compile(r"discord", re.I)),
|
||||||
|
("Google Messages", re.compile(r"googlemessages", re.I)),
|
||||||
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
|
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
|
||||||
("Facebook", re.compile(r"facebookexternalhit|facebot|meta-externalagent", re.I)),
|
("Facebook", re.compile(r"facebookexternalhit|facebot", re.I)),
|
||||||
("WhatsApp", re.compile(r"whatsapp", re.I)),
|
("WhatsApp", re.compile(r"whatsapp", re.I)),
|
||||||
("Telegram", re.compile(r"telegram", re.I)),
|
("Telegram", re.compile(r"telegram", re.I)),
|
||||||
("Reddit", re.compile(r"redditbot", re.I)),
|
("Reddit", re.compile(r"redditbot", re.I)),
|
||||||
@@ -144,8 +146,10 @@ def scan(day, logs=LOGS):
|
|||||||
t["bot"] += 1
|
t["bot"] += 1
|
||||||
t["botAddresses"].add(ip)
|
t["botAddresses"].add(ip)
|
||||||
service = unfurled_by(ua)
|
service = unfurled_by(ua)
|
||||||
# One share per app and page: a preview is often fetched twice.
|
# One share per app and page: a preview is often fetched twice. Only a
|
||||||
if service and (service, path) not in seen_share:
|
# page counts: an app that already holds a preview fetches its card
|
||||||
|
# image again to show it, and robots.txt and scripts are not shared.
|
||||||
|
if service and (path in PAGES or path.startswith("/join/")) and (service, path) not in seen_share:
|
||||||
seen_share.add((service, path))
|
seen_share.add((service, path))
|
||||||
t["shared"][service] += 1
|
t["shared"][service] += 1
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ README.
|
|||||||
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four
|
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four
|
||||||
words good for ten minutes and one use; the hall's *bring a seat from
|
words good for ten minutes and one use; the hall's *bring a seat from
|
||||||
another device* claims them. The browser that claims holds the seat too.
|
another device* claims them. The browser that claims holds the seat too.
|
||||||
|
- **The keeper can bring a seat back**: a player whose browser lost the
|
||||||
|
seat (Safari's seven days, an app's own browser, cleared data) writes to
|
||||||
|
the keeper, and `<slug>-reseat.sh ROOM SEAT` on the host gives the seat
|
||||||
|
a new token and prints a link carrying it in the fragment. The old token
|
||||||
|
opens nothing after. A link is kept only once the server takes it, and
|
||||||
|
never over a seat the browser holds that still opens. The script answers
|
||||||
|
through the game server's `/keeper` door, which Caddy does not proxy and
|
||||||
|
which asks for the key in `/etc/<slug>/keeper.key`, made by the deploy and
|
||||||
|
readable by root and the game's user alone. A spectator at a game in play
|
||||||
|
is told both ways back, and the hall asks before forgetting a game in play.
|
||||||
|
- **The host has a hand on the other seats**: once the game begins, *Seats*
|
||||||
|
in the host's masthead makes a transfer phrase for another person's seat
|
||||||
|
(claimed, it gives the seat a new token, as the keeper's link does), and
|
||||||
|
gives the seat of a player who cannot go on to a housecarl, who plays it
|
||||||
|
from the move the seat owes and does not speak in the person's name.
|
||||||
|
The person's token opens nothing while one stands in, with the reason;
|
||||||
|
their page goes to the gallery, where a claim box takes the host's
|
||||||
|
phrase. A rematch holds the person's seat and leaves the stand-in out.
|
||||||
|
- **A dead browser's socket is closed**: the server pings every socket on
|
||||||
|
the protocol's own ping, which browsers answer by themselves, and closes
|
||||||
|
one silent for a whole round, so a crashed tab does not linger in the
|
||||||
|
gallery's count.
|
||||||
- **Held seats are doubted before they are dropped**: the hall forgets a
|
- **Held seats are doubted before they are dropped**: the hall forgets a
|
||||||
seat only on the second refusal in a row from the server. A restart, a
|
seat only on the second refusal in a row from the server. A restart, a
|
||||||
stale answer or the wrong backend answer with ignorance, and ignorance
|
stale answer or the wrong backend answer with ignorance, and ignorance
|
||||||
@@ -123,6 +145,10 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
|
|||||||
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
|
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
|
||||||
today, every room opened and how far it got. Names seated for the first
|
today, every room opened and how far it got. Names seated for the first
|
||||||
time are marked NEW.
|
time are marked NEW.
|
||||||
|
- `<slug>-reseat.sh ROOM [SEAT]`: who sits at a table and when each last
|
||||||
|
acted; with a seat, a new token for a player locked out, as a link.
|
||||||
|
- `<slug>-say.sh ROOM "text"`: a word from the keeper in a table's talk,
|
||||||
|
signed as the keeper, whether or not the gallery may talk.
|
||||||
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
|
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
|
||||||
the rollup's last week, the backup's last word, the box's vitals.
|
the rollup's last week, the backup's last word, the box's vitals.
|
||||||
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
|
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
|
||||||
|
|||||||
+76
-1
@@ -14,6 +14,8 @@
|
|||||||
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
|
// 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/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/rooms/:id/transfer {token} mint a phrase that carries this seat to another device
|
||||||
|
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another person's seat, which re-seats it when claimed
|
||||||
|
// POST /api/rooms/:id/standin {token, seat} the host gives a person's seat to a bot for the rest of the game
|
||||||
// POST /api/transfer {phrase} claim one
|
// 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
|
||||||
@@ -23,10 +25,18 @@
|
|||||||
// 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
|
||||||
//
|
//
|
||||||
// Caddy serves the static site and proxies /api and /ws here.
|
// Caddy serves the static site and proxies /api and /ws here. The keeper's
|
||||||
|
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
|
||||||
|
// (deploy/hnefatafl-reseat.sh sends it):
|
||||||
|
//
|
||||||
|
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
|
||||||
|
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
|
||||||
|
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
|
||||||
|
|
||||||
import * as Sentry from '@sentry/node';
|
import * as Sentry from '@sentry/node';
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { game } from '../../src/lib/game';
|
import { game } from '../../src/lib/game';
|
||||||
@@ -43,6 +53,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://tafl.kestrelsnest.social'
|
|||||||
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
|
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
|
||||||
const KEEPER = process.env.KEEPER ?? 'the keeper';
|
const KEEPER = process.env.KEEPER ?? 'the keeper';
|
||||||
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
||||||
|
/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */
|
||||||
|
const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/hnefatafl/keeper.key';
|
||||||
const BODY_LIMIT = 16 * 1024;
|
const BODY_LIMIT = 16 * 1024;
|
||||||
|
|
||||||
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
||||||
@@ -72,6 +84,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000);
|
|||||||
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
|
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
|
||||||
const claims = new RateLimit(10, 10 * 60 * 1000);
|
const claims = new RateLimit(10, 10 * 60 * 1000);
|
||||||
const MAX_AUDIENCE = 30;
|
const MAX_AUDIENCE = 30;
|
||||||
|
/** How often each socket is asked to answer; one silent through a whole round is a browser gone, and is closed. */
|
||||||
|
const PING_MS = Number(process.env.PING_MS ?? 30_000);
|
||||||
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
||||||
@@ -184,9 +198,44 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s
|
|||||||
throw new RoomError('Not here.', 404);
|
throw new RoomError('Not here.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The key the keeper's door asks for, read on each knock so a key written after the server started is honoured. */
|
||||||
|
function keeperKeyGiven(req: IncomingMessage): boolean {
|
||||||
|
let key: Buffer;
|
||||||
|
try {
|
||||||
|
key = Buffer.from(readFileSync(KEEPER_KEY_FILE, 'utf8').trim());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const given = Buffer.from(String(req.headers['x-keeper-key'] ?? ''));
|
||||||
|
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy
|
||||||
|
* forwarded is turned away besides, and so is one without the keeper's key.
|
||||||
|
*/
|
||||||
|
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
|
||||||
|
if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
|
||||||
|
const room = rooms.get(parts[2] ?? '');
|
||||||
|
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
|
||||||
|
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const { seat, token } = rooms.reseat(room, body.seat);
|
||||||
|
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
|
||||||
|
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
|
||||||
|
}
|
||||||
|
if (parts[3] === 'say' && parts.length === 4 && req.method === 'POST') {
|
||||||
|
const body = await readBody(req);
|
||||||
|
rooms.keeperSays(room, body.text);
|
||||||
|
return send(res, 204, {});
|
||||||
|
}
|
||||||
|
throw new RoomError('Not here.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||||
const parts = url.pathname.split('/').filter(Boolean);
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
|
if (parts[0] === 'keeper') return handleKeeper(req, res, parts);
|
||||||
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
||||||
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
|
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
|
||||||
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
||||||
@@ -268,6 +317,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|||||||
if (action === 'transfer') {
|
if (action === 'transfer') {
|
||||||
return send(res, 200, rooms.transfer(room, token));
|
return send(res, 200, rooms.transfer(room, token));
|
||||||
}
|
}
|
||||||
|
if (action === 'phrase') {
|
||||||
|
return send(res, 200, rooms.hostPhrase(room, token, body.seat));
|
||||||
|
}
|
||||||
|
if (action === 'standin') {
|
||||||
|
rooms.standIn(room, token, body.seat);
|
||||||
|
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||||
|
}
|
||||||
if (action === 'report') {
|
if (action === 'report') {
|
||||||
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
||||||
const seat = token ? rooms.seatOf(room, token) : null;
|
const seat = token ? rooms.seatOf(room, token) : null;
|
||||||
@@ -313,11 +369,30 @@ 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);
|
||||||
|
answered.set(ws, true);
|
||||||
|
ws.on('pong', () => answered.set(ws, true));
|
||||||
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
|
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
|
||||||
ws.on('error', () => ws.terminate());
|
ws.on('error', () => ws.terminate());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
|
||||||
|
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
|
||||||
|
* answers by itself, finds it within two rounds.
|
||||||
|
*/
|
||||||
|
const answered = new WeakMap<WebSocket, boolean>();
|
||||||
|
setInterval(() => {
|
||||||
|
for (const ws of wss.clients) {
|
||||||
|
if (!answered.get(ws)) {
|
||||||
|
ws.terminate();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
answered.set(ws, false);
|
||||||
|
ws.ping();
|
||||||
|
}
|
||||||
|
}, PING_MS).unref();
|
||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`hnefatafl server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
|
console.log(`hnefatafl server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { game } from '../../src/lib/game';
|
||||||
|
import { Rooms } from './rooms';
|
||||||
|
import { Store } from './store';
|
||||||
|
import { SPECTATOR } from '../../src/lib/game/spec';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A table of two: Ann, who opened it, and Bo, the game begun. */
|
||||||
|
function table() {
|
||||||
|
const rooms = new Rooms(game, new Store(dir));
|
||||||
|
const host = rooms.create('Ann', 2);
|
||||||
|
const guest = rooms.join(host.room, 'Bo');
|
||||||
|
rooms.begin(host.room, host.token);
|
||||||
|
return { rooms, room: host.room, ann: host.token, bo: guest.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rooms', () => {
|
||||||
|
it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => {
|
||||||
|
const { rooms, room } = table();
|
||||||
|
expect(room.galleryTalk).toBe(false);
|
||||||
|
rooms.keeperSays(room, 'I came too late for the seat; call me again.');
|
||||||
|
const said = { id: SPECTATOR, name: 'the keeper', keeper: true, text: 'I came too late for the seat; call me again.' };
|
||||||
|
expect(room.chat.at(-1)).toMatchObject(said);
|
||||||
|
expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a seat a new token at the keeper's word: the old one opens nothing, and a restart keeps the change", () => {
|
||||||
|
const { rooms, room, bo } = table();
|
||||||
|
const phrase = rooms.transfer(room, bo).phrase;
|
||||||
|
const { seat, token } = rooms.reseat(room, 'B');
|
||||||
|
expect(seat.name).toBe('Bo');
|
||||||
|
expect(rooms.seatOf(room, token).id).toBe('B');
|
||||||
|
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||||
|
// A phrase minted with the old token carries nothing now.
|
||||||
|
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||||
|
const reopened = new Rooms(game, new Store(dir)).get(room.id);
|
||||||
|
expect(rooms.seatOf(reopened, token).id).toBe('B');
|
||||||
|
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-seats only a person, and says when each person last acted', () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/);
|
||||||
|
const mover = rooms.waitingOn(room)[0];
|
||||||
|
rooms.submit(room, mover === 'A' ? ann : bo, game.botInput(room.state!, mover), 0);
|
||||||
|
const seats = rooms.seatsForKeeper(room);
|
||||||
|
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
|
||||||
|
expect(seats.find((s) => s.id === mover)!.lastActed).toBeGreaterThan(0);
|
||||||
|
expect(seats.find((s) => s.id !== mover)!.lastActed).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the host and the other seat', () => {
|
||||||
|
it("gives a person's seat to a housecarl, who plays it whenever it owes the move, and the person's token opens nothing", () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
rooms.standIn(room, ann, 'B');
|
||||||
|
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a housecarl/);
|
||||||
|
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
||||||
|
// Whoever moved first, the housecarl has answered, and the move is Ann's.
|
||||||
|
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||||
|
const before = room.resolved;
|
||||||
|
rooms.submit(room, ann, game.botInput(room.state!, 'A'), room.resolved);
|
||||||
|
expect(room.resolved).toBe(before + 2);
|
||||||
|
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the host's call alone, in a game under way, for the other person", () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/);
|
||||||
|
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
|
||||||
|
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No person sits/);
|
||||||
|
const lobby = rooms.create('Ann', 2);
|
||||||
|
rooms.join(lobby.room, 'Bo');
|
||||||
|
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the seat back by the host's phrase, with a new token, and a restart keeps both changes", () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
rooms.standIn(room, ann, 'B');
|
||||||
|
expect(new Rooms(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
|
||||||
|
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
|
||||||
|
expect(claimed.seat).toBe('B');
|
||||||
|
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
|
||||||
|
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||||
|
const reopened = new Rooms(game, new Store(dir)).get(room.id);
|
||||||
|
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-seats a person whose browser lost the seat, by the host's phrase, once, and only the host makes one", () => {
|
||||||
|
const { rooms, room, ann, bo } = table();
|
||||||
|
expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the player who opened/);
|
||||||
|
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other person/);
|
||||||
|
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||||
|
const claimed = rooms.claim(phrase);
|
||||||
|
expect(claimed.token).not.toBe(bo);
|
||||||
|
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||||
|
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("voids a phrase for the seat once a housecarl stands in", () => {
|
||||||
|
const { rooms, room, ann } = table();
|
||||||
|
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||||
|
rooms.standIn(room, ann, 'B');
|
||||||
|
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+102
-10
@@ -17,9 +17,12 @@ export { SPECTATOR };
|
|||||||
export interface Seat {
|
export interface Seat {
|
||||||
id: SeatId;
|
id: SeatId;
|
||||||
name: string;
|
name: string;
|
||||||
/** SHA-256 of the seat's token, hex; empty for a bot. */
|
/** SHA-256 of the seat's token, hex; empty for a bot. A seat a bot stands in for keeps the person's, which opens nothing while it does. */
|
||||||
tokenHash: string;
|
tokenHash: string;
|
||||||
|
/** A bot plays this seat: one seated as a bot, or one standing in for a person. */
|
||||||
bot: boolean;
|
bot: boolean;
|
||||||
|
/** The host gave this person's seat to a bot; a phrase from the host, or the keeper, gives it back. */
|
||||||
|
standIn?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashToken(token: string): string {
|
function hashToken(token: string): string {
|
||||||
@@ -108,8 +111,8 @@ 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. */
|
/** Phrases waiting to be claimed, with the seat and the token they carry; the host's carry none, and re-seat on claim. */
|
||||||
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
|
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string | null; expiresAt: number }>();
|
||||||
private failedClaims = 0;
|
private failedClaims = 0;
|
||||||
private failWindowStart = 0;
|
private failWindowStart = 0;
|
||||||
|
|
||||||
@@ -168,7 +171,8 @@ export class Rooms<State, Input> {
|
|||||||
seatOf(room: Room<State>, token: string | undefined): Seat {
|
seatOf(room: Room<State>, token: string | undefined): Seat {
|
||||||
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
|
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
|
||||||
const given = Buffer.from(hashToken(token), 'hex');
|
const given = Buffer.from(hashToken(token), 'hex');
|
||||||
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
||||||
|
if (seat?.standIn) throw new RoomError('The host gave your seat to a housecarl, who plays on in your place. Ask them for a transfer phrase to take it back.', 403);
|
||||||
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
|
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
|
||||||
return seat;
|
return seat;
|
||||||
}
|
}
|
||||||
@@ -225,6 +229,11 @@ export class Rooms<State, Input> {
|
|||||||
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
|
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The keeper of the site says a word at a table from the keeper's door, whether or not the gallery may talk: the answer to a call they could not take up. */
|
||||||
|
keeperSays(room: Room<State>, raw: unknown): void {
|
||||||
|
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: this.keeper.name, text: this.cleanTalk(raw), keeper: true });
|
||||||
|
}
|
||||||
|
|
||||||
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
|
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
|
||||||
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
|
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
|
||||||
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
|
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
|
||||||
@@ -257,9 +266,10 @@ export class Rooms<State, Input> {
|
|||||||
const caller = this.seatOf(room, token);
|
const caller = this.seatOf(room, token);
|
||||||
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409);
|
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409);
|
||||||
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
|
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
|
||||||
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
|
// A person a bot stood in for is still one of the company; the stand-in does not follow.
|
||||||
|
const others = room.seats.filter((s) => (!s.bot || s.standIn) && s.id !== caller.id).map((s) => s.name);
|
||||||
const next = this.open(caller.name, room.size, room.options, { rematchOf: room.id, expected: others });
|
const next = this.open(caller.name, room.size, room.options, { rematchOf: room.id, expected: others });
|
||||||
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
|
for (const bot of room.seats.filter((s) => s.bot && !s.standIn)) this.sit(next.room, bot.name, true);
|
||||||
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
|
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
|
||||||
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 };
|
||||||
}
|
}
|
||||||
@@ -267,6 +277,22 @@ export class Rooms<State, Input> {
|
|||||||
/** 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);
|
||||||
|
return this.mint(room.id, seat.id, token!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host mints a phrase for another person's seat: for a player whose browser lost it, or to take it back
|
||||||
|
* from a housecarl standing in. Claimed, it gives the seat a new token, so the old one opens nothing after.
|
||||||
|
*/
|
||||||
|
hostPhrase(room: Room<State>, token: string | undefined, seatId: unknown): { phrase: string; expiresAt: number } {
|
||||||
|
const host = this.seatOf(room, token);
|
||||||
|
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may make a phrase for another seat.', 403);
|
||||||
|
const seat = room.seats.find((s) => s.id === seatId && s.id !== host.id && (!s.bot || s.standIn));
|
||||||
|
if (!seat) throw new RoomError('No other person sits in that seat.', 404);
|
||||||
|
return this.mint(room.id, seat.id, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mint(roomId: string, seatId: SeatId, token: string | null): { phrase: string; expiresAt: number } {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
|
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
|
||||||
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
|
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
|
||||||
@@ -275,7 +301,7 @@ export class Rooms<State, Input> {
|
|||||||
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
|
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
|
||||||
} while (this.transfers.has(phrase));
|
} while (this.transfers.has(phrase));
|
||||||
const expiresAt = now + TRANSFER_TTL_MS;
|
const expiresAt = now + TRANSFER_TTL_MS;
|
||||||
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt });
|
this.transfers.set(phrase, { roomId, seatId, token, expiresAt });
|
||||||
return { phrase, expiresAt };
|
return { phrase, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +322,10 @@ export class Rooms<State, Input> {
|
|||||||
}
|
}
|
||||||
this.transfers.delete(phrase);
|
this.transfers.delete(phrase);
|
||||||
const room = this.get(t.roomId);
|
const room = this.get(t.roomId);
|
||||||
|
if (t.token === null) {
|
||||||
|
const { seat, token } = this.reseat(room, t.seatId);
|
||||||
|
return { roomId: room.id, seat: seat.id, token };
|
||||||
|
}
|
||||||
const seat = this.seatOf(room, t.token);
|
const seat = this.seatOf(room, t.token);
|
||||||
return { roomId: room.id, seat: seat.id, token: t.token };
|
return { roomId: room.id, seat: seat.id, token: t.token };
|
||||||
}
|
}
|
||||||
@@ -350,6 +380,52 @@ export class Rooms<State, Input> {
|
|||||||
return { seat, token };
|
return { seat, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A person's seat gets a new token, for a player whose browser lost the old one, or one taking it back from a
|
||||||
|
* bot standing in. Whether the one asking holds the seat is the keeper's judgment, or the host's in the phrase
|
||||||
|
* they made; this carries it out. The old token opens nothing after.
|
||||||
|
*/
|
||||||
|
reseat(room: Room<State>, seatId: unknown): { seat: Seat; token: string } {
|
||||||
|
const seat = room.seats.find((s) => s.id === seatId && (!s.bot || s.standIn));
|
||||||
|
if (!seat) throw new RoomError('No person sits in that seat.', 404);
|
||||||
|
const token = newId(18);
|
||||||
|
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
|
||||||
|
this.voidPhrases(room, seat.id);
|
||||||
|
return { seat, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host gives a person's seat to a bot, for a player who cannot go on: the bot plays it from the next move
|
||||||
|
* the seat owes, and the person's token opens nothing until a phrase from the host gives it back.
|
||||||
|
*/
|
||||||
|
standIn(room: Room<State>, token: string | undefined, seatId: unknown): void {
|
||||||
|
const host = this.seatOf(room, token);
|
||||||
|
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may give a seat to a housecarl.', 403);
|
||||||
|
if (!room.state || this.game.over(room.state)) throw new RoomError('Only a game under way needs a stand-in.', 409);
|
||||||
|
if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
|
||||||
|
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
|
||||||
|
if (!seat) throw new RoomError('No person sits in that seat.', 404);
|
||||||
|
this.commit(room, { t: 'standIn', at: Date.now(), id: seat.id, by: host.id });
|
||||||
|
this.voidPhrases(room, seat.id);
|
||||||
|
// The move may have been this seat's.
|
||||||
|
if (this.waitingOn(room).length === 0) this.resolve(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A phrase minted for a seat whose token has changed hands would carry a seat that no longer answers to it. */
|
||||||
|
private voidPhrases(room: Room<State>, seatId: SeatId): void {
|
||||||
|
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seatId) this.transfers.delete(phrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The table as the keeper sees it before re-seating: who sits where, and when each person last moved or spoke. */
|
||||||
|
seatsForKeeper(room: Room<State>): { id: SeatId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
|
||||||
|
const last = new Map<SeatId, number>();
|
||||||
|
for (const line of this.store.read(room.id)) {
|
||||||
|
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
|
||||||
|
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
|
||||||
|
}
|
||||||
|
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot && !s.standIn, standIn: !!s.standIn, lastActed: s.bot && !s.standIn ? null : (last.get(s.id) ?? null) }));
|
||||||
|
}
|
||||||
|
|
||||||
/** The host sends a bot away before the game begins. People leave by not coming back. */
|
/** The host sends a bot away before the game begins. People leave by not coming back. */
|
||||||
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
|
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
|
||||||
const host = this.seatOf(room, token);
|
const host = this.seatOf(room, token);
|
||||||
@@ -383,7 +459,8 @@ export class Rooms<State, Input> {
|
|||||||
|
|
||||||
private resolve(room: Room<State>): void {
|
private resolve(room: Room<State>): void {
|
||||||
const inputs: Record<SeatId, unknown> = { ...room.pending };
|
const inputs: Record<SeatId, unknown> = { ...room.pending };
|
||||||
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
|
// A move the person made before a bot stood in for them stands.
|
||||||
|
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id) && !(seat.id in inputs)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
|
||||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||||
this.driveBots(room);
|
this.driveBots(room);
|
||||||
}
|
}
|
||||||
@@ -420,7 +497,7 @@ export class Rooms<State, Input> {
|
|||||||
size: room.size,
|
size: room.size,
|
||||||
me: seatId,
|
me: seatId,
|
||||||
host: room.seats[0]?.id ?? seatId,
|
host: room.seats[0]?.id ?? seatId,
|
||||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, ...(s.standIn ? { standIn: true as const } : {}) })),
|
||||||
waitingOn: this.waitingOn(room),
|
waitingOn: this.waitingOn(room),
|
||||||
turn: room.state ? this.game.turn(room.state) : null,
|
turn: room.state ? this.game.turn(room.state) : null,
|
||||||
resolved: room.resolved,
|
resolved: room.resolved,
|
||||||
@@ -486,6 +563,21 @@ export class Rooms<State, Input> {
|
|||||||
case 'seat':
|
case 'seat':
|
||||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
|
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
|
||||||
break;
|
break;
|
||||||
|
case 'reseat': {
|
||||||
|
const seat = room.seats.find((s) => s.id === line.id);
|
||||||
|
if (!seat) break;
|
||||||
|
seat.tokenHash = line.tokenHash;
|
||||||
|
seat.bot = false;
|
||||||
|
delete seat.standIn;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'standIn': {
|
||||||
|
const seat = room.seats.find((s) => s.id === line.id);
|
||||||
|
if (!seat) break;
|
||||||
|
seat.bot = true;
|
||||||
|
seat.standIn = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'galleryTalk':
|
case 'galleryTalk':
|
||||||
room.galleryTalk = line.on;
|
room.galleryTalk = line.on;
|
||||||
break;
|
break;
|
||||||
@@ -512,7 +604,7 @@ export class Rooms<State, Input> {
|
|||||||
room.resolved += 1;
|
room.resolved += 1;
|
||||||
break;
|
break;
|
||||||
case 'chat':
|
case 'chat':
|
||||||
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
|
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}), ...(line.keeper ? { keeper: true as const } : {}) });
|
||||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -11,6 +11,13 @@ export type LedgerLine =
|
|||||||
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions }
|
| { 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. */
|
/** 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 }
|
| { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean }
|
||||||
|
/**
|
||||||
|
* A person's seat got a new token, from the keeper or a phrase the host made, for a player whose browser lost the
|
||||||
|
* old one; the old one opens nothing after. A seat a housecarl was standing in for is the person's again.
|
||||||
|
*/
|
||||||
|
| { t: 'reseat'; at: number; id: SeatId; tokenHash: string }
|
||||||
|
/** The host gave a person's seat to a housecarl, who plays it from here on; a reseat gives it back. */
|
||||||
|
| { t: 'standIn'; at: number; id: SeatId; by: SeatId }
|
||||||
/** 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. */
|
||||||
@@ -21,7 +28,7 @@ export type LedgerLine =
|
|||||||
/** 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 }
|
||||||
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
||||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string }
|
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; keeper?: true }
|
||||||
/** The host let the gallery talk, or hushed it. */
|
/** The host let the gallery talk, or hushed it. */
|
||||||
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
||||||
/** A seat is held for the keeper of the site, called to the table. */
|
/** A seat is held for the keeper of the site, called to the table. */
|
||||||
|
|||||||
@@ -146,7 +146,14 @@
|
|||||||
if (c) void goto(`/join/${c}`);
|
if (c) void goto(`/join/${c}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A game in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
|
||||||
|
let forgetting = $state<string | null>(null);
|
||||||
function forget(roomId: string) {
|
function forget(roomId: string) {
|
||||||
|
if (!games[roomId]?.over && forgetting !== roomId) {
|
||||||
|
forgetting = roomId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
forgetting = null;
|
||||||
forgetSeat(roomId);
|
forgetSeat(roomId);
|
||||||
seats = allSeats();
|
seats = allSeats();
|
||||||
}
|
}
|
||||||
@@ -155,7 +162,7 @@
|
|||||||
const g = games[held.roomId];
|
const g = games[held.roomId];
|
||||||
if (g === undefined) return 'looking';
|
if (g === undefined) return 'looking';
|
||||||
if (g === null) return 'unreachable';
|
if (g === null) return 'unreachable';
|
||||||
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (a housecarl)` : s.name));
|
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a housecarl stands in)` : s.bot ? `${s.name} (a housecarl)` : s.name));
|
||||||
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
|
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
|
||||||
if (g.over) {
|
if (g.over) {
|
||||||
if (g.over.winner === held.seat) return 'you won';
|
if (g.over.winner === held.seat) return 'you won';
|
||||||
@@ -253,7 +260,11 @@
|
|||||||
<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="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
{#if forgetting === held.roomId}
|
||||||
|
<span class="forget-ask">This browser may be all that holds your seat. <button type="button" class="quiet" onclick={() => forget(held.roomId)}>Forget it</button> <button type="button" class="quiet" onclick={() => (forgetting = null)}>Keep it</button></span>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if reports.length}
|
{#if reports.length}
|
||||||
@@ -478,6 +489,11 @@
|
|||||||
background: rgba(240, 165, 58, 0.08);
|
background: rgba(240, 165, 58, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forget-ask {
|
||||||
|
font-size: 0.85em;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ledger-row.your-move .ledger-info {
|
.ledger-row.your-move .ledger-info {
|
||||||
color: var(--ember);
|
color: var(--ember);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $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);
|
||||||
|
/** The host asked to begin while a seat is still held for someone on their way. */
|
||||||
|
let beginAsked = $state(false);
|
||||||
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 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()));
|
||||||
|
|
||||||
@@ -67,8 +69,12 @@
|
|||||||
{#if room.seatEmpty && room.isHost}
|
{#if room.seatEmpty && room.isHost}
|
||||||
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a housecarl</button>
|
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a housecarl</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if room.isHost}
|
{#if room.isHost && beginAsked && v.expected.length > 0}
|
||||||
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => room.begin()}>Begin</button>
|
<span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span>
|
||||||
|
<button type="button" class="commit small" onclick={() => room.begin()}>Begin without them</button>
|
||||||
|
<button type="button" class="link" onclick={() => (beginAsked = false)}>wait</button>
|
||||||
|
{:else if room.isHost}
|
||||||
|
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => (v.expected.length > 0 ? (beginAsked = true) : room.begin())}>Begin</button>
|
||||||
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
|
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="muted">{hostName} will begin when the table is ready.</span>
|
<span class="muted">{hostName} will begin when the table is ready.</span>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The host's hand on the other seats during a game: a transfer phrase for a
|
||||||
|
// player whose browser lost their seat, and a housecarl to play the seat of one
|
||||||
|
// who cannot go on, until a phrase gives it back.
|
||||||
|
import type { SeatId } from '$lib/game/spec';
|
||||||
|
import type { Room } from '$lib/net/room.svelte';
|
||||||
|
|
||||||
|
let { room, open = $bindable(false) }: { room: Room; open?: boolean } = $props();
|
||||||
|
|
||||||
|
const people = $derived(room.view.seats.filter((s) => s.id !== room.me && (!s.bot || s.standIn)));
|
||||||
|
let asked = $state<SeatId | null>(null);
|
||||||
|
let phrase = $state<{ name: string; phrase: string; expiresAt: number } | null>(null);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
asked = null;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePhrase(seat: SeatId, name: string) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const made = await room.hostPhrase(seat);
|
||||||
|
phrase = { name, ...made };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (phrase?.phrase === made.phrase) phrase = null;
|
||||||
|
}, Math.max(0, made.expiresAt - Date.now()));
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'The phrase could not be made.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function standIn(seat: SeatId) {
|
||||||
|
asked = null;
|
||||||
|
if (!(await room.standIn(seat))) error = room.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phraseTitle(name: string, standIn: boolean): string {
|
||||||
|
const why = standIn ? `Brings ${name} back from the housecarl.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
|
||||||
|
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this table, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
|
||||||
|
<div class="slip" role="dialog" aria-labelledby="seats-heading">
|
||||||
|
<h2 id="seats-heading">The other seats</h2>
|
||||||
|
<p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a housecarl, who plays it at once.</p>
|
||||||
|
{#if people.length === 0}
|
||||||
|
<p class="muted">Nobody else at this table is a person.</p>
|
||||||
|
{/if}
|
||||||
|
<ul>
|
||||||
|
{#each people as s (s.id)}
|
||||||
|
<li>
|
||||||
|
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a housecarl plays for them</span>{/if}</span>
|
||||||
|
<span class="ways">
|
||||||
|
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
|
||||||
|
{#if !s.standIn && !room.view.over}
|
||||||
|
{#if asked === s.id}
|
||||||
|
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a housecarl plays for {s.name}</button>
|
||||||
|
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="quiet" title="A housecarl plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a housecarl</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if phrase}
|
||||||
|
<p class="made">Give {phrase.name} this phrase to claim within ten minutes, in the hall or while watching this table: <b class="phrase">{phrase.phrase}</b>. It works once, and their old link stops opening the seat.</p>
|
||||||
|
{/if}
|
||||||
|
{#if error}<p class="warning">{error}</p>{/if}
|
||||||
|
<div class="actions"><button type="button" class="link" onclick={close}>close</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slip {
|
||||||
|
width: min(100%, 34rem);
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--slate);
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem 1.2rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.8rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ways {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.made {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase {
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<li class="muted">Nobody has said a word.</li>
|
<li class="muted">Nobody has said a word.</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#each lines as line, i (line.at + ':' + i)}
|
{#each lines as line, i (line.at + ':' + i)}
|
||||||
<li class:you={!line.name && line.id === room.me}><em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
<li class:you={!line.name && line.id === room.me}><em>{line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{#if room.spectating && room.view.galleryTalk}
|
{#if room.spectating && room.view.galleryTalk}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ function heldSeats(): Record<string, HeldSeat> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
|
||||||
|
export function seatInLink(hash: string): HeldSeat | null {
|
||||||
|
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
|
||||||
|
return m ? { seat: m[1], token: m[2] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function seatFor(roomId: string): HeldSeat | null {
|
export function seatFor(roomId: string): HeldSeat | null {
|
||||||
return heldSeats()[roomId] ?? null;
|
return heldSeats()[roomId] ?? null;
|
||||||
}
|
}
|
||||||
@@ -140,6 +146,9 @@ export function makeApi<State, Input>() {
|
|||||||
rematch: (roomId: string, token: string) =>
|
rematch: (roomId: string, token: string) =>
|
||||||
call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
|
call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
|
||||||
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
|
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
|
||||||
|
/** The host's phrase for another person's seat; claimed, it re-seats them. */
|
||||||
|
hostPhrase: (roomId: string, token: string, seat: SeatId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
|
||||||
|
standIn: (roomId: string, token: string, seat: SeatId) => call<View>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
|
||||||
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 }),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
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';
|
||||||
import type { Input, State } from '$lib/game';
|
import type { Input, State } from '$lib/game';
|
||||||
import { makeApi, rememberSeat, watchRoom, type HeldSeat } from './client';
|
import { makeApi, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
|
||||||
import { markTalkSeen } from './talk';
|
import { markTalkSeen } from './talk';
|
||||||
import type { RoomView } from './view';
|
import type { RoomView } from './view';
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ export class Room {
|
|||||||
error = $state('');
|
error = $state('');
|
||||||
/** A move is on its way to the server. */
|
/** A move is on its way to the server. */
|
||||||
sending = $state(false);
|
sending = $state(false);
|
||||||
|
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
|
||||||
|
lost = $state('');
|
||||||
|
|
||||||
constructor(view: RoomView<State>, token: string) {
|
constructor(view: RoomView<State>, token: string) {
|
||||||
this.view = view;
|
this.view = view;
|
||||||
@@ -72,7 +74,10 @@ export class Room {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
fetchView()
|
fetchView()
|
||||||
.then((v) => this.apply(v))
|
.then((v) => this.apply(v))
|
||||||
.catch((e: Error) => (this.error = e.message));
|
.catch((e: Error) => {
|
||||||
|
if (e instanceof ServerError && e.status === 403 && !this.spectating) this.lost = e.message;
|
||||||
|
else this.error = e.message;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
|
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
|
||||||
}
|
}
|
||||||
@@ -134,6 +139,16 @@ export class Room {
|
|||||||
return api.transfer(this.roomId, this.token);
|
return api.transfer(this.roomId, this.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Host only: a phrase for another person's seat, which gives it a new token when claimed. */
|
||||||
|
hostPhrase(seat: SeatId): Promise<{ phrase: string; expiresAt: number }> {
|
||||||
|
return api.hostPhrase(this.roomId, this.token, seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host only: a housecarl plays this person's seat for the rest of the game, until a phrase gives it back. */
|
||||||
|
standIn(seat: SeatId): Promise<boolean> {
|
||||||
|
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a housecarl.');
|
||||||
|
}
|
||||||
|
|
||||||
async submit(input: Input): Promise<boolean> {
|
async submit(input: Input): Promise<boolean> {
|
||||||
this.sending = true;
|
this.sending = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+4
-1
@@ -9,6 +9,8 @@ export interface ChatLine {
|
|||||||
text: string;
|
text: string;
|
||||||
at: number;
|
at: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Said by the keeper of the site, through the keeper's door. */
|
||||||
|
keeper?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomView<State> {
|
export interface RoomView<State> {
|
||||||
@@ -20,7 +22,8 @@ export interface RoomView<State> {
|
|||||||
me: SeatId;
|
me: SeatId;
|
||||||
/** The player who opened the table and may begin the game. */
|
/** The player who opened the table and may begin the game. */
|
||||||
host: SeatId;
|
host: SeatId;
|
||||||
seats: { id: SeatId; name: string; bot: boolean }[];
|
/** Who sits where; `standIn` marks a person's seat the host gave to a bot, which plays it until they take it back. */
|
||||||
|
seats: { id: SeatId; name: string; bot: boolean; standIn?: true }[];
|
||||||
/** Seats that still have to move before the round resolves. */
|
/** Seats that still have to move before the round resolves. */
|
||||||
waitingOn: SeatId[];
|
waitingOn: SeatId[];
|
||||||
/** Rounds played so far; a move is sent with the count it was made against. */
|
/** Rounds played so far; a move is sent with the count it was made against. */
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<h2>The table</h2>
|
<h2>The table</h2>
|
||||||
<p>
|
<p>
|
||||||
Whoever opened the table is its host, and begins the game once a second player has sat or a housecarl has been seated. A friend who opens the link takes the other seat. The
|
Whoever opened the table is its host, and begins the game once a second player has sat or a housecarl has been seated. A friend who opens the link takes the other seat. The
|
||||||
masthead's <em>Transfer seat</em> gives four words that bring your seat to another device, and when a game is over anyone at the table may call for a rematch.
|
masthead's <em>Transfer seat</em> gives four words that bring your seat to another device, and when a game is over anyone at the table may call for a rematch. During a game the host's <em>Seats</em> makes the same four words for the other player, if their browser loses the seat, and can hand that seat to a housecarl when they cannot go on. Their four words bring them back.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
import Board from '$lib/components/Board.svelte';
|
import Board from '$lib/components/Board.svelte';
|
||||||
import Lobby from '$lib/components/Lobby.svelte';
|
import Lobby from '$lib/components/Lobby.svelte';
|
||||||
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
||||||
|
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
|
||||||
import Preferences from '$lib/components/Preferences.svelte';
|
import Preferences from '$lib/components/Preferences.svelte';
|
||||||
import { seatFor } from '$lib/net/client';
|
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/net/client';
|
||||||
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||||
|
|
||||||
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
||||||
|
|
||||||
@@ -21,20 +22,53 @@
|
|||||||
/** A phrase minted to carry this seat to another device, while it lasts. */
|
/** A phrase minted to carry this seat to another device, while it lasts. */
|
||||||
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);
|
||||||
|
let seatsOpen = $state(false);
|
||||||
|
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
|
||||||
|
let seatLost = $state('');
|
||||||
|
let claimPhrase = $state('');
|
||||||
|
let claiming = $state(false);
|
||||||
|
|
||||||
const link = $derived(`${location.origin}/join/${roomId}`);
|
const link = $derived(`${location.origin}/join/${roomId}`);
|
||||||
const watching = $derived(room?.spectating ?? false);
|
const watching = $derived(room?.spectating ?? false);
|
||||||
|
|
||||||
// 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. A link from the keeper carries a seat:
|
||||||
|
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
|
||||||
|
// still opens, so a link someone else sends cannot take a working seat away.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = roomId;
|
const id = roomId;
|
||||||
room = null;
|
room = null;
|
||||||
error = '';
|
error = '';
|
||||||
|
const sent = seatInLink(location.hash);
|
||||||
|
// The browser's own call: this runs as the page first loads, before the router could take one.
|
||||||
|
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
||||||
const held = seatFor(id);
|
const held = seatFor(id);
|
||||||
const arrive = held ? Room.resume(id, held) : Room.watch(id);
|
const take = (s: HeldSeat) =>
|
||||||
joining = !held;
|
Room.resume(id, s).then((r) => {
|
||||||
|
rememberSeat(id, s);
|
||||||
|
trustSeat(id);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
|
||||||
|
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
||||||
|
// A held seat the server refuses, with no link to replace it, is watched from the gallery, with the reason.
|
||||||
|
const watchRefused = (e: Error) => {
|
||||||
|
seatLost = e.message;
|
||||||
|
return Room.watch(id);
|
||||||
|
};
|
||||||
|
seatLost = '';
|
||||||
|
const arrive = held
|
||||||
|
? Room.resume(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
|
||||||
|
: sent
|
||||||
|
? take(sent).catch(() => Room.watch(id))
|
||||||
|
: Room.watch(id);
|
||||||
|
joining = !held && !sent;
|
||||||
if (!held) name = playerName();
|
if (!held) name = playerName();
|
||||||
arrive.then((r) => (room = r)).catch((e: Error) => (error = e.message));
|
arrive
|
||||||
|
.then((r) => {
|
||||||
|
room = r;
|
||||||
|
if (r.spectating) joining = true;
|
||||||
|
})
|
||||||
|
.catch((e: Error) => (error = e.message));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the view fresh for as long as the room is on screen.
|
// Keep the view fresh for as long as the room is on screen.
|
||||||
@@ -44,6 +78,36 @@
|
|||||||
return r.connect();
|
return r.connect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The seat stopped opening while the page was open (the host gave it to a housecarl): watch on from the gallery.
|
||||||
|
$effect(() => {
|
||||||
|
const r = room;
|
||||||
|
if (!r?.lost) return;
|
||||||
|
seatLost = r.lost;
|
||||||
|
Room.watch(r.roomId)
|
||||||
|
.then((w) => (room = w))
|
||||||
|
.catch((e: Error) => (error = e.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sit back down by a phrase: the host's, or one this seat minted on another device. */
|
||||||
|
async function claim(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
claiming = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const got = await api.claim(claimPhrase.trim());
|
||||||
|
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
|
||||||
|
trustSeat(got.roomId);
|
||||||
|
claimPhrase = '';
|
||||||
|
seatLost = '';
|
||||||
|
if (got.roomId === roomId) room = await Room.resume(roomId, { seat: got.seat, token: got.token });
|
||||||
|
else await goto(`/join/${got.roomId}`);
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'The phrase could not be claimed.';
|
||||||
|
} finally {
|
||||||
|
claiming = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function takeSeat(e: SubmitEvent) {
|
async function takeSeat(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -119,6 +183,9 @@
|
|||||||
{#if !watching}
|
{#if !watching}
|
||||||
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if room.isHost && room.started}
|
||||||
|
<button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a housecarl" onclick={() => (seatsOpen = true)}>Seats</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
|
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
|
||||||
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
|
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
|
||||||
@@ -133,6 +200,7 @@
|
|||||||
<Preferences bind:open={prefsOpen} />
|
<Preferences bind:open={prefsOpen} />
|
||||||
{#if room}
|
{#if room}
|
||||||
<ReportSlip {room} bind:open={reporting} />
|
<ReportSlip {room} bind:open={reporting} />
|
||||||
|
{#if room.isHost}<SeatsSlip {room} bind:open={seatsOpen} />{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
@@ -171,6 +239,22 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if room?.started && !seatLost && room.view.seats.some((s) => s.standIn)}
|
||||||
|
<p class="notice muted">A housecarl plays for {room.view.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if watching && room?.started && !room.view.over}
|
||||||
|
<div class="notice muted">
|
||||||
|
{#if seatLost}<p>{seatLost} Claim it here, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a>.</p>
|
||||||
|
{:else}{#if room.view.expected.length > 0}<p>{room.nameOf(room.view.host)} began the game before {room.view.expected.join(' and ')} arrived, and the seat held for {room.view.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
|
||||||
|
<p>Had a seat at this table? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>{/if}
|
||||||
|
<form class="sit" onsubmit={claim}>
|
||||||
|
<input type="text" bind:value={claimPhrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||||
|
<button type="submit" class="quiet" disabled={claiming || claimPhrase.trim().split(/[\s-]+/).length < 4}>Claim the seat</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if room && !room.started}
|
{#if room && !room.started}
|
||||||
<Lobby {room} {link} {copied} {copyLink} />
|
<Lobby {room} {link} {copied} {copyLink} />
|
||||||
{:else if room}
|
{:else if room}
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
"slug": "dip",
|
"slug": "dip",
|
||||||
"name": "Dip",
|
"name": "Dip",
|
||||||
"url": "https://dip.kestrelsnest.social",
|
"url": "https://dip.kestrelsnest.social",
|
||||||
"pitch": "Seven powers. One map. Nobody has to keep their word.",
|
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
||||||
"players": "2 to 7",
|
"players": "2 to 7",
|
||||||
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -21,6 +21,6 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.test.ts']
|
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user