39 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 9500b8062a Safari's masked extension frames stay out of Sentry
Safari reports an extension's scripts as webkit-masked-url://hidden/, so the
extension filter never matched them; WIZWAR-17 was one such failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:17:02 -05:00
Eric WagonerandClaude Opus 5.5 e3681cd703 The family list welcomes Wyrm, and Dip seats as many as its map allows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:57:07 -05:00
Eric WagonerandClaude Opus 5.5 e2194a49e7 Table talk replayed from the chronicle wears its speaker's color
A rejoin rebuilds chat from tableTalk events, whose player was not counted
as the actor, so those lines took the turn owner's mark instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:49:39 -05:00
Eric WagonerandClaude Opus 5.5 b0993032f7 Chaos asks for your answer in a modal, as the other out-of-turn choices do
The shield-or-surrender question was a slip in the sidebar, below the
board on a phone, and a player who missed it left the table waiting.
It now takes the centre of the screen like a Ward or a giant's push:
who cast it, what it does, a Full Shield button when you hold one, and
the way through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:56:32 -04:00
Eric WagonerandClaude Opus 5.5 f372b0a3ca A fire imp asks for no orders, and Chaos asks for no shield you do not hold
The creature slip offered "attack ready" for every creature you control,
but only trolls, skeletons and shadows strike on command; a fire imp
neither moves nor takes orders, so it no longer awaits any. The Chaos
slip told every player to tap a Full Shield; a hand without one is now
told so and offered only the way through.

