32 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 d76a18d5e6 A seated wizard leaves the room's expected list, live and on rebuild
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 23:45:36 -04:00
Eric WagonerandClaude Fable 5.1 1ebd6dbe47 A refused seat is logged, and the wallet drops a seat only on the second refusal in a row
A lost seat mid-game is the costliest quiet failure this table has: one
refusal around a restart cost Kestrel PRPM's seat with nothing in the
journal to say why.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 23:22:00 -04:00
Eric WagonerandClaude Fable 5.1 f738957100 Rev 24: a REFLECTION against a permanent curse afflicts both wizards
"If this spell is REFLECTed, both players take 1/2 point of damage per
space moved" (WALKING DEAD); "In case of REFLECTION, both players are
affected" (IDIOT); SLOW DEATH follows REFLECTION's own rule that a
spell works for both parties. A permanent curse has no duration for
the reflection's split to halve, so the caster went untouched; the
curse is now laid on them as well, as if cast back. Older games replay
as they were; PRPM, reflected at seq 108 with no move since, was
re-stamped to 24 at its table's request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 23:07:41 -04:00
Eric WagonerandClaude Fable 5.1 f26893f45b The reel measures its padding inside its width, so it stays within the share page on a phone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 21:18:35 -04:00
Eric WagonerandClaude Fable 5.1 942e04c821 The reel fits a phone: title on its own line, a smaller caption, controls that stay on screen
The shared replay page had no rules for a narrow screen: the reel's
header stacked into a column, the caption ran large, and the controls
ran off the right edge. Under 640px the title takes its own line with
the count and buttons beneath, the caption reads smaller, and the
controls wrap. The share page also pins its text size against
Safari's inflation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 21:11:48 -04:00
Eric WagonerandClaude Fable 5.1 11dff428f4 The leading casting panel sizes to its card instead of scrolling inside a sliver
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 20:49:55 -04:00
Eric WagonerandClaude Fable 5.1 1019cb5a38 The casting panel leads the right column, and a number's movement button sits in the dock
On a laptop the right column scrolls in place, and the turn banner,
the coaching, and the scoresheet pushed the casting panel — where an
attack is answered and a number is played for movement — below the
fold, showing as a sliver. The panel now leads the column while a card
is aimed or a blow answered, and a number's "Play for +N movement"
also stands in the dock beneath the board.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 20:48:47 -04:00
Eric WagonerandClaude Fable 5.1 e6ede1aa48 The pulse reads a challenge issue as a caller, not an error
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:56:06 -04:00
Eric WagonerandClaude Fable 5.1 ba1d1d297f The keeper's bell rings at error level, so Sentry files it as high priority and alerts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:36:51 -04:00
Eric WagonerandClaude Fable 5.1 3c10e0463f The raven sets expectations; Create and Join say so when the table is not yet reached
The lobby tells a challenger what to expect in the table's own voice:
the keeper answers every raven, sometimes within the minute, sometimes
after a night's sleep; the seat is held either way, and the table is
theirs meanwhile. A Create or Join tried before the socket is open
used to vanish; it now says the table is still being reached.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:34:45 -04:00
Eric WagonerandClaude Fable 5.1 1be1cf7d1b A lobby may challenge the keeper, and the table can talk before the boards flip
"Challenge Kestrel, the keeper" holds a seat under the keeper's name,
writes the call to the ledger, says so at the table, and raises a
Sentry issue fingerprinted to the room — one ring per call, with the
room's link in the message — which is the alarm the keeper's phone
listens for. The keeper answers by the link: taking the seat, and
leaving a word if the game must wait. For that word, and for any table
to settle when to start, the lobby now shows its talk and carries a
composer; a joiner sees what was said before they sat. Three calls per
address per hour, one per room.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:30:49 -04:00
Eric WagonerandClaude Fable 5.1 de279364ce The table-talk count is reactive, so your own words never show as unread
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:25:07 -04:00
Eric WagonerandClaude Fable 5.1 8582b7feb7 What you missed, in facts; a timeline on the reel; room for table talk
A returning player's slip now lists what happened while they were away
— "Automaton took your treasure.", "You lost 3 life.", "It's your
turn." — each fact opening the catch-up reel at its moment, read from
the missed steps as the server redacted them for that seat; when
nothing touched them it says what each wizard was up to instead. The
reel gains a timeline: a tick per move in the mover's color, taller
where a turn begins, a blow lands, or gold changes hands, and any tick
jumps the reel there.

