Compare commits
39
Commits
c79fdb4adb
..
main
No files matched your search
@@ -36,7 +36,7 @@ one (thief → target's steallables, deja-vu → discard contents, etc.).
|
||||
Fetch and replay production ledgers (details in auto-memory
|
||||
`wizwar-fetch-game-files`):
|
||||
|
||||
scp root@104.236.96.198:/var/lib/wizwar/rooms/<CODE>.jsonl <scratchpad>/
|
||||
scp root@159.203.115.252:/var/lib/wizwar/rooms/<CODE>.jsonl <scratchpad>/
|
||||
|
||||
Replay with `createGame({playerIds, seed, sets, colors, deckRev})` +
|
||||
`applyCommand` per command line (see `deploy/replay-verify.mjs`). Stop at
|
||||
@@ -50,7 +50,7 @@ each, and report phase / round / humans vs bots / last command's `at`.
|
||||
|
||||
## 3. Determinism gate (before deploying engine changes)
|
||||
|
||||
deploy/verify-ledgers.sh 104.236.96.198
|
||||
deploy/verify-ledgers.sh 159.203.115.252
|
||||
|
||||
Strict-replays every production ledger against the local engine; one
|
||||
refused command fails. A room whose ledger no longer replays becomes
|
||||
|
||||
@@ -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.
|
||||
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)
|
||||
|
||||
- "You did nothing but draw for several turns" — true, and fatal. A
|
||||
|
||||
@@ -12,7 +12,7 @@ quiet, say so in one line before the details.
|
||||
|
||||
## Gather
|
||||
|
||||
1. **Box health** (one ssh to root@104.236.96.198):
|
||||
1. **Box health** (one ssh to root@159.203.115.252):
|
||||
- `systemctl is-active wizwar caddy` — both must be active
|
||||
- `systemctl status wizwar` Memory line — the cap is 700M; worry
|
||||
past ~500M steady-state
|
||||
@@ -57,7 +57,7 @@ quiet, say so in one line before the details.
|
||||
lists
|
||||
- Unanswered feedback count from /var/lib/wizwar/feedback.jsonl —
|
||||
if any, offer to run the reports desk (`/wizwar-reports`)
|
||||
- Access-log pulse: `wc -l /var/lib/caddy/access.log` and a count of
|
||||
- Access-log pulse: `wc -l /var/lib/caddy/wizwar-access.log` and a count of
|
||||
distinct `client_ip`s in the last day, for the growth line
|
||||
|
||||
## Report
|
||||
@@ -77,7 +77,7 @@ action, or "nothing needs you."
|
||||
stale and leaves the rest asleep, so a deploy costs no memory spike.
|
||||
- Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
|
||||
requires it; a reboot there is maintenance, not an outage.
|
||||
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating,
|
||||
- Caddy access logs live at /var/lib/caddy/wizwar-access.log (self-rotating,
|
||||
10MiB × 30); the systemd sandbox denies /var/log/caddy.
|
||||
- Per-address limits (2026-09-03): 12 new rooms and 6 reports per
|
||||
address per hour, in packages/server/src/ratelimit.ts. A player who
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Work the Wiz-War player-feedback desk — fetch new bug reports, sh
|
||||
# The Wiz-War reports desk
|
||||
|
||||
Players file surprise reports from the in-game 🐞 button. Each lands in
|
||||
`/var/lib/wizwar/feedback.jsonl` on the droplet (104.236.96.198) pinned
|
||||
`/var/lib/wizwar/feedback.jsonl` on the droplet (159.203.115.252) pinned
|
||||
with `roomId`, `seq`, `round`, and `deckRev` — enough to replay the game
|
||||
to the exact moment. Replies live in the same file and appear under the
|
||||
report in the player's lobby ledger. This desk fetches, displays, and
|
||||
@@ -32,7 +32,7 @@ One JSONL line per entry:
|
||||
screenshot; the file is `/var/lib/wizwar/feedback-images/<id>.<ext>`.
|
||||
`scp` it to the scratchpad and Read it: it is usually the whole story.
|
||||
|
||||
Fetch: `ssh root@104.236.96.198 'cat /var/lib/wizwar/feedback.jsonl'`
|
||||
Fetch: `ssh root@159.203.115.252 'cat /var/lib/wizwar/feedback.jsonl'`
|
||||
|
||||
## a) Fetch and b) display
|
||||
|
||||
@@ -49,7 +49,7 @@ players' voices — never compress their words, or your replies, into a table.
|
||||
For each unanswered report, in order:
|
||||
|
||||
1. **Replay to the pin.** Fetch the ledger
|
||||
(`scp root@104.236.96.198:/var/lib/wizwar/rooms/<roomId>.jsonl <scratchpad>/`)
|
||||
(`scp root@159.203.115.252:/var/lib/wizwar/rooms/<roomId>.jsonl <scratchpad>/`)
|
||||
and replay it to the report's `seq` with the engine
|
||||
(`createGame` + `applyCommand` per command line, printing the events
|
||||
around the pinned moment). CRITICAL replay details:
|
||||
@@ -80,10 +80,10 @@ For each unanswered report, in order:
|
||||
- Chronicle-only changes (event names, humanize lines) ship ungated.
|
||||
- Before deploying: full engine suite passes AND
|
||||
`bash deploy/verify-ledgers.sh` replays every production ledger
|
||||
clean. Deploy with `bash deploy/deploy.sh 104.236.96.198` from the
|
||||
clean. Deploy with `bash deploy/deploy.sh 159.203.115.252` from the
|
||||
repo root. The deploy restarts the server; live games survive.
|
||||
5. **Reply.** From the repo root:
|
||||
`bash deploy/feedback-reply.sh 104.236.96.198 <reportId> <status> "text"`
|
||||
`bash deploy/feedback-reply.sh 159.203.115.252 <reportId> <status> "text"`
|
||||
- `<reportId>` is the report's `id`, or its `at` timestamp for
|
||||
legacy reports. Pass it as ONE clean argument — a stray newline in
|
||||
the id silently orphans the reply (it happened; the bad line had
|
||||
|
||||
@@ -10,7 +10,7 @@ Eric like a host glancing over the tavern.
|
||||
|
||||
## Gather
|
||||
|
||||
ssh root@104.236.96.198 /usr/local/bin/wizwar-visitors.sh [YYYY-MM-DD]
|
||||
ssh root@159.203.115.252 /usr/local/bin/wizwar-visitors.sh [YYYY-MM-DD]
|
||||
|
||||
Default is today, UTC. The script rolls today's partial rollup row
|
||||
first, so the traffic line is current to the minute. Its output:
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Wiz-War's site on a host shared with the other games of Kestrel's Hall:
|
||||
# setup-droplet.sh fills __HOST__ and writes it to /etc/caddy/sites/wizwar.caddy.
|
||||
__HOST__ {
|
||||
# The hall-wide notice (hall-announce on the host): one file, served by every game's site.
|
||||
handle_path /hall/* {
|
||||
root * /var/lib/hall
|
||||
header Cache-Control "no-store"
|
||||
file_server
|
||||
}
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "no-referrer"
|
||||
}
|
||||
|
||||
# Kept to 30 rolls of 10MiB: the nightly rollup keeps the counts; the raw
|
||||
# lines back it for a while. Each game on the host keeps its own log.
|
||||
log {
|
||||
output file /var/lib/caddy/wizwar-access.log {
|
||||
roll_size 10MiB
|
||||
roll_keep 30
|
||||
}
|
||||
}
|
||||
|
||||
# A deploy restarts the game for a few seconds; the proxy holds requests
|
||||
# that land in that gap and keeps dialing, so a visitor waits instead of
|
||||
# meeting a 502.
|
||||
reverse_proxy localhost:8787 {
|
||||
lb_try_duration 30s
|
||||
lb_try_interval 250ms
|
||||
}
|
||||
}
|
||||
+25
-6
@@ -7,16 +7,18 @@ survive deploys and reboots.
|
||||
|
||||
## Current production
|
||||
|
||||
- Droplet: `wizwar` (nyc3, s-1vcpu-1gb, tag `wizwar`), IP 104.236.96.198
|
||||
- Host: `hall` (nyc3, tag `kestrels-hall`), IP 159.203.115.252, shared with
|
||||
the other games of Kestrel's Hall since 2026-09-29; Wiz-War keeps its own
|
||||
service user, port 8787, site file and access log there.
|
||||
- URLs: https://wizwar.kestrelsnest.social
|
||||
and https://wizwar.104.236.96.198.sslip.io (always works, zero DNS).
|
||||
and https://wizwar.159.203.115.252.sslip.io (always works, zero DNS).
|
||||
Caddy serves both; NOTE: kestrelsnest.social's authoritative DNS is at
|
||||
HOVER (ns1/ns2.hover.com), not DigitalOcean — records must be added there.
|
||||
A matching record also sits in the DO zone in case nameservers ever move.
|
||||
|
||||
## Everyday deploys
|
||||
|
||||
deploy/deploy.sh 104.236.96.198
|
||||
deploy/deploy.sh 159.203.115.252
|
||||
|
||||
Builds the client locally, rsyncs the repo (minus node_modules, data/, .git,
|
||||
research), installs dependencies on the droplet, and restarts the service.
|
||||
@@ -25,7 +27,7 @@ automatically.
|
||||
|
||||
Before any deploy that touches the engine, run the determinism gate:
|
||||
|
||||
deploy/verify-ledgers.sh 104.236.96.198
|
||||
deploy/verify-ledgers.sh 159.203.115.252
|
||||
|
||||
It fetches every production ledger and strictly replays it against the local
|
||||
engine; a single refused command fails the check. A room whose ledger no
|
||||
@@ -35,8 +37,9 @@ longer replays becomes unreachable after restart, so this is not optional.
|
||||
|
||||
1. `doctl compute droplet create wizwar --region nyc3 --size s-1vcpu-1gb \
|
||||
--image ubuntu-24-04-x64 --ssh-keys <your-key-ids> --tag-name wizwar --wait`
|
||||
2. `scp deploy/setup-droplet.sh root@<ip>:/root/ && ssh root@<ip> \
|
||||
"bash /root/setup-droplet.sh wizwar.<ip>.sslip.io"`
|
||||
2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
|
||||
"bash /root/setup-droplet.sh 'wizwar.<ip>.sslip.io'"` (the host's Caddyfile
|
||||
gathers each game's site from /etc/caddy/sites/, so the host can be shared)
|
||||
3. `deploy/deploy.sh <ip>`
|
||||
|
||||
## Operations
|
||||
@@ -49,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
|
||||
script, configure rclone (the script refuses to run while the config still
|
||||
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`
|
||||
+16
-2
@@ -8,10 +8,12 @@ KIT_FAMILY="$(dirname "$0")/../../game-kit/family.json"
|
||||
[ -f "$KIT_FAMILY" ] && cp "$KIT_FAMILY" packages/web/public/family.json
|
||||
|
||||
npm run build --workspace=@wizwar/web
|
||||
# /data/ is the local game ledgers, anchored so the engine's own data/ (the
|
||||
# cards and boards the server reads) always ships.
|
||||
# Old hashed chunks stay a week: a page loaded before this deploy can still
|
||||
# fetch the module it was built against instead of failing mid-game.
|
||||
rsync -az --delete --filter='P packages/web/dist/assets/*' \
|
||||
--exclude node_modules --exclude data/ --exclude .git --exclude research \
|
||||
--exclude node_modules --exclude /data/ --exclude .git --exclude research \
|
||||
./ "root@$HOST:/opt/wizwar/"
|
||||
ssh "root@$HOST" '
|
||||
cd /opt/wizwar && npm install --no-audit --no-fund
|
||||
@@ -23,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-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 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 enable --now wizwar
|
||||
systemctl restart wizwar
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# to a local folder, and write a digest beside them for reading.
|
||||
# deploy/pull-feedback.sh [host] [folder] (default ~/Desktop/wizwar-reports)
|
||||
set -euo pipefail
|
||||
HOST="${1:-104.236.96.198}"
|
||||
HOST="${1:-159.203.115.252}"
|
||||
OUT="${2:-$HOME/Desktop/wizwar-reports}"
|
||||
mkdir -p "$OUT/images"
|
||||
scp -q "root@$HOST:/var/lib/wizwar/feedback.jsonl" "$OUT/feedback.jsonl"
|
||||
|
||||
+26
-10
@@ -1,8 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time droplet setup. Run ON the droplet as root:
|
||||
# bash setup-droplet.sh wizwar.<droplet-ip>.sslip.io
|
||||
# One-time host setup. The host may be shared with the other games of
|
||||
# Kestrel's Hall; this touches nothing of theirs. Copy it and Caddyfile.tmpl
|
||||
# to the host and run ON it as root:
|
||||
# bash setup-droplet.sh 'wizwar.kestrelsnest.social, wizwar.<host-ip>.sslip.io'
|
||||
# The argument is Wiz-War's Caddy site address line: one name, or several
|
||||
# separated by commas. Every name must already resolve to this host.
|
||||
set -euo pipefail
|
||||
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
||||
HOST="${1:?usage: setup-droplet.sh <site address line>}"
|
||||
|
||||
apt-get update -q
|
||||
apt-get install -qy curl git rsync
|
||||
@@ -24,13 +28,25 @@ id -u wizwar &>/dev/null || useradd -r -m -d /opt/wizwar-home wizwar
|
||||
mkdir -p /opt/wizwar /var/lib/wizwar/rooms
|
||||
chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar
|
||||
|
||||
# Caddy vhost: auto-TLS, security headers, proxy to the game.
|
||||
# Access log kept to 30 rolls of 10MiB: the nightly rollup keeps the
|
||||
# counts; the raw lines back it for a while. A deploy restarts the game
|
||||
# for a few seconds; the proxy holds requests that land in that gap and
|
||||
# keeps dialing, so a visitor waits instead of meeting a 502.
|
||||
printf '%s\n\nheader {\n\tStrict-Transport-Security "max-age=31536000"\n\tX-Content-Type-Options "nosniff"\n\tX-Frame-Options "DENY"\n\tReferrer-Policy "no-referrer"\n}\nlog {\n\toutput file /var/lib/caddy/access.log {\n\t\troll_size 10MiB\n\t\troll_keep 30\n\t}\n}\nreverse_proxy localhost:8787 {\n\tlb_try_duration 30s\n\tlb_try_interval 250ms\n}\n' "$HOST" > /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
# Caddy: the host's Caddyfile only gathers each game's site file, so this
|
||||
# host can be shared with the other games of Kestrel's Hall. A Caddyfile
|
||||
# that is neither Caddy's stock one nor that is a single-site host from
|
||||
# before the shared layout: stop rather than overwrite it.
|
||||
ROOT_CADDY='# Kestrel'"'"'s Hall: every game on this host brings its own site file.
|
||||
import sites/*.caddy'
|
||||
mkdir -p /etc/caddy/sites
|
||||
if ! grep -q '^import sites/\*.caddy' /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
if [ -s /etc/caddy/Caddyfile ] && ! grep -q 'root \* /usr/share/caddy' /etc/caddy/Caddyfile; then
|
||||
echo "/etc/caddy/Caddyfile holds another site; move it into /etc/caddy/sites/ first" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$ROOT_CADDY" > /etc/caddy/Caddyfile
|
||||
fi
|
||||
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/sites/wizwar.caddy
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
|
||||
chown -R caddy:caddy /var/lib/caddy
|
||||
systemctl reload-or-restart caddy
|
||||
|
||||
# Firewall: ssh + web only. The game server binds loopback and is reached
|
||||
# through Caddy; nothing else should answer the internet.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# replay it against the LOCAL engine. Any refused command is a hard failure.
|
||||
# Run from the repo root before deploying any engine change.
|
||||
set -euo pipefail
|
||||
HOST="${1:-104.236.96.198}"
|
||||
HOST="${1:-159.203.115.252}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
scp -q "root@$HOST:/var/lib/wizwar/rooms/*.jsonl" "$TMP/"
|
||||
|
||||
+12
-3
@@ -10,22 +10,30 @@ LOG="/var/log/wizwar/backup.log"
|
||||
STAMP=$(date +%Y-%m-%d)
|
||||
# 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.
|
||||
# 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)
|
||||
checkin() { [ -n "$CRON_URL" ] && curl -sf -o /dev/null "$CRON_URL?status=$1" || true; }
|
||||
# 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
|
||||
# that lands second is never closed. A run that never ends sends nothing,
|
||||
# and Sentry marks the night missed.
|
||||
START=$SECONDS
|
||||
checkin() { [ -n "$CRON_URL" ] && curl -sf -o /dev/null "$CRON_URL?status=$1&duration=$((SECONDS - START))" || true; }
|
||||
|
||||
if grep -q CHANGE_ME /root/.config/rclone/rclone.conf; then
|
||||
echo "$(date -Is) skipped: Spaces credentials not configured yet" >> "$LOG"
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
status=0
|
||||
{
|
||||
echo "=== $(date -Is) backup start"
|
||||
checkin in_progress
|
||||
if rclone sync "$SRC" "spaces:$BUCKET/wizwar/current" 2>&1 &&
|
||||
rclone copy "$SRC" "spaces:$BUCKET/wizwar/snapshots/$STAMP" 2>&1; then
|
||||
checkin ok
|
||||
else
|
||||
checkin error
|
||||
status=1
|
||||
fi
|
||||
# Prune dated snapshots older than 90 days (by directory name).
|
||||
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
|
||||
@@ -39,3 +47,4 @@ fi
|
||||
done
|
||||
echo "=== $(date -Is) backup done"
|
||||
} >> "$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
|
||||
+12
-6
@@ -9,7 +9,9 @@
|
||||
# one count per address per day.
|
||||
# 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
|
||||
# (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
|
||||
OUT="/var/lib/wizwar/rollup.jsonl"
|
||||
LOG="/var/log/wizwar/rollup.log"
|
||||
@@ -21,13 +23,17 @@ CRON_URL=$(cat /root/.wizwar-sentry-cron-rollup 2>/dev/null || true)
|
||||
# The check-in carries the schedule: Sentry only keeps a monitor it has
|
||||
# been told the shape of, and a bare ping to an unknown slug is accepted
|
||||
# and dropped without a word.
|
||||
# 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
|
||||
# that lands second is never closed. A run that never ends sends nothing,
|
||||
# and Sentry marks the night missed.
|
||||
START=$SECONDS
|
||||
checkin() {
|
||||
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
|
||||
-d "{\"status\":\"$1\",\"duration\":$((SECONDS - START)),\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
|
||||
"$CRON_URL" || true
|
||||
}
|
||||
|
||||
checkin in_progress
|
||||
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
|
||||
import json, sys, os, glob, gzip, datetime, collections, subprocess, re
|
||||
day, out = sys.argv[1], sys.argv[2]
|
||||
@@ -43,7 +49,7 @@ paths = collections.Counter(); status = collections.Counter(); refs = collection
|
||||
# Where a visit came from, when the link itself says so: ?ref=<name> on a
|
||||
# link Eric posted, a Facebook click's fbclid, or a utm_source.
|
||||
campaigns = collections.Counter(); seen_campaign = set()
|
||||
for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
|
||||
for f in sorted(glob.glob("/var/lib/caddy/wizwar-access*.log*")):
|
||||
if os.path.getmtime(f) < t0: continue
|
||||
opener = gzip.open if f.endswith(".gz") else open
|
||||
with opener(f, "rt", errors="ignore") as fh:
|
||||
@@ -74,7 +80,7 @@ for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
m = re.match(r"https?://([^/]+)", ref)
|
||||
host = m.group(1) if m else ""
|
||||
ours = host.endswith("kestrelsnest.social") or "104.236.96.198" in host
|
||||
ours = host.endswith("kestrelsnest.social") or "159.203.115.252" in host
|
||||
# Scanners forge referrers on paths like /.env, and the app answers
|
||||
# unknown paths with the page itself, so the status cannot tell a
|
||||
# probe from a reader. Only the pages count.
|
||||
@@ -144,4 +150,4 @@ tmp = out + ".tmp"
|
||||
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())))
|
||||
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", "")
|
||||
if not ca: continue
|
||||
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:]:
|
||||
try: x = json.loads(l)
|
||||
except Exception: continue
|
||||
@@ -48,15 +49,19 @@ for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
|
||||
if k == "join":
|
||||
(bots if x.get("bot") else humans).append(x["name"])
|
||||
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":
|
||||
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":
|
||||
last = x.get("at", last)
|
||||
if x["player"] in humans: chat.append((x.get("at", "")[11:16], x["player"], x["text"]))
|
||||
for h in humans:
|
||||
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"])
|
||||
names = sorted({h for r in today for h in r["humans"]})
|
||||
@@ -68,12 +73,13 @@ for r in today:
|
||||
if r["cmds"]:
|
||||
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)
|
||||
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"
|
||||
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" % (
|
||||
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))
|
||||
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))
|
||||
|
||||
# --- the desk ---
|
||||
|
||||
@@ -9,6 +9,9 @@ WorkingDirectory=/opt/wizwar/packages/server
|
||||
Environment=PORT=8787
|
||||
# Caddy terminates TLS; the plaintext port must not face the internet.
|
||||
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_STATIC_DIR=/opt/wizwar/packages/web/dist
|
||||
Environment=WIZWAR_KEEPER_TZ=America/New_York
|
||||
@@ -27,8 +30,9 @@ ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
# A runaway process gets killed and restarted before it can take the box down.
|
||||
MemoryMax=700M
|
||||
# A runaway process gets killed and restarted before it can take the box, or
|
||||
# the other games sharing it, down. The server runs in about 125M.
|
||||
MemoryMax=350M
|
||||
LimitNOFILE=4096
|
||||
|
||||
[Install]
|
||||
|
||||
Generated
+2
-1
@@ -2759,7 +2759,8 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"packages/web": {
|
||||
|
||||
@@ -1472,7 +1472,9 @@ export function automatonCommand(
|
||||
// Command the menagerie: creatures march and maul before the wizard moves.
|
||||
for (const c of view.creatures) {
|
||||
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 &&
|
||||
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
|
||||
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
|
||||
|
||||
+111
-49
@@ -247,7 +247,7 @@ export interface CastParams {
|
||||
}
|
||||
|
||||
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
|
||||
export const CURRENT_RULES_REV = 26;
|
||||
export const CURRENT_RULES_REV = 28;
|
||||
|
||||
/** 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
|
||||
@@ -278,6 +278,8 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
|
||||
{ rev: 24, note: "A REFLECTION against a permanent curse afflicts both wizards: WALKING DEAD and IDIOT say so on the card, and SLOW DEATH follows REFLECTION's own rule that a spell works for both parties. Older games left the caster untouched." },
|
||||
{ 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: 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 {
|
||||
@@ -597,6 +599,9 @@ function castSightEdge(
|
||||
cell: Cell,
|
||||
side: Side,
|
||||
): 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 (displays(caster, "visionstone") &&
|
||||
throughOneEdge(board, (b) => losToEdge(b, caster.position, cell, side))) {
|
||||
@@ -623,6 +628,12 @@ function isMisted(state: GameState, playerId: PlayerId): boolean {
|
||||
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 {
|
||||
return sustainedOn(state, playerId, "lock-in-place").length > 0;
|
||||
}
|
||||
@@ -705,6 +716,9 @@ export type GameEvent =
|
||||
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
|
||||
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: 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: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
|
||||
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
|
||||
@@ -2349,7 +2363,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
const caster = activePlayer(state);
|
||||
if (!target) return null;
|
||||
const d = Math.abs(target.position.x - caster.position.x) + Math.abs(target.position.y - caster.position.y);
|
||||
return d <= 1 ? null : "disease spreads by touch — you must be adjacent";
|
||||
return d <= 1 ? null : "disease spreads by touch, so you must be adjacent";
|
||||
},
|
||||
sustains: true,
|
||||
},
|
||||
@@ -2432,17 +2446,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
// Earlier revisions rammed at any range; their ledgers keep it.
|
||||
validate: (state, _cmd, _caster, target) => {
|
||||
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
|
||||
const caster = activePlayer(state);
|
||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
||||
const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
|
||||
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
|
||||
const { d, legs } = charge(state, activePlayer(state), target);
|
||||
if (d > legs) return "the goat's legs cannot carry the charge that far this turn";
|
||||
return null;
|
||||
},
|
||||
onResolved: (ctx) => {
|
||||
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
|
||||
if ((ctx.state.config.deckRev ?? 1) >= 10) {
|
||||
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
|
||||
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
|
||||
const { d, legs } = charge(ctx.state, ctx.attacker, ctx.defender);
|
||||
if (d === 0) return;
|
||||
if (d > legs) {
|
||||
// The target slipped beyond the charge (a counter-teleport, say):
|
||||
@@ -2453,9 +2464,9 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
ctx.state.turn.movementUsed += d;
|
||||
ctx.attacker.position = { ...ctx.defender.position };
|
||||
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
|
||||
// Rev 26: the ram was priced into the blow itself (ramDistance),
|
||||
// so STRENGTH, AMPLIFY, and the counters have already weighed it.
|
||||
// Older games dealt the bare distance here, past all of them.
|
||||
// From rev 26 the ram is priced into the blow itself (ramDistance),
|
||||
// so STRENGTH, AMPLIFY, and the counters have already weighed it;
|
||||
// earlier revs deal the bare distance here, past all of them.
|
||||
if ((ctx.state.config.deckRev ?? 1) < 26) resolvedBlow(ctx, d, "goat ram", "physical");
|
||||
return;
|
||||
}
|
||||
@@ -2542,6 +2553,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
return null;
|
||||
},
|
||||
onResolved: (ctx) => {
|
||||
// Rev 27 prices the illusion into the pipeline and rolls belief there.
|
||||
if ((ctx.state.config.deckRev ?? 1) >= 27) return;
|
||||
if (ctx.fullyStopped || !ctx.defender.alive) return;
|
||||
const chosen = ctx.stack.params!.cardId!;
|
||||
const fx = CARD_EFFECTS[chosen] as AttackEffect;
|
||||
@@ -2758,11 +2771,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
||||
},
|
||||
"opportunity-fire": {
|
||||
kind: "neutral",
|
||||
resolve: () => "played out of turn — wait for another player's turn, then use it",
|
||||
resolve: () => "this is played during another player's turn",
|
||||
},
|
||||
interrupt: {
|
||||
kind: "neutral",
|
||||
resolve: () => "played out of turn — use it during another player's turn",
|
||||
resolve: () => "this is played during another player's turn",
|
||||
},
|
||||
"thumb-of-god": {
|
||||
kind: "neutral",
|
||||
@@ -3061,7 +3074,7 @@ function emptySquareTarget(
|
||||
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
|
||||
// "You can't create an object on a DIMENSIONAL WARP".
|
||||
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
||||
return "the warp shimmers there — nothing can form on it";
|
||||
return "nothing can form where the warp shimmers";
|
||||
}
|
||||
if (!castSight(state, caster, cmd, cell)) return "no line of sight";
|
||||
return cell;
|
||||
@@ -3185,6 +3198,7 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
|
||||
if (onlyPlayer && p.id !== onlyPlayer) continue;
|
||||
if (imp.justCreated && p.id === imp.controllerId) continue; // not its creator on creation turn
|
||||
if (imp.scorchedThisTurn.includes(p.id)) continue;
|
||||
if (mistShieldsFromCreatures(state, p)) continue;
|
||||
// "Imp cannot see through a FIREWALL!" — gameLos already blocks on
|
||||
// firewall edges and terrain.
|
||||
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) {
|
||||
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 (mistShieldsFromCreatures(state, p)) continue;
|
||||
if (creature.kind === "wraith" && !creature.attackUsed) {
|
||||
creature.attackUsed = true;
|
||||
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
|
||||
@@ -3354,6 +3369,17 @@ function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerSt
|
||||
};
|
||||
}
|
||||
|
||||
/** What a creature's blow is called on the stack and in the chronicle:
|
||||
* the counteraction is played against this, and the damage is dealt as it. */
|
||||
function creatureBlowName(kind: CreatureState["kind"]): string {
|
||||
switch (kind) {
|
||||
case "fire-imp": return "fire imp's scorch";
|
||||
case "wraith": return "wraith's touch";
|
||||
case "democratic-monster": return "democratic monster's claw";
|
||||
default: return `${kind}'s blow`;
|
||||
}
|
||||
}
|
||||
|
||||
/** A creature's blow opens a counteraction window like any attack. */
|
||||
function openCreatureStack(
|
||||
state: GameState,
|
||||
@@ -3367,6 +3393,7 @@ function openCreatureStack(
|
||||
params: { damage },
|
||||
kind,
|
||||
creatureId: creature.id,
|
||||
sourceName: creatureBlowName(creature.kind),
|
||||
...(touch ? { creatureTouch: touch } : {}),
|
||||
});
|
||||
}
|
||||
@@ -3405,7 +3432,7 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
|
||||
if (creature.justCreated) return err("it cannot attack the turn it was created");
|
||||
if (creature.attackUsed) return err("that creature has already attacked this turn");
|
||||
if (creature.kind !== "troll" && creature.kind !== "skeleton" && creature.kind !== "shadow") {
|
||||
return err("that creature attacks on its own, not on command");
|
||||
return err("that creature picks its own victims and takes no orders to attack");
|
||||
}
|
||||
|
||||
const events: GameEvent[] = [];
|
||||
@@ -3417,6 +3444,7 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
|
||||
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 && mistShieldsFromCreatures(state, targetPlayer)) return err("your target is mist and cannot be attacked");
|
||||
|
||||
creature.attackUsed = true;
|
||||
let amount: number;
|
||||
@@ -3556,7 +3584,8 @@ function remapState(
|
||||
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
|
||||
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
|
||||
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) {
|
||||
if (inSector(p.position)) p.position = mapCell(p.position);
|
||||
@@ -4056,7 +4085,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
||||
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
|
||||
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params, command.numberInstanceIds);
|
||||
if (command.type === "pass") return doPass(state, playerId);
|
||||
return err("an attack is being resolved — counteract or pass");
|
||||
return err("an attack is being resolved, so counteract or pass");
|
||||
}
|
||||
|
||||
if (state.chaosPending) {
|
||||
@@ -4083,7 +4112,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
||||
finishChaosIfReady(st, events);
|
||||
return { ok: true, state: st, events };
|
||||
}
|
||||
return err("chaos is coming — shield your hand or pass");
|
||||
return err("chaos is coming, so shield your hand or pass");
|
||||
}
|
||||
|
||||
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
|
||||
@@ -4119,7 +4148,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
|
||||
if (!fx || fx.kind !== "attack") return err("opportunity fire permits an attack");
|
||||
}
|
||||
if (kind === "interrupt" && command.type === "punch") {
|
||||
return err("interrupt lets you cast a spell, not brawl");
|
||||
return err("an interruption allows a spell but not a punch");
|
||||
}
|
||||
st.outOfTurnWindow = null;
|
||||
const result = command.type === "cast" ? doCast(st, command) : doPunch(st, command.targetId);
|
||||
@@ -4350,7 +4379,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
const shimmer = state.illusionWalls[key];
|
||||
if (shimmer && shimmer.createdBy !== p.id && !shimmer.belief[p.id]) {
|
||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||
return err("the wall shimmers oddly — test your eyes before you can pass");
|
||||
return err("the wall shimmers oddly, and you must test your eyes before you can pass");
|
||||
}
|
||||
if (shimmer && illusionBelief(state, p.id, key) === "believes") {
|
||||
if (isBlinded(state, p)) return blindBump(state, events, p, direction);
|
||||
@@ -4505,7 +4534,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
}
|
||||
landing = canShoveTo(exits.includes(direction) ? direction : exits[0]!);
|
||||
} else {
|
||||
if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen} — the corridor leads ${exits.join(" or ")}`); }
|
||||
if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen}, because the corridor leads ${exits.join(" or ")}`); }
|
||||
landing = canShoveTo(chosen);
|
||||
}
|
||||
} else {
|
||||
@@ -4556,16 +4585,16 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
|
||||
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
|
||||
if (exits.length === 0) {
|
||||
p.position = from;
|
||||
return err("the pit cannot be crossed from here — nothing to land on beside it");
|
||||
return err("the pit cannot be crossed from here, with nothing to land on beside it");
|
||||
}
|
||||
if (exit !== undefined && !exits.includes(exit)) {
|
||||
p.position = from;
|
||||
return err(`no footing that way — the pit's rim leads ${exits.join(" or ")}`);
|
||||
return err(`no footing that way, because the pit's rim leads ${exits.join(" or ")}`);
|
||||
}
|
||||
landing = exit ?? (bare.length === 1 ? bare[0]! : null);
|
||||
if (landing === null) {
|
||||
p.position = from;
|
||||
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
|
||||
return err(`the rim leads ${exits.join(" or ")}, so click the square to land on`);
|
||||
}
|
||||
const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell };
|
||||
landingCell = foot.to;
|
||||
@@ -4750,7 +4779,7 @@ function doPlayNumberForMovement(prev: GameState, instanceId: string, addInstanc
|
||||
if (blocked) return err(blocked);
|
||||
const mover = activePlayer(prev);
|
||||
if (sustainedOn(prev, mover.id, "slow").length > 0) {
|
||||
return err("you are slowed — no number cards for movement");
|
||||
return err("a slowed wizard cannot play number cards for movement");
|
||||
}
|
||||
// "You may add two NUMBER cards together for any single action" — an ADD
|
||||
// permits one extra movement number this turn.
|
||||
@@ -4803,7 +4832,7 @@ function attackPreconditions(state: GameState): string | null {
|
||||
}
|
||||
return "you may attack only once per turn";
|
||||
}
|
||||
if (state.turn.attackForbidden) return "you are slowed — no attack this turn";
|
||||
if (state.turn.attackForbidden) return "a slowed wizard cannot attack this turn";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4822,7 +4851,7 @@ function castingBlocked(
|
||||
opts?: { counteraction?: boolean; cardId?: string; targetPlayerId?: PlayerId; pickedCardId?: string },
|
||||
): string | null {
|
||||
if (sustainedOn(state, playerId, "medusa").length > 0) return "you are paralyzed by Medusa";
|
||||
if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell — you cannot cast";
|
||||
if (sustainedOn(state, playerId, "no-spell").length > 0) return "No Spell holds you, and you cannot cast";
|
||||
// IDIOT: "He can do nothing else but cast COUNTERACTION spells (if
|
||||
// needed)" — and per the FAQ, spells that aid the march to the treasure.
|
||||
if (sustainedOn(state, playerId, "idiot").length > 0 &&
|
||||
@@ -4875,17 +4904,17 @@ function liftIdiotIfSatisfied(state: GameState, events: GameEvent[], p: PlayerSt
|
||||
function attackBlockedByStatus(state: GameState, attacker: PlayerState, target: PlayerState): string | null {
|
||||
if (sustainedOn(state, target.id, "big-man").length > 0 &&
|
||||
cellKey(attacker.position) === cellKey(target.position)) {
|
||||
return "he fills the corridor — there is no room to swing";
|
||||
return "he fills the corridor and leaves no room to swing";
|
||||
}
|
||||
if (inThornbush(state, attacker)) return "you cannot attack from inside a thornbush";
|
||||
if (inThornbush(state, target)) return "you cannot attack someone in a thornbush";
|
||||
if (isMisted(state, attacker.id)) return "you are mist — you may not attack";
|
||||
if (isMisted(state, attacker.id)) return "as mist, you may not attack";
|
||||
if (isMisted(state, target.id)) return "your target is mist and cannot be attacked";
|
||||
// BUDDY: "Opponent will not attack you unless you attack first."
|
||||
const buddy = state.sustained.find(
|
||||
(s) => s.cardId === "buddy" && s.casterId === target.id && s.targetId === attacker.id,
|
||||
);
|
||||
if (buddy) return "the Buddy pact holds — you cannot bring yourself to attack them";
|
||||
if (buddy) return "the Buddy pact holds, and you cannot bring yourself to attack them";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4974,7 +5003,7 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
|
||||
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
|
||||
/** Compat stub for stale clients: the Ward has no arming step. */
|
||||
function doArmWard(): CommandResult {
|
||||
return err("the Ward is played in the moment — you will be asked when your treasure is grabbed");
|
||||
return err("the Ward is played in the moment, and you will be asked when your treasure is grabbed");
|
||||
}
|
||||
|
||||
/** Player ids in seat order, starting after `fromId`. */
|
||||
@@ -5020,7 +5049,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
|
||||
const key = edgeKey(cell, side);
|
||||
const wall = state.illusionWalls[key];
|
||||
if (!wall) return err("there is no illusion there");
|
||||
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
|
||||
if (wall.createdBy === p.id) return err("you made it, so you know exactly what it is");
|
||||
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
|
||||
const events: GameEvent[] = [];
|
||||
const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id)));
|
||||
@@ -5356,13 +5385,13 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
const ego = state.creatures.find((c) => c.id === cmd.via);
|
||||
if (!ego || ego.kind !== "alter-ego" || ego.controllerId !== caster.id) return err("that is not your double");
|
||||
if (!cmd.target) return err("your double casts only at a target");
|
||||
if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body — not your double's to cast`);
|
||||
if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body, so your double cannot cast it`);
|
||||
origin = ego.position;
|
||||
}
|
||||
const effect = CARD_EFFECTS[inHand.cardId];
|
||||
if (!effect) return err(`${def.name} is not implemented yet`);
|
||||
if (effect.kind === "counter") {
|
||||
return err(`${def.name} is a counteraction — play it in response to an attack`);
|
||||
return err(`${def.name} is a counteraction, played in response to an attack`);
|
||||
}
|
||||
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
|
||||
return err(`${def.name} is already displayed`);
|
||||
@@ -5569,8 +5598,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (werr) return err(werr);
|
||||
}
|
||||
if (likeAWall && mods.powerAttackPoints > 0) {
|
||||
caster.life -= mods.powerAttackPoints;
|
||||
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
|
||||
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
|
||||
}
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||
@@ -5666,8 +5694,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (werr) return err(werr);
|
||||
}
|
||||
if (mods.powerAttackPoints > 0) {
|
||||
caster.life -= mods.powerAttackPoints;
|
||||
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
|
||||
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
|
||||
}
|
||||
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
|
||||
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
|
||||
@@ -5758,8 +5785,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
||||
if (werr) return err(werr);
|
||||
}
|
||||
if (mods.powerAttackPoints > 0) {
|
||||
caster.life -= mods.powerAttackPoints;
|
||||
wandEvents.push({ type: "lifeTraded", player: caster.id, points: mods.powerAttackPoints, newAllowance: state.turn.movementAllowance });
|
||||
payPowerAttack(state, caster, mods.powerAttackPoints, wandEvents);
|
||||
}
|
||||
|
||||
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
|
||||
@@ -5905,7 +5931,7 @@ function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush"
|
||||
if (!spell) return err("the attack to commit is not in your hand");
|
||||
const fx = CARD_EFFECTS[spell.cardId];
|
||||
if (!fx || fx.kind !== "attack") return err("commit an attack spell to the ambush");
|
||||
if (fx.sameSquare) return err("that attack needs to share a square — no good from ambush");
|
||||
if (fx.sameSquare) return err("that attack needs a shared square, so it cannot be sprung from ambush");
|
||||
|
||||
const numbers: CardInstance[] = [];
|
||||
for (const id of cmd.numberInstanceIds ?? []) {
|
||||
@@ -5923,7 +5949,7 @@ function doSetAmbush(prev: GameState, cmd: Extract<Command, { type: "setAmbush"
|
||||
if (state.squareContents[cellKey(cmd.cell)]?.kind === "stone") return err("that destination is solid stone");
|
||||
}
|
||||
if (!cmd.trigger || !["los", "near", "treasure"].includes(cmd.trigger.kind)) {
|
||||
return err("choose a trigger: line of sight, close approach, or treasure");
|
||||
return err("choose a trigger (line of sight, close approach, or treasure)");
|
||||
}
|
||||
|
||||
// Commit the cards out of the hand; they return if the ambush is cancelled.
|
||||
@@ -6036,17 +6062,23 @@ function checkAmbushes(
|
||||
}
|
||||
}
|
||||
|
||||
/** Damage dealt inside an attack's own resolution hook still honors
|
||||
* EMPATHY — "Any attack done in ANY form against you acts against both you
|
||||
* and the caster". */
|
||||
/** The goat's charge: the corridor distance to its mark, and the legs
|
||||
* this turn has left to cover it. */
|
||||
function charge(state: GameState, attacker: PlayerState, defender: PlayerState): { d: number; legs: number } {
|
||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
||||
return { d: walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs)), legs };
|
||||
}
|
||||
|
||||
/** How far the goat charges: the corridor distance to its mark within
|
||||
* this turn's legs, or zero when the mark is underfoot or out of reach. */
|
||||
function ramDistance(state: GameState, attacker: PlayerState, defender: PlayerState): number {
|
||||
const legs = state.turn.movementAllowance - state.turn.movementUsed;
|
||||
const d = walkingDistance(state, attacker.position, defender.position, chargeReach(state, legs));
|
||||
const { d, legs } = charge(state, attacker, defender);
|
||||
return d > legs ? 0 : d;
|
||||
}
|
||||
|
||||
/** Damage dealt inside an attack's own resolution hook still honors
|
||||
* EMPATHY — "Any attack done in ANY form against you acts against both you
|
||||
* and the caster". */
|
||||
function resolvedBlow(
|
||||
ctx: ResolutionContext, dmg: number, source: string, kind?: "physical",
|
||||
): void {
|
||||
@@ -6057,6 +6089,12 @@ function resolvedBlow(
|
||||
}
|
||||
}
|
||||
|
||||
/** POWER ATTACK: the caster pays in life for the extra points. */
|
||||
function payPowerAttack(state: GameState, caster: PlayerState, points: number, events: GameEvent[]): void {
|
||||
caster.life -= points;
|
||||
events.push({ type: "lifeTraded", player: caster.id, points, newAllowance: state.turn.movementAllowance });
|
||||
}
|
||||
|
||||
/** A NUMBER card riding a counteraction sets the raised spell's duration. */
|
||||
function counterDuration(state: GameState, player: PlayerState, numberInstanceIds?: string[]): number {
|
||||
const num = (numberInstanceIds ?? [])
|
||||
@@ -6202,7 +6240,7 @@ function doCounteract(
|
||||
// duration-based spells, reducing effects by [its] value."
|
||||
if (isNumberCard(card.cardId)) {
|
||||
if (!displays(player, "shieldstone")) return err("only a displayed Shieldstone lets you counter with number cards");
|
||||
if (stack.kind !== "spell") return err("shieldstone counters spells, not physical attacks");
|
||||
if (stack.kind !== "spell") return err("shieldstone cannot counter a physical attack");
|
||||
takeFromHand(player, instanceId);
|
||||
state.discard.push(card);
|
||||
stack.counters.push({ player: playerId, card, nullified: false });
|
||||
@@ -6248,7 +6286,7 @@ function doCounteract(
|
||||
if (!targetCounter) return err("no counteraction to absorb");
|
||||
const id = targetCounter.card.cardId;
|
||||
if (id !== "reflection" && id !== "full-reflection") {
|
||||
return err(`${cardDef(id).name} is not cast at you — it cannot be absorbed`);
|
||||
return err(`${cardDef(id).name} is not cast at you, so it cannot be absorbed`);
|
||||
}
|
||||
takeFromHand(player, instanceId);
|
||||
state.discard.push(card);
|
||||
@@ -6386,6 +6424,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
// Rev 26: BUTT-HEAD's ram is priced here, as the physical blow it is,
|
||||
// so everything below weighs it like any other.
|
||||
if (attackId === "butt-head" && (state.config.deckRev ?? 1) >= 26) base = ramDistance(state, attacker, defender);
|
||||
// Rev 27: an ILLUSIONARY ATTACK is priced as the spell it fakes, so the
|
||||
// trail weighs it like the real thing. Whether the weighed blow lands at
|
||||
// all is the belief roll's, taken below once the trail has spoken.
|
||||
if (attackId === "illusionary-attack" && (state.config.deckRev ?? 1) >= 27 && stack.params?.cardId) {
|
||||
const faked = CARD_EFFECTS[stack.params.cardId];
|
||||
if (faked?.kind === "attack") base = faked.baseDamage(stack.numberValue, stack.params);
|
||||
}
|
||||
base *= stack.amplifyFactor;
|
||||
base += stack.powerAttackPoints;
|
||||
// SWARTHMORE'S ENCHANTMENT: an enchanted thrown object bites one deeper.
|
||||
@@ -6482,6 +6527,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
|
||||
}
|
||||
weigh();
|
||||
|
||||
// Rev 27: "Your opponent has a 50% chance of believing the spell to be
|
||||
// real. If so, he takes damage from the spell" — the weighed damage, the
|
||||
// counteractions having met the illusion as they would the real spell.
|
||||
// Disbelief leaves nothing to land, and a total stop leaves nothing to
|
||||
// believe in, so no die is rolled then.
|
||||
if (attackId === "illusionary-attack" && (state.config.deckRev ?? 1) >= 27 && !stack.reflectedBase &&
|
||||
(pipe.damage > 0 || pipe.reflectedDamage > 0 || pipe.duration > 0)) {
|
||||
const [roll, rngNext] = rollDie(state.rng);
|
||||
state.rng = rngNext;
|
||||
const believed = roll <= 2;
|
||||
events.push({ type: "illusionBelieved", player: defender.id, cardId: stack.params!.cardId!, believed });
|
||||
if (!believed) {
|
||||
pipe.damage = 0;
|
||||
pipe.reflectedDamage = 0;
|
||||
pipe.duration = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// A surviving teleport counter whisks the defender away before anything lands.
|
||||
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
|
||||
|
||||
@@ -1021,7 +1021,7 @@ describe("interception, escape, and hazard sense", () => {
|
||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||
expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } });
|
||||
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.
|
||||
const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
|
||||
expect(next).toMatchObject({ type: "pickUpTreasure" });
|
||||
@@ -1050,7 +1050,7 @@ describe("interception, escape, and hazard sense", () => {
|
||||
cmd = automatonFallback(view, "archmage");
|
||||
r = applyCommand(state, seat, cmd);
|
||||
}
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) throw new Error(`${seat}: ${r.error}`);
|
||||
state = r.state;
|
||||
ended = seat === "bot" && cmd.type === "endTurn";
|
||||
}
|
||||
|
||||
@@ -931,15 +931,37 @@ describe("the imp's fire is a spell (rev 8)", () => {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
|
||||
// The scorch rides the stack now: a counteraction window, spell-kind.
|
||||
// The scorch rides the stack as a spell-kind counteraction window,
|
||||
// named for the imp: the counter is played against the scorch.
|
||||
expect(state.stack?.creatureId).toBeTruthy();
|
||||
expect(state.stack?.kind).toBe("spell");
|
||||
expect(state.stack?.sourceName).toBe("fire imp's scorch");
|
||||
giveCard(state, "mark", "full-shield", "FS", 0);
|
||||
state = must(state, "mark", { type: "counteract", instanceId: "full-shield#FS" });
|
||||
state = drain(state);
|
||||
const countered = applyCommand(state, "mark", { type: "counteract", instanceId: "full-shield#FS" });
|
||||
if (!countered.ok) throw new Error(countered.error);
|
||||
expect(countered.events).toContainEqual(expect.objectContaining({ type: "counteractionPlayed", against: "fire imp's scorch" }));
|
||||
state = drain(countered.state);
|
||||
expect(state.players.find((p) => p.id === "mark")!.life).toBe(15);
|
||||
});
|
||||
|
||||
it("an uncountered scorch is dealt in the imp's name", () => {
|
||||
let state = impRig();
|
||||
const owner = state.players.find((p) => p.id === "imp-owner")!;
|
||||
const mark = state.players.find((p) => p.id === "mark")!;
|
||||
mark.position = { ...owner.position };
|
||||
const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 0);
|
||||
const spot = emptyNeighborCell(state, owner.position);
|
||||
let r = applyCommand(state, "imp-owner", {
|
||||
type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
state = r.state;
|
||||
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
|
||||
const done = applyCommand(state, "mark", { type: "pass" });
|
||||
if (!done.ok) throw new Error(done.error);
|
||||
expect(done.events).toContainEqual(expect.objectContaining({ type: "damaged", player: "mark", amount: 2, source: "fire imp's scorch" }));
|
||||
});
|
||||
|
||||
it("soulstone's floor holds from below: a bearer at 2 takes nothing from the flame", () => {
|
||||
let state = impRig();
|
||||
const owner = state.players.find((p) => p.id === "imp-owner")!;
|
||||
@@ -1022,3 +1044,64 @@ describe("the troll's fist on a wall", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
let { state, cell, side, holder, pursuer } = sightRig();
|
||||
const rm = giveCard(state, holder, "remove-lock");
|
||||
|
||||
@@ -799,7 +799,7 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
|
||||
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
|
||||
});
|
||||
if (!r.ok) {
|
||||
if (/legs, not wings/.test(r.error)) refusals += 1;
|
||||
if (/cannot carry the charge that far/.test(r.error)) refusals += 1;
|
||||
continue;
|
||||
}
|
||||
const st = drain(r.state);
|
||||
@@ -839,7 +839,7 @@ describe("butt-head's charge reaches as far as its legs (rev 16)", () => {
|
||||
const card = giveCard(state, goat.id, "butt-head");
|
||||
const r = applyCommand(state, goat.id, { type: "cast", instanceId: card.instanceId, target: { kind: "player", playerId: mark.id } });
|
||||
expect(r.ok).toBe(ok);
|
||||
if (!r.ok) expect(r.error).toMatch(/legs, not wings/);
|
||||
if (!r.ok) expect(r.error).toMatch(/cannot carry the charge that far/);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -939,3 +939,62 @@ describe("the goat's ram is a physical blow like any other (rev 26)", () => {
|
||||
expect(state.players.find((p) => p.id === mark)!.life).toBe(15 - distance);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an illusion is priced as the spell it fakes (rev 27)", () => {
|
||||
/** A faked FIREBALL met by the given counteraction, then both wizards pass. */
|
||||
function fakedFireball(counter: string | null, deckRev?: number, seed = 42) {
|
||||
let { state } = createGame({ playerIds: ["liar", "mark"], seed, sets: ["basic", "expansion1"], ...(deckRev ? { deckRev } : {}) });
|
||||
state = toRound2(state);
|
||||
const { attacker, defender } = faceOff(state);
|
||||
const ill = giveCard(state, attacker, "illusionary-attack");
|
||||
if (counter) giveCard(state, defender, counter, "C", 0);
|
||||
state = must(state, attacker, {
|
||||
type: "cast", instanceId: ill.instanceId, target: { kind: "player", playerId: defender }, params: { cardId: "fireball" },
|
||||
});
|
||||
let done = applyCommand(state, defender, { type: "pass" });
|
||||
if (counter) {
|
||||
state = must(state, defender, { type: "counteract", instanceId: `${counter}#C` });
|
||||
// The attacker's pass settles a total stop at once; otherwise the
|
||||
// defender's pass closes the window.
|
||||
done = applyCommand(state, attacker, { type: "pass" });
|
||||
if (done.ok && done.state.stack) done = applyCommand(done.state, defender, { type: "pass" });
|
||||
}
|
||||
if (!done.ok) throw new Error(done.error);
|
||||
const belief = done.events.find((e) => e.type === "illusionBelieved");
|
||||
const believed = belief?.type === "illusionBelieved" ? belief.believed : null;
|
||||
return { events: done.events, believed, life: done.state.players.find((p) => p.id === defender)!.life };
|
||||
}
|
||||
|
||||
/** The first seed whose die comes up each way: one believed, one disbelieved. */
|
||||
function bothWays(counter: string | null, deckRev?: number) {
|
||||
const found: Partial<Record<"believed" | "doubted", ReturnType<typeof fakedFireball>>> = {};
|
||||
for (let seed = 1; seed <= 40 && !(found.believed && found.doubted); seed++) {
|
||||
const r = fakedFireball(counter, deckRev, seed);
|
||||
expect(r.believed).not.toBeNull();
|
||||
found[r.believed ? "believed" : "doubted"] ??= r;
|
||||
}
|
||||
return { believed: found.believed!, doubted: found.doubted! };
|
||||
}
|
||||
|
||||
it("BLUNT halves a believed fake fireball to three, and disbelief costs nothing", () => {
|
||||
const { believed, doubted } = bothWays("blunt");
|
||||
expect(believed.events).toContainEqual(expect.objectContaining({ type: "attackResolved", incoming: 5 }));
|
||||
expect(believed.life).toBe(12);
|
||||
expect(doubted.life).toBe(15);
|
||||
});
|
||||
|
||||
it("FULL SHIELD stops the fake outright: nothing to believe in, no die rolled", () => {
|
||||
const { events, believed, life } = fakedFireball("full-shield");
|
||||
expect(believed).toBeNull();
|
||||
expect(events).toContainEqual(expect.objectContaining({ type: "attackResolved", fullyStopped: true }));
|
||||
expect(life).toBe(15);
|
||||
});
|
||||
|
||||
it("uncountered, a believed fake fireball still does its five", () => {
|
||||
expect(bothWays(null).believed.life).toBe(10);
|
||||
});
|
||||
|
||||
it("older games dealt a believed illusion's full points past a BLUNT", () => {
|
||||
expect(bothWays("blunt", 26).believed.life).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
|
||||
const me = activePlayer(state);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/node": "^10.73.0",
|
||||
@@ -16,6 +17,7 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/ws": "^8.5.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
|
||||
"NEW PREMISES. SAME AMBITIONS.",
|
||||
"PLEASE UPDATE YOUR ADDRESS BOOK.",
|
||||
"COMMUTING IS FOR PEOPLE WITHOUT SPELLS.",
|
||||
"A CHANGE OF ADDRESS, NOT OF OWNERSHIP.",
|
||||
"MY OFFICES REMAIN OPEN AT THE NEW ADDRESS.",
|
||||
],
|
||||
springTrap: [
|
||||
"AN UNFORESEEN LIABILITY.",
|
||||
@@ -116,7 +116,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
|
||||
"YOU MAY WISH TO REVISE YOUR PROJECTION.",
|
||||
],
|
||||
win: [
|
||||
"PLEASURE DOING BUSINESS WITH ALL OF YOU.",
|
||||
"PLEASURE DOING BUSINESS WITH THIS TABLE.",
|
||||
"THE BOOKS BALANCE. IN MY FAVOR.",
|
||||
"A SATISFACTORY RETURN ON WIZARDRY.",
|
||||
"THANK YOU FOR YOUR CONTRIBUTIONS.",
|
||||
@@ -147,7 +147,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
|
||||
],
|
||||
dealPain: [
|
||||
"YES. AGAIN.",
|
||||
"PAIN RECEIPT PRINTED.",
|
||||
"DID THAT STING? GOOD.",
|
||||
"HOLD STILL. THIS IS THE FUN PART.",
|
||||
"THE MAZE PROVIDES.",
|
||||
"THAT IS THE SOUND I CAME FOR.",
|
||||
@@ -166,7 +166,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
|
||||
"I LIKE YOUR ATTITUDE. KEEP IT WITHIN REACH.",
|
||||
],
|
||||
kill: [
|
||||
"SCHEDULED DEMISE: DELIVERED.",
|
||||
"THAT ONE WILL NOT BE GETTING UP.",
|
||||
"NEXT.",
|
||||
"THE PILE GROWS.",
|
||||
"THE MAZE ECHOES BETTER NOW.",
|
||||
@@ -198,7 +198,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
|
||||
"A LITTLE SCENERY FOR OUR NEXT ARGUMENT.",
|
||||
],
|
||||
escape: [
|
||||
"A TACTICAL LEAP. NOT A RETREAT.",
|
||||
"IT WAS A TACTICAL LEAP. WRITE THAT DOWN.",
|
||||
"A BETTER ANGLE FOR THE NEXT BLOW.",
|
||||
"I GO WHERE THE TROUBLE NEEDS ME.",
|
||||
"SAME FEUD. DIFFERENT SQUARE.",
|
||||
@@ -222,7 +222,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
|
||||
"ALL THAT FUSS. NO BRUISE.",
|
||||
],
|
||||
win: [
|
||||
"LAST ONE STANDING. AS FORETOLD.",
|
||||
"VICTORY. AS FORETOLD.",
|
||||
"A FINE MAZE. LET US DO THAT AGAIN.",
|
||||
"I WAS JUST GETTING COMFORTABLE.",
|
||||
"VICTORY! WHO WANTS A REMATCH?",
|
||||
@@ -242,7 +242,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
|
||||
"this is going to make me conspicuous.",
|
||||
],
|
||||
deliverGold: [
|
||||
"home. safe. breathe.",
|
||||
"home. safe. i can breathe again.",
|
||||
"there. i would like to stop carrying problems now.",
|
||||
"stay. please be good at staying.",
|
||||
"down you go. quietly.",
|
||||
@@ -252,7 +252,7 @@ export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, str
|
||||
"good. now i can wring both hands.",
|
||||
],
|
||||
dealPain: [
|
||||
"ow. logged.",
|
||||
"oh! sorry. was that too hard?",
|
||||
"unnecessary!",
|
||||
"that looked like it hurt. it was supposed to. i think.",
|
||||
"could we consider that a warning and stop?",
|
||||
|
||||
@@ -90,11 +90,11 @@ function cardImageMetas(clip: ClipMeta, base: string): string[] {
|
||||
|
||||
export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
||||
const metas = [
|
||||
`<title>Wiz-War — clips from the labyrinth</title>`,
|
||||
`<title>Wiz-War clips from the labyrinth</title>`,
|
||||
`<meta property="og:type" content="website"/>`,
|
||||
`<meta property="og:site_name" content="Wiz-War"/>`,
|
||||
`<meta property="og:title" content="Wiz-War — clips from the labyrinth"/>`,
|
||||
`<meta property="og:description" content="Short scenes of magical combat in a stone maze: wormhole heists, warp snipers, ambushes, and the walls coming down — each seen through the wizard's own eyes and from the board above."/>`,
|
||||
`<meta property="og:title" content="Wiz-War clips from the labyrinth"/>`,
|
||||
`<meta property="og:description" content="Short scenes of magical combat in a stone maze, from wormhole heists and warp snipers to ambushes and falling walls, each seen through the wizard's own eyes and from the board above."/>`,
|
||||
`<meta property="og:url" content="${base}/clips"/>`,
|
||||
...(clips[0] ? cardImageMetas(clips[0], base) : []),
|
||||
`<meta name="twitter:card" content="summary_large_image"/>`,
|
||||
@@ -115,13 +115,13 @@ export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
|
||||
Each clip has a first-person take and a board take of the same moves.</p>
|
||||
<div class="grid">${cards}</div>
|
||||
<p class="foot">Wiz-War is Tom Jolly's classic of magical combat.
|
||||
This is a fan-built table — <a href="/">deal yourself in</a>.</p>`;
|
||||
This is a fan-built table, and you can <a href="/">deal yourself in</a>.</p>`;
|
||||
return shell(metas, body);
|
||||
}
|
||||
|
||||
export function clipPageHtml(clip: ClipMeta, base: string): string {
|
||||
const metas = [
|
||||
`<title>${esc(clip.title)} — a Wiz-War clip</title>`,
|
||||
`<title>${esc(clip.title)} · Wiz-War clip</title>`,
|
||||
`<meta property="og:type" content="video.other"/>`,
|
||||
`<meta property="og:site_name" content="Wiz-War"/>`,
|
||||
`<meta property="og:title" content="${esc(clip.title)}"/>`,
|
||||
@@ -152,7 +152,7 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
|
||||
<video src="/clips/${clip.name}-board.mp4" controls playsinline preload="metadata"></video>
|
||||
</div>
|
||||
</div>
|
||||
<p class="foot">Played by the real rules engine — every move on screen is a legal move.
|
||||
<p class="foot">Played by the real rules engine, so every move on screen is a legal move.
|
||||
<a href="/">Deal yourself in</a>.</p>
|
||||
<script>${SHARE_JS}</script>`;
|
||||
return shell(metas, body);
|
||||
|
||||
+158
-25
@@ -64,6 +64,7 @@ import {
|
||||
GALLERY_MARK,
|
||||
driveOneAutomaton,
|
||||
makeTransferCode,
|
||||
makeSeatPhrase,
|
||||
redactFor,
|
||||
roomCount,
|
||||
runCommand,
|
||||
@@ -74,6 +75,7 @@ import {
|
||||
viewForPlayer,
|
||||
type Room,
|
||||
kickSeat,
|
||||
standIn,
|
||||
abandonRoom,
|
||||
KEEPER,
|
||||
MAX_SEATS,
|
||||
@@ -81,6 +83,7 @@ import {
|
||||
import { engagementStats, recordHotseat } from "./stats";
|
||||
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store";
|
||||
import { clipsIndexHtml, clipPageHtml } from "./clips";
|
||||
import { openKeeperDoor } from "./keeper";
|
||||
import { SlidingLimit, clientAddress } from "./ratelimit";
|
||||
import { getShare, loadShares, mintShare } from "./shares";
|
||||
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. */
|
||||
const imagesPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
|
||||
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
|
||||
* per report, within the hour, PNG/JPEG/WebP by its own first bytes, kept
|
||||
@@ -257,7 +306,7 @@ function ogPage(metas: string[]): string {
|
||||
/** Host and proto arrive from request headers — attacker-writable text
|
||||
* that must never reach an HTML attribute raw. */
|
||||
const noSuchClip = (res: ServerResponse) =>
|
||||
res.writeHead(404, { "content-type": "text/plain" }).end("no such clip — see /clips");
|
||||
res.writeHead(404, { "content-type": "text/plain" }).end("no such clip (see /clips)");
|
||||
|
||||
/** The absolute origin a request came in on, as the proxy saw it. */
|
||||
function requestBase(req: IncomingMessage): string {
|
||||
@@ -282,9 +331,9 @@ function headlineOf(data: ShareData): string | null {
|
||||
for (const step of data.steps) {
|
||||
for (const e of step.events) {
|
||||
switch (e.type) {
|
||||
case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two treasures home` : `${e.player} is the last wizard standing`); break;
|
||||
case "gameWon": offer(9, e.reason === "treasures" ? `${e.player} wins with two stolen treasures home` : `${e.player} is the last wizard standing`); break;
|
||||
case "playerEliminated": offer(8, e.reason === "killed" ? `${e.player} falls in the maze` : `${e.player} loses both treasures`); break;
|
||||
case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a stolen treasure home`); break;
|
||||
case "treasureDropped": if (e.onHomeOf === e.player) offer(7, `${e.player} carries a treasure home`); break;
|
||||
case "treasurePickedUp": if (e.owner !== e.player) offer(5, `${e.player} snatches ${e.owner}'s treasure`); break;
|
||||
case "attackResolved": if (e.damageDealt > 0) offer(6, `${e.attacker} hits ${e.defender} with ${name(e.attackCardId)} for ${e.damageDealt}`); else if (e.fullyStopped) offer(4, `${e.defender} stops ${e.attacker}'s ${name(e.attackCardId)} cold`); break;
|
||||
case "punched": offer(3, `${e.attacker} punches ${e.target}`); break;
|
||||
@@ -306,12 +355,12 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
|
||||
const base = safeBase(rawHost, rawProto);
|
||||
const headline = data.whole ? null : headlineOf(data);
|
||||
const title = data.whole
|
||||
? "The whole tale — a game of Wiz-War, replayed"
|
||||
? "A whole game of Wiz-War, replayed"
|
||||
: headline
|
||||
? `${escapeHtml(headline)} — a Wiz-War instant replay`
|
||||
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
|
||||
? `${escapeHtml(headline)} · Wiz-War instant replay`
|
||||
: `${escapeHtml(data.actor)}'s turn · Wiz-War instant replay`;
|
||||
const desc = data.whole
|
||||
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
|
||||
? `A full game of Wiz-War, magical combat in a stone labyrinth, with every turn seen through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
|
||||
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
|
||||
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
|
||||
const metas = [
|
||||
@@ -336,11 +385,11 @@ function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
|
||||
const base = safeBase(rawHost, rawProto);
|
||||
const seats = room.players.length;
|
||||
const wizards = `${seats} wizard${seats === 1 ? "" : "s"}`;
|
||||
const title = `You're summoned — Wiz-War room ${room.id}`;
|
||||
const title = `You're summoned to Wiz-War room ${room.id}`;
|
||||
const desc = room.state?.phase === "finished"
|
||||
? `The tale is told — ${wizards} fought in this labyrinth. Follow the link to see how it ended.`
|
||||
? `The tale is told. ${wizards} fought in this labyrinth. Follow the link to see how it ended.`
|
||||
: room.state
|
||||
? `The duel is underway, ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
|
||||
? `The game is underway, with ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
|
||||
: `${wizards} at the table, waiting to flip the boards. Follow the link, pick a name, and take a seat.`;
|
||||
const metas = [
|
||||
`<title>${title}</title>`,
|
||||
@@ -369,6 +418,7 @@ const httpServer = createServer((req, res) => {
|
||||
if (req.method === "POST") {
|
||||
const picture = (req.url ?? "").split("?")[0]!.match(/^\/api\/feedback-image\/([0-9a-f]{8})$/);
|
||||
if (picture) { receiveFeedbackImage(req, res, picture[1]!); return; }
|
||||
if ((req.url ?? "").split("?")[0] === "/api/reports") { receiveBrowserReports(req, res); return; }
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
@@ -377,6 +427,8 @@ const httpServer = createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
res.writeHead(404).end("client not built");
|
||||
return;
|
||||
@@ -535,6 +587,27 @@ const httpServer = createServer((req, res) => {
|
||||
const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 });
|
||||
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 {
|
||||
socket: WebSocket;
|
||||
playerId: PlayerId | null;
|
||||
@@ -554,6 +627,8 @@ interface Session {
|
||||
roomsCreated: number;
|
||||
lastCatchUpAt: number;
|
||||
hotseatReports: number;
|
||||
/** Answered the last protocol ping. */
|
||||
alive: boolean;
|
||||
}
|
||||
|
||||
function underRateLimit(s: Session): boolean {
|
||||
@@ -567,6 +642,21 @@ function underRateLimit(s: Session): boolean {
|
||||
|
||||
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 {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||||
}
|
||||
@@ -606,12 +696,15 @@ function roomInfo(room: Room) {
|
||||
keeperZone: KEEPER_ZONE,
|
||||
galleryTalk: room.galleryTalk,
|
||||
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(
|
||||
// A mystery machine keeps its mood only while the game lives: once it
|
||||
// ends, the hands go face-up and so does the temperament.
|
||||
[...room.bots].map(([name, b]) => [
|
||||
name,
|
||||
room.state?.phase === "finished"
|
||||
b.standIn ? "standing in"
|
||||
: room.state?.phase === "finished"
|
||||
? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}`
|
||||
: `${b.tier} ${b.secret ? "mystery" : b.style}`,
|
||||
]),
|
||||
@@ -665,6 +758,8 @@ function banterTrigger(
|
||||
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
|
||||
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
|
||||
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] ?? {};
|
||||
for (const e of events) {
|
||||
const trigger = banterTrigger(e, seat, actor);
|
||||
@@ -706,7 +801,7 @@ function runBots(room: Room): void {
|
||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
||||
// A refused brain-choice becomes a visible stumble, in character —
|
||||
// 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)]!);
|
||||
if (!("error" in said)) {
|
||||
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
|
||||
@@ -727,7 +822,7 @@ function broadcastRoomState(room: Room): void {
|
||||
|
||||
wss.on("connection", (socket, req) => {
|
||||
if (sessions.size >= MAX_SOCKETS) {
|
||||
send(socket, { type: "error", message: "the tavern is packed — try again shortly" });
|
||||
send(socket, { type: "error", message: "the tavern is packed; try again shortly" });
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
@@ -735,9 +830,12 @@ wss.on("connection", (socket, req) => {
|
||||
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
|
||||
address: clientAddress(req.headers, req.socket.remoteAddress),
|
||||
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
|
||||
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
|
||||
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, alive: true,
|
||||
};
|
||||
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.
|
||||
socket.on("error", () => socket.terminate());
|
||||
send(socket, { type: "welcome", game: "wizwar" });
|
||||
|
||||
socket.on("close", () => {
|
||||
@@ -771,10 +869,10 @@ wss.on("connection", (socket, req) => {
|
||||
const name = cleanName(msg.name);
|
||||
if (!name) return send(socket, { type: "error", message: "name required" });
|
||||
if (session.roomsCreated >= MAX_ROOMS_PER_CONN || roomCount() >= MAX_ROOMS) {
|
||||
return send(socket, { type: "error", message: "no new rooms right now — try again later" });
|
||||
return send(socket, { type: "error", message: "no new rooms are opening right now, so try again later" });
|
||||
}
|
||||
if (!roomsPerAddress.allow(session.address)) {
|
||||
return send(socket, { type: "error", message: "that's a lot of new tables from one place — try again in an hour" });
|
||||
return send(socket, { type: "error", message: "this address can open more tables in an hour" });
|
||||
}
|
||||
session.roomsCreated++;
|
||||
leaveGallery(session);
|
||||
@@ -845,7 +943,7 @@ wss.on("connection", (socket, req) => {
|
||||
const room = getRoom(roomId);
|
||||
if (!room) return send(socket, { type: "error", message: "no such room" });
|
||||
if (audienceCount(room) >= MAX_AUDIENCE) {
|
||||
return send(socket, { type: "error", message: "the gallery is packed — try again later" });
|
||||
return send(socket, { type: "error", message: "the gallery is full for now; try again later" });
|
||||
}
|
||||
leaveGallery(session); // switching galleries updates the old room's count
|
||||
session.playerId = null;
|
||||
@@ -888,6 +986,35 @@ wss.on("connection", (socket, req) => {
|
||||
broadcastRoomState(room);
|
||||
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": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
@@ -1012,7 +1139,7 @@ wss.on("connection", (socket, req) => {
|
||||
}
|
||||
case "claimTransfer": {
|
||||
if (session.claimFails >= 5) {
|
||||
return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" });
|
||||
return send(socket, { type: "error", message: "too many attempts on this connection, so reconnect and mint a fresh phrase" });
|
||||
}
|
||||
const result = claimTransferCode(String(msg.code ?? ""));
|
||||
if ("error" in result) {
|
||||
@@ -1020,7 +1147,13 @@ wss.on("connection", (socket, req) => {
|
||||
return send(socket, { type: "error", message: result.error });
|
||||
}
|
||||
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;
|
||||
}
|
||||
case "catchUp": {
|
||||
@@ -1029,7 +1162,7 @@ wss.on("connection", (socket, req) => {
|
||||
// A catch-up replays the whole game server-side; one at a time, please.
|
||||
const now = Date.now();
|
||||
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||
return send(socket, { type: "error", message: "already catching up, so give it a moment" });
|
||||
}
|
||||
session.lastCatchUpAt = now;
|
||||
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0), msg.full === true);
|
||||
@@ -1045,7 +1178,7 @@ wss.on("connection", (socket, req) => {
|
||||
if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
|
||||
const now = Date.now();
|
||||
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||
return send(socket, { type: "error", message: "already catching up, so give it a moment" });
|
||||
}
|
||||
session.lastCatchUpAt = now;
|
||||
// turn -1 shares the whole finished game; anything else, one turn.
|
||||
@@ -1063,7 +1196,7 @@ wss.on("connection", (socket, req) => {
|
||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||
const now = Date.now();
|
||||
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||
return send(socket, { type: "error", message: "already catching up, so give it a moment" });
|
||||
}
|
||||
session.lastCatchUpAt = now;
|
||||
const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
|
||||
@@ -1081,13 +1214,13 @@ wss.on("connection", (socket, req) => {
|
||||
}
|
||||
case "feedback": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
if (!room) return send(socket, { type: "error", message: "join a room first — a report rides its ledger" });
|
||||
if (!room) return send(socket, { type: "error", message: "join a room first, since every report is filed with its game" });
|
||||
const clean = (raw: unknown) =>
|
||||
String(raw ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
|
||||
const happened = clean(msg.happened);
|
||||
if (!happened) return send(socket, { type: "error", message: "say what happened" });
|
||||
if (!reportsPerAddress.allow(session.address)) {
|
||||
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
|
||||
return send(socket, { type: "error", message: "the desk has plenty from you for now; write again in an hour" });
|
||||
}
|
||||
const reportId = randomBytes(4).toString("hex");
|
||||
appendFeedback({
|
||||
@@ -1115,7 +1248,7 @@ wss.on("connection", (socket, req) => {
|
||||
// seat is proven as the ledger proves it, and the report must be
|
||||
// theirs. Each answer rings the desk's bell.
|
||||
if (!reportsPerAddress.allow(session.address)) {
|
||||
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
|
||||
return send(socket, { type: "error", message: "the desk has plenty from you for now; write again in an hour" });
|
||||
}
|
||||
const seat = (msg.seat ?? {}) as Record<string, unknown>;
|
||||
const roomId = String(seat.roomId ?? "").toUpperCase();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+121
-16
@@ -49,8 +49,9 @@ export interface Room {
|
||||
events: GameEvent[]; // full history (unredacted — redact per recipient)
|
||||
/** Table talk, persisted with the room (public to all seats). */
|
||||
chat: { player: PlayerId; text: string; at: string }[];
|
||||
/** Seats the server itself plays: temperament, tier, and secrecy. */
|
||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||
/** Seats the server itself plays: temperament, tier, and secrecy. A
|
||||
* 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). */
|
||||
touchedAt?: number;
|
||||
/** 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.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 };
|
||||
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 });
|
||||
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 };
|
||||
appendLine(room.id, { kind: "rematch", to: next.id, by: byId, at: new Date().toISOString() });
|
||||
return { roomId: next.id, token, created: true };
|
||||
@@ -299,6 +301,7 @@ export function joinRoom(
|
||||
token: string | null,
|
||||
): { token: string } | { error: string } {
|
||||
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.
|
||||
if (tokenMatches(room, playerId, token)) return { token: token! };
|
||||
return { error: "that wizard name is taken in this room" };
|
||||
@@ -319,8 +322,8 @@ export function joinRoom(
|
||||
* once the deal happens, every seat is a player in the game's record. */
|
||||
export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null {
|
||||
if (byId !== room.hostId) return "only the host may clear a seat";
|
||||
if (room.state) return "the game has started — seats are settled";
|
||||
if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead";
|
||||
if (room.state) return "the game has started and its seats are settled";
|
||||
if (name === room.hostId) return "the host cannot clear their own seat, but can abandon the room";
|
||||
if (!room.players.includes(name)) return "no such seat";
|
||||
room.players = room.players.filter((p) => p !== name);
|
||||
room.tokens.delete(name);
|
||||
@@ -331,6 +334,78 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n
|
||||
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
|
||||
* game done being remembered. The ledger is archived, never deleted. */
|
||||
export function abandonRoom(room: Room, byId: PlayerId): string | null {
|
||||
@@ -361,7 +436,7 @@ export function resolveColors(room: Room): number[] {
|
||||
}
|
||||
|
||||
export function pickColor(room: Room, playerId: PlayerId, color: number): string | null {
|
||||
if (room.state) return "the game has started — your robes are dyed";
|
||||
if (room.state) return "the game has started and your robes are already dyed";
|
||||
if (!room.players.includes(playerId)) return "you hold no seat in this room";
|
||||
if (!Number.isInteger(color) || color < 0 || color > 5) return "no such wizard";
|
||||
for (const [other, c] of room.colorChoices) {
|
||||
@@ -373,7 +448,7 @@ export function pickColor(room: Room, playerId: PlayerId, color: number): string
|
||||
|
||||
function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRev?: number): { events: GameEvent[] } | { error: string } {
|
||||
const n = room.players.length;
|
||||
if (n < 2 || n > 6) return { error: "supported player counts: 2 to 6" };
|
||||
if (n < 2 || n > 6) return { error: "a table seats 2 to 6 wizards" };
|
||||
const { state, events } = createGame({
|
||||
playerIds: room.players,
|
||||
seed: room.seed,
|
||||
@@ -745,7 +820,9 @@ const TRANSFER_WORDS = [
|
||||
interface PendingTransfer {
|
||||
roomId: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -758,19 +835,35 @@ export function makeTransferCode(
|
||||
rawToken: string | null,
|
||||
): { code: string; expiresAt: number } | { error: string } {
|
||||
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.
|
||||
const now = Date.now();
|
||||
for (const [code, t] of transfers) {
|
||||
if (t.expiresAt < now) transfers.delete(code);
|
||||
}
|
||||
if (transfers.size >= 200) return { error: "too many transfers pending — try again in a few minutes" };
|
||||
if (transfers.size >= 200) return { error: "too many seat transfers are waiting; try again in a few minutes" };
|
||||
let code: string;
|
||||
do {
|
||||
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
|
||||
} while (transfers.has(code));
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -781,14 +874,16 @@ let failWindowStart = 0;
|
||||
const FAIL_WINDOW_MS = 10 * 60 * 1000;
|
||||
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();
|
||||
if (now - failWindowStart > FAIL_WINDOW_MS) {
|
||||
failWindowStart = now;
|
||||
failedClaims = 0;
|
||||
}
|
||||
if (failedClaims >= FAIL_LIMIT) {
|
||||
return { error: "too many failed claims — transfers are cooling off, mint a fresh phrase" };
|
||||
return { error: "too many failed claims have cleared every phrase, so wait a few minutes and mint a fresh one" };
|
||||
}
|
||||
const normalized = code.trim().toLowerCase().replace(/\s+/g, "-");
|
||||
const t = transfers.get(normalized);
|
||||
@@ -799,8 +894,14 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
|
||||
}
|
||||
transfers.delete(normalized); // one-time
|
||||
const room = rooms.get(t.roomId);
|
||||
if (!room || !tokenMatches(room, t.name, t.token)) return { error: "that seat no longer exists" };
|
||||
return { roomId: t.roomId, name: t.name, token: t.token };
|
||||
if (!room) return { error: "that seat no longer exists" };
|
||||
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
|
||||
@@ -835,6 +936,10 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||
room.tokens.delete(line.name);
|
||||
room.bots.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") {
|
||||
if (line.bot) {
|
||||
room.players.push(line.name);
|
||||
|
||||
@@ -93,13 +93,35 @@ export interface KickLine {
|
||||
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 {
|
||||
kind: "abandon";
|
||||
by: 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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -4,19 +4,19 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wiz-War</title>
|
||||
<meta name="description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced — play free with friends, online or around one device." />
|
||||
<meta name="description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced. Play free online with friends, or pass one device around." />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Wiz-War" />
|
||||
<meta property="og:title" content="Wiz-War" />
|
||||
<meta property="og:description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced — play free with friends, online or around one device." />
|
||||
<meta property="og:description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced. Play free online with friends, or pass one device around." />
|
||||
<meta property="og:image" content="https://wizwar.kestrelsnest.social/og.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Wiz-War" />
|
||||
<meta name="twitter:description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced — play free with friends." />
|
||||
<meta name="twitter:description" content="A game of magical combat in a stone labyrinth. The classic 1993 sixth edition, faithfully reproduced. Play free online with friends." />
|
||||
<meta name="twitter:image" content="https://wizwar.kestrelsnest.social/og.png" />
|
||||
<meta name="theme-color" content="#efe8d4" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"family": "Kestrel's Hall",
|
||||
"hall": "https://gamehall.kestrelsnest.social",
|
||||
"games": [
|
||||
{
|
||||
"slug": "wizwar",
|
||||
@@ -13,7 +14,7 @@
|
||||
"slug": "waving-hands",
|
||||
"name": "Waving Hands",
|
||||
"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",
|
||||
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
|
||||
},
|
||||
@@ -21,9 +22,25 @@
|
||||
"slug": "hnefatafl",
|
||||
"name": "Hnefatafl",
|
||||
"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",
|
||||
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
|
||||
},
|
||||
{
|
||||
"slug": "dip",
|
||||
"name": "Dip",
|
||||
"url": "https://dip.kestrelsnest.social",
|
||||
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
||||
"players": "2 to 10, depending on the map",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
|
||||
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
|
||||
// game's site serves it at /hall/announcement.json. It is checked when the page
|
||||
// loads, every few minutes, and whenever the page is looked at again. A reader
|
||||
// may dismiss one announcement; the next one still shows.
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Announcement {
|
||||
/** Names this announcement, so dismissing it does not hide the next. */
|
||||
id: string;
|
||||
text: string;
|
||||
/** When what it announces happens, told in each reader's own time. */
|
||||
at?: string;
|
||||
/** After this it is no longer shown. */
|
||||
until?: string;
|
||||
}
|
||||
|
||||
const SOURCE = '/hall/announcement.json';
|
||||
const CHECK_MS = 5 * 60_000;
|
||||
const DISMISSED = 'hall:dismissed';
|
||||
|
||||
let current = $state<Announcement | null>(null);
|
||||
let dismissed = $state<string | null>(null);
|
||||
let now = $state(Date.now());
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await fetch(SOURCE, { cache: 'no-store' });
|
||||
const a: unknown = res.ok ? await res.json() : null;
|
||||
current = a && typeof a === 'object' && typeof (a as Announcement).text === 'string' && (a as Announcement).text && typeof (a as Announcement).id === 'string' ? (a as Announcement) : null;
|
||||
} catch {
|
||||
// No announcement reachable: none shown.
|
||||
}
|
||||
now = Date.now();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
dismissed = localStorage.getItem(DISMISSED);
|
||||
} catch {
|
||||
// Without storage a dismissal lasts this visit.
|
||||
}
|
||||
void check();
|
||||
const poll = setInterval(check, CHECK_MS);
|
||||
const tick = setInterval(() => (now = Date.now()), 30_000);
|
||||
const looked = () => {
|
||||
if (document.visibilityState === 'visible') void check();
|
||||
};
|
||||
document.addEventListener('visibilitychange', looked);
|
||||
return () => {
|
||||
clearInterval(poll);
|
||||
clearInterval(tick);
|
||||
document.removeEventListener('visibilitychange', looked);
|
||||
};
|
||||
});
|
||||
|
||||
const shown = $derived(current && current.id !== dismissed && !(current.until && Date.parse(current.until) < now) ? current : null);
|
||||
|
||||
/** When it happens, in the reader's own clock: "in 25 minutes, at 7:00 AM", or the day and time further off. */
|
||||
const when = $derived.by(() => {
|
||||
const t = shown?.at ? Date.parse(shown.at) : NaN;
|
||||
if (!Number.isFinite(t)) return '';
|
||||
const minutes = Math.round((t - now) / 60_000);
|
||||
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
|
||||
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
|
||||
return minutes >= -15 ? ' (now)' : '';
|
||||
});
|
||||
|
||||
function dismiss() {
|
||||
// Taken first: once dismissed, nothing is shown.
|
||||
const id = shown?.id;
|
||||
if (!id) return;
|
||||
dismissed = id;
|
||||
try {
|
||||
localStorage.setItem(DISMISSED, id);
|
||||
} catch {
|
||||
// The dismissal still holds for this visit.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
<div class="hall-announcement" role="status">
|
||||
<span>{shown.text}{when}</span>
|
||||
<button type="button" aria-label="Dismiss this announcement" onclick={dismiss}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
|
||||
.hall-announcement {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.45rem 2.4rem 0.45rem 1rem;
|
||||
background: #f2c45a;
|
||||
color: #1d1a12;
|
||||
font: 500 0.95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px #0005;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
+473
-235
File diff suppressed because it is too large.
Load diff
@@ -2,7 +2,7 @@
|
||||
import type { Component } from "svelte";
|
||||
import type { GameView, SightTrace } from "@wizwar/engine";
|
||||
import type { Side } from "@wizwar/engine";
|
||||
import { fearCells } from "@wizwar/engine";
|
||||
import { edgeKey, fearCells, neighbor } from "@wizwar/engine";
|
||||
import type { BoardFx } from "./fx";
|
||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||
@@ -20,6 +20,7 @@
|
||||
let {
|
||||
view,
|
||||
edgeSelectMode = false,
|
||||
punchFrom = null,
|
||||
selectedCreatureId = null,
|
||||
onCellClick,
|
||||
onEdgeClick,
|
||||
@@ -43,6 +44,9 @@
|
||||
}: {
|
||||
view: GameView;
|
||||
edgeSelectMode?: boolean;
|
||||
/** A puncher's square, wizard or troll: only the walls and doors on its
|
||||
* four sides can be struck, so only those lines light. */
|
||||
punchFrom?: { x: number; y: number } | null;
|
||||
selectedCreatureId?: string | null;
|
||||
onCellClick?: (cell: { x: number; y: number }) => void;
|
||||
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
||||
@@ -209,7 +213,17 @@
|
||||
if (side === "S") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
|
||||
if (side === "N") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: c.y * CELL - 6, w: CELL - 8, h: 12 });
|
||||
}
|
||||
return boxes;
|
||||
if (!punchFrom) return boxes;
|
||||
const beside = (b: { cell: { x: number; y: number }; side: Side }) => {
|
||||
const far = neighbor(b.cell, b.side);
|
||||
const at = (c: { x: number; y: number }) => c.x === punchFrom!.x && c.y === punchFrom!.y;
|
||||
return at(b.cell) || at(far);
|
||||
};
|
||||
const solid = (b: { cell: { x: number; y: number }; side: Side }) => {
|
||||
const state = view.board.edges[edgeKey(b.cell, b.side)];
|
||||
return state === "wall" || state === "door";
|
||||
};
|
||||
return boxes.filter((b) => beside(b) && solid(b));
|
||||
});
|
||||
|
||||
// FEAR's dread: three spaces in every direction, straight through walls
|
||||
@@ -443,10 +457,10 @@
|
||||
{#if e.state === "firewall"}
|
||||
<FirewallEdge x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"} onpeek={onEdgePeek} />
|
||||
{:else}
|
||||
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
|
||||
: e.lock === "jammed" ? "lock jammed — sealed for good"
|
||||
: e.lock === "held" ? "held open by a standing wizard"
|
||||
: e.lock === "ajar" ? "unlocked until end of turn" : null}
|
||||
{@const lockTitle = e.lock === "removed" ? "a door with its lock removed, swinging free"
|
||||
: e.lock === "jammed" ? "a door with its lock jammed, sealed for good"
|
||||
: e.lock === "held" ? "a door held open by a standing wizard"
|
||||
: e.lock === "ajar" ? "a door unlocked until the end of the turn" : null}
|
||||
<EdgeGlyph x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"}
|
||||
state={e.state === "door" ? "door" : "wall"}
|
||||
lock={(e.lock ?? null) as LockState | null}
|
||||
@@ -465,7 +479,7 @@
|
||||
? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
|
||||
: edgeScar(wx * CELL + 3, (wy + 1) * CELL, (wx + 1) * CELL - 3, (wy + 1) * CELL, frac)}
|
||||
<path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
|
||||
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
|
||||
<path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred, {dmg} damage taken</title></path>
|
||||
{/each}
|
||||
|
||||
<!-- illusions YOU know are fake: ghostly dashed lines -->
|
||||
@@ -474,9 +488,9 @@
|
||||
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
|
||||
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
|
||||
{#if kind === "V"}
|
||||
<path d={edgeWisps((ix+1)*CELL, iy*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion — your eyes see through it</title></path>
|
||||
<path d={edgeWisps((ix+1)*CELL, iy*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion your eyes see through</title></path>
|
||||
{:else}
|
||||
<path d={edgeWisps(ix*CELL, (iy+1)*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion — your eyes see through it</title></path>
|
||||
<path d={edgeWisps(ix*CELL, (iy+1)*CELL, (ix+1)*CELL, (iy+1)*CELL)} class="illusion"><title>an illusion your eyes see through</title></path>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -635,7 +649,7 @@
|
||||
x={-CELL * 0.26} y={-CELL * 0.26}
|
||||
width={CELL * 0.52} height={CELL * 0.52}
|
||||
cls={`token-art hit${c.id === selectedCreatureId ? " selected-art" : ""}`}
|
||||
title={`${c.kind} (${c.controllerId}) — ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`}
|
||||
title={`${c.kind} (${c.controllerId}), ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`}
|
||||
/>
|
||||
<rect
|
||||
x={-CELL * 0.26} y={-CELL * 0.26}
|
||||
@@ -652,7 +666,7 @@
|
||||
class:selected={c.id === selectedCreatureId}
|
||||
stroke={playerColor(c.controllerId)}
|
||||
>
|
||||
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
|
||||
<title>{c.kind} ({c.controllerId}), {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
|
||||
</rect>
|
||||
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
|
||||
{/if}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
const hp = $derived(damage > 0
|
||||
? `${Math.max(0, needed - damage)} of ${needed} points left`
|
||||
: `${needed} points to destroy`);
|
||||
const tip = $derived(`${title ?? (state === "door" ? "a locked door" : "a wall")} — ${hp}`);
|
||||
const tip = $derived(`${title ?? (state === "door" ? "a locked door" : "a wall")} (${hp})`);
|
||||
|
||||
const { press, release } = holdToPeek(() => onpeek?.(tip));
|
||||
</script>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
onkeydown={() => {}}
|
||||
>
|
||||
<header class="faq-head">
|
||||
<span class="faq-title">{def.name} — Official FAQ</span>
|
||||
<span class="faq-title">Official FAQ on {def.name}</span>
|
||||
<button class="close" onclick={onclose} aria-label="close">×</button>
|
||||
</header>
|
||||
<div class="faq-body">
|
||||
@@ -31,7 +31,7 @@
|
||||
{/each}
|
||||
<p class="colophon">
|
||||
From Tom Jolly's official Wiz-War Rules FAQ (wizwar.com, September 2002).
|
||||
Rulings occasionally reference other editions; the cards overrule the rules,
|
||||
Rulings occasionally reference other editions. The cards overrule the rules,
|
||||
and the designer overrules the table.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/** Press-and-hold (touch has no hover): report what this fire is. */
|
||||
onpeek?: (tip: string) => void;
|
||||
} = $props();
|
||||
const TIP = "a wall of fire — passing through burns for 4";
|
||||
const TIP = "a wall of fire that burns for 4 as you pass through";
|
||||
const { press, release } = holdToPeek(() => onpeek?.(TIP));
|
||||
const CELL = 48;
|
||||
const WALL = 7;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// body, the card library with proper entries, the house rulings, the
|
||||
// story of the table, and the tally. Every section and card has a link
|
||||
// a player can send; the desk remembers where the reader was.
|
||||
import { otherGames, type FamilyGame } from "./family";
|
||||
import { family as kin } from "./family.svelte";
|
||||
import { cardDef } from "@wizwar/engine";
|
||||
import { tick } from "svelte";
|
||||
import Card from "./Card.svelte";
|
||||
@@ -129,10 +129,8 @@
|
||||
else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
|
||||
});
|
||||
/** The other games of Kestrel's Hall, for the about page. */
|
||||
let family = $state<FamilyGame[]>([]);
|
||||
$effect(() => {
|
||||
void otherGames("wizwar").then((g) => (family = g));
|
||||
});
|
||||
const family = $derived(kin.games);
|
||||
const hall = $derived(kin.hall);
|
||||
</script>
|
||||
|
||||
{#snippet section(s: RefSection, withSource: boolean)}
|
||||
@@ -166,7 +164,7 @@
|
||||
{typeLabel(entryDef)} · {sightOf(entryDef)} · {setLabel(entryDef)}
|
||||
</p>
|
||||
<h3>As printed</h3>
|
||||
<p class="printed">{entryDef.text ?? (entryDef.cardType === "number" ? `A NUMBER card worth ${entryDef.value}: add it to your movement, or set a spell's duration or power.` : "")}</p>
|
||||
<p class="printed">{entryDef.text ?? (entryDef.cardType === "number" ? `A NUMBER card worth ${entryDef.value}. Add it to your movement, or use it to set a spell's duration or power.` : "")}</p>
|
||||
{#if entryDef.faqRulings.length > 0}
|
||||
<h3>Official FAQ</h3>
|
||||
{#each entryDef.faqRulings as q, i (i)}<p>{q}</p>{/each}
|
||||
@@ -200,7 +198,7 @@
|
||||
<span class="booklet-title">Wiz-War</span>
|
||||
<nav class="tabs" aria-label="help sections">
|
||||
<button class:current={tab === "play"} onclick={() => goTab("play")}>How to play</button>
|
||||
<button class:current={tab === "rules"} onclick={() => goTab("rules")}>The rules</button>
|
||||
<button class:current={tab === "rules"} onclick={() => goTab("rules")}>Rules</button>
|
||||
<button class:current={tab === "cards"} onclick={() => goTab("cards")}>Card library</button>
|
||||
<button class:current={tab === "rulings"} onclick={() => goTab("rulings")}>House rulings</button>
|
||||
<button class:current={tab === "about"} onclick={() => goTab("about")}>About</button>
|
||||
@@ -222,7 +220,7 @@
|
||||
</div>
|
||||
<h3>The ledgers in detail</h3>
|
||||
<dl class="tally-list">
|
||||
<dt>{n(stats.gamesCreated)}</dt><dd>games chronicled — {n(stats.gamesStarted)} begun</dd>
|
||||
<dt>{n(stats.gamesCreated)}</dt><dd>games chronicled, {n(stats.gamesStarted)} of them begun</dd>
|
||||
<dt>{n(stats.wizardsSeated)}</dt><dd>distinct wizard names have taken a seat</dd>
|
||||
<dt>{n(stats.commandsPlayed)}</dt><dd>spells, steps, and punches recorded</dd>
|
||||
<dt>{n(stats.longestGameCommands)}</dt><dd>actions in the longest game yet played</dd>
|
||||
@@ -232,9 +230,9 @@
|
||||
</dl>
|
||||
<p class="colophon">
|
||||
{#if stats.firstGameAt}The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}.{/if}
|
||||
Wizards are counted by name, not by person: one player under two names is two wizards here.
|
||||
Wizards are counted by name, so one player under two names counts as two wizards.
|
||||
Table time is estimated from the clock between moves, so a game left open overnight counts only the minutes it was actually played.
|
||||
Games on one device send only their counts — names and moves stay on the device.
|
||||
Games on one device send only their counts, and their names and moves stay on the device.
|
||||
</p>
|
||||
{:else}
|
||||
<p>Counting the ledgers…</p>
|
||||
@@ -245,15 +243,15 @@
|
||||
<h3>Why it exists</h3>
|
||||
<p>
|
||||
In the early nineties, a group of friends played this exact edition
|
||||
for countless hours — the slammed doors, the ambushes around
|
||||
corners, the goat charges, the laughter. That box is long out of
|
||||
for countless hours of slammed doors, ambushes around corners,
|
||||
goat charges and laughter. That box is long out of
|
||||
print, and its cardboard is worn soft at the edges. This digital
|
||||
table was built from that very copy: every card was photographed and
|
||||
transcribed word for word, every board layout verified wall by wall,
|
||||
and the tokens on this screen are the actual hand-inked art from
|
||||
those counter sheets. It is a faithful reproduction of the game as
|
||||
it was played at that table, made so the same friends — and their
|
||||
friends — can keep playing it.
|
||||
it was played at that table, made so the same friends, and their
|
||||
friends, can keep playing it.
|
||||
</p>
|
||||
<h3>What this is</h3>
|
||||
<p>
|
||||
@@ -261,16 +259,16 @@
|
||||
wizards prowl a maze, hurling fireballs, walking through walls,
|
||||
summoning trolls, and stealing each other's treasure. It was created
|
||||
by <strong>Tom Jolly</strong> in 1983 and first published under his
|
||||
own Jolly Games label; the edition reproduced here is the
|
||||
own Jolly Games label. The edition reproduced here is the
|
||||
<strong>sixth edition</strong>, published by Chessex in 1993, together
|
||||
with its one expansion — monsters and magic wands included.
|
||||
with its one expansion, which brings monsters and magic wands.
|
||||
</p>
|
||||
<h3>Whose game it is</h3>
|
||||
<p>
|
||||
Wiz-War is Tom Jolly's design, and the rights to it now rest with
|
||||
<strong>Steve Jackson Games</strong>, who publish the current
|
||||
edition. This project is an unofficial, non-commercial fan work,
|
||||
made with love and admiration for the original — nothing here is
|
||||
made with love and admiration for the original. Nothing here is
|
||||
endorsed by or affiliated with Tom Jolly, Chessex, or Steve Jackson
|
||||
Games. If this game delights you, honor it the right way:
|
||||
<a href="https://www.sjgames.com/wiz-war/" target="_blank" rel="noreferrer">buy
|
||||
@@ -296,21 +294,27 @@
|
||||
<span class="tile-note">the maze through a wizard's own eyes</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>: two clockwork wizards play a stretch, then the reel replays it.</p>
|
||||
<p class="colophon"><a href="/?fpv&demo=1">Watch the demo</a>, in which two clockwork wizards play a stretch and the reel replays it.</p>
|
||||
<h3>What the table remembers</h3>
|
||||
<p>
|
||||
An online game is written to this table's ledger move by move, table
|
||||
talk included, and kept: a finished game can be replayed from above
|
||||
talk included, and kept. A finished game can be replayed from above
|
||||
or through a wizard's own eyes, and any turn can be shared by link.
|
||||
A shared replay shows that turn's moves and the sharing wizard's view
|
||||
of the board — never anyone's hand. A game passed around one device
|
||||
stays on that device; only its counts reach the tally. Play under
|
||||
A shared replay shows that turn's moves as an onlooker would have
|
||||
seen them, and never anyone's hand. A game passed around one device
|
||||
stays on that device, and only its counts reach the tally. Play under
|
||||
whatever name you like, and know that what you say and do at an
|
||||
online table is part of its record.
|
||||
</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>
|
||||
<p>
|
||||
Feedback, bug reports, and faint praise all welcome — ravens fly to
|
||||
Feedback, bug reports and faint praise are all welcome. Ravens fly to
|
||||
<a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>,
|
||||
<a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky,
|
||||
or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon.
|
||||
@@ -318,16 +322,19 @@
|
||||
<a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.
|
||||
</p>
|
||||
{#if family.length}
|
||||
<h3>More games in Kestrel's Hall</h3>
|
||||
<h3>More games in {#if hall}<a href={hall}>Kestrel's Hall</a>{:else}Kestrel's Hall{/if}</h3>
|
||||
<ul class="family">
|
||||
{#each family as g (g.slug)}
|
||||
<li><a href={g.url}>{g.name}</a>: {g.pitch} <span class="family-note">{g.players} players; {g.origin}.</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<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
|
||||
<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 class="colophon">
|
||||
Wiz-War © Tom Jolly. Sixth edition published by Chessex, 1993.
|
||||
Built by Eric and a very enthusiastic AI, 2026.
|
||||
</p>
|
||||
</div>
|
||||
{:else if tab === "play"}
|
||||
@@ -340,10 +347,10 @@
|
||||
{:else if tab === "rulings"}
|
||||
<div class="reading">
|
||||
<p class="colophon">
|
||||
For players who know the box: how this table resolves what the
|
||||
cardboard leaves to the players, by card. If the table rules
|
||||
For players who know the box, here is how this table resolves, card
|
||||
by card, what the cardboard leaves to the players. If the table rules
|
||||
against your memory of the rules, the 🐞 report button pins the
|
||||
moment for review — the reply lands in your lobby.
|
||||
moment for review, and the reply lands in your lobby.
|
||||
</p>
|
||||
<div class="search-row">
|
||||
<input bind:value={rulingsSearch} placeholder="search the rulings by card or topic…" aria-label="search house rulings" />
|
||||
@@ -366,11 +373,12 @@
|
||||
{/each}
|
||||
{#if !rulingsSearch.trim()}
|
||||
<h3 id="ruling-the-table">The table itself</h3>
|
||||
<p>Clockwork wizards — the automatons — play from the same redacted
|
||||
view a human seat gets and play by the rules the engine enforces on
|
||||
everyone; a tier changes what they draw and know.</p>
|
||||
<p>The clockwork wizards, the automatons, play from the same redacted
|
||||
view a human seat gets and by the same rules the engine enforces on
|
||||
everyone. Their tier changes how many cards they draw and how many
|
||||
tricks they know.</p>
|
||||
<p>A turn need not be taken at once. Games wait on the lobby ledger
|
||||
for days; the browser holds your seat; a replay of any game can be
|
||||
for days while the browser holds your seat. A replay of any game can be
|
||||
watched from above or through a wizard's eyes and shared by link.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -398,7 +406,7 @@
|
||||
{#each refBySource as group (group.source)}
|
||||
<h2 class="divider" id="source-{group.source}">{SOURCE_LABELS[group.source]}</h2>
|
||||
{#if group.source === "summary"}
|
||||
<p class="colophon">Sixth edition rules, condensed from the original rulebook. © 1985 Jolly Games — this digital adaptation is a fan project.</p>
|
||||
<p class="colophon">Sixth edition rules, condensed from the original rulebook. © 1985 Jolly Games. This digital adaptation is a fan project.</p>
|
||||
{:else if group.source === "rulebook"}
|
||||
<p class="colophon">The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT} Notes marked <span class="ed-note">(6E: …)</span> are this table's, on where the sixth edition differs.</p>
|
||||
{:else if group.source === "faq"}
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
</g>
|
||||
{#if onclick}
|
||||
<line {x1} {y1} {x2} {y2} class="illusion-hit"
|
||||
role="button" tabindex="-1" aria-label="a shimmering wall — test your eyes"
|
||||
role="button" tabindex="-1" aria-label="a shimmering wall, to test with your eyes"
|
||||
onclick={(ev) => { ev.stopPropagation(); onclick?.(); }}
|
||||
onkeydown={() => {}}><title>this wall shimmers oddly — tap to test your eyes</title></line>
|
||||
onkeydown={() => {}}><title>this wall shimmers oddly, so tap to test your eyes</title></line>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
@@ -708,7 +708,7 @@
|
||||
<div class="replay-scrim">
|
||||
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
||||
<header class="replay-head">
|
||||
<span class="replay-title">{moment ? `Instant replay — ${povId}'s turn` : steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||
<span class="replay-title">{moment ? `Instant replay of ${povId}'s turn` : steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||||
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
||||
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
||||
@@ -770,7 +770,7 @@
|
||||
<div class="timeline" role="group" aria-label="the moves, a tick each">
|
||||
{#each ticks as t (t.i)}
|
||||
<button class="tick" class:current={t.i === idx} class:turn={t.flag === "turn"} class:hit={t.flag === "hit"} class:gold={t.flag === "gold"} class:end={t.flag === "end"}
|
||||
style:--who={t.color} title={`move ${t.i + 1} — ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
|
||||
style:--who={t.color} title={`move ${t.i + 1}, ${t.actor}${t.flag ? ` · ${t.flag === "turn" ? "turn begins" : t.flag === "hit" ? "a blow" : t.flag === "gold" ? "treasure" : "the end"}` : ""}`}
|
||||
onclick={() => { playing = false; idx = t.i; }} aria-label={`jump to move ${t.i + 1}`}></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -36,12 +36,12 @@
|
||||
{#if data}
|
||||
<div class="share-title">
|
||||
{#if data.whole}
|
||||
<span class="share-deed">The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}</span>
|
||||
<span class="share-deed">{data.actor ? `The whole tale of ${data.actor}'s triumph` : "The whole tale"}</span>
|
||||
{:else if data.headline}
|
||||
<span class="share-deed">{data.headline}</span>
|
||||
<span class="share-sub">an instant replay of {data.actor}'s turn{data.round ? `, round ${data.round}` : ""}</span>
|
||||
{:else}
|
||||
<span class="share-deed">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</span>
|
||||
<span class="share-deed">Instant replay of {data.actor}'s turn{data.round ? `, round ${data.round}` : ""}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
{#if failed}
|
||||
<div class="share-note">
|
||||
<p>This replay has wandered off — the link may be old, or the game gone.</p>
|
||||
<p>This replay has wandered off. The link may be old, or the game gone.</p>
|
||||
<a class="share-cta" href="/">▶ deal yourself into a fresh game</a>
|
||||
</div>
|
||||
{:else if !data}
|
||||
@@ -62,7 +62,7 @@
|
||||
{/key}
|
||||
</div>
|
||||
<div class="share-play">
|
||||
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
|
||||
<a class="share-cta" href="/">▶ play free, with two to six wizards in the maze</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -70,9 +70,9 @@
|
||||
<p>
|
||||
<strong>Wiz-War</strong> is Tom Jolly's 1983 game of magical combat in
|
||||
a stone labyrinth. This digital table is an unofficial, non-commercial
|
||||
fan re-creation of the sixth edition (Chessex, 1993) — every card
|
||||
transcribed, every wall verified, replays rebuilt move by move from
|
||||
the game's own ledger.
|
||||
fan re-creation of the sixth edition (Chessex, 1993). Every card is
|
||||
transcribed and every wall verified, and each replay is rebuilt move
|
||||
by move from the game's own ledger.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ export function summarizeMissed(steps: readonly ReplayStep[], you: string, now:
|
||||
});
|
||||
for (const [who, d] of doings) {
|
||||
const bits = [d.moves > 0 ? `walked ${d.moves} square${d.moves === 1 ? "" : "s"}` : "", d.casts > 0 ? `cast ${d.casts} spell${d.casts === 1 ? "" : "s"}` : ""].filter(Boolean);
|
||||
facts.push({ text: `${who} ${bits.length ? bits.join(" and ") : "passed the time"}; nothing touched you.`, step: d.first });
|
||||
facts.push({ text: bits.length ? `${who} ${bits.join(" and ")}, but nothing touched you.` : `${who} passed the time without troubling you.`, step: d.first });
|
||||
}
|
||||
}
|
||||
if (talk > 0) facts.push({ text: `${talk} line${talk === 1 ? "" : "s"} of table talk.`, step: -1 });
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// The other games of Kestrel's Hall, from the family list the kit keeps
|
||||
// (family.json, one copy shipped with every game), so a player who liked one
|
||||
// table can find the next, and the hall's front door. This game is left out
|
||||
// of its own list. Fetched once, shared by every page that shows it.
|
||||
|
||||
export interface FamilyGame {
|
||||
slug: string;
|
||||
name: string;
|
||||
url: string;
|
||||
pitch: string;
|
||||
players: string;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
/** The hall's front door and every game but this one; empty until the list arrives. */
|
||||
export const family = $state<{ hall: string; games: FamilyGame[] }>({ hall: "", games: [] });
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch("/family.json", { cache: "no-cache" });
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
|
||||
family.hall = data.hall ?? "";
|
||||
family.games = (data.games ?? []).filter((g) => g.slug !== "wizwar");
|
||||
} catch {
|
||||
// No list, no neighbors shown.
|
||||
}
|
||||
})();
|
||||
@@ -1,23 +0,0 @@
|
||||
// The other games of Kestrel's Hall, from the family list the kit keeps
|
||||
// (family.json, one copy shipped with every game), so a player who liked one
|
||||
// table can find the next. This game is left out of its own list.
|
||||
|
||||
export interface FamilyGame {
|
||||
slug: string;
|
||||
name: string;
|
||||
url: string;
|
||||
pitch: string;
|
||||
players: string;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export async function otherGames(self: string): Promise<FamilyGame[]> {
|
||||
try {
|
||||
const res = await fetch('/family.json', { cache: 'no-cache' });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { games?: FamilyGame[] };
|
||||
return (data.games ?? []).filter((g) => g.slug !== self);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export const SCREENPLAYS: Screenplay[] = [
|
||||
...DUEL,
|
||||
name: "warp-sniper",
|
||||
title: "The Warp Sniper",
|
||||
blurb: "Line of sight threads a wormhole: a fireball cast into one mouth arrives through the other.",
|
||||
blurb: "Line of sight threads a wormhole, so a fireball cast into one mouth arrives through the other.",
|
||||
pov: "Wanderer",
|
||||
rig: { Wanderer: ["number-3", "fireball"], Rival: ["number-3"] },
|
||||
beats: [6, 16],
|
||||
@@ -176,7 +176,7 @@ export const SCREENPLAYS: Screenplay[] = [
|
||||
...DUEL,
|
||||
name: "springing-the-trap",
|
||||
title: "Springing the Trap",
|
||||
blurb: "An ambush watches its corridor through a wormhole — and fires the moment a wizard crosses the far sightline.",
|
||||
blurb: "An ambush watches its corridor through a wormhole and fires the moment a wizard crosses the far sightline.",
|
||||
pov: "Rival",
|
||||
rig: { Wanderer: ["opportunity-fire", "fireball"] },
|
||||
beats: [5, 6],
|
||||
@@ -197,7 +197,7 @@ export const SCREENPLAYS: Screenplay[] = [
|
||||
...DUEL,
|
||||
name: "the-leap",
|
||||
title: "The Leap",
|
||||
blurb: "A pit yawns in the corridor — but the die says the intruder sails clean over it, onto the trapper's doorstep.",
|
||||
blurb: "A pit yawns in the corridor, but the die says the intruder sails clean over it, onto the trapper's doorstep.",
|
||||
pov: "Rival",
|
||||
rig: { Wanderer: ["create-pit"] },
|
||||
beats: [8, 9],
|
||||
@@ -221,7 +221,7 @@ export const SCREENPLAYS: Screenplay[] = [
|
||||
...DUEL,
|
||||
name: "come-here",
|
||||
title: "The Puppeteer",
|
||||
blurb: "Mental Force walks the enemy to exactly where you want them — which has its risks.",
|
||||
blurb: "Mental Force walks the enemy to exactly where you want them, and that has its risks.",
|
||||
pov: "Wanderer",
|
||||
rig: { Wanderer: ["mental-force"], Rival: ["number-2"] },
|
||||
beats: [10, 13],
|
||||
@@ -243,7 +243,7 @@ export const SCREENPLAYS: Screenplay[] = [
|
||||
...DUEL,
|
||||
name: "stone-gaze",
|
||||
title: "The Stone Gaze",
|
||||
blurb: "Medusa freezes a wizard solid — and stone, inconveniently, cannot be hurt.",
|
||||
blurb: "Medusa freezes a wizard solid, and stone, inconveniently, cannot be hurt.",
|
||||
pov: "Rival",
|
||||
rig: { Rival: ["number-2", "medusa", "number-5", "fireball"] },
|
||||
beats: [10, 14],
|
||||
@@ -279,7 +279,7 @@ export const SCREENPLAYS: Screenplay[] = [
|
||||
...DUEL,
|
||||
name: "wall-comes-down",
|
||||
title: "Knock Knock",
|
||||
blurb: "Blast the wall between you — the rubble bloodies both sides of it — then step through the breach.",
|
||||
blurb: "Blast the wall between you, bloodying both sides with its rubble, then step through the breach.",
|
||||
pov: "Wanderer",
|
||||
rig: { Wanderer: ["number-2", "destroy-wall"] },
|
||||
beats: [6, 7],
|
||||
@@ -320,7 +320,7 @@ export const SCREENPLAYS: Screenplay[] = [
|
||||
...DUEL,
|
||||
name: "fire-in-the-webs",
|
||||
title: "Fire in the Webs",
|
||||
blurb: "The Sticky Wand binds a wizard in silk — and fire burns twice as hot in a web.",
|
||||
blurb: "The Sticky Wand binds a wizard in silk, and a fireball bites two points harder in a web.",
|
||||
pov: "Rival",
|
||||
rig: { Rival: ["number-2", "sticky-wand", "fireball"] },
|
||||
beats: [11, 14],
|
||||
@@ -340,7 +340,7 @@ export const SCREENPLAYS: Screenplay[] = [
|
||||
...DUEL,
|
||||
name: "dust-and-light",
|
||||
title: "Dust and Light",
|
||||
blurb: "A dust cloud hides her from his spells — so he throws a warp behind her, steps through the light, and she flees blind into her own dust.",
|
||||
blurb: "A dust cloud hides her from his spells, so he throws a warp behind her, steps through the light, and she flees blind into her own dust.",
|
||||
pov: "Rival",
|
||||
rig: { Rival: ["number-2", "dimensional-warp"], Wanderer: ["dust-cloud"] },
|
||||
beats: [12, 14, 20],
|
||||
|
||||
@@ -40,7 +40,7 @@ function describe(view: GameView, cmd: Command): string {
|
||||
case "counteract":
|
||||
return `answer with ${nameOf(view, cmd.instanceId)}`;
|
||||
case "pass":
|
||||
return "let it resolve — nothing in hand is worth spending on this";
|
||||
return "let it resolve, since nothing in hand is worth spending on this";
|
||||
case "punch":
|
||||
return `punch ${cmd.targetId}`;
|
||||
case "punchWall":
|
||||
@@ -56,7 +56,7 @@ function describe(view: GameView, cmd: Command): string {
|
||||
case "warpStep":
|
||||
return "step through the warp underfoot";
|
||||
case "testIllusion":
|
||||
return "test that shimmering wall — doubting it is free";
|
||||
return "test that shimmering wall, since doubting it is free";
|
||||
case "moveCreature":
|
||||
return `march the monster ${DIRECTION[cmd.direction]}`;
|
||||
case "creatureAttack":
|
||||
@@ -90,7 +90,7 @@ export function tableHints(view: GameView): TableHint[] {
|
||||
let advice: string;
|
||||
try {
|
||||
const cmd = automatonCommand(view, s, "archmage");
|
||||
advice = cmd ? `I would ${describe(view, cmd)}.` : "Nothing calls for us right now — wait for your moment.";
|
||||
advice = cmd ? `I would ${describe(view, cmd)}.` : "Nothing calls for us right now. Wait for your moment.";
|
||||
} catch (e) {
|
||||
console.warn("hint council choked:", e);
|
||||
advice = "…I have no counsel here.";
|
||||
|
||||
@@ -77,7 +77,7 @@ class LocalGame {
|
||||
rollTableDie(): void {
|
||||
if (!this.viewerId) return;
|
||||
const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4);
|
||||
this.log = [...this.log, { text: `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`, turn: null, notable: false }];
|
||||
this.log = [...this.log, { text: `\u{1F3B2} ${this.viewerId} rolls a ${roll}.`, turn: null, notable: false }];
|
||||
}
|
||||
|
||||
/** Rebuild the whole game as replay steps (finished games only). */
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { mount } from "svelte";
|
||||
import Announcement from "./Announcement.svelte";
|
||||
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
|
||||
// (VITE_SENTRY_DSN in .env.production) this is entirely inert.
|
||||
if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
@@ -9,8 +18,12 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
environment: "production",
|
||||
// Facebook's in-app browser injects its own scripts (iabjs://) and
|
||||
// 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"],
|
||||
beforeSend: (event) => JSON.parse(scrub(JSON.stringify(event))),
|
||||
beforeBreadcrumb: (crumb) => JSON.parse(scrub(JSON.stringify(crumb))),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,4 +47,8 @@ const target = document.getElementById("app")!;
|
||||
const page = location.pathname.startsWith("/watch/")
|
||||
? import("./SharePage.svelte")
|
||||
: import("./App.svelte");
|
||||
page.then(({ default: Root }) => mount(Root, { target }));
|
||||
// A retired chunk resolves to nothing once the handler above has claimed it;
|
||||
// the reload is already on its way, so there is nothing to mount.
|
||||
page.then((m) => m && mount(m.default, { target }));
|
||||
// The hall-wide notice sits above the game, apart from it: every game of Kestrel's Hall shows the same one.
|
||||
mount(Announcement, { target: document.body, anchor: document.body.firstChild ?? undefined });
|
||||
+155
-64
@@ -4,6 +4,7 @@ import type { ReplayStep } from "./catchup";
|
||||
import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
|
||||
import { cardDef, isPermanentDuration } from "@wizwar/engine";
|
||||
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. */
|
||||
const API_URL =
|
||||
@@ -38,7 +39,7 @@ export function humanize(e: GameEvent): string | null {
|
||||
const rolloff = e.players
|
||||
.map((p) => `${p} 🎲 ${(e.dieRolls[p] ?? []).join(", ")}`)
|
||||
.join(" · ");
|
||||
return `Game started. The roll-off: ${rolloff} — ${e.firstPlayer} goes first.`;
|
||||
return `Game started. The roll-off: ${rolloff}. ${e.firstPlayer} goes first.`;
|
||||
}
|
||||
case "turnStarted": return `— ${e.player}'s turn (round ${e.round}) —`;
|
||||
case "turnSkipped": return `${e.player} loses a turn.`;
|
||||
@@ -52,9 +53,13 @@ export function humanize(e: GameEvent): string | null {
|
||||
const at = e.target ? ` at ${e.target}` : "";
|
||||
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${amp}${at}.`;
|
||||
}
|
||||
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
|
||||
case "counteractionPlayed": {
|
||||
// A card's id has no spaces; a creature's blow or the ward's bite is named in words.
|
||||
const what = e.against === "punch" ? "the punch" : /[ ']/.test(e.against) ? `the ${e.against}` : spellName(e.against);
|
||||
return `${e.player} counters ${what} with ${cardDef(e.cardId).name}!`;
|
||||
}
|
||||
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
|
||||
case "attackAbsorbedIntoHand": return `${e.player} absorbs ${cardDef(e.attackCard.cardId).name} into their hand — the card is theirs now!`;
|
||||
case "attackAbsorbedIntoHand": return `${e.player} absorbs ${cardDef(e.attackCard.cardId).name} into their hand, and the card is theirs now!`;
|
||||
case "attackResolved":
|
||||
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
|
||||
if (e.fullyStopped) return `The attack is completely stopped.`;
|
||||
@@ -64,42 +69,42 @@ export function humanize(e: GameEvent): string | null {
|
||||
// blow that follows it has nothing to add.
|
||||
if (e.amount === 0 && e.source === "shadow upkeep") return null;
|
||||
const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
|
||||
return `${e.player} takes ${e.amount} damage (${e.source}${soak ? ` — ${soak}` : ""}) — ${e.lifeAfter} life left.`;
|
||||
return `${e.player} takes ${e.amount} damage (${e.source}${soak ? `, ${soak}` : ""}), ${e.lifeAfter} life left.`;
|
||||
}
|
||||
case "stunned": return `${e.player} is stunned and loses a turn!`;
|
||||
case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`;
|
||||
case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`;
|
||||
case "wallCreated": return `A wall appears!`;
|
||||
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
|
||||
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
|
||||
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
|
||||
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
|
||||
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`;
|
||||
case "warpOpened": return `The outer wall breaches clean through, and a new warp opens across the maze!`;
|
||||
case "extraTurnGranted": return `${e.player} speeds up and banks an extra turn.`;
|
||||
case "wardWindow": return `The grab hangs in the air as ${e.owner} clutches something…`;
|
||||
case "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork. Which way will they go?`;
|
||||
case "pushChosen": return `${e.pushee} breaks ${SIDE_NAMES[e.direction].toLowerCase()}.`;
|
||||
case "treasureTorn": return e.toFloor
|
||||
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
|
||||
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms, and it tumbles to the floor!`
|
||||
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
|
||||
case "tearResisted": return `${e.defender} clutches the treasure with white knuckles — ${e.attacker} comes away empty!`;
|
||||
case "tearResisted": return `${e.defender} clutches the treasure with white knuckles, and ${e.attacker} comes away empty!`;
|
||||
case "treasureTearAttempted": return `${e.attacker} grabs for the treasure in ${e.defender}'s arms!`;
|
||||
case "powerstoneBoost": return `The powerstone hums over the number already played — ${e.player} gains a stride (${e.newAllowance} total).`;
|
||||
case "powerstoneBoost": return `The powerstone hums over the number already played, and ${e.player} gains a stride (${e.newAllowance} total).`;
|
||||
case "trapSprung": return e.cardId === "gift-from-below"
|
||||
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
|
||||
? `${e.player} draws GIFT FROM BELOW. It bites for 3, then deals again!`
|
||||
: `${e.player} walked into an old TRAP! Lose a turn.`;
|
||||
case "attackMissed": return e.because === "invisible"
|
||||
? `The attack passes through empty air — ${e.defender} is invisible!`
|
||||
: `${e.defender} is too small to hit — the attack misses!`;
|
||||
? `The attack passes through empty air, for ${e.defender} is invisible!`
|
||||
: `${e.defender} is too small to hit, and the attack misses!`;
|
||||
case "damageImmune": return e.because === "bloodstone"
|
||||
? `${e.player}'s bloodstone drinks the whole blow — no damage.`
|
||||
: `${e.player} is stone — the damage has no effect.`;
|
||||
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
|
||||
case "madDash": return `${e.player} MAD DASHES — every stride doubles: ${e.newAllowance} movement this turn!`;
|
||||
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`;
|
||||
case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
|
||||
? `${e.player}'s bloodstone drinks the whole blow, and no damage lands.`
|
||||
: `${e.player} is stone, and the damage has no effect.`;
|
||||
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}), now ${e.lifeAfter}.`;
|
||||
case "madDash": return `${e.player} MAD DASHES, every stride doubled, for ${e.newAllowance} movement this turn!`;
|
||||
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open until the turn's end.`;
|
||||
case "safeDamaged": return `${e.attacker} batters the safe for ${e.amount} damage (${e.total}/15).`;
|
||||
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`;
|
||||
case "squareContentDamaged": return `${e.attacker} ${e.kind === "ooze" ? "burns" : "batters"} the ${e.kind} — ${e.amount} damage (${e.total}/${e.needed}).`;
|
||||
case "squareContentDamaged": return `${e.attacker} ${e.kind === "ooze" ? "burns" : "batters"} the ${e.kind} for ${e.amount} damage (${e.total}/${e.needed}).`;
|
||||
case "squareContentDestroyed": return e.kind === "ooze" ? `🔥 The ooze burns away under ${e.attacker}'s fire!` : `🌿 The ${e.kind} is torn apart by ${e.attacker}'s attack!`;
|
||||
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points} — a counter hovers over the wound…`;
|
||||
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
|
||||
case "slowDeathWindow": return `Slow Death bites ${e.player} for ${e.points}, and a counter hovers over the wound…`;
|
||||
case "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot, with ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
|
||||
case "spellSustained": return isPermanentDuration(e.turns)
|
||||
? `${spellName(e.cardId)} settles over ${e.target} for good.`
|
||||
: `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
|
||||
@@ -108,16 +113,16 @@ export function humanize(e: GameEvent): string | null {
|
||||
? `${e.player} teleports across the maze!`
|
||||
: `${e.player} is teleported away by ${e.by}!`;
|
||||
case "positionsSwapped": return `${e.a} and ${e.b} swap places!`;
|
||||
case "homeBasesSwapped": return `${e.a} and ${e.b} swap home bases — the maze's loyalties shift!`;
|
||||
case "homeBasesSwapped": return `${e.a} and ${e.b} swap home bases, and the maze's loyalties shift!`;
|
||||
case "cardErased": return e.found
|
||||
? `${e.player}'s ${e.cardId ? cardDef(e.cardId).name : "card"} is erased from their mind!`
|
||||
: `${e.player} wasn't holding that card — the erasure fizzles.`;
|
||||
: `${e.player} wasn't holding that card, so the erasure fizzles.`;
|
||||
case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`;
|
||||
case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`;
|
||||
case "handRevealed": return `${e.to} reads ${e.player}'s mind, and their hand lies revealed.`;
|
||||
case "cardsDrawnPrivate": return e.cards.length > 0
|
||||
? `You draw ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
|
||||
: null;
|
||||
case "creationDispelled": return `The ${e.what.replace(/-/g, " ")} unravels — dispelled!`;
|
||||
case "creationDispelled": return `The ${e.what.replace(/-/g, " ")} unravels, dispelled!`;
|
||||
case "firewallCreated": return `A wall of fire roars up (${e.turns} turn${e.turns === 1 ? "" : "s"})!`;
|
||||
case "firewallExpired": return `The wall of fire gutters out.`;
|
||||
case "firewallBurned": return `${e.player} steps through the flames!`;
|
||||
@@ -130,7 +135,7 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "doorReleased": return `The held door swings shut.`;
|
||||
case "doorsRelocked": return `The door swings shut and relocks.`;
|
||||
case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
|
||||
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
|
||||
case "enteredThornbush": return `${e.player} pushes into the thorns and bleeds for it.`;
|
||||
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
|
||||
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
|
||||
case "cardsStolenPrivate": return e.cards.length > 0
|
||||
@@ -138,25 +143,25 @@ export function humanize(e: GameEvent): string | null {
|
||||
: null;
|
||||
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
|
||||
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
|
||||
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
|
||||
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} and fails to find a path.`;
|
||||
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
||||
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
|
||||
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
|
||||
case "lifeTraded": return `${e.player} burns ${e.points} life for speed!`;
|
||||
case "castAroundCorner": return `The spell bends around the corner!`;
|
||||
case "moveBumped": return e.why === "pit"
|
||||
? `${e.player} leaps the pit — nothing to land on beyond it — and teeters back.`
|
||||
? `${e.player} leaps the pit, finds nothing to land on beyond it, and teeters back.`
|
||||
: `${e.player} blunders into a wall!`;
|
||||
case "attackMisdirected": return e.newTarget
|
||||
? `${e.attacker}'s blind attack veers off — and hits ${e.newTarget}!`
|
||||
? `${e.attacker}'s blind attack veers off and hits ${e.newTarget}!`
|
||||
: `${e.attacker}'s blind attack flies off into the darkness.`;
|
||||
case "retreatedInHorror": return `${e.player} flees the hideous sight!`;
|
||||
case "illusionWallCreated": return `A wall appears... or does it?`;
|
||||
case "illusionTested": return e.result === "seesThrough"
|
||||
? `${e.player} sees right through the illusion!`
|
||||
: `${e.player} is convinced the wall is real.`;
|
||||
case "sectorRotated": return `The maze GRINDS — a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
|
||||
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
|
||||
case "sectorRotated": return `The maze GRINDS as a sector rotates ${e.clockwise ? "clockwise" : "counterclockwise"}!`;
|
||||
case "sectorRelocated": return `The maze SHUDDERS as an entire sector slides away!`;
|
||||
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
|
||||
case "creatureMoved": return null;
|
||||
case "creatureAttacked":
|
||||
@@ -168,7 +173,7 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "trollRegenerated": return `The troll's stony hide knits itself back together.`;
|
||||
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
|
||||
case "impScorches": return `The fire imp scorches ${e.player}!`;
|
||||
case "monsterBoosted": return `The monster GROWS — its ${e.boost} doubles!`;
|
||||
case "monsterBoosted": return `The monster GROWS, and its ${e.boost} doubles!`;
|
||||
case "wandCharged": return `${e.player} charges a wand (${e.charges} charges).`;
|
||||
case "wandUsed": return e.chargesLeft > 0 ? `The wand crackles (${e.chargesLeft} left).` : null;
|
||||
case "wandExhausted": return `${e.player}'s wand crumbles to dust.`;
|
||||
@@ -186,59 +191,62 @@ export function humanize(e: GameEvent): string | null {
|
||||
case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
|
||||
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
|
||||
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
|
||||
case "boobytrapBlank": return `${e.player} flips a face-down token — a blank. The rest still wait.`;
|
||||
case "boobytrapBlank": return `${e.player} flips a face-down token and finds a blank. The rest still wait.`;
|
||||
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
|
||||
case "safeCreated": return `A massive safe slams down around the loot.`;
|
||||
case "itemsTraded": return `Two items blink and trade places.`;
|
||||
case "stoneTurnedToWater": return `Stone runs like water — a wave crashes out!`;
|
||||
case "stoneTurnedToWater": return `Stone runs like water, and a wave crashes out!`;
|
||||
case "handsSwapped": return `${e.a} and ${e.b} trade entire hands of cards!`;
|
||||
case "handsScrambled": return `CHAOS! Every hand is thrown in a pile and redealt!`;
|
||||
case "rammed": return `BAAA! ${e.attacker} turns into a goat and rams ${e.target} (${e.distance} spaces)!`;
|
||||
case "treasureThrown": return `${e.attacker} HURLS their treasure (${e.distance} spaces)!`;
|
||||
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
|
||||
case "illusionBelieved": return e.believed ? `${e.player} flinches, for the illusion feels real!` : `${e.player} laughs off the illusion.`;
|
||||
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
|
||||
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
|
||||
case "swapFizzled": return `${e.player}'s trade comes to nothing — the named items were not there to swap.`;
|
||||
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
|
||||
case "swapFizzled": return `${e.player}'s trade comes to nothing, for the named items were not there to swap.`;
|
||||
case "wardSprung": return `${e.owner}'s treasure was WARDED, and it bites ${e.victim}!`;
|
||||
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
|
||||
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
|
||||
case "warpTokensPlaced": return `Two dimensional warp tokens hum to life.`;
|
||||
case "warpStepped": return `${e.player} steps through the dimensional warp!`;
|
||||
case "exitsRedirected": return `The maze's outer exits twist and reconnect!`;
|
||||
case "outOfTurnWindow": return `${e.player} interrupts the flow of time (${e.kind === "interrupt" ? "Interrupt" : "Opportunity Fire"})!`;
|
||||
case "thumbOfGod": return `THE THUMB OF GOD descends! The die crashes down${e.aimedAt.x === e.landedAt.x && e.aimedAt.y === e.landedAt.y ? " dead on target" : " — and drifts"}!`;
|
||||
case "thumbOfGod": return `THE THUMB OF GOD descends! The die crashes down${e.aimedAt.x === e.landedAt.x && e.aimedAt.y === e.landedAt.y ? " dead on target" : ", and drifts"}!`;
|
||||
case "tokenScattered": return `${e.what} goes flying!`;
|
||||
case "ambushSet": return `You commit ${e.spell} to an ambush (${e.via}).`;
|
||||
case "ambushCancelled": return `You quietly disarm your ambush.`;
|
||||
case "ambushSprung": return `AMBUSH! ${e.owner}'s hidden ${e.via.replace(/-/g, " ")} springs on ${e.victim}!`;
|
||||
case "trapRedrawnDuringDeal": return null;
|
||||
case "died": return `☠ ${e.player} is dead${e.killedBy ? ` — killed by ${e.killedBy}` : ""}.`;
|
||||
case "died": return `☠ ${e.player} is dead${e.killedBy ? `, killed by ${e.killedBy}` : ""}.`;
|
||||
case "handTaken": return `${e.to} takes ${e.count} cards from ${e.from}'s body.`;
|
||||
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
|
||||
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
|
||||
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions 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 "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
|
||||
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll} — ${e.purpose}.`;
|
||||
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 "pushed": return e.player
|
||||
? `${e.by} shoves ${e.player} down the corridor!`
|
||||
: `${e.by} shoves the beast down the corridor!`;
|
||||
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime — it sticks, waiting.`;
|
||||
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime, where it sticks and waits.`;
|
||||
case "slimeTrapSprung": return `The slime releases its ${spellName(e.cardId)} at ${e.victim}!`;
|
||||
case "slimeWashed": return `The wave washes the slime away.`;
|
||||
case "wallDamaged": {
|
||||
const what = e.needed === 15 ? "door" : "wall";
|
||||
const weapon = e.source === "punch" ? "bare fists" : e.source === "troll" ? "the troll's fist" : cardDef(e.source).name;
|
||||
return `${e.player} batters the ${what} with ${weapon} — ${e.total}/${e.needed}.`;
|
||||
return `${e.player} batters the ${what} with ${weapon} (${e.total}/${e.needed}).`;
|
||||
}
|
||||
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
|
||||
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
|
||||
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated, both treasures lost!` : null;
|
||||
case "cardsDiscarded":
|
||||
return `${e.player} discards ${e.cards.map((c) => spellName(c.cardId)).join(", ")}.`;
|
||||
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
|
||||
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
|
||||
case "turnEnded": return null;
|
||||
case "gameWon": return `🏆 ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "— last wizard standing!"}`;
|
||||
case "gameWon": return `🏆 ${e.player} WINS ${e.reason === "treasures" ? "by treasure!" : "as the last wizard standing!"}`;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -261,7 +269,7 @@ export interface LogLine {
|
||||
const ACTING = new Set([
|
||||
"moved", "warpStepped", "jumpedPit", "counteractionPlayed", "treasurePickedUp", "treasureDropped",
|
||||
"numberPlayedForMovement", "cardsDiscarded", "turnStarted", "turnEnded", "doorUnlocked", "madDash",
|
||||
"climbedFromPit", "spellTrapped",
|
||||
"climbedFromPit", "spellTrapped", "tableTalk",
|
||||
]);
|
||||
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
|
||||
if ("caster" in e && typeof e.caster === "string") return e.caster;
|
||||
@@ -293,6 +301,15 @@ function loadSeen(): Record<string, number> {
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
at: string;
|
||||
@@ -325,7 +342,7 @@ export interface GameSummary {
|
||||
|
||||
export function attentionLabel(a: GameSummary["attention"]): string {
|
||||
switch (a) {
|
||||
case "counteract": return "UNDER ATTACK — respond!";
|
||||
case "counteract": return "UNDER ATTACK! Respond.";
|
||||
case "discard": return "DISCARD to your hand limit";
|
||||
case "interrupt": return "your interruption is waiting";
|
||||
default: return "YOUR TURN";
|
||||
@@ -333,8 +350,20 @@ export function attentionLabel(a: GameSummary["attention"]): string {
|
||||
}
|
||||
|
||||
function loadSeats(): Seat[] {
|
||||
try { return JSON.parse(localStorage.getItem(SEATS_KEY) ?? "[]"); }
|
||||
catch { return []; }
|
||||
let seats: Seat[];
|
||||
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 {
|
||||
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
|
||||
@@ -346,6 +375,10 @@ class Net {
|
||||
* socket that never fires onclose; the watchdog closes it by hand so
|
||||
* the reconnect loop (and the masthead banner) actually engage. */
|
||||
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. */
|
||||
private turnOwner: string | null = null;
|
||||
private watchdog: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -356,6 +389,10 @@ class Net {
|
||||
/** Lobby standee choices, by player name. */
|
||||
roomColors = $state<Record<string, number>>({});
|
||||
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);
|
||||
/** Seated in the Peanut Gallery: watching nameless, read-only. */
|
||||
spectating = $state(false);
|
||||
@@ -441,17 +478,24 @@ class Net {
|
||||
private ws: WebSocket | null = null;
|
||||
private token: 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 {
|
||||
if (this.ws) return;
|
||||
const ws = new WebSocket(SERVER_URL);
|
||||
this.ws = ws;
|
||||
this.lastHeard = Date.now();
|
||||
this.pingSentAt = null;
|
||||
if (!this.watchdog) {
|
||||
this.watchdog = setInterval(() => {
|
||||
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" });
|
||||
if (Date.now() - this.lastHeard > 45_000) this.ws.close();
|
||||
}, 15_000);
|
||||
}
|
||||
ws.onopen = () => {
|
||||
@@ -479,6 +523,8 @@ class Net {
|
||||
ws.onclose = () => {
|
||||
this.status = "disconnected";
|
||||
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
|
||||
// landed when the connection returns, and the talk goes back in the box.
|
||||
if (this.pending) { this.unconfirmed = this.pending.label; this.pending = null; }
|
||||
@@ -520,6 +566,7 @@ class Net {
|
||||
case "pong":
|
||||
break;
|
||||
case "seat": {
|
||||
this.onRefused = null;
|
||||
this.token = msg.token;
|
||||
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
|
||||
if (seatRoom) {
|
||||
@@ -538,8 +585,11 @@ class Net {
|
||||
break;
|
||||
case "room":
|
||||
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.roomBots = msg.bots ?? {};
|
||||
this.standIns = msg.standIns ?? [];
|
||||
this.audience = msg.audience ?? 0;
|
||||
this.rematchCall = msg.rematch ?? null;
|
||||
this.expected = msg.expected ?? [];
|
||||
@@ -602,6 +652,11 @@ class Net {
|
||||
break;
|
||||
case "events": {
|
||||
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[]);
|
||||
for (const e of msg.events as GameEvent[]) {
|
||||
if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
|
||||
@@ -634,6 +689,13 @@ class Net {
|
||||
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
|
||||
this.leaveLocal();
|
||||
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":
|
||||
this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
|
||||
this.leaveLocal();
|
||||
@@ -678,8 +740,8 @@ class Net {
|
||||
this.pendingImage = null;
|
||||
if (image && typeof msg.id === "string") {
|
||||
fetch(`${API_URL}/api/feedback-image/${msg.id}`, { method: "POST", headers: { "content-type": image.type }, body: image })
|
||||
.then((r) => { if (!r.ok) this.flash("The words went through; the picture did not — a PNG or JPEG under 2.5 MB, please"); })
|
||||
.catch(() => this.flash("The words went through; the picture did not"));
|
||||
.then((r) => { if (!r.ok) this.flash("The words went through, but the picture did not. A PNG or JPEG under 2.5 MB, please."); })
|
||||
.catch(() => this.flash("The words went through, but the picture did not."));
|
||||
}
|
||||
this.refreshFeedback();
|
||||
break;
|
||||
@@ -704,14 +766,28 @@ class Net {
|
||||
for (const key of voided) { if (this.voidedOnce.has(key)) doubted.add(key); }
|
||||
this.voidedOnce = voided;
|
||||
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) {
|
||||
this.seats = kept;
|
||||
saveSeats(this.seats);
|
||||
}
|
||||
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
|
||||
// credential itself is dead, and holding it helps nobody. But
|
||||
// "no such room" proves nothing about the seat — a restarting
|
||||
@@ -732,6 +808,7 @@ class Net {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the message left; false when the socket was not open to carry it. */
|
||||
private send(message: unknown): boolean {
|
||||
@@ -749,7 +826,7 @@ class Net {
|
||||
create(name: string): void {
|
||||
this.you = name;
|
||||
this.spectating = false;
|
||||
if (!this.send({ type: "create", name })) this.flash("Still reaching the table — try again in a moment");
|
||||
if (!this.send({ type: "create", name })) this.flash("Still reaching the table. Try again in a moment.");
|
||||
}
|
||||
|
||||
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
|
||||
@@ -767,12 +844,14 @@ class Net {
|
||||
(s) => s.roomId === this.roomIdPending && s.name === name,
|
||||
);
|
||||
if (!this.send({ type: "join", roomId, name, token: existing?.token ?? this.token })) {
|
||||
this.flash("Still reaching the table — try again in a moment");
|
||||
this.flash("Still reaching the table. Try again in a moment.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sit back down at a remembered seat. */
|
||||
resume(seat: Seat): void {
|
||||
/** Sit back down at a seat. It is remembered only once the server
|
||||
* 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.spectating = false;
|
||||
this.token = seat.token;
|
||||
@@ -809,7 +888,7 @@ class Net {
|
||||
/** A word back to the desk under one of your reports, proven by the seat it was filed from. */
|
||||
answerReport(report: { id: string; roomId: string; player?: string }, text: string): boolean {
|
||||
const seat = this.seats.find((s) => s.roomId === report.roomId && (!report.player || s.name === report.player));
|
||||
if (!seat) { this.flash("That seat is no longer in this browser, so the desk cannot be sure it is you"); return false; }
|
||||
if (!seat) { this.flash("That seat is no longer in this browser, so the keeper cannot be sure it is you."); return false; }
|
||||
return this.send({ type: "feedbackAnswer", reportId: report.id, text, seat: $state.snapshot(seat) });
|
||||
}
|
||||
|
||||
@@ -828,6 +907,16 @@ class Net {
|
||||
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 {
|
||||
this.send({ type: "abandonRoom" });
|
||||
}
|
||||
@@ -918,8 +1007,8 @@ class Net {
|
||||
try {
|
||||
new Notification(
|
||||
g.attention === "counteract"
|
||||
? `Wiz-War — you are under attack in ${g.roomId}!`
|
||||
: `Wiz-War — your turn in ${g.roomId}`,
|
||||
? `Wiz-War · you are under attack in ${g.roomId}!`
|
||||
: `Wiz-War · your turn in ${g.roomId}`,
|
||||
{
|
||||
body: `${g.players.join(" vs ")} · round ${g.round ?? "?"}`,
|
||||
tag: `wizwar-${g.roomId}`,
|
||||
@@ -933,6 +1022,7 @@ class Net {
|
||||
* line, and wiping it would erase the only notice of why. */
|
||||
leaveLocal(): void {
|
||||
localStorage.removeItem(SEAT_KEY);
|
||||
Sentry.setTags({ room: "none", seat: "none" });
|
||||
this.missedSteps = null;
|
||||
this.catchUp = null;
|
||||
this.pending = null;
|
||||
@@ -1013,6 +1103,7 @@ class Net {
|
||||
private resetChronicle(): void {
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
this.chatCount = 0;
|
||||
}
|
||||
|
||||
/** Summon one turn's reel by its chronicle turn number. */
|
||||
@@ -1051,7 +1142,7 @@ class Net {
|
||||
const label = describeCommand(command);
|
||||
if (!this.send({ type: "command", command })) {
|
||||
this.unsent = label;
|
||||
this.flash(`${label[0]!.toUpperCase()}${label.slice(1)} was not sent — the table is out of reach`);
|
||||
this.flash(`${label[0]!.toUpperCase()}${label.slice(1)} was not sent. The table is out of reach.`);
|
||||
return false;
|
||||
}
|
||||
this.unsent = null;
|
||||
|
||||
@@ -21,8 +21,8 @@ function nameOf(id: string | null): string {
|
||||
export function receiptFor(batch: readonly GameEvent[], e: GameEvent): Receipt | null {
|
||||
if (e.type === "attackMissed") {
|
||||
const why =
|
||||
e.because === "invisible" ? `${e.defender} is invisible; the die sent it astray.`
|
||||
: e.because === "shrink" ? `${e.defender} is shrunk; the die said miss.`
|
||||
e.because === "invisible" ? `${e.defender} is invisible, and the die sent it astray.`
|
||||
: e.because === "shrink" ? `${e.defender} is shrunk, and the die said miss.`
|
||||
: `${e.defender} outran it.`;
|
||||
return { title: `${nameOf(e.attackCardId)} misses ${e.defender}`, lines: [why] };
|
||||
}
|
||||
@@ -91,7 +91,7 @@ export function receiptFor(batch: readonly GameEvent[], e: GameEvent): Receipt |
|
||||
const soak = x.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
|
||||
lines.push(`${x.player}: ${x.lifeAfter + x.amount} → ${x.lifeAfter} life${soak ? ` (${soak})` : ""}`);
|
||||
} else if (x.type === "damageImmune") {
|
||||
lines.push(`${x.player} takes nothing — ${x.because}`);
|
||||
lines.push(`${x.player} takes nothing (${x.because})`);
|
||||
} else if (x.type === "lifeGained") {
|
||||
lines.push(`${x.player}: ${x.lifeAfter - x.amount} → ${x.lifeAfter} life, reversed`);
|
||||
} else if (x.type === "stonesDestroyed") {
|
||||
|
||||
@@ -188,11 +188,11 @@ export interface HouseRuling {
|
||||
export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
{
|
||||
id: "thumb-of-god", title: "The Thumb of God", cards: ["thumb-of-god"],
|
||||
body: ["THE THUMB OF GOD is a divine meteor. Aim it at a square; the die drifts up to two squares in a random direction, then every token in and around the landing square — objects, treasures, creatures, even wizards — is flung to a random nearby square. Walls mean nothing to falling cardboard, and there is no counteraction."],
|
||||
body: ["THE THUMB OF GOD is a divine meteor. Aim it at a square and the die drifts up to two squares in a random direction. Then every token in and around the landing square is flung to a random nearby square, whether object, treasure, creature or wizard. Walls mean nothing to falling cardboard, and there is no counteraction."],
|
||||
},
|
||||
{
|
||||
id: "illusion-wall", title: "Illusion walls", cards: ["illusion-wall"],
|
||||
body: ["An ILLUSION WALL is real only to those who believe it. Its creator sees through it from the start; everyone else sees stone until they walk into it or see through it, and the maze remembers each wizard's verdict separately. An untested illusion shimmers faintly, and doubting it is free."],
|
||||
body: ["An ILLUSION WALL is real only to those who believe it. Its creator sees through it from the start. Everyone else sees stone until they walk into it or see through it, and the maze remembers each wizard's verdict separately. An untested illusion shimmers faintly, and doubting it is free."],
|
||||
},
|
||||
{
|
||||
id: "ambush", title: "Ambushes", cards: ["opportunity-fire"],
|
||||
@@ -201,24 +201,24 @@ export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
{
|
||||
id: "butt-head", title: "The goat's ram", cards: ["butt-head", "mad-dash", "power-run"],
|
||||
body: [
|
||||
"BUTT-HEAD's ram is movement. The charge is measured as the shortest walk through the corridors from where you cast it to your victim's square, on the legs you have this turn — three, plus any NUMBER played for movement — and it spends them. You cannot pad the blow by taking the long way round, and the goat lands on the victim's square. There is no ceiling on the damage.",
|
||||
"BUTT-HEAD's ram is movement. The charge is measured as the shortest walk through the corridors from where you cast it to your victim's square, on the legs you have this turn (three, plus any NUMBER played for movement), and it spends them. You cannot pad the blow by taking the long way round, and the goat lands on the victim's square. There is no ceiling on the damage.",
|
||||
"MAD DASH doubles the whole allowance, NUMBER cards and POWER RUN points included, so a well-placed goat can ram for sixteen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "slime", title: "Spells cast into slime", cards: ["fill-square-with-slime"],
|
||||
body: ["Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a slime may hold several. They go off one at a time, oldest first: each wizard who pushes in springs one spell, the next wizard the next. The card says only that each spell goes off once; the queue is this table's reading. A slime shows how many it holds, and a peek names them, since every cast into it was seen."],
|
||||
body: ["Spells cast at a FILL SQUARE WITH SLIME lodge in the gel, and a slime may hold several. They go off one at a time, oldest first, so each wizard who pushes in springs one spell and the next wizard the next. The card says only that each spell goes off once, and the queue is this table's reading. A slime shows how many it holds, and a peek names them, since every cast into it was seen."],
|
||||
},
|
||||
{
|
||||
id: "doors-and-sight", title: "Doors and sight", cards: ["pick-lock", "master-key", "remove-lock"],
|
||||
body: [
|
||||
"A wizard beside a door they can open — its lock removed, unlocked this turn, or PICK LOCK or MASTER KEY in hand — sees through the doorway. The hallway behind them still cannot.",
|
||||
"A wizard beside a door they can open (its lock removed, unlocked this turn, or PICK LOCK or MASTER KEY in hand) sees through the doorway. The hallway behind them still cannot.",
|
||||
"\"The door will relock behind you\" means it: passing through an unlocked door shuts it at the walker's back unless a hand holds it open. A door unlocked and not passed relocks at the turn's end.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "visionstone", title: "Visionstone", cards: ["visionstone", "dust-cloud"],
|
||||
body: ["VISIONSTONE pierces its one wall for every sight the game asks of its bearer — creations and utility spells included, not only direct attacks. It does not see through a DUST CLOUD."],
|
||||
body: ["VISIONSTONE pierces its one wall for every sight the game asks of its bearer, for creations and utility spells as well as direct attacks. It does not see through a DUST CLOUD."],
|
||||
},
|
||||
{
|
||||
id: "dust-cloud", title: "Dust clouds", cards: ["dust-cloud"],
|
||||
@@ -234,11 +234,11 @@ export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
},
|
||||
{
|
||||
id: "disease", title: "Disease", cards: ["disease"],
|
||||
body: ["DISEASE is a plague cast on yourself (\"You're the carrier!\"). The caster carries it; sharing a square bites in both directions; there is no counteraction."],
|
||||
body: ["DISEASE is a plague cast on yourself (\"You're the carrier!\"). The caster carries it, sharing a square bites in both directions, and there is no counteraction."],
|
||||
},
|
||||
{
|
||||
id: "fire-imp", title: "The fire imp's scorch", cards: ["fire-imp"],
|
||||
body: ["The fire imp's scorch is a spell, as the FAQ has it: magical, and counteractable."],
|
||||
body: ["The fire imp's scorch is a spell, as the FAQ has it, so it is magical and can be counteracted."],
|
||||
},
|
||||
{
|
||||
id: "soulstone", title: "Soulstone", cards: ["soulstone"],
|
||||
@@ -246,15 +246,15 @@ export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
},
|
||||
{
|
||||
id: "ward", title: "The ward's bite", cards: ["ward"],
|
||||
body: ["A WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does — a reversal, a reflection — touches the ward's caster."],
|
||||
body: ["A WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does (a reversal, a reflection) touches the ward's caster."],
|
||||
},
|
||||
{
|
||||
id: "curses", title: "Reversing and reflecting a curse", cards: ["reverse", "full-reflection", "reflection", "slow-death", "walking-dead", "idiot"],
|
||||
body: [
|
||||
"A REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse, as the FAQ rules: a point gained per card drawn, half a point per space walked, permanently.",
|
||||
"A FULL REFLECTION returns a permanent curse — SLOW DEATH, WALKING DEAD, IDIOT — onto its caster instead of letting it evaporate.",
|
||||
"A REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse, as the FAQ rules, into a point gained per card drawn and half a point per space walked, permanently.",
|
||||
"A FULL REFLECTION returns a permanent curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of letting it evaporate.",
|
||||
"REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window: an ABSORB or a BLUNT meets it as it would any blow.",
|
||||
"A REFLECTION against a permanent curse afflicts both wizards. WALKING DEAD and IDIOT say so on the card; SLOW DEATH follows REFLECTION's own rule that a spell works for both parties.",
|
||||
"A REFLECTION against a permanent curse afflicts both wizards. WALKING DEAD and IDIOT say so on the card, and SLOW DEATH follows REFLECTION's own rule that a spell works for both parties.",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -267,25 +267,25 @@ export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
},
|
||||
{
|
||||
id: "teleport", title: "Teleporting across the maze's edge", cards: ["teleport"],
|
||||
body: ["TELEPORT ignores the maze's outer edge as it ignores any wall. A teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on — the lettered openings are not needed. Four spaces straight up from two squares below the top edge lands two squares up from the bottom."],
|
||||
body: ["TELEPORT ignores the maze's outer edge as it ignores any wall. A teleporter leaving the maze at any square's edge re-enters at the opposite edge on the same line, one space on, with no need for the lettered openings. Four spaces straight up from the second row lands in the third row from the bottom."],
|
||||
},
|
||||
{
|
||||
id: "power-drain", title: "Power Drain", cards: ["power-drain", "blunt", "absorb"],
|
||||
body: ["POWER DRAIN drains the number played. The caster gains it whether the blow is BLUNTed or ABSORBed — the FAQ has the counter blunting the damage done, not the drain — and a wall drained for its points gives them up as a wizard would. A FULL SHIELD stops the drain with the spell."],
|
||||
body: ["POWER DRAIN drains the number played. The caster gains it whether the blow is BLUNTed or ABSORBed (the FAQ has the counter blunting the damage done and leaving the drain whole), and a wall drained for its points gives them up as a wizard would. A FULL SHIELD stops the drain with the spell."],
|
||||
},
|
||||
{
|
||||
id: "troll", title: "The troll's fist", cards: ["troll"],
|
||||
body: ["A commanded TROLL punches a wall line beside it as it punches a wizard: a D4 of damage toward the wall's fall, once a turn."],
|
||||
body: ["A commanded TROLL punches a wall line beside it as it punches a wizard, for a D4 of damage toward the wall's fall, once a turn."],
|
||||
},
|
||||
{
|
||||
id: "pits", title: "Crossing a pit", cards: ["create-pit"],
|
||||
body: [
|
||||
"Crossing a pit on a 2, 3, or 4 means edging around its rim. The walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it. A warp mouth on the rim is a way off like any square, and is taken when no square offers. A pit with no way off cannot be entered.",
|
||||
"Crossing a pit on a 2, 3, or 4 means edging around its rim. The walker lands on an open square beside the pit. If there is more than one, they name it by clicking it. A warp mouth on the rim is a way off like any square, and is taken when no square offers. A pit with no way off cannot be entered.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "big-man", title: "The giant's shove at a fork", cards: ["big-man"],
|
||||
body: ["BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do. The FAQ gives the choice to the other player; so does this table."],
|
||||
body: ["When BIG MAN pushes a wizard to a fork, they leave by any open side but the way the giant came. If more than one is open they choose, and the giant's stride hangs until they do. The FAQ gives the choice to the wizard being pushed, and so does this table."],
|
||||
},
|
||||
{
|
||||
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
|
||||
@@ -293,7 +293,7 @@ export const HOUSE_RULINGS: HouseRuling[] = [
|
||||
},
|
||||
{
|
||||
id: "force-field", title: "Force Field", cards: ["force-field"],
|
||||
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn: they may not enter its caster's square, nor cast on or past them, on every side — where the card says one side, this table gives all four."],
|
||||
body: ["FORCE FIELD, after stopping the spell, stands until the end of the opponent's turn. Until then they may not enter its caster's square, or cast on or past them, from any side. The card says one side, and this table gives all four."],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
title: "EQUIPMENT",
|
||||
paragraphs: [
|
||||
"A four section playing board, a deck of 124 cards, one sheet of cardboard tokens, a rule booklet, and one die (numbered from 1-4). It may be 8-sided or 4-sided, depending on what game edition you have.",
|
||||
"*(6E: 125 cards, two sheets of tokens — see Section 1.)*"
|
||||
"*(6E: 125 cards and two sheets of tokens.)*"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -75,7 +75,7 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
paragraphs: [
|
||||
"There are five different kinds of cards in the game. They are called ATTACK cards, NEUTRAL cards, COUNTER-ACTION cards, NUMBER cards, and MAGIC STONES.",
|
||||
"**ATTACK** cards say \"Attack\" in the upper left-hand corner of each card. **ONLY ONE ATTACK CAN BE USED DURING EACH PLAYER TURN.** In the upper right-hand corner, the abbreviation \"L.O.S.\" might appear. This means that a spell can only be used if a player has a clear line of sight to the person or object the spell is to be cast upon. Line of sight is defined as the line going from the center of the attacker's square to the center of the target's square. The center is the dot marked on each space. In the case of a CREATE WALL spell, the center would be the center of the wall to be created. If this line of sight is interrupted by any part of a wall, the spell cannot be cast.",
|
||||
"*(6E: the boards have no printed dots — eyeball the square centers. See Section 1.)*",
|
||||
"*(6E: the boards have no printed dots, so eyeball the square centers.)*",
|
||||
"**NEUTRAL** cards are non-offensive or non-defensive spells. ANY NUMBER of Neutral spells can be cast during a player's turn. It is not impossible for a player to use his entire hand in one turn.",
|
||||
"**COUNTERACTION** cards are the only cards that can be used OUT OF TURN. They can be used (but aren't usually) during a normal turn as a NEUTRAL card, but their primary use is as a defense against attacks. They are played immediately following an attack by the player who was attacked. You CANNOT counteract a Neutral card. You can, however, use an ABSORB or BLUNT against indirect damage caused to you (e.g., by a falling DESTROYed WALL).",
|
||||
"More than one COUNTERACTION card can be used to defend against a single attack, either used sequentially or added together. It is also possible to COUNTERACT a COUNTERACTION card, so an attack may bounce back and forth a few times before coming to rest. In case a COUNTERACTION card only stops half of a spell, always round the resulting damage UP to the nearest whole number. If all damage from a spell is stopped, any secondary effects, such as a lost turn, are also stopped.",
|
||||
@@ -105,7 +105,7 @@ export const RULEBOOK_BASE: RuleSection[] = [
|
||||
"Damage points, points, damage, and point-based spells all refer to the same thing: things that take away points from you, an opponent, or object (such as a wall or door). They have nothing to do with duration-based spells.",
|
||||
"If a person is eliminated from the game by being killed, then the last attacker gets all the dead person's cards. If a player is eliminated by having lost both his treasures to opponents' home bases, his cards are discarded.",
|
||||
"You cannot physically attack through any type of wall, firewall, or bush. You cannot attack yourself. You cannot attack on the first round of turns, even if you have the SPEED spell.",
|
||||
"*(6E: \"You cannot attack yourself\" is stated in the 6E rulebook; FAQ errata: the first-round restriction should read \"no ATTACKS during the first round\", not \"no combat\" — e.g. Destroy Wall next to someone on turn one is legal.)*",
|
||||
"*(6E: \"You cannot attack yourself\" is stated in the 6E rulebook. The FAQ's errata say the first-round restriction should read \"no ATTACKS during the first round\" rather than \"no combat\", so a Destroy Wall next to someone on turn one is legal.)*",
|
||||
"ANY CARD MAY BE PLAYED WITHOUT A NUMBER CARD, but its power is only \"1\". There are no \"1\" NUMBER cards in the deck.",
|
||||
"When all else fails, you may PUNCH your opponent. This requires no card, but does constitute your attack for the turn. A punch does one point of damage, and you must be in the same square as your opponent to do it. If the object to be hit fills an entire square, you may be in an adjacent square to hit it. It is possible, though time-consuming, to punch a wall down. A wall takes 20 points of damage to destroy; a door takes 15. Any attack against an inanimate object counts as your one attack for the turn. The only inanimate objects you can attack are the walls, doors, and thornbushes.",
|
||||
"It is possible to walk by an opponent or cast a spell past him without affecting him at all, if you want to.",
|
||||
|
||||
@@ -94,14 +94,14 @@ export const HOW_TO_PLAY: RulesSection[] = [
|
||||
{
|
||||
title: "Winning",
|
||||
body: [
|
||||
"Carry two treasures that aren't yours home — one at a time, to your home square, and drop them there — or be the last wizard standing. Lose both of your own treasures to enemy homes and you're out.",
|
||||
"Move and play cards in any order, then end your turn. You have three steps and one attack a turn — a step, a spell, two more steps is a fine turn. The banner above the board keeps count of your moves and your treasures home.",
|
||||
"Carry home two treasures that aren't yours, one at a time, and drop them on your home square. Or be the last wizard standing. If both of your own treasures end up on rival homes, you're out.",
|
||||
"Each turn you have three steps and one attack, to use in any order. You might take a step, cast a spell and take two more steps, then end your turn. The banner above the board keeps count of your moves and the treasures you have carried home.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Moving",
|
||||
body: [
|
||||
"You can move up to three squares on your turn. Click a square next to your wizard to step there; blocked steps are refused with a reason. Standing on a lettered edge opening lights its arrow green and pulses the square it leads to — click the arrow to step through.",
|
||||
"You can move up to three squares on your turn. Click a square next to your wizard to step there, and a blocked step is refused with the reason. When you stand on a lettered opening at the board's edge, its arrow lights green and the square it leads to pulses. Click the arrow to step through.",
|
||||
"To move farther, select a number card and press “Play for +N movement”.",
|
||||
],
|
||||
},
|
||||
@@ -115,23 +115,23 @@ export const HOW_TO_PLAY: RulesSection[] = [
|
||||
{
|
||||
title: "Fighting",
|
||||
body: [
|
||||
"Click an enemy wizard in your square to punch. When you are attacked, the sidebar offers your counteractions — click one to play it, or let the attack resolve.",
|
||||
"Your creatures are the diamond tokens: click one to select it, click adjacent squares to march it, click a wizard or creature in its square to attack.",
|
||||
"Click an enemy wizard in your square to punch. When you are attacked, the sidebar offers your counteractions. Click one to play it, or let the attack resolve.",
|
||||
"Your creatures' tokens are framed in your color. Click one to select it, click the squares beside it to march it, and click a wizard or creature in its square to attack.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Turns, hands, and games",
|
||||
body: [
|
||||
"End your turn with the draw selector set to how many cards you want (up to two). Use “Discard cards…” to throw cards away without playing them.",
|
||||
"Games are saved move-by-move on the server and survive anything. The lobby lists every game you're in and whose turn it is; the tab title and favicon flip when a turn is yours, and “notify me on my turn” adds browser notifications. “Transfer seat” hands a game to another device with a spoken phrase.",
|
||||
"Online games are saved on the server move by move, so a closed tab or a dropped connection loses nothing. The lobby lists every game you're in and whose turn it is. The tab title and icon change when a turn is yours, and “notify me on my turn” adds browser notifications. “Transfer seat” hands a game to another device with a short phrase you enter there.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "The automatons",
|
||||
body: [
|
||||
"Any empty seat can be filled with an automaton — a clockwork wizard that plays by the same rules and the same information a human seat gets. Add one in the lobby before the game starts, choosing its temperament: a HUNTER marches for treasure, a BERSERKER hunts wizards over gold, a WORRIER keeps its distance and guards what it has. Pick MYSTERY and the maze deals a temperament in secret — you'll have to read its play to guess its mood, and the roster only unmasks it (🎭) when the game ends.",
|
||||
"The difficulty picker sets how much the clockwork has to work with, never how fairly it plays: an APPRENTICE draws one card a turn and knows a modest spellbook, an ADEPT draws two and defends its gold, and an ARCHMAGE knows every trick in the deck — ambushes, amplifies, walls blasted through, roads denied. Every tier plays its cards correctly; none of them cheats, peeks, or rolls loaded dice.",
|
||||
"The automatons chat a little, hold grudges not at all, and take exactly one action per second so you can watch them think. If one does something that looks brilliant, it probably learned it from a human at this table.",
|
||||
"Any empty seat at an online table can be filled with an automaton, a clockwork wizard that plays by the same rules and sees only what a human seat would see. Add one in the lobby before the game starts and choose its temperament. A HUNTER marches for treasure, a BERSERKER hunts wizards over gold, and a WORRIER keeps its distance and guards what it has. Pick MYSTERY and the maze deals a temperament in secret. You'll have to read its play to guess its mood, and the roster unmasks it (🎭) only when the game ends.",
|
||||
"The difficulty picker sets how much the clockwork has to work with. An APPRENTICE draws one card a turn and knows a modest spellbook, an ADEPT draws two and defends its gold, and an ARCHMAGE knows every trick in the deck: ambushes, amplified spells, walls blasted through, and roads denied. Every tier plays its cards correctly, and none of them cheats, peeks or rolls loaded dice.",
|
||||
"The automatons chat a little, hold no grudges, and take one action a second so you can watch them think. If one does something that looks brilliant, it probably learned it from a human at this table.",
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in new issue
Block a user