The duel skill gains game fifteen's lessons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:54:00 -04:00
Eric WagonerandClaude Opus 5.5 3a9f39780b A wallet that lost the seat last sat in takes it back on load
The cap that dropped the newest seat left wallets without the very seat
their browser was sitting in, so a reload landed in the Peanut Gallery.
That seat is also kept on its own; loading the wallet now restores it
from there when it is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:07:13 -04:00
Eric WagonerandClaude Opus 5.5 bca4d5eba7 The seat wallet's cap of fifty drops the oldest seats, not the newest
Seats are kept newest first, but the cap kept the tail, so a browser
holding more than fifty threw away the seat it had just taken. Sitting
in that room hid it (each room message remembers the seat again); the
first games poll after moving to another table lost it for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:13:09 -04:00
Eric WagonerandClaude Opus 5.5 29025f2ccb A rejoin's replayed chronicle replaces the one the table already holds
When a socket dropped and came back, the server sent the whole chronicle
again and the client appended it, so every turn was counted twice and
the instant-replay eyes asked for turns the server had not reached ("no
such turn yet"). The talk count doubled the same way and never reset
between rooms; it now resets with the chronicle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:01:04 -04:00
Eric WagonerandClaude Opus 5.5 0ced49ca6c A wall beyond the door you stand at and have unlocked is in sight for wall-aimed spells
Edge casts judged sight on the bare board, where every door hangs shut,
so the threshold peek (an unlocked, held, removed or pickable door at
your elbow) reached only square-aimed spells. Ramses (BDFS) unlocked a
door with MASTER KEY and could not STONE TO WATER the wall beyond it
until he stepped through. Edge casts now see with the caster's eye.
Widening only: refused casts never entered ledgers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:10:56 -04:00
Eric WagonerandClaude Opus 5.5 8dcdfd2415 Mist shields its wizard from creatures; a glued square turns with its sector; Waterbolt splits the power it is cast with
Three of Ramses's reports from KFJL:
- Rev 28: MIST-BODY's "may not attack or be attacked" holds against
  creatures. A wraith's or democratic monster's touch, a fire imp's
  scorch, and an ordered troll/skeleton/shadow blow all pass the mist by;
  older games keep the strike their ledgers recorded. The automaton no
  longer orders its creatures at a misted wizard.
- ROTATE and RELOCATE SECTOR threw while GLUE was down: GLUE keeps its
  cell's key in the sustained effect's edge slot, and the remap read it
  as an edge. It maps as a cell now (the throw never reached a ledger,
  so this ships ungated).
- WATERBOLT's damage/knockback split was sized by the NUMBER's face; the
  engine weighs POWERSTONE's +1 and AMPLIFY's doubling, so a 5 behind a
  powerstone was refused for not totalling 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:54:43 -04:00
Eric WagonerandClaude Opus 5.5 3368a92cf5 A seat link is cut from a report to the end of its string, so a name with a quote, an ampersand or a hash in it cannot end the cut before the token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-17 20:14:41 -04:00
Eric WagonerandClaude Opus 5.5 6606853332 A seat link's token is cut from an error report and its breadcrumbs before it goes to Sentry, since the history breadcrumb for taking the link out of the address bar still names it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-17 20:12:49 -04:00
Eric WagonerandClaude Opus 5.5 c984bb4b56 A crash report's strings reach the log on one line: anyone may post one, and a line break could otherwise forge another log line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 17:06:24 -04:00
Eric WagonerandClaude Opus 5.5 956c908867 Chromium's crash reports reach the server, which logs each and files it in Sentry as a warning, and the page's own errors name the room and seat and leave extensions' failures out
A tab that crashes takes the page's error reporting down with it, so the
page never says so. Every page now names a default Reporting API endpoint,
/api/reports, where Chromium sends a crash report after the fact; the
server logs the room, the reason when one is given, and the browser, and
files a warning that rings no bell. Other report types are ignored, and an
address may send ten an hour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 17:03:14 -04:00
Eric WagonerandClaude Opus 5.5 9a1c98ca55 The host's phrase button says it makes a transfer phrase, and its tooltip says who it is for and how the player uses it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 15:54:54 -04:00
Eric WagonerandClaude Opus 5.5 4b7e05713d The host can give a stuck player's seat to the clockwork and make a phrase that brings a seat back, and a socket in a background tab no longer drops every minute
A "seats" button beside "transfer seat" opens the host's panel. In a game
under way, "give to the clockwork" lets an automaton play a person's seat
from the next beat, with their hand, treasures and place untouched; a
standIn line on the ledger records it, the chronicle says so, the
stand-in never talks under the person's name, and the person's token
opens nothing after. "Make a phrase" mints the four-word phrase for any
person's seat; claiming it re-seats on a new token, and a seat the
clockwork stood in for goes back to its person mid-turn. A rematch holds
a stood-in seat for its person. The gallery now has its own claim box,
for a player who opens the invite link in a fresh browser.

The client's watchdog pinged and then closed a socket quiet for 45
seconds. A background tab's timers run about once a minute, so the last
pong was always a minute old when it looked, and idle tabs reconnected
every 58 seconds. It now closes only when its own ping went unanswered.
The server pings every socket at the protocol level every 30 seconds and
drops one that left the last ping unanswered, so a crashed browser's
socket no longer lingers until TCP gives up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 15:43:24 -04:00
Eric WagonerandClaude Opus 5.5 7ef0c1bc14 On a phone, the table fits the screen and another wizard's turn says whose it is
The masthead's links never wrapped, so a game widened a phone's page to
nearly 500px: the page scrolled sideways and the opening roll-off sat off
its centre. And while an automaton's opening turn played back, the rail
that says "Automaton is taking their turn" sat below the fold, leaving a
board that moved on its own. Of 132 bot-first games, 13 humans never made
a move. Now the links wrap, and a caption over the board names the wizard
whose turn it is, and says when yours is next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:35:39 -04:00
Eric WagonerandClaude Opus 5.5 5ea0d4fdec The keeper can bring back a seat a browser lost: a reseat line on the ledger gives that wizard's seat a new token and the old one opens nothing after, wizwar-reseat.sh on the host asks for it through a keeper door that listens on the loopback alone and opens only to a key the deploy makes once, and the link it prints carries the seat in the fragment, which leaves the address bar at once and is kept only when no seat held in this browser still opens and the server takes it; a gallery watching a game in play is told both ways back, the front page asks before forgetting a game not yet finished, and the server's tests now run with the rest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:33:04 -04:00
Eric WagonerandClaude Opus 5.5 1b9acfd7c4 The family list carries the kit's current pitches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 09:29:58 -04:00
Eric WagonerandClaude Opus 5.5 6f86076712 The hosting line says what it promises: chipping in unlocks nothing, and nothing is held back without it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 09:23:44 -04:00
Eric WagonerandClaude Fable 5.1 636d97d5c1 The nightly rollup and backup say by their exit status whether they managed, and the deploy leaves the cron file off a host the hall schedules
On the shared host the hall's hall-nightly.sh (game-kit, gamehall/ops) runs
both scripts for every game and checks in with Sentry Crons once for all,
so this game's own check-in files are absent there and its exit status is
the word. A box of its own keeps its cron and check-ins as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 10:07:06 -04:00
Eric WagonerandClaude Opus 5.5 c64ff70ed4 The table speaks plainer, and keeps its whimsy
Wording throughout the app, the server's messages and the engine's
refusals trades its stacked dashes for sentences, while the maze still
trusts you to honor its curses, the gallery still listens, and the
clockwork still learns its best tricks from the humans at the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:29:07 -04:00
Eric WagonerandClaude Opus 5.5 ad23c83b8a The rollup and the backup check in once, when they end, with the run's duration
A start and a finish sent seconds apart can reach Sentry in either order,
and a start that lands second is never closed, so a fast rollup could be
reported as a timeout on a night it succeeded. Hnefatafl's monitor did so
nine nights in fifteen on the shared host. One check-in at the end cannot
race; a run that hangs sends nothing and is marked missed. Installed on
the host by hand; the next deploy installs the same files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-10-10 17:50:55 -04:00
Eric WagonerandClaude Opus 5.5 aeb58bdf4a A treasure carried home says so, and a turn out of steps points at End turn
A first-time player banked a stolen chest, stepped off with no moves left,
and left (NQ6S): the score changed only in the turn bar's quiet tally, and
a still board with no word read as a game that had stopped. Now a slip
announces the score for the rest of the turn, and once the steps are
spent the turn bar says to cast or end the turn while End turn beckons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:36:27 -04:00
Eric WagonerandClaude Opus 5.5 ceb22a9898 A stale tab's reload no longer throws: a retired chunk resolves to nothing, and nothing is mounted
The preload-error handler claims the failed import and reloads; the import
then resolves empty, and destructuring its default threw (WIZWAR-X).

Fixes WIZWAR-X

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZtFMp1AtjXPBY4Z69Tx81
2026-09-30 12:06:34 -04:00
Eric WagonerandClaude Opus 5.5 cbf897ff13 Pages show the hall-wide notice, when there is one, at the top
The keeper's hall-announce writes one file on the shared host; the site
serves it at /hall/announcement.json, and the banner shows it with the time
in the reader's own clock, dismissable one notice at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 13:03:37 -04:00
Eric WagonerandClaude Opus 5.5 397c68a1c8 Wiz-War lives on the shared hall host: its scripts, skills and docs name it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:52:36 -04:00
Eric WagonerandClaude Opus 5.5 56ea593e07 A deploy ships the engine's own data: only the top-level ledger folder is left behind
The exclusion for local game data matched any folder named data, so the
cards and boards the server reads never shipped after the droplet's first
setup; the live copy of cards.json had drifted to August's (text of FAQ
rulings, and rows of a set this edition does not deal). All 198 production
ledgers replay clean with the current data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:48:04 -04:00
Eric WagonerandClaude Opus 5.5 811d5f84cb Wiz-War's host follows the hall's shared layout: its own Caddy site file, its own access log, a memory cap for a shared box
The host's Caddyfile gathers each game's site from /etc/caddy/sites/;
Wiz-War's is rendered from deploy/Caddyfile.tmpl and logs to
wizwar-access.log, which the rollup and the pulse read. The server's memory
cap drops from 700M to 350M (it runs in about 125M), so a runaway cannot
starve other games sharing the box. Applied on the live droplet with its
logs renamed in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:37:06 -04:00
Eric WagonerandClaude Opus 5.5 d5b92d9a63 The about panel no longer credits its making
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 20:58:56 -04:00
Eric WagonerandClaude Opus 5.5 a4196480ce A socket's own error closes that socket, not the server
A message over the size limit or a malformed frame raised an error on
the socket, which had no listener, and Node ended the process: one
message from anyone could take the server down until systemd restarted
it. The socket is terminated instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 01:21:45 -04:00
Eric WagonerandClaude Opus 5.5 3a6b0ff43e Credibility pass: the illusion tests roll both ways, and the seams from the last batch are sanded
Scope: 66b5c06..HEAD.