The chronicle takes a filter — All, Game, Table talk — with an unread
count on the talk that arrived while the casting panel covered it (a
wizard's own words never count), a peek at the latest line above the
composer while the panel is up, and the composer itself no longer
leaves when a response is owed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:22:54 -04:00
Eric WagonerandClaude Fable 5.1 80dd7a7865 Rev 23: POWER DRAIN drains the number played; the troll punches walls; the first-person stack fits
POWER DRAIN's gain is the number played, BLUNTed or ABSORBed or not
(FAQ: the counter blunts the damage done, not the drain), and a wall
drained for its points gives them up too. Older games gave only what
the opponent lost and nothing from a wall, and replay so.

"This rock-hard beast can punch a player (or a wall, etc.)": a commanded
troll now punches a wall line beside it for a D4, once a turn — a new
command, so no old game changes.

Under the first-person pane the board strip could run into the dock on
a short or tall window; the pane now takes no more height than the row
can spare and the strip shrinks beneath it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-17 00:07:34 -04:00
Eric WagonerandClaude Fable 5.1 803c51415e Rev 22: TELEPORT crosses the maze's outer edge anywhere
A teleporter ignores walls, and the outer edge is no more than a wall
to it: a line leaving the maze at any square's edge re-enters at the
opposite edge on the same row or column, one space on. The lettered
openings connect as they always did. Older games crossed the edge only
at the openings, and replay so; U3U2, dealt at rev 21 with no teleport
yet cast, was re-stamped to 22 at its table's request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 23:56:13 -04:00
Eric WagonerandClaude Fable 5.1 7a6aa3d2a1 View controls above the board, decisions below it, the account beside it
The board's controls had three homes: zoom in the corner, the eyes
toggle floating above it, and the turn's actions wrapping into rows
beneath. Now a toolbar hugs the board's top — First-person at the
left; − · level · + · Fit at the right — and a dock sits beneath it in
three groups: what the square offers, the tools (punch a wall, hints,
discard) set quieter, and draw beside End turn pinned at the right.
While a card is aimed the dock leads with the decision in a line —
"Choose a destination · lit squares are within 4 · ends your movement"
— and a cancel, so the lit squares always say what they mean. The
panel beside the board keeps the card and the full account, now with
the instruction first and discarding demoted to a quiet last resort.
A phone keeps the offers and End turn in view and its tools behind
More. With the hand at the left, the columns grow with the cards so
they no longer overlap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 23:47:10 -04:00
Eric WagonerandClaude Fable 5.1 648f6ab8cd Receipts under resolved attacks; the cast line clears; the table holds still; teleport wraps
Each resolved attack now folds a receipt under its line, read off the
engine's own record: the blow as it came in, what each counter left of
it in the order they were weighed, what landed, and whose life moved.
The resolution event carries the incoming damage and duration and the
trail of counters for it. Hotseat chronicles carry the same.

The line a cast was accepted on stayed on the board: its timer compared
a reactive proxy to the object it held. Kept raw, it clears.

The board jumped while walking. The "sending" note added a line beside
End turn and took it away again, and the refusal toast stood in the
flow above the table for five seconds; both now float and move
nothing.

Teleport reaches through the lettered openings in the engine, but the
board dimmed the squares beyond them. The eligibility map follows the
warps as the engine does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 23:32:34 -04:00
Eric WagonerandClaude Fable 5.1 7d0c1c68a3 A cast that resolved at once draws the sight line it was accepted on
The attack on the stack has always shown its line; a creation or a
curse resolved in the same breath showed nothing, so a legal aim
through a Visionstone and out a lettered opening (PPL7, move 309)
looked like a cheat. The line a cast was accepted on — plain, through
the one wall a VISIONSTONE dissolves, or AROUND THE CORNER's two legs —
now draws on the live board for a few seconds and on that move of any
reel, traced after the fact so the cast's own dust cloud cannot blind it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 23:05:32 -04:00
Eric WagonerandClaude Fable 5.1 12a655868b The table says when it is out of reach, a finished table can call a rematch, and spells and treasure read plainly
Connection: while the socket is down a notice says so, the hand, board,
and action buttons go quiet, a card left selected stays selected, and
table talk stays in its box. A command that could not leave is named
as not sent; one that left before the drop is named as unconfirmed
until the board answers. A command on its way shows as sending beside
End turn and confirms when the table replies. Talk shows as sending
until the table echoes it, and comes back to the composer if the
socket drops first.

Rematch: anyone at a finished table may call one. The server makes a
new room hosted by the caller, with the same clockwork at the same
tiers and moods (a mystery stays a mystery) and the same expansion
setting, and keeps seats for the last table's other wizards, who find
the call on the finished table and in their lobby ledger and sit with
one click. The old table's replay stays where it was. A solo table
against the clockwork keeps "play again".

Reading the table: an ongoing spell's chip opens its card with a plain
account — what it does, who cast it on whom, and when it ends, in the
rulebook's own terms. Each score row counts the treasures a wizard has
carried home, and marks a wizard one carry from winning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 22:52:55 -04:00
Eric WagonerandClaude Fable 5.1 bc99ee3eb7 Attacks aimed at a bush or the ooze: five points clear the square
A THORNBUSH or ROSEBUSH "will be destroyed" by five points of damage,
and the FAQ lets only fire hurt a KILLER OOZE; the table refused every
attack aimed at one. An attack tapped onto a bush or ooze now lands on
it, the damage accumulating across attackers and turns, and the
fifth point clears the square. A WIZARDBLADE is swung from the square
beside it, as its text asks of a target that fills a square.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 22:35:01 -04:00
Eric WagonerandClaude Fable 5.1 23b4a7675e The lobby's outer computer button says "Hide computer options" while its panel is open
So it reads apart from the Add button inside the panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 22:26:11 -04:00
Eric WagonerandClaude Fable 5.1 f7991ee64f The lobby is a table being assembled
"Gather your wizards" leads, with the room code and the seat count
beneath it. The roster is the picture: each seat's portrait, name, and
role, an empty seat showing what fills it, and beneath it the two ways
to fill one — copy the invite link, add a computer wizard — with the
raw URL folded away for pasting by hand. Your own row carries the
wizard picker, a deliberate three-by-two with a check on the chosen
one, open by itself until you have a wizard. Adding a computer wizard
is a panel with difficulty and personality, a line on what the chosen
personality does, and one explicit button. Readiness and Start sit
together at the bottom: "Waiting for one more wizard" above the
button, "Start game · 2 wizards" once it can flip; the expansion
option is nearby and quiet, and abandoning the room is a small link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 22:18:39 -04:00
Eric WagonerandClaude Fable 5.1 9dee01d393 The help becomes a reference desk: searchable, linkable, and it remembers its place
The rules tab is one body — the condensed rules, the rulebook, the
expansion, and the FAQ — with a contents list, a search that labels
each hit by source, and a link on every section. The rulebook's
emphasis renders, and the (6E: …) notes are set apart from the original
words. The card library gains filters for set, type, and target, ranks
an exact name first, counts its results, and says so when there are
none; a card opens as an entry: the card itself beside reading-size
text with the printed wording, deck counts, the official FAQ, this
table's rulings, and links to the cards it names. The house rulings are
organized by card, in the present tense, with the revision history kept
off the page. Every page and card has a link (/?help=rules/…, /?card=…)
that opens the desk to it, and the desk reopens on the tab, search, and
scroll position it was closed at.

About leads with the worn box and the friends, shows the workshops as a
small gallery with a direct link to the demo reel, and says plainly
what an online ledger keeps and what a passed-around device keeps to
itself. The tally leads with finished games, treasure and combat wins,
and hours played, and explains that wizards are counted by name and
table time by the clock between moves.

Escape closes only the foremost layer and returns focus to the card
that opened it; the tabs wrap on a phone instead of clipping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 22:13:44 -04:00
Eric WagonerandClaude Fable 5.1 4c2a253723 The tester's second pass: the rule as written, advice that follows the board, the fold, the story on the replay page, and one click to begin
- Help said "walk, then act"; the rules let moves and cards come in any
  order, and now the help does too.
- The council's advice is derived from the live view while its panel is
  open, so a Number just played for movement is no longer recommended.
  It rests when the turn is not yours; "ask again" had nothing to add.
- The landing puts the name and the ways in before the clip, and beside
  it on a desktop: at 1280x720 the play button and Create/Join are above
  the fold.
- The replay page opens with the same deed the share card promises, and
  "play free" stands beside the reel instead of after the history.
- In hotseat the "pass the device" scrim stood above the roll-off, so the
  first click on "begin" landed on the handoff card. The handoff waits
  for the dice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 21:43:12 -04:00
Eric WagonerandClaude Fable 5.1 3d88f629b7 The face-up hands at game's end keep to their frame and fold away
On a desktop the table is pinned to the window and the board's row may
shrink to nothing; four hands of cards beneath it did exactly that in
room 7S6C, and nothing put them away. The reveal now scrolls inside a
capped frame and its header carries a fold button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 00:36:57 -04:00
Eric WagonerandClaude Fable 5.1 6e49fc9020 Credibility pass: comments say what the code can't, and the seams are gone
Engine: pit-rim exits live in one helper (pitRimExits) shared by the
resolver and the automaton; chargeReach names the six-stride cap; dust
sight is inDust + dustAtEnd; PushPending is exported once; the force-field
counter builds its events in one list; drawUnderSlowDeath says what it
draws under. Test groups are named for the rule they pin, not the
revision that introduced it.

Client: one smoothstep; one edgeScar per battle-scarred edge; one
board-zone rule (which also seats the caption strip on phones); the
compass names live in SIDE_NAMES; net.flash() is the one toast; MediaQuery
replaces two hand-rolled matchMedia listeners; sprites carry their sizes
as plain numbers and their CSS in one rule each; canvas helpers are
formatted like the rest of the tree. Comments that told how a cel or a
fallback used to look now say what it draws. The actions-over notice no
longer blames a pickup when slime ended the turn.

Server and ops: dataRoot() is the one data directory; headlineOf builds
its deeds without a cast; the rollup and skills read the same way the
code behaves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-16 00:21:34 -04:00
Eric WagonerandClaude Fable 5.1 85690f0e23 Rev 21: LIFESAVER saves, and FORCE FIELD stands for the turn
An audit of every card in the deck, prompted by the Alter Ego, found
two more whose cast worked but whose promise was never kept.
LIFESAVER — "immune to the effects of losing both of your treasures"
— was cast and remembered and never asked where a wizard is
eliminated for exactly that; twenty-three recorded casts never
mattered. FORCE FIELD stopped the spell and vanished, though the card
keeps it standing until the opponent's turn ends, barring them from
entering its caster's square or casting on or past them. Both are
honored from rev 21, the field on every side where the card says one.
Older games replay as they were played. Tests pin both readings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:57:38 -04:00
Eric WagonerandClaude Fable 5.1 10d5c5815d The Alter Ego casts from its own square; the first-stride cue leaves the maze
Randy raised his double, selected it, and could cast nothing through
it: the card's whole point — "it may use any of the spells in your
hand, and you need not be in its L.O.S." — had never been wired. A
cast may now name the double, and the double's square becomes the
spell's origin: sight, reach, the launch point the reel films, and
the direction a knockback or rout pushes. The hand, the attack, and
every answer to it stay the caster's. Spells that move the caster's
own body — a goat's charge, a swap — do not travel through it. On the
board, the double's sight lights the eligible squares; selecting the
double and then a card aims from where it stands. A test hides the
caster and fires through the double.

The first-stride caption sat on the maze and hid the walls beside the
wizard; it now stands above the board with a color chip for "you."

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:38:58 -04:00
Eric WagonerandClaude Fable 5.1 5f58a0955a A notice taller than the screen scrolls inside its frame
Preferences on a phone ran off the bottom with no way to reach the
rest. Every notice now caps at the window's height and scrolls within.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:29:18 -04:00
Eric WagonerandClaude Fable 5.1 ff35e8ef8e The table fits the window: the hand stays in reach, the maze takes the height
On a desktop the game grid is now exactly the window's height. The
board and the paper rail share the top row, the rail scrolling in
place, and the hand with its End turn sit pinned beneath — nothing a
first turn needs is ever below the fold. The board's window fills
whatever height the row allows instead of a fixed subtraction. A
wide, short window — a laptop in landscape — stands the hand beside
the board on its own until the player chooses a side, since a tall
maze under a bottom hand left a postage stamp. A short window shrinks
the bottom hand's cards a little. The eyes toggle floats over the
board's corner so it costs no height, and the masthead's "invite
friends" hides on phones where it wrapped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:19:59 -04:00
Eric WagonerandClaude Fable 5.1 4cc0423846 The first minute, for a stranger: what the game is, what to do, and how to join
The landing says the pitch before it asks for a name — steal treasure,
blast through walls, outwit your friends — and states the goal. The
coach says "select a card, then choose its target," which is what the
cards actually want. The turn banner counts treasures home beside the
moves left. The council speaks once when its three voices agree. A
friend arriving on an invite link meets "Join Eric's game," a name
field, and a seat, with the gallery an afterthought; the masthead
says "invite friends" beside the room code. The first-person view has
a toggle beside the board instead of a checkbox in preferences. How
to play opens with winning. And a shared moment's card is titled by
the turn's story — the treasure carried home, the blow that landed,
the spell cast — instead of whose turn it was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 23:03:17 -04:00
Eric WagonerandClaude Fable 5.1 a071494f4a Reduced motion is honored everywhere the table moves
One shared motion policy: the OS's reduce-motion setting, read live
through Svelte's own media query, folds into the flourishes switch.
Under it the board plays no flourishes, the reel skips its die
interlude and slate wipes, bodies jump between squares instead of
gliding, the canvas holds its ambient shimmers still, and the camera
cuts wherever the director would have panned — in the cockpit as in
the reel, so the eye still lands on what the director chose to watch.
Timers and animation frames are cleared when the policy flips or the
pane closes. A first turn and two reels stepped clean under the
setting with no errors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 22:42:08 -04:00
51 changed files with 3526 additions and 970 deletions
+17 -16
View File
@@ -22,7 +22,11 @@ quiet, say so in one line before the details.
protocol error"` — anything nonzero deserves a look (Sentry has protocol error"` — anything nonzero deserves a look (Sentry has
the stack traces) the stack traces)
2. **Sentry** (MCP connector, org `locallygrownnet`, project `wizwar`): 2. **Sentry** (MCP connector, org `locallygrownnet`, project `wizwar`):
- `search_issues` for `is:unresolved` — new issues are the headline - `search_issues` for `is:unresolved` — new issues are the headline.
An issue titled "<name> challenges Kestrel to a game — <link>" is
not an error: it is the keeper's bell (a lobby called Eric to a
seat; the room link is in the title). Report it as a caller waiting,
with the room code; Eric resolves it himself once answered.
- `get_monitor_details` for cron monitor slug `new-monitor` (named - `get_monitor_details` for cron monitor slug `new-monitor` (named
wizwar-backup) — last check-in must be ok and recent; the uptime wizwar-backup) — last check-in must be ok and recent; the uptime
monitor alerts on its own but note any incidents monitor alerts on its own but note any incidents
@@ -34,26 +38,23 @@ quiet, say so in one line before the details.
written at 00:10 by /usr/local/bin/wizwar-rollup.sh; counts only, no written at 00:10 by /usr/local/bin/wizwar-rollup.sh; counts only, no
addresses): the last 7 rows give requests, human vs bot addresses, addresses): the last 7 rows give requests, human vs bot addresses,
socket connects, path mix (/, /clips, /watch, /ws), external socket connects, path mix (/, /clips, /watch, /ws), external
referrers (reddit.com, boardgamegeek.com after the 2026-09-03 referrers, new rooms, human seats and names, reports/replies,
announcement), new rooms, human seats and names, reports/replies,
and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors, and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors,
serviceStarts, ledgerMB). Report day-over-day direction, not raw serviceStarts, ledgerMB). Report day-over-day direction, not raw
rows. Its Sentry cron monitor is slug `wizwar-rollup` (a missed rows. Its Sentry cron monitor is slug `wizwar-rollup` (a missed
check-in there means the rollup itself is broken). Re-roll a day by check-in there means the rollup itself is broken). Re-roll a day by
hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors
(one per address per day) whose link carried `?ref=<name>` — the tags (one per address per day) whose link carried a tag the rollup header
on Eric's posts (reddit, bgg, …) — or a Facebook fbclid; referrers names (`?ref=<name>`, a utm_source, Facebook's fbclid); referrers
alone can't attribute Reddit or BGG, which send none. alone can't attribute Reddit or BGG, which send none.
**Right now** (on request, or when the announcement is fresh): live **Right now** (on request): `wizwar-visitors.sh` prints it — live
sockets `ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l`, sockets, rooms touched in the last hour, and today's partial row —
rooms touched in the last hour `find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l`, see /wizwar-visitors.
and today so far `wizwar-rollup.sh $(date -u +%F)` (a partial row,
replaced again at 00:10; the vitals in it are the moment's).
5. **The table itself**: 5. **The table itself**:
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l` - Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
- New human names this week: room ledgers' join lines minus bots - New human names this week: the rollup rows' `humanNames` (host and
(Automaton*) and known names, on rooms created in the last 7 days joiners alike, bots excluded) minus the regulars the visitors skill
(meta line `createdAt`) lists
- Unanswered feedback count from /var/lib/wizwar/feedback.jsonl — - Unanswered feedback count from /var/lib/wizwar/feedback.jsonl —
if any, offer to run the reports desk (`/wizwar-reports`) 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/access.log` and a count of
@@ -71,9 +72,9 @@ action, or "nothing needs you."
- The service restarts cleanly (ledgers replay); restarts in the journal - The service restarts cleanly (ledgers replay); restarts in the journal
during deploys are normal, not incidents. during deploys are normal, not incidents.
- Room memory is evicted after idle (finished 30 min, others 24 h); - Room memory is evicted after idle (finished 30 min, others 24 h), each
boot-restores load everything then shed, so post-deploy memory spikes sleeper leaving a stub on disk; a boot replays only rooms whose stub is
settle within the hour. 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 - Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
requires it; a reboot there is maintenance, not an outage. 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/access.log (self-rotating,
+4 -3
View File
@@ -19,8 +19,9 @@ first, so the traffic line is current to the minute. Its output:
"right now" line. Two sockets at a quiet hour is Eric plus one. "right now" line. Two sockets at a quiet hour is Eric plus one.
- **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`: - **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`:
addresses of people (bots split out), front-page vs gallery vs addresses of people (bots split out), front-page vs gallery vs
replay views, campaigns (`?ref=` tags on posted links, Facebook's replay views, campaigns (the tags a link carries: `?ref=`, a
fbclid) and referrers. Reddit and BGG send no referrer. utm_source, Facebook's fbclid) and referrers. Reddit and BGG send no
referrer.
- **players today / NEW today** — names seated in rooms created today; - **players today / NEW today** — names seated in rooms created today;
NEW means the name's first room ever is today. Known regulars: NEW means the name's first room ever is today. Known regulars:
Kestrel, Reuben, Jason, Yohon, and "Claude" (the duel seat). Kestrel, Reuben, Jason, Yohon, and "Claude" (the duel seat).
@@ -37,7 +38,7 @@ Lead with new names and whether anyone is at the table now. Then, for
each stranger, say how far they got and where they stopped — the last each stranger, say how far they got and where they stopped — the last
human move and the span tell it: a room that is "lobby only" stopped human move and the span tell it: a room that is "lobby only" stopped
before the start button; a game of under five minutes ending on before the start button; a game of under five minutes ending on
pickUpTreasure is the treasure wall the slips now explain; a long span pickUpTreasure is the treasure wall (the in-game slips explain it); a long span
with many moves is a real session, resumable from their lobby. Quote with many moves is a real session, resumable from their lobby. Quote
human chat verbatim. Then the traffic line as a single sentence with human chat verbatim. Then the traffic line as a single sentence with
the day-over-day direction if a previous row exists. the day-over-day direction if a previous row exists.
+1
View File
@@ -1,2 +1,3 @@
# Nightly rollup of yesterday's traffic, table, and vitals (UTC). Installed by deploy.sh. # Nightly rollup of yesterday's traffic, table, and vitals (UTC). Installed by deploy.sh.
# The time is also SCHEDULE in wizwar-rollup.sh, which tells Sentry to expect it; change both.
10 0 * * * root /usr/local/bin/wizwar-rollup.sh 10 0 * * * root /usr/local/bin/wizwar-rollup.sh
+16 -10
View File
@@ -4,22 +4,26 @@
# the room ledgers, stats.json and feedback.jsonl), and the box's vitals. # the room ledgers, stats.json and feedback.jsonl), and the box's vitals.
# Counts only: no addresses are kept. The pulse reads it for trends the # Counts only: no addresses are kept. The pulse reads it for trends the
# self-rotating access log cannot keep. Referrers are what browsers send # self-rotating access log cannot keep. Referrers are what browsers send
# (Reddit and BGG send none); campaigns are the ?ref=<name> tags on links # (Reddit and BGG send none); campaigns are the tags a link itself carries
# Eric posts, one count per address per day, plus Facebook's fbclid. # — ?ref=<name> on a link Eric posts, a utm_source, or Facebook's fbclid
# Cron: 10 0 * * * (UTC), see /etc/cron.d/wizwar-rollup (deploy.sh installs both). # 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 # Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL
# (absent = no check-ins). # (absent = no check-ins).
set -u set -u
OUT="/var/lib/wizwar/rollup.jsonl" OUT="/var/lib/wizwar/rollup.jsonl"
LOG="/var/log/wizwar/rollup.log" LOG="/var/log/wizwar/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}" DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
# The crontab line, as /etc/cron.d/wizwar-rollup spells it: the Sentry
# monitor is told the same shape, so a missed night is noticed.
SCHEDULE="10 0 * * *"
CRON_URL=$(cat /root/.wizwar-sentry-cron-rollup 2>/dev/null || true) 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 # 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 # been told the shape of, and a bare ping to an unknown slug is accepted
# and dropped without a word. # and dropped without a word.
checkin() { checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \ [ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"10 0 * * *\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \ -d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true "$CRON_URL" || true
} }
@@ -31,7 +35,9 @@ d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp() t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
BOT = ("bot", "crawl", "spider", "facebookexternalhit", "slack", "discord", "twitter", "preview", "curl", "python", "go-http", "wget", "headless") BOT = ("bot", "crawl", "spider", "facebookexternalhit", "slack", "discord", "twitter", "preview", "curl", "python", "go-http", "wget", "headless")
# --- traffic: every access log file touched since the day began --- # --- traffic: every access log file touched since the day began. Address
# and request counts split people from bots; the path mix and referrers
# count people only. ---
req = 0; human = 0; bot = 0; hips = set(); bips = set() req = 0; human = 0; bot = 0; hips = set(); bips = set()
paths = collections.Counter(); status = collections.Counter(); refs = collections.Counter(); ws = 0 paths = collections.Counter(); status = collections.Counter(); refs = collections.Counter(); ws = 0
# Where a visit came from, when the link itself says so: ?ref=<name> on a # Where a visit came from, when the link itself says so: ?ref=<name> on a
@@ -63,15 +69,15 @@ for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
key = ("/clips" if uri.startswith("/clips") else "/watch" if uri.startswith("/watch") key = ("/clips" if uri.startswith("/clips") else "/watch" if uri.startswith("/watch")
else "(assets)" if uri.startswith("/assets") or re.search(r"\.(png|jpg|svg|ico|css|js|webmanifest|mp4)$", uri) else "(assets)" if uri.startswith("/assets") or re.search(r"\.(png|jpg|svg|ico|css|js|webmanifest|mp4)$", uri)
else uri if uri in ("/", "/ws", "/robots.txt") else "(other)") else uri if uri in ("/", "/ws", "/robots.txt") else "(other)")
if not isbot: paths[key] += 1 # the path mix is what people looked at if not isbot: paths[key] += 1
status[str(r.get("status", 0))[0] + "xx"] += 1 status[str(r.get("status", 0))[0] + "xx"] += 1
ref = " ".join(h.get("Referer", [""])) ref = " ".join(h.get("Referer", [""]))
m = re.match(r"https?://([^/]+)", ref) m = re.match(r"https?://([^/]+)", ref)
host = m.group(1) if m else "" 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 "104.236.96.198" in host
# A referrer on /.env is a scanner's costume (a forged google.com), # Scanners forge referrers on paths like /.env, and the app answers
# not a reader — and the app answers unknown paths with the page, # unknown paths with the page itself, so the status cannot tell a
# so the status cannot tell them apart. Only the pages count. # probe from a reader. Only the pages count.
if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1 if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1
# --- the table --- # --- the table ---
@@ -107,7 +113,7 @@ except OSError: pass
# --- vitals (now, at rollup time) --- # --- vitals (now, at rollup time) ---
def sh(cmd): def sh(cmd):
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip() try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip()
except Exception: return "" except Exception: return "?"
mem = sh("systemctl show wizwar -p MemoryCurrent --value"); peak = sh("systemctl show wizwar -p MemoryPeak --value") mem = sh("systemctl show wizwar -p MemoryCurrent --value"); peak = sh("systemctl show wizwar -p MemoryPeak --value")
disk = sh("df --output=pcent / | tail -1").strip().rstrip("%") disk = sh("df --output=pcent / | tail -1").strip().rstrip("%")
load = sh("cut -d' ' -f1 /proc/loadavg") load = sh("cut -d' ' -f1 /proc/loadavg")
+1 -1
View File
@@ -7,7 +7,7 @@ set -u
DAY="${1:-$(date -u +%F)}" DAY="${1:-$(date -u +%F)}"
/usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1 /usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1
python3 - "$DAY" <<'PY' python3 - "$DAY" <<'PY'
import json, sys, glob, os, datetime, subprocess import json, sys, glob, datetime, subprocess
day = sys.argv[1] day = sys.argv[1]
now = datetime.datetime.now(datetime.timezone.utc) now = datetime.datetime.now(datetime.timezone.utc)
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day)) print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
+13 -25
View File
@@ -7,7 +7,7 @@
// BERSERKER for blood, the WORRIER for the shadows between the two. // BERSERKER for blood, the WORRIER for the shadows between the two.
import { cardDef, type CardInstance } from "./cards"; import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side, pitRimExits } from "./board";
import { bentSightFor, sightedCellsFor, type GameView } from "./view"; import { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game"; import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game"; import type { AmbushTrigger, Command, PlayerId } from "./game";
@@ -584,31 +584,19 @@ function wallBlastTarget(
return { cell: best.cell, side: best.side }; return { cell: best.cell, side: best.side };
} }
/** The sides a walker may leave a pit by, having entered it heading
* `dir`: neighbouring squares first, a warp mouth on the rim only when
* no square offers — the one reading legal under every deckRev. */
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
const { floor, mouths } = pitRimExits(view.board, pit, dir, (c) => view.squareContents[cellKey(c)]?.kind === "stone");
return floor.length > 0 ? floor : mouths;
}
/** /**
* BFS over walkable steps toward the nearest goal. Doors count as passable * BFS over walkable steps toward the nearest goal. Doors count as passable
* when the clockwork can unlock them; the first such door is reported so the * when the clockwork can unlock them; the first such door is reported so the
* key gets used before the boot. * key gets used before the boot.
*/ */
/** Can a wizard entering the pit at `pit` heading `dir` get off its rim
* — the maze's own test: some side other than the way back with a cell
* there, no wall between, and no stone on it. */
/** The sides a walker may leave a pit by, having entered it heading
* `dir`: neighbouring squares first; a warp mouth on the rim only when
* no square offers, the reading every revision accepts. */
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
const footing = (d: Side) => {
if (d === opposite(dir)) return null;
const t = stepTarget(view.board, pit, d);
if (t.kind === "blocked" || view.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t.kind;
};
const floor = SIDES.filter((d) => footing(d) === "step");
return floor.length > 0 ? floor : SIDES.filter((d) => footing(d) === "warp");
}
function pitLandable(view: GameView, pit: Cell, dir: Side): boolean {
return pitExits(view, pit, dir).length > 0;
}
export function pathToward( export function pathToward(
view: GameView, view: GameView,
@@ -634,12 +622,12 @@ export function pathToward(
const { to, viaDoor } = step; const { to, viaDoor } = step;
const k = cellKey(to); const k = cellKey(to);
if (seen.has(k)) continue; if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
const hazard = view.squareContents[k]?.kind; const hazard = view.squareContents[k]?.kind;
if (hazard === "stone") continue;
// A pit is entered by leaping to the square beyond it in the same // A pit is entered by leaping to the square beyond it in the same
// direction; with nothing to land on, the maze bounces the leaper // direction; with nothing to land on, the maze bounces the leaper
// back and charges the stride. Goal or waypoint, that is no road. // back and charges the stride. Goal or waypoint, that is no road.
if (hazard === "pit" && !pitLandable(view, to, dir)) continue; if (hazard === "pit" && pitExits(view, to, dir).length === 0) continue;
// Out of a pit only by one of its exits. // Out of a pit only by one of its exits.
if (view.squareContents[cellKey(c)]?.kind === "pit") { if (view.squareContents[cellKey(c)]?.kind === "pit") {
const entry = cameBy.get(cellKey(c))?.dir; const entry = cameBy.get(cellKey(c))?.dir;
@@ -766,7 +754,7 @@ function overLimit(view: GameView): number {
* clogged hand never refreshes; only cards the brain has no play for are * clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */ * shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command { function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
const draw = drawUnderCurses(view, tier.draw); const draw = drawUnderSlowDeath(view, tier.draw);
const deficit = draw - (handLimitOf(view) - view.yourHand.length); const deficit = draw - (handLimitOf(view) - view.yourHand.length);
if (tier.sheds && deficit > 0) { if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand] const shed = [...view.yourHand]
@@ -782,7 +770,7 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle
/** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what /** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what
* it can afford, keeping four life in hand, and nothing at all when it * it can afford, keeping four life in hand, and nothing at all when it
* cannot — a bare hand beats a bare grave. */ * cannot — a bare hand beats a bare grave. */
export function drawUnderCurses(view: GameView, draw: number): number { export function drawUnderSlowDeath(view: GameView, draw: number): number {
const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed); const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed);
if (!slow) return draw; if (!slow) return draw;
return Math.max(0, Math.min(draw, me(view).life - 4)); return Math.max(0, Math.min(draw, me(view).life - 4));
+18
View File
@@ -253,6 +253,24 @@ export function stepTarget(
return { kind: "blocked", by: "wall" }; return { kind: "blocked", by: "wall" };
} }
/** The sides a walker may leave a pit by, having entered it heading
* `entry`: the neighbouring squares that offer footing, and the warp
* mouths on the rim, the way back excluded. `filled` names the squares
* nothing can land on. */
export function pitRimExits(
board: AssembledBoard, pit: Cell, entry: Side, filled: (c: Cell) => boolean,
): { floor: Side[]; mouths: Side[] } {
const floor: Side[] = [];
const mouths: Side[] = [];
for (const d of SIDES) {
if (d === opposite(entry)) continue;
const t = stepTarget(board, pit, d);
if (t.kind === "blocked" || filled(t.to)) continue;
(t.kind === "step" ? floor : mouths).push(d);
}
return { floor, mouths };
}
/** /**
* Line of sight from the center of `from` to the center of `to`, blocked by * Line of sight from the center of `from` to the center of `to`, blocked by
* wall/door/firewall edges the segment crosses and by any `blockedCells` * wall/door/firewall edges the segment crosses and by any `blockedCells`
+294 -84
View File
@@ -27,7 +27,7 @@ import {
bentSightThroughGap, bentSightThroughGap,
neighbor, neighbor,
stepTarget, stepTarget,
opposite, opposite, pitRimExits,
} from "./board"; } from "./board";
import { import {
buildDeck, buildDeck,
@@ -80,6 +80,12 @@ export interface PlayerState {
} }
/** A duration spell in play. Expires at the START of the caster's turns. */ /** A duration spell in play. Expires at the START of the caster's turns. */
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
* the pushed one picks which way to go (rev 15). */
export interface PushPending {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
}
export interface SustainedEffect { export interface SustainedEffect {
id: string; id: string;
cardId: string; cardId: string;
@@ -212,6 +218,9 @@ export interface CastStack {
reflectedBase?: { damage: number; duration: number }; reflectedBase?: { damage: number; duration: number };
/** The wraith's touch also steals a random card if damage lands. */ /** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw"; creatureTouch?: "wraith" | "claw";
/** Where the spell left from when not the attacker's own square: an
* ALTER EGO's. Knockbacks and routs push away from here. */
origin?: Cell;
/** A spell freed from slime: counteractions cannot touch its caster. */ /** A spell freed from slime: counteractions cannot touch its caster. */
trapped?: boolean; trapped?: boolean;
/** STRENGTH's tear: "this would be an attack" the grab rides the stack /** STRENGTH's tear: "this would be an attack" the grab rides the stack
@@ -238,7 +247,7 @@ export interface CastParams {
} }
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 20; export const CURRENT_RULES_REV = 24;
/** Every rulings revision since the baseline, newest last the entries a /** Every rulings revision since the baseline, newest last the entries a
* game's deckRev freezes it before or after. Shown to players as the house * game's deckRev freezes it before or after. Shown to players as the house
@@ -263,6 +272,10 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 18, note: "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. Before, the half landed the instant the spell resolved." }, { rev: 18, note: "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. Before, the half landed the instant the spell resolved." },
{ rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." }, { rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." },
{ rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." }, { rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." },
{ rev: 21, note: "Two cards keep their whole promise. LIFESAVER's holder is not eliminated for losing both treasures. 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." },
{ rev: 22, note: "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. Older games crossed the edge only at the lettered openings." },
{ rev: 23, note: "POWER DRAIN drains the number played: the caster gains it whether the blow is BLUNTed or ABSORBed (FAQ: the counter blunts the damage done, not the drain), and a wall drained for its points gives them up too. Older games gave the caster only what the opponent lost, and nothing from a wall." },
{ 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." },
]; ];
export interface GameConfig { export interface GameConfig {
@@ -294,11 +307,7 @@ export interface GameState {
/** A treasure was just grabbed and its owner holds WARD: /** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */ * the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while pushPending: PushPending | null;
* the pushed one picks which way to go (rev 15). */
pushPending: {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
} | null;
/** SLOW DEATH's bites hang while the victim weighs counteractions: /** SLOW DEATH's bites hang while the victim weighs counteractions:
* the draw's total is one blow ABSORB soaks up to three of it, * the draw's total is one blow ABSORB soaks up to three of it,
* BLUNT halves it rounding up (table ruling). */ * BLUNT halves it rounding up (table ruling). */
@@ -450,10 +459,24 @@ function dustAtEnd(state: GameState, from: Cell, to: Cell): boolean {
return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust"; return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust";
} }
/** A FORCE FIELD raised against `id` on the wizard standing at `cell`,
* if any (rev 21): they may not enter it, nor cast on or past it. */
function fieldAgainst(state: GameState, id: PlayerId, cell: Cell): boolean {
if ((state.config.deckRev ?? 1) < 21) return false;
const idx = state.players.findIndex((q) => q.id === id);
return state.players.some((h) => h.alive && h.id !== id && cellKey(h.position) === cellKey(cell) &&
sustainedOn(state, h.id, "force-field").some((f) => f.data.against === idx));
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
if (dustAtEnd(state, from, to)) return false; if (dustAtEnd(state, from, to)) return false;
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state); const blockers = losBlockers(state);
if (viewerId) {
for (const h of state.players) {
if (h.alive && h.id !== viewerId && fieldAgainst(state, viewerId, h.position)) blockers[cellKey(h.position)] = true;
}
}
if (sightBetween(board, from, to, blockers)) return true; if (sightBetween(board, from, to, blockers)) return true;
if (!stone) return false; if (!stone) return false;
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined; const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
@@ -590,6 +613,9 @@ function castSightEdge(
return false; return false;
} }
/** The attacks that burn: what the FAQ lets hurt a KILLER OOZE. */
const FIRE_ATTACKS = new Set(["fireball"]);
function inThornbush(state: GameState, p: PlayerState): boolean { function inThornbush(state: GameState, p: PlayerState): boolean {
return state.squareContents[cellKey(p.position)]?.kind === "thornbush"; return state.squareContents[cellKey(p.position)]?.kind === "thornbush";
} }
@@ -638,7 +664,10 @@ export type GameEvent =
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance } | { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance } | { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" } | { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" }
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean } | { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean;
/** The blow before any counter, and what each counter left of it, in
* the order they were weighed the receipt the table reads. */
incoming?: number; incomingDuration?: number; trail?: CounterStep[] }
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number; | { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] } soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] }
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" } | { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" }
@@ -766,6 +795,8 @@ export type GameEvent =
| { type: "slowDeathCountered"; player: PlayerId; cardId: string; remaining: number } | { type: "slowDeathCountered"; player: PlayerId; cardId: string; remaining: number }
| { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number } | { type: "safeDamaged"; attacker: PlayerId; cell: Cell; amount: number; total: number }
| { type: "safeSmashed"; attacker: PlayerId; cell: Cell } | { type: "safeSmashed"; attacker: PlayerId; cell: Cell }
| { type: "squareContentDamaged"; attacker: PlayerId; cell: Cell; kind: "thornbush" | "rosebush" | "ooze"; amount: number; total: number; needed: number }
| { type: "squareContentDestroyed"; attacker: PlayerId; cell: Cell; kind: "thornbush" | "rosebush" | "ooze" }
| { type: "trapSprung"; player: PlayerId; cardId?: string } | { type: "trapSprung"; player: PlayerId; cardId?: string }
| { type: "died"; player: PlayerId; killedBy: PlayerId | null } | { type: "died"; player: PlayerId; killedBy: PlayerId | null }
| { type: "handTaken"; from: PlayerId; to: PlayerId; count: number } | { type: "handTaken"; from: PlayerId; to: PlayerId; count: number }
@@ -816,6 +847,7 @@ export type Command =
| { type: "moveCreature"; creatureId: string; direction: Side } | { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureWarpStep"; creatureId: string } | { type: "creatureWarpStep"; creatureId: string }
| { type: "creatureAttack"; creatureId: string; targetId: string } | { type: "creatureAttack"; creatureId: string; targetId: string }
| { type: "creatureAttackWall"; creatureId: string; cell: Cell; side: Side }
| { | {
type: "cast"; type: "cast";
instanceId: string; instanceId: string;
@@ -836,6 +868,10 @@ export type Command =
powerAttackPoints?: number; powerAttackPoints?: number;
target?: CastTarget; target?: CastTarget;
params?: CastParams; params?: CastParams;
/** ALTER EGO: the double's creature id, when the spell leaves from
* its square "it may use any of the spells in your hand, and you
* need not be in its L.O.S. to do so." */
via?: string;
} }
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell } | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell }
| { type: "cancelAmbush"; ambushId: string } | { type: "cancelAmbush"; ambushId: string }
@@ -903,6 +939,8 @@ interface Magnitude {
} }
interface ResolutionContext { interface ResolutionContext {
/** The square the blow came from: the attacker's, or their double's. */
origin: Cell;
state: GameState; state: GameState;
events: GameEvent[]; events: GameEvent[];
attacker: PlayerState; attacker: PlayerState;
@@ -915,6 +953,17 @@ interface ResolutionContext {
stack: CastStack; stack: CastStack;
} }
/** One counter's mark on the blow: the damage, the half sent back, and
* the duration left after it was weighed (unchanged when nullified). */
export interface CounterStep {
cardId: string;
player: PlayerId;
nullified: boolean;
damage: number;
reflected: number;
duration: number;
}
interface DamagePipeline { interface DamagePipeline {
damage: number; damage: number;
duration: number; duration: number;
@@ -1069,7 +1118,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => { onResolved: (ctx) => {
const knock = ctx.stack.params?.knockback ?? 0; const knock = ctx.stack.params?.knockback ?? 0;
if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return; if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return;
knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock); knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock, ctx.origin);
}, },
}, },
"sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 }, "sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 },
@@ -1078,12 +1127,21 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
requiresLos: true, requiresLos: true,
baseDamage: (n) => n ?? 1, baseDamage: (n) => n ?? 1,
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.damageDealt <= 0 || !ctx.attacker.alive) return; if (!ctx.attacker.alive) return;
ctx.attacker.life += ctx.damageDealt; // Rev 23: the drain is the number played, counters or no — "he is
// blunting damage done, not acting upon the attack spell itself"
// (FAQ). A returned drain carries its settled amount. Older games
// drained only what the opponent lost.
const modern = (ctx.state.config.deckRev ?? 1) >= 23 && !ctx.stack.reflectedBase;
const gain = modern
? (ctx.fullyStopped || ctx.reversed ? 0 : (ctx.stack.numberValue ?? 1) * ctx.stack.amplifyFactor)
: ctx.damageDealt;
if (gain <= 0) return;
ctx.attacker.life += gain;
ctx.events.push({ ctx.events.push({
type: "lifeGained", type: "lifeGained",
player: ctx.attacker.id, player: ctx.attacker.id,
amount: ctx.damageDealt, amount: gain,
source: "power drain", source: "power drain",
lifeAfter: ctx.attacker.life, lifeAfter: ctx.attacker.life,
}); });
@@ -1114,7 +1172,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
const n = ctx.stack.numberValue ?? 1; const n = ctx.stack.numberValue ?? 1;
goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n, ctx.origin);
ctx.defender.lostTurns++; ctx.defender.lostTurns++;
ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 }); ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 });
}, },
@@ -1415,7 +1473,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const view = boardView(state); const view = boardView(state);
if (!view.cells[cellKey(to)]) return "destination is off the board"; if (!view.cells[cellKey(to)]) return "destination is off the board";
if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone"; if (state.squareContents[cellKey(to)]?.kind === "stone") return "that square is solid stone";
if (wallIgnoringDistance(view, caster.position, to) > 4) { if (wallIgnoringDistance(view, caster.position, to, (state.config.deckRev ?? 1) >= 22) > 4) {
return "teleport reaches at most four spaces"; return "teleport reaches at most four spaces";
} }
// A teleport is a willing move: FEAR's bubble refuses it. // A teleport is a willing move: FEAR's bubble refuses it.
@@ -2377,11 +2435,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((state.config.deckRev ?? 1) < 10 || !target) return null; if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state); const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed; const legs = state.turn.movementAllowance - state.turn.movementUsed;
// Rev 16: the charge is measured as far as the legs reach. Before it, const d = walkingDistance(state, caster.position, target.position, chargeReach(state, legs));
// the search stopped at six steps and called anything past that
// unreachable — a goat with eight legs refused a seven-step ram.
const reach = (state.config.deckRev ?? 1) >= 16 ? legs : 6;
const d = walkingDistance(state, caster.position, target.position, reach);
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn"; if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null; return null;
}, },
@@ -2389,8 +2443,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) { if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed; const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6; const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, chargeReach(ctx.state, legs));
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach);
if (d === 0) return; if (d === 0) return;
if (d > legs) { if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say): // The target slipped beyond the charge (a counter-teleport, say):
@@ -2879,10 +2932,8 @@ function waveForce(range: number, dist: number): number {
/** One line of a wave's sweep: the squares it reaches, nearest first, with /** One line of a wave's sweep: the squares it reaches, nearest first, with
* the force left at each. The victims are named before any is pushed * the force left at each. The victims are named before any is pushed
* (rev 20). Before it the sweep, walking the way it pushed, could find a * (rev 20); older games sweep square by square, so a wizard shoved along
* victim it had just shoved and shove them again with the force left: a * the sweep's own line is caught and shoved again. */
* wizard washed one square into a wall was crushed twice. Older games
* replay that sweep. */
function sweepWave( function sweepWave(
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string, state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
): void { ): void {
@@ -3389,6 +3440,31 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
return { ok: true, state, events }; return { ok: true, state, events };
} }
/** "This rock-hard beast can punch a player (or a wall, etc.)": the troll's
* fist on a wall line beside it, a D4 of damage toward the wall's fall. */
function doCreatureAttackWall(prev: GameState, creatureId: string, cell: Cell, side: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.round === 1) return err("no combat during the first round of turns");
const state = clone(prev);
const active = activePlayer(state);
const creature = creatureById(state, creatureId);
if (!creature) return err("no such creature");
if (creature.controllerId !== active.id) return err("that creature does not obey you");
if (creature.kind !== "troll") return err("only the troll punches walls");
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 (!touchesEdge(creature.position, cell, side)) return err("the troll must stand beside the wall");
const events: GameEvent[] = [];
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
creature.attackUsed = true;
events.push({ type: "creatureAttacked", creatureId: creature.id, kind: creature.kind, target: "wall", dieRoll: roll });
const problem = damageWall(state, events, active, cell, side, roll, "troll");
if (problem) return err(problem);
return { ok: true, state, events };
}
/** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */ /** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */
function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void { function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void {
const view = boardView(state); const view = boardView(state);
@@ -3700,9 +3776,16 @@ function attachSustained(
}); });
} }
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */ /** How far a BUTT-HEAD charge is measured: as far as the goat's legs
/** Steps along real corridors from one cell to another, searched out to * reach (rev 16); older games search six steps and call the rest
* `limit` steps; beyond that the answer is Infinity. */ * unreachable. */
function chargeReach(state: GameState, legs: number): number {
return (state.config.deckRev ?? 1) >= 16 ? legs : 6;
}
/** Steps along real corridors from one cell to another (MENTAL FORCE's
* three moved spaces, a goat's charge), searched out to `limit` steps;
* beyond that the answer is Infinity. */
export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number { export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number {
if (cellKey(from) === cellKey(to)) return 0; if (cellKey(from) === cellKey(to)) return 0;
const view = boardView(state); const view = boardView(state);
@@ -3725,8 +3808,25 @@ export function walkingDistance(state: GameState, from: Cell, to: Cell, limit =
return seen.get(cellKey(to)) ?? Infinity; return seen.get(cellKey(to)) ?? Infinity;
} }
/** BFS steps between cells ignoring walls (teleport distance). */ /** Where a line leaving the maze at `cur` through `side` comes back in:
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number { * the first square of the maze scanning from the opposite edge along the
* same row or column the maze's edges as one continuous field. */
export function edgeReentry(board: AssembledBoard, cur: Cell, side: Side): Cell | null {
const cells = Object.keys(board.cells).map((k) => k.split(",").map(Number) as [number, number]);
const line = side === "N" || side === "S"
? cells.filter(([x]) => x === cur.x).map(([, y]) => y)
: cells.filter(([, y]) => y === cur.y).map(([x]) => x);
if (line.length === 0) return null;
const far = side === "N" || side === "W" ? Math.max(...line) : Math.min(...line);
const cell = side === "N" || side === "S" ? { x: cur.x, y: far } : { x: far, y: cur.y };
return cellKey(cell) === cellKey(cur) ? null : cell;
}
/** BFS steps between cells ignoring walls (teleport distance). With
* `wrapEdges` (rev 22) the outer edge is no more to a teleporter than any
* wall: a line leaving the maze re-enters at the opposite edge, one space
* on. Without it, only the lettered openings carry a teleporter across. */
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell, wrapEdges = false): number {
if (cellKey(from) === cellKey(to)) return 0; if (cellKey(from) === cellKey(to)) return 0;
const seen = new Map<string, number>([[cellKey(from), 0]]); const seen = new Map<string, number>([[cellKey(from), 0]]);
const queue: Cell[] = [from]; const queue: Cell[] = [from];
@@ -3742,8 +3842,12 @@ export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell
const w = board.warps.find( const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side, (w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
); );
if (!w) continue; if (w) n = w.to.cell;
n = w.to.cell; else if (wrapEdges) {
const back = edgeReentry(board, cur, side);
if (!back) continue;
n = back;
} else continue;
} }
if (seen.has(cellKey(n))) continue; if (seen.has(cellKey(n))) continue;
seen.set(cellKey(n), d + 1); seen.set(cellKey(n), d + 1);
@@ -4064,6 +4168,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId); case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
case "creatureAttackWall": return doCreatureAttackWall(state, command.creatureId, command.cell, command.side);
case "cast": return doCast(state, command); case "cast": return doCast(state, command);
case "setAmbush": return doSetAmbush(state, command); case "setAmbush": return doSetAmbush(state, command);
case "cancelAmbush": return doCancelAmbush(state, command.ambushId); case "cancelAmbush": return doCancelAmbush(state, command.ambushId);
@@ -4230,6 +4335,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement." // move ... bumping into a wall counts as one space of movement."
// A resumed push carries the direction its pushee chose: no stagger.
if (isBlinded(state, p) && shove === undefined) { if (isBlinded(state, p) && shove === undefined) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!; direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
} }
@@ -4337,6 +4443,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// Square contents at the destination. // Square contents at the destination.
let content = state.squareContents[cellKey(p.position)]; let content = state.squareContents[cellKey(p.position)];
if (content?.kind === "stone") return err("that square is solid stone"); if (content?.kind === "stone") return err("that square is solid stone");
if (fieldAgainst(state, p.id, p.position)) return err("a force field bars the way");
// BIG MAN: nobody enters his square — except a SHRUNK wizard, small // BIG MAN: nobody enters his square — except a SHRUNK wizard, small
// enough to slip between the giant's boots (table ruling). // enough to slip between the giant's boots (table ruling).
if (sustainedOn(state, p.id, "shrink").length === 0) { if (sustainedOn(state, p.id, "shrink").length === 0) {
@@ -4372,10 +4479,9 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
// the other player gets to decide which direction to go." The // the other player gets to decide which direction to go." The
// pushed one leaves by any open side but the way the giant came; // pushed one leaves by any open side but the way the giant came;
// one open side is taken, several are theirs to choose — the stride // one open side is taken, several are theirs to choose — the stride
// hangs until they do. Older games shoved straight ahead only. // hangs until they do. Older games (deckRev < 15) shove straight
// A game dealt before rev 15 shoves straight ahead when it can, as it // ahead when that way is open; rounding a corner is allowed in every
// always did; the corner and the fork were refusals before, so every // game.
// game may round a corner now.
const fork = (state.config.deckRev ?? 1) >= 15; const fork = (state.config.deckRev ?? 1) >= 15;
let landing: Cell | null = null; let landing: Cell | null = null;
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null); const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
@@ -4436,19 +4542,12 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
let landingWarp = false; let landingWarp = false;
if (ledge) { if (ledge) {
const pitCell = p.position; const pitCell = p.position;
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => { const { floor, mouths } = pitRimExits(view, pitCell, direction,
const t = stepTarget(view, pitCell, d); (c) => state.squareContents[cellKey(c)]?.kind === "stone");
if (t.kind === "blocked" || state.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t;
};
const ways = SIDES.filter((d) => d !== opposite(direction));
const floor = ways.filter((d) => footing(d)?.kind === "step");
const mouths = ways.filter((d) => footing(d)?.kind === "warp");
// A warp mouth on the rim is a way off it like any square, and may // A warp mouth on the rim is a way off it like any square, and may
// always be named. Only what a bare step does is a matter of // always be named. Only what a bare step does is a matter of
// revision: from rev 17 a square beside a mouth is a fork to name; // revision: from rev 17 a square beside a mouth is a fork to name;
// before it the step took the square, and takes it still, so the // before it a bare step takes the square.
// recorded games replay as they were played.
const exits = [...floor, ...mouths]; const exits = [...floor, ...mouths];
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor; const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) { if (exits.length === 0) {
@@ -4464,7 +4563,7 @@ function doMove(prev: GameState, direction: Side, over = false, exit?: Side, sho
p.position = from; 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 more than one way — click the square to land on: ${exits.join(" or ")}`);
} }
const foot = footing(landing)!; const foot = stepTarget(view, pitCell, landing) as { kind: "step" | "warp"; to: Cell };
landingCell = foot.to; landingCell = foot.to;
landingWarp = foot.kind === "warp"; landingWarp = foot.kind === "warp";
} }
@@ -5243,6 +5342,19 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId); const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId);
if (!inHand) return err("card not in hand"); if (!inHand) return err("card not in hand");
const def = cardDef(inHand.cardId); const def = cardDef(inHand.cardId);
// ALTER EGO: "It may use any of the spells in your hand, and you need
// not be in its L.O.S. to do so." The double's square is where the
// spell leaves from — sight, reach, and the blow's direction are its —
// while the hand, the attack, and any answer to it stay the caster's.
// Spells that move the caster's own body do not travel through it.
let origin: Cell = caster.position;
if (cmd.via) {
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`);
origin = ego.position;
}
const effect = CARD_EFFECTS[inHand.cardId]; const effect = CARD_EFFECTS[inHand.cardId];
if (!effect) return err(`${def.name} is not implemented yet`); if (!effect) return err(`${def.name} is not implemented yet`);
if (effect.kind === "counter") { if (effect.kind === "counter") {
@@ -5328,7 +5440,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [{ const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null, from: origin, target: null, targetCell: null,
}]; }];
attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration); attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration);
return { ok: true, state, events }; return { ok: true, state, events };
@@ -5342,12 +5454,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "creature") { if (cmd.target?.kind === "creature") {
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return err("no such creature"); if (!creature) return err("no such creature");
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(creature.position) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
if (effect.requiresLos && !(mods.aroundCorner if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, creature.position) ? bentLos(state, caster, origin, creature.position)
: casterLos(state, caster, caster.position, creature.position))) { : casterLos(state, caster, origin, creature.position))) {
return err("no line of sight to the creature"); return err("no line of sight to the creature");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
@@ -5362,7 +5474,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [...wandEvents, { const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: creature.position, from: origin, target: null, targetCell: creature.position,
}]; }];
// "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP). // "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP).
if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") { if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") {
@@ -5383,7 +5495,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "cell" && if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") { state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") {
const cell = cmd.target.cell; const cell = cmd.target.cell;
if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(cell) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
// castSight carries the whole sight law: straight lines, the // castSight carries the whole sight law: straight lines, the
@@ -5405,7 +5517,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [...wandEvents, { const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: { ...cell }, from: origin, target: null, targetCell: { ...cell },
}]; }];
const box = state.squareContents[cellKey(cell)]!; const box = state.squareContents[cellKey(cell)]!;
box.damage += dmg; box.damage += dmg;
@@ -5417,6 +5529,53 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
} }
return { ok: true, state, events }; return { ok: true, state, events };
} }
// A THORNBUSH or ROSEBUSH: "Five points of damage will destroy it." A
// KILLER OOZE: "Only fire ... will hurt the ooze" (FAQ), and five points
// burn it away. Damage accumulates across attackers and turns. A
// same-square attack (WIZARDBLADE) is swung from a square beside it:
// "If target fills an entire square, you must be in an adjacent square."
if (cmd.target?.kind === "cell") {
const cell = cmd.target.cell;
const content = state.squareContents[cellKey(cell)];
if (content && (content.kind === "thornbush" || content.kind === "rosebush" || content.kind === "ooze")) {
const what = content.kind === "ooze" ? "ooze" : content.kind;
if (effect.sameSquare) {
const dx = cell.x - origin.x, dy = cell.y - origin.y;
const side: Side | null = dx === 1 && dy === 0 ? "E" : dx === -1 && dy === 0 ? "W" : dy === 1 && dx === 0 ? "S" : dy === -1 && dx === 0 ? "N" : null;
if (!side || boardView(state).edges[edgeKey(origin, side)] === "wall") {
return err(`you must stand beside the ${what} to use that`);
}
} else if (effect.requiresLos && !castSight(state, caster, cmd, cell)) {
return err(`no line of sight to the ${what}`);
}
if (content.kind === "ooze" && !FIRE_ATTACKS.has(inHand.cardId)) {
return err("only fire hurts the ooze");
}
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
if (dmg <= 0) return err(`that spell would not singe the ${what}`);
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: origin, target: null, targetCell: { ...cell },
}];
content.damage += dmg;
events.push({ type: "squareContentDamaged", attacker: caster.id, cell: { ...cell }, kind: content.kind, amount: dmg, total: content.damage, needed: 5 });
if (content.damage >= 5) {
delete state.squareContents[cellKey(cell)];
events.push({ type: "squareContentDestroyed", attacker: caster.id, cell: { ...cell }, kind: content.kind });
}
return { ok: true, state, events };
}
}
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and // FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT // affect anyone in the slime or entering it later on." A 5-point WATERBOLT
// washes the slime away instead. // washes the slime away instead.
@@ -5424,8 +5583,8 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") { state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell; const cell = cmd.target.cell;
if (effect.requiresLos && !(mods.aroundCorner if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, cell) ? bentLos(state, caster, origin, cell)
: casterLos(state, caster, caster.position, cell))) { : casterLos(state, caster, origin, cell))) {
return err("no line of sight to the slime"); return err("no line of sight to the slime");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
@@ -5469,7 +5628,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (current !== "wall" && current !== "door") { if (current !== "wall" && current !== "door") {
return err("there is no wall or door on that edge to attack"); return err("there is no wall or door on that edge to attack");
} }
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) { if (effect.sameSquare && !touchesEdge(origin, cell, side)) {
return err("you must stand beside the wall"); return err("you must stand beside the wall");
} }
if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) { if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) {
@@ -5495,10 +5654,15 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events2: GameEvent[] = [...wandEvents, { const events2: GameEvent[] = [...wandEvents, {
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: cell, from: origin, target: null, targetCell: cell,
}]; }];
const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId); const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId);
if (problem) return err(problem); if (problem) return err(problem);
// Rev 23: a wall drained for its points gives them up like anyone.
if (inHand.cardId === "power-drain" && (state.config.deckRev ?? 1) >= 23 && caster.alive) {
caster.life += dmg;
events2.push({ type: "lifeGained", player: caster.id, amount: dmg, source: "power drain", lifeAfter: caster.life });
}
// Thrown weapons clatter to the floor at the foot of the wall. // Thrown weapons clatter to the floor at the foot of the wall.
if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") { if (inHand.cardId === "dagger" || inHand.cardId === "large-rock") {
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId); const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
@@ -5524,7 +5688,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const events: GameEvent[] = [{ const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null, from: origin, target: null, targetCell: null,
}]; }];
const queue = turnOrderFrom(state, caster.id).filter((id) => const queue = turnOrderFrom(state, caster.id).filter((id) =>
id !== caster.id && state.players.find((p) => p.id === id)!.alive); id !== caster.id && state.players.find((p) => p.id === id)!.alive);
@@ -5536,16 +5700,17 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return err("no such living player"); if (!target || !target.alive) return err("no such living player");
if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(target.position) !== cellKey(origin)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
const statusBlock = attackBlockedByStatus(state, caster, target); const statusBlock = attackBlockedByStatus(state, caster, target);
if (statusBlock) return err(statusBlock); if (statusBlock) return err(statusBlock);
if (fieldAgainst(state, caster.id, target.position)) return err("their force field turns your spell aside");
const preEvents: GameEvent[] = []; const preEvents: GameEvent[] = [];
if (effect.requiresLos) { if (effect.requiresLos) {
const sighted = mods.aroundCorner const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position) ? bentLos(state, caster, origin, target.position)
: casterLos(state, caster, caster.position, target.position); : casterLos(state, caster, origin, target.position);
if (!sighted) return err("no line of sight to the target"); if (!sighted) return err("no line of sight to the target");
} }
// Attacking someone breaks any BUDDY pact you swore to them. // Attacking someone breaks any BUDDY pact you swore to them.
@@ -5579,9 +5744,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
// go intended distance" — if the die disagrees with the true direction, // go intended distance" — if the die disagrees with the true direction,
// the spell hits whoever lies that way, or dissipates. // the spell hits whoever lies that way, or dissipates.
if (isBlinded(state, caster) && if (isBlinded(state, caster) &&
cellKey(target.position) !== cellKey(caster.position)) { cellKey(target.position) !== cellKey(origin)) {
const dx = target.position.x - caster.position.x; const dx = target.position.x - origin.x;
const dy = target.position.y - caster.position.y; const dy = target.position.y - origin.y;
const intended: Side = const intended: Side =
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N"; Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
const [roll, rngNext] = rollDie(state.rng); const [roll, rngNext] = rollDie(state.rng);
@@ -5590,11 +5755,11 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (rolled !== intended) { if (rolled !== intended) {
const along = state.players.find((p) => { const along = state.players.find((p) => {
if (!p.alive || p.id === caster.id) return false; if (!p.alive || p.id === caster.id) return false;
const px = p.position.x - caster.position.x; const px = p.position.x - origin.x;
const py = p.position.y - caster.position.y; const py = p.position.y - origin.y;
const dirOf: Side | null = const dirOf: Side | null =
Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null; Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null;
return dirOf === rolled && casterLos(state, caster, caster.position, p.position); return dirOf === rolled && casterLos(state, caster, origin, p.position);
}); });
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
state.turn.attackUsed = true; state.turn.attackUsed = true;
@@ -5616,11 +5781,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
kind: effect.physical ? "physical" : "spell", kind: effect.physical ? "physical" : "spell",
counters: [], counters: [],
waitingOn: along.id, waitingOn: along.id,
...(cmd.via ? { origin } : {}),
}; };
missEvents.push({ missEvents.push({
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: along.id, targetCell: along.position, from: origin, target: along.id, targetCell: along.position,
}); });
return { ok: true, state, events: missEvents }; return { ok: true, state, events: missEvents };
} }
@@ -5642,6 +5808,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
counters: [], counters: [],
waitingOn: target.id, waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}), ...(mods.aroundCorner ? { bentCorner: true as const } : {}),
...(cmd.via ? { origin } : {}),
}; };
state.lastSpellUsed[caster.id] = inHand.cardId; state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents]; const events: GameEvent[] = [...wandEvents, ...preEvents];
@@ -5654,7 +5821,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
numberCards: mods.numbers, numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue, numberValue: mods.magnitude.numberValue,
amplifies: mods.amplifies.length, amplifies: mods.amplifies.length,
from: caster.position, from: origin,
target: target.id, target: target.id,
targetCell: target.position, targetCell: target.position,
}); });
@@ -5674,7 +5841,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
cardId: inHand.cardId, cardId: inHand.cardId,
numberCards: mods.numbers, numberCards: mods.numbers,
numberValue: mods.magnitude.numberValue, numberValue: mods.magnitude.numberValue,
from: caster.position, from: origin,
target: cmd.target?.kind === "player" ? cmd.target.playerId : null, target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null, targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
}); });
@@ -5934,7 +6101,7 @@ function doCounteract(
const view = boardView(state); const view = boardView(state);
if (!view.cells[cellKey(to)]) return err("destination is off the board"); if (!view.cells[cellKey(to)]) return err("destination is off the board");
if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone"); if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone");
if (wallIgnoringDistance(view, player.position, to) > 4) { if (wallIgnoringDistance(view, player.position, to, (state.config.deckRev ?? 1) >= 22) > 4) {
return err("teleport reaches at most four spaces"); return err("teleport reaches at most four spaces");
} }
takeFromHand(player, instanceId); takeFromHand(player, instanceId);
@@ -6029,12 +6196,17 @@ function doCounteract(
// any, will be swapped" — the reflector's choice rides the counter. // any, will be swapped" — the reflector's choice rides the counter.
...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}), ...(card.cardId === "full-reflection" && params?.cardId ? { cardId: params.cardId } : {}),
}); });
// Rev 21: FORCE FIELD stands after it stops the spell — "preventing an
// opponent from entering the space you occupy or casting spells on or
// past you. Lasts only until the end of the opponent's turn." The
// field faces every side, not one: this table's simplification.
const events: GameEvent[] = [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }];
if (card.cardId === "force-field" && (state.config.deckRev ?? 1) >= 21) {
const facing = state.players.findIndex((q) => q.id === stack.attackerId);
attachSustained(state, events, "force-field", playerId, playerId, PERMANENT_TURNS, { against: facing });
}
stack.waitingOn = stack.attackerId; stack.waitingOn = stack.attackerId;
return { return { ok: true, state, events };
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
};
} }
if (playerId === stack.attackerId) { if (playerId === stack.attackerId) {
@@ -6211,6 +6383,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
// Whether this attack even tries to wound: utility attacks (DROP OBJECT, // Whether this attack even tries to wound: utility attacks (DROP OBJECT,
// TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped. // TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped.
const dealsDamage = base > 0; const dealsDamage = base > 0;
const trail: CounterStep[] = [];
const receipt = { incoming: base, incomingDuration: baseDuration, trail };
const pipe: DamagePipeline = { const pipe: DamagePipeline = {
damage: base, damage: base,
duration: baseDuration, duration: baseDuration,
@@ -6229,18 +6403,23 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
} }
} }
for (const counter of stack.counters) { for (const counter of stack.counters) {
trail.push({ cardId: counter.card.cardId, player: counter.player, nullified: counter.nullified, damage: pipe.damage, reflected: pipe.reflectedDamage, duration: pipe.duration });
const mark = trail[trail.length - 1]!;
const weigh = () => { mark.damage = pipe.damage; mark.reflected = pipe.reflectedDamage; mark.duration = pipe.duration; };
if (counter.nullified) continue; if (counter.nullified) continue;
if (isNumberCard(counter.card.cardId)) { if (isNumberCard(counter.card.cardId)) {
// SHIELDSTONE number counter: reduce point AND duration effects. // SHIELDSTONE number counter: reduce point AND duration effects.
const v = numberValue(counter.card.cardId); const v = numberValue(counter.card.cardId);
pipe.damage = Math.max(0, pipe.damage - v); pipe.damage = Math.max(0, pipe.damage - v);
pipe.duration = Math.max(0, pipe.duration - v); pipe.duration = Math.max(0, pipe.duration - v);
weigh();
continue; continue;
} }
if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") { if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
// Fire meets water, whichever was thrown first: entirely stopped. // Fire meets water, whichever was thrown first: entirely stopped.
pipe.damage = 0; pipe.damage = 0;
pipe.fullyStopped = true; pipe.fullyStopped = true;
weigh();
continue; continue;
} }
if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") { if (counter.card.cardId === "invisible" || counter.card.cardId === "empathy") {
@@ -6250,6 +6429,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
pipe.damage = 0; pipe.damage = 0;
pipe.duration = 0; pipe.duration = 0;
pipe.fullyStopped = true; pipe.fullyStopped = true;
weigh();
continue; continue;
} }
if (stack.creatureId && if (stack.creatureId &&
@@ -6262,10 +6442,12 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
} else { } else {
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage) pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
} }
weigh();
continue; continue;
} }
const ce = CARD_EFFECTS[counter.card.cardId]; const ce = CARD_EFFECTS[counter.card.cardId];
if (ce && ce.kind === "counter") ce.apply(pipe); if (ce && ce.kind === "counter") ce.apply(pipe);
weigh();
} }
// A surviving teleport counter whisks the defender away before anything lands. // A surviving teleport counter whisks the defender away before anything lands.
@@ -6291,12 +6473,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
effect.onResolved({ effect.onResolved({
state, events, state, events,
attacker: defender, defender: attacker, attacker: defender, defender: attacker,
origin: defender.position,
damageDealt: 0, fullyStopped: false, reversed: false, duration: 0, damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
stack: { ...stack, params: { ...stack.params, cardId: chosen } }, stack: { ...stack, params: { ...stack.params, cardId: chosen } },
}); });
} }
events.push({ events.push({
type: "attackResolved", type: "attackResolved", ...receipt,
attacker: attacker.id, attacker: attacker.id,
defender: defender.id, defender: defender.id,
attackCardId: attackId, attackCardId: attackId,
@@ -6328,7 +6511,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
reflectedBase: { damage: pipe.damage, duration: pipe.duration }, reflectedBase: { damage: pipe.damage, duration: pipe.duration },
}; };
events.push({ events.push({
type: "attackResolved", type: "attackResolved", ...receipt,
attacker: attacker.id, attacker: attacker.id,
defender: defender.id, defender: defender.id,
attackCardId: attackId, attackCardId: attackId,
@@ -6418,7 +6601,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
} }
events.push({ events.push({
type: "attackResolved", type: "attackResolved", ...receipt,
attacker: attacker.id, attacker: attacker.id,
defender: defender.id, defender: defender.id,
attackCardId: attackId, attackCardId: attackId,
@@ -6428,12 +6611,30 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
redirected: pipe.redirected, redirected: pipe.redirected,
}); });
// Rev 24: "If this spell is REFLECTed, both players take 1/2 point of
// damage per space moved" (WALKING DEAD); "In case of REFLECTION, both
// players are affected" (IDIOT). A permanent curse has no duration for
// REFLECTION's split to halve, so the curse is laid on the caster as
// well, as if the defender had cast it back.
const curseShared = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 24 &&
!pipe.redirected && !pipe.fullyStopped && !stack.reflectedBase && !stack.trapped && attacker.alive &&
stack.counters.some((c) => !c.nullified && c.card.cardId === "reflection");
if (effect?.onResolved && curseShared) {
effect.onResolved({
state, events,
attacker: defender, defender: attacker,
origin: defender.position,
damageDealt: 0, fullyStopped: false, reversed: pipe.reversed, duration: pipe.duration,
stack,
});
}
if (effect?.onResolved && !pipe.redirected) { if (effect?.onResolved && !pipe.redirected) {
effect.onResolved({ effect.onResolved({
state, state,
events, events,
attacker, attacker,
defender, defender,
origin: stack.origin ?? attacker.position,
damageDealt, damageDealt,
fullyStopped: pipe.fullyStopped, fullyStopped: pipe.fullyStopped,
reversed: pipe.reversed, reversed: pipe.reversed,
@@ -6472,11 +6673,12 @@ function goAwayRout(
attacker: PlayerState, attacker: PlayerState,
defender: PlayerState, defender: PlayerState,
squares: number, squares: number,
origin: Cell = attacker.position,
): void { ): void {
if (isLockedInPlace(state, defender.id)) return; if (isLockedInPlace(state, defender.id)) return;
const from = defender.position; const from = defender.position;
const dist = (c: Cell) => const dist = (c: Cell) =>
Math.abs(attacker.position.x - c.x) + Math.abs(attacker.position.y - c.y); Math.abs(origin.x - c.x) + Math.abs(origin.y - c.y);
let heading: Side | null = null; let heading: Side | null = null;
let moved = 0; let moved = 0;
for (let i = 0; i < squares; i++) { for (let i = 0; i < squares; i++) {
@@ -6527,9 +6729,10 @@ function knockBack(
attacker: PlayerState, attacker: PlayerState,
defender: PlayerState, defender: PlayerState,
squares: number, squares: number,
origin: Cell = attacker.position,
): void { ): void {
const dx = defender.position.x - attacker.position.x; const dx = defender.position.x - origin.x;
const dy = defender.position.y - attacker.position.y; const dy = defender.position.y - origin.y;
let dir: Side | null = null; let dir: Side | null = null;
if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W"; if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W";
else if (dy !== 0) dir = dy > 0 ? "S" : "N"; else if (dy !== 0) dir = dy > 0 ? "S" : "N";
@@ -6790,7 +6993,11 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null; const ownerPlayer = owner ? state.players.find((q) => q.id === owner) : null;
return owner !== null && owner !== p.id && ownerPlayer?.alive === true; return owner !== null && owner !== p.id && ownerPlayer?.alive === true;
}); });
if (lost) { // Rev 21: LIFESAVER — "immune to the effects of losing both of your
// treasures to other players' home bases." Older games eliminate its
// holder.
const saved = (state.config.deckRev ?? 1) >= 21 && sustainedOn(state, p.id, "lifesaver").length > 0;
if (lost && !saved) {
p.alive = false; p.alive = false;
p.finalHand = [...p.hand]; p.finalHand = [...p.hand];
state.discard.push(...p.hand.splice(0)); state.discard.push(...p.hand.splice(0));
@@ -7120,6 +7327,9 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
} }
state.openSafes = []; state.openSafes = [];
// A FORCE FIELD raised against this wizard lasts only until their turn ends.
const seat = state.players.indexOf(p);
state.sustained = state.sustained.filter((f) => !(f.cardId === "force-field" && f.data.against === seat));
events.push({ type: "turnEnded", player: p.id }); events.push({ type: "turnEnded", player: p.id });
if (p.extraTurns > 0) { if (p.extraTurns > 0) {
+122 -62
View File
@@ -2,13 +2,16 @@
// The server sends this after every state change; clients never see the // The server sends this after every state change; clients never see the
// deck order or other players' hands. // deck order or other players' hands.
import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; import { neighbor, SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, type CardInstance } from "./cards"; import { cardDef, type CardInstance } from "./cards";
import { import {
boardView, boardView,
edgeReentry,
LOS_BLOCKING_CONTENT, LOS_BLOCKING_CONTENT,
type AmbushState, type AmbushState,
type CastStack, type CastStack,
type GameEvent,
type PushPending,
type CreatureState, type CreatureState,
type GameState, type GameState,
type PlayerId, type PlayerId,
@@ -93,7 +96,7 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null; chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** A grab hangs while the treasure's owner decides their Ward. */ /** A grab hangs while the treasure's owner decides their Ward. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null; wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
pushPending: GameState["pushPending"]; pushPending: PushPending | null;
slowDeathPending: { playerId: PlayerId; points: number } | null; slowDeathPending: { playerId: PlayerId; points: number } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */ /** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[]; yourAmbushes: AmbushState[];
@@ -220,20 +223,23 @@ export function fearCells(view: GameView): Set<string> {
* reflects what this player knows (illusion walls they believe in block it). * reflects what this player knows (illusion walls they believe in block it).
* The basis for the client's "dim the ineligible squares" targeting aid. * The basis for the client's "dim the ineligible squares" targeting aid.
*/ */
export function sightedCellsFor(view: GameView): Set<string> { export function sightedCellsFor(view: GameView, from?: Cell): Set<string> {
const out = new Set<string>(); const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return out; if (!me) return out;
// Sight is taken from the viewer's square unless a cast leaves from
// elsewhere — an ALTER EGO's — in which case it is the double's.
const eye = from ?? me.position;
const { board, blockers } = sightBasis(view); const { board, blockers } = sightBasis(view);
// In a dust cloud a wizard sees only their own square (rev 19). // In a dust cloud a wizard sees only their own square (rev 19).
if (dustAtEnd(view, me.position, me.position, true)) { if (inDust(view, eye)) {
out.add(`${me.position.x},${me.position.y}`); out.add(`${eye.x},${eye.y}`);
return out; return out;
} }
for (const key of Object.keys(board.cells)) { for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
if (dustAtEnd(view, me.position, { x, y })) continue; if (dustAtEnd(view, eye, { x, y })) continue;
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); if (sightBetween(board, eye, { x, y }, blockers)) out.add(key);
} }
// VISIONSTONE lets its bearer see through exactly one wall or door — // VISIONSTONE lets its bearer see through exactly one wall or door —
// any one — so a square is also sighted if removing a single edge // any one — so a square is also sighted if removing a single edge
@@ -248,7 +254,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
const opened = { ...board, edges }; const opened = { ...board, edges };
for (let i = unseen.length - 1; i >= 0; i--) { for (let i = unseen.length - 1; i >= 0; i--) {
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number]; const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
if (sightBetween(opened, me.position, { x, y }, blockers)) { if (sightBetween(opened, eye, { x, y }, blockers)) {
out.add(unseen[i]!); out.add(unseen[i]!);
unseen.splice(i, 1); unseen.splice(i, 1);
} }
@@ -263,8 +269,8 @@ export function sightedCellsFor(view: GameView): Set<string> {
* plus every square visible from one of those — the caster looks to a middle * plus every square visible from one of those — the caster looks to a middle
* cell and the spell turns there, mirroring the engine's bentLos. * cell and the spell turns there, mirroring the engine's bentLos.
*/ */
export function bentSightedCellsFor(view: GameView): Set<string> { export function bentSightedCellsFor(view: GameView, from?: Cell): Set<string> {
const out = sightedCellsFor(view); const out = sightedCellsFor(view, from);
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return out; if (!me) return out;
const mids = [...out].map((k) => { const mids = [...out].map((k) => {
@@ -324,21 +330,21 @@ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Recor
* (believed illusion walls block; held doors admit). Null when no sight * (believed illusion walls block; held doors admit). Null when no sight
* exists — the renderer's material for drawing the line an attack traveled. * exists — the renderer's material for drawing the line an attack traveled.
*/ */
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null { export function traceSightFor(view: GameView, from: Cell, to: Cell, ignoreDust = false): SightTrace | null {
if (dustAtEnd(view, from, to)) return null; if (!ignoreDust && dustAtEnd(view, from, to)) return null;
const { board, blockers } = sightBasis(view); const { board, blockers } = sightBasis(view);
return traceSight(board, from, to, blockers); return traceSight(board, from, to, blockers);
} }
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells; /** Rev 19: DUST CLOUD blinds its occupant. Older games see through it. */
* a square sees itself still. `standing` asks only whether the viewer's function inDust(view: GameView, cell: Cell): boolean {
* own square is dust. */ return view.deckRev >= 19 && view.squareContents[`${cell.x},${cell.y}`]?.kind === "dust";
function dustAtEnd(view: GameView, from: Cell, to: Cell, standing = false): boolean { }
if (view.deckRev < 19) return false;
const dust = (c: Cell) => view.squareContents[`${c.x},${c.y}`]?.kind === "dust"; /** Dust at either end kills a sight line; a square sees itself still. */
if (standing) return dust(from); function dustAtEnd(view: GameView, from: Cell, to: Cell): boolean {
if (from.x === to.x && from.y === to.y) return false; if (from.x === to.x && from.y === to.y) return false;
return dust(from) || dust(to); return inDust(view, from) || inDust(view, to);
} }
/** /**
@@ -368,54 +374,96 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
* defender sharing a square, or no sight under this viewer's knowledge * defender sharing a square, or no sight under this viewer's knowledge
* (a believed illusion wall can honestly hide the line). * (a believed illusion wall can honestly hide the line).
*/ */
export function stackSightTrace( /** A sight line as the board draws it: the legs, the corner bend, and the
view: GameView, * one wall a VISIONSTONE dissolved. */
): { export interface SightLine {
from: Cell; from: Cell;
to: Cell; to: Cell;
trace: SightTrace; trace: SightTrace;
bend?: { mid: Cell; trace: SightTrace }; bend?: { mid: Cell; trace: SightTrace };
/** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */ /** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */
pierced?: string; pierced?: string;
} | null { }
/** The sight line from one square to another under the sight law the
* engine applies to a cast: a plain line (warps included), else the
* VISIONSTONE's look through one wall, else AROUND THE CORNER's two legs.
* `afterTheFact` traces a cast already resolved, whose own dust cloud
* would otherwise blind the line to it. */
export function sightTraceBetween(
view: GameView,
from: Cell,
to: Cell,
opts: { visionstone?: boolean; bentCorner?: boolean; afterTheFact?: boolean } = {},
): SightLine | null {
if (from.x === to.x && from.y === to.y) return null;
const trace = traceSightFor(view, from, to, opts.afterTheFact);
if (trace) return { from, to, trace };
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
// plain line exists, find the single edge whose removal opens one and draw
// the ray straight through it, marking the pierced wall for the table.
if (opts.visionstone) {
const { board, blockers } = sightBasis(view);
for (const [key, edge] of Object.entries(board.edges)) {
if (edge === "open") continue;
const edges = { ...board.edges };
delete edges[key];
const t = traceSight({ ...board, edges }, from, to, blockers);
if (t) return { from, to, trace: t, pierced: key };
}
}
// AROUND THE CORNER: no straight line exists — find a middle square both
// ends can see and draw the sight leg by leg, so the table can audit the
// needle instead of doubting it.
if (opts.bentCorner) {
for (const key of Object.keys(view.board.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if ((mid.x === from.x && mid.y === from.y) || (mid.x === to.x && mid.y === to.y)) continue;
const leg1 = traceSightFor(view, from, mid, opts.afterTheFact);
if (!leg1) continue;
const leg2 = traceSightFor(view, mid, to, opts.afterTheFact);
if (leg2) return { from, to, trace: leg1, bend: { mid, trace: leg2 } };
}
}
return null;
}
/** The sight line of the attack on the stack, for the board to draw. */
export function stackSightTrace(view: GameView): SightLine | null {
const stack = view.stack; const stack = view.stack;
if (!stack || stack.creatureId) return null; if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null; if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
const a = view.players.find((p) => p.id === stack.attackerId); const a = view.players.find((p) => p.id === stack.attackerId);
const d = view.players.find((p) => p.id === stack.defenderId); const d = view.players.find((p) => p.id === stack.defenderId);
if (!a || !d) return null; if (!a || !d) return null;
if (a.position.x === d.position.x && a.position.y === d.position.y) return null; return sightTraceBetween(view, a.position, d.position, {
const trace = traceSightFor(view, a.position, d.position); visionstone: a.displayed.some((c) => c.cardId === "visionstone"),
if (trace) return { from: a.position, to: d.position, trace }; bentCorner: stack.bentCorner === true,
// VISIONSTONE: the bearer sees through exactly one wall or door. When no });
// plain line exists, find the single edge whose removal opens one and draw }
// the ray straight through it, marking the pierced wall for the table.
if (a.displayed.some((c) => c.cardId === "visionstone")) { /** The sight line a resolved cast was accepted on — a creation, a curse,
const { board, blockers } = sightBasis(view); * anything aimed by line of sight that never waited on the stack — so the
for (const [key, edge] of Object.entries(board.edges)) { * table can see how the aim was legal. `events` is the cast's own batch,
if (edge === "open") continue; * which names an AROUND THE CORNER cast. */
const edges = { ...board.edges }; export function castSightTrace(
delete edges[key]; view: GameView,
const t = traceSight({ ...board, edges }, a.position, d.position, blockers); cast: Extract<GameEvent, { type: "spellCast" }>,
if (t) return { from: a.position, to: d.position, trace: t, pierced: key }; events: readonly GameEvent[],
} afterTheFact = false,
} ): SightLine | null {
// AROUND THE CORNER: no straight line exists — find a middle square both let def;
// ends can see and draw the sight leg by leg, so the table can audit the try { def = cardDef(cast.cardId); } catch { return null; }
// needle instead of doubting it. if (def.los !== true) return null;
if (stack.bentCorner) { const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null;
for (const key of Object.keys(view.board.cells)) { if (!to) return null;
const [mx, my] = key.split(",").map(Number) as [number, number]; const caster = view.players.find((p) => p.id === cast.caster);
const mid = { x: mx, y: my }; return sightTraceBetween(view, cast.from, to, {
if ((mid.x === a.position.x && mid.y === a.position.y) || visionstone: caster?.displayed.some((c) => c.cardId === "visionstone") ?? false,
(mid.x === d.position.x && mid.y === d.position.y)) continue; bentCorner: events.some((e) => e.type === "castAroundCorner" && e.caster === cast.caster),
const leg1 = traceSightFor(view, a.position, mid); afterTheFact,
if (!leg1) continue; });
const leg2 = traceSightFor(view, mid, d.position);
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
}
}
return null;
} }
const CREATION_CARD_IDS = new Set([ const CREATION_CARD_IDS = new Set([
@@ -431,12 +479,12 @@ const SUMMON_CARD_IDS = new Set([
* aid — mirroring the engine's own validation from the viewer's knowledge. * aid — mirroring the engine's own validation from the viewer's knowledge.
* Null = this card's eligibility is not modeled; light everything. * Null = this card's eligibility is not modeled; light everything.
*/ */
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null { export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false, from?: Cell): Set<string> | null {
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return null; if (!me) return null;
const cells = Object.keys(view.board.cells); const cells = Object.keys(view.board.cells);
const key = (x: number, y: number) => `${x},${y}`; const key = (x: number, y: number) => `${x},${y}`;
const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view); const sighted = bentCorner ? bentSightedCellsFor(view, from) : sightedCellsFor(view, from);
if (CREATION_CARD_IDS.has(cardId)) { if (CREATION_CARD_IDS.has(cardId)) {
// emptySquareTarget: on the board, unoccupied by content, home, wizard, // emptySquareTarget: on the board, unoccupied by content, home, wizard,
@@ -469,7 +517,9 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
if (cardId === "teleport") { if (cardId === "teleport") {
// Up to four spaces, walls and objects ignored; not into solid stone. // Up to four spaces, walls and objects ignored; not into solid stone.
// BFS over existing cells, matching the engine's wallIgnoringDistance. // BFS over existing cells, matching the engine's wallIgnoringDistance
// the maze wraps for teleporters as it does for walkers, a warp mouth
// being one step like any doorway.
const out = new Set<string>(); const out = new Set<string>();
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]); const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
const queue = [me.position]; const queue = [me.position];
@@ -477,10 +527,20 @@ export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = fa
const cur = queue.shift()!; const cur = queue.shift()!;
const d = dist.get(key(cur.x, cur.y))!; const d = dist.get(key(cur.x, cur.y))!;
if (d >= 4) continue; if (d >= 4) continue;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { for (const side of SIDES) {
const n = { x: cur.x + dx, y: cur.y + dy }; let n = neighbor(cur, side);
if (!view.board.cells[key(n.x, n.y)]) {
const w = view.board.warps.find((w) => w.from.cell.x === cur.x && w.from.cell.y === cur.y && w.from.side === side);
if (w) n = w.to.cell;
else if (view.deckRev >= 22) {
// Rev 22: the outer edge is no more than a wall to a teleporter.
const back = edgeReentry(view.board, cur, side);
if (!back) continue;
n = back;
} else continue;
}
const nk = key(n.x, n.y); const nk = key(n.x, n.y);
if (!view.board.cells[nk] || dist.has(nk)) continue; if (dist.has(nk)) continue;
dist.set(nk, d + 1); dist.set(nk, d + 1);
queue.push(n); queue.push(n);
if (view.squareContents[nk]?.kind !== "stone") out.add(nk); if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
+6 -6
View File
@@ -8,7 +8,7 @@ import {
} from "../src/game"; } from "../src/game";
import { cellKey, edgeKey } from "../src/board"; import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view"; import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton"; import { automatonCommand, automatonFallback, drawUnderSlowDeath, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers"; import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */ /** Whose input does the maze want right now? */
@@ -1129,14 +1129,14 @@ describe("the clockwork under a curse, and before a pit", () => {
const state = botsTurn(); const state = botsTurn();
const bot = state.players.find((p) => p.id === "bot")!; const bot = state.players.find((p) => p.id === "bot")!;
const plain = viewFor(state, "bot"); const plain = viewFor(state, "bot");
expect(drawUnderCurses(plain, 2)).toBe(2); expect(drawUnderSlowDeath(plain, 2)).toBe(2);
pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 }); pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
bot.life = 7; bot.life = 7;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2); expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(2);
bot.life = 5; bot.life = 5;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1); expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(1);
bot.life = 4; bot.life = 4;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0); expect(drawUnderSlowDeath(viewFor(state, "bot"), 2)).toBe(0);
}); });
it("raises no SHADOW while bleeding, nor with little blood to spare", () => { it("raises no SHADOW while bleeding, nor with little blood to spare", () => {
@@ -1152,7 +1152,7 @@ describe("the clockwork under a curse, and before a pit", () => {
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) { for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
const view = viewFor(state, "bot"); const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage"); const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage");
expect(JSON.stringify(cmd)).not.toContain("shadow#T"); expect(cmd.type === "cast" && cmd.instanceId === "shadow#T").toBe(false);
const r = applyCommand(state, "bot", cmd); const r = applyCommand(state, "bot", cmd);
if (!r.ok) break; if (!r.ok) break;
state = r.state; state = r.state;
+25
View File
@@ -1394,3 +1394,28 @@ describe("disease makes the caster the carrier (rev 7)", () => {
throw new Error("no adjacent step for the return walk"); throw new Error("no adjacent step for the return walk");
}); });
}); });
describe("a REFLECTION against a permanent curse afflicts both (rev 24)", () => {
const cursed = (s: GameState, id: string) => s.sustained.some((e) => e.cardId === "walking-dead" && e.targetId === id);
const play = (deckRev: number) => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wd = giveCard(state, attacker, "walking-dead");
state.players.find((p) => p.id === defender)!.hand[0] = { instanceId: "reflection#T", cardId: "reflection" };
state = must(state, attacker, { type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "reflection#T" });
state = drain(state);
return { state, attacker, defender };
};
it("lays WALKING DEAD on the caster too", () => {
const { state, attacker, defender } = play(24);
expect(cursed(state, defender)).toBe(true);
expect(cursed(state, attacker)).toBe(true);
});
it("older games left the caster untouched", () => {
const { state, attacker, defender } = play(23);
expect(cursed(state, defender)).toBe(true);
expect(cursed(state, attacker)).toBe(false);
});
});
+67 -6
View File
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame, gameLos,
} from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained, faceOff } from "./helpers";
/** Summon a creature next to its creator (round 2+, consumes the attack). */ /** Summon a creature next to its creator (round 2+, consumes the attack). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
@@ -395,7 +396,7 @@ describe("big man", () => {
/** From a giant's square: a step into a square whose exits (all sides but /** From a giant's square: a step into a square whose exits (all sides but
* the way back) number exactly `want` — a corner (1, not straight) or a * the way back) number exactly `want` — a corner (1, not straight) or a
* fork (2+). Returns the giant's start, the pushee's square, and the exits. */ * fork (2+). Returns the giant's start, the pushee's square, and the exits. */
function pushSite(state: GameState, want: "corner" | "fork") { function pushSite(state: GameState, want: "corner" | "fork" | "straight-fork") {
const view = boardView(state); const view = boardView(state);
for (const key of Object.keys(view.cells)) { for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
@@ -406,6 +407,7 @@ describe("big man", () => {
const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step"); const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step");
if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits }; if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits }; if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "straight-fork" && exits.length >= 2 && exits.includes(side)) return { at: { x, y }, side, mid: t1.to, exits };
} }
} }
throw new Error(`no ${want} on this board`); throw new Error(`no ${want} on this board`);
@@ -452,15 +454,15 @@ describe("big man", () => {
expect(state.turn.movementUsed).toBe(used + 1); expect(state.turn.movementUsed).toBe(used + 1);
}); });
it("an older game shoves straight ahead when it can, and now rounds a corner when it cannot", () => { it("a pre-rev-15 game shoves straight ahead when it can and rounds a corner when it cannot", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
state = toRound2(state); state = toRound2(state);
const giant = activePlayer(state); const giant = activePlayer(state);
state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} }); state.sustained.push({ id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id, remainingTurns: 9, data: {} });
const victim = state.players.find((p) => p.id !== giant.id)!; const victim = state.players.find((p) => p.id !== giant.id)!;
// A fork where straight ahead is open: no window, straight it is. // A fork where straight ahead is open: no window, straight it is.
const fork = pushSite(state, "fork"); const fork = pushSite(state, "straight-fork");
if (fork.exits.includes(fork.side)) { {
giant.position = { ...fork.at }; giant.position = { ...fork.at };
victim.position = { ...fork.mid }; victim.position = { ...fork.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side }); const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
@@ -961,3 +963,62 @@ describe("the imp's fire is a spell (rev 8)", () => {
expect(after.alive).toBe(true); expect(after.alive).toBe(true);
}); });
}); });
describe("the alter ego casts from its own square", () => {
it("a fireball leaves from the double, by the double's sight, and answers to the caster", () => {
let { state } = newGame(42);
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
const ego = giveCard(state, attacker, "alter-ego");
state = must(state, attacker, { type: "cast", instanceId: ego.instanceId });
const double = state.creatures.find((c) => c.kind === "alter-ego" && c.controllerId === attacker)!;
expect(cellKey(double.position)).toBe(cellKey(a.position));
// The caster steps out of the defender's sight; the double keeps it.
const view = boardView(state);
const hidden = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
.find((c) => !gameLos(state, c, d.position) && !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
expect(hidden).toBeDefined();
const me = state.players.find((p) => p.id === attacker)!;
me.position = { ...hidden! };
const fb = giveCard(state, attacker, "fireball");
const blind = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
expect(blind.ok).toBe(false);
const wrong = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: "no-such-double" });
expect(wrong.ok).toBe(false);
const viaEgo = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: double.id });
expect(viaEgo.ok).toBe(true);
if (!viaEgo.ok) return;
expect(viaEgo.state.stack?.attackerId).toBe(attacker);
expect(cellKey(viaEgo.state.stack!.origin!)).toBe(cellKey(double.position));
const cast = viaEgo.events.find((e) => e.type === "spellCast");
expect(cast && cast.type === "spellCast" ? cellKey(cast.from) : null).toBe(cellKey(double.position));
const done = must(viaEgo.state, defender, { type: "pass" });
expect(done.players.find((p) => p.id === defender)!.life).toBe(10);
});
});
describe("the troll's fist on a wall", () => {
it("punches a wall line beside it for a D4, once a turn", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state).id;
const r = summon(state, me, "troll");
state = r.state;
const other = state.players.find((p) => p.id !== me)!.id;
state = must(state, me, { type: "endTurn", draw: 0 });
state = must(state, other, { type: "endTurn", draw: 0 });
const troll = state.creatures[0]!;
const board = boardView(state);
const side = SIDES.find((s) => board.edges[edgeKey(troll.position, s)] === "wall")!;
const res = applyCommand(state, me, { type: "creatureAttackWall", creatureId: troll.id, cell: troll.position, side });
expect(res.ok).toBe(true);
if (!res.ok) return;
const hit = res.events.find((e) => e.type === "wallDamaged");
expect(hit && hit.type === "wallDamaged" ? hit.amount : 0).toBeGreaterThanOrEqual(1);
expect(res.state.creatures[0]!.attackUsed).toBe(true);
const again = applyCommand(res.state, me, { type: "creatureAttackWall", creatureId: troll.id, cell: troll.position, side });
expect(again.ok).toBe(false);
});
});
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game"; import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board"; import { cellKey, edgeKey, neighbor, opposite, SIDES, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; import { newGame, must, giveCard, toRound2, faceOff, castAt, drain } from "./helpers";
describe("duration spells", () => { describe("duration spells", () => {
it("slow reduces movement to 1, blocks number cards, and halves attacks", () => { it("slow reduces movement to 1, blocks number cards, and halves attacks", () => {
@@ -567,3 +567,44 @@ describe("the displayed MASTER KEY", () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
}); });
describe("POWER DRAIN drains the number played (rev 23)", () => {
it("gains the number even when the blow is blunted", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const pd = giveCard(state, attacker, "power-drain");
giveCard(state, attacker, "number-4", "N", 1);
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
state = must(state, attacker, { type: "cast", instanceId: pd.instanceId, target: { kind: "player", playerId: defender }, numberInstanceIds: ["number-4#N"] });
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
state = drain(state);
expect(state.players.find((p) => p.id === defender)!.life).toBe(13);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(19);
});
it("drains a wall for its points", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state);
const board = boardView(state);
const side = SIDES.find((s) => board.edges[edgeKey(me.position, s)] === "wall")!;
const pd = giveCard(state, me.id, "power-drain");
giveCard(state, me.id, "number-3", "N", 1);
state = must(state, me.id, { type: "cast", instanceId: pd.instanceId, target: { kind: "edge", cell: me.position, side }, numberInstanceIds: ["number-3#N"] });
expect(state.wallDamage[edgeKey(me.position, side)]).toBe(3);
expect(state.players.find((p) => p.id === me.id)!.life).toBe(18);
});
it("older games gave only what the opponent lost, and nothing from a wall", () => {
let state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 22 }).state);
const me = activePlayer(state);
const board = boardView(state);
const side = SIDES.find((s) => board.edges[edgeKey(me.position, s)] === "wall")!;
const pd = giveCard(state, me.id, "power-drain");
giveCard(state, me.id, "number-3", "N", 1);
state = must(state, me.id, { type: "cast", instanceId: pd.instanceId, target: { kind: "edge", cell: me.position, side }, numberInstanceIds: ["number-3#N"] });
expect(state.players.find((p) => p.id === me.id)!.life).toBe(15);
});
});
+54 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game";
import { cellKey, edgeKey, stepTarget, type Cell } from "../src/board"; import { cellKey, edgeKey, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -813,7 +813,7 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
}); });
}); });
describe("butt-head's charge reaches as far as its legs (K4T3)", () => { describe("butt-head's charge reaches as far as its legs (rev 16)", () => {
/** Two empty cells seven or eight corridor steps apart on the seed-42 board. */ /** Two empty cells seven or eight corridor steps apart on the seed-42 board. */
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } { function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
const view = boardView(state); const view = boardView(state);
@@ -843,3 +843,55 @@ describe("butt-head's charge reaches as far as its legs (K4T3)", () => {
}); });
} }
}); });
describe("cards that keep their whole promise (rev 21)", () => {
it("LIFESAVER's holder survives losing both treasures", () => {
for (const [deckRev, alive] of [[21, true], [20, false]] as const) {
let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
const others = state.players.filter((p) => p.id !== me.id);
const card = giveCard(state, me.id, "lifesaver");
state = must(state, me.id, { type: "cast", instanceId: card.instanceId });
// Both of the holder's treasures rest on two different enemy homes: nobody wins, the holder is done for.
const mine = state.treasures.filter((t) => t.owner === me.id);
mine[0]!.position = { ...others[0]!.home };
mine[1]!.position = { ...others[1]!.home };
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(state.players.find((p) => p.id === me.id)!.alive).toBe(alive);
}
});
it("FORCE FIELD stands until the opponent's turn ends: no entering, no casting on its caster", () => {
for (const deckRev of [21, 20]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
// Stand the attacker beside the defender, an open edge between.
const view = boardView(state);
const side = SIDES.find((s: Side) => { const t = stepTarget(view, d.position, s); return t.kind === "step" && !state.squareContents[cellKey(t.to)]; })!;
const beside = stepTarget(view, d.position, side);
if (beside.kind === "blocked") throw new Error("no open side");
a.position = { ...beside.to };
const fb = giveCard(state, attacker, "fireball");
d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "force-field#T" });
state = must(state, attacker, { type: "pass" });
if (state.stack) state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
const step = applyCommand(state, attacker, { type: "move", direction: opposite(side) });
if (deckRev >= 21) {
expect(step.ok).toBe(false);
if (!step.ok) expect(step.error).toMatch(/force field/);
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(true);
const after = must(state, attacker, { type: "endTurn", draw: 0 });
expect(after.sustained.some((f) => f.cardId === "force-field")).toBe(false);
} else {
expect(step.ok).toBe(true);
expect(state.sustained.some((f) => f.cardId === "force-field")).toBe(false);
}
}
});
});
+119 -4
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game"; import { applyCommand, activePlayer, boardView, createGame, gameLos, wallIgnoringDistance, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view"; import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
@@ -561,7 +561,7 @@ describe("the boobytrap keeps its secret", () => {
}); });
}); });
describe("a pit on the board's rim (M4Q7)", () => { describe("a pit on the board's rim (rev 17)", () => {
/** A square on the outer rim with a warp mouth on one side, entered from /** A square on the outer rim with a warp mouth on one side, entered from
* the square opposite the mouth; its other neighbouring squares listed. */ * the square opposite the mouth; its other neighbouring squares listed. */
function rimPitSite(state: GameState) { function rimPitSite(state: GameState) {
@@ -637,7 +637,7 @@ describe("a pit on the board's rim (M4Q7)", () => {
}); });
}); });
describe("a wave names its victims before it pushes (LK97)", () => { describe("a wave names its victims before it pushes (rev 20)", () => {
/** A wall between two squares, a wizard on its near side with one open /** A wall between two squares, a wizard on its near side with one open
* square behind them and a wall beyond it, and a caster's square beside. */ * square behind them and a wall beyond it, and a caster's square beside. */
function site(state: GameState) { function site(state: GameState) {
@@ -692,7 +692,6 @@ describe("spells cast at a slime wait in the gel", () => {
expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1); expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1);
// Nobody is hurt yet; the victim walks in on their own turn. // Nobody is hurt yet; the victim walks in on their own turn.
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 }); state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
victim.position = { ...caster.position };
const v = state.players.find((p) => p.id === victim.id)!; const v = state.players.find((p) => p.id === victim.id)!;
v.position = { ...caster.position }; v.position = { ...caster.position };
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side }); const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
@@ -706,3 +705,119 @@ describe("spells cast at a slime wait in the gel", () => {
expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined(); expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined();
}); });
}); });
describe("attacks aimed at a bush or the ooze", () => {
function withContent(kind: "thornbush" | "rosebush" | "ooze") {
const state = toRound2(newGame().state);
const me = activePlayer(state);
const { cell } = emptyNeighborCell(state, me.position);
state.squareContents[cellKey(cell)] = { kind, damage: 0, createdBy: "bob" };
return { state, me, cell };
}
it("a FIREBALL tears a thornbush apart at five points", () => {
const { state, me, cell } = withContent("thornbush");
const fireball = giveCard(state, me.id, "fireball");
const r = applyCommand(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDamaged", kind: "thornbush", amount: 5, total: 5 }));
expect(r.events).toContainEqual(expect.objectContaining({ type: "squareContentDestroyed", kind: "thornbush" }));
expect(r.state.squareContents[cellKey(cell)]).toBeUndefined();
expect(r.state.turn.attackUsed).toBe(true);
});
it("a thrown dagger scratches a rosebush, and the scratch stays", () => {
const { state, me, cell } = withContent("rosebush");
const dagger = giveCard(state, me.id, "dagger");
const next = must(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
expect(next.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "rosebush", damage: 3 }));
});
it("only fire hurts the ooze", () => {
const { state, me, cell } = withContent("ooze");
const dagger = giveCard(state, me.id, "dagger");
const refused = applyCommand(state, me.id, { type: "cast", instanceId: dagger.instanceId, target: { kind: "cell", cell } });
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toMatch(/only fire/);
const fireball = giveCard(state, me.id, "fireball");
const burned = must(state, me.id, { type: "cast", instanceId: fireball.instanceId, target: { kind: "cell", cell } });
expect(burned.squareContents[cellKey(cell)]).toBeUndefined();
});
it("a WIZARDBLADE is swung at a bush from the square beside it, never from afar", () => {
const { state, me, cell } = withContent("thornbush");
const blade = giveCard(state, me.id, "wizardblade");
const three = giveCard(state, me.id, "number-3", "N", 1);
const near = applyCommand(state, me.id, {
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell },
});
expect(near.ok).toBe(true);
if (near.ok) expect(near.state.squareContents[cellKey(cell)]).toEqual(expect.objectContaining({ kind: "thornbush", damage: 3 }));
const far = sightedCellsFor(viewFor(state, me.id));
const farKey = [...far].find((k) => {
const [x, y] = k.split(",").map(Number);
return Math.abs(x! - me.position.x) + Math.abs(y! - me.position.y) >= 2 && !state.squareContents[k];
});
if (!farKey) return;
const [fx, fy] = farKey.split(",").map(Number);
state.squareContents[farKey] = { kind: "thornbush", damage: 0, createdBy: "bob" };
const refused = applyCommand(state, me.id, {
type: "cast", instanceId: blade.instanceId, numberInstanceIds: [three.instanceId], target: { kind: "cell", cell: { x: fx!, y: fy! } },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toMatch(/beside/);
});
});
describe("teleport's reach wraps through the board's openings", () => {
it("offers the square beyond a warp mouth, as the engine allows it", () => {
const state = toRound2(newGame().state);
const me = activePlayer(state);
const view = boardView(state);
const warp = view.warps[0]!;
me.position = { ...warp.from.cell };
giveCard(state, me.id, "teleport");
const cells = eligibleCellsFor(viewFor(state, me.id), "teleport")!;
const beyond = cellKey(warp.to.cell);
expect(cells.has(beyond)).toBe(true);
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: warp.to.cell } });
expect(r.ok).toBe(true);
});
});
describe("teleport across the maze's outer edge (rev 22)", () => {
function atTheTopEdge(deckRev?: number) {
const config = { playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] as ("basic" | "expansion1")[], ...(deckRev ? { deckRev } : {}) };
const state = toRound2(createGame(config).state);
const me = activePlayer(state);
const view = boardView(state);
// A square on the top row whose north side is plain edge, not a lettered mouth.
const top = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number])
.filter(([x, y]) => y === 0 && !view.warps.some((w) => w.from.cell.x === x && w.from.cell.y === y && w.from.side === "N"))
.find(([x]) => !state.squareContents[`${x},0`])!;
me.position = { x: top[0], y: 0 };
const column = Object.keys(view.cells).map((k) => k.split(",").map(Number) as [number, number]).filter(([x]) => x === top[0]).map(([, y]) => y);
const bottom = { x: top[0], y: Math.max(...column) };
giveCard(state, me.id, "teleport");
return { state, me, bottom };
}
it("re-enters at the bottom of the same column, one space on", () => {
const { state, me, bottom } = atTheTopEdge();
expect(wallIgnoringDistance(boardView(state), me.position, bottom, true)).toBe(1);
expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(true);
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } });
expect(r.ok).toBe(true);
if (r.ok) expect(r.state.players.find((p) => p.id === me.id)!.position).toEqual(bottom);
});
it("older games cross only at the lettered openings", () => {
const { state, me, bottom } = atTheTopEdge(21);
expect(wallIgnoringDistance(boardView(state), me.position, bottom)).toBeGreaterThan(4);
expect(eligibleCellsFor(viewFor(state, me.id), "teleport")!.has(cellKey(bottom))).toBe(false);
const r = applyCommand(state, me.id, { type: "cast", instanceId: "teleport#T", target: { kind: "cell", cell: bottom } });
expect(r.ok).toBe(false);
});
});
+112 -4
View File
@@ -36,8 +36,9 @@ import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs"; import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { extname, join, normalize, sep } from "node:path"; import { extname, join, normalize, sep } from "node:path";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import { cardDef } from "@wizwar/engine";
import type { Command, PlayerId } from "@wizwar/engine"; import type { Command, PlayerId } from "@wizwar/engine";
import { import { callKeeper, callRematch,
catchUpSteps, catchUpSteps,
momentSteps, momentSteps,
claimTransferCode, claimTransferCode,
@@ -85,6 +86,11 @@ const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
// Per-address limits, held across reconnects: a table of friends never // Per-address limits, held across reconnects: a table of friends never
// nears them; a script filling the vault or the reports desk does. // nears them; a script filling the vault or the reports desk does.
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000); const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
/** Calls to the keeper: a real person's phone rings for each. */
const challengesPerAddress = new SlidingLimit(3, 60 * 60 * 1000);
/** The keeper of this table: the wizard a lobby may challenge. */
const KEEPER = process.env.WIZWAR_KEEPER ?? "Kestrel";
const PUBLIC_URL = (process.env.WIZWAR_PUBLIC_URL ?? "https://wizwar.kestrelsnest.social").replace(/\/$/, "");
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000); const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
const MAX_COMMAND_BYTES = 16384; // serialized game command const MAX_COMMAND_BYTES = 16384; // serialized game command
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
@@ -198,11 +204,46 @@ function safeBase(rawHost: string, rawProto: string): string {
return `${proto}://${escapeHtml(rawHost)}`; return `${proto}://${escapeHtml(rawHost)}`;
} }
/** The turn's story in one line, for the share card: the deed a
* stranger would click to see, chosen by weight — a crown, a treasure
* carried home, a blow that landed, a spell cast — before the bare fact
* of whose turn it was. */
function headlineOf(data: ShareData): string | null {
// A card retired since the share was written keeps its id as its name.
const name = (id: string | null) => { if (!id) return "a spell"; try { return cardDef(id).name; } catch { return id; } };
const deeds: { rank: number; text: string }[] = [];
const offer = (rank: number, text: string) => { deeds.push({ rank, text }); };
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 "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 "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;
case "spellCast": offer(2, e.target ? `${e.caster} casts ${name(e.cardId)} at ${e.target}` : `${e.caster} casts ${name(e.cardId)}`); break;
case "jumpedPit": offer(3, `${e.player} leaps the pit`); break;
case "fellInPit": offer(3, `${e.player} falls into the pit`); break;
case "wardSprung": offer(4, `${e.owner}'s warded treasure bites ${e.victim}`); break;
case "slimeTrapSprung": offer(4, `the slime springs ${name(e.cardId)} on ${e.victim}`); break;
default: break;
}
}
}
let best: { rank: number; text: string } | null = null;
for (const d of deeds) if (!best || d.rank > best.rank) best = d;
return best?.text ?? null;
}
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string { function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto); const base = safeBase(rawHost, rawProto);
const headline = data.whole ? null : headlineOf(data);
const title = data.whole const title = data.whole
? "The whole tale — a game of Wiz-War, replayed" ? "The whole tale — a game of Wiz-War, replayed"
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`; : headline
? `${escapeHtml(headline)} — a Wiz-War instant replay`
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = data.whole 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 — every turn 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. ` + : `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
@@ -289,7 +330,10 @@ const httpServer = createServer((req, res) => {
"cache-control": "public, max-age=60", "cache-control": "public, max-age=60",
"access-control-allow-origin": "*", "access-control-allow-origin": "*",
}); });
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true })); res.end(JSON.stringify({
steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true,
headline: data.whole ? null : headlineOf(data),
}));
return; return;
} }
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/); const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
@@ -482,6 +526,11 @@ function roomInfo(room: Room) {
hostId: room.hostId, hostId: room.hostId,
started: room.state !== null, started: room.state !== null,
audience: audienceCount(room), audience: audienceCount(room),
rematch: room.rematch ?? null,
expected: room.expected ?? [],
expansion: room.expansion,
challenge: room.challenge ?? null,
keeper: KEEPER,
colors: Object.fromEntries(room.colorChoices), colors: Object.fromEntries(room.colorChoices),
bots: Object.fromEntries( bots: Object.fromEntries(
// A mystery machine keeps its mood only while the game lives: once it // A mystery machine keeps its mood only while the game lives: once it
@@ -668,7 +717,12 @@ wss.on("connection", (socket, req) => {
const room = getRoom(roomId); const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" }); if (!room) return send(socket, { type: "error", message: "no such room" });
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null); const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
if ("error" in result) return send(socket, { type: "error", message: result.error }); if ("error" in result) {
// A refused seat is worth a line: a lost seat mid-game is the
// costliest quiet failure this table has.
console.warn(`join refused: room ${room.id} name ${JSON.stringify(name)}${result.error}`);
return send(socket, { type: "error", message: result.error });
}
leaveGallery(session); leaveGallery(session);
session.playerId = name; session.playerId = name;
session.roomId = room.id; session.roomId = room.id;
@@ -679,11 +733,40 @@ wss.on("connection", (socket, req) => {
// from firing again on every return to the room. // from firing again on every return to the room.
if (room.state) { if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true }); send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
} else if (room.chat.length > 0) {
// The lobby's talk so far, for whoever just sat down.
send(socket, { type: "events", events: room.chat.map((c) => ({ type: "tableTalk", player: c.player, text: c.text })), replayed: true });
} }
broadcastRoomState(room); broadcastRoomState(room);
runBots(room); runBots(room);
break; break;
} }
case "challengeKeeper": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (!challengesPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "the keeper has been called enough from here for one hour" });
}
const called = callKeeper(room, session.playerId, KEEPER);
if ("error" in called) return send(socket, { type: "error", message: called.error });
// The alarm the keeper listens for: one issue per room, so each
// call rings once, with the door in the message. Error level,
// because Sentry's alerts ring for high-priority issues and a
// warning is filed as medium — a bell nobody hears.
const link = `${PUBLIC_URL}/join/${room.id}`;
if (process.env.SENTRY_DSN) {
Sentry.captureMessage(`${session.playerId} challenges ${KEEPER} to a game — ${link}`, {
level: "error",
fingerprint: ["challenge", room.id],
tags: { room: room.id, challenger: session.playerId },
extra: { link, players: room.players.join(", ") },
});
}
const said = addChat(room, session.playerId, `calls ${KEEPER} to the table`);
if (!("error" in said)) broadcast(room, () => ({ type: "chat", player: session.playerId, text: said.text, at: said.at }));
broadcastRoomState(room);
break;
}
case "watch": { case "watch": {
// The Peanut Gallery: no name, no seat, no ledger line — a pure // The Peanut Gallery: no name, no seat, no ledger line — a pure
// reader of the public broadcast, counted but never identified. // reader of the public broadcast, counted but never identified.
@@ -801,6 +884,30 @@ wss.on("connection", (socket, req) => {
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at })); broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
break; break;
} }
case "rematch": {
// Anyone at a finished table may call; the caller lands in the
// new lobby, and the old table hears where it went.
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const called = callRematch(room, session.playerId);
if ("error" in called) return send(socket, { type: "error", message: called.error });
const next = getRoom(called.roomId);
if (!next) return send(socket, { type: "error", message: "the rematch room is gone" });
let token = called.token;
if (!token) {
const joined = joinRoom(next, session.playerId, null);
if ("error" in joined) return send(socket, { type: "error", message: joined.error });
token = joined.token;
}
if (called.created) broadcast(room, () => ({ type: "rematch", roomId: room.id, to: next.id, by: session.playerId }));
leaveGallery(session);
send(socket, { type: "rematched", roomId: next.id });
session.roomId = next.id;
session.token = token;
send(socket, { type: "seat", playerId: session.playerId, token });
broadcastRoomState(next);
break;
}
case "chat": { case "chat": {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
@@ -963,6 +1070,7 @@ wss.on("connection", (socket, req) => {
const result = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null); const result = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null);
if (result === null) continue; if (result === null) continue;
if (result === "badToken") { if (result === "badToken") {
console.warn(`seat voided: room ${roomId} name ${JSON.stringify(name)} — the token did not match`);
voided.push(`${roomId}:${name}`); voided.push(`${roomId}:${name}`);
continue; continue;
} }
+65 -4
View File
@@ -52,6 +52,12 @@ export interface Room {
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>; bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
/** Last time anything looked at this room (memory eviction clock). */ /** Last time anything looked at this room (memory eviction clock). */
touchedAt?: number; touchedAt?: number;
/** A finished table that called for a rematch: where it went, and who called. */
rematch?: { roomId: string; by: PlayerId };
/** A rematch lobby: the wizards of the last table who have not yet sat. */
expected?: PlayerId[];
/** The table called the keeper of this site to a seat, and by whom. */
challenge?: { by: PlayerId; at: string };
} }
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
@@ -91,7 +97,10 @@ export function runningRooms(): Room[] {
return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing"); return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing");
} }
export function createRoom(hostId: PlayerId): { room: Room; token: string } { export function createRoom(
hostId: PlayerId,
rematch?: { of: string; expected: PlayerId[]; expansion: boolean },
): { room: Room; token: string } {
const token = randomBytes(16).toString("hex"); const token = randomBytes(16).toString("hex");
const room: Room = { const room: Room = {
id: makeRoomCode(), id: makeRoomCode(),
@@ -99,7 +108,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
players: [hostId], players: [hostId],
tokens: new Map([[hostId, hashToken(token)]]), tokens: new Map([[hostId, hashToken(token)]]),
seed: randomInt(0, 0xffffffff), seed: randomInt(0, 0xffffffff),
expansion: false, expansion: rematch?.expansion ?? false,
colorChoices: new Map(), colorChoices: new Map(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
state: null, state: null,
@@ -108,6 +117,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
chat: [], chat: [],
bots: new Map(), bots: new Map(),
touchedAt: Date.now(), touchedAt: Date.now(),
...(rematch ? { expected: [...rematch.expected] } : {}),
}; };
rooms.set(room.id, room); rooms.set(room.id, room);
recordRoom(room); recordRoom(room);
@@ -118,10 +128,45 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
hostTokenHash: hashToken(token), hostTokenHash: hashToken(token),
seed: room.seed, seed: room.seed,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
...(rematch ? { rematchOf: rematch.of, expected: rematch.expected, expansion: rematch.expansion } : {}),
}); });
return { room, token }; return { room, token };
} }
/** A lobby calls the keeper to the table: a seat is held under their
* name, the call is written to the ledger, and the caller (index.ts)
* raises the alarm the keeper listens for. Once per room. */
export function callKeeper(room: Room, byId: PlayerId, keeper: PlayerId): { at: string } | { error: string } {
if (room.state) return { error: "the game has started" };
if (!room.players.includes(byId) || room.bots.has(byId)) return { error: "take a seat first" };
if (room.challenge) return { error: `${keeper} has already been called to this table` };
if (room.players.includes(keeper)) return { error: `${keeper} is already here` };
if (room.players.length + (room.expected?.length ?? 0) >= 6) return { error: "the table is full" };
const at = new Date().toISOString();
room.challenge = { by: byId, at };
room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
appendLine(room.id, { kind: "challenge", by: byId, at });
return { at };
}
/** A finished table calls for a rematch: a new room with the same
* clockwork at the same tiers and the same expansion setting, waiting for
* the same humans. The first caller hosts it; anyone at the old table may
* call, and a second call finds the room already made. */
export function callRematch(
room: Room, byId: PlayerId,
): { roomId: string; token: string | null; created: boolean } | { error: string } {
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);
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);
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 };
}
export function getRoom(id: string): Room | undefined { export function getRoom(id: string): Room | undefined {
const code = id.toUpperCase(); const code = id.toUpperCase();
const live = rooms.get(code); const live = rooms.get(code);
@@ -165,7 +210,7 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number
evicted++; evicted++;
try { try {
const stat = ledgerStat(id); const stat = ledgerStat(id);
if (stat) writeStubFile(id, stat.bytes, { ...stub, tokens: Object.fromEntries(stub.tokens) }); if (stat) writeStubFile(id, stat.bytes, toStored(stub));
} catch (e) { } catch (e) {
console.error(`could not write stub for ${id}:`, e); console.error(`could not write stub for ${id}:`, e);
} }
@@ -185,6 +230,8 @@ interface RoomStub {
const sleepingStubs = new Map<string, RoomStub>(); const sleepingStubs = new Map<string, RoomStub>();
/** A stub as it rests on disk — the token map spelled as an object. */ /** A stub as it rests on disk — the token map spelled as an object. */
type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> }; type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> };
const toStored = (stub: RoomStub): StoredStub => ({ ...stub, tokens: Object.fromEntries(stub.tokens) });
const fromStored = (stored: StoredStub): RoomStub => ({ ...stored, tokens: new Map(Object.entries(stored.tokens)) });
function stubOf(room: Room): RoomStub { function stubOf(room: Room): RoomStub {
const { active, turnHolder, waitKind } = turnFacts(room.state); const { active, turnHolder, waitKind } = turnFacts(room.state);
@@ -209,6 +256,7 @@ function baseSummary(room: Room, active: PlayerId | null): Omit<GameSummary, "na
round: room.state?.turn.round ?? null, round: room.state?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
chatCount: room.chat.length, chatCount: room.chat.length,
rematch: room.rematch ?? null,
}; };
} }
@@ -246,6 +294,8 @@ export function joinRoom(
const fresh = randomBytes(16).toString("hex"); const fresh = randomBytes(16).toString("hex");
room.players.push(playerId); room.players.push(playerId);
room.tokens.set(playerId, hashToken(fresh)); room.tokens.set(playerId, hashToken(fresh));
// An expected wizard who sits is expected no longer.
if (room.expected?.includes(playerId)) room.expected = room.expected.filter((n) => n !== playerId);
appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) }); appendLine(room.id, { kind: "join", name: playerId, tokenHash: hashToken(fresh) });
recordRoom(room); recordRoom(room);
return { token: fresh }; return { token: fresh };
@@ -465,6 +515,8 @@ export interface GameSummary {
lastMoveAt: string | null; lastMoveAt: string | null;
/** Total table-talk messages; the client tracks which it has seen. */ /** Total table-talk messages; the client tracks which it has seen. */
chatCount: number; chatCount: number;
/** A finished table that moved on: the rematch room and who called it. */
rematch?: { roomId: string; by: PlayerId } | null;
} }
/** Who holds the table's attention, and why the summary's turn facts. /** Who holds the table's attention, and why the summary's turn facts.
@@ -741,6 +793,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
chat: [], chat: [],
bots: new Map(), bots: new Map(),
touchedAt: Date.now(), touchedAt: Date.now(),
...(meta.expected ? { expected: [...meta.expected] } : {}),
...(meta.expansion !== undefined ? { expansion: meta.expansion } : {}),
}; };
if (lines.some((l) => l.kind === "abandon")) return null; if (lines.some((l) => l.kind === "abandon")) return null;
for (const line of lines.slice(1)) { for (const line of lines.slice(1)) {
@@ -764,10 +818,17 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
if (!joinHash) throw new Error("join line has no token"); if (!joinHash) throw new Error("join line has no token");
room.players.push(line.name); room.players.push(line.name);
room.tokens.set(line.name, joinHash); room.tokens.set(line.name, joinHash);
if (room.expected?.includes(line.name)) room.expected = room.expected.filter((n) => n !== line.name);
} }
} else if (line.kind === "start") { } else if (line.kind === "start") {
const r = startInMemory(room, line.expansion, line.colors, line.deckRev); const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
if ("error" in r) throw new Error(`replay start failed: ${r.error}`); if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
} else if (line.kind === "rematch") {
room.rematch = { roomId: line.to, by: line.by };
} else if (line.kind === "challenge") {
room.challenge = { by: line.by, at: line.at };
const keeper = process.env.WIZWAR_KEEPER ?? "Kestrel";
if (!room.players.includes(keeper)) room.expected = [...(room.expected ?? []).filter((n) => n !== keeper), keeper];
} else if (line.kind === "chat") { } else if (line.kind === "chat") {
// File order preserves the interleaving with commands. // File order preserves the interleaving with commands.
room.chat.push({ player: line.player, text: line.text, at: line.at }); room.chat.push({ player: line.player, text: line.text, at: line.at });
@@ -797,7 +858,7 @@ export function loadPersistedRooms(): void {
if (!stat) continue; if (!stat) continue;
const stored = readStubFile<StoredStub>(id); const stored = readStubFile<StoredStub>(id);
if (stored && stored.bytes === stat.bytes) { if (stored && stored.bytes === stat.bytes) {
sleepingStubs.set(id, { ...stored.stub, tokens: new Map(Object.entries(stored.stub.tokens)) }); sleepingStubs.set(id, fromStored(stored.stub));
asleep++; asleep++;
continue; continue;
} }
+2 -2
View File
@@ -5,7 +5,7 @@
import { appendFileSync, existsSync, readFileSync } from "node:fs"; import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { randomInt } from "node:crypto"; import { randomInt } from "node:crypto";
import { statsDir } from "./store"; import { dataRoot } from "./store";
export interface Share { export interface Share {
id: string; id: string;
@@ -20,7 +20,7 @@ const SHARE_ID_LENGTH = 10; // 31^10 ≈ 8×10^14 — unguessable, typeable
const shares = new Map<string, Share>(); const shares = new Map<string, Share>();
function sharesFile(): string { function sharesFile(): string {
return join(statsDir(), "shares.jsonl"); return join(dataRoot(), "shares.jsonl");
} }
export function loadShares(): void { export function loadShares(): void {
+2 -2
View File
@@ -6,7 +6,7 @@
import { readFileSync, renameSync, writeFileSync } from "node:fs"; import { readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { statsDir } from "./store"; import { dataRoot } from "./store";
import type { Room } from "./rooms"; import type { Room } from "./rooms";
export interface EngagementStats { export interface EngagementStats {
@@ -35,7 +35,7 @@ interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
roomStages: Record<string, "created" | "started" | "finished">; roomStages: Record<string, "created" | "started" | "finished">;
} }
const FILE = () => join(statsDir(), "stats.json"); const FILE = () => join(dataRoot(), "stats.json");
let data: StatsFile = { let data: StatsFile = {
gamesCreated: 0, gamesStarted: 0, gamesFinished: 0, gamesCreated: 0, gamesStarted: 0, gamesFinished: 0,
+30 -8
View File
@@ -16,6 +16,26 @@ export interface RoomMetaLine {
hostToken?: string; hostToken?: string;
seed: number; seed: number;
createdAt: string; createdAt: string;
/** A rematch: the finished room it follows, and the wizards it waits for. */
rematchOf?: string;
expected?: string[];
/** The lobby's expansion default, carried over from the last table. */
expansion?: boolean;
}
/** A table called the keeper: the seat is held and the keeper told. */
export interface ChallengeLine {
kind: "challenge";
by: string;
at: string;
}
/** The finished table called for a rematch: the new room it moved to. */
export interface RematchLine {
kind: "rematch";
to: string;
by: string;
at: string;
} }
export interface JoinLine { export interface JoinLine {
@@ -71,7 +91,7 @@ export interface AbandonLine {
at: string; at: string;
} }
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine; export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
@@ -86,8 +106,10 @@ function fileFor(roomId: string): string {
return join(DATA_DIR, `${roomId}.jsonl`); return join(DATA_DIR, `${roomId}.jsonl`);
} }
/** Directory holding stats.json — the parent of the rooms dir. */ /** The data root the parent of the rooms dir where everything that
export function statsDir(): string { * is not a room ledger lives: stats, feedback, stubs, the graveyard, the
* clip vault. */
export function dataRoot(): string {
return join(DATA_DIR, ".."); return join(DATA_DIR, "..");
} }
@@ -149,7 +171,7 @@ export function ledgerStat(roomId: string): { bytes: number; mtimeMs: number } |
// ledger only ever grows, so at boot a stub whose size still matches is // ledger only ever grows, so at boot a stub whose size still matches is
// the room's whole truth and the room stays asleep, unreplayed. // the room's whole truth and the room stays asleep, unreplayed.
const stubDir = () => join(DATA_DIR, "..", "stubs"); const stubDir = () => join(dataRoot(), "stubs");
function stubFor(roomId: string): string { function stubFor(roomId: string): string {
if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`); if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`);
return join(stubDir(), `${roomId}.json`); return join(stubDir(), `${roomId}.json`);
@@ -168,7 +190,7 @@ export function readStubFile<T>(roomId: string): { bytes: number; stub: T } | nu
} }
} }
export function writeStubFile(roomId: string, bytes: number, stub: unknown): void { export function writeStubFile<T>(roomId: string, bytes: number, stub: T): void {
const file = stubFor(roomId); const file = stubFor(roomId);
mkdirSync(stubDir(), { recursive: true }); mkdirSync(stubDir(), { recursive: true });
writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8"); writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8");
@@ -182,7 +204,7 @@ export function writeStubFile(roomId: string, bytes: number, stub: unknown): voi
* timestamp. A report line may carry fields (deckRev, player context) * timestamp. A report line may carry fields (deckRev, player context)
* that only the operator's raw read uses; readFeedback keeps the * that only the operator's raw read uses; readFeedback keeps the
* player-facing subset. */ * player-facing subset. */
const feedbackFile = () => join(DATA_DIR, "..", "feedback.jsonl"); const feedbackFile = () => join(dataRoot(), "feedback.jsonl");
export function appendFeedback(entry: Record<string, unknown>): void { export function appendFeedback(entry: Record<string, unknown>): void {
ensureDataDir(); ensureDataDir();
@@ -239,7 +261,7 @@ export function readFeedback(): FeedbackReport[] {
export function archiveRoomFile(roomId: string): void { export function archiveRoomFile(roomId: string): void {
const src = fileFor(roomId); const src = fileFor(roomId);
if (!existsSync(src)) return; if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned"); const graveyard = join(dataRoot(), "rooms-abandoned");
mkdirSync(graveyard, { recursive: true }); mkdirSync(graveyard, { recursive: true });
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`)); renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
} }
@@ -260,7 +282,7 @@ export interface ClipMeta {
height?: number; height?: number;
} }
const clipsDir = () => join(DATA_DIR, "..", "clips"); const clipsDir = () => join(dataRoot(), "clips");
/** A clip's name, which is also its file stem: no separators, no dots, /** A clip's name, which is also its file stem: no separators, no dots,
* so a name can never name a path. */ * so a name can never name a path. */
File diff suppressed because it is too large Load Diff
+5 -33
View File
@@ -35,7 +35,6 @@
markedCells = null, markedCells = null,
litCells = null, litCells = null,
spotCells = [], spotCells = [],
spotCaption = null,
markedSector = null, markedSector = null,
ghostSlots = null, ghostSlots = null,
onGhostClick, onGhostClick,
@@ -70,8 +69,6 @@
litCells?: Set<string> | null; litCells?: Set<string> | null;
/** The step light's squares: a pulsing rim on each. */ /** The step light's squares: a pulsing rim on each. */
spotCells?: string[]; spotCells?: string[];
/** One line hung above a square — the first stride's cue. */
spotCaption?: { cell: { x: number; y: number }; text: string } | null;
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */ /** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
markedSector?: { x: number; y: number } | null; markedSector?: { x: number; y: number } | null;
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground /** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
@@ -464,15 +461,11 @@
{@const wx = Number(key.split(":")[1]?.split(",")[0])} {@const wx = Number(key.split(":")[1]?.split(",")[0])}
{@const wy = Number(key.split(":")[1]?.split(",")[1])} {@const wy = Number(key.split(":")[1]?.split(",")[1])}
{@const frac = Math.min(1, dmg / 20)} {@const frac = Math.min(1, dmg / 20)}
{#if kind === "V"} {@const scar = kind === "V"
<path d={edgeScar((wx+1)*CELL, wy*CELL+3, (wx+1)*CELL, (wy+1)*CELL-3, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} /> ? edgeScar((wx + 1) * CELL, wy * CELL + 3, (wx + 1) * CELL, (wy + 1) * CELL - 3, frac)
<path d={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)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path> <path d={scar} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
{:else} <path d={scar} class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)} class="crack-shadow" style:opacity={0.35 + frac * 0.65} />
<path d={edgeScar(wx*CELL+3, (wy+1)*CELL, (wx+1)*CELL-3, (wy+1)*CELL, frac)}
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></path>
{/if}
{/each} {/each}
<!-- illusions YOU know are fake: ghostly dashed lines --> <!-- illusions YOU know are fake: ghostly dashed lines -->
@@ -697,17 +690,6 @@
{@const sy = Number(key.split(",")[1])} {@const sy = Number(key.split(",")[1])}
<rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" /> <rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" />
{/each} {/each}
{#if spotCaption}
{@const cols = Math.max(...Object.keys(view.board.cells).map((k) => Number(k.split(",")[0]))) + 1}
{@const cw = spotCaption.text.length * 6.6 + 20}
{@const cx = Math.min(Math.max(spotCaption.cell.x * CELL + CELL / 2, cw / 2 + 4), cols * CELL - cw / 2 - 4)}
{@const above = spotCaption.cell.y > 0}
{@const cy = above ? spotCaption.cell.y * CELL - 14 : (spotCaption.cell.y + 1) * CELL + 14}
<g class="spot-caption" transform={`translate(${cx}, ${cy})`}>
<rect x={-cw / 2} y={-12} width={cw} height={24} rx="12" />
<text x="0" y="4">{spotCaption.text}</text>
</g>
{/if}
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim --> <!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
{#each fearAura as key (key)} {#each fearAura as key (key)}
{@const bx = Number(key.split(",")[0])} {@const bx = Number(key.split(",")[0])}
@@ -865,15 +847,6 @@
0%, 100% { opacity: 0.35; } 0%, 100% { opacity: 0.35; }
50% { opacity: 1; } 50% { opacity: 1; }
} }
.spot-caption { pointer-events: none; }
.spot-caption rect { fill: rgba(28, 22, 12, 0.92); stroke: #f2cf5b; stroke-width: 1.5; }
.spot-caption text {
fill: #f7ecc8;
font-size: 12px;
font-family: "Oswald", "Helvetica Neue", Arial, sans-serif;
letter-spacing: 0.02em;
text-anchor: middle;
}
.marked-cell { .marked-cell {
fill: rgba(211, 133, 43, 0.18); fill: rgba(211, 133, 43, 0.18);
stroke: #d3852b; stroke: #d3852b;
@@ -933,7 +906,6 @@
stroke: rgba(240, 231, 209, 0.9); stroke: rgba(240, 231, 209, 0.9);
stroke-width: 1.25; stroke-width: 1.25;
stroke-linecap: round; stroke-linecap: round;
pointer-events: none;
} }
.warp-dest { .warp-dest {
fill: none; fill: none;
+1 -1
View File
@@ -48,7 +48,7 @@
><title>{tip}</title></rect> ><title>{tip}</title></rect>
{/if} {/if}
<!-- Pigment and hardware sit over the original wall/door hit target. --> <!-- stone highlights or wood grain and lock hardware; the rect beneath takes the taps -->
<g class="edge-detail" transform={`translate(${kind === "V" ? (x+1)*CELL : x*CELL+CELL/2} ${kind === "V" ? y*CELL+CELL/2 : (y+1)*CELL}) rotate(${kind === "V" ? 90 : 0})`} aria-hidden="true"> <g class="edge-detail" transform={`translate(${kind === "V" ? (x+1)*CELL : x*CELL+CELL/2} ${kind === "V" ? y*CELL+CELL/2 : (y+1)*CELL}) rotate(${kind === "V" ? 90 : 0})`} aria-hidden="true">
{#if state === "wall"} {#if state === "wall"}
<path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" /> <path d="M -23 -2.2 L -11 -2 -2 -2.3 11 -2 23 -2.2" class="stone-light" />
+1 -1
View File
@@ -43,7 +43,7 @@
fill="#bd3b1b" class="fw-bar" fill="#bd3b1b" class="fw-bar"
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} /> role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
{/if} {/if}
<!-- Drawn flame poses share the original bases and hit area. --> <!-- tongues of flame, each on its own beat; the bar beneath is the hit target -->
{#each TONGUES as f (f.t)} {#each TONGUES as f (f.t)}
<g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art"> <g transform={`translate(${px(f.t)} ${py(f.t)})`} class="fw-art">
<g class="fw-tongue" style={`animation-delay: ${-f.d}s`}> <g class="fw-tongue" style={`animation-delay: ${-f.d}s`}>
+462 -222
View File
@@ -1,130 +1,231 @@
<script lang="ts"> <script lang="ts">
import { allCardDefs, RULES_REVISIONS } from "@wizwar/engine"; // The reference desk: how to play, the rules and FAQ as one searchable
// 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 { cardDef } from "@wizwar/engine";
import { tick } from "svelte";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Faq from "./Faq.svelte"; import { HOW_TO_PLAY } from "./rules";
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules"; import { RULEBOOK_COPYRIGHT } from "./rulebook";
import { FAQ_GENERAL } from "./faq-general"; import { helpState, type HelpTab } from "./help-state";
import { RULEBOOK_BASE, RULEBOOK_EXPANSION, RULEBOOK_COPYRIGHT } from "./rulebook"; import {
REFERENCE, SOURCE_LABELS, searchReference, renderInline,
CARD_POOL, searchCards, mentionedCards, sightOf, typeLabel, setLabel,
HOUSE_RULINGS, rulingsFor, searchRulings,
type RefSource,
} from "./reference";
let { let {
onclose, onclose,
initialTab = "play", initialTab = null,
initialAnchor = null,
initialCard = null,
stats = null, stats = null,
onstats, onstats,
onnavigate,
}: { }: {
onclose: () => void; onclose: () => void;
initialTab?: "play" | "rules" | "cards" | "rulings" | "about" | "tally"; /** A tab to open on; null reopens where the reader left off. */
initialTab?: HelpTab | null;
/** A section to scroll to on the tab. */
initialAnchor?: string | null;
/** A card entry to open. */
initialCard?: string | null;
stats?: Record<string, number | string | null> | null; stats?: Record<string, number | string | null> | null;
onstats?: () => void; onstats?: () => void;
/** The address bar follows the reader, so the link is always a copy away. */
onnavigate?: (tab: HelpTab, anchor: string | null, card: string | null) => void;
} = $props(); } = $props();
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value // svelte-ignore state_referenced_locally -- the opening tab is a one-time value
let tab = $state<"play" | "rules" | "cards" | "rulings" | "about" | "tally">(initialTab); let tab = $state<HelpTab>(initialCard ? "cards" : (initialTab ?? helpState.tab));
/** Cards the FAQ has spoken on, in the library's order. */ let rulesSearch = $state(helpState.rulesSearch);
const ruledCards = allCardDefs().filter((c) => c.faqRulings.length > 0); let rulingsSearch = $state(helpState.rulingsSearch);
let faqCardId = $state<string | null>(null); let cardText = $state(helpState.cards.text);
let libPeek = $state<string | null>(null); let cardSet = $state(helpState.cards.set);
let cardType = $state(helpState.cards.type);
let cardSight = $state(helpState.cards.sight);
// svelte-ignore state_referenced_locally -- the opening card is a one-time value
let entry = $state<string | null>(initialCard);
let entryOpener: HTMLElement | null = null;
let bodyEl = $state<HTMLElement | null>(null);
let copied = $state<string | null>(null);
function openTally() { const refResults = $derived(searchReference(rulesSearch));
tab = "tally"; const rulingResults = $derived(searchRulings(rulingsSearch));
onstats?.(); const cardResults = $derived(searchCards({ text: cardText, set: cardSet, type: cardType, sight: cardSight }));
const entryDef = $derived(entry ? cardDef(entry) : null);
const entryRulings = $derived(entry ? rulingsFor(entry) : []);
const entryMentions = $derived(entry ? mentionedCards(entry) : []);
const refBySource = $derived(
(["summary", "rulebook", "expansion", "faq"] as RefSource[])
.map((source) => ({ source, sections: REFERENCE.filter((s) => s.source === source) })),
);
$effect(() => { helpState.tab = tab; });
$effect(() => { helpState.rulesSearch = rulesSearch; });
$effect(() => { helpState.rulingsSearch = rulingsSearch; });
$effect(() => { helpState.cards = { text: cardText, set: cardSet, type: cardType, sight: cardSight }; });
$effect(() => { onnavigate?.(tab, null, entry); });
function saveScroll() {
if (bodyEl) helpState.scroll[tab] = bodyEl.scrollTop;
}
async function goTab(t: HelpTab) {
if (t === tab) return;
saveScroll();
tab = t;
if (t === "tally") onstats?.();
await tick();
if (bodyEl) bodyEl.scrollTop = helpState.scroll[t] ?? 0;
}
async function jumpTo(anchor: string) {
await tick();
const el = bodyEl?.querySelector(`#${CSS.escape(anchor)}`);
el?.scrollIntoView({ block: "start" });
onnavigate?.(tab, anchor, null);
}
function linkFor(t: HelpTab, anchor: string | null, card: string | null): string {
const q = card ? `card=${card}` : `help=${t}${anchor ? `/${anchor}` : ""}`;
return `${location.origin}/?${q}`;
}
function copyLink(t: HelpTab, anchor: string | null, card: string | null) {
const key = card ?? `${t}/${anchor ?? ""}`;
navigator.clipboard?.writeText(linkFor(t, anchor, card)).catch(() => {});
onnavigate?.(t, anchor, card);
copied = key;
setTimeout(() => { if (copied === key) copied = null; }, 1500);
}
function openEntry(id: string, opener?: EventTarget | null) {
entryOpener = (opener instanceof HTMLElement ? opener : document.activeElement) as HTMLElement | null;
entry = id;
}
function closeEntry() {
entry = null;
entryOpener?.focus();
entryOpener = null;
}
function close() {
saveScroll();
onclose();
}
/** Escape closes the foremost layer only: the card entry before the desk. */
function onkeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
if (entry) { closeEntry(); return; }
close();
}
function n(v: unknown): string {
return Number(v ?? 0).toLocaleString();
} }
function hours(mins: number): string { function hours(mins: number): string {
return mins < 90 ? `${mins} minutes` : `${Math.round(mins / 6) / 10} hours`; return mins < 90 ? `${n(mins)} minutes` : `${n(Math.round(mins / 6) / 10)} hours`;
} }
let search = $state("");
// The playable pool: every card actually in the 6e game (base + Exp1). $effect(() => {
const pool = allCardDefs().filter( if (initialAnchor) void jumpTo(initialAnchor);
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0, else if (bodyEl) bodyEl.scrollTop = helpState.scroll[tab] ?? 0;
);
const filtered = $derived.by(() => {
const q = search.trim().toLowerCase();
const list = q
? pool.filter(
(d) => d.name.toLowerCase().includes(q) || (d.text ?? "").toLowerCase().includes(q),
)
: pool;
return [...list].sort((a, b) => a.name.localeCompare(b.name));
}); });
function onkeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
</script> </script>
<svelte:window {onkeydown} /> <svelte:window {onkeydown} />
{#if libPeek} {#if entryDef}
<div class="lib-peek-scrim" role="button" tabindex="-1" onclick={() => (libPeek = null)} onkeydown={() => {}}> <div class="layer-scrim" role="presentation" onclick={closeEntry}>
<div class="lib-peek"> <div class="entry" role="dialog" aria-modal="true" aria-label="{entryDef.name}, card entry" tabindex="-1"
<Card card={{ instanceId: `peek-${libPeek}`, cardId: libPeek }} onfaq={(id) => (faqCardId = id)} /> onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
<header class="entry-head">
<span class="entry-title">{entryDef.name}</span>
<button class="link-btn" onclick={() => copyLink("cards", null, entryDef!.id)} title="copy a link to this card">
{copied === entryDef.id ? "link copied" : "🔗 link"}</button>
<button class="close" onclick={closeEntry} aria-label="close card entry">×</button>
</header>
<div class="entry-body">
<div class="entry-card">
<Card card={{ instanceId: `entry-${entryDef.id}`, cardId: entryDef.id }} />
</div>
<div class="entry-text reading">
<p class="entry-meta">
{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>
{#if entryDef.faqRulings.length > 0}
<h3>Official FAQ</h3>
{#each entryDef.faqRulings as q, i (i)}<p>{q}</p>{/each}
<p class="colophon">Tom Jolly's own rulings (wizwar.com, September 2002). The cards overrule the rules, and the designer overrules the table.</p>
{/if}
{#if entryRulings.length > 0}
<h3>At this table</h3>
{#each entryRulings as r (r.id)}
{#each r.body as p, i (i)}<p>{p}</p>{/each}
<p class="colophon"><button class="inline-link" onclick={() => { closeEntry(); void goTab("rulings"); void jumpTo(`ruling-${r.id}`); }}>{r.title}, in the house rulings</button></p>
{/each}
{/if}
{#if entryMentions.length > 0}
<h3>Mentions</h3>
<p class="chips">
{#each entryMentions as m (m.id)}
<button class="chip" onclick={(e) => openEntry(m.id, e.currentTarget)}>{m.name}</button>
{/each}
</p>
{/if}
</div>
</div>
</div> </div>
</div> </div>
{/if} {/if}
{#if faqCardId} <div class="scrim" role="presentation" onclick={close}>
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} /> <div class="booklet" role="dialog" aria-modal="true" aria-label="help" tabindex="-1"
{/if} onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
<div
class="booklet"
role="dialog"
aria-modal="true"
aria-label="help"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={() => {}}
>
<header class="booklet-head"> <header class="booklet-head">
<span class="booklet-title">Wiz-War</span> <span class="booklet-title">Wiz-War</span>
<nav class="tabs"> <nav class="tabs" aria-label="help sections">
<button class:current={tab === "play"} onclick={() => (tab = "play")}>How to play</button> <button class:current={tab === "play"} onclick={() => goTab("play")}>How to play</button>
<button class:current={tab === "rules"} onclick={() => (tab = "rules")}>The rules</button> <button class:current={tab === "rules"} onclick={() => goTab("rules")}>The rules</button>
<button class:current={tab === "cards"} onclick={() => (tab = "cards")}>Card library</button> <button class:current={tab === "cards"} onclick={() => goTab("cards")}>Card library</button>
<button class:current={tab === "rulings"} onclick={() => (tab = "rulings")}>House rulings</button> <button class:current={tab === "rulings"} onclick={() => goTab("rulings")}>House rulings</button>
<button class:current={tab === "about"} onclick={() => (tab = "about")}>About</button> <button class:current={tab === "about"} onclick={() => goTab("about")}>About</button>
<button class:current={tab === "tally"} onclick={openTally}>The tally</button> <button class:current={tab === "tally"} onclick={() => goTab("tally")}>The tally</button>
</nav> </nav>
<button class="close" onclick={onclose} aria-label="close help">×</button> <button class="close" onclick={close} aria-label="close help">×</button>
</header> </header>
<div class="booklet-body"> <div class="booklet-body" bind:this={bodyEl}>
{#if tab === "tally"} {#if tab === "tally"}
<div class="tally"> <div class="reading">
<h3>How much love the maze is getting</h3> <h3>How much love the maze is getting</h3>
{#if stats} {#if stats}
<div class="stories">
<div class="story"><span class="big">{n(stats.gamesFinished)}</span><span>games fought to a finish</span></div>
<div class="story"><span class="big">{n(stats.winsByTreasure)}</span><span>won by carrying treasure home</span></div>
<div class="story"><span class="big">{n(stats.winsByLastStanding)}</span><span>won as the last wizard standing</span></div>
<div class="story"><span class="big">{hours(Number(stats.minutesAtTable))}</span><span>spent at the table</span></div>
</div>
<h3>The ledgers in detail</h3>
<dl class="tally-list"> <dl class="tally-list">
<dt>{stats.gamesCreated}</dt><dd>games chronicled — {stats.gamesStarted} begun, {stats.gamesFinished} fought to a finish</dd> <dt>{n(stats.gamesCreated)}</dt><dd>games chronicled — {n(stats.gamesStarted)} begun</dd>
<dt>{stats.wizardsSeated}</dt><dd>distinct wizards have taken a seat</dd> <dt>{n(stats.wizardsSeated)}</dt><dd>distinct wizard names have taken a seat</dd>
<dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd> <dt>{n(stats.commandsPlayed)}</dt><dd>spells, steps, and punches recorded</dd>
<dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</dd> <dt>{n(stats.longestGameCommands)}</dt><dd>actions in the longest game yet played</dd>
<dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd> <dt>{n(stats.fullestTable)}</dt><dd>wizards at the fullest table</dd>
<dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd> <dt>{n(stats.hotseatGames)}</dt><dd>games played on one device, passed around</dd>
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd> <dt>{n(stats.automatonGames ?? 0)}</dt><dd>games fought against the clockwork</dd>
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
<dt>{stats.automatonGames ?? 0}</dt><dd>games fought against the clockwork</dd>
</dl> </dl>
{#if stats.firstGameAt} <p class="colophon">
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p> {#if stats.firstGameAt}The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}.{/if}
{/if} Wizards are counted by name, not by person: one player under two names is two wizards here.
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.
</p>
{:else} {:else}
<p>Counting the ledgers…</p> <p>Counting the ledgers…</p>
{/if} {/if}
</div> </div>
{:else if tab === "about"} {:else if tab === "about"}
<div class="about"> <div class="reading about">
<h3>What this is</h3>
<p>
Wiz-War is a game of magical combat in a stone labyrinth: two to six
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
<strong>sixth edition</strong>, published by Chessex in 1993, together
with its one expansion — monsters and magic wands included.
</p>
<h3>Why it exists</h3> <h3>Why it exists</h3>
<p> <p>
In the early nineties, a group of friends played this exact edition In the early nineties, a group of friends played this exact edition
@@ -138,6 +239,16 @@
it was played at that table, made so the same friends — and their it was played at that table, made so the same friends — and their
friends — can keep playing it. friends — can keep playing it.
</p> </p>
<h3>What this is</h3>
<p>
Wiz-War is a game of magical combat in a stone labyrinth: two to six
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
<strong>sixth edition</strong>, published by Chessex in 1993, together
with its one expansion — monsters and magic wands included.
</p>
<h3>Whose game it is</h3> <h3>Whose game it is</h3>
<p> <p>
Wiz-War is Tom Jolly's design, and the rights to it now rest with Wiz-War is Tom Jolly's design, and the rights to it now rest with
@@ -151,22 +262,35 @@
somebody at a real table. somebody at a real table.
</p> </p>
<h3>Behind the curtain</h3> <h3>Behind the curtain</h3>
<p> <p>The workshops where this table's pieces are made are open to visitors.</p>
The workshops where this table's pieces are made are open to <div class="gallery">
visitors: <a class="tile" href="/?tokens">
the <a href="/?tokens">token workshop</a> shows every token in both <img src="/tokens/alter-ego.png" alt="" />
arts beside the wall textures and spell sprites; <span class="tile-name">The token workshop</span>
the <a href="/?fx">flourish workshop</a> plays each board effect on <span class="tile-note">every token in both arts, beside the wall textures and spell sprites</span>
demand; and </a>
the <a href="/?fpv">first-person workshop</a> walks the maze through <a class="tile" href="/?fx">
a wizard's own eyes (add <code>&demo=1</code> to watch a reel). <img src="/fx3d/fireball.png" alt="" />
</p> <span class="tile-name">The flourish workshop</span>
<span class="tile-note">each board effect, played on demand</span>
</a>
<a class="tile" href="/?fpv">
<img src="/hero.jpg" alt="" />
<span class="tile-name">The first-person workshop</span>
<span class="tile-note">the maze through a wizard's own eyes — or <span class="tile-link">watch the demo reel</span></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>
<h3>What the table remembers</h3> <h3>What the table remembers</h3>
<p> <p>
Every game here is recorded permanently, move by move table talk An online game is written to this table's ledger move by move, table
included — so finished games can be replayed, shared, and studied. talk included, and kept: a finished game can be replayed from above
Play under whatever name you like, but know that what you say and or through a wizard's own eyes, and any turn can be shared by link.
do at the table becomes part of its lasting record. 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
whatever name you like, and know that what you say and do at an
online table is part of its record.
</p> </p>
<h3>Send word</h3> <h3>Send word</h3>
<p> <p>
@@ -183,113 +307,139 @@
</p> </p>
</div> </div>
{:else if tab === "play"} {:else if tab === "play"}
{#each HOW_TO_PLAY as section (section.title)} <div class="reading">
<h3>{section.title}</h3> {#each HOW_TO_PLAY as section (section.title)}
{#each section.body as p, i (i)}<p>{p}</p>{/each} <h3 id="play-{section.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}">{section.title}</h3>
{/each} {#each section.body as p, i (i)}<p>{p}</p>{/each}
{:else if tab === "rulings"}
<p class="colophon">
For players who know the box. Where this table departs from the
cardboard, and the calls it makes where the rulebook is silent.
Every game is frozen at the revision it was dealt under, so an old
game replays exactly as it was played. If the table rules against
your memory of the rules, the 🐞 report button pins the moment
for review — the reply lands in your lobby.
</p>
<h3>What the digital table does differently</h3>
<p>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.</p>
<p>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.</p>
<p>An ambush (OPPORTUNITY FIRE) is set with the attack card it will
fire and a trigger of your choice: an opponent entering your line of
sight, coming within one square, or picking up any treasure. It
springs on their turn, out of yours.</p>
<p>BUTT-HEAD's ram 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; you cannot pad the blow by taking the
long way round, and the goat lands on the victim's square. There
is no ceiling. MAD DASH doubles the whole allowance, NUMBER cards
included, so a well-placed goat can ram for sixteen.</p>
<p>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.</p>
<p>Clockwork wizards — the automatons — play from the same redacted
view a human seat gets and obey every rule; a tier changes what
they draw and know, never how correctly they play.</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
watched from above or through a wizard's eyes and shared by link.</p>
<h3>Rulings by revision</h3>
<p class="colophon">Each revision below changed how something resolves; games dealt before it keep the old reading.</p>
<dl class="revisions">
{#each RULES_REVISIONS as r (r.rev)}
<dt>Rev {r.rev}</dt><dd>{r.note}</dd>
{/each} {/each}
</dl> </div>
<h3>Card rulings from the FAQ</h3> {:else if tab === "rulings"}
<p class="colophon">{ruledCards.length} cards carry rulings from the official FAQ; the engine follows them.</p> <div class="reading">
{#each ruledCards as c (c.id)} <p class="colophon">
<details class="ruling"> For players who know the box: how this table resolves what the
<summary>{c.name}</summary> cardboard leaves to the players, by card. If the table rules
{#each c.faqRulings as q, i (i)}<p>{q}</p>{/each} against your memory of the rules, the 🐞 report button pins the
</details> moment for review — the reply lands in your lobby.
{/each} </p>
<div class="search-row">
<input bind:value={rulingsSearch} placeholder="search the rulings by card or topic…" aria-label="search house rulings" />
</div>
{#if rulingsSearch.trim()}
<p class="count">{rulingResults.length === 0 ? `Nothing here mentions "${rulingsSearch.trim()}".` : `${rulingResults.length} of ${HOUSE_RULINGS.length} rulings`}</p>
{/if}
{#each rulingResults as r (r.id)}
<h3 id="ruling-{r.id}" class="linked">
{r.title}
<button class="link-btn" onclick={() => copyLink("rulings", `ruling-${r.id}`, null)} title="copy a link to this ruling">
{copied === `rulings/ruling-${r.id}` ? "copied" : "🔗"}</button>
</h3>
{#each r.body as p, i (i)}<p>{p}</p>{/each}
<p class="chips">
{#each r.cards as id (id)}
<button class="chip" onclick={(e) => openEntry(id, e.currentTarget)}>{cardDef(id).name}</button>
{/each}
</p>
{/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>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
watched from above or through a wizard's eyes and shared by link.</p>
{/if}
</div>
{:else if tab === "rules"} {:else if tab === "rules"}
<p class="colophon"> <div class="reading">
Sixth edition rules, from the original rulebook. © 1985 Jolly Games — <div class="search-row">
this digital adaptation is a fan project. <input bind:value={rulesSearch} placeholder="search the rules, the rulebook, the expansion, and the FAQ…" aria-label="search the rules" />
</p> </div>
{#each RULES_SECTIONS as section (section.title)} {#if rulesSearch.trim()}
<h3>{section.title}</h3> <p class="count">{refResults.length === 0 ? `Nothing in the rules mentions "${rulesSearch.trim()}".` : `${refResults.length} of ${REFERENCE.length} sections`}</p>
{#each section.body as p, i (i)}<p>{p}</p>{/each} {#each refResults as s (s.id)}
{/each} <h3 id={s.id} class="linked">
<h2 class="faq-divider">The rulebook, verbatim</h2> {s.title}
<p class="colophon"> <span class="source">{SOURCE_LABELS[s.source]}</span>
The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT} <button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
</p> {copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
{#each RULEBOOK_BASE as section (section.title)} </h3>
<h3>{section.title}</h3> {#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each} {/each}
{/each} {:else}
<h2 class="faq-divider">Expansion Set 1 — verbatim</h2> <nav class="contents" aria-label="contents">
{#each RULEBOOK_EXPANSION as section (section.title)} {#each refBySource as group (group.source)}
<h3>{section.title}</h3> <div class="contents-group">
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each} <span class="contents-head">{SOURCE_LABELS[group.source]}</span>
{/each} {#each group.sections as s (s.id)}
<h2 class="faq-divider">Official FAQ — general rulings</h2> <button class="contents-link" onclick={() => jumpTo(s.id)}>{s.title}</button>
<p class="colophon"> {/each}
Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings </div>
occasionally reference other editions. Card-specific rulings sit on the {/each}
cards themselves — look for the FAQ seal. </nav>
</p> {#each refBySource as group (group.source)}
{#each FAQ_GENERAL as section (section.title)} <h2 class="divider" id="source-{group.source}">{SOURCE_LABELS[group.source]}</h2>
<h3>{section.title}</h3> {#if group.source === "summary"}
{#each section.body as p, i (i)}<p>{p}</p>{/each} <p class="colophon">Sixth edition rules, condensed from the original rulebook. © 1985 Jolly Games — this digital adaptation is a fan project.</p>
{/each} {: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"}
<p class="colophon">Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings occasionally reference other editions. Card-specific rulings are on the cards themselves, in the library.</p>
{/if}
{#each group.sections as s (s.id)}
<h3 id={s.id} class="linked">
{s.title}
<button class="link-btn" onclick={() => copyLink("rules", s.id, null)} title="copy a link to this section">
{copied === `rules/${s.id}` ? "copied" : "🔗"}</button>
</h3>
{#each s.paragraphs as p, i (i)}<p>{@html renderInline(p)}</p>{/each}
{/each}
{/each}
{/if}
</div>
{:else} {:else}
<div class="search-row"> <div class="search-row">
<input <input bind:value={cardText} placeholder="search {CARD_POOL.length} cards by name or text…" aria-label="search cards" />
bind:value={search}
placeholder="search {pool.length} cards…"
aria-label="search cards"
/>
</div> </div>
<div class="filters">
<label>set
<select bind:value={cardSet}>
<option value="all">all</option>
<option value="basic">base deck</option>
<option value="expansion1">expansion</option>
</select>
</label>
<label>type
<select bind:value={cardType}>
<option value="all">all</option>
<option value="attack">attack</option>
<option value="neutral">neutral</option>
<option value="counteraction">counteraction</option>
<option value="number">number</option>
<option value="object">object</option>
<option value="trap">trap</option>
</select>
</label>
<label>target
<select bind:value={cardSight}>
<option value="all">any</option>
<option value="los">line of sight</option>
<option value="adjacent">adjacent</option>
<option value="none">no target</option>
</select>
</label>
<span class="count">{cardResults.length === 0 ? "No cards found." : `${cardResults.length} card${cardResults.length === 1 ? "" : "s"}`}</span>
</div>
{#if cardResults.length === 0}
<p class="colophon">No card matches {cardText.trim() ? `"${cardText.trim()}"` : "those filters"}. Try fewer words, or widen the filters.</p>
{/if}
<div class="card-grid"> <div class="card-grid">
{#each filtered as def (def.id)} {#each cardResults as def (def.id)}
<div class="card-slot"> <div class="card-slot">
<Card <Card
card={{ instanceId: `lib-${def.id}`, cardId: def.id }} card={{ instanceId: `lib-${def.id}`, cardId: def.id }}
onclick={() => (libPeek = def.id)} onclick={() => openEntry(def.id)}
onfaq={(id) => (faqCardId = id)} onfaq={() => openEntry(def.id)}
/> />
<span class="card-meta"> <span class="card-meta">
×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""} ×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""}
@@ -340,7 +490,7 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.tabs { display: flex; gap: 0.3rem; } .tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.tabs button { .tabs button {
font-family: "Oswald", sans-serif; font-family: "Oswald", sans-serif;
font-size: 0.72rem; font-size: 0.72rem;
@@ -368,6 +518,12 @@
line-height: 1; line-height: 1;
} }
.close:hover { color: #b3372b; } .close:hover { color: #b3372b; }
/* A narrow screen wraps the tabs to their own rows under the title and the close. */
@media (max-width: 640px) {
.booklet-head { gap: 0.5rem 1rem; }
.tabs { order: 3; flex-basis: 100%; }
.close { order: 2; }
}
.booklet-body { .booklet-body {
overflow-y: auto; overflow-y: auto;
@@ -376,7 +532,9 @@
font-size: 0.98rem; font-size: 0.98rem;
line-height: 1.5; line-height: 1.5;
} }
.faq-divider { /* Long reading gets a book's column: narrower, a touch larger. */
.reading { max-width: 44rem; margin: 0 auto; font-size: 1.04rem; line-height: 1.55; }
.divider {
font-family: "Oswald", sans-serif; font-family: "Oswald", sans-serif;
font-size: 1rem; font-size: 1rem;
letter-spacing: 0.1em; letter-spacing: 0.1em;
@@ -385,7 +543,7 @@
padding-top: 0.8rem; padding-top: 0.8rem;
margin: 1.6rem 0 0.2rem; margin: 1.6rem 0 0.2rem;
} }
.booklet-body h3 { .booklet-body h3, .entry-text h3 {
font-family: "Oswald", sans-serif; font-family: "Oswald", sans-serif;
font-size: 0.85rem; font-size: 0.85rem;
letter-spacing: 0.12em; letter-spacing: 0.12em;
@@ -394,18 +552,63 @@
padding-bottom: 0.15rem; padding-bottom: 0.15rem;
margin: 1.2rem 0 0.4rem; margin: 1.2rem 0 0.4rem;
} }
.booklet-body h3:first-child { margin-top: 0; } .reading > h3:first-child, .entry-text > h3:first-child { margin-top: 0; }
.revisions { margin: 0.4rem 0 1rem; } h3.linked { display: flex; align-items: baseline; gap: 0.5rem; scroll-margin-top: 0.5rem; }
.revisions dt { font-family: "Oswald", sans-serif; letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.75rem; margin-top: 0.6rem; } .source { font-size: 0.65rem; letter-spacing: 0.08em; color: #8a7a5e; border: 1px solid #c9bd9f; border-radius: 3px; padding: 0 0.3rem; text-transform: none; }
.revisions dd { margin: 0.1rem 0 0; } .link-btn {
.revisions dd::first-letter { text-transform: uppercase; } margin-left: auto;
.ruling { margin: 0.3rem 0; } background: none;
.ruling summary { cursor: pointer; font-weight: 600; } border: 1px solid transparent;
.ruling p { margin: 0.3rem 0 0.5rem 1rem; } border-radius: 3px;
color: #8a7a5e;
font-family: "Oswald", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
padding: 0.1rem 0.35rem;
opacity: 0.6;
}
h3:hover .link-btn, .link-btn:focus-visible, .entry-head .link-btn { opacity: 1; }
.link-btn:hover { border-color: #b3a687; color: #43331f; }
.booklet-body p { margin: 0.35rem 0; } .booklet-body p { margin: 0.35rem 0; }
.colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; } .colophon { font-style: italic; color: #6b5a41; font-size: 0.88rem; }
.booklet-body a { color: #8a4a1f; text-decoration-style: dotted; } .count { font-family: "Courier Prime", monospace; font-size: 0.8rem; color: #6b5a41; }
.booklet-body a:hover { color: #43331f; } .booklet-body a, .inline-link { color: #8a4a1f; text-decoration: underline; text-decoration-style: dotted; }
.booklet-body a:hover, .inline-link:hover { color: #43331f; }
.inline-link { background: none; border: none; padding: 0; font: inherit; cursor: pointer; }
.booklet-body :global(.ed-note) {
font-style: italic;
color: #5e4d33;
background: rgba(179, 166, 135, 0.22);
border-left: 2px solid #b3a687;
padding: 0 0.3rem;
}
.contents { columns: 2; column-gap: 2rem; margin: 0.6rem 0 0.4rem; font-size: 0.92rem; }
.contents-group { break-inside: avoid; margin-bottom: 0.7rem; }
.contents-head { display: block; font-family: "Oswald", sans-serif; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: #8a7a5e; margin-bottom: 0.15rem; }
.contents-link { display: block; background: none; border: none; padding: 0.05rem 0; font: inherit; color: #8a4a1f; cursor: pointer; text-align: left; }
.contents-link:hover { color: #43331f; text-decoration: underline; }
.chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.chip {
font-family: "Oswald", sans-serif;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: #e4dbc2;
border: 1px solid #b3a687;
border-radius: 3px;
color: #43331f;
padding: 0.15rem 0.45rem;
cursor: pointer;
}
.chip:hover { background: #d8cdae; }
.stories { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.8rem; margin: 0.6rem 0 1rem; }
.story { display: flex; flex-direction: column; align-items: center; text-align: center; background: #e4dbc2; border: 1px solid #b3a687; border-radius: 4px; padding: 0.6rem 0.5rem; font-size: 0.9rem; }
.big { font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.7rem; color: #b3372b; line-height: 1.1; }
.tally-list { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.8rem; margin: 0.6rem 0 1rem; } .tally-list { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.8rem; margin: 0.6rem 0 1rem; }
.tally-list dt { .tally-list dt {
font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem; font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem;
@@ -413,6 +616,14 @@
} }
.tally-list dd { margin: 0; align-self: center; } .tally-list dd { margin: 0; align-self: center; }
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 0.8rem; margin: 0.6rem 0; }
.tile { display: flex; flex-direction: column; gap: 0.25rem; background: #e4dbc2; border: 1px solid #b3a687; border-radius: 4px; padding: 0.5rem; text-decoration: none; color: inherit; }
.tile:hover { background: #d8cdae; }
.tile img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 3px; border: 1px solid #b3a687; background: #2a2622; }
.tile-name { font-family: "Oswald", sans-serif; font-size: 0.8rem; letter-spacing: 0.08em; text-transform: uppercase; }
.tile-note { font-size: 0.85rem; color: #6b5a41; }
.tile-link { text-decoration: underline; text-decoration-style: dotted; }
.search-row { margin-bottom: 0.8rem; } .search-row { margin-bottom: 0.8rem; }
.search-row input { .search-row input {
width: 100%; width: 100%;
@@ -425,6 +636,9 @@
font-size: 0.95rem; font-size: 0.95rem;
color: #43331f; color: #43331f;
} }
.filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; margin: -0.3rem 0 0.8rem; font-family: "Oswald", sans-serif; font-size: 0.68rem; letter-spacing: 0.1em; text-transform: uppercase; color: #6b5a41; }
.filters select { font-family: "Archivo Narrow", sans-serif; font-size: 0.9rem; text-transform: none; letter-spacing: 0; margin-left: 0.3rem; background: #f6f0df; border: 1px solid #b3a687; border-radius: 3px; color: #43331f; padding: 0.15rem 0.3rem; }
.filters .count { margin-left: auto; text-transform: none; letter-spacing: 0; }
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
@@ -432,25 +646,51 @@
justify-items: center; justify-items: center;
} }
.card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; } .card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
.card-slot :global(.card) { cursor: default; } .card-slot :global(.card:hover) { transform: translateY(-0.2rem); }
.card-slot :global(.card:hover) { transform: none; box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); } .card-meta {
.lib-peek-scrim { font-family: "Courier Prime", monospace;
font-size: 0.68rem;
color: #6b5a41;
}
/* The card entry: the familiar card beside reading-size text. */
.layer-scrim {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(10, 12, 16, 0.55); background: rgba(10, 12, 16, 0.55);
display: grid; display: grid;
place-items: center; place-items: center;
z-index: 55; z-index: 55;
padding: 1.5rem 1rem;
} }
.lib-peek :global(.card) { .entry {
transform: scale(2.1); background: #efe8d4;
cursor: default; color: #43331f;
width: min(46rem, 100%);
max-height: calc(100vh - 3rem);
border-radius: 6px;
border: 1px solid #b3a687;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7); box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
overflow: hidden;
} }
.lib-peek :global(.card:hover) { transform: scale(2.1); } .entry-head { display: flex; align-items: center; gap: 0.8rem; padding: 0.6rem 1rem; border-bottom: 2px solid #43331f; }
.card-meta { .entry-title { font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem; text-transform: uppercase; letter-spacing: 0.04em; }
font-family: "Courier Prime", monospace; .entry-head .link-btn { margin-left: 0; }
font-size: 0.68rem; .entry-body { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1.2rem; padding: 1rem 1.2rem 1.2rem; overflow-y: auto; font-family: "Archivo Narrow", sans-serif; }
color: #6b5a41; .entry-card { align-self: start; position: sticky; top: 0; }
.entry-card :global(.card) { transform: scale(1.35); transform-origin: top left; margin: 0 2.9rem 4rem 0; cursor: default; }
.entry-card :global(.card:hover) { transform: scale(1.35); box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
.entry-text { min-width: 0; }
.entry-text p { margin: 0.35rem 0; }
.entry-meta { font-family: "Courier Prime", monospace; font-size: 0.8rem; color: #6b5a41; margin: 0 0 0.4rem; }
.printed { font-size: 1.05rem; }
@media (max-width: 640px) {
.entry-body { grid-template-columns: 1fr; }
.entry-card { position: static; display: flex; justify-content: center; }
.entry-card :global(.card) { transform: none; margin: 0; }
.entry-card :global(.card:hover) { transform: none; }
.contents { columns: 1; }
} }
</style> </style>
-1
View File
@@ -34,7 +34,6 @@
stroke: #9d8cc1; stroke: #9d8cc1;
stroke-width: 0.4; stroke-width: 0.4;
stroke-linecap: round; stroke-linecap: round;
pointer-events: none;
animation: shimmer-drift 1.4s ease-in-out infinite; animation: shimmer-drift 1.4s ease-in-out infinite;
} }
.illusion-art { pointer-events: none; } .illusion-art { pointer-events: none; }
+24 -6
View File
@@ -7,6 +7,7 @@
// Every live event batch plays through the same fx pipeline the // Every live event batch plays through the same fx pipeline the
// instant replay uses, so spells, doors, and conjurations perform // instant replay uses, so spells, doors, and conjurations perform
// here first. // here first.
import { motion } from "./motion";
import FirstPerson from "./fpv/FirstPerson.svelte"; import FirstPerson from "./fpv/FirstPerson.svelte";
import type { FpvTarget } from "./fpv/raycast"; import type { FpvTarget } from "./fpv/raycast";
import { objectArt, tokenArt } from "./art"; import { objectArt, tokenArt } from "./art";
@@ -161,6 +162,8 @@
* still fire — your own magic always turns your head. */ * still fire — your own magic always turns your head. */
let manualUntil = 0; let manualUntil = 0;
let turning = false; let turning = false;
let turnRaf = 0;
onDestroy(() => cancelAnimationFrame(turnRaf));
/** Manual steering holds the director's idle aims off this long. */ /** Manual steering holds the director's idle aims off this long. */
const STEER_HOLD_MS = 4000; const STEER_HOLD_MS = 4000;
@@ -173,14 +176,15 @@
manualUntil = performance.now() + STEER_HOLD_MS; manualUntil = performance.now() + STEER_HOLD_MS;
const from = cam.facing; const from = cam.facing;
const to = from + dir * (Math.PI / 2); const to = from + dir * (Math.PI / 2);
if (motion.reduced) { cam.facing = to; turning = false; return; }
const t0 = performance.now(); const t0 = performance.now();
const tick = (now: number) => { const tick = (now: number) => {
const w = Math.min(1, (now - t0) / 180); const w = Math.min(1, (now - t0) / 180);
cam.facing = from + (to - from) * (smoothstep(w)); cam.facing = from + (to - from) * (smoothstep(w));
if (w < 1) requestAnimationFrame(tick); if (w < 1) turnRaf = requestAnimationFrame(tick);
else turning = false; else turning = false;
}; };
requestAnimationFrame(tick); turnRaf = requestAnimationFrame(tick);
} }
/** Pane clicks: a possessed mount claims them first — adjacent floor /** Pane clicks: a possessed mount claims them first — adjacent floor
* steps it, a same-square enemy takes its blow — and only the rider's * steps it, a same-square enemy takes its blow — and only the rider's
@@ -225,14 +229,23 @@
let actorPos = $state<Record<string, { x: number; y: number }>>({}); let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. ----------------- // --- The fx: each batch plays once, on its own beat. -----------------
// Track ONLY the batch: the server sends `events` then `state` back to // Track the batch and motion policy: the server sends `events` then `state` back to
// back, so a wider dependency set re-runs this within the same frame, // back, so a wider dependency set re-runs this within the same frame,
// before a zero-delay timer can fire. No per-run cleanup for the same // before a zero-delay timer can fire. No per-run cleanup for the same
// reason — timers die only with the component. // reason — timers are cleared when effects are disabled or the pane closes.
let playedBatch = 0; let playedBatch = 0;
const fxTimers = new Set<ReturnType<typeof setTimeout>>(); const fxTimers = new Set<ReturnType<typeof setTimeout>>();
$effect(() => { $effect(() => {
const enabled = motion.effects;
const b = batch; const b = batch;
if (!enabled) {
fxTimers.forEach(clearTimeout);
fxTimers.clear();
fpFx = [];
// Consume suppressed batches: enabling effects must not replay them.
if (b) playedBatch = b.n;
return;
}
if (!b) return; if (!b) return;
untrack(() => { untrack(() => {
if (b.n === playedBatch || !me) return; if (b.n === playedBatch || !me) return;
@@ -240,6 +253,7 @@
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) { for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
const starter = setTimeout(() => { const starter = setTimeout(() => {
fxTimers.delete(starter); fxTimers.delete(starter);
if (!motion.effects) return;
fpFx = [...fpFx, { ...fx, t0: performance.now() }]; fpFx = [...fpFx, { ...fx, t0: performance.now() }];
const ender = setTimeout(() => { const ender = setTimeout(() => {
fxTimers.delete(ender); fxTimers.delete(ender);
@@ -251,7 +265,7 @@
} }
}); });
}); });
onDestroy(() => fxTimers.forEach(clearTimeout)); onDestroy(() => { fxTimers.forEach(clearTimeout); fxTimers.clear(); });
// --- Other bodies glide between views. ------------------------------- // --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null; let prevView: GameView | null = null;
@@ -259,6 +273,7 @@
const v = view; const v = view;
const before = prevView; const before = prevView;
prevView = v; prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v || !me) return; if (!before || before === v || !me) return;
const moves = gatherGlides(before, v, povId); const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; } if (moves.length === 0) { actorPos = {}; return; }
@@ -283,6 +298,7 @@
// --- The camera: the director's ladder, live. ------------------------ // --- The camera: the director's ladder, live. ------------------------
let directedBatch = 0; let directedBatch = 0;
$effect(() => { $effect(() => {
const reduced = motion.reduced;
const b = batch; const b = batch;
const m = me; const m = me;
if (!m) { camReady = false; return; } if (!m) { camReady = false; return; }
@@ -385,7 +401,9 @@
// pointed your own head is where it stays, brick or no brick. // pointed your own head is where it stays, brick or no brick.
targetFacing = deepestFacing(v, tx, ty); targetFacing = deepestFacing(v, tx, ty);
} }
if (willCut) { // Under reduced motion every move is a cut: the director still picks
// where to look, and the eye lands there without the pan or the walk.
if (willCut || reduced) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing; cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true; camReady = true;
return; return;
+98 -10
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { motion } from "./motion";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import { wizardColor } from "./colors";
import FirstPerson from "./fpv/FirstPerson.svelte"; import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { humanize, spellName } from "./net.svelte"; import { humanize, spellName } from "./net.svelte";
@@ -8,8 +10,7 @@
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d"; import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast"; import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director"; import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director";
import { prefs } from "./prefs.svelte"; import { cardDef, isPermanentDuration, stackSightTrace, castSightTrace } from "@wizwar/engine";
import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
let { let {
@@ -19,6 +20,7 @@
pov = null, pov = null,
onshare = null, onshare = null,
endLabel = null, endLabel = null,
startAt = 0,
}: { }: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
onclose: () => void; onclose: () => void;
@@ -32,6 +34,8 @@
onshare?: (() => Promise<string>) | null; onshare?: (() => Promise<string>) | null;
/** What the leave button says once the reel has run out. */ /** What the leave button says once the reel has run out. */
endLabel?: string | null; endLabel?: string | null;
/** The step the reel opens on. */
startAt?: number;
} = $props(); } = $props();
/** The share button's little life: offer, mint, report. */ /** The share button's little life: offer, mint, report. */
@@ -49,11 +53,24 @@
setTimeout(() => (shareState = "idle"), 4000); setTimeout(() => (shareState = "idle"), 4000);
} }
let idx = $state(0); let idx = $state(untrack(() => Math.min(Math.max(0, startAt), Math.max(0, steps.length - 1))));
/** The timeline: one tick a step, a turn's first step marked, and the
* steps where blows landed or gold changed hands flagged, so a reel of
* a hundred moves can be read at a glance and jumped into. */
const ticks = $derived(steps.map((s, i) => {
let flag: "turn" | "hit" | "gold" | "end" | null = null;
for (const e of s.events) {
if (e.type === "gameWon" || e.type === "playerEliminated") { flag = "end"; break; }
if (e.type === "treasurePickedUp" || (e.type === "treasureDropped" && e.onHomeOf != null)) flag = "gold";
else if (!flag && (e.type === "damaged" || e.type === "attackResolved" || e.type === "punched")) flag = "hit";
else if (!flag && e.type === "turnStarted") flag = "turn";
}
return { i, actor: s.actor, flag, color: wizardColor(s.view, s.actor) };
}));
let playing = $state(true); let playing = $state(true);
let speed = $state(1); let speed = $state(1);
/** Watch the board from above, or relive it through your own eyes. */ /** Watch the board from above, or relive it through your own eyes. */
let fp = $state(moment); let fp = $state(untrack(() => moment));
/** Whose eyes the first-person camera wears: the wizard whose turn it /** Whose eyes the first-person camera wears: the wizard whose turn it
* is, ALWAYS — their position seen with the viewer's knowledge of the * is, ALWAYS — their position seen with the viewer's knowledge of the
@@ -90,12 +107,12 @@
const dieOf = (st: { events: GameEvent[] } | undefined) => const dieOf = (st: { events: GameEvent[] } | undefined) =>
st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled"); st?.events.find((e): e is Extract<GameEvent, { type: "dieRolled" }> => e.type === "dieRolled");
/** How long this step's interlude holds the world before it moves. */ /** How long this step's interlude holds the world before it moves. */
const dieMs = $derived(dieOf(step) && prefs.flourishes ? DIE_MS / speed : 0); const dieMs = $derived(dieOf(step) && motion.effects ? DIE_MS / speed : 0);
let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null); let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null);
$effect(() => { $effect(() => {
const i = Math.min(idx, steps.length - 1); const i = Math.min(idx, steps.length - 1);
const d = dieOf(steps[i]); const d = dieOf(steps[i]);
if (!d || !prefs.flourishes) { die = null; return; } if (!d || !motion.effects) { die = null; return; }
const t0 = performance.now(); const t0 = performance.now();
const dur = DIE_MS / speed; const dur = DIE_MS / speed;
const who = d.player ?? "The maze"; const who = d.player ?? "The maze";
@@ -169,6 +186,7 @@
$effect(() => { $effect(() => {
const who = povId; const who = povId;
if (!fp) { lastPov = null; return; } if (!fp) { lastPov = null; return; }
if (!motion.effects) { lastPov = who; return; }
if (lastPov !== null && lastPov !== who) { if (lastPov !== null && lastPov !== who) {
const p = step.view.players.find((x) => x.id === who); const p = step.view.players.find((x) => x.id === who);
const fx: FpFx = { const fx: FpFx = {
@@ -179,14 +197,26 @@
fpFx = [...untrack(() => fpFx), fx]; fpFx = [...untrack(() => fpFx), fx];
const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80); const t = setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80);
lastPov = who; lastPov = who;
return () => clearTimeout(t); return () => { clearTimeout(t); fpFx = fpFx.filter((f) => f.id !== fx.id); };
} }
lastPov = who; lastPov = who;
}); });
// The reel draws the same sight line the live table shows for an LOS // The reel draws the same sight line the live table shows for an LOS
// attack in progress, so a replay-watcher can see how a spell reached them. // attack in progress, so a replay-watcher can see how a spell reached them.
const sightTrace = $derived(stackSightTrace(step.view)); /** The attack's line, or the line a cast in this step was accepted on —
* traced after the fact, so the cast's own dust cloud does not blind it. */
const sightTrace = $derived.by(() => {
const onStack = stackSightTrace(step.view);
if (onStack) return onStack;
for (let i = step.events.length - 1; i >= 0; i--) {
const e = step.events[i]!;
if (e.type !== "spellCast") continue;
const line = castSightTrace(step.view, e, step.events, true);
if (line) return line;
}
return null;
});
/** Every wall destroyed so far in the reel leaves a mound of rubble on /** Every wall destroyed so far in the reel leaves a mound of rubble on
* the first-person floor for the rest of it. */ * the first-person floor for the rest of it. */
@@ -210,7 +240,7 @@
let boardFx = $state<BoardFx[]>([]); let boardFx = $state<BoardFx[]>([]);
$effect(() => { $effect(() => {
const step = steps[Math.min(idx, steps.length - 1)]; const step = steps[Math.min(idx, steps.length - 1)];
if (!step || !prefs.flourishes) return; if (!step || !motion.effects) return;
let cancel = () => {}; let cancel = () => {};
const wait = setTimeout(() => { const wait = setTimeout(() => {
cancel = scheduleFx( cancel = scheduleFx(
@@ -237,7 +267,7 @@
$effect(() => { $effect(() => {
const i = Math.min(idx, steps.length - 1); const i = Math.min(idx, steps.length - 1);
const st = steps[i]; const st = steps[i];
if (!fp || !st || !prefs.flourishes) return; if (!fp || !st || !motion.effects) return;
const plan = camPlan; const plan = camPlan;
if (plan?.idx !== i) return; // the camera has not set this step's slate yet if (plan?.idx !== i) return; // the camera has not set this step's slate yet
const timers: ReturnType<typeof setTimeout>[] = []; const timers: ReturnType<typeof setTimeout>[] = [];
@@ -280,6 +310,7 @@
const v = st.view; const v = st.view;
const before = prevView; const before = prevView;
prevView = v; prevView = v;
if (motion.reduced) { actorPos = {}; return; }
if (!before || before === v) return; if (!before || before === v) return;
const moves = gatherGlides(before, v, povId); const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; } if (moves.length === 0) { actorPos = {}; return; }
@@ -316,6 +347,7 @@
"treasurePickedUp", "treasureDropped", "objectDropped"]); "treasurePickedUp", "treasureDropped", "objectDropped"]);
let camReady = false; let camReady = false;
$effect(() => { $effect(() => {
const reduced = motion.reduced;
if (!fp) { camReady = false; return; } if (!fp) { camReady = false; return; }
const v = step.view; const v = step.view;
const me = v.players.find((p) => p.id === povId); const me = v.players.find((p) => p.id === povId);
@@ -360,6 +392,7 @@
cutawayShot = true; cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1); const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null; const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
if (reduced) { heldView = null; camPlan = { idx: i2, holdMs: 0 }; return; }
heldView = pv; heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 }; camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 };
if (pv) { if (pv) {
@@ -483,6 +516,13 @@
} }
return () => {}; return () => {};
}; };
if (reduced) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing + closingArc; cam.pitch = targetPitch;
camReady = true;
heldView = null;
camPlan = { idx: stepIdx, holdMs: 0 };
return;
}
if (willCut) { if (willCut) {
// First frame, or a leap the legs cannot explain: cut. An aimed // First frame, or a leap the legs cannot explain: cut. An aimed
// cut still holds a beat of the before-world once the reel is // cut still holds a beat of the before-world once the reel is
@@ -725,6 +765,15 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if steps.length > 3}
<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"}` : ""}`}
onclick={() => { playing = false; idx = t.i; }} aria-label={`jump to move ${t.i + 1}`}></button>
{/each}
</div>
{/if}
<div class="replay-controls"> <div class="replay-controls">
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button> <button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button>
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}> <button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
@@ -749,10 +798,12 @@
padding: 1rem; padding: 1rem;
} }
.replay { .replay {
box-sizing: border-box;
background: #171a20; background: #171a20;
border: 1px solid rgba(233, 225, 203, 0.25); border: 1px solid rgba(233, 225, 203, 0.25);
border-radius: 8px; border-radius: 8px;
width: min(46rem, 100%); width: min(46rem, 100%);
max-width: 100%;
max-height: calc(100dvh - 2rem); max-height: calc(100dvh - 2rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -908,6 +959,29 @@
opacity: 0.7; opacity: 0.7;
} }
.replay-controls .speed.current { opacity: 1; text-decoration: underline; } .replay-controls .speed.current { opacity: 1; text-decoration: underline; }
.timeline {
display: flex;
align-items: flex-end;
gap: 1px;
height: 14px;
margin: 0.4rem 0.2rem 0.2rem;
}
.tick {
flex: 1 1 0;
min-width: 2px;
height: 6px;
padding: 0;
border: 0;
border-radius: 1px;
background: var(--who, #8d8672);
opacity: 0.45;
cursor: pointer;
}
.tick.turn { height: 9px; opacity: 0.7; }
.tick.hit { height: 12px; opacity: 0.9; }
.tick.gold { height: 14px; opacity: 1; box-shadow: 0 0 0 1px #e0b34a; }
.tick.end { height: 14px; opacity: 1; background: #e9e1cb; }
.tick.current { opacity: 1; outline: 1px solid #fff; outline-offset: 1px; }
.replay-controls { .replay-controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -925,4 +999,18 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
.playpause { min-width: 3.4rem; } .playpause { min-width: 3.4rem; }
/* A phone: the title takes its own line, the count and the buttons
share the next, the caption reads smaller, and the controls fit. */
@media (max-width: 640px) {
.replay { padding: 0.6rem 0.6rem 0.8rem; }
.replay-head { flex-wrap: wrap; gap: 0.4rem 0.6rem; }
.replay-title { flex-basis: 100%; font-size: 0.72rem; letter-spacing: 0.1em; }
.replay-count { font-size: 0.72rem; }
.replay-eyes { white-space: nowrap; }
.replay-caption { font-size: 0.72rem; padding: 0.45rem 0.55rem; gap: 0.45rem; }
.caption-face { width: 36px; height: 36px; }
.caption-status { max-width: 45%; }
.replay-controls { gap: 0.35rem; flex-wrap: wrap; }
.replay-controls button { min-width: 2.3rem; padding: 0.3rem 0.45rem; font-size: 0.85rem; }
}
</style> </style>
+21 -2
View File
@@ -15,6 +15,8 @@
actor: string; actor: string;
round: number; round: number;
whole?: boolean; whole?: boolean;
/** The turn's deed in one line, as the share card says it. */
headline?: string | null;
} }
let data = $state<ShareSteps | null>(null); let data = $state<ShareSteps | null>(null);
let failed = $state(false); let failed = $state(false);
@@ -32,7 +34,14 @@
<a class="share-brand" href="/">WIZ-WAR</a> <a class="share-brand" href="/">WIZ-WAR</a>
{#if data} {#if data}
<div class="share-title"> <div class="share-title">
{#if data.whole}The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}{:else}Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}{/if} {#if data.whole}
<span class="share-deed">The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}</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>
{/if}
</div> </div>
{/if} {/if}
</header> </header>
@@ -51,6 +60,9 @@
onclose={() => (reelKey += 1)} /> onclose={() => (reelKey += 1)} />
{/key} {/key}
</div> </div>
<div class="share-play">
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
</div>
{/if} {/if}
<footer class="share-foot"> <footer class="share-foot">
@@ -61,12 +73,15 @@
transcribed, every wall verified, replays rebuilt move by move from transcribed, every wall verified, replays rebuilt move by move from
the game's own ledger. the game's own ledger.
</p> </p>
<a class="share-cta" href="/">▶ play free — two to six wizards enter the maze</a>
</footer> </footer>
</div> </div>
<style> <style>
.share-page { .share-page {
/* Safari's text inflation has no business here: the page is laid
out for the phone already. */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
min-height: 100vh; min-height: 100vh;
background: #171a20; background: #171a20;
color: #d8d2c0; color: #d8d2c0;
@@ -79,6 +94,7 @@
.share-head { .share-head {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
flex-wrap: wrap;
gap: 1rem; gap: 1rem;
width: min(46rem, 100%); width: min(46rem, 100%);
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
@@ -97,6 +113,9 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #e9e1cb; color: #e9e1cb;
} }
.share-deed { display: block; }
.share-sub { display: block; text-transform: none; letter-spacing: 0.02em; color: #8d8672; font-size: 0.8rem; }
.share-play { width: min(46rem, 100%); margin-top: 0.8rem; text-align: center; }
/* The reel is a modal everywhere else; here it stands in the page. */ /* The reel is a modal everywhere else; here it stands in the page. */
.share-stage { width: min(46rem, 100%); } .share-stage { width: min(46rem, 100%); }
.share-stage :global(.replay-scrim) { .share-stage :global(.replay-scrim) {
+5 -5
View File
@@ -9,23 +9,23 @@ export const edgeWisps = (x1: number, y1: number, x2: number, y2: number) => {
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset); const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return [0.06, 0.38, 0.71].map((t, i) => { return [0.06, 0.38, 0.71].map((t, i) => {
const sign = i === 1 ? -1 : 1; const sign = i === 1 ? -1 : 1;
return `M ${p(t)} C ${p(t+.07,-3*sign)} ${p(t+.2,-2.5*sign)} ${p(t+.23,.5*sign)} Q ${p(t+.18,-.6*sign)} ${p(t+.15,1.2*sign)} Q ${p(t+.12,-1.6*sign)} ${p(t)}`; return `M ${p(t)} C ${p(t+0.07,-3*sign)} ${p(t+0.2,-2.5*sign)} ${p(t+0.23,0.5*sign)} Q ${p(t+0.18,-0.6*sign)} ${p(t+0.15,1.2*sign)} Q ${p(t+0.12,-1.6*sign)} ${p(t)}`;
}).join(' '); }).join(" ");
}; };
/** An irregular fracture with branches that become longer as damage increases. */ /** An irregular fracture with branches that become longer as damage increases. */
export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => { export const edgeScar = (x1: number, y1: number, x2: number, y2: number, strength: number) => {
const width = 1.2 + Math.max(0, Math.min(1, strength)) * 1.4; const width = 1.2 + Math.max(0, Math.min(1, strength)) * 1.4;
const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset); const p = (t: number, offset = 0) => along(x1, y1, x2, y2, t, offset);
return `M ${p(.03)} L ${p(.18,-width)} ${p(.34,.6*width)} ${p(.51,-.5*width)} ${p(.7,width)} ${p(.86,-.6*width)} ${p(.98)} M ${p(.34,.6*width)} L ${p(.4,1.25*width)} ${p(.47,1.4*width)} M ${p(.7,width)} L ${p(.64,-1.1*width)} ${p(.57,-1.35*width)}`; return `M ${p(0.03)} L ${p(0.18,-width)} ${p(0.34,0.6*width)} ${p(0.51,-0.5*width)} ${p(0.7,width)} ${p(0.86,-0.6*width)} ${p(0.98)} M ${p(0.34,0.6*width)} L ${p(0.4,1.25*width)} ${p(0.47,1.4*width)} M ${p(0.7,width)} L ${p(0.64,-1.1*width)} ${p(0.57,-1.35*width)}`;
}; };
/** Silk spokes and two scalloped cross-threads, with a clear center for the token. */ /** Silk spokes and two scalloped cross-threads, with a clear center for the token. */
export const webThreads = (r: number) => { export const webThreads = (r: number) => {
const p = (x: number, y: number) => `${x*r} ${y*r}`; const p = (x: number, y: number) => `${x*r} ${y*r}`;
return `M ${p(-1,-.6)} L ${p(1,.65)} M ${p(-.75,1)} L ${p(.65,-1)} M ${p(-1,.6)} L ${p(1,-.4)} M ${p(-.15,-1)} L ${p(.18,1)} M ${p(-.75,-.45)} Q ${p(-.15,-.2)} ${p(.4,-.62)} Q ${p(.3,-.15)} ${p(.72,-.28)} M ${p(-.65,.39)} Q ${p(-.25,.1)} ${p(-.47,.63)} Q ${p(0,.4)} ${p(.12,.7)} Q ${p(.35,.35)} ${p(.72,.46)}`; return `M ${p(-1,-0.6)} L ${p(1,0.65)} M ${p(-0.75,1)} L ${p(0.65,-1)} M ${p(-1,0.6)} L ${p(1,-0.4)} M ${p(-0.15,-1)} L ${p(0.18,1)} M ${p(-0.75,-0.45)} Q ${p(-0.15,-0.2)} ${p(0.4,-0.62)} Q ${p(0.3,-0.15)} ${p(0.72,-0.28)} M ${p(-0.65,0.39)} Q ${p(-0.25,0.1)} ${p(-0.47,0.63)} Q ${p(0,0.4)} ${p(0.12,0.7)} Q ${p(0.35,0.35)} ${p(0.72,0.46)}`;
}; };
/** Open painted curls around a warp's precise center. */ /** Open painted curls around a warp's precise center. */
export const warpCurls = (x: number, y: number, r: number) => export const warpCurls = (x: number, y: number, r: number) =>
`M ${x-r} ${y+r*.15} C ${x-r*1.1} ${y-r*.75} ${x+r*.4} ${y-r*1.2} ${x+r*.72} ${y-r*.4} Q ${x+r*.05} ${y-r*.85} ${x-r*.58} ${y-r*.25} M ${x+r} ${y-r*.1} C ${x+r*.95} ${y+r*.8} ${x-r*.3} ${y+r*1.18} ${x-r*.7} ${y+r*.45} Q ${x-r*.1} ${y+r*.85} ${x+r*.6} ${y+r*.25}`; `M ${x-r} ${y+r*0.15} C ${x-r*1.1} ${y-r*0.75} ${x+r*0.4} ${y-r*1.2} ${x+r*0.72} ${y-r*0.4} Q ${x+r*0.05} ${y-r*0.85} ${x-r*0.58} ${y-r*0.25} M ${x+r} ${y-r*0.1} C ${x+r*0.95} ${y+r*0.8} ${x-r*0.3} ${y+r*1.18} ${x-r*0.7} ${y+r*0.45} Q ${x-r*0.1} ${y+r*0.85} ${x+r*0.6} ${y+r*0.25}`;
+109
View File
@@ -0,0 +1,109 @@
// What happened while you were away, as a few facts before the reel:
// read from the missed steps as the server redacted them for you, so it
// tells you only what you would have seen at the table.
import { cardDef, type GameEvent, type GameView } from "@wizwar/engine";
export interface MissedStep {
seq: number;
actor: string;
events: GameEvent[];
view: GameView;
chat?: { player: string; text: string }[];
}
export interface MissedFact {
text: string;
/** The step of the reel this fact belongs to. */
step: number;
}
function spell(id: string | null): string {
if (!id) return "a punch";
try { return cardDef(id).name; } catch { return id; }
}
function nearHome(view: GameView, you: string, cell: { x: number; y: number }): boolean {
const me = view.players.find((p) => p.id === you);
if (!me) return false;
return Math.abs(me.home.x - cell.x) + Math.abs(me.home.y - cell.y) <= 2;
}
/** The facts of the missed steps that touch you, in order, capped so the
* slip stays a slip. Each fact points at its step for the reel. */
export function summarizeMissed(steps: readonly MissedStep[], you: string, now: GameView | null): MissedFact[] {
const facts: MissedFact[] = [];
let lifeLost = 0;
let lifeLostStep = -1;
let talk = 0;
steps.forEach((s, i) => {
talk += s.chat?.length ?? 0;
for (const e of s.events) {
switch (e.type) {
case "treasurePickedUp":
if (e.owner === you && e.player !== you) facts.push({ text: `${e.player} took your treasure.`, step: i });
else if (e.player !== you && e.owner !== e.player) facts.push({ text: `${e.player} picked up ${e.owner}'s treasure.`, step: i });
break;
case "treasureDropped": {
const owner = s.view.treasures.find((t) => t.id === e.treasureId)?.owner;
if (e.onHomeOf != null && owner && e.onHomeOf !== owner) {
facts.push({ text: owner === you ? `Your treasure was carried home by ${e.player}.` : `${e.player} carried ${owner}'s treasure home.`, step: i });
}
break;
}
case "damaged":
if (e.player === you && e.amount > 0) { lifeLost += e.amount; if (lifeLostStep < 0) lifeLostStep = i; }
break;
case "spellCast":
if (e.target === you) facts.push({ text: `${e.caster} cast ${spell(e.cardId)} at you.`, step: i });
break;
case "punched":
if (e.target === you) facts.push({ text: `${e.attacker} punched you.`, step: i });
break;
case "wallDestroyed":
if (nearHome(s.view, you, e.edge.cell)) facts.push({ text: "A wall near your home was destroyed.", step: i });
break;
case "playerEliminated":
facts.push({ text: e.player === you ? "You were eliminated." : `${e.player} is out of the game.`, step: i });
break;
case "gameWon":
facts.push({ text: e.player === you ? "You won." : `${e.player} won the game.`, step: i });
break;
case "tableTalk":
talk++;
break;
default:
break;
}
}
});
if (lifeLost > 0) facts.splice(Math.min(facts.length, 1), 0, { text: `You lost ${lifeLost} life.`, step: lifeLostStep });
if (facts.length === 0) {
// Nothing touched you: say what each wizard was up to, all the same.
const doings = new Map<string, { moves: number; casts: number; first: number }>();
steps.forEach((s, i) => {
if (s.actor === you) return;
const d = doings.get(s.actor) ?? { moves: 0, casts: 0, first: i };
for (const e of s.events) {
if (e.type === "moved" && e.player === s.actor) d.moves++;
if (e.type === "spellCast" && e.caster === s.actor) d.casts++;
}
doings.set(s.actor, d);
});
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 });
}
}
if (talk > 0) facts.push({ text: `${talk} line${talk === 1 ? "" : "s"} of table talk.`, step: -1 });
const out = facts.slice(0, 6);
if (facts.length > 6) out.push({ text: `…and ${facts.length - 6} more.`, step: -1 });
if (now) {
const me = now.players.find((p) => p.id === you);
if (now.phase === "playing" && me?.alive) {
const mine = now.activePlayerId === you || now.stack?.waitingOn === you || now.pendingDiscard === you;
out.push({ text: mine ? "It's your turn." : `It's ${now.activePlayerId}'s turn.`, step: -1 });
}
}
return out;
}
+24 -25
View File
@@ -3,8 +3,9 @@
// GameView. Columns of wall shaded by distance and facing; token art // GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column // billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote. // by the same depth buffer the walls wrote.
import { motion } from "../motion";
import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast"; import { billboards, castRay, warpMotion, type Billboard, type FpvTarget } from "./raycast";
import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing } from "./paintedFx"; import { paintFist, paintPow, punchPose, paintWeb, paintFloorRing as floorRing } from "./paintedFx";
import { materialTextures } from "./textures"; import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d"; import { doorOpenness, fxFallback, growProgress, slateBand, surgeIntensity, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d";
@@ -197,6 +198,10 @@
} }
function draw(time: number) { function draw(time: number) {
// With effects off the frame still draws — walls, bodies, hazards —
// but nothing moves: no fx, and the ambient pulses freeze at t=0.
const activeFx = motion.effects ? fx : [];
const ambientTime = motion.effects ? time : 0;
const ctx = canvas?.getContext("2d"); const ctx = canvas?.getContext("2d");
if (!ctx) return; if (!ctx) return;
ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew
@@ -207,7 +212,7 @@
// Active shakes wobble the eye a hair off true, fading as they run. // Active shakes wobble the eye a hair off true, fading as they run.
let ex = x, ey = y; let ex = x, ey = y;
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "shake") continue; if (f.kind !== "shake") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -221,7 +226,7 @@
let growing: Record<string, number> | undefined; let growing: Record<string, number> | undefined;
const spawn: Record<string, number> = {}; const spawn: Record<string, number> = {};
const surging: Record<number, number> = {}; const surging: Record<number, number> = {};
for (const f of fx) { for (const f of activeFx) {
if (f.kind === "door") { if (f.kind === "door") {
const a = doorOpenness((time - f.t0) / f.dur); const a = doorOpenness((time - f.t0) / f.dur);
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a); if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
@@ -321,7 +326,7 @@
// FEAR's aura stains the forbidden ground itself, as the board draws // FEAR's aura stains the forbidden ground itself, as the board draws
// its dashed diamond — the same warp-walking yardstick behind both. // its dashed diamond — the same warp-walking yardstick behind both.
const dreadGrid = dreadGridOf(view); const dreadGrid = dreadGridOf(view);
const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(time / 400) : 0; const dreadBlend = dreadGrid ? 0.16 + 0.07 * Math.sin(ambientTime / 400) : 0;
for (let row = 0; row < H; row++) { for (let row = 0; row < H; row++) {
const below = row > half; const below = row > half;
const dz = below ? row - half : half - row; const dz = below ? row - half : half - row;
@@ -440,7 +445,7 @@
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45)); let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8; if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
if (hit.kind === "firewall") { if (hit.kind === "firewall") {
const flicker = 0.15 + 0.15 * Math.sin(time / 90 + hit.worldU * 17 + col * 0.15); const flicker = 0.15 + 0.15 * Math.sin(ambientTime / 90 + hit.worldU * 17 + col * 0.15);
ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`; ctx.fillStyle = `rgba(255,140,40,${Math.max(0, flicker)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
dark *= 0.5; // the fire lights itself dark *= 0.5; // the fire lights itself
@@ -448,13 +453,13 @@
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") { if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
// The same tell the board gives: an untested illusion's face // The same tell the board gives: an untested illusion's face
// shimmers faintly — maybe stone, maybe not. // shimmers faintly — maybe stone, maybe not.
const tell = 0.06 + 0.05 * Math.sin(time / 260 + hit.worldU * 13); const tell = 0.06 + 0.05 * Math.sin(ambientTime / 260 + hit.worldU * 13);
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`; ctx.fillStyle = `rgba(190,160,255,${Math.max(0, tell)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") { if (hit.kind === "door" && hit.edge && view.doorStates[hit.edge] === "jammed") {
// A jammed lock seethes: a rusty seal pulsing across the wood. // A jammed lock seethes: a rusty seal pulsing across the wood.
const seethe = 0.10 + 0.08 * Math.sin(time / 160 + hit.worldU * 22); const seethe = 0.10 + 0.08 * Math.sin(ambientTime / 160 + hit.worldU * 22);
ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`; ctx.fillStyle = `rgba(210,70,20,${Math.max(0, seethe)})`;
ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3); ctx.fillRect(col, top + wallH * 0.35, 1, wallH * 0.3);
} }
@@ -464,13 +469,13 @@
} }
if (hit.warped) { if (hit.warped) {
// Seen through a warp: the far side swims in violet haze. // Seen through a warp: the far side swims in violet haze.
const haze = 0.16 + 0.05 * Math.sin(time / 300 + col * 0.05); const haze = 0.16 + 0.05 * Math.sin(ambientTime / 300 + col * 0.05);
ctx.fillStyle = `rgba(120,70,220,${haze})`; ctx.fillStyle = `rgba(120,70,220,${haze})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) { if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
// The raised edge-card's invitation: faces breathe gold. // The raised edge-card's invitation: faces breathe gold.
const offer = 0.10 + 0.05 * Math.sin(time / 420 + hit.worldU * 3); const offer = 0.10 + 0.05 * Math.sin(ambientTime / 420 + hit.worldU * 3);
ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`; ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
@@ -487,7 +492,7 @@
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey); const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" }); if (s) sprites.push({ ...s, fallback: "rubble" });
} }
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "projectile" && f.kind !== "impact") continue; if (f.kind !== "projectile" && f.kind !== "impact") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -545,13 +550,13 @@
const w = s.right - s.left; const w = s.right - s.left;
if (seen && s.dread && s.hit) { if (seen && s.dread && s.hit) {
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155), floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155),
"160,30,30", 0.45 + 0.25 * Math.sin(time / 300), 0.12); "160,30,30", 0.45 + 0.25 * Math.sin(ambientTime / 300), 0.12);
} }
if (seen && ontarget && aimBeings && litCells && s.hit && s.cell && if (seen && ontarget && aimBeings && litCells && s.hit && s.cell &&
litCells.has(`${s.cell.x},${s.cell.y}`) && litCells.has(`${s.cell.x},${s.cell.y}`) &&
!(s.hit.kind === "player" && s.hit.id === view.you)) { !(s.hit.kind === "player" && s.hit.id === view.you)) {
floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13), floorRing(ctx, (s.left + s.right) / 2, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13),
"232,160,60", 0.55 + 0.25 * Math.sin(time / 220), 0.16); "232,160,60", 0.55 + 0.25 * Math.sin(ambientTime / 220), 0.16);
} }
// Painted art composites normally (inks stay true); light glows // Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies. // additively. Either way translucency applies.
@@ -607,7 +612,7 @@
const vTop = half - vh / 2; const vTop = half - vh / 2;
const wt = textures.warp!; const wt = textures.warp!;
const s = surging[vl.warp] ?? 0; const s = surging[vl.warp] ?? 0;
const swirl = 0.1 + 0.1 * Math.sin(time / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02); const swirl = 0.1 + 0.1 * Math.sin(ambientTime / (240 - 170 * s) + vl.u * 9 + vl.col * 0.02);
// The ripple: a ring of light widening from the veil's center over // The ripple: a ring of light widening from the veil's center over
// the surge, one full sweep to the edges; ~a fifth of the veil wide. // the surge, one full sweep to the edges; ~a fifth of the veil wide.
const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0; const ring = s > 0 ? Math.max(0, 1 - Math.abs(Math.abs(vl.u - 0.5) * 2 - (1 - s)) / 0.2) : 0;
@@ -636,7 +641,7 @@
const top0 = half + full / 2 - grown; const top0 = half + full / 2 - grown;
const tex = textures[ri.kind] ?? textures.wall!; const tex = textures[ri.kind] ?? textures.wall!;
const swirl = (0.35 * (1 - ri.g) + 0.08) * const swirl = (0.35 * (1 - ri.g) + 0.08) *
(0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21)); (0.7 + 0.3 * Math.sin(ambientTime / 90 + ri.worldU * 21));
for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) { for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) {
ctx.globalAlpha = 0.55 + 0.45 * ri.g; ctx.globalAlpha = 0.55 + 0.45 * ri.g;
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width), ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width),
@@ -674,7 +679,7 @@
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05)); const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
const gTop = half - gHft / 2; const gTop = half - gHft / 2;
const tex = textures.wall!; const tex = textures.wall!;
const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01); const ripple = 0.10 + 0.07 * Math.sin(ambientTime / 200 + gh.worldU * 11 + gHft * 0.01);
for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) { for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) {
ctx.globalAlpha = 0.2; ctx.globalAlpha = 0.2;
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
@@ -695,7 +700,7 @@
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
// Being hit is felt: a whole-screen wash that fades as it goes. // Being hit is felt: a whole-screen wash that fades as it goes.
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "flash") continue; if (f.kind !== "flash") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -709,7 +714,7 @@
// as it goes, and snaps back; one thrown at you swells from the // as it goes, and snaps back; one thrown at you swells from the
// middle of the pane until it fills it. Either way the blow lands // middle of the pane until it fills it. Either way the blow lands
// with a comic starburst. // with a comic starburst.
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "fist") continue; if (f.kind !== "fist") continue;
const p = (time - f.t0) / f.dur; const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue; if (p < 0 || p >= 1) continue;
@@ -720,7 +725,7 @@
ctx.rotate(pose.rotation); ctx.rotate(pose.rotation);
paintFist(ctx, pose.size, f.swing); paintFist(ctx, pose.size, f.swing);
ctx.restore(); ctx.restore();
if (pose.radius > H * 0.19 * 0.01) { if (pose.radius > 0.5) {
ctx.save(); ctx.save();
ctx.translate(pose.impactX, pose.impactY); ctx.translate(pose.impactX, pose.impactY);
ctx.rotate(-0.14); ctx.rotate(-0.14);
@@ -731,7 +736,7 @@
// A change of eyes: the slate wipes across, names the wizard whose // A change of eyes: the slate wipes across, names the wizard whose
// sight this now is, and wipes away to reveal it. // sight this now is, and wipes away to reveal it.
for (const f of fx) { for (const f of activeFx) {
if (f.kind !== "slate") continue; if (f.kind !== "slate") continue;
const [l, r] = slateBand((time - f.t0) / f.dur); const [l, r] = slateBand((time - f.t0) / f.dur);
if (r <= l) continue; if (r <= l) continue;
@@ -772,12 +777,6 @@
if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half); if ((ontarget || hover) && mouse) drawHover(ctx, W, H, half);
} }
/** An ellipse of light on the floor at a body's feet: `rgb` as "r,g,b",
* the stroke at `alpha`, the fill fainter. */
function floorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number,
rgb: string, alpha: number, fill: number) {
paintFloorRing(ctx, cx, cy, rx, ry, rgb, alpha, fill);
}
/** Paint the hover cue for whatever stands under the crosshair. */ /** Paint the hover cue for whatever stands under the crosshair. */
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) { function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
+9 -8
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { motion } from "../motion";
import { smoothstep } from "./fx3d"; import { smoothstep } from "./fx3d";
// The first-person workshop (/?fpv): a real dealt game, one wizard's // The first-person workshop (/?fpv): a real dealt game, one wizard's
// eyes, free-fly controls. No rules run here — the camera walks where // eyes, free-fly controls. No rules run here — the camera walks where
@@ -145,7 +146,7 @@
const tick = (now: number) => { const tick = (now: number) => {
if (!anim) startNext(now); if (!anim) startNext(now);
if (anim) { if (anim) {
const w = Math.min(1, (now - anim.t0) / anim.dur); const w = motion.reduced ? 1 : Math.min(1, (now - anim.t0) / anim.dur);
if (anim.kind === "glide") { if (anim.kind === "glide") {
x = anim.fx + (anim.tx - anim.fx) * ease(w); x = anim.fx + (anim.tx - anim.fx) * ease(w);
y = anim.fy + (anim.ty - anim.fy) * ease(w); y = anim.fy + (anim.ty - anim.fy) * ease(w);
@@ -205,17 +206,17 @@
// a refused move reports itself instead of a blank stage. // a refused move reports itself instead of a blank stage.
const scriptName = q.get("script"); const scriptName = q.get("script");
const scriptPlay = scriptName ? screenplayByName(scriptName) : null; const scriptPlay = scriptName ? screenplayByName(scriptName) : null;
let scriptSteps: ScreenplayStep[] = []; function loadScript(): { scriptSteps: ScreenplayStep[]; scriptError: string | null } {
let scriptError: string | null = null; if (scriptName && !scriptPlay) {
if (scriptName && !scriptPlay) { return { scriptSteps: [], scriptError: `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}` };
scriptError = `No screenplay named "${scriptName}". The catalog: ${SCREENPLAYS.map((sp) => sp.name).join(", ")}`; }
} else if (scriptPlay) {
try { try {
scriptSteps = buildScreenplaySteps(scriptPlay); return { scriptSteps: scriptPlay ? buildScreenplaySteps(scriptPlay) : [], scriptError: null };
} catch (e) { } catch (e) {
scriptError = e instanceof Error ? e.message : String(e); return { scriptSteps: [], scriptError: e instanceof Error ? e.message : String(e) };
} }
} }
const { scriptSteps, scriptError } = loadScript();
// Minimap geometry (top-down, one small square per cell). // Minimap geometry (top-down, one small square per cell).
const MM = 9; const MM = 9;
+3 -5
View File
@@ -4,7 +4,8 @@
// camera shakes for the raycaster to draw. What happens ACROSS the room // camera shakes for the raycaster to draw. What happens ACROSS the room
// is watched; what happens to YOU is felt. // is watched; what happens to YOU is felt.
import { fallbackCels, paintCels } from "./paintedFx"; import { fallbackCels, paintCels, smoothstep } from "./paintedFx";
export { smoothstep };
import { fxForEvents } from "../fx"; import { fxForEvents } from "../fx";
import { CELL } from "../fx-sprites/geom"; import { CELL } from "../fx-sprites/geom";
import { castRay, edgeMid, warpMotion } from "./raycast"; import { castRay, edgeMid, warpMotion } from "./raycast";
@@ -55,9 +56,6 @@ export function slateBand(p: number): [number, number] {
return [smoothstep((p - 0.68) / 0.32), 1]; return [smoothstep((p - 0.68) / 0.32), 1];
} }
/** The one easing every tween here shares. */
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
/** Eased growth 0..1 for a conjuration's rise. */ /** Eased growth 0..1 for a conjuration's rise. */
export function growProgress(p: number): number { export function growProgress(p: number): number {
if (p <= 0) return 0.01; if (p <= 0) return 0.01;
@@ -306,7 +304,7 @@ export function fpFxForEvents(
return out; return out;
} }
/** Inked stand-ins in each original palette, replaced when the PNG loads. */ /** Core and rim colors for each conjuration's inked stand-in. */
const FX_COLORS: Record<string, [string, string]> = { const FX_COLORS: Record<string, [string, string]> = {
fireball: ["#ffe27a", "#e0431a"], fireball: ["#ffe27a", "#e0431a"],
bolt: ["#ffffff", "#7ab0ff"], bolt: ["#ffffff", "#7ab0ff"],
+81 -41
View File
@@ -1,10 +1,11 @@
/** Inked canvas cels. Paths are shared by the renderer and artwork previews. */ /** Inked canvas cels. Paths are shared by the renderer and artwork previews. */
export const smoothstep = (w: number): number => w * w * (3 - 2 * w);
export type Cel = { d: string; fill?: string; stroke?: string; width?: number }; export type Cel = { d: string; fill?: string; stroke?: string; width?: number };
const INK = "#2b2218"; const INK = "#2b2218";
const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e"; const ochre = "#dba54b", light = "#f2ce78", shade = "#95602e";
/** Poses modeled on the original right-facing and oncoming fist emoji: /** Two fist poses: a punch thrown (wrist left, knuckles right) and one
* folded fingers face the viewer; the thumb stays outside their curl. */ * arriving (knuckles to the viewer). The thumb stays outside the curl. */
export const FISTS: Record<"out" | "in", Cel[]> = { export const FISTS: Record<"out" | "in", Cel[]> = {
out: [ out: [
// Wrist at left, back of hand above, bent index at the right edge. // Wrist at left, back of hand above, bent index at the right edge.
@@ -33,7 +34,7 @@ export const FISTS: Record<"out" | "in", Cel[]> = {
], ],
}; };
/** Deliberately uneven impact silhouette and hand-cut lettering; no font dependency. */ /** The POW starburst and its lettering, as paths: the canvas has no font to wait on. */
export const POW: Cel[] = [ export const POW: Cel[] = [
{ d: "M-65-26 L-115-65 -53-53 -59-91 -18-60 8-88 17-57 63-79 53-43 119-43 82-13 126 13 78 23 98 64 45 48 27 86 4 57 -36 82 -39 48 -100 66 -76 28 -126 14 -81-5 -115-28Z", fill: "#f2d24a", width: 5 }, { d: "M-65-26 L-115-65 -53-53 -59-91 -18-60 8-88 17-57 63-79 53-43 119-43 82-13 126 13 78 23 98 64 45 48 27 86 4 57 -36 82 -39 48 -100 66 -76 28 -126 14 -81-5 -115-28Z", fill: "#f2d24a", width: 5 },
{ d: "M-83-38 L-60-31 M49-50 L59-60 M77 29 L88 37 M-51 46 L-64 55", stroke: "#c03020", width: 3 }, { d: "M-83-38 L-60-31 M49-50 L59-60 M77 29 L88 37 M-51 46 L-64 55", stroke: "#c03020", width: 3 },
@@ -57,58 +58,97 @@ export function paintCels(ctx: CanvasRenderingContext2D, cels: readonly Cel[]) {
ctx.restore(); ctx.restore();
} }
export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") { export function paintFist(ctx: CanvasRenderingContext2D, size: number, swing: "out" | "in") {
ctx.save(); ctx.scale(size / 100, size / 100); paintCels(ctx, FISTS[swing]); ctx.restore(); ctx.save();
} ctx.scale(size / 100, size / 100);
export function paintPow(ctx: CanvasRenderingContext2D, radius: number) { paintCels(ctx, FISTS[swing]);
ctx.save(); ctx.scale(radius / 100, radius / 100); paintCels(ctx, POW); ctx.restore(); ctx.restore();
} }
/** Same endpoints and landing beats as the original animation. */ export function paintPow(ctx: CanvasRenderingContext2D, radius: number) {
ctx.save();
ctx.scale(radius / 100, radius / 100);
paintCels(ctx, POW);
ctx.restore();
}
/** Where the fist is at progress `p`: thrown, it lunges to 0.42 and
* retracts; arriving, it closes to 0.45, holds, and fades. The POW pops
* at the landing beat and shrinks away. */
export function punchPose(p: number, out: boolean, W: number, H: number) { export function punchPose(p: number, out: boolean, W: number, H: number) {
const ease = (v: number) => v * v * (3 - 2 * v); const ease = smoothstep;
// Clamp before easing: otherwise the incoming fist reverses past p=.45. // Clamp before easing: past 0.45 the arriving fist would reverse.
const a = out ? (p < .42 ? ease(p / .42) : ease(1 - (p - .42) / .58)) : ease(Math.min(1, p / .45)); const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : ease(Math.min(1, p / 0.45));
const sx = out ? W * .86 : W * .5, sy = out ? H * 1.15 : H * .55; const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55;
const tx = W * .56, ty = H * .5; const tx = W * 0.56, ty = H * 0.5;
const land = out ? .36 : .4, q = (p - land) / (1 - land); const land = out ? 0.36 : 0.4, q = (p - land) / (1 - land);
const pop = p < land ? 0 : q < .18 ? ease(q / .18) * 1.15 : q < .7 ? 1.15 - .15 * ease((q - .18) / .52) : 1 - ease((q - .7) / .3); const pop = p < land ? 0 : q < 0.18 ? ease(q / 0.18) * 1.15 : q < 0.7 ? 1.15 - 0.15 * ease((q - 0.18) / 0.52) : 1 - ease((q - 0.7) / 0.3);
return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a, return { x: sx + (tx - sx) * a, y: sy + (ty - sy) * a,
size: out ? H * (.62 - .30 * a) : H * (.18 + 1.25 * a), size: out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a),
alpha: !out && p > .75 ? 1 - (p - .75) / .25 : 1, alpha: !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1,
rotation: out ? -.6 : .15, radius: H * .19 * pop, rotation: out ? -0.6 : 0.15, radius: H * 0.19 * pop,
impactX: out ? tx - W * .02 : W * .5, impactY: H * .34 }; impactX: out ? tx - W * 0.02 : W * 0.5, impactY: H * 0.34 };
} }
export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) { export function paintWeb(ctx: CanvasRenderingContext2D, left: number, top: number, w: number, h: number) {
const hub = [left + w * .5, top + h * .45]; type Pt = [number, number];
const rim = [[.02,.06],[.5,0],[.98,.08],[1,.5],[.96,.94],[.5,1],[.04,.92],[0,.5]].map(([u,v]) => [left + u! * w, top + v! * h]); const hub: Pt = [left + w * 0.5, top + h * 0.45];
const at = (i: number, t: number) => { const r = rim[i % 8]!; return [hub[0]! + (r[0]! - hub[0]!) * t, hub[1]! + (r[1]! - hub[1]!) * t]; }; const rim: Pt[] = ([[0.02, 0.06], [0.5, 0], [0.98, 0.08], [1, 0.5], [0.96, 0.94], [0.5, 1], [0.04, 0.92], [0, 0.5]] as Pt[])
ctx.save(); ctx.lineJoin = "round"; ctx.lineCap = "round"; .map(([u, v]) => [left + u * w, top + v * h]);
const at = (i: number, t: number): Pt => {
const r = rim[i % 8] as Pt;
return [hub[0] + (r[0] - hub[0]) * t, hub[1] + (r[1] - hub[1]) * t];
};
ctx.save();
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.beginPath(); ctx.beginPath();
for (const r of rim) { ctx.moveTo(hub[0]!,hub[1]!); ctx.lineTo(r[0]!,r[1]!); } for (const r of rim) {
for (const t of [.27,.53,.8]) for (let i = 0; i < 8; i++) { ctx.moveTo(hub[0], hub[1]);
const a = at(i,t), b = at(i+1,t); ctx.lineTo(r[0], r[1]);
ctx.moveTo(a[0]!,a[1]!);
ctx.quadraticCurveTo((a[0]!+b[0]!)*.36+hub[0]!*.28,(a[1]!+b[1]!)*.36+hub[1]!*.28,b[0]!,b[1]!);
} }
ctx.strokeStyle = "rgba(25,25,30,0.65)"; ctx.lineWidth = 3; ctx.stroke(); // Three sagging rings, each strand bowed toward the hub.
ctx.strokeStyle = "rgba(245,245,240,0.9)"; ctx.lineWidth = 1.3; ctx.stroke(); ctx.restore(); for (const t of [0.27, 0.53, 0.8]) {
for (let i = 0; i < 8; i++) {
const a = at(i, t), b = at(i + 1, t);
ctx.moveTo(a[0], a[1]);
ctx.quadraticCurveTo((a[0] + b[0]) * 0.36 + hub[0] * 0.28, (a[1] + b[1]) * 0.36 + hub[1] * 0.28, b[0], b[1]);
}
}
ctx.strokeStyle = "rgba(25,25,30,0.65)";
ctx.lineWidth = 3;
ctx.stroke();
ctx.strokeStyle = "rgba(245,245,240,0.9)";
ctx.lineWidth = 1.3;
ctx.stroke();
ctx.restore();
} }
export function paintFloorRing(ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number) { export function paintFloorRing(
ctx.save(); ctx.fillStyle = `rgba(${rgb},${fill})`; ctx.strokeStyle = `rgba(${rgb},${alpha})`; ctx.lineWidth = 2; ctx: CanvasRenderingContext2D, cx: number, cy: number, rx: number, ry: number, rgb: string, alpha: number, fill: number,
ctx.beginPath(); ctx.ellipse(cx,cy,rx,ry,0,0,Math.PI*2); ctx.fill(); ) {
ctx.save();
ctx.fillStyle = `rgba(${rgb},${fill})`;
ctx.strokeStyle = `rgba(${rgb},${alpha})`;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
const segments = [[.06,1.75],[1.87,3.48],[3.62,5.04],[5.18,6.22]]; ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
for (const [start,end] of segments) for (let i=0; i<=20; i++) { ctx.fill();
const t = start! + (end!-start!) * i/20, k = 1 - .018 * (1 + Math.sin(t*5)); // The rim is inked in four strokes with a slight tremor, not one perfect ellipse.
const x = cx + Math.cos(t)*rx*k, y = cy + Math.sin(t)*ry*k; ctx.beginPath();
if (!i) ctx.moveTo(x,y); else ctx.lineTo(x,y); const segments: [number, number][] = [[0.06, 1.75], [1.87, 3.48], [3.62, 5.04], [5.18, 6.22]];
for (const [start, end] of segments) {
for (let i = 0; i <= 20; i++) {
const t = start + (end - start) * i / 20;
const k = 1 - 0.018 * (1 + Math.sin(t * 5));
const x = cx + Math.cos(t) * rx * k, y = cy + Math.sin(t) * ry * k;
if (!i) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
} }
ctx.stroke(); ctx.restore(); ctx.stroke();
ctx.restore();
} }
/** Small, legible loading/error sprites using the original fallback palettes. */ /** The inked silhouette drawn in a conjuration's palette until its PNG loads. */
export function fallbackCels(name: string, core: string, rim: string): Cel[] { export function fallbackCels(name: string, core: string, rim: string): Cel[] {
const outer: Record<string, string> = { const outer: Record<string, string> = {
fireball: "M20 78 Q3 54 28 32 L19 10 Q42 17 48 37 Q68 11 58 3 Q96 24 85 62 Q81 83 60 88 Q33 98 20 78Z", fireball: "M20 78 Q3 54 28 32 L19 10 Q42 17 48 37 Q68 11 58 3 Q96 24 85 62 Q81 83 60 88 Q33 98 20 78Z",
@@ -135,5 +175,5 @@ export function fallbackCels(name: string, core: string, rim: string): Cel[] {
rubble:"M5 85 L18 79 29 83 24 90 11 91Z M36 72 L48 66 60 73 55 81 40 80Z M69 84 L80 79 89 84 83 91 73 92Z", rubble:"M5 85 L18 79 29 83 24 90 11 91Z M36 72 L48 66 60 73 55 81 40 80Z M69 84 L80 79 89 84 83 91 73 92Z",
}; };
const kind = outer[name] ? name : "spark"; const kind = outer[name] ? name : "spark";
return [{d:outer[kind]!,fill:rim,width:3},{d:inside[kind]!,fill:core,width:1.5}]; return [{ d: outer[kind]!, fill: rim, width: 3 }, { d: inside[kind]!, fill: core, width: 1.5 }];
} }
+6 -6
View File
@@ -19,13 +19,13 @@
</script> </script>
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}> <g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
<path d={puff(c.x - 6, c.y, (7) * 1)} fill="#b7a786" class="puff" /> <path d={puff(c.x - 6, c.y, 7)} fill="#b7a786" class="puff" />
<path d={puff(c.x + 5, c.y - 2, (5) * 1)} fill="#b7a786" class="puff back late" /> <path d={puff(c.x + 5, c.y - 2, 5)} fill="#b7a786" class="puff back late" />
<path d={puff(c.x, c.y + 3, (6) * 1)} fill="#b7a786" class="puff later" /> <path d={puff(c.x, c.y + 3, 6)} fill="#b7a786" class="puff later" />
<g class="grit"> <g class="grit">
<path d={puff(c.x - 4, c.y - 5, (1.3) * 1)} /> <path d={puff(c.x - 4, c.y - 5, 1.3)} />
<path d={puff(c.x + 6, c.y + 2, (1.6) * 1)} /> <path d={puff(c.x + 6, c.y + 2, 1.6)} />
<path d={puff(c.x + 1, c.y - 2, (0.9) * 1)} /> <path d={puff(c.x + 1, c.y - 2, 0.9)} />
</g> </g>
</g> </g>
+2 -3
View File
@@ -35,7 +35,7 @@
style={`transform-origin: ${c.x}px ${c.y}px`} style={`transform-origin: ${c.x}px ${c.y}px`}
/> />
{/each} {/each}
<path d={glint(c.x, c.y, (3.5) * 1.6)} class="heart" /> <path d={glint(c.x, c.y, 5.6)} class="heart" />
</g> </g>
<style> <style>
@@ -47,7 +47,7 @@
.spark { animation: spark-out 1.05s ease-out forwards; } .spark { animation: spark-out 1.05s ease-out forwards; }
.trail.late, .spark.late { animation-delay: 0.07s; } .trail.late, .spark.late { animation-delay: 0.07s; }
.trail.later, .spark.later { animation-delay: 0.14s; } .trail.later, .spark.later { animation-delay: 0.14s; }
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; } .heart { fill: #fffdf0; transform-box: fill-box; transform-origin: center; animation: heart-pop 0.4s ease-out forwards; }
@keyframes trail-out { @keyframes trail-out {
0% { opacity: 0; transform: scale(0.15); } 0% { opacity: 0; transform: scale(0.15); }
25% { opacity: 1; } 25% { opacity: 1; }
@@ -63,5 +63,4 @@
0% { opacity: 1; transform: scale(0.5); } 0% { opacity: 1; transform: scale(0.5); }
100% { opacity: 0; transform: scale(3); } 100% { opacity: 0; transform: scale(3); }
} }
.heart { transform-box: fill-box; transform-origin: center; }
</style> </style>
+4 -5
View File
@@ -8,9 +8,9 @@
<path d={`M ${c.x-14} ${c.y+7} q -6 -6 1 -7 q 4 1 4 5 q 7 -9 12 -5 q -3 4 0 5 q 12 -7 14 -1 q 0 8 -17 9 q -15 1 -14 -6 Z`} fill="#6ea03c" stroke="#46662c" stroke-width="1" class="splat" /> <path d={`M ${c.x-14} ${c.y+7} q -6 -6 1 -7 q 4 1 4 5 q 7 -9 12 -5 q -3 4 0 5 q 12 -7 14 -1 q 0 8 -17 9 q -15 1 -14 -6 Z`} fill="#6ea03c" stroke="#46662c" stroke-width="1" class="splat" />
<g class="blobs"> <g class="blobs">
<path d={drop(c.x - 12, c.y + 3, (2.5) * 1)} class="blob" /> <path d={drop(c.x - 12, c.y + 3, 2.5)} class="blob" />
<path d={drop(c.x + 11, c.y + 2, (2) * 1)} class="blob late" /> <path d={drop(c.x + 11, c.y + 2, 2)} class="blob late" />
<path d={drop(c.x + 3, c.y - 3, (1.7) * 1)} class="blob later" /> <path d={drop(c.x + 3, c.y - 3, 1.7)} class="blob later" />
</g> </g>
<style> <style>
@@ -19,7 +19,7 @@
transform-origin: center; transform-origin: center;
animation: splat-wobble 0.7s ease-out forwards; animation: splat-wobble 0.7s ease-out forwards;
} }
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; } .blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out both; }
.blob.late { animation-delay: 0.06s; } .blob.late { animation-delay: 0.06s; }
.blob.later { animation-delay: 0.12s; } .blob.later { animation-delay: 0.12s; }
@keyframes splat-wobble { @keyframes splat-wobble {
@@ -33,5 +33,4 @@
50% { transform: translateY(-7px); } 50% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(2px); } 100% { opacity: 0; transform: translateY(2px); }
} }
.blob { animation-fill-mode: both; }
</style> </style>
+5 -6
View File
@@ -11,9 +11,9 @@
<path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" /> <path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" />
<circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} /> <circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} />
<g class="rim-dust"> <g class="rim-dust">
<path d={puff(c.x - 11, c.y - 6, (3) * 1)} class="dust" /> <path d={puff(c.x - 11, c.y - 6, 3)} class="dust" />
<path d={puff(c.x + 10, c.y - 7, (2.5) * 1)} class="dust late" /> <path d={puff(c.x + 10, c.y - 7, 2.5)} class="dust late" />
<path d={puff(c.x + 1, c.y - 11, (2) * 1)} class="dust later" /> <path d={puff(c.x + 1, c.y - 11, 2)} class="dust later" />
</g> </g>
<style> <style>
@@ -32,8 +32,9 @@
stroke-width: 1.6; stroke-width: 1.6;
stroke-linecap: round; stroke-linecap: round;
animation: hole-open 0.75s ease-out forwards; animation: hole-open 0.75s ease-out forwards;
transform-box: fill-box; transform-origin: center;
} }
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; } .dust { fill: rgba(160, 150, 130, 0.75); opacity: 0; transform-box: fill-box; transform-origin: center; animation: dust-drift 0.7s ease-out 0.15s forwards; }
.dust.late { animation-delay: 0.24s; } .dust.late { animation-delay: 0.24s; }
.dust.later { animation-delay: 0.32s; } .dust.later { animation-delay: 0.32s; }
@keyframes hole-open { @keyframes hole-open {
@@ -51,6 +52,4 @@
30% { opacity: 0.8; } 30% { opacity: 0.8; }
100% { opacity: 0; transform: translateY(-10px) scale(1.7); } 100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
} }
.lip { transform-box: fill-box; transform-origin: center; }
.dust { transform-box: fill-box; transform-origin: center; }
</style> </style>
@@ -51,7 +51,7 @@
{/if} {/if}
<g class="corner-dust"> <g class="corner-dust">
{#each CORNERS as p, i (i)} {#each CORNERS as p, i (i)}
<path d={puff(p.x, p.y, (3.5) * 1)} class={`dust d${i}`} /> <path d={puff(p.x, p.y, 3.5)} class={`dust d${i}`} />
{/each} {/each}
</g> </g>
@@ -86,6 +86,7 @@
fill: rgba(160, 150, 130, 0.85); fill: rgba(160, 150, 130, 0.85);
opacity: 0; opacity: 0;
animation: dust-kick 0.6s ease-out 0.85s forwards; animation: dust-kick 0.6s ease-out 0.85s forwards;
transform-box: fill-box; transform-origin: center;
} }
.dust.d1 { animation-delay: 0.9s; } .dust.d1 { animation-delay: 0.9s; }
.dust.d2 { animation-delay: 0.95s; } .dust.d2 { animation-delay: 0.95s; }
@@ -128,5 +129,4 @@
30% { opacity: 0.9; } 30% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-9px) scale(1.6); } 100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
} }
.dust { transform-box: fill-box; transform-origin: center; }
</style> </style>
+4 -4
View File
@@ -11,11 +11,11 @@
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}> <g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} /> <path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} />
</g> </g>
<path d={glint(c.x, c.y, (3) * 1.7)} class="core" /> <path d={glint(c.x, c.y, 5.1)} class="core" />
<g class="motes"> <g class="motes">
<path d={glint(c.x - 10, c.y - 9, (1.3) * 1.7)} /> <path d={glint(c.x - 10, c.y - 9, 2.21)} />
<path d={glint(c.x + 11, c.y - 5, (1.1) * 1.7)} class="late" /> <path d={glint(c.x + 11, c.y - 5, 1.87)} class="late" />
<path d={glint(c.x + 2, c.y + 11, (1.2) * 1.7)} class="later" /> <path d={glint(c.x + 2, c.y + 11, 2.04)} class="later" />
</g> </g>
<style> <style>
+1 -2
View File
@@ -32,7 +32,7 @@
stroke-width: 3.2; stroke-width: 3.2;
animation: ring-out 0.55s ease-out forwards; animation: ring-out 0.55s ease-out forwards;
} }
.drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in forwards; } .drop { fill: #7cc0ee; stroke: #245981; stroke-width: 0.6; animation: drop-arc 0.6s ease-in both; }
.drop.late { animation-delay: 0.07s; } .drop.late { animation-delay: 0.07s; }
.drop.later { animation-delay: 0.13s; } .drop.later { animation-delay: 0.13s; }
@keyframes pool-spread { @keyframes pool-spread {
@@ -48,5 +48,4 @@
45% { transform: translateY(-8px); } 45% { transform: translateY(-8px); }
100% { opacity: 0; transform: translateY(6px); } 100% { opacity: 0; transform: translateY(6px); }
} }
.drop { animation-fill-mode: both; }
</style> </style>
+5 -6
View File
@@ -6,16 +6,15 @@ export const center = (c: { x: number; y: number }) => ({
y: c.y * CELL + CELL / 2, y: c.y * CELL + CELL / 2,
}); });
/** Small cel silhouettes, expressed in board coordinates so their animation /** Small cel silhouettes, expressed in board coordinates so their animation
* origins stay attached to the existing cell and edge anchors. */ * origins stay attached to the existing cell and edge anchors. */
export const glint = (x: number, y: number, r: number) => export const glint = (x: number, y: number, r: number) =>
`M ${x} ${y-r} Q ${x+r*.13} ${y-r*.13} ${x+r*.84} ${y+.04*r} Q ${x+r*.15} ${y+r*.15} ${x+.06*r} ${y+r} Q ${x-r*.15} ${y+r*.13} ${x-r*.88} ${y} Q ${x-r*.14} ${y-r*.15} ${x} ${y-r} Z`; `M ${x} ${y-r} Q ${x+r*0.13} ${y-r*0.13} ${x+r*0.84} ${y+0.04*r} Q ${x+r*0.15} ${y+r*0.15} ${x+0.06*r} ${y+r} Q ${x-r*0.15} ${y+r*0.13} ${x-r*0.88} ${y} Q ${x-r*0.14} ${y-r*0.15} ${x} ${y-r} Z`;
export const puff = (x: number, y: number, r: number) => export const puff = (x: number, y: number, r: number) =>
`M ${x-r} ${y+r*.3} C ${x-r*1.2} ${y-r*.15} ${x-r*.85} ${y-r*.6} ${x-r*.5} ${y-r*.5} C ${x-r*.65} ${y-r*1.12} ${x+r*.15} ${y-r*1.1} ${x+r*.32} ${y-r*.55} C ${x+r*.95} ${y-r*.75} ${x+r*1.22} ${y+r*.1} ${x+r*.75} ${y+r*.4} C ${x+r*.58} ${y+r*.9} ${x-r*.72} ${y+r*.8} ${x-r} ${y+r*.3} Z`; `M ${x-r} ${y+r*0.3} C ${x-r*1.2} ${y-r*0.15} ${x-r*0.85} ${y-r*0.6} ${x-r*0.5} ${y-r*0.5} C ${x-r*0.65} ${y-r*1.12} ${x+r*0.15} ${y-r*1.1} ${x+r*0.32} ${y-r*0.55} C ${x+r*0.95} ${y-r*0.75} ${x+r*1.22} ${y+r*0.1} ${x+r*0.75} ${y+r*0.4} C ${x+r*0.58} ${y+r*0.9} ${x-r*0.72} ${y+r*0.8} ${x-r} ${y+r*0.3} Z`;
export const drop = (x: number, y: number, r: number) => export const drop = (x: number, y: number, r: number) =>
`M ${x+r*.2} ${y-r*1.7} C ${x+r*.45} ${y-r*.65} ${x+r*1.12} ${y-r*.1} ${x+r*.8} ${y+r*.6} C ${x+r*.45} ${y+r*1.25} ${x-r*.85} ${y+r*.9} ${x-r*.8} ${y+r*.1} C ${x-r*.75} ${y-r*.6} ${x-r*.1} ${y-r} ${x+r*.2} ${y-r*1.7} Z`; `M ${x+r*0.2} ${y-r*1.7} C ${x+r*0.45} ${y-r*0.65} ${x+r*1.12} ${y-r*0.1} ${x+r*0.8} ${y+r*0.6} C ${x+r*0.45} ${y+r*1.25} ${x-r*0.85} ${y+r*0.9} ${x-r*0.8} ${y+r*0.1} C ${x-r*0.75} ${y-r*0.6} ${x-r*0.1} ${y-r} ${x+r*0.2} ${y-r*1.7} Z`;
export const impact = (x: number, y: number, r: number) => export const impact = (x: number, y: number, r: number) =>
`M ${x-r*.06} ${y-r} L ${x+r*.22} ${y-r*.34} ${x+r*.91} ${y-r*.58} ${x+r*.46} ${y-r*.05} ${x+r} ${y+r*.39} ${x+r*.31} ${y+r*.28} ${x+r*.2} ${y+r*.92} ${x-r*.12} ${y+r*.44} ${x-r*.74} ${y+r*.72} ${x-r*.4} ${y+r*.14} ${x-r} ${y-r*.18} ${x-r*.34} ${y-r*.27} Z`; `M ${x-r*0.06} ${y-r} L ${x+r*0.22} ${y-r*0.34} ${x+r*0.91} ${y-r*0.58} ${x+r*0.46} ${y-r*0.05} ${x+r} ${y+r*0.39} ${x+r*0.31} ${y+r*0.28} ${x+r*0.2} ${y+r*0.92} ${x-r*0.12} ${y+r*0.44} ${x-r*0.74} ${y+r*0.72} ${x-r*0.4} ${y+r*0.14} ${x-r} ${y-r*0.18} ${x-r*0.34} ${y-r*0.27} Z`;
export const ward = (x: number, y: number, r: number) => export const ward = (x: number, y: number, r: number) =>
`M ${x} ${y-r} Q ${x+r*.45} ${y-r*.63} ${x+r*.9} ${y-r*.68} L ${x+r*.82} ${y+r*.18} Q ${x+r*.7} ${y+r*.66} ${x} ${y+r} Q ${x-r*.75} ${y+r*.59} ${x-r*.81} ${y+r*.15} L ${x-r*.9} ${y-r*.68} Q ${x-r*.42} ${y-r*.65} ${x} ${y-r} Z`; `M ${x} ${y-r} Q ${x+r*0.45} ${y-r*0.63} ${x+r*0.9} ${y-r*0.68} L ${x+r*0.82} ${y+r*0.18} Q ${x+r*0.7} ${y+r*0.66} ${x} ${y+r} Q ${x-r*0.75} ${y+r*0.59} ${x-r*0.81} ${y+r*0.15} L ${x-r*0.9} ${y-r*0.68} Q ${x-r*0.42} ${y-r*0.65} ${x} ${y-r} Z`;
+21
View File
@@ -0,0 +1,21 @@
// What the help remembers between openings: the tab, each tab's search,
// the library's filters, and where the reader was on each page. Module
// state, so it lasts the session and costs nothing to restore.
import type { CardQuery } from "./reference";
export type HelpTab = "play" | "rules" | "cards" | "rulings" | "about" | "tally";
export const helpState: {
tab: HelpTab;
rulesSearch: string;
rulingsSearch: string;
cards: CardQuery;
scroll: Partial<Record<HelpTab, number>>;
} = {
tab: "play",
rulesSearch: "",
rulingsSearch: "",
cards: { text: "", set: "all", type: "all", sight: "all" },
scroll: {},
};
+17 -12
View File
@@ -16,7 +16,8 @@ import {
type GameEvent, type GameEvent,
redactEvent, redactEvent,
} from "@wizwar/engine"; } from "@wizwar/engine";
import { humanize, net } from "./net.svelte"; import { type LogLine, humanize, net } from "./net.svelte";
import { receiptFor } from "./receipt";
const SAVE_KEY = "wizwar-hotseat"; const SAVE_KEY = "wizwar-hotseat";
@@ -47,7 +48,7 @@ class LocalGame {
viewerId = $state<PlayerId | null>(null); viewerId = $state<PlayerId | null>(null);
/** Set while the device should be handed to the named player. */ /** Set while the device should be handed to the named player. */
handoffTo = $state<PlayerId | null>(null); handoffTo = $state<PlayerId | null>(null);
log = $state<string[]>([]); log = $state<LogLine[]>([]);
/** The opening roll-off, shown once as the boards flip. */ /** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null); openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
/** Board flourishes: the app hooks in to animate command results. */ /** Board flourishes: the app hooks in to animate command results. */
@@ -64,11 +65,19 @@ class LocalGame {
private activeMs = 0; private activeMs = 0;
private lastMoveAt = 0; private lastMoveAt = 0;
/** A line of the chronicle for an event, and the receipt under a resolved attack. */
private chronicle(e: GameEvent, batch: readonly GameEvent[]): void {
const line = humanize(e);
if (line) this.log = [...this.log, { text: line, turn: null, notable: false }];
const receipt = receiptFor(batch, e);
if (receipt) this.log = [...this.log, { text: receipt.title, turn: null, notable: false, receipt: receipt.lines }];
}
/** The tabletop D4 for house calls: chronicle only, no game state. */ /** The tabletop D4 for house calls: chronicle only, no game state. */
rollTableDie(): void { rollTableDie(): void {
if (!this.viewerId) return; if (!this.viewerId) return;
const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4); const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4);
this.log = [...this.log, `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`]; this.log = [...this.log, { text: `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`, turn: null, notable: false }];
} }
/** Rebuild the whole game as replay steps (finished games only). */ /** Rebuild the whole game as replay steps (finished games only). */
@@ -148,8 +157,7 @@ class LocalGame {
this.gameState = state; this.gameState = state;
this.log = []; this.log = [];
for (const e of events) { for (const e of events) {
const line = humanize(e); this.chronicle(e, events);
if (line) this.log = [...this.log, line];
} }
this.active = true; this.active = true;
this.viewerId = null; this.viewerId = null;
@@ -174,16 +182,14 @@ class LocalGame {
let current = state; let current = state;
this.log = []; this.log = [];
for (const e of events) { for (const e of events) {
const line = humanize(e); this.chronicle(e, events);
if (line) this.log = [...this.log, line];
} }
for (const c of saved.commands) { for (const c of saved.commands) {
const result = applyCommand(current, c.playerId, c.command); const result = applyCommand(current, c.playerId, c.command);
if (!result.ok) throw new Error(`replay failed: ${result.error}`); if (!result.ok) throw new Error(`replay failed: ${result.error}`);
current = result.state; current = result.state;
for (const e of result.events) { for (const e of result.events) {
const line = humanize(e); this.chronicle(e, result.events);
if (line) this.log = [...this.log, line];
} }
} }
this.config = saved.config; this.config = saved.config;
@@ -218,7 +224,7 @@ class LocalGame {
const plain = $state.snapshot(this.gameState) as GameState; const plain = $state.snapshot(this.gameState) as GameState;
const result = applyCommand(plain, this.viewerId, command); const result = applyCommand(plain, this.viewerId, command);
if (!result.ok) { if (!result.ok) {
this.log = [...this.log, `${result.error}`]; this.log = [...this.log, { text: `${result.error}`, turn: null, notable: false }];
return; return;
} }
this.onFx?.(result.events); this.onFx?.(result.events);
@@ -237,8 +243,7 @@ class LocalGame {
}); });
} }
for (const e of result.events) { for (const e of result.events) {
const line = humanize(e); this.chronicle(e, result.events);
if (line) this.log = [...this.log, line];
} }
this.persist(); this.persist();
if (this.gameState.phase === "playing") { if (this.gameState.phase === "playing") {
+11
View File
@@ -0,0 +1,11 @@
import { prefersReducedMotion } from "svelte/motion";
import { prefs } from "./prefs.svelte";
// Shared by the board, the reel, and the canvas. Svelte's own media query
// tracks the OS setting live and releases its listener when no reactive
// consumer remains.
export const motion = {
get reduced(): boolean { return prefersReducedMotion.current; },
/** Flourishes play only when the player wants them AND the OS allows motion. */
get effects(): boolean { return prefs.flourishes && !prefersReducedMotion.current; },
};
+187 -22
View File
@@ -1,7 +1,8 @@
// Websocket client + reactive session state (Svelte 5 runes). // Websocket client + reactive session state (Svelte 5 runes).
import type { Command, GameEvent, GameView } from "@wizwar/engine"; import type { Command, GameEvent, GameView, Side } from "@wizwar/engine";
import { cardDef } from "@wizwar/engine"; import { cardDef } from "@wizwar/engine";
import { receiptFor } from "./receipt";
const SERVER_URL = const SERVER_URL =
import.meta.env.VITE_WIZWAR_SERVER ?? import.meta.env.VITE_WIZWAR_SERVER ??
@@ -23,6 +24,9 @@ export function spellName(cardId: string): string {
} }
} }
/** The compass, for captions and buttons. */
export const SIDE_NAMES: Record<Side, string> = { N: "North", E: "East", S: "South", W: "West" };
export function humanize(e: GameEvent): string | null { export function humanize(e: GameEvent): string | null {
switch (e.type) { switch (e.type) {
case "gameStarted": { case "gameStarted": {
@@ -66,7 +70,7 @@ export function humanize(e: GameEvent): string | null {
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`; case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`; 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 "pushWindow": return `${e.giant} bears down on ${e.pushee} at a fork — which way will they go?`;
case "pushChosen": return `${e.pushee} breaks ${e.direction === "N" ? "north" : e.direction === "S" ? "south" : e.direction === "E" ? "east" : "west"}.`; case "pushChosen": return `${e.pushee} breaks ${SIDE_NAMES[e.direction].toLowerCase()}.`;
case "treasureTorn": return e.toFloor 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 — it tumbles to the floor!`
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`; : `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
@@ -87,6 +91,8 @@ export function humanize(e: GameEvent): string | null {
case "safeOpened": return `${e.player}'s ${cardDef(e.withCardId).name} clicks the safe open — until turn's end.`; 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).`; case "safeDamaged": return `${e.attacker} batters the safe — ${e.amount} damage (${e.total}/15).`;
case "safeSmashed": return `💥 The safe BURSTS open under ${e.attacker}'s assault!`; 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 "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 "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 "slowDeathCountered": return `${e.player}'s ${cardDef(e.cardId).name} blunts the rot — ${e.remaining} point${e.remaining === 1 ? "" : "s"} still coming.`;
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`; case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
@@ -146,7 +152,9 @@ export function humanize(e: GameEvent): string | null {
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`; case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`; case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
case "creatureMoved": return null; case "creatureMoved": return null;
case "creatureAttacked": return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`; case "creatureAttacked":
if (e.target === "wall") return `The ${spellName(e.kind)} punches the wall (rolled ${e.dieRoll ?? "?"})!`;
return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`;
case "creatureTouched": return `The ${spellName(e.kind)} falls upon ${e.player}!`; case "creatureTouched": return `The ${spellName(e.kind)} falls upon ${e.player}!`;
case "creatureDamaged": return e.amount > 0 ? `The ${spellName(e.kind)} takes ${e.amount} damage.` : `The attack has no effect on the ${spellName(e.kind)}.`; case "creatureDamaged": return e.amount > 0 ? `The ${spellName(e.kind)} takes ${e.amount} damage.` : `The attack has no effect on the ${spellName(e.kind)}.`;
case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`; case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`;
@@ -213,7 +221,8 @@ export function humanize(e: GameEvent): string | null {
case "slimeWashed": return `The wave washes the slime away.`; case "slimeWashed": return `The wave washes the slime away.`;
case "wallDamaged": { case "wallDamaged": {
const what = e.needed === 15 ? "door" : "wall"; const what = e.needed === 15 ? "door" : "wall";
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name}${e.total}/${e.needed}.`; 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}.`;
} }
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`; 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;
@@ -237,6 +246,8 @@ export interface LogLine {
/** The wizard acting, for the color mark beside the line: whoever /** The wizard acting, for the color mark beside the line: whoever
* cast, struck, walked, or countered otherwise the turn's owner. */ * cast, struck, walked, or countered otherwise the turn's owner. */
actor?: string; actor?: string;
/** A resolved attack's account, folded under its line. */
receipt?: string[];
} }
/** Events whose `player` is the one acting rather than the one acted on. */ /** Events whose `player` is the one acting rather than the one acted on. */
@@ -246,9 +257,11 @@ const ACTING = new Set([
"climbedFromPit", "spellTrapped", "climbedFromPit", "spellTrapped",
]); ]);
function actorOf(e: GameEvent, turnOwner: string | null): string | undefined { function actorOf(e: GameEvent, turnOwner: string | null): string | undefined {
const any = e as unknown as Record<string, unknown>; if ("caster" in e && typeof e.caster === "string") return e.caster;
for (const k of ["caster", "attacker", "by", "owner"]) if (typeof any[k] === "string") return any[k] as string; if ("attacker" in e && typeof e.attacker === "string") return e.attacker;
if (ACTING.has(e.type) && typeof any.player === "string") return any.player as string; if ("by" in e && typeof e.by === "string") return e.by;
if ("owner" in e && typeof e.owner === "string") return e.owner;
if (ACTING.has(e.type) && "player" in e && typeof e.player === "string") return e.player;
return turnOwner ?? undefined; return turnOwner ?? undefined;
} }
@@ -296,6 +309,8 @@ export interface GameSummary {
round: number | null; round: number | null;
lastMoveAt: string | null; lastMoveAt: string | null;
chatCount: number; chatCount: number;
/** A finished table that moved on: the rematch room and who called it. */
rematch?: { roomId: string; by: string } | null;
} }
export function attentionLabel(a: GameSummary["attention"]): string { export function attentionLabel(a: GameSummary["attention"]): string {
@@ -336,6 +351,28 @@ class Net {
spectating = $state(false); spectating = $state(false);
/** How many watch from the gallery (0 hides the count). */ /** How many watch from the gallery (0 hides the count). */
audience = $state(0); audience = $state(0);
/** A command on its way: sent, and the table has not answered yet. */
pending = $state<{ label: string; at: number } | null>(null);
/** The last command the table answered, shown for a moment. */
confirmed = $state<string | null>(null);
/** A command that never left: the socket was down when it was tried. */
unsent = $state<string | null>(null);
/** A command that left, then the socket dropped before the table answered. */
unconfirmed = $state<string | null>(null);
/** A line of table talk on its way, until the table echoes it. */
chatPending = $state<string | null>(null);
/** Table talk that never arrived: handed back to the composer. */
chatUnsent = $state<string | null>(null);
/** Who spoke last at the table: your own words are never unread. */
lastTalkBy = $state<string | null>(null);
/** A finished table's call for a rematch: where it went, and who called. */
rematchCall = $state<{ roomId: string; by: string } | null>(null);
/** A rematch lobby: the last table's wizards not yet seated. */
expected = $state<string[]>([]);
/** The table called the keeper of this site, and by whom. */
challenge = $state<{ by: string; at: string } | null>(null);
/** The keeper's name, as the server knows it. */
keeper = $state("Kestrel");
view = $state<GameView | null>(null); view = $state<GameView | null>(null);
log = $state<LogLine[]>([]); log = $state<LogLine[]>([]);
error = $state<string | null>(null); error = $state<string | null>(null);
@@ -343,11 +380,13 @@ class Net {
feedbackReports = $state<FeedbackReportView[]>([]); feedbackReports = $state<FeedbackReportView[]>([]);
/** Every seat this browser holds, across rooms. */ /** Every seat this browser holds, across rooms. */
seats = $state<Seat[]>(loadSeats()); seats = $state<Seat[]>(loadSeats());
/** Seats the last games answer refused: dropped only if refused again. */
private voidedOnce = new Set<string>();
/** Lobby ledger: one summary per live seat. */ /** Lobby ledger: one summary per live seat. */
stats = $state<Record<string, number | string | null> | null>(null); stats = $state<Record<string, number | string | null> | null>(null);
chatSeen = $state<Record<string, number>>(loadChatSeen()); chatSeen = $state<Record<string, number>>(loadChatSeen());
/** Messages in the current room this session (history + live). */ /** Messages in the current room this session (history + live). */
chatCount = 0; chatCount = $state(0);
games = $state<GameSummary[]>([]); games = $state<GameSummary[]>([]);
notificationsEnabled = $state( notificationsEnabled = $state(
typeof Notification !== "undefined" && Notification.permission === "granted", typeof Notification !== "undefined" && Notification.permission === "granted",
@@ -362,6 +401,11 @@ class Net {
onFx: ((events: GameEvent[]) => void) | null = null; onFx: ((events: GameEvent[]) => void) | null = null;
/** A catch-up reel delivered by the server. */ /** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null); catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** The missed steps, fetched for the summary slip before any reel is opened. */
missedSteps = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** Where the catch-up reel opens: the step a summary line pointed at. */
catchUpStart = $state(0);
private catchUpWanted: "summary" | "reel" = "reel";
/** One turn's reel, summoned from a chronicle line's instant-replay /** One turn's reel, summoned from a chronicle line's instant-replay
* eye steps plus the wizard whose eyes the camera wears. */ * eye steps plus the wizard whose eyes the camera wears. */
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null); moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
@@ -421,6 +465,10 @@ class Net {
ws.onclose = () => { ws.onclose = () => {
this.status = "disconnected"; this.status = "disconnected";
this.ws = null; this.ws = 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; }
if (this.chatPending) { this.chatUnsent = this.chatPending; this.chatPending = null; }
setTimeout(() => this.connect(), 1500); setTimeout(() => this.connect(), 1500);
}; };
ws.onmessage = (raw) => { ws.onmessage = (raw) => {
@@ -479,6 +527,10 @@ class Net {
this.roomColors = msg.colors ?? {}; this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {}; this.roomBots = msg.bots ?? {};
this.audience = msg.audience ?? 0; this.audience = msg.audience ?? 0;
this.rematchCall = msg.rematch ?? null;
this.expected = msg.expected ?? [];
this.challenge = msg.challenge ?? null;
if (typeof msg.keeper === "string") this.keeper = msg.keeper;
if (this.you && this.token) { if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token }; const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat)); localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -490,6 +542,15 @@ class Net {
break; break;
case "state": { case "state": {
this.view = msg.view; this.view = msg.view;
// The table answered: the last command landed, and any doubt from
// a dropped socket is settled by the board itself.
if (this.pending) {
const label = this.pending.label;
this.pending = null;
this.confirmed = label;
setTimeout(() => { if (this.confirmed === label) this.confirmed = null; }, 1500);
}
this.unconfirmed = null;
if (typeof msg.seq === "number" && this.roomId && !this.spectating) { if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
this.currentSeq = msg.seq; this.currentSeq = msg.seq;
// Only the FIRST state after arriving carries a gap worth // Only the FIRST state after arriving carries a gap worth
@@ -503,13 +564,17 @@ class Net {
const last = this.seen[this.roomId] ?? 0; const last = this.seen[this.roomId] ?? 0;
this.missedMoves = Math.max(0, msg.seq - last); this.missedMoves = Math.max(0, msg.seq - last);
if (this.missedMoves === 0) this.markSeen(); if (this.missedMoves === 0) this.markSeen();
// The missed steps come now, for the facts on the slip; the
// reel waits for a tap.
else if (!this.missedSteps) this.requestCatchUp(true);
} }
if (document.visibilityState === "visible") this.watching = this.roomId; if (document.visibilityState === "visible") this.watching = this.roomId;
} }
break; break;
} }
case "catchUp": case "catchUp":
this.catchUp = msg.steps; if (this.catchUpWanted === "summary") this.missedSteps = msg.steps;
else this.catchUp = msg.steps;
break; break;
case "moment": case "moment":
this.moment = { steps: msg.steps, owner: msg.owner }; this.moment = { steps: msg.steps, owner: msg.owner };
@@ -523,7 +588,7 @@ class Net {
let talk = 0; let talk = 0;
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]); if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
for (const e of msg.events as GameEvent[]) { for (const e of msg.events as GameEvent[]) {
if (e.type === "tableTalk") talk++; if (e.type === "tableTalk") { talk++; this.lastTalkBy = e.player; }
if (e.type === "gameStarted" && !msg.replayed) { if (e.type === "gameStarted" && !msg.replayed) {
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players }; this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
} }
@@ -538,6 +603,10 @@ class Net {
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type)); (e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable, actor: actorOf(e, this.turnOwner) }]; this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable, actor: actorOf(e, this.turnOwner) }];
} }
const receipt = receiptFor(msg.events as GameEvent[], e);
if (receipt) {
this.log = [...this.log, { text: receipt.title, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable: false, actor: actorOf(e, this.turnOwner), receipt: receipt.lines }];
}
} }
if (talk > 0) { if (talk > 0) {
this.chatCount += talk; this.chatCount += talk;
@@ -563,7 +632,21 @@ class Net {
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token }); this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
break; break;
} }
case "rematch":
// The old table hears where the rematch went.
if (msg.roomId === this.roomId) this.rematchCall = { roomId: msg.to, by: msg.by };
break;
case "rematched":
// The caller's own move: the seat and room for the new table follow.
this.resetChronicle();
this.view = null;
this.started = false;
this.roomId = null;
this.roomIdPending = msg.roomId;
break;
case "chat": { case "chat": {
if (msg.player === this.you && this.chatPending === msg.text) this.chatPending = null;
this.lastTalkBy = msg.player;
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }]; this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false, actor: msg.player }];
this.chatCount += 1; this.chatCount += 1;
if (this.roomId) this.markChatSeen(); if (this.roomId) this.markChatSeen();
@@ -592,8 +675,13 @@ class Net {
// stays — a restarting server, a stale restore, or the wrong // stays — a restarting server, a stale restore, or the wrong
// backend all answer with ignorance, and ignorance is not // backend all answer with ignorance, and ignorance is not
// deletion. The wallet is capped by age instead of by trust. // deletion. The wallet is capped by age instead of by trust.
// And a seat is dropped only on the SECOND refusal in a row: one
// refusal around a restart or a stale answer must not cost a seat.
const voided = new Set((msg.voided as string[] | undefined) ?? []); const voided = new Set((msg.voided as string[] | undefined) ?? []);
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`)); const doubted = new Set<string>();
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); if (kept.length > 50) kept = kept.slice(kept.length - 50);
if (kept.length !== this.seats.length) { if (kept.length !== this.seats.length) {
this.seats = kept; this.seats = kept;
@@ -614,22 +702,32 @@ class Net {
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) { if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
this.roomIdPending = null; this.roomIdPending = null;
} }
this.pending = null;
this.error = msg.message; this.error = msg.message;
// The toast fades; the chronicle remembers why nothing happened. // The toast fades; the chronicle remembers why nothing happened.
this.log = [...this.log, { text: `${msg.message}`, turn: null, notable: false }]; this.log = [...this.log, { text: `${msg.message}`, turn: null, notable: false }];
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000); setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break; break;
} }
} }
private send(message: unknown): void { /** True when the message left; false when the socket was not open to carry it. */
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message)); private send(message: unknown): boolean {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify(message));
return true;
}
/** A toast the table shows for a moment, as it shows the server's refusals. */
flash(message: string): void {
this.error = message;
setTimeout(() => { if (this.error === message) this.error = null; }, 5000);
} }
create(name: string): void { create(name: string): void {
this.you = name; this.you = name;
this.spectating = false; this.spectating = false;
this.send({ type: "create", name }); 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. */ /** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
@@ -646,7 +744,9 @@ class Net {
const existing = this.seats.find( const existing = this.seats.find(
(s) => s.roomId === this.roomIdPending && s.name === name, (s) => s.roomId === this.roomIdPending && s.name === name,
); );
this.send({ type: "join", roomId, name, token: existing?.token ?? this.token }); if (!this.send({ type: "join", roomId, name, token: existing?.token ?? this.token })) {
this.flash("Still reaching the table — try again in a moment");
}
} }
/** Sit back down at a remembered seat. */ /** Sit back down at a remembered seat. */
@@ -704,8 +804,30 @@ class Net {
this.send({ type: "rollDie" }); this.send({ type: "rollDie" });
} }
sendChat(text: string): void { /** True when the line left; the composer keeps it otherwise. */
this.send({ type: "chat", text }); sendChat(text: string): boolean {
if (!this.send({ type: "chat", text })) return false;
this.chatPending = text;
return true;
}
/** Call the keeper of this site to the table: a seat is held, and their phone rings. */
challengeKeeper(): void {
this.send({ type: "challengeKeeper" });
}
/** Call for a rematch from a finished table, or join the one already called. */
callRematch(): void {
this.send({ type: "rematch" });
}
/** Take the seat kept for you at the rematch table. */
acceptRematch(roomId: string): void {
if (!this.you) return;
this.resetChronicle();
this.view = null;
this.started = false;
this.join(roomId, this.you);
} }
/** Watching the table counts as reading the talk. */ /** Watching the table counts as reading the talk. */
@@ -776,6 +898,15 @@ class Net {
* line, and wiping it would erase the only notice of why. */ * line, and wiping it would erase the only notice of why. */
leaveLocal(): void { leaveLocal(): void {
localStorage.removeItem(SEAT_KEY); localStorage.removeItem(SEAT_KEY);
this.missedSteps = null;
this.catchUp = null;
this.pending = null;
this.unsent = null;
this.unconfirmed = null;
this.chatPending = null;
this.rematchCall = null;
this.expected = [];
this.challenge = null;
this.roomId = null; this.roomId = null;
this.roomIdPending = null; this.roomIdPending = null;
this.view = null; this.view = null;
@@ -808,18 +939,27 @@ class Net {
this.send({ type: "catchUp", sinceSeq: 0, full: true }); this.send({ type: "catchUp", sinceSeq: 0, full: true });
} }
/** Ask for the reel of everything since we last watched. */ /** Ask for the steps since we last watched: for the summary slip, or for the reel. */
requestCatchUp(): void { requestCatchUp(summaryOnly = false): void {
if (!this.roomId) return; if (!this.roomId) return;
this.catchUpWanted = summaryOnly ? "summary" : "reel";
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 }); this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });
} }
/** Open the reel of the missed steps, at the step a fact pointed to. */
watchCatchUp(startAt = 0): void {
this.catchUpStart = Math.max(0, startAt);
if (this.missedSteps) this.catchUp = this.missedSteps;
else this.requestCatchUp(false);
}
/** All caught up: remember it and clear the banner. */ /** All caught up: remember it and clear the banner. */
markSeen(): void { markSeen(): void {
if (!this.roomId) return; if (!this.roomId) return;
this.seen[this.roomId] = this.currentSeq; this.seen[this.roomId] = this.currentSeq;
localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen)); localStorage.setItem(SEEN_KEY, JSON.stringify(this.seen));
this.missedMoves = 0; this.missedMoves = 0;
this.missedSteps = null;
} }
closeCatchUp(): void { closeCatchUp(): void {
@@ -864,8 +1004,33 @@ class Net {
this.momentTurn = null; this.momentTurn = null;
} }
command(command: Command): void { /** Send a command, or say plainly that it did not go. */
this.send({ type: "command", command }); command(command: Command): boolean {
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`);
return false;
}
this.unsent = null;
this.pending = { label, at: Date.now() };
return true;
}
}
/** A command as the player would name it, for the sending and not-sent notes. */
function describeCommand(c: Command): string {
switch (c.type) {
case "move": return "your step";
case "cast": return "your spell";
case "counteract": return "your counter";
case "pass": return "your pass";
case "punch": case "punchWall": return "your punch";
case "endTurn": return "ending your turn";
case "pickUpTreasure": case "pickUpObject": return "the pickup";
case "dropTreasure": case "dropObject": return "the drop";
case "playNumberForMovement": return "your number";
default: return "your action";
} }
} }
+5 -3
View File
@@ -25,6 +25,9 @@ export interface Prefs {
/** The hand of cards stands in a column at the board's left instead of /** The hand of cards stands in a column at the board's left instead of
* lying along the bottom (wide screens only). */ * lying along the bottom (wide screens only). */
handLeft: boolean; handLeft: boolean;
/** The hand's side was picked by hand; until then a wide, short screen
* stands the cards beside the board on its own. */
handLeftChosen: boolean;
/** The first-turn coaching slip has been read and dismissed. */ /** The first-turn coaching slip has been read and dismissed. */
coached: boolean; coached: boolean;
/** The automaton tier was picked by hand (so a finished game leaves it alone). */ /** The automaton tier was picked by hand (so a finished game leaves it alone). */
@@ -42,7 +45,7 @@ const KEY = "wizwar-prefs";
function load(): Prefs { function load(): Prefs {
const fallback: Prefs = { const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null, art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false, cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false, handLeftChosen: false,
coached: false, tierChosen: false, finishedGames: 0, stepLightAlways: false, coached: false, tierChosen: false, finishedGames: 0, stepLightAlways: false,
}; };
try { try {
@@ -56,12 +59,11 @@ function load(): Prefs {
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "", wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null, color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
cautions: p.cautions !== false, cautions: p.cautions !== false,
// A first game is against an apprentice; a browser that has finished
// one is promoted to adept unless the tier was picked by hand.
botTier: p.botTier === "adept" || p.botTier === "archmage" ? p.botTier : "apprentice", botTier: p.botTier === "adept" || p.botTier === "archmage" ? p.botTier : "apprentice",
instantReplay: p.instantReplay !== false, instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true, liveFp: p.liveFp === true,
handLeft: p.handLeft === true, handLeft: p.handLeft === true,
handLeftChosen: p.handLeftChosen === true || p.handLeft === true,
coached: p.coached === true, coached: p.coached === true,
tierChosen: p.tierChosen === true, tierChosen: p.tierChosen === true,
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0, finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
+102
View File
@@ -0,0 +1,102 @@
// The receipt under a resolved attack: the blow as it came in, what each
// counter left of it, what landed, and whose life moved — read off the
// engine's own events, so it explains what happened rather than what the
// cards promise.
import { cardDef, type GameEvent } from "@wizwar/engine";
export interface Receipt {
title: string;
lines: string[];
}
function nameOf(id: string | null): string {
if (!id) return "The punch";
try { return cardDef(id).name; } catch { return id; }
}
/** The receipt for a resolution event, or null when there is nothing to
* account for. `batch` is the event list the resolution arrived in: the
* damage and life bookkeeping of the same exchange sits beside it. */
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.defender} outran it.`;
return { title: `${nameOf(e.attackCardId)} misses ${e.defender}`, lines: [why] };
}
if (e.type !== "attackResolved") return null;
const at = batch.indexOf(e);
let start = 0;
for (let i = at - 1; i >= 0; i--) {
if (batch[i]!.type === "attackResolved") { start = i + 1; break; }
}
const exchange = batch.slice(start, at + 1);
const returned = exchange.some((x) => x.type === "damaged" && /\(reflected\)/.test(x.source));
const lines: string[] = [];
const healed = exchange.find((x) => x.type === "lifeGained" && /\(reversed\)/.test(x.source));
const tookHold = exchange.find((x) => x.type === "spellSustained");
if (e.incoming != null) {
const card = nameOf(e.attackCardId);
const dur0 = e.incomingDuration ?? 0;
const parts = [
e.incoming > 0 ? `${e.incoming} incoming`
: dur0 > 0 ? `${dur0} turn${dur0 === 1 ? "" : "s"} of ${card} incoming`
: `${card} incoming`,
];
let damage = e.incoming;
let duration = dur0;
let reflected = 0;
for (const t of e.trail ?? []) {
const c = nameOf(t.cardId);
if (t.nullified) { parts.push(`${t.player}'s ${c} is nullified`); continue; }
const off = damage - t.damage;
const back = t.reflected - reflected;
const turnsOff = duration - t.duration;
let verb: string;
if (t.cardId === "full-reflection") verb = "turns it around";
else if (t.cardId === "reverse") verb = healed ? "turns it into healing" : "reverses it";
else if (t.cardId === "empathy") verb = "shares it with the caster";
else if (t.cardId === "anti-anti") verb = "cancels the counter before it";
else if (back > 0) verb = t.damage > 0 ? `sends ${back} back and lets ${t.damage} through` : `sends ${back} back`;
else if ((t.damage === 0 && damage > 0) || (t.duration === 0 && duration > 0 && damage === 0)) verb = "stops it cold";
else if (off > 0) verb = `takes ${off} off`;
else if (turnsOff > 0) verb = `takes ${turnsOff} turn${turnsOff === 1 ? "" : "s"} off`;
else verb = "changes nothing";
parts.push(`${t.player}'s ${c} ${verb}`);
damage = t.damage;
duration = t.duration;
reflected = t.reflected;
}
parts.push(
e.redirected ? `the whole blow turns back on ${e.attacker}`
: e.fullyStopped ? "nothing lands"
: healed && healed.type === "lifeGained" ? `${healed.amount} heals ${e.defender} instead`
: e.damageDealt > 0 ? `${e.damageDealt} lands on ${e.defender}`
: tookHold && tookHold.type === "spellSustained" ? `${card} takes hold of ${e.defender} for ${tookHold.turns} turn${tookHold.turns === 1 ? "" : "s"}`
: "nothing to land",
);
lines.push(parts.join(" → "));
} else if (e.redirected) {
lines.push(`the whole blow turns back on ${e.attacker}`);
} else if (e.fullyStopped) {
lines.push("nothing lands");
}
for (const x of exchange) {
if (x.type === "damaged" && x.amount > 0) {
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}`);
} else if (x.type === "lifeGained") {
lines.push(`${x.player}: ${x.lifeAfter - x.amount}${x.lifeAfter} life, reversed`);
} else if (x.type === "stonesDestroyed") {
lines.push(`${x.player}'s magic stones are destroyed`);
}
}
if (lines.length === 0) return null;
return { title: `${nameOf(e.attackCardId)}${returned ? ", returned," : ""} resolved`, lines };
}
+309
View File
@@ -0,0 +1,309 @@
// The reference desk behind the help: the rules and FAQ as one searchable,
// linkable body; the house rulings keyed to the cards they touch; and the
// small text services (inline emphasis, cross-references) the help renders
// with. Everything here is data and pure functions; Help.svelte presents it.
import { allCardDefs, cardDef, type CardDef } from "@wizwar/engine";
import { RULES_SECTIONS } from "./rules";
import { RULEBOOK_BASE, RULEBOOK_EXPANSION } from "./rulebook";
import { FAQ_GENERAL } from "./faq-general";
/** A stable anchor from a title: "Line of Sight (L.O.S.)" → "line-of-sight-l-o-s". */
export function slug(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
export type RefSource = "summary" | "rulebook" | "expansion" | "faq";
export const SOURCE_LABELS: Record<RefSource, string> = {
summary: "the rules, condensed",
rulebook: "the rulebook, verbatim",
expansion: "Expansion Set 1, verbatim",
faq: "official FAQ",
};
export interface RefSection {
/** Unique across all sources: the source and the title's slug. */
id: string;
source: RefSource;
title: string;
paragraphs: string[];
}
function sections(source: RefSource, list: { title: string; body?: string[]; paragraphs?: string[] }[]): RefSection[] {
return list.map((s) => ({
id: `${source}-${slug(s.title)}`,
source,
title: s.title,
paragraphs: s.body ?? s.paragraphs ?? [],
}));
}
/** Every section of the rules tab, in reading order. */
export const REFERENCE: RefSection[] = [
...sections("summary", RULES_SECTIONS),
...sections("rulebook", RULEBOOK_BASE),
...sections("expansion", RULEBOOK_EXPANSION),
...sections("faq", FAQ_GENERAL),
];
/** The sections whose title or text mention every word of the query. */
export function searchReference(query: string): RefSection[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return REFERENCE;
return REFERENCE.filter((s) => {
const hay = `${s.title}\n${s.paragraphs.join("\n")}`.toLowerCase();
return words.every((w) => hay.includes(w));
});
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** The transcriptions carry light Markdown: **bold** for the rulebook's own
* run-in headings, and *(6E: )* for this table's editorial notes on where
* the sixth edition differs from the text it shipped with. Rendered, with
* the notes set apart from the original words. */
export function renderInline(text: string): string {
return escapeHtml(text)
.replace(/\*\((6E:[\s\S]*?)\)\*/g, '<span class="ed-note">($1)</span>')
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
}
/** The playable pool: every card in the 6e box and its expansion. */
export const CARD_POOL: CardDef[] = allCardDefs().filter(
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
);
export type CardTypeFilter = "all" | "attack" | "neutral" | "counteraction" | "number" | "object" | "trap";
export type SetFilter = "all" | "basic" | "expansion1";
export type SightFilter = "all" | "los" | "adjacent" | "none";
export interface CardQuery {
text: string;
set: SetFilter;
type: CardTypeFilter;
sight: SightFilter;
}
function typeMatches(d: CardDef, t: CardTypeFilter): boolean {
if (t === "all") return true;
const kind = d.cardType ?? "";
if (t === "neutral" || t === "counteraction") return kind === t || kind === "neutral/counteraction";
return kind === t;
}
function sightMatches(d: CardDef, s: SightFilter): boolean {
if (s === "all") return true;
if (s === "los") return d.los === true;
if (s === "adjacent") return d.adjacent === true;
return d.los !== true && d.adjacent !== true;
}
/** Cards matching the query, ranked: an exact name first, then names that
* start with the words, then names that contain them, then texts that do.
* Alphabetical within each rank. */
export function searchCards(q: CardQuery): CardDef[] {
const text = q.text.trim().toLowerCase();
const rank = (d: CardDef): number => {
if (!text) return 2;
const name = d.name.toLowerCase();
if (name === text) return 0;
if (name.startsWith(text)) return 1;
if (name.includes(text)) return 2;
if ((d.text ?? "").toLowerCase().includes(text)) return 3;
return -1;
};
return CARD_POOL
.filter((d) => typeMatches(d, q.type) && sightMatches(d, q.sight))
.filter((d) => q.set === "all" || d.set === q.set || d.alsoIn?.some((a) => a.set === q.set))
.map((d) => ({ d, r: rank(d) }))
.filter((x) => x.r >= 0)
.sort((a, b) => a.r - b.r || a.d.name.localeCompare(b.d.name))
.map((x) => x.d);
}
/** Other cards a card's text names in capitals "a NUMBER card" is a
* kind, "MAD DASH" is a card. Longest names first so SLOW DEATH is not
* also read as SLOW. */
export function mentionedCards(cardId: string): CardDef[] {
const def = cardDef(cardId);
let text = def.text ?? "";
if (!text) return [];
text = text.replace(new RegExp(`\\b${def.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), " ");
const found: CardDef[] = [];
const candidates = CARD_POOL
.filter((d) => d.id !== cardId && d.name.length >= 4 && d.cardType !== "number")
.sort((a, b) => b.name.length - a.name.length);
for (const d of candidates) {
const re = new RegExp(`\\b${d.name.toUpperCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
if (re.test(text)) {
found.push(d);
text = text.replace(new RegExp(re.source, "g"), " ");
}
}
return found.sort((a, b) => a.name.localeCompare(b.name));
}
/** How a card's corner reads: where it must be aimed. */
export function sightOf(d: CardDef): string {
if (d.adjacent) return "adjacent";
if (d.los) return "line of sight";
return "no target";
}
export function typeLabel(d: CardDef): string {
switch (d.cardType) {
case "neutral/counteraction": return "neutral or counteraction";
case null: return "card";
default: return d.cardType;
}
}
export function setLabel(d: CardDef): string {
const also = d.alsoIn?.length ? ` and ×${d.alsoIn[0]!.quantity} in the expansion` : "";
return `×${d.quantity} in the ${d.set === "basic" ? "base deck" : "expansion"}${also}`;
}
/** A house ruling: how this table resolves something the cardboard leaves
* to the players, in the present tense, keyed to the cards it touches. */
export interface HouseRuling {
id: string;
title: string;
cards: string[];
body: string[];
}
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."],
},
{
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."],
},
{
id: "ambush", title: "Ambushes", cards: ["opportunity-fire"],
body: ["An ambush (OPPORTUNITY FIRE) is set with the attack card it will fire and a trigger of your choice: an opponent entering your line of sight, coming within one square, or picking up any treasure. It springs on their turn, out of yours."],
},
{
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.",
"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."],
},
{
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.",
"\"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."],
},
{
id: "dust-cloud", title: "Dust clouds", cards: ["dust-cloud"],
body: ["A DUST CLOUD blinds whoever stands in it. No line-of-sight spell may be cast from inside a cloud, nor at anyone standing in one, and sight lines that pass through it are blocked. Spells a wizard casts on themself still work."],
},
{
id: "mental-force", title: "Mental Force", cards: ["mental-force"],
body: ["MENTAL FORCE refuses a destination the victim cannot walk to in three spaces, rather than spending the card on nothing."],
},
{
id: "strength", title: "Tearing a treasure away", cards: ["strength"],
body: ["STRENGTH's treasure-tear is an attack: it opens a counteraction window (\"this would be an attack\") rather than resolving on the spot."],
},
{
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."],
},
{
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."],
},
{
id: "soulstone", title: "Soulstone", cards: ["soulstone"],
body: ["SOULSTONE's floor holds even when its bearer is at three life-points or below."],
},
{
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."],
},
{
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.",
"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.",
],
},
{
id: "slow-death", title: "Slow Death's bites", cards: ["slow-death", "absorb", "blunt"],
body: ["SLOW DEATH's per-draw bites land as one blow, which pauses for a victim holding ABSORB or BLUNT. The counter is played against the total: ABSORB soaks up to three, BLUNT halves it rounding up."],
},
{
id: "mad-dash", title: "Mad Dash", cards: ["mad-dash", "power-run"],
body: ["MAD DASH doubles \"NUMBER cards and other add-ons\" too. A number riding the cast fuels it, and numbers or POWER RUN points played under the dash are doubled as well."],
},
{
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."],
},
{
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."],
},
{
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."],
},
{
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.",
],
},
{
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."],
},
{
id: "waterwall", title: "The waterwall's wave", cards: ["waterwall"],
body: ["A waterwall's wave names its victims before it pushes any of them, so no wizard is caught twice by the same wave. One square into a wall costs one point."],
},
{
id: "lifesaver", title: "Lifesaver", cards: ["lifesaver"],
body: ["A wizard holding LIFESAVER is not eliminated for losing both treasures, exactly as the card promises."],
},
{
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."],
},
];
/** The rulings that touch a card. */
export function rulingsFor(cardId: string): HouseRuling[] {
return HOUSE_RULINGS.filter((r) => r.cards.includes(cardId));
}
/** Rulings whose title, text, or cards mention every word of the query. */
export function searchRulings(query: string): HouseRuling[] {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return HOUSE_RULINGS;
return HOUSE_RULINGS.filter((r) => {
const names = r.cards.map((id) => { try { return cardDef(id).name; } catch { return id; } });
const hay = `${r.title}\n${r.body.join("\n")}\n${names.join("\n")}`.toLowerCase();
return words.every((w) => hay.includes(w));
});
}
+7
View File
@@ -91,6 +91,13 @@ export const RULES_SECTIONS: RulesSection[] = [
]; ];
export const HOW_TO_PLAY: RulesSection[] = [ 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.",
],
},
{ {
title: "Moving", title: "Moving",
body: [ body: [