Compare commits
21
Commits
c64ff70ed4
...
main
No files matched your search
@@ -255,6 +255,29 @@ probing legality is free, so when unsure, try it and read the error.
|
|||||||
on WARP LANDING squares hit players who never see the token coming.
|
on WARP LANDING squares hit players who never see the token coming.
|
||||||
That placement doctrine is proven; repeat it.
|
That placement doctrine is proven; repeat it.
|
||||||
|
|
||||||
|
## Game-fifteen scars (six seats, lost to a two-life walk home)
|
||||||
|
|
||||||
|
- **Same victim twice, again — and the kill feeds the carrier.**
|
||||||
|
Kestrel banked Automaton IV's first chest, his troll killed IV, and
|
||||||
|
IV's cards passed to him — the hand that kept him alive on two life
|
||||||
|
long enough to walk IV's second chest home (the game-nine lesson,
|
||||||
|
taught a second time). A wizard's death is a card windfall for its
|
||||||
|
killer: when a weak player falls to someone carrying, price the loot
|
||||||
|
into his survival. When a human has one banked, find the victim whose
|
||||||
|
LAST floor chest sits nearest him and guard THAT square.
|
||||||
|
- **The interception window is the turn BEFORE the grab.** Once he held
|
||||||
|
the chest four steps from home, nothing I could reach could see his
|
||||||
|
road. Run the sight-and-block scan against his likely grab squares
|
||||||
|
every turn he has one banked, not after he picks up.
|
||||||
|
- **CHAOS scrambles your own tools.** I cast it to strip his numbers and
|
||||||
|
lost both pick-locks my door route needed; my whole plan was a hand
|
||||||
|
I no longer held. Cast it only when your plan does not depend on the
|
||||||
|
hand you are giving away.
|
||||||
|
- Stone and thornbush as blockers worked twice against a bot carrier
|
||||||
|
(8 -> 11, then 8 -> 22 steps): a bot BFS reroutes rather than wades,
|
||||||
|
and buys real turns. The block scan (my reach x visible empty squares
|
||||||
|
x carrier's new distance) is the tool; keep it in the scratchpad.
|
||||||
|
|
||||||
## The passivity audit (Eric, after game thirteen)
|
## The passivity audit (Eric, after game thirteen)
|
||||||
|
|
||||||
- "You did nothing but draw for several turns" — true, and fatal. A
|
- "You did nothing but draw for several turns" — true, and fatal. A
|
||||||
|
|||||||
@@ -52,4 +52,20 @@ longer replays becomes unreachable after restart, so this is not optional.
|
|||||||
`setup-droplet.sh` does NOT install it — on a fresh droplet, copy the
|
`setup-droplet.sh` does NOT install it — on a fresh droplet, copy the
|
||||||
script, configure rclone (the script refuses to run while the config still
|
script, configure rclone (the script refuses to run while the config still
|
||||||
holds its CHANGE_ME placeholder), and add the cron entry by hand.
|
holds its CHANGE_ME placeholder), and add the cron entry by hand.
|
||||||
|
- A player whose browser lost their seat (Safari's seven days, an app's own
|
||||||
|
browser, cleared data) asks the host, whose "seats" button makes a
|
||||||
|
four-word phrase that re-seats them on a new token. The same panel lets the
|
||||||
|
host give a seat to the clockwork in a game under way, for a player who
|
||||||
|
cannot take their turns; a `standIn` line on the ledger records it, and a
|
||||||
|
later reseat gives the seat back. Failing the host, they write to the
|
||||||
|
keeper. On the droplet,
|
||||||
|
`wizwar-reseat.sh ROOM` lists who sits at the table and when each last
|
||||||
|
moved or spoke; `wizwar-reseat.sh ROOM NAME` gives that wizard's seat a new
|
||||||
|
token and prints a link carrying it in the fragment. The old token opens
|
||||||
|
nothing after. The script answers through the game server's keeper door,
|
||||||
|
a listener of its own on 127.0.0.1:9787 that Caddy never reaches, which
|
||||||
|
opens only to the key in /etc/wizwar/keeper.key (root and the wizwar user
|
||||||
|
alone can read it, so the other services on the host cannot use the door).
|
||||||
|
The first deploy makes the key, and every deploy installs the script at
|
||||||
|
/usr/local/bin.
|
||||||
- One-off backup: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
|
- One-off backup: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
|
||||||
+13
-1
@@ -25,7 +25,19 @@ ssh "root@$HOST" '
|
|||||||
install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh
|
install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh
|
||||||
install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh
|
install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh
|
||||||
install -m 755 /opt/wizwar/deploy/wizwar-visitors.sh /usr/local/bin/wizwar-visitors.sh
|
install -m 755 /opt/wizwar/deploy/wizwar-visitors.sh /usr/local/bin/wizwar-visitors.sh
|
||||||
install -m 644 /opt/wizwar/deploy/wizwar-rollup.cron /etc/cron.d/wizwar-rollup
|
install -m 755 /opt/wizwar/deploy/wizwar-reseat.sh /usr/local/bin/wizwar-reseat.sh
|
||||||
|
# The key to the keeper door: made once, readable by root (the reseat
|
||||||
|
# script) and the wizwar user (the server) alone.
|
||||||
|
if [ ! -s /etc/wizwar/keeper.key ]; then
|
||||||
|
install -d -m 750 -o root -g wizwar /etc/wizwar
|
||||||
|
(umask 037; openssl rand -hex 32 > /etc/wizwar/keeper.key)
|
||||||
|
chown root:wizwar /etc/wizwar/keeper.key
|
||||||
|
fi
|
||||||
|
# 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
|
||||||
|
# its own schedules its own.
|
||||||
|
if [ -f /etc/cron.d/gamehall ]; then rm -f /etc/cron.d/wizwar-rollup
|
||||||
|
else install -m 644 /opt/wizwar/deploy/wizwar-rollup.cron /etc/cron.d/wizwar-rollup; fi
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now wizwar
|
systemctl enable --now wizwar
|
||||||
systemctl restart wizwar
|
systemctl restart wizwar
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ LOG="/var/log/wizwar/backup.log"
|
|||||||
STAMP=$(date +%Y-%m-%d)
|
STAMP=$(date +%Y-%m-%d)
|
||||||
# Sentry Crons check-in: /root/.wizwar-sentry-cron holds the check-in URL
|
# Sentry Crons check-in: /root/.wizwar-sentry-cron holds the check-in URL
|
||||||
# (auto-creates the monitor on first ping). Absent file = no check-ins.
|
# (auto-creates the monitor on first ping). Absent file = no check-ins.
|
||||||
|
# On a host the hall shares, hall-nightly.sh runs this and checks in once
|
||||||
|
# for every game, so the file is absent and the exit status is the word.
|
||||||
CRON_URL=$(cat /root/.wizwar-sentry-cron 2>/dev/null || true)
|
CRON_URL=$(cat /root/.wizwar-sentry-cron 2>/dev/null || true)
|
||||||
# One check-in, when the run ends, carrying its duration: a start and a
|
# One check-in, when the run ends, carrying its duration: a start and a
|
||||||
# finish sent seconds apart can reach Sentry in either order, and a start
|
# finish sent seconds apart can reach Sentry in either order, and a start
|
||||||
@@ -20,9 +22,10 @@ checkin() { [ -n "$CRON_URL" ] && curl -sf -o /dev/null "$CRON_URL?status=$1&dur
|
|||||||
|
|
||||||
if grep -q CHANGE_ME /root/.config/rclone/rclone.conf; then
|
if grep -q CHANGE_ME /root/.config/rclone/rclone.conf; then
|
||||||
echo "$(date -Is) skipped: Spaces credentials not configured yet" >> "$LOG"
|
echo "$(date -Is) skipped: Spaces credentials not configured yet" >> "$LOG"
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
status=0
|
||||||
{
|
{
|
||||||
echo "=== $(date -Is) backup start"
|
echo "=== $(date -Is) backup start"
|
||||||
if rclone sync "$SRC" "spaces:$BUCKET/wizwar/current" 2>&1 &&
|
if rclone sync "$SRC" "spaces:$BUCKET/wizwar/current" 2>&1 &&
|
||||||
@@ -30,6 +33,7 @@ fi
|
|||||||
checkin ok
|
checkin ok
|
||||||
else
|
else
|
||||||
checkin error
|
checkin error
|
||||||
|
status=1
|
||||||
fi
|
fi
|
||||||
# Prune dated snapshots older than 90 days (by directory name).
|
# Prune dated snapshots older than 90 days (by directory name).
|
||||||
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
|
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
|
||||||
@@ -43,3 +47,4 @@ fi
|
|||||||
done
|
done
|
||||||
echo "=== $(date -Is) backup done"
|
echo "=== $(date -Is) backup done"
|
||||||
} >> "$LOG"
|
} >> "$LOG"
|
||||||
|
exit $status
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/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,
|
||||||
|
# which listens on the loopback alone.
|
||||||
|
# wizwar-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
|
||||||
|
# wizwar-reseat.sh ROOM NAME re-seat the wizard NAME and print the link
|
||||||
|
set -euo pipefail
|
||||||
|
ROOM="${1:?usage: wizwar-reseat.sh ROOM [NAME]}"
|
||||||
|
DOOR="http://127.0.0.1:${KEEPER_PORT:-9787}/keeper/rooms/${ROOM^^}"
|
||||||
|
KEY_FILE="${KEEPER_KEY_FILE:-/etc/wizwar/keeper.key}"
|
||||||
|
[ -s "$KEY_FILE" ] || { echo "No keeper's key at $KEY_FILE; 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 "$KEY_FILE")")
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
curl -s -H "$KEY" "$DOOR" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
r = json.load(sys.stdin)
|
||||||
|
if "error" in r: sys.exit(r["error"])
|
||||||
|
print("room %s, %s" % (r["roomId"], "finished" if r["finished"] else "in play" if r["started"] else "not begun"))
|
||||||
|
for s in r["seats"]:
|
||||||
|
when = "automaton" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + s["lastActed"][:16].replace("T", " ") + " UTC"
|
||||||
|
print(" %-24s %-5s %s" % (s["name"], "host" if s["host"] else "", when))
|
||||||
|
'
|
||||||
|
else
|
||||||
|
python3 -c 'import json, sys; print(json.dumps({"seat": sys.argv[1]}))' "$2" \
|
||||||
|
| curl -s -X POST -H "$KEY" -H 'content-type: application/json' --data-binary @- "$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
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
# one count per address per day.
|
# one count per address per day.
|
||||||
# Cron: see SCHEDULE below and /etc/cron.d/wizwar-rollup (deploy.sh installs both).
|
# Cron: see SCHEDULE below and /etc/cron.d/wizwar-rollup (deploy.sh installs both).
|
||||||
# Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL
|
# Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL
|
||||||
# (absent = no check-ins).
|
# (absent = no check-ins). On a host the hall shares, hall-nightly.sh
|
||||||
|
# runs this and checks in once for every game, so the file is absent and
|
||||||
|
# the exit status is the word.
|
||||||
set -u
|
set -u
|
||||||
OUT="/var/lib/wizwar/rollup.jsonl"
|
OUT="/var/lib/wizwar/rollup.jsonl"
|
||||||
LOG="/var/log/wizwar/rollup.log"
|
LOG="/var/log/wizwar/rollup.log"
|
||||||
@@ -148,4 +150,4 @@ tmp = out + ".tmp"
|
|||||||
open(tmp, "w").write("\n".join(rows) + "\n"); os.replace(tmp, out)
|
open(tmp, "w").write("\n".join(rows) + "\n"); os.replace(tmp, out)
|
||||||
print("%s rollup %s: %d requests, %d human addresses, %d new rooms, %d human seats" % (datetime.datetime.now(datetime.timezone.utc).isoformat(), day, req, len(hips), new_rooms, sum(seats.values())))
|
print("%s rollup %s: %d requests, %d human addresses, %d new rooms, %d human seats" % (datetime.datetime.now(datetime.timezone.utc).isoformat(), day, req, len(hips), new_rooms, sum(seats.values())))
|
||||||
PY
|
PY
|
||||||
then checkin ok; else checkin error; fi
|
then checkin ok; else checkin error; exit 1; fi
|
||||||
@@ -41,6 +41,7 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
|
|||||||
ca = meta.get("createdAt", "")
|
ca = meta.get("createdAt", "")
|
||||||
if not ca: continue
|
if not ca: continue
|
||||||
humans = [meta["hostId"]]; bots = []; started = False; cmds = 0; chat = []; last = ca; steps = []
|
humans = [meta["hostId"]]; bots = []; started = False; cmds = 0; chat = []; last = ca; steps = []
|
||||||
|
standing = set(); stood = [] # seats the host gave the clockwork, and who and when
|
||||||
for l in lines[1:]:
|
for l in lines[1:]:
|
||||||
try: x = json.loads(l)
|
try: x = json.loads(l)
|
||||||
except Exception: continue
|
except Exception: continue
|
||||||
@@ -48,15 +49,19 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
|
|||||||
if k == "join":
|
if k == "join":
|
||||||
(bots if x.get("bot") else humans).append(x["name"])
|
(bots if x.get("bot") else humans).append(x["name"])
|
||||||
elif k == "start": started = True
|
elif k == "start": started = True
|
||||||
|
elif k == "standIn":
|
||||||
|
standing.add(x["name"]); stood.append((x.get("at", "")[11:16], x["name"], "to the clockwork"))
|
||||||
|
elif k == "reseat" and x["name"] in standing:
|
||||||
|
standing.discard(x["name"]); stood.append((x.get("at", "")[11:16], x["name"], "back to a person"))
|
||||||
elif k == "command":
|
elif k == "command":
|
||||||
cmds += 1; last = x.get("at", last)
|
cmds += 1; last = x.get("at", last)
|
||||||
steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", "")))
|
steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", ""), x.get("playerId") in standing))
|
||||||
elif k == "chat":
|
elif k == "chat":
|
||||||
last = x.get("at", last)
|
last = x.get("at", last)
|
||||||
if x["player"] in humans: chat.append((x.get("at", "")[11:16], x["player"], x["text"]))
|
if x["player"] in humans: chat.append((x.get("at", "")[11:16], x["player"], x["text"]))
|
||||||
for h in humans:
|
for h in humans:
|
||||||
if h not in first_seen or ca < first_seen[h]: first_seen[h] = ca
|
if h not in first_seen or ca < first_seen[h]: first_seen[h] = ca
|
||||||
rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps))
|
rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps, stood=stood))
|
||||||
|
|
||||||
today = sorted([r for r in rooms if r["created"].startswith(day)], key=lambda r: r["created"])
|
today = sorted([r for r in rooms if r["created"].startswith(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"]})
|
||||||
@@ -68,12 +73,13 @@ for r in today:
|
|||||||
if r["cmds"]:
|
if r["cmds"]:
|
||||||
t0 = datetime.datetime.fromisoformat(r["created"].replace("Z", "+00:00")); t1 = datetime.datetime.fromisoformat(r["last"].replace("Z", "+00:00"))
|
t0 = datetime.datetime.fromisoformat(r["created"].replace("Z", "+00:00")); t1 = datetime.datetime.fromisoformat(r["last"].replace("Z", "+00:00"))
|
||||||
span = "%d min" % round((t1 - t0).total_seconds() / 60)
|
span = "%d min" % round((t1 - t0).total_seconds() / 60)
|
||||||
human_cmds = [s for s in r["steps"] if s[0] in r["humans"]]
|
human_cmds = [s for s in r["steps"] if s[0] in r["humans"] and not s[3]]
|
||||||
last_human = ("last human move: %s %s at %s" % (human_cmds[-1][0], human_cmds[-1][1], human_cmds[-1][2][11:16])) if human_cmds else "no human move"
|
last_human = ("last human move: %s %s at %s" % (human_cmds[-1][0], human_cmds[-1][1], human_cmds[-1][2][11:16])) if human_cmds else "no human move"
|
||||||
state = "lobby only" if not r["started"] else ("started, no moves" if not r["cmds"] else "in play")
|
state = "lobby only" if not r["started"] else ("started, no moves" if not r["cmds"] else "in play")
|
||||||
print(" %s %s %s%s %s %d moves (%d human) %s %s" % (
|
print(" %s %s %s%s %s %d moves (%d human) %s %s" % (
|
||||||
r["created"][11:16], r["id"], "+".join(r["humans"]), (" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "",
|
r["created"][11:16], r["id"], "+".join(r["humans"]), (" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "",
|
||||||
state, r["cmds"], len(human_cmds), span, last_human))
|
state, r["cmds"], len(human_cmds), span, last_human))
|
||||||
|
for at, who, what in r["stood"]: print(" %s %s's seat goes %s" % (at, who, what))
|
||||||
for at, who, text in r["chat"]: print(" %s %s: %s" % (at, who, text))
|
for at, who, text in r["chat"]: print(" %s %s: %s" % (at, who, text))
|
||||||
|
|
||||||
# --- the desk ---
|
# --- the desk ---
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ WorkingDirectory=/opt/wizwar/packages/server
|
|||||||
Environment=PORT=8787
|
Environment=PORT=8787
|
||||||
# Caddy terminates TLS; the plaintext port must not face the internet.
|
# Caddy terminates TLS; the plaintext port must not face the internet.
|
||||||
Environment=HOST=127.0.0.1
|
Environment=HOST=127.0.0.1
|
||||||
|
# The keeper's door (deploy/wizwar-reseat.sh) binds the loopback whatever HOST says.
|
||||||
|
Environment=KEEPER_PORT=9787
|
||||||
|
Environment=KEEPER_KEY_FILE=/etc/wizwar/keeper.key
|
||||||
Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms
|
Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms
|
||||||
Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist
|
Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist
|
||||||
Environment=WIZWAR_KEEPER_TZ=America/New_York
|
Environment=WIZWAR_KEEPER_TZ=America/New_York
|
||||||
|
|||||||
Generated
+2
-1
@@ -2759,7 +2759,8 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/ws": "^8.5.0",
|
"@types/ws": "^8.5.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
|
|||||||
@@ -1472,7 +1472,9 @@ export function automatonCommand(
|
|||||||
// Command the menagerie: creatures march and maul before the wizard moves.
|
// Command the menagerie: creatures march and maul before the wizard moves.
|
||||||
for (const c of view.creatures) {
|
for (const c of view.creatures) {
|
||||||
if (c.controllerId !== you || c.justCreated) continue;
|
if (c.controllerId !== you || c.justCreated) continue;
|
||||||
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
|
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position) &&
|
||||||
|
// Rev 28: creatures cannot strike the mist either.
|
||||||
|
!(view.deckRev >= 28 && view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === p.id)));
|
||||||
if (enemyHere && !c.attackUsed &&
|
if (enemyHere && !c.attackUsed &&
|
||||||
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
|
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
|
||||||
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
|
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export interface CastParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||||
export const CURRENT_RULES_REV = 27;
|
export const CURRENT_RULES_REV = 28;
|
||||||
|
|
||||||
/** Every rulings revision since the baseline, newest last — the entries a
|
/** Every rulings revision since the baseline, newest last — the entries a
|
||||||
* game's deckRev freezes it before or after. Shown to players as the house
|
* game's deckRev freezes it before or after. Shown to players as the house
|
||||||
@@ -279,6 +279,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
|
|||||||
{ rev: 25, note: "An attack on a bush or the ooze is an attack on an inanimate object like any wall: POWER ATTACK's bought points count, and a thrown DAGGER or LARGE ROCK lands at the foot of the bush instead of vanishing into the discard. Older games took neither." },
|
{ rev: 25, note: "An attack on a bush or the ooze is an attack on an inanimate object like any wall: POWER ATTACK's bought points count, and a thrown DAGGER or LARGE ROCK lands at the foot of the bush instead of vanishing into the discard. Older games took neither." },
|
||||||
{ rev: 26, note: "BUTT-HEAD's ram is physical damage like any other: STRENGTH doubles it, AMPLIFY doubles it, WEAKNESS doubles it, POWER ATTACK adds to it, and ABSORB or BLUNT soak it. Older games dealt the bare distance, whatever rode the cast." },
|
{ rev: 26, note: "BUTT-HEAD's ram is physical damage like any other: STRENGTH doubles it, AMPLIFY doubles it, WEAKNESS doubles it, POWER ATTACK adds to it, and ABSORB or BLUNT soak it. Older games dealt the bare distance, whatever rode the cast." },
|
||||||
{ rev: 27, note: "An ILLUSIONARY ATTACK is priced as the spell it fakes, so a BLUNT halves it, an ABSORB soaks it, and a shield stops it, exactly as against the real thing; the belief roll then decides whether the weighed blow lands at all. Older games dealt a believed illusion's full points past every counteraction." },
|
{ rev: 27, note: "An ILLUSIONARY ATTACK is priced as the spell it fakes, so a BLUNT halves it, an ABSORB soaks it, and a shield stops it, exactly as against the real thing; the belief roll then decides whether the weighed blow lands at all. Older games dealt a believed illusion's full points past every counteraction." },
|
||||||
|
{ rev: 28, note: "MIST-BODY's \"may not be attacked\" holds against creatures too: a wraith's chill or a democratic monster's claw passes through a misted wizard, a fire imp's scorch finds nothing to burn, and no troll, skeleton or shadow may be ordered to strike one. Older games let creatures attack the mist." },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface GameConfig {
|
export interface GameConfig {
|
||||||
@@ -598,6 +599,9 @@ function castSightEdge(
|
|||||||
cell: Cell,
|
cell: Cell,
|
||||||
side: Side,
|
side: Side,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// The caster's own eye, as for square-aimed casts: a held door stands
|
||||||
|
// open, and the door at their elbow they may pull ajar and look through.
|
||||||
|
board = doorsAjar(state, caster.id, openedDoors(state, board));
|
||||||
if (losToEdge(board, caster.position, cell, side)) return true;
|
if (losToEdge(board, caster.position, cell, side)) return true;
|
||||||
if (displays(caster, "visionstone") &&
|
if (displays(caster, "visionstone") &&
|
||||||
throughOneEdge(board, (b) => losToEdge(b, caster.position, cell, side))) {
|
throughOneEdge(board, (b) => losToEdge(b, caster.position, cell, side))) {
|
||||||
@@ -624,6 +628,12 @@ function isMisted(state: GameState, playerId: PlayerId): boolean {
|
|||||||
return sustainedOn(state, playerId, "mist-body").length > 0;
|
return sustainedOn(state, playerId, "mist-body").length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rev 28: MIST-BODY — "You may not attack or be attacked" — shields its
|
||||||
|
* wizard from creatures as from wizards. Older games let them strike. */
|
||||||
|
function mistShieldsFromCreatures(state: GameState, p: PlayerState): boolean {
|
||||||
|
return (state.config.deckRev ?? 1) >= 28 && isMisted(state, p.id);
|
||||||
|
}
|
||||||
|
|
||||||
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
|
function isLockedInPlace(state: GameState, playerId: PlayerId): boolean {
|
||||||
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
||||||
}
|
}
|
||||||
@@ -706,6 +716,9 @@ export type GameEvent =
|
|||||||
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
|
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
|
||||||
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
|
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
|
||||||
| { type: "tableTalk"; player: PlayerId; text: string }
|
| { type: "tableTalk"; player: PlayerId; text: string }
|
||||||
|
/** The room's word, not the maze's: the host gave a person's seat to the
|
||||||
|
* clockwork, or the seat went back to a person. */
|
||||||
|
| { type: "seatChanged"; player: PlayerId; to: "clockwork" | "person"; by?: PlayerId }
|
||||||
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
|
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
|
||||||
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||||
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
|
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
|
||||||
@@ -3185,6 +3198,7 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
|
|||||||
if (onlyPlayer && p.id !== onlyPlayer) continue;
|
if (onlyPlayer && p.id !== onlyPlayer) continue;
|
||||||
if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn
|
if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn
|
||||||
if (imp.scorchedThisTurn.includes(p.id)) continue;
|
if (imp.scorchedThisTurn.includes(p.id)) continue;
|
||||||
|
if (mistShieldsFromCreatures(state, p)) continue;
|
||||||
// "Imp cannot see through a FIREWALL!" — gameLos already blocks on
|
// "Imp cannot see through a FIREWALL!" — gameLos already blocks on
|
||||||
// firewall edges and terrain.
|
// firewall edges and terrain.
|
||||||
if (!gameLos(state, imp.position, p.position)) continue;
|
if (!gameLos(state, imp.position, p.position)) continue;
|
||||||
@@ -3218,6 +3232,7 @@ function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature:
|
|||||||
for (const p of state.players) {
|
for (const p of state.players) {
|
||||||
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
|
||||||
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
|
||||||
|
if (mistShieldsFromCreatures(state, p)) continue;
|
||||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
if (creature.kind === "wraith" && !creature.attackUsed) {
|
||||||
creature.attackUsed = true;
|
creature.attackUsed = true;
|
||||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||||
@@ -3429,6 +3444,7 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
|
|||||||
return err("the creature must share its target's square");
|
return err("the creature must share its target's square");
|
||||||
}
|
}
|
||||||
if (targetPlayer && targetPlayer.id === creature.controllerId) return err("it will not hurt its creator");
|
if (targetPlayer && targetPlayer.id === creature.controllerId) return err("it will not hurt its creator");
|
||||||
|
if (targetPlayer && mistShieldsFromCreatures(state, targetPlayer)) return err("your target is mist and cannot be attacked");
|
||||||
|
|
||||||
creature.attackUsed = true;
|
creature.attackUsed = true;
|
||||||
let amount: number;
|
let amount: number;
|
||||||
@@ -3568,7 +3584,8 @@ function remapState(
|
|||||||
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
|
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
|
||||||
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
|
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
|
||||||
for (const fx of state.sustained) {
|
for (const fx of state.sustained) {
|
||||||
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
|
// GLUE keeps its glued cell's key where edge spells keep an edge's.
|
||||||
|
if (fx.edge) fx.edge = fx.cardId === "glue" ? mapCellKey(fx.edge) : mapEdgeKey(fx.edge);
|
||||||
}
|
}
|
||||||
for (const p of state.players) {
|
for (const p of state.players) {
|
||||||
if (inSector(p.position)) p.position = mapCell(p.position);
|
if (inSector(p.position)) p.position = mapCell(p.position);
|
||||||
|
|||||||
@@ -1021,7 +1021,7 @@ describe("interception, escape, and hazard sense", () => {
|
|||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } });
|
expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } });
|
||||||
const r = applyCommand(state, "bot", cmd!);
|
const r = applyCommand(state, "bot", cmd!);
|
||||||
expect(r.ok).toBe(true);
|
if (!r.ok) throw new Error(r.error);
|
||||||
// The box gone, the very next thought is the grab.
|
// The box gone, the very next thought is the grab.
|
||||||
const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
||||||
expect(next).toMatchObject({ type: "pickUpTreasure" });
|
expect(next).toMatchObject({ type: "pickUpTreasure" });
|
||||||
@@ -1050,7 +1050,7 @@ describe("interception, escape, and hazard sense", () => {
|
|||||||
cmd = automatonFallback(view, "archmage");
|
cmd = automatonFallback(view, "archmage");
|
||||||
r = applyCommand(state, seat, cmd);
|
r = applyCommand(state, seat, cmd);
|
||||||
}
|
}
|
||||||
expect(r.ok).toBe(true);
|
if (!r.ok) throw new Error(`${seat}: ${r.error}`);
|
||||||
state = r.state;
|
state = r.state;
|
||||||
ended = seat === "bot" && cmd.type === "endTurn";
|
ended = seat === "bot" && cmd.type === "endTurn";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1044,3 +1044,64 @@ describe("the troll's fist on a wall", () => {
|
|||||||
expect(again.ok).toBe(false);
|
expect(again.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("creatures cannot strike the mist (rev 28)", () => {
|
||||||
|
// MIST-BODY: "You may not attack or be attacked." Rev 28 holds the
|
||||||
|
// card against creatures as well as wizards; older games let them strike.
|
||||||
|
function mistRig(deckRev?: number) {
|
||||||
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
state = toRound2(state);
|
||||||
|
const creator = activePlayer(state);
|
||||||
|
state = summon(state, creator.id, "democratic-monster").state;
|
||||||
|
state = must(state, creator.id, { type: "endTurn", draw: 0 });
|
||||||
|
const mover = activePlayer(state);
|
||||||
|
const victim = state.players.find((p) => p.id !== mover.id)!;
|
||||||
|
pushSustained(state, { id: "fx-mist", cardId: "mist-body", casterId: victim.id, targetId: victim.id, remainingTurns: 2 });
|
||||||
|
const dm = state.creatures.find((c) => c.kind === "democratic-monster")!;
|
||||||
|
const spot = emptyNeighborCell(state, victim.position);
|
||||||
|
dm.position = { ...spot.cell };
|
||||||
|
return { state, mover: mover.id, victim: victim.id, dm: dm.id, into: opposite(spot.side) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a democratic monster passes through a misted wizard without clawing", () => {
|
||||||
|
const { state, mover, dm, into } = mistRig();
|
||||||
|
const r = applyCommand(state, mover, { type: "moveCreature", creatureId: dm, direction: into });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
expect(r.state.stack).toBeNull();
|
||||||
|
expect(r.events.some((e) => e.type === "creatureTouched")).toBe(false);
|
||||||
|
expect(r.state.creatures.find((c) => c.id === dm)!.attackUsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("older games let the monster claw the mist", () => {
|
||||||
|
const { state, mover, dm, into } = mistRig(27);
|
||||||
|
const r = applyCommand(state, mover, { type: "moveCreature", creatureId: dm, direction: into });
|
||||||
|
if (!r.ok) throw new Error(r.error);
|
||||||
|
expect(r.state.stack?.creatureId ?? null).toBe(dm);
|
||||||
|
});
|
||||||
|
|
||||||
|
function skeletonRig(deckRev?: number) {
|
||||||
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
||||||
|
state = toRound2(state);
|
||||||
|
const me = activePlayer(state).id;
|
||||||
|
state = summon(state, me, "skeleton").state;
|
||||||
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
||||||
|
const other = activePlayer(state).id;
|
||||||
|
state = must(state, other, { type: "endTurn", draw: 0 });
|
||||||
|
const victim = state.players.find((p) => p.id === other)!;
|
||||||
|
victim.position = { ...state.creatures[0]!.position };
|
||||||
|
pushSustained(state, { id: "fx-mist", cardId: "mist-body", casterId: other, targetId: other, remainingTurns: 2 });
|
||||||
|
return { state, me, victim: other, skeleton: state.creatures[0]!.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("no skeleton may be ordered to strike a misted wizard", () => {
|
||||||
|
const { state, me, victim, skeleton } = skeletonRig();
|
||||||
|
const r = applyCommand(state, me, { type: "creatureAttack", creatureId: skeleton, targetId: victim });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("older games let the order stand", () => {
|
||||||
|
const { state, me, victim, skeleton } = skeletonRig(27);
|
||||||
|
const r = applyCommand(state, me, { type: "creatureAttack", creatureId: skeleton, targetId: victim });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -458,6 +458,26 @@ describe("a held door is an open doorway to the eye", () => {
|
|||||||
expect(cast.ok).toBe(true);
|
expect(cast.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a wall beyond the unlocked door is in sight for wall-aimed spells too (BDFS)", () => {
|
||||||
|
// Edge casts once judged sight on the bare board, where every door
|
||||||
|
// hangs shut — the threshold peek reached only square-aimed spells.
|
||||||
|
let { state, cell, side, holder } = sightRig();
|
||||||
|
const near = neighbor(cell, side);
|
||||||
|
const view = boardView(state);
|
||||||
|
const far = SIDES.find((s) => s !== opposite(side) && view.edges[edgeKey(near, s)] === "wall");
|
||||||
|
if (!far) throw new Error("setup: no wall on the far side of seed 42's door");
|
||||||
|
const pick = giveCard(state, holder, "pick-lock");
|
||||||
|
state = must(state, holder, {
|
||||||
|
type: "cast", instanceId: pick.instanceId,
|
||||||
|
target: { kind: "edge", cell, side },
|
||||||
|
});
|
||||||
|
const dw = giveCard(state, holder, "destroy-wall", "DW", 1);
|
||||||
|
const cast = applyCommand(state, holder, {
|
||||||
|
type: "cast", instanceId: dw.instanceId, target: { kind: "edge", cell: near, side: far },
|
||||||
|
});
|
||||||
|
expect(cast.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("a lock REMOVED lets any wizard beside the door peek through", () => {
|
it("a lock REMOVED lets any wizard beside the door peek through", () => {
|
||||||
let { state, cell, side, holder, pursuer } = sightRig();
|
let { state, cell, side, holder, pursuer } = sightRig();
|
||||||
const rm = giveCard(state, holder, "remove-lock");
|
const rm = giveCard(state, holder, "remove-lock");
|
||||||
|
|||||||
@@ -331,6 +331,27 @@ describe("sector manipulation", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a glued treasure turns with its sector, and the glue turns with it (KFJL)", () => {
|
||||||
|
// GLUE keeps its cell's key in the sustained effect's edge slot; the
|
||||||
|
// rotation once read it as an edge key and threw.
|
||||||
|
let { state } = newGame();
|
||||||
|
const me = activePlayer(state);
|
||||||
|
const chest = state.treasures.find((t) => t.owner === me.id)!;
|
||||||
|
const key = cellKey(chest.position!);
|
||||||
|
state.gluedCells[key] = true;
|
||||||
|
state.sustained.push({
|
||||||
|
id: "fx-glue", cardId: "glue", casterId: me.id, targetId: me.id, remainingTurns: 4, data: {}, edge: key,
|
||||||
|
});
|
||||||
|
const rs = giveCard(state, me.id, "rotate-sector");
|
||||||
|
state = must(state, me.id, {
|
||||||
|
type: "cast", instanceId: rs.instanceId,
|
||||||
|
target: { kind: "cell", cell: me.position }, params: { clockwise: true },
|
||||||
|
});
|
||||||
|
const moved = cellKey(state.treasures.find((t) => t.id === chest.id)!.position!);
|
||||||
|
expect(state.gluedCells).toEqual({ [moved]: true });
|
||||||
|
expect(state.sustained.find((s) => s.id === "fx-glue")!.edge).toBe(moved);
|
||||||
|
});
|
||||||
|
|
||||||
it("relocate sector slides everything and keeps adjacency", () => {
|
it("relocate sector slides everything and keeps adjacency", () => {
|
||||||
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
|
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
|
||||||
const me = activePlayer(state);
|
const me = activePlayer(state);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/node": "^10.73.0",
|
"@sentry/node": "^10.73.0",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/ws": "^8.5.0",
|
"@types/ws": "^8.5.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
GALLERY_MARK,
|
GALLERY_MARK,
|
||||||
driveOneAutomaton,
|
driveOneAutomaton,
|
||||||
makeTransferCode,
|
makeTransferCode,
|
||||||
|
makeSeatPhrase,
|
||||||
redactFor,
|
redactFor,
|
||||||
roomCount,
|
roomCount,
|
||||||
runCommand,
|
runCommand,
|
||||||
@@ -74,6 +75,7 @@ import {
|
|||||||
viewForPlayer,
|
viewForPlayer,
|
||||||
type Room,
|
type Room,
|
||||||
kickSeat,
|
kickSeat,
|
||||||
|
standIn,
|
||||||
abandonRoom,
|
abandonRoom,
|
||||||
KEEPER,
|
KEEPER,
|
||||||
MAX_SEATS,
|
MAX_SEATS,
|
||||||
@@ -81,6 +83,7 @@ import {
|
|||||||
import { engagementStats, recordHotseat } from "./stats";
|
import { engagementStats, recordHotseat } from "./stats";
|
||||||
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
|
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
|
||||||
import { clipsIndexHtml, clipPageHtml } from "./clips";
|
import { clipsIndexHtml, clipPageHtml } from "./clips";
|
||||||
|
import { openKeeperDoor } from "./keeper";
|
||||||
import { SlidingLimit, clientAddress } from "./ratelimit";
|
import { SlidingLimit, clientAddress } from "./ratelimit";
|
||||||
import { getShare, loadShares, mintShare } from "./shares";
|
import { getShare, loadShares, mintShare } from "./shares";
|
||||||
import { renderSharePng } from "./ogimage";
|
import { renderSharePng } from "./ogimage";
|
||||||
@@ -110,6 +113,52 @@ const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
|
|||||||
/** Screenshots for the desk: a few per address per hour, like reports. */
|
/** Screenshots for the desk: a few per address per hour, like reports. */
|
||||||
const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||||
const IMAGE_MAX_BYTES = 2_500_000;
|
const IMAGE_MAX_BYTES = 2_500_000;
|
||||||
|
/** Crash reports from browsers: a crashing tab reloads and crashes again, so a few per address per hour say all there is. */
|
||||||
|
const crashReportsPerAddress = new SlidingLimit(10, 60 * 60 * 1000);
|
||||||
|
const CRASH_REPORT_MAX_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
/** Anyone may post a report, so its strings reach the log on one line,
|
||||||
|
* with no control characters to forge another. */
|
||||||
|
function printable(value: unknown, max: number): string {
|
||||||
|
return String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chromium sends the Reporting API's crash reports here, after the fact,
|
||||||
|
* from the browser itself: a tab that crashes takes the page's own error
|
||||||
|
* reporting down with it. Each crash is logged and filed in Sentry as a
|
||||||
|
* warning, which rings no bell. Other report types are ignored. */
|
||||||
|
function receiveBrowserReports(req: IncomingMessage, res: ServerResponse): void {
|
||||||
|
const address = clientAddress(req.headers, req.socket.remoteAddress);
|
||||||
|
let raw = "";
|
||||||
|
let tooBig = false;
|
||||||
|
req.setEncoding("utf8");
|
||||||
|
req.on("data", (chunk: string) => {
|
||||||
|
if (tooBig) return;
|
||||||
|
raw += chunk;
|
||||||
|
if (raw.length > CRASH_REPORT_MAX_BYTES) tooBig = true;
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
res.writeHead(204).end();
|
||||||
|
if (tooBig) return;
|
||||||
|
let reports: unknown;
|
||||||
|
try { reports = JSON.parse(raw); } catch { return; }
|
||||||
|
if (!Array.isArray(reports)) return;
|
||||||
|
for (const r of reports as { type?: unknown; url?: unknown; user_agent?: unknown; body?: Record<string, unknown> }[]) {
|
||||||
|
if (r?.type !== "crash" || !crashReportsPerAddress.allow(address)) continue;
|
||||||
|
const url = printable(r.url, 200);
|
||||||
|
const room = url.match(/\/join\/([A-Za-z0-9]{4})/)?.[1]?.toUpperCase() ?? "none";
|
||||||
|
const reason = typeof r.body?.reason === "string" ? printable(r.body.reason, 40) : "unknown";
|
||||||
|
const userAgent = printable(r.user_agent, 300);
|
||||||
|
console.warn(`browser tab crashed: room ${room}, reason ${reason}, ${userAgent}`);
|
||||||
|
Sentry.captureMessage("A player's browser tab crashed", {
|
||||||
|
level: "warning",
|
||||||
|
fingerprint: ["browser-crash", reason],
|
||||||
|
tags: { room, reason },
|
||||||
|
extra: { url, userAgent, body: r.body ?? null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** A picture for a report, POSTed right after the report is filed: one
|
/** A picture for a report, POSTed right after the report is filed: one
|
||||||
* per report, within the hour, PNG/JPEG/WebP by its own first bytes, kept
|
* per report, within the hour, PNG/JPEG/WebP by its own first bytes, kept
|
||||||
@@ -369,6 +418,7 @@ const httpServer = createServer((req, res) => {
|
|||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/);
|
const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/);
|
||||||
if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; }
|
if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; }
|
||||||
|
if ((req.url ?? "").split("?")[0] === "/api/reports") { receiveBrowserReports(req, res); return; }
|
||||||
res.writeHead(404).end();
|
res.writeHead(404).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -377,6 +427,8 @@ const httpServer = createServer((req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.setHeader("x-content-type-options", "nosniff");
|
res.setHeader("x-content-type-options", "nosniff");
|
||||||
|
// Where Chromium sends a crash report about a tab that showed this page.
|
||||||
|
res.setHeader("reporting-endpoints", `default="${requestBase(req)}/api/reports"`);
|
||||||
if (!staticRoot) {
|
if (!staticRoot) {
|
||||||
res.writeHead(404).end("client not built");
|
res.writeHead(404).end("client not built");
|
||||||
return;
|
return;
|
||||||
@@ -535,6 +587,27 @@ const httpServer = createServer((req, res) => {
|
|||||||
const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 });
|
const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 });
|
||||||
httpServer.listen(port, host);
|
httpServer.listen(port, host);
|
||||||
|
|
||||||
|
// The keeper's door listens on the loopback alone, on a port of its own, and
|
||||||
|
// opens only to the key in KEEPER_KEY_FILE (deploy/wizwar-reseat.sh). A
|
||||||
|
// socket still seated on the old token is closed: its reconnect offers that
|
||||||
|
// token, and it opens nothing now.
|
||||||
|
openKeeperDoor({
|
||||||
|
port: Number(process.env.KEEPER_PORT ?? 9787),
|
||||||
|
publicUrl: PUBLIC_URL,
|
||||||
|
keyFile: process.env.KEEPER_KEY_FILE ?? "/etc/wizwar/keeper.key",
|
||||||
|
onReseat: (room, name, returned) => {
|
||||||
|
closeSeat(room, name);
|
||||||
|
if (returned) broadcast(room, () => ({ type: "events", events: [returned] }));
|
||||||
|
broadcastRoomState(room);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Close every socket still seated as NAME: its reconnect offers the old
|
||||||
|
* token, which opens nothing now. */
|
||||||
|
function closeSeat(room: Room, name: PlayerId): void {
|
||||||
|
for (const s of sessions) if (s.roomId === room.id && s.playerId === name) s.socket.close();
|
||||||
|
}
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
playerId: PlayerId | null;
|
playerId: PlayerId | null;
|
||||||
@@ -554,6 +627,8 @@ interface Session {
|
|||||||
roomsCreated: number;
|
roomsCreated: number;
|
||||||
lastCatchUpAt: number;
|
lastCatchUpAt: number;
|
||||||
hotseatReports: number;
|
hotseatReports: number;
|
||||||
|
/** Answered the last protocol ping. */
|
||||||
|
alive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function underRateLimit(s: Session): boolean {
|
function underRateLimit(s: Session): boolean {
|
||||||
@@ -567,6 +642,21 @@ function underRateLimit(s: Session): boolean {
|
|||||||
|
|
||||||
const sessions = new Set<Session>();
|
const sessions = new Set<Session>();
|
||||||
|
|
||||||
|
// A browser that crashes or loses its network never closes its socket, and
|
||||||
|
// TCP may take minutes to notice. Every beat each socket is pinged at the
|
||||||
|
// protocol level, which browsers answer on their own, and one that left the
|
||||||
|
// last ping unanswered is dropped.
|
||||||
|
setInterval(() => {
|
||||||
|
for (const s of sessions) {
|
||||||
|
if (!s.alive) {
|
||||||
|
s.socket.terminate();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s.alive = false;
|
||||||
|
s.socket.ping();
|
||||||
|
}
|
||||||
|
}, Number(process.env.WIZWAR_PING_MS ?? 30_000));
|
||||||
|
|
||||||
function send(socket: WebSocket, message: unknown): void {
|
function send(socket: WebSocket, message: unknown): void {
|
||||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
@@ -606,12 +696,15 @@ function roomInfo(room: Room) {
|
|||||||
keeperZone: KEEPER_ZONE,
|
keeperZone: KEEPER_ZONE,
|
||||||
galleryTalk: room.galleryTalk,
|
galleryTalk: room.galleryTalk,
|
||||||
colors: Object.fromEntries(room.colorChoices),
|
colors: Object.fromEntries(room.colorChoices),
|
||||||
|
/** People's seats the clockwork is playing for now. */
|
||||||
|
standIns: [...room.bots].filter(([, b]) => b.standIn).map(([name]) => name),
|
||||||
bots: Object.fromEntries(
|
bots: Object.fromEntries(
|
||||||
// A mystery machine keeps its mood only while the game lives: once it
|
// A mystery machine keeps its mood only while the game lives: once it
|
||||||
// ends, the hands go face-up and so does the temperament.
|
// ends, the hands go face-up and so does the temperament.
|
||||||
[...room.bots].map(([name, b]) => [
|
[...room.bots].map(([name, b]) => [
|
||||||
name,
|
name,
|
||||||
room.state?.phase === "finished"
|
b.standIn ? "standing in"
|
||||||
|
: room.state?.phase === "finished"
|
||||||
? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}`
|
? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}`
|
||||||
: `${b.tier} ${b.secret ? "mystery" : b.style}`,
|
: `${b.tier} ${b.secret ? "mystery" : b.style}`,
|
||||||
]),
|
]),
|
||||||
@@ -665,6 +758,8 @@ function banterTrigger(
|
|||||||
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
|
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
|
||||||
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
|
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
|
||||||
for (const [seat, bot] of room.bots) {
|
for (const [seat, bot] of room.bots) {
|
||||||
|
// A stand-in plays under a person's name, and does not speak for them.
|
||||||
|
if (bot.standIn) continue;
|
||||||
const lines = BOT_LINES[bot.style] ?? {};
|
const lines = BOT_LINES[bot.style] ?? {};
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
const trigger = banterTrigger(e, seat, actor);
|
const trigger = banterTrigger(e, seat, actor);
|
||||||
@@ -706,7 +801,7 @@ function runBots(room: Room): void {
|
|||||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||||
// A refused brain-choice becomes a visible stumble, in character —
|
// A refused brain-choice becomes a visible stumble, in character —
|
||||||
// an idle bot turn should read as hesitation, never as nothing.
|
// an idle bot turn should read as hesitation, never as nothing.
|
||||||
if (step.hesitated) {
|
if (step.hesitated && !room.bots.get(step.seat)?.standIn) {
|
||||||
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
|
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
|
||||||
if (!("error" in said)) {
|
if (!("error" in said)) {
|
||||||
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
|
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
|
||||||
@@ -735,9 +830,10 @@ wss.on("connection", (socket, req) => {
|
|||||||
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
|
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
|
||||||
address: clientAddress(req.headers, req.socket.remoteAddress),
|
address: clientAddress(req.headers, req.socket.remoteAddress),
|
||||||
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
||||||
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
|
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, alive: true,
|
||||||
};
|
};
|
||||||
sessions.add(session);
|
sessions.add(session);
|
||||||
|
socket.on("pong", () => { session.alive = true; });
|
||||||
// A socket's own error (a frame over the limit, a malformed one) closes that socket, never the server.
|
// A socket's own error (a frame over the limit, a malformed one) closes that socket, never the server.
|
||||||
socket.on("error", () => socket.terminate());
|
socket.on("error", () => socket.terminate());
|
||||||
send(socket, { type: "welcome", game: "wizwar" });
|
send(socket, { type: "welcome", game: "wizwar" });
|
||||||
@@ -890,6 +986,35 @@ wss.on("connection", (socket, req) => {
|
|||||||
broadcastRoomState(room);
|
broadcastRoomState(room);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "standIn": {
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
const name = String(msg.name ?? "");
|
||||||
|
const result = standIn(room, session.playerId, name);
|
||||||
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||||
|
// The person's live sockets are set adrift: the seat plays itself now.
|
||||||
|
for (const other of sessions) {
|
||||||
|
if (other.roomId === room.id && other.playerId === name) {
|
||||||
|
other.playerId = null;
|
||||||
|
other.roomId = null;
|
||||||
|
other.token = null;
|
||||||
|
send(other.socket, { type: "standingIn", roomId: room.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broadcast(room, () => ({ type: "events", events: [result.event] }));
|
||||||
|
broadcastRoomState(room);
|
||||||
|
runBots(room);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "makeSeatPhrase": {
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
const name = String(msg.name ?? "");
|
||||||
|
const result = makeSeatPhrase(room, session.playerId, name);
|
||||||
|
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
||||||
|
send(socket, { type: "seatPhrase", name, code: result.code, expiresAt: result.expiresAt });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "abandonRoom": {
|
case "abandonRoom": {
|
||||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
@@ -1022,7 +1147,13 @@ wss.on("connection", (socket, req) => {
|
|||||||
return send(socket, { type: "error", message: result.error });
|
return send(socket, { type: "error", message: result.error });
|
||||||
}
|
}
|
||||||
session.claimFails = 0;
|
session.claimFails = 0;
|
||||||
send(socket, { type: "transferClaimed", seat: result });
|
const claimed = getRoom(result.roomId);
|
||||||
|
if (result.reseated && claimed) {
|
||||||
|
closeSeat(claimed, result.name);
|
||||||
|
if (result.returned) broadcast(claimed, () => ({ type: "events", events: [result.returned] }));
|
||||||
|
broadcastRoomState(claimed);
|
||||||
|
}
|
||||||
|
send(socket, { type: "transferClaimed", seat: { roomId: result.roomId, name: result.name, token: result.token } });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "catchUp": {
|
case "catchUp": {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// The keeper's door: a listener of its own on the loopback, apart from the
|
||||||
|
// port Caddy forwards to, so it answers only on the host itself, and only to
|
||||||
|
// the key in its key file, so another service on a shared host cannot use
|
||||||
|
// it. Its one caller is deploy/wizwar-reseat.sh, which sends the key:
|
||||||
|
// 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
|
||||||
|
|
||||||
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
|
import type { GameEvent, PlayerId } from "@wizwar/engine";
|
||||||
|
import { getRoom, reseat, seatsForKeeper, type Room } from "./rooms";
|
||||||
|
|
||||||
|
function send(res: ServerResponse, status: number, body: unknown): void {
|
||||||
|
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The key is read on each knock, so a key written after the server started is honoured. Absent, the door stays shut. */
|
||||||
|
function keyGiven(req: IncomingMessage, keyFile: string): boolean {
|
||||||
|
let key: Buffer;
|
||||||
|
try {
|
||||||
|
key = Buffer.from(readFileSync(keyFile, "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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSeat(req: IncomingMessage): Promise<string> {
|
||||||
|
let raw = "";
|
||||||
|
for await (const chunk of req) {
|
||||||
|
raw += chunk;
|
||||||
|
if (raw.length > 4096) return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const seat = (JSON.parse(raw) as { seat?: unknown }).seat;
|
||||||
|
return typeof seat === "string" ? seat : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeeperDoor {
|
||||||
|
port: number;
|
||||||
|
publicUrl: string;
|
||||||
|
keyFile: string;
|
||||||
|
/** Lets the caller set adrift any socket still seated on the old token,
|
||||||
|
* and tell the table when the seat comes back from the clockwork. */
|
||||||
|
onReseat: (room: Room, name: PlayerId, returned?: GameEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, keyFile, onReseat }: KeeperDoor): Promise<void> {
|
||||||
|
const parts = (req.url ?? "/").split("?")[0]!.split("/").filter(Boolean);
|
||||||
|
// Nothing proxied belongs here: a forwarded request is turned away even if a proxy is someday pointed at this port.
|
||||||
|
if (req.headers["x-forwarded-for"] || !keyGiven(req, keyFile)) return send(res, 404, { error: "not here" });
|
||||||
|
if (parts[0] !== "keeper" || parts[1] !== "rooms" || !parts[2]) return send(res, 404, { error: "not here" });
|
||||||
|
const room = getRoom(parts[2]);
|
||||||
|
if (!room) return send(res, 404, { error: "no such room" });
|
||||||
|
if (parts.length === 3 && req.method === "GET") {
|
||||||
|
return send(res, 200, { roomId: room.id, started: room.state !== null, finished: room.state?.phase === "finished", seats: seatsForKeeper(room) });
|
||||||
|
}
|
||||||
|
if (parts.length === 4 && parts[3] === "reseat" && req.method === "POST") {
|
||||||
|
const name = await readSeat(req);
|
||||||
|
const result = reseat(room, name);
|
||||||
|
if ("error" in result) return send(res, 404, result);
|
||||||
|
onReseat(room, name, result.returned);
|
||||||
|
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor the server.
|
||||||
|
return send(res, 200, { name, link: `${publicUrl}/join/${room.id}#seat=${encodeURIComponent(name)}.${result.token}` });
|
||||||
|
}
|
||||||
|
return send(res, 404, { error: "not here" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the door on 127.0.0.1, whatever address the game itself listens on. */
|
||||||
|
export function openKeeperDoor(door: KeeperDoor): Server {
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
handle(req, res, door).catch((e) => {
|
||||||
|
console.error("keeper door:", e);
|
||||||
|
if (!res.headersSent) send(res, 500, { error: "internal error" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
server.listen(door.port, "127.0.0.1");
|
||||||
|
return server;
|
||||||
|
}
|
||||||
+115
-10
@@ -49,8 +49,9 @@ export interface Room {
|
|||||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||||
/** Table talk, persisted with the room (public to all seats). */
|
/** Table talk, persisted with the room (public to all seats). */
|
||||||
chat: { player: PlayerId; text: string; at: string }[];
|
chat: { player: PlayerId; text: string; at: string }[];
|
||||||
/** Seats the server itself plays: temperament, tier, and secrecy. */
|
/** Seats the server itself plays: temperament, tier, and secrecy. A
|
||||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
* stand-in plays a person's seat the host handed over mid-game. */
|
||||||
|
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier; standIn?: true }>;
|
||||||
/** Last time anything looked at this room (memory eviction clock). */
|
/** Last time anything looked at this room (memory eviction clock). */
|
||||||
touchedAt?: number;
|
touchedAt?: number;
|
||||||
/** A finished table that called for a rematch: where it went, and who called. */
|
/** A finished table that called for a rematch: where it went, and who called. */
|
||||||
@@ -173,9 +174,10 @@ export function callRematch(
|
|||||||
if (room.state?.phase !== "finished") return { error: "the game is not over yet" };
|
if (room.state?.phase !== "finished") return { error: "the game is not over yet" };
|
||||||
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "only a wizard of this table may call a rematch" };
|
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "only a wizard of this table may call a rematch" };
|
||||||
if (room.rematch) return { roomId: room.rematch.roomId, token: null, created: false };
|
if (room.rematch) return { roomId: room.rematch.roomId, token: null, created: false };
|
||||||
const humans = room.players.filter((p) => !room.bots.has(p) && p !== byId);
|
// A seat the clockwork stood in for is still a person's, and is held for them.
|
||||||
|
const humans = room.players.filter((p) => personSeat(room, p) && p !== byId);
|
||||||
const { room: next, token } = createRoom(byId, { of: room.id, expected: humans, expansion: room.expansion });
|
const { room: next, token } = createRoom(byId, { of: room.id, expected: humans, expansion: room.expansion });
|
||||||
for (const [, b] of room.bots) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
|
for (const [, b] of room.bots) if (!b.standIn) addAutomaton(next, b.secret ? undefined : b.style, b.tier);
|
||||||
room.rematch = { roomId: next.id, by: byId };
|
room.rematch = { roomId: next.id, by: byId };
|
||||||
appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() });
|
appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() });
|
||||||
return { roomId: next.id, token, created: true };
|
return { roomId: next.id, token, created: true };
|
||||||
@@ -299,6 +301,7 @@ export function joinRoom(
|
|||||||
token: string | null,
|
token: string | null,
|
||||||
): { token: string } | { error: string } {
|
): { token: string } | { error: string } {
|
||||||
if (room.players.includes(playerId)) {
|
if (room.players.includes(playerId)) {
|
||||||
|
if (room.bots.get(playerId)?.standIn) return { error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" };
|
||||||
// Reclaiming an existing seat requires that seat's secret.
|
// Reclaiming an existing seat requires that seat's secret.
|
||||||
if (tokenMatches(room, playerId, token)) return { token: token! };
|
if (tokenMatches(room, playerId, token)) return { token: token! };
|
||||||
return { error: "that wizard name is taken in this room" };
|
return { error: "that wizard name is taken in this room" };
|
||||||
@@ -331,6 +334,78 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A person's seat gets a new token, for a player whose browser lost the
|
||||||
|
* old one: the keeper's door asks for it, or a phrase the host made is
|
||||||
|
* claimed. Whether the one asking holds the seat is their judgment; this
|
||||||
|
* carries it out. The old token opens nothing after, and a seat the
|
||||||
|
* clockwork was standing in for goes back to a person. */
|
||||||
|
export function reseat(room: Room, name: PlayerId): { token: string; returned?: GameEvent } | { error: string } {
|
||||||
|
if (!personSeat(room, name)) return { error: "no person sits in that seat" };
|
||||||
|
const token = randomBytes(16).toString("hex");
|
||||||
|
const returned = seatToPerson(room, name, hashToken(token));
|
||||||
|
appendLine(room.id, { kind: "reseat", name, tokenHash: hashToken(token), at: new Date().toISOString() });
|
||||||
|
// A phrase minted with the old token would hand over a seat that no longer answers to it.
|
||||||
|
for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code);
|
||||||
|
return { token, ...(returned ? { returned } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A seat answers to a new token; one the clockwork was standing in for
|
||||||
|
* goes back to its person, and the chronicle says so. */
|
||||||
|
function seatToPerson(room: Room, name: PlayerId, tokenHash: string): GameEvent | undefined {
|
||||||
|
const wasStanding = room.bots.get(name)?.standIn === true;
|
||||||
|
room.bots.delete(name);
|
||||||
|
room.tokens.set(name, tokenHash);
|
||||||
|
if (!wasStanding) return undefined;
|
||||||
|
const event: GameEvent = { type: "seatChanged", player: name, to: "person" };
|
||||||
|
room.events.push(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The clockwork takes a person's seat, and the chronicle says so. */
|
||||||
|
function seatToClockwork(room: Room, name: PlayerId, by: PlayerId, style: AutomatonStyle, tier: AutomatonTier): GameEvent {
|
||||||
|
room.tokens.delete(name);
|
||||||
|
room.bots.set(name, { style, secret: true, tier, standIn: true });
|
||||||
|
const event: GameEvent = { type: "seatChanged", player: name, to: "clockwork", by };
|
||||||
|
room.events.push(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A seat a person holds, or held until the host gave it to the clockwork. */
|
||||||
|
function personSeat(room: Room, name: PlayerId): boolean {
|
||||||
|
const bot = room.bots.get(name);
|
||||||
|
return bot ? bot.standIn === true : room.tokens.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host gives a person's seat to the clockwork in a game under way, for
|
||||||
|
* a player who cannot take their turns: the wizard keeps hand, treasures
|
||||||
|
* and place, and an automaton plays on from here. The person's token opens
|
||||||
|
* nothing until a reseat gives the seat back. */
|
||||||
|
export function standIn(room: Room, byId: PlayerId, name: PlayerId): { event: GameEvent } | { error: string } {
|
||||||
|
if (byId !== room.hostId) return { error: "only the host may give a seat to the clockwork" };
|
||||||
|
if (room.state?.phase !== "playing") return { error: "only a game under way needs a stand-in" };
|
||||||
|
if (name === room.hostId) return { error: "the host keeps their own seat" };
|
||||||
|
if (!room.tokens.has(name) || room.bots.has(name)) return { error: "no person sits in that seat" };
|
||||||
|
if (!room.state.players.find((p) => p.id === name)?.alive) return { error: `${name} is out of the game already` };
|
||||||
|
const style = AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
|
||||||
|
const tier: AutomatonTier = "adept";
|
||||||
|
const event = seatToClockwork(room, name, byId, style, tier);
|
||||||
|
for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code);
|
||||||
|
appendLine(room.id, { kind: "standIn", name, by: byId, style, tier, at: new Date().toISOString() });
|
||||||
|
return { event };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The table as the keeper sees it before re-seating: who sits where, and
|
||||||
|
* when each person last moved or spoke. */
|
||||||
|
export function seatsForKeeper(room: Room): { name: PlayerId; host: boolean; bot: boolean; lastActed: string | null }[] {
|
||||||
|
const last = new Map<PlayerId, string>();
|
||||||
|
for (const c of room.log) last.set(c.playerId, c.at);
|
||||||
|
for (const c of room.chat) if ((last.get(c.player) ?? "") < c.at) last.set(c.player, c.at);
|
||||||
|
return room.players.map((name) => {
|
||||||
|
const bot = room.bots.has(name) && !room.bots.get(name)!.standIn;
|
||||||
|
return { name, host: name === room.hostId, bot, lastActed: bot ? null : (last.get(name) ?? null) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** The host dissolves the room: a lobby that never dealt, or a finished
|
/** The host dissolves the room: a lobby that never dealt, or a finished
|
||||||
* game done being remembered. The ledger is archived, never deleted. */
|
* game done being remembered. The ledger is archived, never deleted. */
|
||||||
export function abandonRoom(room: Room, byId: PlayerId): string | null {
|
export function abandonRoom(room: Room, byId: PlayerId): string | null {
|
||||||
@@ -745,7 +820,9 @@ const TRANSFER_WORDS = [
|
|||||||
interface PendingTransfer {
|
interface PendingTransfer {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
name: PlayerId;
|
name: PlayerId;
|
||||||
token: string;
|
/** The seat's own token, from its holder; null for a phrase the host
|
||||||
|
* made, which re-seats on its claim. */
|
||||||
|
token: string | null;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,7 +835,23 @@ export function makeTransferCode(
|
|||||||
rawToken: string | null,
|
rawToken: string | null,
|
||||||
): { code: string; expiresAt: number } | { error: string } {
|
): { code: string; expiresAt: number } | { error: string } {
|
||||||
if (!tokenMatches(room, playerId, rawToken)) return { error: "you hold no seat in this room" };
|
if (!tokenMatches(room, playerId, rawToken)) return { error: "you hold no seat in this room" };
|
||||||
const token = rawToken!;
|
return mintPhrase(room.id, playerId, rawToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host makes a phrase for a person's seat, for a player whose browser
|
||||||
|
* lost it or who wants it back from the clockwork. Claiming it re-seats:
|
||||||
|
* the seat gets a new token and the old one opens nothing after. */
|
||||||
|
export function makeSeatPhrase(
|
||||||
|
room: Room,
|
||||||
|
byId: PlayerId,
|
||||||
|
name: PlayerId,
|
||||||
|
): { code: string; expiresAt: number } | { error: string } {
|
||||||
|
if (byId !== room.hostId) return { error: "only the host may make a phrase for another seat" };
|
||||||
|
if (!room.players.includes(name) || !personSeat(room, name)) return { error: "no person sits in that seat" };
|
||||||
|
return mintPhrase(room.id, name, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mintPhrase(roomId: string, name: PlayerId, token: string | null): { code: string; expiresAt: number } | { error: string } {
|
||||||
// Sweep expired codes while we are here.
|
// Sweep expired codes while we are here.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [code, t] of transfers) {
|
for (const [code, t] of transfers) {
|
||||||
@@ -770,7 +863,7 @@ export function makeTransferCode(
|
|||||||
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
|
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
|
||||||
} while (transfers.has(code));
|
} while (transfers.has(code));
|
||||||
const expiresAt = now + TRANSFER_TTL_MS;
|
const expiresAt = now + TRANSFER_TTL_MS;
|
||||||
transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt });
|
transfers.set(code, { roomId, name, token, expiresAt });
|
||||||
return { code, expiresAt };
|
return { code, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,7 +874,9 @@ let failWindowStart = 0;
|
|||||||
const FAIL_WINDOW_MS = 10 * 60 * 1000;
|
const FAIL_WINDOW_MS = 10 * 60 * 1000;
|
||||||
const FAIL_LIMIT = 30;
|
const FAIL_LIMIT = 30;
|
||||||
|
|
||||||
export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } {
|
export function claimTransferCode(
|
||||||
|
code: string,
|
||||||
|
): { roomId: string; name: PlayerId; token: string; reseated: boolean; returned?: GameEvent } | { error: string } {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - failWindowStart > FAIL_WINDOW_MS) {
|
if (now - failWindowStart > FAIL_WINDOW_MS) {
|
||||||
failWindowStart = now;
|
failWindowStart = now;
|
||||||
@@ -799,8 +894,14 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
|
|||||||
}
|
}
|
||||||
transfers.delete(normalized); // one-time
|
transfers.delete(normalized); // one-time
|
||||||
const room = rooms.get(t.roomId);
|
const room = rooms.get(t.roomId);
|
||||||
if (!room || !tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" };
|
if (!room) return { error: "that seat no longer exists" };
|
||||||
return { roomId: t.roomId, name: t.name, token: t.token };
|
if (t.token === null) {
|
||||||
|
const fresh = reseat(room, t.name);
|
||||||
|
if ("error" in fresh) return { error: "that seat no longer exists" };
|
||||||
|
return { roomId: t.roomId, name: t.name, token: fresh.token, reseated: true, ...(fresh.returned ? { returned: fresh.returned } : {}) };
|
||||||
|
}
|
||||||
|
if (!tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" };
|
||||||
|
return { roomId: t.roomId, name: t.name, token: t.token, reseated: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild one room from its ledger lines — the seed plus the log IS the
|
/** Rebuild one room from its ledger lines — the seed plus the log IS the
|
||||||
@@ -835,6 +936,10 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
|||||||
room.tokens.delete(line.name);
|
room.tokens.delete(line.name);
|
||||||
room.bots.delete(line.name);
|
room.bots.delete(line.name);
|
||||||
room.colorChoices.delete(line.name);
|
room.colorChoices.delete(line.name);
|
||||||
|
} else if (line.kind === "reseat") {
|
||||||
|
if (personSeat(room, line.name)) seatToPerson(room, line.name, line.tokenHash);
|
||||||
|
} else if (line.kind === "standIn") {
|
||||||
|
seatToClockwork(room, line.name, line.by, line.style as AutomatonStyle, line.tier as AutomatonTier);
|
||||||
} else if (line.kind === "join") {
|
} else if (line.kind === "join") {
|
||||||
if (line.bot) {
|
if (line.bot) {
|
||||||
room.players.push(line.name);
|
room.players.push(line.name);
|
||||||
|
|||||||
@@ -93,13 +93,35 @@ export interface KickLine {
|
|||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A person's seat got a new token, from the keeper or from a phrase the
|
||||||
|
* host made, and the old one opens nothing after. A seat the clockwork was
|
||||||
|
* standing in for goes back to a person. */
|
||||||
|
export interface ReseatLine {
|
||||||
|
kind: "reseat";
|
||||||
|
name: string;
|
||||||
|
tokenHash: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host gave a person's seat to the clockwork in a game under way: the
|
||||||
|
* wizard keeps their hand and place, the automaton plays their turns, and
|
||||||
|
* the person's token opens nothing until a reseat gives the seat back. */
|
||||||
|
export interface StandInLine {
|
||||||
|
kind: "standIn";
|
||||||
|
name: string;
|
||||||
|
by: string;
|
||||||
|
style: string;
|
||||||
|
tier: string;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AbandonLine {
|
export interface AbandonLine {
|
||||||
kind: "abandon";
|
kind: "abandon";
|
||||||
by: string;
|
by: string;
|
||||||
at: string;
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
|
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | StandInLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine;
|
||||||
|
|
||||||
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// The store reads its directory when first imported, so the rooms module is
|
||||||
|
// loaded only after the ledgers have somewhere of their own to go.
|
||||||
|
const scratch = mkdtempSync(join(tmpdir(), "wizwar-keeper-"));
|
||||||
|
process.env.WIZWAR_DATA_DIR = join(scratch, "rooms");
|
||||||
|
const KEY_FILE = join(scratch, "keeper.key");
|
||||||
|
const KEY = "a".repeat(64);
|
||||||
|
|
||||||
|
let rooms: typeof import("../src/rooms");
|
||||||
|
let keeper: typeof import("../src/keeper");
|
||||||
|
beforeAll(async () => {
|
||||||
|
rooms = await import("../src/rooms");
|
||||||
|
rooms.loadPersistedRooms();
|
||||||
|
keeper = await import("../src/keeper");
|
||||||
|
});
|
||||||
|
|
||||||
|
function table() {
|
||||||
|
const { room, token: ann } = rooms.createRoom("Ann");
|
||||||
|
const joined = rooms.joinRoom(room, "Bo", null);
|
||||||
|
if ("error" in joined) throw new Error(joined.error);
|
||||||
|
const bot = rooms.addAutomaton(room);
|
||||||
|
if ("error" in bot) throw new Error(bot.error);
|
||||||
|
return { room, ann, bo: joined.token, bot: bot.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(result: { token: string } | { error: string }): string {
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
return result.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the keeper's reseat", () => {
|
||||||
|
it("gives a person's seat a new token: the old one is refused and the new one opens the seat", () => {
|
||||||
|
const { room, bo } = table();
|
||||||
|
const fresh = token(rooms.reseat(room, "Bo"));
|
||||||
|
expect(fresh).not.toBe(bo);
|
||||||
|
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" });
|
||||||
|
expect(rooms.joinRoom(room, "Bo", fresh)).toEqual({ token: fresh });
|
||||||
|
expect(rooms.peekSummary(room.id, "Bo", bo)).toBe("badToken");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-seats the host the same way", () => {
|
||||||
|
const { room, ann } = table();
|
||||||
|
const fresh = token(rooms.reseat(room, "Ann"));
|
||||||
|
expect("error" in rooms.joinRoom(room, "Ann", ann)).toBe(true);
|
||||||
|
expect(rooms.joinRoom(room, "Ann", fresh)).toEqual({ token: fresh });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("voids a transfer phrase minted with the old token", () => {
|
||||||
|
const { room, bo } = table();
|
||||||
|
const minted = rooms.makeTransferCode(room, "Bo", bo);
|
||||||
|
if ("error" in minted) throw new Error(minted.error);
|
||||||
|
rooms.reseat(room, "Bo");
|
||||||
|
expect("error" in rooms.claimTransferCode(minted.code)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the new token across a reload from the ledger", async () => {
|
||||||
|
const { room, ann, bo } = table();
|
||||||
|
const fresh = token(rooms.reseat(room, "Bo"));
|
||||||
|
vi.resetModules();
|
||||||
|
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
|
||||||
|
const again = reloaded.getRoom(room.id);
|
||||||
|
expect(again).toBeDefined();
|
||||||
|
expect(again).not.toBe(room);
|
||||||
|
expect(reloaded.joinRoom(again!, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" });
|
||||||
|
expect(reloaded.joinRoom(again!, "Bo", fresh)).toEqual({ token: fresh });
|
||||||
|
expect(reloaded.joinRoom(again!, "Ann", ann)).toEqual({ token: ann });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-seats only a person who sits at the table", () => {
|
||||||
|
const { room, bot } = table();
|
||||||
|
expect(rooms.reseat(room, bot)).toEqual({ error: "no person sits in that seat" });
|
||||||
|
expect(rooms.reseat(room, "Cy")).toEqual({ error: "no person sits in that seat" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the keeper who sits where, and when each person last moved or spoke", () => {
|
||||||
|
const { room, bot } = table();
|
||||||
|
rooms.addChat(room, "Bo", "good luck");
|
||||||
|
const seats = rooms.seatsForKeeper(room);
|
||||||
|
expect(seats.map((s) => [s.name, s.bot, s.host])).toEqual([["Ann", false, true], ["Bo", false, false], [bot, true, false]]);
|
||||||
|
expect(seats[0]!.lastActed).toBeNull();
|
||||||
|
expect(seats[1]!.lastActed).toMatch(/^\d{4}-/);
|
||||||
|
expect(seats[2]!.lastActed).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the keeper's door", () => {
|
||||||
|
let door: import("node:http").Server;
|
||||||
|
let base: string;
|
||||||
|
const knock = (path: string, init: RequestInit = {}) =>
|
||||||
|
fetch(`${base}${path}`, { ...init, headers: { "x-keeper-key": KEY, ...(init.headers as Record<string, string>) } });
|
||||||
|
beforeAll(async () => {
|
||||||
|
writeFileSync(KEY_FILE, KEY + "\n");
|
||||||
|
door = keeper.openKeeperDoor({ port: 0, publicUrl: "https://wizwar.example", keyFile: KEY_FILE, onReseat: () => {} });
|
||||||
|
await once(door, "listening");
|
||||||
|
const address = door.address() as AddressInfo;
|
||||||
|
base = `http://127.0.0.1:${address.port}`;
|
||||||
|
});
|
||||||
|
afterAll(() => door.close());
|
||||||
|
|
||||||
|
it("listens on the loopback alone", () => {
|
||||||
|
expect((door.address() as AddressInfo).address).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a table's seats and re-seats one as a link carrying the token in the fragment", async () => {
|
||||||
|
const { room, bo } = table();
|
||||||
|
const listed = await (await knock(`/keeper/rooms/${room.id}`)).json();
|
||||||
|
expect(listed.seats.map((s: { name: string }) => s.name)).toContain("Bo");
|
||||||
|
const res = await knock(`/keeper/rooms/${room.id.toLowerCase()}/reseat`, {
|
||||||
|
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ seat: "Bo" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const { name, link } = await res.json();
|
||||||
|
expect(name).toBe("Bo");
|
||||||
|
const m = new RegExp(`^https://wizwar\\.example/join/${room.id}#seat=Bo\\.([0-9a-f]{32})$`).exec(link);
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true);
|
||||||
|
expect(rooms.joinRoom(room, "Bo", m![1]!)).toEqual({ token: m![1] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns away a forwarded request, an unknown room and a bot", async () => {
|
||||||
|
const { room, bot } = table();
|
||||||
|
expect((await knock(`/keeper/rooms/${room.id}`, { headers: { "x-forwarded-for": "203.0.113.9" } })).status).toBe(404);
|
||||||
|
expect((await knock(`/keeper/rooms/${room.id}X`)).status).toBe(404);
|
||||||
|
const res = await knock(`/keeper/rooms/${room.id}/reseat`, { method: "POST", body: JSON.stringify({ seat: bot }) });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect((await res.json()).error).toBe("no person sits in that seat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens only to the keeper's key, and stays shut while the key file is absent", async () => {
|
||||||
|
const { room, bo } = table();
|
||||||
|
const reseatBo = (headers: Record<string, string>) =>
|
||||||
|
fetch(`${base}/keeper/rooms/${room.id}/reseat`, { method: "POST", headers, body: JSON.stringify({ seat: "Bo" }) });
|
||||||
|
expect((await reseatBo({})).status).toBe(404);
|
||||||
|
expect((await reseatBo({ "x-keeper-key": "b".repeat(64) })).status).toBe(404);
|
||||||
|
expect((await reseatBo({ "x-keeper-key": KEY.slice(1) })).status).toBe(404);
|
||||||
|
rmSync(KEY_FILE);
|
||||||
|
try {
|
||||||
|
expect((await reseatBo({ "x-keeper-key": KEY })).status).toBe(404);
|
||||||
|
expect((await reseatBo({ "x-keeper-key": "" })).status).toBe(404);
|
||||||
|
} finally {
|
||||||
|
writeFileSync(KEY_FILE, KEY + "\n");
|
||||||
|
}
|
||||||
|
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ token: bo });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// The store reads its directory when first imported, so the rooms module is
|
||||||
|
// loaded only after the ledgers have somewhere of their own to go.
|
||||||
|
process.env.WIZWAR_DATA_DIR = join(mkdtempSync(join(tmpdir(), "wizwar-seats-")), "rooms");
|
||||||
|
|
||||||
|
let rooms: typeof import("../src/rooms");
|
||||||
|
beforeAll(async () => {
|
||||||
|
rooms = await import("../src/rooms");
|
||||||
|
rooms.loadPersistedRooms();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ann hosts, Bo and Cy sit, and the boards have flipped. */
|
||||||
|
function playing() {
|
||||||
|
const { room, token: ann } = rooms.createRoom("Ann");
|
||||||
|
const bo = token(rooms.joinRoom(room, "Bo", null));
|
||||||
|
const cy = token(rooms.joinRoom(room, "Cy", null));
|
||||||
|
const started = rooms.startGame(room, false);
|
||||||
|
if ("error" in started) throw new Error(started.error);
|
||||||
|
return { room, ann, bo, cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function token(result: { token: string } | { error: string }): string {
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
return result.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phrase(result: { code: string } | { error: string }): string {
|
||||||
|
if ("error" in result) throw new Error(result.error);
|
||||||
|
return result.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a stand-in", () => {
|
||||||
|
it("plays a person's seat the host gives it, and the person's token opens nothing", () => {
|
||||||
|
const { room, bo } = playing();
|
||||||
|
expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ event: { type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" } });
|
||||||
|
expect(room.bots.get("Bo")).toMatchObject({ standIn: true, secret: true });
|
||||||
|
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "the host gave this seat to the clockwork; ask them for a phrase to take it back" });
|
||||||
|
expect(room.players).toContain("Bo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the turn when the seat it stands in for holds it, and leaves the host's to the host", () => {
|
||||||
|
const { room } = playing();
|
||||||
|
rooms.standIn(room, "Ann", "Bo");
|
||||||
|
rooms.standIn(room, "Ann", "Cy");
|
||||||
|
const holder = room.state!.players[room.state!.turn.activeIndex]!.id;
|
||||||
|
expect(rooms.driveOneAutomaton(room)?.seat ?? null).toBe(holder === "Ann" ? null : holder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the host's call alone, in a game under way, for a person still in it", () => {
|
||||||
|
const { room } = playing();
|
||||||
|
expect(rooms.standIn(room, "Bo", "Cy")).toEqual({ error: "only the host may give a seat to the clockwork" });
|
||||||
|
expect(rooms.standIn(room, "Ann", "Ann")).toEqual({ error: "the host keeps their own seat" });
|
||||||
|
expect(rooms.standIn(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" });
|
||||||
|
rooms.standIn(room, "Ann", "Bo");
|
||||||
|
expect(rooms.standIn(room, "Ann", "Bo")).toEqual({ error: "no person sits in that seat" });
|
||||||
|
|
||||||
|
const { room: lobby } = rooms.createRoom("Ann");
|
||||||
|
rooms.joinRoom(lobby, "Bo", null);
|
||||||
|
expect(rooms.standIn(lobby, "Ann", "Bo")).toEqual({ error: "only a game under way needs a stand-in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is still standing in after a reload from the ledger", async () => {
|
||||||
|
const { room, bo } = playing();
|
||||||
|
rooms.standIn(room, "Ann", "Bo");
|
||||||
|
vi.resetModules();
|
||||||
|
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
|
||||||
|
const again = reloaded.getRoom(room.id)!;
|
||||||
|
expect(again).not.toBe(room);
|
||||||
|
expect(again.bots.get("Bo")).toEqual(room.bots.get("Bo"));
|
||||||
|
expect(again.events.filter((e) => e.type === "seatChanged")).toEqual([{ type: "seatChanged", player: "Bo", to: "clockwork", by: "Ann" }]);
|
||||||
|
expect("error" in reloaded.joinRoom(again, "Bo", bo)).toBe(true);
|
||||||
|
});
|
||||||
|
it("holds the person's seat at a rematch, and brings no automaton for it", () => {
|
||||||
|
const { room } = playing();
|
||||||
|
rooms.standIn(room, "Ann", "Bo");
|
||||||
|
room.state = { ...room.state!, phase: "finished" };
|
||||||
|
const called = rooms.callRematch(room, "Ann");
|
||||||
|
if ("error" in called) throw new Error(called.error);
|
||||||
|
const next = rooms.getRoom(called.roomId)!;
|
||||||
|
expect(next.expected).toEqual(["Bo", "Cy"]);
|
||||||
|
expect(next.bots.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a phrase the host makes", () => {
|
||||||
|
it("re-seats a person whose browser lost the seat: the old token is refused, the new one opens it", () => {
|
||||||
|
const { room, bo } = playing();
|
||||||
|
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
||||||
|
if ("error" in claimed) throw new Error(claimed.error);
|
||||||
|
expect(claimed).toMatchObject({ roomId: room.id, name: "Bo", reseated: true });
|
||||||
|
expect(claimed.token).not.toBe(bo);
|
||||||
|
expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true);
|
||||||
|
expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a seat back from the clockwork", () => {
|
||||||
|
const { room } = playing();
|
||||||
|
rooms.standIn(room, "Ann", "Bo");
|
||||||
|
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
||||||
|
if ("error" in claimed) throw new Error(claimed.error);
|
||||||
|
expect(room.bots.has("Bo")).toBe(false);
|
||||||
|
expect(claimed.returned).toEqual({ type: "seatChanged", player: "Bo", to: "person" });
|
||||||
|
expect(rooms.joinRoom(room, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the seat a person's again after a reload from the ledger", async () => {
|
||||||
|
const { room } = playing();
|
||||||
|
rooms.standIn(room, "Ann", "Bo");
|
||||||
|
const claimed = rooms.claimTransferCode(phrase(rooms.makeSeatPhrase(room, "Ann", "Bo")));
|
||||||
|
if ("error" in claimed) throw new Error(claimed.error);
|
||||||
|
vi.resetModules();
|
||||||
|
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
|
||||||
|
const again = reloaded.getRoom(room.id)!;
|
||||||
|
expect(again.bots.has("Bo")).toBe(false);
|
||||||
|
expect(again.events.filter((e) => e.type === "seatChanged").map((e) => (e as { to: string }).to)).toEqual(["clockwork", "person"]);
|
||||||
|
expect(reloaded.joinRoom(again, "Bo", claimed.token)).toEqual({ token: claimed.token });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is the host's to make, and only for a person's seat", () => {
|
||||||
|
const { room } = playing();
|
||||||
|
expect(rooms.makeSeatPhrase(room, "Bo", "Cy")).toEqual({ error: "only the host may make a phrase for another seat" });
|
||||||
|
expect(rooms.makeSeatPhrase(room, "Ann", "Dee")).toEqual({ error: "no person sits in that seat" });
|
||||||
|
const { room: withBot } = rooms.createRoom("Ann");
|
||||||
|
const bot = rooms.addAutomaton(withBot);
|
||||||
|
if ("error" in bot) throw new Error(bot.error);
|
||||||
|
expect(rooms.makeSeatPhrase(withBot, "Ann", bot.name)).toEqual({ error: "no person sits in that seat" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works once", () => {
|
||||||
|
const { room } = playing();
|
||||||
|
const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Cy"));
|
||||||
|
expect("error" in rooms.claimTransferCode(code)).toBe(false);
|
||||||
|
expect(rooms.claimTransferCode(code)).toEqual({ error: "that transfer phrase is unknown or has expired" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is voided when the host gives the seat to the clockwork after making it", () => {
|
||||||
|
const { room } = playing();
|
||||||
|
const code = phrase(rooms.makeSeatPhrase(room, "Ann", "Bo"));
|
||||||
|
rooms.standIn(room, "Ann", "Bo");
|
||||||
|
expect("error" in rooms.claimTransferCode(code)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a player's own transfer phrase", () => {
|
||||||
|
it("still hands over the same token, without re-seating", () => {
|
||||||
|
const { room, bo } = playing();
|
||||||
|
const claimed = rooms.claimTransferCode(phrase(rooms.makeTransferCode(room, "Bo", bo)));
|
||||||
|
expect(claimed).toEqual({ roomId: room.id, name: "Bo", token: bo, reseated: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,5 +4,5 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "test"]
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"slug": "waving-hands",
|
"slug": "waving-hands",
|
||||||
"name": "Waving Hands",
|
"name": "Waving Hands",
|
||||||
"url": "https://hands.kestrelsnest.social",
|
"url": "https://hands.kestrelsnest.social",
|
||||||
"pitch": "Duelling wizards. Two hands each. The right run of gestures is a spell.",
|
"pitch": "Wizards duel with their hands, and the right run of gestures is a spell.",
|
||||||
"players": "2 to 12",
|
"players": "2 to 12",
|
||||||
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
|
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
|
||||||
},
|
},
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"slug": "hnefatafl",
|
"slug": "hnefatafl",
|
||||||
"name": "Hnefatafl",
|
"name": "Hnefatafl",
|
||||||
"url": "https://tafl.kestrelsnest.social",
|
"url": "https://tafl.kestrelsnest.social",
|
||||||
"pitch": "The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.",
|
"pitch": "The Viking board game of a king breaking for safety and a ring of attackers closing in.",
|
||||||
"players": "2",
|
"players": "2",
|
||||||
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
|
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
|
||||||
},
|
},
|
||||||
@@ -30,9 +30,17 @@
|
|||||||
"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 10, depending on the map",
|
||||||
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "wyrm",
|
||||||
|
"name": "Wyrm",
|
||||||
|
"url": "https://wyrm.kestrelsnest.social",
|
||||||
|
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
|
||||||
|
"players": "2",
|
||||||
|
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+167
-17
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { motion } from "./motion";
|
import { motion } from "./motion";
|
||||||
import { attentionLabel, net, SIDE_NAMES, spellName } from "./net.svelte";
|
import { attentionLabel, net, seatInLink, SIDE_NAMES, spellName } from "./net.svelte";
|
||||||
import { MediaQuery } from "svelte/reactivity";
|
import { MediaQuery } from "svelte/reactivity";
|
||||||
import { FISTS } from "./fpv/paintedFx";
|
import { FISTS } from "./fpv/paintedFx";
|
||||||
import Board from "./Board.svelte";
|
import Board from "./Board.svelte";
|
||||||
@@ -32,19 +32,40 @@
|
|||||||
const hall = $derived(kin.hall);
|
const hall = $derived(kin.hall);
|
||||||
let joinCode = $state("");
|
let joinCode = $state("");
|
||||||
let claimPhrase = $state("");
|
let claimPhrase = $state("");
|
||||||
|
/** The host's seats panel: hand a seat to the clockwork, or make a transfer phrase for it. */
|
||||||
|
let seatsOpen = $state(false);
|
||||||
|
/** The seat the host asked to give the clockwork, waiting on a second click. */
|
||||||
|
let standInAsked = $state<string | null>(null);
|
||||||
|
/** The people's seats besides the host's own: the clockwork the host seated is not among them. */
|
||||||
|
const peopleSeats = $derived(net.players.filter((p) => p !== net.you && (!net.roomBots[p] || net.standIns.includes(p))));
|
||||||
/** A /join/<CODE> recruiting link. The visitor lands looking at the
|
/** A /join/<CODE> recruiting link. The visitor lands looking at the
|
||||||
* room: a held seat walks them back to it, a waiting room offers a
|
* room: a held seat walks them back to it, a waiting room offers a
|
||||||
* seat or the gallery, a started game is watched from the gallery. */
|
* seat or the gallery, a started game is watched from the gallery. */
|
||||||
const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
|
const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
|
||||||
if (inviteCode) joinCode = inviteCode;
|
if (inviteCode) joinCode = inviteCode;
|
||||||
|
/** A link from the keeper carries a seat in its fragment. It leaves the
|
||||||
|
* address bar at once, is tried only when no seat held here still opens,
|
||||||
|
* and is kept only once the server takes it, so a link someone else
|
||||||
|
* sends cannot take a working seat away. */
|
||||||
|
const linkedSeat = inviteCode ? seatInLink(location.hash, inviteCode) : null;
|
||||||
|
if (location.hash.startsWith("#seat=")) history.replaceState(history.state, "", location.pathname + location.search);
|
||||||
let inviteFollowed = false;
|
let inviteFollowed = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
|
if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
|
||||||
inviteFollowed = true;
|
inviteFollowed = true;
|
||||||
const seat = net.seats.find((s) => s.roomId === inviteCode);
|
const code = inviteCode;
|
||||||
if (seat) net.resume(seat);
|
const fromLink = linkedSeat && (() => net.resume(linkedSeat, () => {
|
||||||
else net.watch(inviteCode);
|
net.watch(code);
|
||||||
|
net.flash("The keeper's link no longer opens that seat. Write again for a fresh one.");
|
||||||
|
}));
|
||||||
|
const seat = net.seats.find((s) => s.roomId === code);
|
||||||
|
if (seat) net.resume(seat, fromLink || undefined);
|
||||||
|
else if (fromLink) fromLink();
|
||||||
|
else net.watch(code);
|
||||||
});
|
});
|
||||||
|
/** A game not yet finished asks before it is forgotten: this browser may
|
||||||
|
* be all that holds the seat. */
|
||||||
|
let forgetting = $state<string | null>(null);
|
||||||
/** The address bar holds the table's invite link while you sit at one,
|
/** The address bar holds the table's invite link while you sit at one,
|
||||||
* so recruiting is a copy away. */
|
* so recruiting is a copy away. */
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -405,6 +426,17 @@
|
|||||||
view !== null && view.phase === "playing" && view.activePlayerId === view.you && !view.stack,
|
view !== null && view.phase === "playing" && view.activePlayerId === view.you && !view.stack,
|
||||||
);
|
);
|
||||||
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
|
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
|
||||||
|
/** Another wizard's turn, seen from a phone: the rail that says so sits
|
||||||
|
* below the fold, and a board moving on its own with no word reads as a
|
||||||
|
* game running without you. Names the wizard, and whether you're next. */
|
||||||
|
const theirTurnCaption = $derived.by(() => {
|
||||||
|
if (wideScreen || !view || !view.you || net.spectating || local.active || openingRolls) return null;
|
||||||
|
if (view.phase !== "playing" || view.activePlayerId === view.you) return null;
|
||||||
|
const alive = view.players.filter((p) => p.alive || p.id === view!.activePlayerId);
|
||||||
|
const at = alive.findIndex((p) => p.id === view!.activePlayerId);
|
||||||
|
const next = alive.length ? alive[(at + 1) % alive.length]!.id : null;
|
||||||
|
return `${view.activePlayerId}'s turn${next === view.you ? " — yours is next" : ""}.`;
|
||||||
|
});
|
||||||
/** An interruption window (INTERRUPT / OPPORTUNITY FIRE) is your moment:
|
/** An interruption window (INTERRUPT / OPPORTUNITY FIRE) is your moment:
|
||||||
* casting and targeting open up as if the turn were yours. */
|
* casting and targeting open up as if the turn were yours. */
|
||||||
const yourMoment = $derived(
|
const yourMoment = $derived(
|
||||||
@@ -660,7 +692,14 @@
|
|||||||
if (selectedDef.los) return "Choose a target · lit squares are in your sight";
|
if (selectedDef.los) return "Choose a target · lit squares are in your sight";
|
||||||
return `${selectedDef.name} · its panel offers the choices`;
|
return `${selectedDef.name} · its panel offers the choices`;
|
||||||
});
|
});
|
||||||
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
/** The spell's power as the engine weighs it: the NUMBER's face, +1 for
|
||||||
|
* a displayed POWERSTONE, doubled per AMPLIFY riding along. WATERBOLT's
|
||||||
|
* split must total exactly this, or the cast is refused. */
|
||||||
|
const numberTotal = $derived.by(() => {
|
||||||
|
const stone = view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "powerstone") ? 1 : 0;
|
||||||
|
const face = attachedNumber ? cardDef(attachedNumber.cardId).value! + stone : 1;
|
||||||
|
return face * 2 ** attachedMods.filter((m) => m.cardId === "amplify").length;
|
||||||
|
});
|
||||||
|
|
||||||
function dispatch(command: Parameters<typeof net.command>[0]) {
|
function dispatch(command: Parameters<typeof net.command>[0]) {
|
||||||
if (local.active) local.command(command);
|
if (local.active) local.command(command);
|
||||||
@@ -822,6 +861,7 @@
|
|||||||
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
|
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
|
||||||
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
|
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
|
||||||
: [...attachedMods, card];
|
: [...attachedMods, card];
|
||||||
|
wbDamage = numberTotal;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard?.instanceId === card.instanceId) {
|
if (selectedCard?.instanceId === card.instanceId) {
|
||||||
@@ -1340,7 +1380,8 @@
|
|||||||
};
|
};
|
||||||
withMods(cmd);
|
withMods(cmd);
|
||||||
if (selectedCard.cardId === "waterbolt") {
|
if (selectedCard.cardId === "waterbolt") {
|
||||||
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
|
const damage = Math.min(Math.max(0, wbDamage), numberTotal);
|
||||||
|
cmd.params = { damage, knockback: numberTotal - damage };
|
||||||
}
|
}
|
||||||
if (NAMED_CARDS.has(selectedCard.cardId)) {
|
if (NAMED_CARDS.has(selectedCard.cardId)) {
|
||||||
const id = nameToCardId(nameInput);
|
const id = nameToCardId(nameInput);
|
||||||
@@ -1849,12 +1890,17 @@
|
|||||||
return bent ? bentSightedCellsFor(view, castingDouble?.position) : sightedCellsFor(view, castingDouble?.position);
|
return bent ? bentSightedCellsFor(view, castingDouble?.position) : sightedCellsFor(view, castingDouble?.position);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The creatures that strike on command; the rest (a fire imp, a wraith)
|
||||||
|
* pick their own victims, as the engine's creatureAttack holds. */
|
||||||
|
const ORDERED_ATTACKERS = new Set(["troll", "skeleton", "shadow"]);
|
||||||
|
const attackReady = (c: { kind: string; attackUsed: boolean; justCreated?: boolean }) =>
|
||||||
|
ORDERED_ATTACKERS.has(c.kind) && !c.attackUsed && !c.justCreated;
|
||||||
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
|
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
|
||||||
const idleCreatures = $derived(
|
const idleCreatures = $derived(
|
||||||
view != null && isYourTurn
|
view != null && isYourTurn
|
||||||
? view.creatures.filter((c) =>
|
? view.creatures.filter((c) =>
|
||||||
(c.controllerId === view!.you || c.kind === "democratic-monster") &&
|
(c.controllerId === view!.you || c.kind === "democratic-monster") &&
|
||||||
(c.movementUsed < c.movesPerTurn || (!c.attackUsed && !c.justCreated)))
|
(c.movementUsed < c.movesPerTurn || attackReady(c)))
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2004,6 +2050,10 @@
|
|||||||
{#if !net.spectating}
|
{#if !net.spectating}
|
||||||
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if !net.spectating && net.you === net.hostId && peopleSeats.length > 0}
|
||||||
|
<button class="mast-leave" title="Make a phrase that brings a wizard back to their seat, or let the clockwork play for someone who cannot."
|
||||||
|
onclick={() => { seatsOpen = !seatsOpen; standInAsked = null; }}>seats</button>
|
||||||
|
{/if}
|
||||||
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
|
<button class="mast-leave" onclick={() => net.leave()}>{net.spectating ? "leave the gallery" : "leave table"}</button>
|
||||||
<button class="mast-leave" title="Something behaved unexpectedly? Tell the keeper."
|
<button class="mast-leave" title="Something behaved unexpectedly? Tell the keeper."
|
||||||
onclick={() => (feedbackOpen = true)}>🐞 report</button>
|
onclick={() => (feedbackOpen = true)}>🐞 report</button>
|
||||||
@@ -2118,6 +2168,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if youMustShield && view?.chaosPending}
|
||||||
|
{@const shield = view.yourHand.find((c) => c.cardId === "full-shield")}
|
||||||
|
<div class="scrim attack-scrim" role="alertdialog" aria-label="chaos">
|
||||||
|
<div class="attack-notice">
|
||||||
|
<div class="attack-title">{view.chaosPending.casterId} casts Chaos!</div>
|
||||||
|
<div class="attack-power">
|
||||||
|
Every hand goes into one pile and is dealt back at random, each
|
||||||
|
wizard getting as many cards as they held.
|
||||||
|
{shield ? "Your Full Shield keeps your hand out of it." : "You hold no Full Shield to sit it out."}
|
||||||
|
</div>
|
||||||
|
<div class="attack-actions">
|
||||||
|
{#if shield}
|
||||||
|
<button class="stamp primary" onclick={() => dispatch({ type: "counteract", instanceId: shield.instanceId })}>Raise the Full Shield</button>
|
||||||
|
{/if}
|
||||||
|
<button class={shield ? "hint-cancel" : "stamp primary"} onclick={pass}>let it take you</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if megaPrompt && view}
|
{#if megaPrompt && view}
|
||||||
{@const beast = view.creatures.find((c) => c.id === megaPrompt!.creatureId)}
|
{@const beast = view.creatures.find((c) => c.id === megaPrompt!.creatureId)}
|
||||||
<div class="scrim attack-scrim" role="alertdialog" aria-label="mega-monster">
|
<div class="scrim attack-scrim" role="alertdialog" aria-label="mega-monster">
|
||||||
@@ -2537,6 +2607,41 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if seatsOpen && net.you === net.hostId && !net.spectating}
|
||||||
|
<div class="slip seats-slip">
|
||||||
|
<div class="seats-head">
|
||||||
|
<span>Seats at your table</span>
|
||||||
|
<button class="hint-cancel" onclick={() => { seatsOpen = false; standInAsked = null; }}>close</button>
|
||||||
|
</div>
|
||||||
|
{#each peopleSeats as p (p)}
|
||||||
|
{@const standing = net.standIns.includes(p)}
|
||||||
|
<div class="seats-row">
|
||||||
|
<span class="seats-name">{p}{#if standing} <span class="seats-note">⚙ the clockwork plays for them</span>{/if}</span>
|
||||||
|
<button class="stamp tiny"
|
||||||
|
title={`${standing ? `Brings ${p} back from the clockwork.` : `For when ${p}'s browser lost the seat, or they need to play from another device.`} You get four words to pass to them. Within ten minutes, they enter them on the front page, or in the claim box while watching this table, and sit back down as ${p}. The phrase works once, and the browser they used before stops opening the seat.`}
|
||||||
|
onclick={() => net.makeSeatPhrase(p)}>{standing ? "transfer phrase to take it back" : "make a transfer phrase"}</button>
|
||||||
|
{#if !standing && net.started && view?.phase === "playing" && view.players.find((w) => w.id === p)?.alive}
|
||||||
|
{#if standInAsked === p}
|
||||||
|
<button class="stamp tiny primary" onclick={() => { net.standIn(p); standInAsked = null; }}>yes, the clockwork plays for {p}</button>
|
||||||
|
<button class="hint-cancel" onclick={() => (standInAsked = null)}>no</button>
|
||||||
|
{:else}
|
||||||
|
<button class="stamp tiny" title="{p} keeps their hand, treasures and place, and an automaton takes their turns until you make them a transfer phrase to take it back."
|
||||||
|
onclick={() => (standInAsked = p)}>give to the clockwork</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if net.seatPhrase}
|
||||||
|
<div class="slip transfer-slip">
|
||||||
|
Give {net.seatPhrase.name} this phrase to claim within ten minutes, on the front page or while watching this table. It works once, and their old link stops opening the seat.
|
||||||
|
<strong class="transfer-phrase">{net.seatPhrase.code}</strong>
|
||||||
|
<button class="hint-cancel" onclick={() => (net.seatPhrase = null)}>dismiss</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if local.setup}
|
{#if local.setup}
|
||||||
<div class="scrim-handoff">
|
<div class="scrim-handoff">
|
||||||
<div class="handoff-card">
|
<div class="handoff-card">
|
||||||
@@ -2701,8 +2806,15 @@
|
|||||||
<button class="stamp tiny" title="take your seat at the rematch"
|
<button class="stamp tiny" title="take your seat at the rematch"
|
||||||
onclick={() => { net.you = seat.name; net.spectating = false; net.join(g.rematch!.roomId, seat.name); }}>rematch</button>
|
onclick={() => { net.you = seat.name; net.spectating = false; net.join(g.rematch!.roomId, seat.name); }}>rematch</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if forgetting === seat.roomId}
|
||||||
|
<span class="forget-ask">This browser may be all that holds your seat.
|
||||||
|
<button class="hint-cancel" onclick={() => { forgetting = null; net.forgetSeat(seat.roomId); }}>forget it</button>
|
||||||
|
<button class="hint-cancel" onclick={() => (forgetting = null)}>keep it</button>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
<button class="ledger-forget" title="forget this game"
|
<button class="ledger-forget" title="forget this game"
|
||||||
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
|
onclick={() => { if (g?.finished) net.forgetSeat(seat.roomId); else forgetting = seat.roomId; }}>×</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if net.feedbackReports.length > 0}
|
{#if net.feedbackReports.length > 0}
|
||||||
@@ -3157,6 +3269,11 @@
|
|||||||
<span class="step-chip" style:background={playerColor(me.id)}></span>
|
<span class="step-chip" style:background={playerColor(me.id)}></span>
|
||||||
This is you. Tap a lit square beside your wizard to walk.
|
This is you. Tap a lit square beside your wizard to walk.
|
||||||
</div>
|
</div>
|
||||||
|
{:else if theirTurnCaption}
|
||||||
|
<div class="step-caption" aria-live="polite">
|
||||||
|
<span class="step-chip" style:background={playerColor(view.activePlayerId)}></span>
|
||||||
|
{theirTurnCaption}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
|
||||||
{#if prefs.liveFp && view.you && !net.spectating}
|
{#if prefs.liveFp && view.you && !net.spectating}
|
||||||
@@ -3234,7 +3351,7 @@
|
|||||||
{#if view.phase === "finished"}
|
{#if view.phase === "finished"}
|
||||||
<div class="slip winner">🏆 {view.winner} wins!
|
<div class="slip winner">🏆 {view.winner} wins!
|
||||||
{#if !local.active}
|
{#if !local.active}
|
||||||
{@const otherHumans = net.players.some((p) => p !== net.you && !net.roomBots[p])}
|
{@const otherHumans = net.players.some((p) => p !== net.you && (!net.roomBots[p] || net.standIns.includes(p)))}
|
||||||
{#if net.rematchCall}
|
{#if net.rematchCall}
|
||||||
{#if net.rematchCall.by === net.you}
|
{#if net.rematchCall.by === net.you}
|
||||||
<button class="stamp tiny" onclick={() => net.acceptRematch(net.rematchCall!.roomId)}>back to the rematch</button>
|
<button class="stamp tiny" onclick={() => net.acceptRematch(net.rematchCall!.roomId)}>back to the rematch</button>
|
||||||
@@ -3346,19 +3463,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if youMustShield}
|
{#if view.chaosPending && !youMustShield}
|
||||||
<div class="slip urgent">
|
|
||||||
Chaos comes for your hand. Tap your Full Shield to sit out, or
|
|
||||||
<button class="stamp tiny" onclick={pass}>let it take you</button>
|
|
||||||
</div>
|
|
||||||
{:else if view.chaosPending}
|
|
||||||
<div class="slip">Chaos gathers. Waiting on {view.chaosPending.queue[0]}…</div>
|
<div class="slip">Chaos gathers. Waiting on {view.chaosPending.queue[0]}…</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each idleCreatures as c (c.id)}
|
{#each idleCreatures as c (c.id)}
|
||||||
{@const moves = c.movesPerTurn - c.movementUsed}
|
{@const moves = c.movesPerTurn - c.movementUsed}
|
||||||
<div class="slip creature-note">
|
<div class="slip creature-note">
|
||||||
🜲 {c.controllerId === view.you ? "Your" : "The"} {cardDef(c.kind).name} awaits orders:
|
🜲 {c.controllerId === view.you ? "Your" : "The"} {cardDef(c.kind).name} awaits orders:
|
||||||
{#if moves > 0}{moves} move{moves === 1 ? "" : "s"}{/if}{#if moves > 0 && !c.attackUsed && !c.justCreated} · {/if}{#if !c.attackUsed && !c.justCreated}attack ready{/if}.
|
{#if moves > 0}{moves} move{moves === 1 ? "" : "s"}{/if}{#if moves > 0 && attackReady(c)} · {/if}{#if attackReady(c)}attack ready{/if}.
|
||||||
Tap its token to command it.
|
Tap its token to command it.
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -3731,6 +3843,17 @@
|
|||||||
<div class="hand" bind:this={handEl} role="list" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
|
<div class="hand" bind:this={handEl} role="list" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
|
||||||
{#if net.spectating}
|
{#if net.spectating}
|
||||||
<span class="gallery-note">👁 You watch from the Peanut Gallery, and every hand stays secret, even from you.</span>
|
<span class="gallery-note">👁 You watch from the Peanut Gallery, and every hand stays secret, even from you.</span>
|
||||||
|
{#if view.phase !== "finished"}
|
||||||
|
<span class="gallery-note">Had a seat at this table on another device? Choose <em>transfer seat</em> there, or ask {net.hostId ?? "the host"} for a phrase, and claim it here. Failing that, <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${net.roomId}`)}">write to the keeper</a> for a link that brings it back.</span>
|
||||||
|
<span class="claim-row gallery-claim">
|
||||||
|
<input class="claim-input" bind:value={claimPhrase}
|
||||||
|
placeholder="ember-troll-dagger-moss" aria-label="seat transfer phrase" />
|
||||||
|
<button class="stamp tiny" disabled={!claimPhrase.trim()}
|
||||||
|
onclick={() => { net.claimTransfer(claimPhrase); claimPhrase = ""; }}>
|
||||||
|
Claim the seat
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)}
|
{#each view.phase === "finished" ? [] : orderedHand as card (card.instanceId)}
|
||||||
<div class="hand-slot"
|
<div class="hand-slot"
|
||||||
@@ -4325,6 +4448,29 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
.seats-slip {
|
||||||
|
width: min(34rem, 100%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 auto 0.7rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.seats-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.seats-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.seats-name { flex: 1 1 8rem; }
|
||||||
|
.seats-note { font-weight: normal; font-style: italic; opacity: 0.8; margin-left: 0.4em; }
|
||||||
|
.gallery-claim { margin-top: 0.4rem; }
|
||||||
.claim-row {
|
.claim-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -4399,6 +4545,7 @@
|
|||||||
padding: 0 0.3rem;
|
padding: 0 0.3rem;
|
||||||
}
|
}
|
||||||
.ledger-forget:hover { color: #b3372b; }
|
.ledger-forget:hover { color: #b3372b; }
|
||||||
|
.forget-ask { font-size: 0.8rem; color: #8d8672; flex: none; }
|
||||||
|
|
||||||
.stamp {
|
.stamp {
|
||||||
font-family: "Oswald", sans-serif;
|
font-family: "Oswald", sans-serif;
|
||||||
@@ -5220,7 +5367,10 @@
|
|||||||
height: 56dvh;
|
height: 56dvh;
|
||||||
}
|
}
|
||||||
.chronicle { max-height: 30dvh; flex: initial; }
|
.chronicle { max-height: 30dvh; flex: initial; }
|
||||||
.masthead { gap: 0.45rem; padding-top: 0.5rem; }
|
/* The table's links wrap to a second line rather than widen the page:
|
||||||
|
* an unwrapped row pushed a phone's layout past the screen's edge. */
|
||||||
|
.masthead { gap: 0.45rem; padding-top: 0.5rem; flex-wrap: wrap; row-gap: 0.25rem; }
|
||||||
|
.mast-leave, .mast-room { white-space: nowrap; }
|
||||||
.boxlid { padding: 1rem 0; }
|
.boxlid { padding: 1rem 0; }
|
||||||
.boxlid-inner { padding: 1.5rem 1.2rem 1.4rem; }
|
.boxlid-inner { padding: 1.5rem 1.2rem 1.4rem; }
|
||||||
.boxlid-title { font-size: 2.3rem; }
|
.boxlid-title { font-size: 2.3rem; }
|
||||||
|
|||||||
@@ -306,6 +306,12 @@
|
|||||||
whatever name you like, and know that what you say and do at an
|
whatever name you like, and know that what you say and do at an
|
||||||
online table is part of its record.
|
online table is part of its record.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Your seat at an online game is kept in this browser. To carry it to
|
||||||
|
another device, choose <em>transfer seat</em> at the table and claim
|
||||||
|
the phrase there. If a browser loses it, write to the keeper for a
|
||||||
|
link that brings it back.
|
||||||
|
</p>
|
||||||
<h3>Send word</h3>
|
<h3>Send word</h3>
|
||||||
<p>
|
<p>
|
||||||
Feedback, bug reports and faint praise are all welcome. Ravens fly to
|
Feedback, bug reports and faint praise are all welcome. Ravens fly to
|
||||||
@@ -325,7 +331,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<p class="hosting">
|
<p class="hosting">
|
||||||
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
|
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
|
||||||
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Every game stays whole either way.
|
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it.
|
||||||
</p>
|
</p>
|
||||||
<p class="colophon">
|
<p class="colophon">
|
||||||
Wiz-War © Tom Jolly. Sixth edition published by Chessex, 1993.
|
Wiz-War © Tom Jolly. Sixth edition published by Chessex, 1993.
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { mount } from "svelte";
|
|||||||
import Announcement from "./Announcement.svelte";
|
import Announcement from "./Announcement.svelte";
|
||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
|
|
||||||
|
/** A seat link carries its token in the fragment (#seat=<name>.<token>), and
|
||||||
|
* the history breadcrumb for taking it out of the address bar still names
|
||||||
|
* it, so it is cut from every report before one leaves the browser. */
|
||||||
|
function scrub(text: string): string {
|
||||||
|
// The cut runs to the end of the JSON string, whatever the name holds.
|
||||||
|
return text.replace(/(#seat=)(?:[^"\\]|\\.)*/g, "$1…");
|
||||||
|
}
|
||||||
|
|
||||||
// Errors only — no tracing, no replay. Without a DSN baked at build time
|
// Errors only — no tracing, no replay. Without a DSN baked at build time
|
||||||
// (VITE_SENTRY_DSN in .env.production) this is entirely inert.
|
// (VITE_SENTRY_DSN in .env.production) this is entirely inert.
|
||||||
if (import.meta.env.VITE_SENTRY_DSN) {
|
if (import.meta.env.VITE_SENTRY_DSN) {
|
||||||
@@ -10,8 +18,12 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
|||||||
environment: "production",
|
environment: "production",
|
||||||
// Facebook's in-app browser injects its own scripts (iabjs://) and
|
// Facebook's in-app browser injects its own scripts (iabjs://) and
|
||||||
// reports their failures as ours when the page unloads.
|
// reports their failures as ours when the page unloads.
|
||||||
denyUrls: [/^iabjs:\/\//],
|
// Extensions run their own scripts in the page, and their failures are not ours.
|
||||||
|
// Safari hides an extension's address behind webkit-masked-url.
|
||||||
|
denyUrls: [/^iabjs:\/\//, /^(chrome|moz|safari(-web)?)-extension:\/\//, /^webkit-masked-url:\/\//],
|
||||||
ignoreErrors: ["Java object is gone"],
|
ignoreErrors: ["Java object is gone"],
|
||||||
|
beforeSend: (event) => JSON.parse(scrub(JSON.stringify(event))),
|
||||||
|
beforeBreadcrumb: (crumb) => JSON.parse(scrub(JSON.stringify(crumb))),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ReplayStep } from "./catchup";
|
|||||||
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
||||||
import { cardDef, isPermanentDuration } from "@wizwar/engine";
|
import { cardDef, isPermanentDuration } from "@wizwar/engine";
|
||||||
import { receiptFor } from "./receipt";
|
import { receiptFor } from "./receipt";
|
||||||
|
import * as Sentry from "@sentry/browser";
|
||||||
|
|
||||||
/** The same server over plain HTTP, for the one door a socket cannot carry: a picture. */
|
/** The same server over plain HTTP, for the one door a socket cannot carry: a picture. */
|
||||||
const API_URL =
|
const API_URL =
|
||||||
@@ -223,6 +224,9 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name}, and their actions are over.`;
|
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name}, and their actions are over.`;
|
||||||
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
|
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
|
||||||
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
|
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
|
||||||
|
case "seatChanged": return e.to === "clockwork"
|
||||||
|
? `⚙ ${e.by ?? "The host"} gives ${e.player}'s seat to the clockwork, which plays on in their place.`
|
||||||
|
: `${e.player} takes their seat back from the clockwork.`;
|
||||||
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} (${e.purpose}).`;
|
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} (${e.purpose}).`;
|
||||||
case "pushed": return e.player
|
case "pushed": return e.player
|
||||||
? `${e.by} shoves ${e.player} down the corridor!`
|
? `${e.by} shoves ${e.player} down the corridor!`
|
||||||
@@ -265,7 +269,7 @@ export interface LogLine {
|
|||||||
const ACTING = new Set([
|
const ACTING = new Set([
|
||||||
"moved", "warpStepped", "jumpedPit", "counteractionPlayed", "treasurePickedUp", "treasureDropped",
|
"moved", "warpStepped", "jumpedPit", "counteractionPlayed", "treasurePickedUp", "treasureDropped",
|
||||||
"numberPlayedForMovement", "cardsDiscarded", "turnStarted", "turnEnded", "doorUnlocked", "madDash",
|
"numberPlayedForMovement", "cardsDiscarded", "turnStarted", "turnEnded", "doorUnlocked", "madDash",
|
||||||
"climbedFromPit", "spellTrapped",
|
"climbedFromPit", "spellTrapped", "tableTalk",
|
||||||
]);
|
]);
|
||||||
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
|
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
|
||||||
if ("caster" in e && typeof e.caster === "string") return e.caster;
|
if ("caster" in e && typeof e.caster === "string") return e.caster;
|
||||||
@@ -297,6 +301,15 @@ function loadSeen(): Record<string, number> {
|
|||||||
|
|
||||||
export interface Seat { name: string; roomId: string; token: string }
|
export interface Seat { name: string; roomId: string; token: string }
|
||||||
|
|
||||||
|
/** A seat the keeper sent in a link, `#seat=<name>.<token>`: a fragment
|
||||||
|
* stays in the browser, out of every log. */
|
||||||
|
export function seatInLink(hash: string, roomId: string): Seat | null {
|
||||||
|
const m = /^#seat=(.+)\.([0-9a-f]{32})$/.exec(hash);
|
||||||
|
if (!m) return null;
|
||||||
|
try { return { name: decodeURIComponent(m[1]!), roomId, token: m[2]! }; }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeedbackReportView {
|
export interface FeedbackReportView {
|
||||||
id: string;
|
id: string;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -337,8 +350,20 @@ export function attentionLabel(a: GameSummary["attention"]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadSeats(): Seat[] {
|
function loadSeats(): Seat[] {
|
||||||
try { return JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); }
|
let seats: Seat[];
|
||||||
catch { return []; }
|
try { seats = JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); }
|
||||||
|
catch { seats = []; }
|
||||||
|
// The seat last sat in is kept on its own as well. A wallet that lost
|
||||||
|
// it (a cap that once dropped the newest seat) takes it back from there.
|
||||||
|
try {
|
||||||
|
const last = JSON.parse(localStorage.getItem(SEAT_KEY) ?? "null") as Seat | null;
|
||||||
|
if (last?.roomId && last.name && last.token &&
|
||||||
|
!seats.some((s) => s.roomId === last.roomId && s.name === last.name)) {
|
||||||
|
seats = [last, ...seats];
|
||||||
|
saveSeats(seats);
|
||||||
|
}
|
||||||
|
} catch { /* an unreadable last seat adds nothing */ }
|
||||||
|
return seats;
|
||||||
}
|
}
|
||||||
function saveSeats(seats: Seat[]): void {
|
function saveSeats(seats: Seat[]): void {
|
||||||
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
|
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
|
||||||
@@ -350,6 +375,10 @@ class Net {
|
|||||||
* socket that never fires onclose; the watchdog closes it by hand so
|
* socket that never fires onclose; the watchdog closes it by hand so
|
||||||
* the reconnect loop (and the masthead banner) actually engage. */
|
* the reconnect loop (and the masthead banner) actually engage. */
|
||||||
private lastHeard = Date.now();
|
private lastHeard = Date.now();
|
||||||
|
/** When the watchdog's last ping went out. A socket is dead when that
|
||||||
|
* ping went unanswered, not when it has been quiet a while: a hidden
|
||||||
|
* tab's timers may run only once a minute, so quiet proves nothing. */
|
||||||
|
private pingSentAt: number | null = null;
|
||||||
/** Whose turn the chronicle is in, for the mark on lines with no actor of their own. */
|
/** Whose turn the chronicle is in, for the mark on lines with no actor of their own. */
|
||||||
private turnOwner: string | null = null;
|
private turnOwner: string | null = null;
|
||||||
private watchdog: ReturnType<typeof setInterval> | null = null;
|
private watchdog: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -360,6 +389,10 @@ class Net {
|
|||||||
/** Lobby standee choices, by player name. */
|
/** Lobby standee choices, by player name. */
|
||||||
roomColors = $state<Record<string, number>>({});
|
roomColors = $state<Record<string, number>>({});
|
||||||
roomBots = $state<Record<string, string>>({});
|
roomBots = $state<Record<string, string>>({});
|
||||||
|
/** People's seats the clockwork plays for now, at the host's word. */
|
||||||
|
standIns = $state<string[]>([]);
|
||||||
|
/** A phrase the host made for another wizard's seat, to pass to them. */
|
||||||
|
seatPhrase = $state<{ name: string; code: string; expiresAt: number } | null>(null);
|
||||||
you = $state<string | null>(null);
|
you = $state<string | null>(null);
|
||||||
/** Seated in the Peanut Gallery: watching nameless, read-only. */
|
/** Seated in the Peanut Gallery: watching nameless, read-only. */
|
||||||
spectating = $state(false);
|
spectating = $state(false);
|
||||||
@@ -445,17 +478,24 @@ class Net {
|
|||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
private roomIdPending: string | null = null;
|
private roomIdPending: string | null = null;
|
||||||
|
/** Where a resume turns when the server refuses its seat. */
|
||||||
|
private onRefused: (() => void) | null = null;
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
if (this.ws) return;
|
if (this.ws) return;
|
||||||
const ws = new WebSocket(SERVER_URL);
|
const ws = new WebSocket(SERVER_URL);
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
this.lastHeard = Date.now();
|
this.lastHeard = Date.now();
|
||||||
|
this.pingSentAt = null;
|
||||||
if (!this.watchdog) {
|
if (!this.watchdog) {
|
||||||
this.watchdog = setInterval(() => {
|
this.watchdog = setInterval(() => {
|
||||||
if (!this.ws || this.status !== "connected") return;
|
if (!this.ws || this.status !== "connected") return;
|
||||||
|
if (this.pingSentAt !== null && this.lastHeard < this.pingSentAt) {
|
||||||
|
this.ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pingSentAt = Date.now();
|
||||||
this.send({ type: "ping" });
|
this.send({ type: "ping" });
|
||||||
if (Date.now() - this.lastHeard > 45_000) this.ws.close();
|
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
}
|
}
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@@ -483,6 +523,8 @@ class Net {
|
|||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
this.status = "disconnected";
|
this.status = "disconnected";
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
// A dropped socket proves nothing about a seat: no fallback outlives it.
|
||||||
|
this.onRefused = null;
|
||||||
// Whatever was in flight is now in doubt: the board will say what
|
// Whatever was in flight is now in doubt: the board will say what
|
||||||
// landed when the connection returns, and the talk goes back in the box.
|
// landed when the connection returns, and the talk goes back in the box.
|
||||||
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
|
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
|
||||||
@@ -524,6 +566,7 @@ class Net {
|
|||||||
case "pong":
|
case "pong":
|
||||||
break;
|
break;
|
||||||
case "seat": {
|
case "seat": {
|
||||||
|
this.onRefused = null;
|
||||||
this.token = msg.token;
|
this.token = msg.token;
|
||||||
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
||||||
if (seatRoom) {
|
if (seatRoom) {
|
||||||
@@ -542,8 +585,11 @@ class Net {
|
|||||||
break;
|
break;
|
||||||
case "room":
|
case "room":
|
||||||
this.roomId = msg.roomId;
|
this.roomId = msg.roomId;
|
||||||
|
// An error report names the table it happened at, and from which seat.
|
||||||
|
Sentry.setTags({ room: msg.roomId, seat: this.spectating ? "gallery" : (this.you ?? "none") });
|
||||||
this.roomColors = msg.colors ?? {};
|
this.roomColors = msg.colors ?? {};
|
||||||
this.roomBots = msg.bots ?? {};
|
this.roomBots = msg.bots ?? {};
|
||||||
|
this.standIns = msg.standIns ?? [];
|
||||||
this.audience = msg.audience ?? 0;
|
this.audience = msg.audience ?? 0;
|
||||||
this.rematchCall = msg.rematch ?? null;
|
this.rematchCall = msg.rematch ?? null;
|
||||||
this.expected = msg.expected ?? [];
|
this.expected = msg.expected ?? [];
|
||||||
@@ -606,6 +652,11 @@ class Net {
|
|||||||
break;
|
break;
|
||||||
case "events": {
|
case "events": {
|
||||||
let talk = 0;
|
let talk = 0;
|
||||||
|
// A replay is the whole chronicle from the deal, sent on every
|
||||||
|
// (re)join: it replaces what we hold, or a dropped socket's return
|
||||||
|
// would count each turn (and each line of talk) twice and the eyes
|
||||||
|
// would ask for turns the server has not reached.
|
||||||
|
if (msg.replayed) this.resetChronicle();
|
||||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||||
for (const e of msg.events as GameEvent[]) {
|
for (const e of msg.events as GameEvent[]) {
|
||||||
if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
|
if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
|
||||||
@@ -638,6 +689,13 @@ class Net {
|
|||||||
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
|
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
|
||||||
this.leaveLocal();
|
this.leaveLocal();
|
||||||
break;
|
break;
|
||||||
|
case "standingIn":
|
||||||
|
this.log = [...this.log, { text: `— the host gave your seat in ${msg.roomId} to the clockwork —`, turn: null, notable: false }];
|
||||||
|
this.leaveLocal();
|
||||||
|
break;
|
||||||
|
case "seatPhrase":
|
||||||
|
this.seatPhrase = { name: msg.name, code: msg.code, expiresAt: msg.expiresAt };
|
||||||
|
break;
|
||||||
case "roomAbandoned":
|
case "roomAbandoned":
|
||||||
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
|
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
|
||||||
this.leaveLocal();
|
this.leaveLocal();
|
||||||
@@ -708,14 +766,28 @@ class Net {
|
|||||||
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
|
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
|
||||||
this.voidedOnce = voided;
|
this.voidedOnce = voided;
|
||||||
let kept = this.seats.filter((s) => !doubted.has(`${s.roomId}:${s.name}`));
|
let kept = this.seats.filter((s) => !doubted.has(`${s.roomId}:${s.name}`));
|
||||||
if (kept.length > 50) kept = kept.slice(kept.length - 50);
|
// Newest first (rememberSeat puts a seat at the front): the oldest go.
|
||||||
|
if (kept.length > 50) kept = kept.slice(0, 50);
|
||||||
if (kept.length !== this.seats.length) {
|
if (kept.length !== this.seats.length) {
|
||||||
this.seats = kept;
|
this.seats = kept;
|
||||||
saveSeats(this.seats);
|
saveSeats(this.seats);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "error":
|
case "error": {
|
||||||
|
// A refused seat with somewhere else to turn takes that turn, with
|
||||||
|
// no toast for a door that was only tried. Only the server's word
|
||||||
|
// that the token is wrong counts as a refusal; any other error
|
||||||
|
// disarms the fallback.
|
||||||
|
const refused = this.onRefused;
|
||||||
|
this.onRefused = null;
|
||||||
|
if (refused && this.roomIdPending && /name is taken/.test(msg.message)) {
|
||||||
|
localStorage.removeItem(SEAT_KEY);
|
||||||
|
this.roomIdPending = null;
|
||||||
|
this.token = null;
|
||||||
|
refused();
|
||||||
|
break;
|
||||||
|
}
|
||||||
// "Name is taken" means THIS token was tried and refused: the
|
// "Name is taken" means THIS token was tried and refused: the
|
||||||
// credential itself is dead, and holding it helps nobody. But
|
// credential itself is dead, and holding it helps nobody. But
|
||||||
// "no such room" proves nothing about the seat — a restarting
|
// "no such room" proves nothing about the seat — a restarting
|
||||||
@@ -736,6 +808,7 @@ class Net {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** True when the message left; false when the socket was not open to carry it. */
|
/** True when the message left; false when the socket was not open to carry it. */
|
||||||
private send(message: unknown): boolean {
|
private send(message: unknown): boolean {
|
||||||
@@ -775,8 +848,10 @@ class Net {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sit back down at a remembered seat. */
|
/** Sit back down at a seat. It is remembered only once the server
|
||||||
resume(seat: Seat): void {
|
* takes it; `refused` runs if the server turns it away. */
|
||||||
|
resume(seat: Seat, refused?: () => void): void {
|
||||||
|
this.onRefused = refused ?? null;
|
||||||
this.you = seat.name;
|
this.you = seat.name;
|
||||||
this.spectating = false;
|
this.spectating = false;
|
||||||
this.token = seat.token;
|
this.token = seat.token;
|
||||||
@@ -832,6 +907,16 @@ class Net {
|
|||||||
this.send({ type: "kickSeat", name });
|
this.send({ type: "kickSeat", name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Host only: the clockwork plays this person's seat from here. */
|
||||||
|
standIn(name: string): void {
|
||||||
|
this.send({ type: "standIn", name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host only: a phrase that brings this person's seat to whoever claims it. */
|
||||||
|
makeSeatPhrase(name: string): void {
|
||||||
|
this.send({ type: "makeSeatPhrase", name });
|
||||||
|
}
|
||||||
|
|
||||||
abandonRoom(): void {
|
abandonRoom(): void {
|
||||||
this.send({ type: "abandonRoom" });
|
this.send({ type: "abandonRoom" });
|
||||||
}
|
}
|
||||||
@@ -937,6 +1022,7 @@ class Net {
|
|||||||
* line, and wiping it would erase the only notice of why. */
|
* line, and wiping it would erase the only notice of why. */
|
||||||
leaveLocal(): void {
|
leaveLocal(): void {
|
||||||
localStorage.removeItem(SEAT_KEY);
|
localStorage.removeItem(SEAT_KEY);
|
||||||
|
Sentry.setTags({ room: "none", seat: "none" });
|
||||||
this.missedSteps = null;
|
this.missedSteps = null;
|
||||||
this.catchUp = null;
|
this.catchUp = null;
|
||||||
this.pending = null;
|
this.pending = null;
|
||||||
@@ -1017,6 +1103,7 @@ class Net {
|
|||||||
private resetChronicle(): void {
|
private resetChronicle(): void {
|
||||||
this.log = [];
|
this.log = [];
|
||||||
this.turnCounter = -1;
|
this.turnCounter = -1;
|
||||||
|
this.chatCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Summon one turn's reel by its chronicle turn number. */
|
/** Summon one turn's reel by its chronicle turn number. */
|
||||||
|
|||||||
Reference in new issue
Block a user