- The rev-27 illusion tests search seeds for a believed and a doubted
  roll and assert fixed life totals; seed 42 alone never believed, so
  the BLUNT pricing went unchecked.
- Three docblocks stranded above newly inserted functions are back on
  their own (openCreatureStack, resolvedBlow, castTrace).
- POWER ATTACK's life trade is one helper instead of three copies; the
  goat's charge distance is one helper for its validator, its ram and
  its pricing.
- The family list loads once into a shared store; the lobby and gallery
  share one talk filter; Board uses the engine's neighbor().
- A creature's blow shows the card that summoned it by the creature's
  kind, not by matching the blow's display name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-26 00:22:20 -04:00
Eric WagonerandClaude Fable 5.1 e97deb8d6f A commanded troll lights only the walls and doors beside it, and so does a punching wizard
Every interior line lit whenever an edge was wanted; the troll can strike
only the four sides of its own square, so only those light now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-24 21:37:13 -04:00
Eric WagonerandClaude Fable 5.1 7a0a972452 A creature's blow is named on the stack: the counter is played against the fire imp's scorch, not a punch (SC59)
The caster whose creature is countered now sees "your attack, answered"
and the name of their own blow, instead of an incoming punch with the
opponent's counter card standing in the attack slot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-24 21:32:34 -04:00
Eric WagonerandClaude Fable 5.1 c3f63d4eb7 Rev 27: an ILLUSIONARY ATTACK is priced as the spell it fakes, so BLUNT and the rest meet it (UPJE)
The believed illusion was dealt after the counter trail had already
spoken over a zero, so a BLUNT trailed for nothing and the full points
landed. Now the fake carries its spell's points through the pipeline and
the belief roll decides whether the weighed blow lands. Games dealt under
rev 26 and before replay as they were.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-24 20:40:47 -04:00
Eric WagonerandClaude Fable 5.1 991a6a0443 ROTATE SECTOR asks which way: the tap marks the sector, a button turns it (UPJE)
The clockwise checkbox sat in the hint strip and defaulted on, so a
sector could spin the way nobody meant. The turn is now its own decision,
in the dock beside the board like the teleport confirm.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-24 20:30:37 -04:00
Eric WagonerandClaude Fable 5.1 6c29f750fc A waterwall tapped onto a slime washes it: the tap picks the face across the caster's approach (B8G9)
The flash for a corridor-line card no longer names wizards, bushes and
slimes as its targets; it gives the card's own line hint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 22:59:14 -04:00
Eric WagonerandClaude Fable 5.1 0244cffb4b The family list knows the hall: about panels link to gamehall.kestrelsnest.social, and a new game gets a card there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 21:04:31 -04:00
Eric WagonerandClaude Fable 5.1 9f1358496d The about panel says the games are free and where the hosting tip jar is
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-23 20:25:05 -04:00
58 changed files with 2171 additions and 596 deletions

No files matched your search

+2 -2
View File
@@ -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
+23
View File
@@ -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
+3 -3
View File
@@ -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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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:
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
+36
View File
@@ -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
View File
@@ -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
+9 -3
View File
@@ -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 ---
+6 -2
View File
@@ -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]
+2 -1
View File
@@ -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": {
+3 -1
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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";
}
+86 -3
View File
@@ -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");
+61 -2
View File
@@ -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);
+4 -2
View File
@@ -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"
}
}
+8 -8
View File
@@ -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?",
+6 -6
View File
@@ -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
View File
@@ -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();
+84
View File
@@ -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
View File
@@ -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);
+23 -1
View File
@@ -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");
+151
View File
@@ -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 });
});
});
+154
View File
@@ -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 });
});
});
+1 -1
View File
@@ -4,5 +4,5 @@
"noEmit": true,
"types": ["node"]
},
"include": ["src"]
"include": ["src", "test"]
}
+3 -3
View File
@@ -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" />
+19 -2
View File
@@ -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"
}
]
}
+119
View File
@@ -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>
File diff suppressed because it is too large. Load diff
+25 -11
View File
@@ -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}
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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;
+41 -33
View File
@@ -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"}
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
+7 -7
View File
@@ -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>
+1 -1
View File
@@ -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 });
+28
View File
@@ -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.
}
})();
-23
View File
@@ -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 [];
}
}
+8 -8
View File
@@ -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],
+3 -3
View File
@@ -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.";
+1 -1
View File
@@ -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). */
+19 -2
View File
@@ -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
View File
@@ -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;
+3 -3
View File
@@ -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") {
+18 -18
View File
@@ -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."],
},
];
+3 -3
View File
@@ -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.",
+9 -9
View File
@@ -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.",
],
},
];