134 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 0d2b25ad74 Go Away routs 'as straight a line as possible' (rules rev 36)
The card's line bends: a victim shoved against a wall moved zero
squares and just lost the turn, because GO AWAY borrowed waterbolt's
straight-line knockback. Its own rout now holds the heading while open,
bends around walls, sidesteps when no ground can be gained, rolls the
D4 only when options tie, and stops early only when truly cornered.
Waterbolt's physical shove stays straight, as a shove should. Ledgers
verified before deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-20 11:15:19 -04:00
Eric WagonerandClaude Fable 5 76dd019d5d Ambushed teleports carry their destination; room 59UN unstuck (rev 35)
An ambush springing TELEPORT OPPONENT opened a stack with params null —
the spring bypasses cast validation and nothing ever asked where the
victim goes — and resolution crashed on the missing cell, wedging the
room 'waiting on Automaton'. Three layers:
- Crash guards: teleport-opponent and mental-force fizzle gracefully on
  a destination-less stack (ungated: no stored command had resolved one).
- The trap commits its destination when laid: setAmbush carries a cell
  ('wherever you say', said in advance), the spring passes it into the
  stack, and the client's arming flow asks for the click.
- Rev 35 refuses arming those spells without a destination; older
  ledgers armed blind and their springs fizzle.
All ledgers verified before deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-20 10:51:16 -04:00
Eric WagonerandClaude Fable 5 e8c25ddc19 Stone Dead counts only the stones in play (rules rev 34)
Same reading as fireball's rev 33: 'carrying' means displayed. Counting
hidden hand stones both inflated the damage and broadcast the secret
count in the total. The automatons' estimate (displayed as the visible
floor) is now simply the truth. All production ledgers verified clean
before deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 22:22:45 -04:00
Eric WagonerandClaude Fable 5 b67f2a4c8a Fireball burns only the stones in play (rules rev 33)
'Destroys all magical stones an opponent is CARRYING' — the owner's
ruling: a stone hidden in the hand is a secret card, not a carried
stone. Only displayed stones burn; the old all-hand burning also leaked
hidden information by announcing cards nobody knew existed. Earlier
games replay their broader fire. All 42 production ledgers verified
before deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 22:19:20 -04:00
Eric WagonerandClaude Fable 5 1cf5d0a68d The determinism harness moves into deploy/ for real
deploy/verify-ledgers.sh fetches every production ledger and strictly
replays it against the local engine (any refusal or crash fails; a
lobby that never dealt is trivially clean). Run from the repo root
before deploying engine changes — the check both of tonight's replay
breaks would have caught.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 22:06:56 -04:00
Eric WagonerandClaude Fable 5 c87b8cb28d Hotfix: the wrap gains its vintage gate; every ledger verified
Same lesson twice in one night: the toroidal dread applied at every rev
and QD3S (recorded flat) refused seq 76 again on restore. rev >= 32
wraps; older vintages measure the flat diamond they were played under.
This time verified properly: a strict replay harness (any refused
command is a hard failure) ran all 41 production ledgers against the
new engine before deploy — 0 failures. That harness now lives in the
repo as the pre-deploy determinism check it always should have been.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 22:03:21 -04:00
Eric WagonerandClaude Fable 5 0a0108c48c The dread wraps: fear's diamond continues from the opposite rim
The maze is a torus — a wizard walking through walls off one edge
arrives at the other, so the crow flies around the wrap too.
dreadDistance measures toroidal manhattan; the aura's wash wraps with
it. No rev-32 game has cast fear yet, so the measure corrects in
place; older vintages replay flat as they were played.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 22:00:29 -04:00
Eric WagonerandClaude Fable 5 bcd2155fe0 IDIOT's duty spelled out, like FEAR's
The engine already steers every step and bars items, attacks, and
non-aiding casts; the one honor-bound piece is spending the movement at
all. The afflicted now see the card's demand in an urgent slip the
moment their turn opens, matching the FEAR pattern: bots comply
mechanically, humans get the words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 21:53:01 -04:00
Eric WagonerandClaude Fable 5 6ec9d6ab55 FEAR's rout: bots flee it, humans are told their duty
'Players or monsters must move away from you on their turn if within
range, if they can.' Automatons caught in the dread now spend their
legs escaping before any other marching — the step that most increases
distance, a true dead end excusing them per the card. Humans keep their
freedom but get the compulsion spelled out in an urgent slip (move
away, even by card or through danger; only a dead end excuses you),
with the maze trusting the table to honor it — the owner's chosen
depth of enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 21:51:22 -04:00
Eric WagonerandClaude Fable 5 fdb44ab09f Fear measures as the card says: through walls, never diagonal, no warps
The FAQ is exact: 'The 3 spaces away is measured as a Wizard walks (if
he could walk through walls), not diagonally' — manhattan, at every
vintage. The walking-distance detour is reverted (no rev-32 ledger ever
recorded under it); the rev-32 gains stand: monsters, warp-steps, and
teleports all honor the dread. The aura returns to the true diamond,
now as a wash on its member squares.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 21:46:44 -04:00
Eric WagonerandClaude Fable 5 724d178636 Hotfix: fear's new metric leaked into old vintages and broke replay
QD3S failed to restore ('replay failed at seq 76: an unnatural dread
keeps you away'): the walkingDistance switch ran at every rev, refusing
a move that was legal under the crow-flies rule when it was played.
fearRepels now measures walked spaces at rev 32+ and as the crow flies
below, so every stored ledger replays its own vintage. No data was
lost; the room's ledger was intact throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 21:40:48 -04:00
Eric WagonerandClaude Fable 5 410ebc0135 Fear's spaces are walked, not flown — warps carry the dread
The owner caught the metric: a wizard eight squares away as the crow
flies stood three WALKED spaces away through a warp, and the manhattan
check waved it through (while falsely blocking approaches manhattan-
near through solid walls). fearRepels now measures walkingDistance —
the same BFS the maze walks, warps included, walls respected. The
board's aura follows suit: the diamond becomes a violet wash on every
square within three walked spaces, computed by the same BFS, warp
mouths included. No rev-32 ledger existed yet, so the metric corrects
inside rev 32.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 21:34:15 -04:00
Eric WagonerandClaude Fable 5 644d651ae0 Fear wears its bubble: a breathing dashed diamond at three spaces
The dread was invisible, so a wizard legally circling at four squares
read as a rules failure (QD3S: no wizard ever actually breached the
bubble — only the monster, fixed at rev 32). Every feared player now
radiates a manhattan-3 diamond, dashed violet, breathing slowly; what
the engine refuses, the board now shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 21:29:31 -04:00
Eric WagonerandClaude Fable 5 0e9d482495 Fear repels every willing step (rules rev 32)
Room QD3S: Kestrel cast Fear and a Democratic Monster warp-stepped from
eight squares away to ONE — moveCreature never knew the card existed.
'No player or monster will move to within 3 spaces of you' now guards
every willing move: walking wizards (as before), commanded monsters
(walks, wall-passes, and warp mouths alike), dimensional warp-steps,
and self-teleports. Forced movement — knockback, waves, Mental Force,
Teleport Opponent — is not willing, and still throws you anywhere.
The shared fearRepels helper replaces the inline wizard-only check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 21:23:47 -04:00
Eric WagonerandClaude Fable 5 172fbc6ff8 Marching the Democratic Monster onto yourself takes two taps
Its card claws WHOEVER's square it enters, mover included — the owner
learned this standing beside a troll it could not touch. The client now
warns on the first tap ('it will claw YOU') and commits on the second;
no warning when its round's claw is already spent, when the walk is
safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 20:39:42 -04:00
Eric WagonerandClaude Fable 5 75d3d44c6c The Ward is played in the moment (rules rev 31)
'When a player picks up one of your treasures, you may play at that
time (out of turn) this card on him' — the owner read the card and is
right: no arming ahead. When a treasure whose owner holds WARD is
grabbed, the grab hangs (new wardPending phase, outranking all other
input) and the owner alone answers: spring it (3 damage, card spent)
or let them go (card kept, silence). Automatons always spring it on a
thief of their gold. The arming mechanic is refused at rev 31 and its
button hidden; rev 3-30 games keep their armed wards and rev 1-2 keep
the automatic spring, replaying unchanged. The window does reveal that
the owner holds SOMETHING - as it would at any real table when the
room turns to look at them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 20:17:43 -04:00
Eric WagonerandClaude Fable 5 b7f93fe454 Name and color join the preferences
Your wizard's name pre-fills the lobby (and is remembered whenever you
create or join), and a preferred color — six swatches in the slip, tap
again for none — is claimed quietly in any lobby where it is free and
you have not chosen. First-come still wins a contested color; the
preference never bumps anyone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 18:53:44 -04:00
Eric WagonerandClaude Fable 5 62c4680d1f The fallen drop what they carry, however they fall (rev 30)
A wizard eliminated by treasuresLost took any carried treasure into the
void with them — the killed path drops it, but checkVictory's branch
never did. At rev 30 the treasure lands where they stood, with the
usual treasureDropped event (onHomeOf included, so a capture completed
by the falling is honored in the same breath). Stored games replay the
vanishing unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 12:25:41 -04:00
Eric WagonerandClaude Fable 5 f1743e6fe5 Modal ink: .attack-title and pref rows stop inheriting the table's cream
'Preferences', 'Token art', and the checkbox labels were cream on cream
- .attack-title never had a color rule, so modals inherited the dark
table's pale text. Explicit dark ink; the illusion-test modal shared
the class and heals with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 10:05:07 -04:00
Eric WagonerandClaude Fable 5 6aae40ad5f Player preferences: art set, auto-grab, flourishes
A ⚙ preferences slip in the masthead, saved to this device:
- Token art, all or nothing: the photographed cardboard or the
  hand-drawn vectors (the workshop's per-category query params stay as
  the undocumented editing override).
- Ending a turn on a lone grabbable treasure picks it up on the way
  out — never on your own home, never when two share the square.
- Spell flourishes on or off, honored by the live board and the
  replay reel alike (trap notices still fire; they are information).
Preferences change what you see or what routine commands the client
sends for you — never what is legal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-19 09:45:12 -04:00
Eric WagonerandClaude Fable 5 5310fbdca9 No movement number is spent on a turn that ends at a grab
Room M9QJ, seq 190: a berserker played a 5 to charge a distant enemy,
walked two squares, crossed a treasure, and the gold-underfoot rung —
which outranks everything — ended its actions with six paid moves
unwalked. Before spending a number, the march now checks whether any
goal treasure lies within the free legs: if the turn is going to end at
a grab, the number stays in hand. (Even a charging berserker stoops
for loot; it just shouldn't pay toll first.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 16:50:17 -04:00
Eric WagonerandClaude Fable 5 63e8d4c545 Mental Force becomes castable: the destination step it never got
The card prints no LOS and the engine asks none — but the client never
offered the two-step flow (click the victim, then click where they go),
so every cast arrived without a destination and was refused. It joins
teleport-opponent and shift-wand in the destination gate, with a hint
naming the three-space walk limit and the no-sight freedom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 16:21:09 -04:00
Eric WagonerandClaude Fable 5 4720ec25ff A bare wand lights a single charge
'ANY CARD MAY BE PLAYED WITHOUT A NUMBER CARD, but its power is only 1.'
The wand first-use gate was the engine's one remaining missing-number
refusal — everything else already defaults its power to 1. A wand cast
bare now charges to one, fires once, and crumbles; the client hint says
so. Ungated: the refused casts never reached a ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 10:47:05 -04:00
Eric WagonerandClaude Fable 5 1e5b7ee985 The game's own vocabulary: 'points' for segments, 'life-points' for the living
Wiz-War never says hitpoints. Walls and doors quote the rulebook's own
phrasing ('20 points to destroy', '13 of 20 points left'), and the
Mega-Monster boost button says life-points, as its card does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 10:44:12 -04:00
Eric WagonerandClaude Fable 5 0f91d30a3e Press-and-hold peeks a segment — the tooltip, for hands without a mouse
The same 450ms hold that peeks a square now works on walls, doors, and
fire walls: the segment's full description (state, lock, hitpoints, the
fire's burn warning) rises in the peek scrim. Shimmering illusions keep
their richer tap modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 10:40:55 -04:00
Eric WagonerandClaude Fable 5 497c2245a3 Every segment states its hitpoints in the tooltip
'a wall — 20 hitpoints'; battered, 'a wall — 13 of 20 hitpoints left';
doors the same against their 15, lock states included ('held open by a
standing wizard — 15 hitpoints'). The crack overlay already told the
damage; now the wall itself tells what still stands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 10:37:57 -04:00
Eric WagonerandClaude Fable 5 9eeb89caca Every edge answers a hover: tooltips for walls, doors, fire, illusions
Only the special door lock states carried titles. Now plain walls ('a
wall'), locked doors, the wall of fire (with its 4-damage warning), the
untested shimmer ('tap to test your eyes'), the known-illusion ghost,
and battle cracks (damage taken so far) all speak on hover. The
firewall's hover target is its bar; the flames stay click-through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 10:36:41 -04:00
Eric WagonerandClaude Fable 5 d8b8f8fd53 Wall-segment 'tokens' retire; the gallery shows edges as the maze draws them
The game has never used token art for edge effects — created walls,
doors and their lock states, fire walls, illusions are all vector edge
treatments. The ten unreferenced file pairs (wall, wall-2, door,
fire-wall, illusion-wall, destroy-wall, jam-lock, lock-gone,
redirection-a/b) are gone from both art sets, and the Token Workshop
gains 'Walls & doors — as the maze draws them': every edge state
(locked, ajar, held, removed, jammed), the standing fire, and both
faces of an illusion, rendered by the same EdgeGlyph / FirewallEdge /
IllusionShimmer components the board mounts. EdgeGlyph is newly
extracted from Board so the display cannot drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 10:16:28 -04:00
Eric WagonerandClaude Fable 5 e1aeeaabff The Token Workshop: every token, twice, at /?tokens
All 45 tokens in both arts — the photographed physical set beside the
hand-drawn contingency — grouped (wizards, treasures, monsters, terrain,
objects & markers), each at board size and close-up. The drawn column
renders through TokenArt, so it previews exactly as the board inlines
it. Cross-linked with the flourish workshop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 09:16:46 -04:00
Eric WagonerandClaude Fable 5 ed7a58a698 The workshop gains 'The standing fires': board furniture on display
FirewallEdge (both orientations), the illusion shimmer, and the sight
trace (direct and through-a-warp) join the /?fx gallery — and to keep
the display honest, the shimmer and sight trace were extracted from
Board.svelte's inline markup into IllusionShimmer.svelte and
SightTraceOverlay.svelte, so the workshop mounts the very components
the live board does. No copies to drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 09:13:31 -04:00
Eric WagonerandClaude Fable 5 ce556b5508 Wall of Fire burns like it means it
The pulsing red rectangle becomes a standing fire: a glowing gradient
bar seated on the wall line, five tongues of flame licking on staggered
beats, and embers drifting off the top. Pure CSS on a handful of shapes
- no turbulence filters, which stay budgeted for the one-shot Fireball,
since firewalls burn indefinitely. Vertical walls flame just as well as
horizontal ones; reduced motion stills the fire but keeps it visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 09:07:36 -04:00
Eric WagonerandClaude Fable 5 b1f03fee81 Ask the automatons: the council of three offers hints from your seat
The owner's idea for teaching novices: a hints button runs all three
temperaments' brains against YOUR view — the same cards and board you
see, nothing hidden — and each says what it would do in your robes.
The Hunter (eyes on the gold), the Berserker (eyes on the blood), the
Worrier (eyes on the exits). Available on your turn and while under
fire (counter advice included); purely advisory, computed client-side,
nothing dispatched. hints.ts translates any Command into plain words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 02:19:32 -04:00
Eric WagonerandClaude Fable 5 5ac1c9ed70 One-tap discard from the hand: select a card, 'Discard it'
Shedding a single card meant entering discard mode, marking, and
confirming. Now any selected card offers 'Discard it' right in its
action strip — beside 'Drop it here' when the card is an object, so
the drop-or-discard choice sits in one place. The bulk discard flow
stays for multi-card sheds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 02:12:39 -04:00
Eric WagonerandClaude Fable 5 d5b2b92f2c Illusion walls shimmer until YOU test them (rules rev 29)
Per the owner's design — and truer to a real table, where the cast is
public: every illusion wall's location is open knowledge, rendered as a
shimmering wall to any player whose eyes have not yet ruled on it.
Testing is an explicit act: tap the shimmer (or bump into it) and a
modal explains the 50/50 and offers 'Roll to test your eyes' — the new
testIllusion command rolls once, then the wall either hardens or
dissolves for you alone. Nobody's verdict is moved by watching someone
else stroll through (the Q3WZ mystery: an automaton walking through
its own round-1 fake). Untested walls block movement and sight without
consuming RNG; the pre-29 lazy belief roll is preserved for stored
games. Automatons doubt free shimmers on their best crossing before
spending any card on that wall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 01:54:53 -04:00
Eric WagonerandClaude Fable 5 500d9a7154 How-to-play gains 'The automatons': temperaments, tiers, mysteries
The help never explained the clockwork — hunter/berserker/worrier
moods, the mystery deal and its end-of-game unmasking, and what the
difficulty tiers actually change (resources and repertoire, never
fairness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 01:03:28 -04:00
Eric WagonerandClaude Fable 5 7d97bb1093 Bots stop wasting counters on what cannot be countered
Per the owner's ruling: EMPATHY mirrors only delivered damage, so a
damage-less curse gives it nothing — the berserker keeps it sheathed
against afflictions (an automaton burned one on SLOW DEATH). And the
permanent curses (slow death, walking dead, idiot) ride no duration
pipe, so BLUNT and shieldstone numbers can't shorten them: only a full
stop or REMOVE CURSE answers those, and the ladder now knows it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 00:57:31 -04:00
Eric WagonerandClaude Fable 5 3323f81fba Roster rows wrap instead of truncating the temperament
The name cell was a fixed-height nowrap/ellipsis line, clipping even
plain 'archmage hunter' mid-game. The temper tag now wraps below the
name as one piece and the row grows to fit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 00:48:37 -04:00
Eric WagonerandClaude Fable 5 b650e1e636 The unmasking fits the row: a mask glyph instead of prose
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 00:46:01 -04:00
Eric WagonerandClaude Fable 5 55c76cf20e Game over unmasks the mystery machines
Hands already go face-up when the maze is won; now the roster does
too — a secret automaton's row reads 'archmage berserker (was a
mystery)' once the game finishes, broadcast with the final command
from either a human or the bot pump. Mid-game, the mood stays its
own business.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 00:38:42 -04:00
Eric WagonerandClaude Fable 5 5243a173a8 Stone soaks show their work in the damage line
'Takes 2 damage' from a 3-point bolt read as broken arithmetic unless
you remembered the bloodstone. The damaged event now carries its soaks
(bloodstone's flat point, soulstone's spell-damage clamp) and the log
prints them: 'takes 2 damage (lightning-blast (reflected) — bloodstone
soaks 1)'. A blow the bloodstone drinks entirely — previously silent —
announces itself too. Events are derived, so replays gain the notes
retroactively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-18 00:02:07 -04:00
Eric WagonerandClaude Fable 5 1d7407cb20 A reflected Lightning Blast ends the caster's turn on the spot (rev 28)
FAQ: 'If this gets FULLY REFLECTED, you lose the rest of your turn and
cannot draw cards (in addition to damage).' When the returned bolt
stuns its own caster mid-turn, their remaining actions end at once and
the end-of-turn draw comes up empty (new turn.drawForbidden flag); the
normal stun still costs them the next turn. The affected player sees
'Your own bolt has left you reeling' in place of the usual spent-turn
slip. Older revisions played on and stored games replay so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 23:47:25 -04:00
Eric WagonerandClaude Fable 5 67157ad3a2 Ledger chests tap open full-size, like the table's other peeks
The carried-treasure chest in the scoresheet is now a button: tapping
it raises the big-peek scrim with the chest at full size and a note
naming whose treasure sits in whose arms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 23:31:01 -04:00
Eric WagonerandClaude Fable 5 f4355a60c6 Ledger chest doubled — big enough to read the color at a glance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 23:28:15 -04:00
Eric WagonerandClaude Fable 5 1069730880 The ledger's money bag becomes the owner's chest too
The scoresheet's carrier mark now shows whose gold is being hauled —
the same colored SVG chest as the board badge, inline at text height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 23:26:10 -04:00
Eric WagonerandClaude Fable 5 4a5baed853 The carried-treasure dot becomes the owner's chest
A gold dot said 'carrying' but never whose. The badge on the carrier's
shoulder is now the treasure owner's colored chest — always the drawn
SVG chest, which stays legible at badge size in either art mode. The
dot survives only as a fallback for a treasure the view cannot resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 23:23:55 -04:00
Eric WagonerandClaude Fable 5 3dcdf50498 Nightly ledger backup to DO Spaces (deploy/wizwar-backup.sh)
Installed on the droplet at /usr/local/bin/wizwar-backup.sh, cron 07:17
UTC daily: mirrors /var/lib/wizwar to spaces:kestrel-wizwar-backups/
wizwar/current and keeps 90 days of dated snapshots. Ledgers are
append-only, so no service stop is needed. The script no-ops with a
log line until Spaces credentials land in /root/.config/rclone/
rclone.conf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 23:15:28 -04:00
Eric WagonerandClaude Fable 5 e412463aae Thumb of God gets its ceremony: prompt, sighted dimming, and the die
The cast always worked (click a square), but nothing guided it and
nothing marked its arrival. Now: a targeting prompt, eligibility
dimming to the caster's sighted squares (the engine demands LOS to the
aim point), and a flourish — the aiming mark, a shadow gathering, the
die crashing down out of a clear sky with a bounce and flung grit, a
drift line when fate moves it, and every scattered token streaking to
where it lands. In the /?fx gallery as die-drop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 23:09:40 -04:00
Eric WagonerandClaude Fable 5 5351e345c3 Object-ness comes from the card face, not a parallel list
'This is most certainly an object' (WIZARDBLADE's FAQ) — and the card
data already says so: every physical item bears the OBJECT subtype on
its face. isMovableObject now reads it from there, retiring the
hand-kept list — which had missed MASTER KEY and HANDFUL OF TACKS,
both object-marked. Ungated: no stored ledger holds a swap or drop
this widens (verified against production), and refused commands are
never recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 22:39:46 -04:00
Eric WagonerandClaude Fable 5 2bb2ca62d8 Reflected Swap Meet: the reflector chooses the trade (rev 27)
'FULL REFLECTION lets the other player decide which objects, if any,
will be swapped' — the reflector's choice now rides their counteract
(params.cardId, reflector's item first), resolved with the roles
swapped when the reflection settles; 'none' or no choice trades
nothing. The client walks the reflector through the same give-and-take
picker, with a 'swap nothing' refusal.

Also from room XRT7: the trade picker now honors each room's own rules
revision (older rooms trade only object-typed cards), so it can no
longer offer a wizardblade a rev-25 engine will quietly refuse — and a
fizzled swap finally says so in the log instead of vanishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 22:36:00 -04:00
Eric WagonerandClaude Fable 5 3aef6694f3 Swap Meet trades treasures — a carried treasure is a carried item
The trade grammar gains a 'treasure' token (never present in stored
ledgers, so no gate needed): a dagger can buy back the treasure in a
thief's arms, or two armfuls of gold can change hands outright. The
one-treasure carry limit and WEAKNESS both hold in a trade as at a
grab — a one-way treasure needs an open slot and an able back. The
picker offers each side's carried treasure alongside displayed items,
withholding theirs when your own arms are already full.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 22:19:29 -04:00
Eric WagonerandClaude Fable 5 00517e08b1 Pacts are honored; Swap Meet trades items, not card names (rev 26)
The pact fix: a clockwork no longer attacks a wizard it holds a BUDDY
pact on — Eric watched one sign the pact and punch him the next turn.
A pact is torn up for exactly one thing: a kill. And the post-attack
pact is signed only by a wizard who wants OUT of the fight (hauling
gold, bleeding, or with other enemies left) — a healthy duelist keeps
its options, and mutual-pact stalemates stay out of bot wars.

Swap Meet drops the 'name a card' prompt for a real trade picker:
click the other trader, choose one of your carried items, then claim
one of their displayed items. Rules rev 26 widens the engine's match
from object-typed cards to every movable object (daggers, rocks,
wands, stones); older games matched narrowly and replay so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 22:14:57 -04:00
Eric WagonerandClaude Fable 5 06faea0c79 The reflected-attack window (rules rev 25)
A FULL REFLECTION's returned spell is a fresh attack on its own caster —
who, per the owner's ruling, gets a defender's counteraction window
against it (absorb the blast, shield it, or FULL REFLECTION again: the
ping-pong war ends when the cards run out). The reflection now settles
into a new stack with the roles swapped and the damage pre-priced
(numbers, amplifies, and surviving counters already applied); the blow
returns to its sender unerringly, with no fresh hit rolls. onResolved
effects run with the swapped roles, so a reflected IDIOT now afflicts
its own caster. Earlier revisions land the blow instantly and stored
games replay unchanged.

Automatons price the incoming reflected damage off the stack; the attack
modal announces 'Your own spell comes screaming back!' with the inbound
total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 22:02:16 -04:00
Eric WagonerandClaude Fable 5 dcaf358a93 Right the dimensional warp token — its caption stood on its head
The physical-set crop was rotated 180; sips -r 180 sets it upright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 21:12:13 -04:00
Eric WagonerandClaude Fable 5 21884fa26e The fireball-then-buddy lockout
Per the owner's favorite play: the pact only breaks when its caster
attacks the target, so a blow landed BEFORE the pact leaves it intact.
After spending its attack, a non-berserker bot with BUDDY in hand signs
it onto the weakest visible unpacted enemy — the one it likely just
burned — barring their revenge. The berserker skips it: it would only
break its own pact tomorrow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 21:03:40 -04:00
Eric WagonerandClaude Fable 5 3087f00bdc Round two of the owner's tactics: exile, wand-slip, finishers, boosts
- TELEPORT OPPONENT exiles a delivery in progress — the thief of the
  bot's gold, or any carrier within six of home — to the square farthest
  (by walking distance) from the victim's own home; a sealed pocket wins
  outright. No LOS needed for the destination, per the card.
- WARP WAND joins the roadwork: a wall slips open for a 4+-step shortcut
  when the crossing fits this turn's remaining legs (the wall returns at
  end of turn); charges itself with the smallest number on first use.
- ADRENALINE is the finisher: cast when no single attack in hand kills
  the target but the top two together do; the attack window now honors
  the second swing.
- STRENGTH doubles a thrown dagger or rock exactly when that turns a
  wound into a kill.
- EXTEND rides afflictions, doubling the misery's stay.
- MEGA-MONSTER doubles a pet's stride (movement over life) so the
  menagerie actually catches people.
All six leave the bottom discard tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 20:33:45 -04:00
Eric WagonerandClaude Fable 5 4bda6087b1 The automatons learn Eric's tactics: roadwork, denial, bursts, breathing room
Per the owner's briefing on how these cards are actually played:
- Roadwork on the march: DISPEL CREATION un-creates conjured walls and
  filled squares; STONE TO WATER melts walls and stone blocks (cast only
  from outside its own wave); CREATE DOOR plus a lock-opener turns a wall
  into a doorway; DIMENSIONAL WARP folds a 6+-step detour into one step,
  and the bot steps through its tokens when the far side is closer.
- Path denial grows: JAM LOCK seals doors on a threat's road, ILLUSION
  WALL stands in for CREATE WALL, the full nuisance shelf (pit, slime,
  ooze, rosebush, tacks, dust cloud) joins stone and thornbush, and a
  BOOBYTRAP lands mid-path when nothing can reroute the thief.
- Bursts: ADD joins a second number to the march; MAD DASH doubles the
  sprint to gold (never while carrying); POWER RUN buys the last spaces
  of a winning delivery with blood.
- Breathing room when pressed (worrier, treasure-carrier, or bleeding):
  FEAR at three paces, UGLY when they crowd in, BUDDY to pacify the hound.
- AROUND THE CORNER: an enemy one bend out of sight can be attacked; new
  bentSightFor mirrors the engine's bentLos from the viewer's knowledge.
- GameView gains createdEdges (public — the table watched them conjured).
All newly playable cards leave the bottom discard tier via USEFUL_NEUTRALS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 20:14:55 -04:00
Eric WagonerandClaude Fable 5 4e14926c0c Discard valuation knows whose hand it is; the shed spares the playbook
The end-of-turn shed was eating cards the brain itself casts: BIG MAN,
MIST BODY, SHRINK, LIFESAVER, and STONE DEAD all sat at or below the shed
threshold. But worth is contextual — LIFESAVER is genuinely dead at a
two-seat table, BIG MAN outside the berserker's book — so discardValue now
takes the view and style: STONE DEAD joins the attacks tier outright, the
rest are kept exactly by the wizard who can play them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 19:57:26 -04:00
Eric WagonerandClaude Fable 5 78085e26fb Automatons wield Pass Through Wall and deny roads with terrain
Pass Through Wall joins the march: the bot banks a crossing when stepping
through one wall beats the walk by 4+ steps (or no road exists), walks to
the chosen wall, and spends the charge stepping through. passWallCharges
is now in PlayerPublicView — the cast is public at a physical table.

Path denial for guardGold tiers: when an enemy carries the bot's treasure
toward home, or closes on its gold on the floor, the bot reconstructs the
threat's shortest path and prices every corridor line and empty square on
it; CREATE WALL, FILL SQUARE WITH STONE, or THORNBUSH lands wherever the
detour costs the enemy 3+ steps, legality mirrored from the engine
(sighted, empty, off homes and warp tokens).

All five cards leave the bottom discard tier (2 -> 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 19:50:41 -04:00
Eric WagonerandClaude Fable 5 2d53ba4bc0 Waves spend their force as they travel (rev 24); bots wield Destroy Wall
Rules rev 24: a waterwall wave that finds a victim dist cells from its
source has only range-dist spaces of push left — a range-2 wave throws its
adjacent victim two spaces but a victim at its far edge only one, and
spent force never converts into crush damage. Applies to WATERWALL and
both STONE TO WATER waves; older revisions keep the flat full-range wash
so stored games replay unchanged.

Automatons now use DESTROY WALL on the march: two BFS distance maps (from
the bot, from its objectives) price every visible wall by the shortcut its
removal opens; the bot blasts when it saves 4+ steps of walking — or when
no road exists at all — never standing beside the blast unless trapped
and healthy. The card also leaves the shed pile: discardValue 2 -> 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 19:40:41 -04:00
Eric WagonerandClaude Fable 5 dfd7a2ff56 Stone to Water's targeting aid honors sight and its wall targets
The cast validation demands LOS to a stone block, but the dimming aid lit
every stone square on the map — including ones the caster cannot see. Now
only sighted blocks light; and since the card equally targets stone walls
(edges the cell-shadow cannot express), no sighted block means no shadow
at all rather than a maze dimmed away from its true targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 19:31:26 -04:00
Eric WagonerandClaude Fable 5 77c5a6342f Purge expansion2 card rows — a set this edition does not have
The 5e-inherited card database carried 85 rows tagged expansion2; 6e has
only the basic deck and Expansion Set #1, and buildDeck never dealt them.
They kept leaking into things that cite the database (rulebook text, the
IDIOT aid list), so the rows are gone and CardSet no longer names the set.
Other editions can reintroduce their own sets when they arrive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 19:12:15 -04:00
Eric WagonerandClaude Fable 5 c013ba7e86 Trim IDIOT_AIDS to cards that exist in the 6e deck
vampire, werewolf, flight, and exploding-door are expansion2 rows in the
card database — a set this edition does not have; buildDeck never deals
them. Listing them as idiot-permitted aids cited cards that cannot occur.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 19:06:10 -04:00
Eric WagonerandClaude Fable 5 cdeb3d5103 Idiot enforces its card (rev 23) + attack sight-line tracing
Rules rev 23 — IDIOT, per the card and FAQ:
- No handling items: pick up / drop of treasures and objects refused
  (dropping was the exploit: capturing a stolen treasure on your own home,
  or dropping your own treasure underfoot for an instant cure)
- No punching, no thrown dagger / large rock (attacks on players)
- No effect on a victim already carrying one of their own treasures
Ungated (permissive): counteractions are now castable while idiotized (the
one thing the card expressly allows — the gate wrongly blocked them), and
goal-aiding spells (IDIOT_AIDS: destroy-wall, teleport, mad-dash, ...) per
the FAQ's 'you could, however, destroy a wall'.

Sight tracing: while an LOS attack sits on the stack, the board draws the
line it traveled — straight when direct, leg by leg through both warp
mouths (with pulsing rings) when the maze's wraparound carried it. Engine
traceSight/traceSightFor/stackSightTrace; overlay in Board.svelte; shown
live and in replays. Verified against H3PC's cross-board Idiot cast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
2026-08-17 19:03:09 -04:00
Eric WagonerandClaude Fable 5 7372bfc5e8 Rev 22: the self-stack resolves — one wizard cannot bounce to himself
A democratic monster touching its own creator opens a stack where one
player wears both hats, and doPass matched the attacker first: every
pass "bounced the exchange to the defender" — the same wizard —
forever. Room 928D livelocked there at seq 83; once the boot kick
revived its pump, the automaton politely passed 389 times. At rev 22
a self-stack pass RESOLVES (the claw lands, the game moves on);
older ledgers hold their recorded bounces and replay so. 928D itself
was repaired by truncating the no-op run and lifting it to rev 22,
verified clean and unstuck on a copy before installation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 18:14:18 -04:00
Eric WagonerandClaude Fable 5 4a75370de5 Restored rooms wake their automatons
The bot pump only ran when a command or join arrived, so a server
restart landing mid-bot-turn left the restored room waiting forever
for a human to poke it — room 928D sat exactly there, its automaton
holding a perfectly answerable stack. Boot now kicks the pump for
every restored still-running room, two seconds after restore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 18:07:33 -04:00
Eric WagonerandClaude Fable 5 2ac046611d ADD explains itself instead of dead-ending
Two numbers for movement work by playing them one after another, the
Add spending itself on the second — but clicking the ADD card itself,
the natural first instinct, showed nothing but a cancel button. It
now says how the ride works; a second number without an Add in hand
says what is missing instead of offering a doomed button; and the
button's label says "spends your Add" at the moment it does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:59:39 -04:00
Eric WagonerandClaude Fable 5 7df58d29fa Rev 21: collapsing walls crush anyone — monsters included
DESTROY WALL's "anyone in either square next to the wall takes 4
points" swept only wizards; a troll leaned on the falling masonry
untouched. The expansion sheet is explicit — "if a card specifies
'Opponent' or 'Anyone' as a target, this includes Monsters" — so the
collapse now crushes creatures in both squares, at the rim breach's
far side too. Earlier ledgers keep their charmed monsters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:47:14 -04:00
Eric WagonerandClaude Fable 5 6380fae134 The rulebook itself joins the shelf, verbatim
The rules tab carried a paraphrase and the FAQ; now the court of
final appeal sits between them — the full base rulebook text (the
5th-edition wording the 6th shipped with) and the Expansion Set 1
sections, word for word from the designer's own wizwar.com, with
copyright and provenance shown rather than hidden. This edition has
no Expansion 2 — Artifacts and Transformations do not exist here and
are not included. The text moves out of research/ into real source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:40:00 -04:00
Eric WagonerandClaude Fable 5 8d308bc606 Rev 20: the junction wall rolls for its allegiance
The rulebook (Cards And Actions That Change The Map): a CREATE WALL
or DESTROY WALL alteration on a junction between sectors rolls a
50-50 when "some other moron" rotates one of them — 1-2 stays with
the standing sector, 3-4 travels with the moving one, per the book's
own low/high convention. Both rotation and relocation roll (the same
seam tears either way), demolished holes roll alongside conjured
walls, the pips land loud in the chronicle, and a traveling edge
rides anchored to its in-sector cell. Rev-gated: the roll consumes
dice that stored games never spent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:34:33 -04:00
Eric WagonerandClaude Fable 5 a17b5238f0 Rev 19: breaching the rim opens a new warp across the maze
Destroying a wall on the board's outer edge used to leave a one-sided
hole into nothing. The table's physics prevail: the outer rim wraps,
so the breach goes clean through — the opposite perimeter wall in the
same row or column crumbles too (collapse damage and all), and a new
warp pair opens between the two edges, shimmering at both mouths.
Rev-gated: stored games hold one-sided breaches and replay so. Sector
moves recompute the wraparounds and forget improvised openings, as
the maze's own reshaping always has.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:25:25 -04:00
Eric WagonerandClaude Fable 5 766ec0a726 A warp mouth is a wallable line — and the tunnel seals whole
Create Wall refused the board-edge openings: no far cell, so "walls
must be created between two spaces". But bricking over a lettered
opening is a classic play, and the far space is the tunnel itself.
A warp mouth now counts as wallable — with the wall raised at BOTH
mouths, since a tunnel has one passage: no walking in from the far
board, no warp sight through a sealed pair, and tearing down either
end reopens both. The client grows edge hitboxes at the perimeter
mouths so the line is clickable at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:22:49 -04:00
Eric WagonerandClaude Fable 5 bed7d849eb The cruel draw gets its modal, and its own words
GIFT FROM BELOW bit for three in the middle of end-of-turn noise and
nobody saw it — not live, not in the replay, where its log line even
claimed "lose a turn" (the other trap's fate). The trapSprung event
now names its card; drawing a trap YOURSELF raises the full modal —
the card, what it did, "Curse the deck" — and the chronicle tells
each trap's true consequence. The amplify narration was already
fixed; replays re-derive events, so both now show in old games'
reels too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:13:36 -04:00
Eric WagonerandClaude Fable 5 028ff813f3 Amplify announces itself
Room 9BBV's "impossible" 14-point powerthrust was an archmage playing
correctly: 2 + 5, AMPLIFIED. The engine's math was honest and its
narration was not — neither the log line nor the attack modal
mentioned the amplify, so a legal kill read as a bug. The spellCast
event now carries its amplify count, the chronicle says "casts
Powerthrust with a 5, AMPLIFIED, at Kestrel", and the modal's power
line shows "powered by a 5 — AMPLIFIED ×2" while there is still time
to respect it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:10:06 -04:00
Eric WagonerandClaude Fable 5 b4adb97536 The mid-round democratic monster claws when its turn comes
Every creature is born with attackUsed spent — the right way to say
"cannot attack the turn created" for monsters whose flag refreshes
next turn. But the democratic monster's flag budgets its ROUND, so a
mid-round summon arrived pre-spent and bumped harmlessly until the
round rolled over. Creation-turn pacifism now rides justCreated
alone for the monster (checked at the touch), and its round-claw
starts live — folded into rev 18 with the boundary refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:53:28 -04:00
Eric WagonerandClaude Fable 5 3b0aa01867 Rev 18: the democratic monster's claw outlives the first wizard
Its one-attack-per-round refreshed inside beginTurnFor when the turn
index matched the roll-off winner's seat — and only the living begin
turns, so once that wizard fell the claw never refreshed again: one
attack, then a lifetime of harmless bumping. The refresh now lives at
the true round boundary in the turn-advance loop, which passes dead
seats without skipping the count. Earlier ledgers keep their gummed
monster and replay so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:51:44 -04:00
Eric WagonerandClaude Fable 5 d89d995daf The sector ghost slides where the sector actually went
The relocation flourish corrected its coordinates against the view —
but effects are mapped when the events arrive, one message before
the new state, so the correction read the PRE-move board and the
ghost slid between unrelated squares. The sectorRelocated event now
carries its origins in final coordinates, computed after the maze
renormalizes, and the flourish trusts the event alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 16:48:20 -04:00
Eric WagonerandClaude Fable 5 e57307efbe Rev 17: the wave carries monsters as it carries wizards
Stone to Water's wave (and every waterwall collapse) washed wizards
back and crushed them against the maze — while a skeleton in the
same water stood dry. The wave only knew creatures well enough to
drown fire imps. From rev 17 it carries them all: washed back along
the surge, one point of crush per space the maze refuses, fire imps
still destroyed outright. Earlier ledgers keep their dry monsters
and replay so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:07:34 -04:00
Eric WagonerandClaude Fable 5 aef88d892a Two treasures, one square: the taker names their prize
pickUpTreasure grabbed whichever treasure the list offered first —
with two on a square, no choice and sometimes the wrong one. The
command now takes an optional treasureId (absent in every stored
ledger, so old replays keep their old grab), and when the square
holds more than one, the button asks "whose?" before dispatching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:03:34 -04:00
Eric WagonerandClaude Fable 5 794b809a1b Swapping homes stops announcing a body swap
SWAP HOME BASES traded homes correctly but borrowed the
positionsSwapped event, so the chronicle claimed the wizards
themselves changed places. It now speaks its own event — "swap home
bases — the maze's loyalties shift!" — and both hearths shimmer as
their allegiance changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:01:07 -04:00
Eric WagonerandClaude Fable 5 e7c8ddc0b3 Creatures walk open doorways, and the interruption gets its fanfare
Player movement asked the full question of a door — removed, keyed
this turn, or held — while creature movement only saw "door" and
refused, so a skeleton balked at a doorway its master had unlocked
for good. One doorIsOpen() now answers for both, and an open doorway
costs a wraith none of its wall passes.

And the interruption window earns the modal it deserved: "The maze
holds its breath" — the card shown, the instruction plain, dismissed
with "Seize it". No more hunting the rail for a slip while time
stands parted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:50:38 -04:00
Eric WagonerandClaude Fable 5 44c14f5ce1 The interruption window opens for actual casting
OPPORTUNITY FIRE and INTERRUPT bought a moment the client would not
honor: every selection and targeting gate asked "is it your turn?",
and mid-interruption it is not — so the window's holder could click
attack cards into silence while the engine sat ready to accept the
cast. A window is now yourMoment: hand selection, player/creature/
cell/edge targeting, and the dimming aid all open exactly as on your
own turn, and the slip says what to do with it ("pick an attack card
and a target") instead of the cryptic "cast now".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:45:52 -04:00
Eric WagonerandClaude Fable 5 94ec714e35 Rev 16: empathy mirrors the blows that resolve by their own hand
A believed ILLUSIONARY ATTACK, the goat's ram, and the hurled
treasure all deal their damage inside the attack's own resolution
hook — past the pipeline where the empathy mirror lived, so a linked
caster walked away unbloodied. One resolvedBlow helper now carries
the mirror to all three: "any attack done in ANY form" means these
too. Rev-gated: proactive empathy predates this, so stored ledgers
may hold unmirrored blows and replay them as struck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:37:37 -04:00
Eric WagonerandClaude Fable 5 89ba225fe5 Illusions show their face, and empathy answers the call
An automaton's ILLUSIONARY ATTACK arrived as a mystery: the modal
showed only the illusion card, never the spell being faked — though
the stack has always carried it face-up. The defender now sees both
cards side by side, the caption names the fake ("an ILLUSIONARY
Fireball — it bites only if believed"), and the hint bar says which
spell to weigh a counter against.

And EMPATHY — corner: COUNTERACTION — was refused at the door like
INVISIBLE before it, while the automatons' own counter ladder had
been reaching for it and silently failing into the fallback all
along. It now raises mid-stack: the blow lands on both wizards, the
link lingers for its NUMBER card's turns, and ANTI-ANTI severs it
cleanly (empathy is no escape). The number-riding-a-counter idiom is
one shared helper now. Permissive both ways: no stored ledger
contains either play, so no rev gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:33:24 -04:00
Eric WagonerandClaude Fable 5 ea3b9e5bd6 The clockwork reads absorb's fine print
An automaton answered NO SPELL with an Absorb — which "has no effect
on duration-based spells." Afflictions sat outside the price table
and drew the phantom two-point default, tripping the point-soaking
rungs. An affliction's weight is now its duration, and the two
counters that touch only points — ABSORB and REVERSE — stand aside
for it, leaving the ones that genuinely bite durations: full shield,
remove curse, blunt, reflection, shieldstone numbers. Pinned: under
NO SPELL with both in hand, the bot blunts, never absorbs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:27:14 -04:00
Eric WagonerandClaude Fable 5 adf463effa Rev 15: the held doorway carries sight — the whole point of holding it
The table holds doors open to cast back through them at pursuers;
our held door opened for feet but not for eyes. From rev 15 a held
door is an open doorway to sight — casting at players, creatures,
and squares sees through it, the client's dimming aid and the
automatons alike (they share sightedCellsFor). REMOVE LOCK's "still
considered to block L.O.S." stands: that speaks of a CLOSED door,
and an unheld unlocked door still blocks. Rev-gated because rev-14
ledgers may already contain holds and must replay blind.

Pinned three ways: the holder blasts a pursuer through the held
doorway; an unheld unlocked door still blocks; rev 14 stays blind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:20:45 -04:00
Eric WagonerandClaude Fable 5 a0c65413ef The door can be held open, as both key cards always promised
MASTER KEY and PICK LOCK each read: "You may 'hold the door open' for
others, if you wish" — and the engine always slammed it at end of
turn. Now the cast takes a hold param: the door stays unlocked past
the turn, for anyone, as long as its holder stands adjacent and
alive. A step away, a shove, a teleport, or a killing blow lets it
swing shut — swept after every command, since anything can move a
wizard. New state, new param: no old ledger contains either, so no
rev gate is needed.

The client offers a "hold the door open" checkbox when either card is
selected; a held door shows pale with a green jamb ("held open by a
standing wizard"), and the chronicle records the holding and the
shutting. Pinned: a held door outlives the turn and admits the other
wizard; walking away releases it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:15:57 -04:00
Eric WagonerandClaude Fable 5 d2b40ec6f8 Credibility pass: the seams get sanded
Scoped to everything since the last pass (3308850). Three blind
reviews, every finding verified before touching anything.

Confirmed and fixed: two identical comment-splitting insertions left
doc comments orphaned from their fields (net and local alike); a
51-line CSS fossil of the pre-extraction inline effects survived in
Board.svelte; the rev-13 miss-roll test asserted tautologies while
its comment claimed a check the code never made — it now proves the
die was consumed, and the skeleton is no longer returned as trollId;
the sprite registry's `as never` silently disabled the completeness
its annotation advertised (now a mapped type, one cast at the
dispatch seam); a dead ternary guarded a union that doesn't exist;
Bolt carried a duplicate .fork rule from a color iteration; fxTtl
contradicted three sprites' real animation lengths; the permanence
sentinel was reinvented as a magic 9000 (the engine now exports
isPermanentDuration); CELL was declared thrice (fx.ts now imports
it); App and Replay ran two divergent fx schedulers (one scheduleFx
now, cancellable — stale flourishes can no longer fire after leaving
a game); TokenArt retried missing files forever; the anti-anti
escape guards merge with the gate asymmetry explained; wall-of-fire's
rev-12 carve-out is marked; overLimit ignored a displayed BRAINSTONE
(bots over-discarded by two); botRemark's header mis-stated its own
branches; deliverGold fired on any drop, not a home-base delivery;
escape and win banter never fired from the steps that carry them.

Rejected: "as a human would" (house voice); FxGallery's dev-harness
framing (trimmed one plea, kept the facts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:12:03 -04:00
Eric WagonerandClaude Fable 5 4528c66057 The peeked token joins its card — and stops hiding behind it
Clicking a board token now shows the thing itself, enlarged, above
its card in the peek modal — whichever art is active, photograph or
SVG. The art tables move to art.ts so the board renderer and the
modal share one source.

First cut overlapped: the card scales 2.1x out of its layout box, so
flow neighbors sat underneath its visual footprint (the note had been
compensating with a 6.8rem margin hack). A card-stage now reserves
the card's TRUE visual size; token above, note below, nothing buried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:58:25 -04:00
Eric WagonerandClaude Fable 5 1d015681d3 SVG tokens render as vectors, not bitmaps of vectors
An svg referenced through an <image> element is rasterized once at
its layout size in user units — about 29 pixels for a wizard — and
scales as a blurry bitmap thereafter, defeating the entire point of
vector art. TokenArt.svelte now fetches each file once, namespaces
its ids (45 tokens' worth of filter and pattern defs share one
document), and inlines it as a nested <svg>: live vector at any
zoom, verified crisp at 3x device scale. PNG art keeps the <image>
path untouched, and the token style family goes :global to reach the
component's elements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:36:49 -04:00
Eric WagonerandClaude Fable 5 c62c3b4f05 The SVG tokens join the source proper
The symlink into research/ becomes real files: public/tokens-svg now
owns the hand-drawn set, edited in place and served as-is by the dev
server. The research folder is scaffolding with a demolition date,
not a place for shipped assets to live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:29:42 -04:00
Eric WagonerandClaude Fable 5 e0e96ca5ca The art workshop's side door
The hand-drawn SVG token set wires in behind deliberately
undocumented query params — svgPlayers, svgCreatures, svgTerrain,
svgObjects, each =true — swapping that category's art everywhere it
draws: board tokens, lobby standees, roster. public/tokens-svg
symlinks research/tokens-svg, so under the dev server an edit to the
source art shows on the next refresh, and the production build copies
real files through the link. Nobody finds the door unless handed the
key; the photographed set remains the face of the game.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:27:28 -04:00
Eric WagonerandClaude Fable 5 93bdbb403a Travelling effects fly token to token
Projectiles flew cell-center to cell-center, launching from the
caster's feet and landing under the target's chin — tokens sit high
in their squares and fan sideways when crowded. Every travelling
effect (fireball, waterbolt, bolt, streak, and the reflection's
return flight) now resolves token anchors that mirror the board's
exact positioning, fan-out included: caster's center to target's
center, wizard or creature. Spots with no token to aim at fall back
to sensible cell points, and wash/knockback streaks end on the
carried wizard wherever they fanned to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 11:00:09 -04:00
Eric WagonerandClaude Fable 5 126f003156 Wizards walk; portals stay instantaneous
Tokens jumped square to square, which made the portal shimmer look
late — the wizard had already re-appeared before the threshold lit.
Wizards and creatures are now identity-keyed movers positioned by a
transform, so the DOM node survives a move and a single step glides
(260ms). Anything farther than a step — teleports, warps, drags,
sector moves — snaps as before, because sliding a wizard across the
whole maze would tell a false story; the warp's instant jump beside
its threshold shimmer is now the point, not the bug. Replays glide
the same way, and reduced motion stills it all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:56:41 -04:00
Eric WagonerandClaude Fable 5 64709f6c9c Stone to water splashes like it means it
The transmutation fired no flourish: its event carried no coordinates
for the wall case and the mapper knew neither it nor the wave it
raises. The event now carries its edge; the burst splashes both sides
of the vanished wall (or the freed square, for a block), and every
wizard the collapsing wave carries leaves a wash streak — which also
gives the plain WATERWALL's wave its motion for free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:47:42 -04:00
Eric WagonerandClaude Fable 5 ca55f7d00b The fireworks get the stage before the victory modal takes it
Winning raised the modal on the same frame the fireworks lit,
covering them. The modal now waits behind the curtain for 2.4
seconds — three volleys over the winner's home, seen — then takes
the stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:40:11 -04:00
Eric WagonerandClaude Fable 5 c6d73550df The optional final polish: five refinements
The bolt deepens to saturated gold over a dark amber glow; the splash
throws a wider crown of five droplets over a broader pool; the
fireworks lose their protractor — uneven angles, lengths, sizes, and
staggered timing, as a real burst has; the portal-cell's void gains
turning swirl arms so the dark center reads as motion, not blur; the
streak narrows and softens so its peak frame no longer resembles a
drawn white line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:37:57 -04:00
Eric WagonerandClaude Fable 5 e713d69582 Round two of the outside eye: contrast, scale, and travel
The whiff darkens to slate and sweeps longer; the portal-cell opens
a dark void behind its collapsing rings — an opening in space, not a
selected square; the tacks grow half again and recoil harder; the
streak's reveal slows so the travel reads even in a sampled frame;
dust lifts its contrast a step without turning heavy. The sector
ghosts stay board-machinery on purpose — the reviewer allowed it,
and the maze grinding IS an operation, not a spell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:34:02 -04:00
Eric WagonerandClaude Fable 5 bcaa06251a The review lands: twelve sprites get their motion stories
The outside review's diagnosis was right — the weak sprites had one
element doing one motion. Now each tells its story: the whiff is
three air streaks sweeping past a dodger with a wobble; the hit is a
contact flash, a compressed ring punched outward, and flying debris;
the streak's head visibly travels, drawing a tapered trail that
collapses into the destination; the sparkle snaps white-hot between
counter-rotating glints and sheds motes; the tacks are drawn
silhouettes now, points up as a tack on the floor menaces, hopping in
sequence; the claw slashes reveal one by one along their length with
a bright leading edge while the whole strike rakes sideways.

The sector ghosts keep their dashed frames but earn motion arcs,
directional chevrons, corner dust, and a grinding shudder as they
settle. The portal opens wide, ripples along its length, and pinches
shut; cell mouths collapse rings of energy inward. The thornbush
springs actual branches with thorn spurs. The slime is an asymmetric
blob that grips, with elastic strands snapping toward the caught.
Dust gains contrast, varied grain, and blows perpendicular to the
wall that shed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:30:48 -04:00
Eric WagonerandClaude Fable 5 a88cae3a0e The pit reads as depth, not a dot
Its hole was a near-black disc that sat on the parchment like spilled
ink. Now an earthen gradient ellipse, offset toward the far wall,
with the far lip catching the light — a hole you look INTO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:19:28 -04:00
Eric WagonerandClaude Fable 5 308f1b6ff9 The fireball sets the bar, and the rest of the catalog rises to it
Eric's fireball lands as the house style — layered construction,
gradient core, glow halo, a directional tail riding rotate="auto" —
with its defs scoped per instance via $props.id(), so simultaneous
fireballs cannot steal each other's gradients. The rest of the
catalog is rebuilt in its image: the waterbolt becomes its cold
sibling (spray tail, no turbulence — one heavy filter stack is
enough); lightning grows a glowing core and branching forks; the
burst blooms through a fire gradient with a shockwave and flying
embers; the splash throws a crown of droplets; the shield raises a
gradient dome with a rim strike; the pow star flashes white and
radiates speed dashes; claws leave tapered gashes; the absorbed card
spirals in along a pull ring; the soul is a proper little ghost with
eyes, swaying as it rises; fireworks ride colored trails; Chaos
catches card slips in its vortex; the pit opens a gradient hole that
swallows a faller; ooze splats and flings blobs; the thornbush sheds
leaves; slime closes over and pops bubbles; masonry dust goes soft
with falling grit. Whiff, tacks, portal, and the sector ghosts keep
their simpler forms on purpose — restraint is part of the style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 10:16:01 -04:00
Eric Wagoner fe0318b99b The clockwork quickens its step
Bot pacing from 1500ms to 1000ms per command — the table's preferred
tempo after living with both.
2026-08-17 09:43:48 -04:00
Eric WagonerandClaude Fable 5 a59ecfa615 Every flourish gets its own file, and a workshop to watch them in
The effects move out of Board.svelte into src/fx-sprites/ — one
component per flourish, markup and animation CSS together, so editing
the fireball means opening Fireball.svelte and nothing else. A
registry (index.ts) maps kind to sprite; Board renders through it.
The event mapping stays in fx.ts.

And a viewer: /?fx opens the Flourish Workshop — every sprite on a
labeled parchment tile, replaying on a 2.2-second beat. Under the dev
server, editing any sprite file hot-reloads the gallery mid-loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:27:32 -04:00
Eric Wagoner 0fcbff4dc6 Merge branch 'main' into flourishes 2026-08-17 09:18:40 -04:00
Eric WagonerandClaude Fable 5 13ce8c442c The clockwork throws more spells and fewer fists
Punching was mostly deck arithmetic — nineteen castable damage cards
in a 196-card deck — but three leaks made it worse. The march was
burning the biggest NUMBER card on long walks, starving waterbolts
and lightning; the biggest number is now reserved whenever an attack
in hand wants it. Heave-Ho, priced for defense, was being cast
without a treasure to throw (refused, turn stumbled) — now never
cast. Illusionary Attack was cast without naming a spell to fake —
now it fakes a fireball, the best-selling lie. And STONE DEAD joins
the arsenal against anyone flashing displayed stones: number times
the visible count, cast when the arithmetic beats the alternatives.

Measured across thirty berserker mirrors: attack casts up a quarter,
the tier handicap sharper (25-5), no stalls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:18:32 -04:00
Eric WagonerandClaude Fable 5 a82b25b0e8 Warp travel shimmers at the threshold, not around the traveler
Green rings at the traveler's squares become a portal curtain: the
opening LINE itself shimmers on both boards — resolved from the warp
table via the step's direction — in iridescent cyan over a soft
violet breath, dashes drifting like light through a veil. Dimensional
warp tokens, whose mouths are whole squares, wrap in the same veil
around the square instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 09:11:29 -04:00
Eric WagonerandClaude Fable 5 68b0da6301 Flourishes third wave: the grind, the grave, and the pratfalls
The showstopper: sectors visibly move — a dashed parchment ghost
rotates through its quarter-turn or slides the whole way to its new
ground (corrected for rev-11 renormalization, since the event records
pre-shift origins and the view holds the truth). Death sends a small
soul drifting up from the token; victory bursts fireworks over the
winner's home, three volleys; CHAOS spins a triple spiral over the
whole maze and stands alone in its batch.

And the slapstick tier: knockbacks, shoves, drags, and pit-jumps
leave a motion streak; falling in a pit swallows dark with dust;
ooze gets a wobbling pratfall ellipse; tacks sting in three hopping
asterisks; thornbush snaps green; slime sinks its ring; a successful
pit climb puffs dust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 01:12:22 -04:00
Eric WagonerandClaude Fable 5 6da350fd46 Flourishes second wave: reflections, warps, claws, and ongoing looks
The reflected spell turns in the air and flies home wearing its own
colors, striking its caster with the hit it meant for someone else.
Warp travel glows green at both mouths. Punches land as a proper
burst star; creature blows rake three red gashes; an absorbed spell
is a little card swallowed into the absorber, spinning as it shrinks.

And ongoing spells now wear their look between events: firewalls lick
and glow, the invisible and misted breathe at a fraction of opacity,
Medusa's victims gray to stone, and the webbed wear their webs. The
pulses stop under reduced motion; the informative looks stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 01:02:37 -04:00
Eric WagonerandClaude Fable 5 70d7b4babb Flourishes play on the reel too
Replay steps carry the same events the live table saw, so the spells
flare on catch-up reels and whole-game replays alike — retroactively:
stored games re-derive their events on replay, so every fireball ever
thrown bursts again. Step changes clear pending effects so scrubbing
backward does not smear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:55:01 -04:00
Eric WagonerandClaude Fable 5 bca34ae9e4 Flourishes: the spells become visible (branch only — not for the droplet yet)
A cosmetic effects layer on the board svg, fed by the same event
stream that writes the log. Fireballs streak and burst; waterbolts
arc and splash; lightning jags and flickers; teleports shimmer at
both ends; walls rise and fall in dust; stopped attacks flash a
golden shield; hits ring red; misses whiff; other attack spells
sparkle at the caster. Successive visuals from one command stagger by
a beat, everything self-expires, reduced-motion hides the layer
whole, and nothing here touches game state — the effects are mapped
from events in fx.ts and drawn in Board.svelte, online and hotseat
alike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:43:16 -04:00
Eric WagonerandClaude Fable 5 b01e27c644 The banter moves to its own book, and learns new verses
BOT_LINES leaves the server internals for banter.ts, keyed by
semantic trigger instead of raw event type, so editing the clockwork's
voice takes no knowledge of the event stream. The repertoire grows
from fourteen lines to fifty across twelve occasions — grabbing and
delivering gold, dealing and taking pain, killing and dying,
summoning, walling, escaping, springing traps, dodging, and winning.

Bots also now speak when things happen TO them: the actor remarks on
its deeds, bystanders on their suffering — a berserker taking a hit
answers "I FELT THAT. DO IT AGAIN." even on your turn. Still at most
one voice per step, still half the time, menace over chatter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:38:45 -04:00
Eric WagonerandClaude Fable 5 d4565006bc Rev 14: elimination sweeps the board by either door
Two ways out of the game, one cleanup. A wizard killed by damage took
their monsters, sustained spells, and armed ambushes with them; a
wizard eliminated by treasure loss left everything standing — a fire
imp scorching for a dead master, a Medusa still gazing at a corpse.
Both paths now share one sweep, gated at rev 14 so stored games keep
their orphans acting as recorded.

Also: Lifesaver lasts until used, carried as a near-billion-turn
sentinel — which the scoresheet printed. Durations that mean forever
no longer show a number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:27:14 -04:00
Eric WagonerandClaude Fable 5 a60467c6fc The missed-moves banner stops counting games watched live
Once a gap opened — a backgrounded tab during the bots' turns was
enough — the mark-seen condition (exactly one move behind) could
never hold again, so the banner grew with every move a player watched
happen in front of them. Now only the first state after arriving (or
reconnecting) announces a gap; while present and visible, play marks
itself seen, an announced gap stays frozen until watched or skipped,
and a hidden tab still accumulates its gap honestly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:23:25 -04:00
Eric WagonerandClaude Fable 5 4a4e164e88 The dice roll once
Rejoining a room replays the whole chronicle through the same events
channel that carries live play, so the opening roll-off modal fired
on every return. The replayed chronicle is now flagged, and one-time
fanfare stays quiet on it — the log still rebuilds in full.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:15:12 -04:00
Eric WagonerandClaude Fable 5 ef5336caa8 The clockwork learns to shed a dead hand — if its tier knows how
Two archmages idled turn after turn: hands clogged with situational
neutrals, and doEndTurn caps the draw by the room left, so a full
hand never refreshed. Nothing to cast, nothing to draw, nothing done.

The brain now sheds dead weight before ending the turn — only cards
it has no play for; good counters and attacks stay hoarded — so the
draw always has room. The trick is repertoire, so it is tier-gated:
adepts and archmages churn, the apprentice hoards like the novice it
is. Measured across forty berserker mirrors that sharpened the
handicap from two-to-one to three-to-one with zero stalled games.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:11:18 -04:00
Eric WagonerandClaude Fable 5 9493873b64 Dispel Creation reaches every creation, and edge hints tell the truth
An opponent's wraith refused to be dispelled — the engine was willing
all along (any controller's creature, sight required), but creature
clicks never routed cell-target spells, so the cast never left the
client. Clicking a creature with a cell spell selected now lands on
its square. The dimming aid told the same lie twice over: it lit
every created square including ones out of sight (a caster's own
thornbush beyond the walls) and never lit creatures at all. It now
mirrors the engine — anything created, whoever made it, in sight.

And "click a wall line" stops standing in for every edge card: each
now says what the click actually is — a locked door for the keys, a
corridor line for the fire, the creation itself for the dispel — and
attacks no longer show two hints for one wall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:59:18 -04:00
Eric WagonerandClaude Fable 5 ee6a08f25c Boobytrap tokens land where you can see them
The four placements accumulated invisibly until the last click cast
the spell. Each token now marks its square as it is placed, numbered
in click order — the 1 is the real trap — and tapping a placed token
lifts it again, since the four must be distinct squares.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:56:29 -04:00
Eric WagonerandClaude Fable 5 04dc9bc750 Teleport waits for a second tap
One tap cast the jump, and a player reaching for click-per-square
movement spent their teleport on the adjacent floor. Now the first
tap only marks the destination (the familiar pulsing square), and the
jump commits on a second tap of the same square or the hint-bar
button — with the hint spelling out the four-square, walls-ignored
range. The escape teleport gets the same two-tap arming; a counter
wasted on a misclick is a counter wasted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:49:27 -04:00
Eric WagonerandClaude Fable 5 22153b1e1b The opening roll-off steps into the light
First wizard was never random: createGame has always rolled a die
per player from the seed, re-rolling ties, and the gameStarted event
carried every pip — straight into a log line that said only "X goes
first." Now the table sees the ceremony: a dice modal as the boards
flip, one row per wizard, the trophy on the high roll, a note when
tied wizards rolled again — in online games and hotseat alike. The
chronicle line keeps the full roll-off too. Pure presentation; the
dice were always honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:47:31 -04:00
Eric WagonerandClaude Fable 5 2950380e23 The clockwork stops absorbing nothing
An automaton answered DROP OBJECT with an Absorb, then a Blunt —
two counters drained against a spell that carries no points. The
brain's price table listed only the classic damage spells; anything
unknown was guessed at 2 points, which tripped every damage-counter
rung. Utility attacks (dropping, dragging, swapping, peeking) now
route past those rungs entirely: the bot passes, or spends a full
stop when the effect actually threatens something — a shield for the
treasure it carries, a worrier's refusal to be dragged. Stone Dead,
Butt Head, Heave-Ho, and Illusionary Attack join the price table
while the hood is up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:39:34 -04:00
Eric WagonerandClaude Fable 5 8a460a0eed Doors show the state of their locks
A lock removed, a lock jammed, and a door picked open for the turn
all looked exactly like a locked door. Now the door itself tells:
pale wood for a lock gone for good, near-black for one jammed shut,
pale with a dashed jamb for a door standing open until end of turn —
each with a hover title saying so in words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:34:46 -04:00
Eric WagonerandClaude Fable 5 e0da97627b Rev 13: monsters roll to hit the hidden — the invented exception dies
"The creature shares your square — there is nowhere to hide" appeared
on no card and in no rulebook. Neither did the convention beneath it:
the engine skipped INVISIBLE and SHRINK miss rolls for creature blows
on the theory that a monster in your square has nothing to aim. The
cards say otherwise — SHRINK reduces the chance to hit "in any
attack", and INVISIBLE's D4 carves out no exception for monsters.

From rev 13, creature blows roll the same dice as every other attack
(1 finds the unseen; 1-2 find the tiny), INVISIBLE may be counter-
cast against a creature's blow, and the automatons will do so. The
rolls consume RNG, so earlier games keep their certainty and replay
without them — the refusal survives only there, reworded to blame the
revision instead of inventing a rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:11:30 -04:00
Eric WagonerandClaude Fable 5 4d8584ae44 Rev 12: vanishing and water answer the call to arms
INVISIBLE's corner reads NEUTRAL/COUNTERACTION, and ANTI-ANTI's own
face names it an escape — yet the engine refused it at the door. Now
it counters: the vanishing goes up mid-stack (duration from a NUMBER
card riding the counteract command), the attacker's 1-in-4 hit roll
happens at resolution, the spell lingers after the exchange, and
ANTI-ANTI cannot pin it. WATERWALL — "Acts as counteraction to
FIREBALL" — now mirrors WALL OF FIRE's waterbolt-stopping role and
counts as a total stop. WALL OF FIRE itself joins the total-stop set
at rev 12 only: stored rev-10/11 games bounced after it and replay so.

While responding, tapping a number card holds it to ride the next
counter (unless a displayed Shieldstone makes the number itself the
counteraction, as before). The automatons learn the trick too: vanish
when the blow is heavy and the shields are spent, and never waste
ANTI-ANTI against an escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:07:47 -04:00
Eric WagonerandClaude Fable 5 fb2266b401 Mega-Monster offers its choice
"Doubles the existing life-points OR movement rate" — the engine
always took the OR's left side because the client never asked. With
Mega-Monster selected, the hint bar now offers the choice (hit points
lit by default), and the pick rides the cast as params.boost — its
own field, freeing params.cardId for the name-a-card spells it
belongs to. Both halves of the OR are pinned in the creature suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:56:49 -04:00
Eric WagonerandClaude Fable 5 4e365c168f The workshop row learns to wrap
The lobby's automaton row was a no-wrap inline-flex: at card width the
"seat a" label collapsed to a vertical sliver and the MYSTERY button
ran clean off the paper. The row now wraps centered within the card
and the label holds one line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:49:52 -04:00
Eric WagonerandClaude Fable 5 3308850bb6 Credibility pass: sweep the workshop floor
Scoped to everything since the last pass (695307d). The residue of
fast iteration, removed: a reduced-motion media query that had
swallowed a full copy of the .faq-seal rules; doc comments orphaned
from their functions by inserted methods; the FAQ scrape's seams
(section headings run into ruling bodies under the wrong topics, a
next-page heading shipped as a ruling, an amputated "h", and rulings
filed under alphabetically-nearest strangers — Large Rock/Dagger now
lives on those cards; Book of Spells and Torquemada describe no card
in this set and are gone); the write-only aisle flag left behind by
the reverted rev 7; a linter-silenced dead destructure; a duplicated
median lookup; dead casts; and the fallback path that ignored the
apprentice's one-card draw.

Tests now typecheck (tsconfig includes test/), which surfaced the
missing type imports and a drifted creature literal hiding under an
as-cast. Also: a no-op self-assignment, mid-file imports hoisted, a
dynamic-import habit made static, a hedge comment replaced with a
loud setup failure, the fallback-retries-itself dead rung removed
from both bot drivers, and the twin peek scrims merged.

Deliberately kept: the TIERS lookup guard (ledger JSON is untrusted),
the wand-cost stanzas (they differ on the power-attack trade — a
rules question, not a dedup), and rollDie's coexistence with rollD4
(migrating changes visible logs; future work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:20:37 -04:00
Eric WagonerandClaude Fable 5 73150a89b5 Rev 11: the maze has no wall at zero, and moving sectors carry everything
Relocate Sector could never land a sector west or north of the origin
— placements the physical game allows by simply sliding the whole map
across the table. The coordinate grid still cannot go negative, but
now it does not need to: a sector may land at negative coordinates
and the whole maze renormalizes, zero-anchoring on both axes (which
also pulls the maze snug when the origin corner is vacated, instead
of leaving a blank band). The client offers the new landings as ghost
slots on every side, verified slot-for-slot against engine truth.

The move also closes a real gap: remapState never carried creatures,
boobytrap tokens, glue, open safes, or dimensional warp tokens when a
sector relocated or rotated — they were left hovering at their old
coordinates. Sectors now carry everything standing on them. Both
changes ride rules rev 11; older ledgers replay their flaws intact,
as replay determinism demands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:07:01 -04:00
Eric WagonerandClaude Fable 5 53c84ec6cb Relocate Sector gets ground to land on
The card asked for a destination click that did not exist: a sector
relocates onto an EMPTY 5x5 slot, and empty slots have no floor to
click — the board only rendered cells that are part of the maze. Now
lifting a sector outlines the whole 5x5 (not one square) and dashed
green landings appear beyond the maze edge, one per legal slot; the
svg canvas grows to hold them. Clicking a landing completes the cast.

The landings mirror the engine's own relocateSector law — on the
5-grid, non-negative, vacant, every sector still adjacent to at least
one other — verified slot-for-slot against engine ground truth across
2p and 4p boards. Clicking the maze again re-picks the sector, and a
sector with nowhere legal to go says so in the hint bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:00:43 -04:00
Eric WagonerandClaude Fable 5 18d3142f95 Rev 10: a total stop ends the exchange, and the modal stops goading
Slow Death, countered with Force Field — and the battle modal popped
right back up as if nothing had happened, goading a second counter
out of a hand that had already won the exchange. The engine was
bouncing every attacker's pass back to the defender so counters could
stack; correct for partial stops (Blunt, Absorb, Reflection numbers),
senseless past a total one. Under rules rev 10, when a Full Shield,
Force Field, Teleport escape, Full Reflection, or Reverse stands
unnullified, the attacker's declined answer resolves the stack on the
spot. Older ledgers keep the bounce and replay unchanged.

And when the exchange legitimately returns — a partial counter
standing, or a shield broken by Anti-Anti — the modal now says so:
"Your Blunt stands — only what slips past it will land," or "Your
Full Shield was nullified — the attack comes on unchecked," instead
of replaying the fanfare of a fresh, unanswered attack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 19:53:38 -04:00
Eric WagonerandClaude Fable 5 ac53773fe4 The dimming aid stops lying about boobytrap, glue, and safe
Three cards sat wrongly in the creation bucket of eligibleCellsFor,
so the targeting dim demanded sighted empty squares the engine never
asks for. Boobytrap tokens go anywhere on the board but solid stone —
no sight required; a trap you can see coming is a poor trap. Glue and
the safe were worse than restricted: inverted. Both seize a square
that HOLDS a loose object or treasure, exactly the squares the empty-
square rule dimmed out. Each now has its own branch mirroring the
engine's actual checks, pinned by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 19:31:57 -04:00
Eric WagonerandClaude Fable 5 1275ac9287 Three table manners: Safari replays, docked creature cards, a quiet hand
Safari collapsed the replay board to nothing: an svg with only a
viewBox has no intrinsic size there, and inside the replay's
max-height flex column WebKit sized it 0x0 while Chrome inferred the
ratio and filled the frame. Explicit width/height attributes (doubled,
so the CSS max-* caps still govern the display size) give every engine
the same answer — verified 560x560 in both WebKit and Chromium, with
the main board-zone rendering unchanged.

Commanding your own creature no longer opens the centered card peek —
a modal squarely blocks the squares you are about to march it across.
The card now docks in the paper rail with its live stats, the one
place a card enlarges outside the center of the table. Enemy creatures
and idle peeks keep the modal; they ask nothing of the board.

And once an object is pocketed, the hand goes visibly quiet: cards dim
and refuse selection instead of walking you through target-picking
toward an engine refusal, with a rail slip saying what remains (draw
and end the turn). Counters, chaos shields, and discards still play —
an ambush sprung by the very pickup must be answerable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 18:59:16 -04:00
Eric Wagoner 7bd82d7679 Merge branch 'automatons' 2026-08-16 18:34:43 -04:00
Eric WagonerandClaude Fable 5 2c3e622ce5 Rev 9: REDIRECTION connects the exits you choose
Playtesting read the card right and the engine had it wrong. "Placing
the A tokens on one set of exits & the B tokens on the other" means
the two exits you mark BECOME a pair — and their former partners pair
with each other — not that the two exits trade destinations. Under
rev 9, click the exit and then the exit you want it to connect TO
(the hint now says so); already-connected exits are refused. Earlier
games keep the swap their logs were validated against. Test pins the
full four-way rewiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:40:35 -04:00
Eric WagonerandClaude Fable 5 d9922da4df Number cards ride along on edge and cell casts
The edge-click dispatch attached the tapped number card only for
wall attacks, so the warp wand's first use could never receive its
charges ("a wand's first use needs a number card" no matter what),
and wall of fire silently burned one turn instead of its NUMBER.
Cell-target casts had the same gap for duration terrain. The attached
number now rides every edge and cell cast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:21:54 -04:00
Eric WagonerandClaude Fable 5 4ef3ee5641 Drop playwright scratch output from tracking
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:21:46 -04:00
166 changed files with 9945 additions and 1995 deletions
+21
View File
@@ -0,0 +1,21 @@
// Strict full-ledger replay: ANY refused command is a hard failure.
import { readFileSync } from "fs";
import { createGame, applyCommand } from "../packages/engine/src/game.ts";
const lines = readFileSync(process.argv[2], "utf8").trim().split("\n").map((l) => JSON.parse(l));
const meta = lines.find((l) => l.kind === "meta");
const start = lines.find((l) => l.kind === "start");
if (!start) {
console.log(`OK ${process.argv[2].split("/").pop()}: lobby only, nothing to replay`);
process.exit(0);
}
const joins = lines.filter((l) => l.kind === "join");
const playerIds = [...new Set([meta.hostId, ...joins.map((j) => j.name)])];
let { state } = createGame({ playerIds, seed: meta.seed, sets: start.expansion ? ["basic", "expansion1"] : ["basic"], colors: start.colors, deckRev: start.deckRev });
let n = 0;
for (const l of lines) {
if (l.kind !== "command") continue;
const r = applyCommand(state, l.playerId, l.command);
if (!r.ok) { console.log(`FAIL ${process.argv[2].split("/").pop()} seq ${l.seq}: ${r.error}`); process.exit(1); }
state = r.state; n++;
}
console.log(`OK ${process.argv[2].split("/").pop()}: ${n} commands replay clean`);
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Pre-deploy determinism check: fetch every production ledger and strictly
# replay it against the LOCAL engine. Any refused command is a hard failure.
# Run from the repo root before deploying any engine change.
set -euo pipefail
HOST="${1:-104.236.96.198}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
scp -q "root@$HOST:/var/lib/wizwar/rooms/*.jsonl" "$TMP/"
fails=0
for f in "$TMP"/*.jsonl; do
out=$(npx tsx deploy/replay-verify.mjs "$f" 2>&1 | tail -1)
case "$out" in
FAIL*) echo "$out"; fails=$((fails+1));;
esac
done
count=$(ls "$TMP" | wc -l | tr -d ' ')
echo "--- $count ledgers checked, $fails failures"
[ "$fails" -eq 0 ]
+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# Nightly wiz-war ledger backup to DigitalOcean Spaces.
# current/ - exact mirror of /var/lib/wizwar
# snapshots/ - one dated copy per day, pruned after 90 days
# Ledgers are append-only JSONL; the game server never needs stopping.
set -u
BUCKET="kestrel-wizwar-backups"
SRC="/var/lib/wizwar"
LOG="/var/log/wizwar/backup.log"
STAMP=$(date +%Y-%m-%d)
if grep -q CHANGE_ME /root/.config/rclone/rclone.conf; then
echo "$(date -Is) skipped: Spaces credentials not configured yet" >> "$LOG"
exit 0
fi
{
echo "=== $(date -Is) backup start"
rclone sync "$SRC" "spaces:$BUCKET/wizwar/current" 2>&1
rclone copy "$SRC" "spaces:$BUCKET/wizwar/snapshots/$STAMP" 2>&1
# Prune dated snapshots older than 90 days (by directory name).
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
rclone lsf "spaces:$BUCKET/wizwar/snapshots/" --dirs-only 2>/dev/null | \
while read -r d; do
day="${d%/}"
if [[ "$day" < "$CUTOFF" ]]; then
echo "pruning snapshot $day"
rclone purge "spaces:$BUCKET/wizwar/snapshots/$day" 2>&1
fi
done
echo "=== $(date -Is) backup done"
} >> "$LOG"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+50 -9
View File
@@ -27,8 +27,6 @@ export interface Warp {
from: { cell: Cell; side: Side };
/** ...you arrive at this cell (entering through `side`). */
to: { cell: Cell; side: Side };
/** An aisle (AUTO) warp joins two edges that meet at a corner. */
aisle?: boolean;
}
export interface AssembledBoard {
@@ -121,7 +119,7 @@ export interface AssemblyOptions {
* openings pair straight across the map — correct for the 4p square; the
* standard 2p column crosses its side openings and must pass pairs.
*/
warpPairs?: ([OpeningRef, OpeningRef] | [OpeningRef, OpeningRef, "aisle"])[];
warpPairs?: [OpeningRef, OpeningRef][];
}
/**
@@ -206,12 +204,11 @@ export function assembleBoard(
};
if (options.warpPairs) {
for (const [a, b, kind] of options.warpPairs) {
for (const [a, b] of options.warpPairs) {
const ca = openingCell(a.sector, a.side);
const cb = openingCell(b.sector, b.side);
const aisle = kind === "aisle" ? { aisle: true as const } : {};
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side }, ...aisle });
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side }, ...aisle });
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side } });
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side } });
}
} else {
for (let i = 0; i < placements.length; i++) {
@@ -363,6 +360,41 @@ export function hasWarpLineOfSight(
to: Cell,
blockedCells?: Record<string, true>,
): boolean {
return warpSightTrace(board, from, to, blockedCells) !== null;
}
/**
* How a sight line reached its target — the material for drawing it. A warp
* trace carries the two mouths and the exact rim-crossing points (in board
* coordinates, where cell (x,y) spans [x,x+1]) so a renderer can draw the
* near leg to `entry` and the far leg from `exit`.
*/
export type SightTrace =
| { kind: "direct" }
| {
kind: "warp";
mouthA: { cell: Cell; side: Side };
mouthB: { cell: Cell; side: Side };
entry: { x: number; y: number };
exit: { x: number; y: number };
};
export function traceSight(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): SightTrace | null {
if (hasLineOfSight(board, from, to, blockedCells)) return { kind: "direct" };
return warpSightTrace(board, from, to, blockedCells);
}
function warpSightTrace(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): Extract<SightTrace, { kind: "warp" }> | null {
const DIR: Record<Side, { x: number; y: number }> = {
N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 },
};
@@ -375,6 +407,9 @@ export function hasWarpLineOfSight(
// A filled mouth chokes the tunnel unless the viewer/target IS the mouth.
if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue;
if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue;
// A wall built over either mouth seals the tunnel to sight entirely.
if (edgeState(board, mouthA, sideA) !== "open") continue;
if (edgeState(board, mouthB, w.to.side) !== "open") continue;
// Rotation taking the far board's inward direction onto sideA.
const u = DIR[inward], v = DIR[sideA];
@@ -423,10 +458,16 @@ export function hasWarpLineOfSight(
segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells,
[cellKey(to), cellKey(mouthB)])
) {
return true;
return {
kind: "warp",
mouthA: { cell: mouthA, side: sideA },
mouthB: { cell: mouthB, side: w.to.side },
entry: P,
exit: Pfar,
};
}
}
return false;
return null;
}
/** The game's full line-of-sight check: direct, or through a wraparound opening. */
+1 -1
View File
@@ -4,7 +4,7 @@
import cardsData from "../data/cards.json";
export type CardSet = "basic" | "expansion1" | "expansion2";
export type CardSet = "basic" | "expansion1";
export type CardType =
| "attack"
| "neutral"
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -68,7 +68,7 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
];
const board = assembleBoard(placements, {
warpPairs: [
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // AUTO WARP corner
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // AUTO WARP corner
[{ sector: 0, side: "N" }, { sector: 2, side: "S" }],
[{ sector: 1, side: "W" }, { sector: 2, side: "E" }],
[{ sector: 0, side: "E" }, { sector: 1, side: "S" }],
@@ -106,10 +106,10 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
warpPairs: [
[{ sector: 0, side: "N" }, { sector: 4, side: "S" }], // tip to tip
[{ sector: 1, side: "W" }, { sector: 3, side: "E" }],
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // aisle-warp corners
[{ sector: 0, side: "E" }, { sector: 3, side: "N" }, "aisle"],
[{ sector: 4, side: "W" }, { sector: 1, side: "S" }, "aisle"],
[{ sector: 4, side: "E" }, { sector: 3, side: "S" }, "aisle"],
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // aisle-warp corners
[{ sector: 0, side: "E" }, { sector: 3, side: "N" }],
[{ sector: 4, side: "W" }, { sector: 1, side: "S" }],
[{ sector: 4, side: "E" }, { sector: 3, side: "S" }],
],
});
return { board, rng: rngN };
+141 -11
View File
@@ -2,8 +2,8 @@
// The server sends this after every state change; clients never see the
// deck order or other players' hands.
import { sightBetween, type AssembledBoard } from "./board";
import { type CardInstance } from "./cards";
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, type CardInstance } from "./cards";
import {
boardView,
LOS_BLOCKING_CONTENT,
@@ -28,6 +28,8 @@ export interface PlayerPublicView {
carriedTreasureId: string | null;
lostTurns: number;
extraTurns: number;
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
passWallCharges: number;
displayed: CardInstance[];
/** Which of the six physical wizard colors this player plays. */
colorIndex: number;
@@ -40,6 +42,8 @@ export interface GameView {
winReason: "treasures" | "lastStanding" | null;
turn: TurnState;
activePlayerId: PlayerId;
/** The game's rules revision — the client mirrors rev-gated legality. */
deckRev: number;
/** Board with dynamic wall changes already merged in. */
board: AssembledBoard;
players: PlayerPublicView[];
@@ -60,8 +64,15 @@ export interface GameView {
/** Accumulated attack damage per edge (public — cracks show). */
wallDamage: Record<string, number>;
openDoorEdges: string[];
/** Door edges held open by a standing wizard. */
heldDoorEdges: string[];
/** Edges conjured into being (walls, doors, firewalls) - dispellable. */
createdEdges: string[];
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
knownIllusionEdges: string[];
/** Every illusion edge and YOUR verdict on it (rules rev 29+): untested
* shimmers, believes renders solid, mine/seesThrough are ghosts. */
illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine">;
creatures: CreatureState[];
/** Charges left on displayed wands (public), by card instance id. */
wandCharges: Record<string, number>;
@@ -73,6 +84,8 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** Whether YOUR ward is set to spring. */
yourWardArmed: boolean;
/** A grab hangs while the treasure's owner decides their Ward (rev 31). */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
/** Once the game is finished, every hand goes face-up on the table. */
@@ -82,11 +95,20 @@ export interface GameView {
export function viewFor(state: GameState, playerId: PlayerId): GameView {
const you = state.players.find((p) => p.id === playerId);
// Illusion walls render as real walls unless this viewer knows better.
// From rules rev 29 their locations are open knowledge (the cast is
// public at a table) — what stays personal is each player's verdict.
const base = boardView(state);
const rev29 = (state.config.deckRev ?? 1) >= 29;
const knownIllusionEdges: string[] = [];
const illusionEdges: Record<string, "untested" | "believes" | "seesThrough" | "mine"> = {};
let edges = base.edges;
for (const [key, wall] of Object.entries(state.illusionWalls)) {
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
if (rev29) {
illusionEdges[key] =
wall.createdBy === playerId ? "mine"
: wall.belief[playerId] ?? "untested";
}
if (knows) {
knownIllusionEdges.push(key);
} else {
@@ -114,12 +136,14 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
carriedTreasureId: p.carriedTreasureId,
lostTurns: p.lostTurns,
extraTurns: p.extraTurns,
passWallCharges: p.passWallCharges,
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
})),
yourHand: you ? [...you.hand] : [],
revealedHands: state.phase === "finished"
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
: null,
deckRev: state.config.deckRev ?? 1,
treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length,
discardCount: state.discard.length,
@@ -134,7 +158,10 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
doorStates: { ...state.doorStates },
wallDamage: { ...state.wallDamage },
openDoorEdges: [...state.openDoorEdges],
heldDoorEdges: state.heldDoors.map((h) => h.key),
createdEdges: Object.keys(state.createdEdges),
knownIllusionEdges,
illusionEdges,
creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })),
wandCharges: { ...state.wandCharges },
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
@@ -143,6 +170,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
: null,
yourWardArmed: state.wardArmed.includes(playerId),
wardPending: state.wardPending ? { ...state.wardPending } : null,
yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId)
.map((a) => ({ ...a, numbers: [...a.numbers] })),
@@ -167,6 +195,23 @@ export function sightedCellsFor(view: GameView): Set<string> {
const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you);
if (!me) return out;
const { board, blockers } = sightBasis(view);
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
}
return out;
}
/** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// Held-open doors are open doorways to the eye (rules rev 15).
let board = view.board;
if (view.deckRev >= 15 && view.heldDoorEdges.length > 0) {
const edges = { ...board.edges };
for (const k of view.heldDoorEdges) delete edges[k];
board = { ...board, edges };
}
const blockers: Record<string, true> = {};
for (const [key, content] of Object.entries(view.squareContents)) {
if (LOS_BLOCKING_CONTENT[content.kind]) blockers[key] = true;
@@ -176,16 +221,62 @@ export function sightedCellsFor(view: GameView): Set<string> {
blockers[`${p.position.x},${p.position.y}`] = true;
}
}
for (const key of Object.keys(view.board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(view.board, me.position, { x, y }, blockers)) out.add(key);
return { board, blockers };
}
/**
* How one square sees another under this viewer's knowledge of the board
* (believed illusion walls block; held doors admit). Null when no sight
* exists — the renderer's material for drawing the line an attack traveled.
*/
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
const { board, blockers } = sightBasis(view);
return traceSight(board, from, to, blockers);
}
/**
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
* sees a middle square which sees the target — mirroring the engine's
* bentLos so a client can predict whether the modifier will land.
*/
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
const { board, blockers } = sightBasis(view);
if (sightBetween(board, from, to, blockers)) return true;
for (const key of Object.keys(board.cells)) {
if (blockers[key]) continue;
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if (sightBetween(board, from, mid, blockers) && sightBetween(board, mid, to, blockers)) {
return true;
}
}
return out;
return false;
}
/**
* The sight line behind the attack currently on the stack — the board's
* answer to "how can he even see me?". Null when nothing should draw:
* no stack, a creature's or physical attack, a non-LOS card, attacker and
* defender sharing a square, or no sight under this viewer's knowledge
* (a believed illusion wall can honestly hide the line).
*/
export function stackSightTrace(
view: GameView,
): { from: Cell; to: Cell; trace: SightTrace } | null {
const stack = view.stack;
if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
const a = view.players.find((p) => p.id === stack.attackerId);
const d = view.players.find((p) => p.id === stack.defenderId);
if (!a || !d) return null;
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
const trace = traceSightFor(view, a.position, d.position);
return trace ? { from: a.position, to: d.position, trace } : null;
}
const CREATION_CARD_IDS = new Set([
"fill-square-with-stone", "thornbush", "killer-ooze", "rosebush", "dust-cloud",
"fill-square-with-slime", "create-pit", "handful-of-tacks", "glue", "safe", "boobytrap",
"fill-square-with-slime", "create-pit", "handful-of-tacks",
]);
const SUMMON_CARD_IDS = new Set([
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
@@ -209,7 +300,6 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
const out = new Set<string>();
for (const k of cells) {
if (view.squareContents[k]) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue;
if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue;
if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue;
@@ -217,7 +307,6 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue;
if (!sighted.has(k)) continue;
out.add(k);
void x; void y;
}
return out;
}
@@ -256,11 +345,52 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
return out;
}
if (cardId === "boobytrap") {
// Tokens go anywhere on the board except solid stone — no sight needed;
// a trap you can see coming is a poor trap.
return new Set(cells.filter((k) => view.squareContents[k]?.kind !== "stone"));
}
if (cardId === "glue" || cardId === "safe") {
// Both seize a square that HOLDS something — a loose object or an
// unclaimed treasure — within the caster's sight. The safe additionally
// needs the square clear of terrain to build around its prize.
const out = new Set<string>();
for (const k of cells) {
if (cardId === "safe" && view.squareContents[k]) continue;
const hasObject =
(view.groundObjects[k] ?? []).length > 0 ||
view.treasures.some((t) => t.position && !t.carriedBy && key(t.position.x, t.position.y) === k);
if (!hasObject) continue;
if (!sighted.has(k)) continue;
out.add(k);
}
return out;
}
if (cardId === "thumb-of-god") {
// The die is aimed by sight; where it lands after drifting is fate's.
return sighted;
}
if (cardId === "stone-to-water") {
return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone"));
// The cast demands sight of the stone block, so only sighted ones light.
// The card equally targets stone WALLS — edges the cell-shadow cannot
// express — so with no stone square in view, dimming would shroud the
// true targets: light everything instead.
const out = new Set(
cells.filter((k) => view.squareContents[k]?.kind === "stone" && sighted.has(k)),
);
return out.size > 0 ? out : null;
}
if (cardId === "dispel-creation") {
return new Set(cells.filter((k) => view.squareContents[k] != null));
// Anything created — terrain or creature, whoever made it — in sight.
const out = new Set<string>();
for (const k of cells) {
const created = view.squareContents[k] != null ||
view.creatures.some((c) => key(c.position.x, c.position.y) === k);
if (created && sighted.has(k)) out.add(k);
}
return out;
}
return null;
}
+439 -5
View File
@@ -5,6 +5,7 @@ import {
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, edgeKey } from "../src/board";
import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
@@ -39,11 +40,14 @@ function playOut(
while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const cmd = automatonCommand(view, styleOf.get(seat), tierOf.get(seat)) ?? automatonFallback(view);
const tier = tierOf.get(seat);
const chosen = automatonCommand(view, styleOf.get(seat), tier);
const cmd = chosen ?? automatonFallback(view, tier);
let r = applyCommand(state, seat, cmd);
if (!r.ok) {
const fb = automatonFallback(view);
r = applyCommand(state, seat, fb);
// Retry with the fallback only if the fallback was not what just failed.
const fb = automatonFallback(view, tier);
if (chosen) r = applyCommand(state, seat, fb);
if (!r.ok) {
stuck++;
if (stuck > 3) {
@@ -68,10 +72,9 @@ function playOut(
describe("automaton vs automaton", () => {
it("two clockwork wizards fight a game to its end", () => {
const { state, commands } = playOut(11, 2);
const { state } = playOut(11, 2);
expect(state.phase).toBe("finished");
expect(state.winner).not.toBeNull();
expect(commands).toBeLessThan(4000);
});
it("holds up across many seeds without wedging", () => {
@@ -114,3 +117,434 @@ describe("automaton vs automaton", () => {
}
});
});
function underAttackShared(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
// burn round 1
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
while (actingSeat(state) !== "human") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
const human = state.players.find((p) => p.id === "human")!;
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { ...human.position };
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
rig?.(state, "bot");
const r = applyCommand(state, "human", {
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
});
if (!r.ok) throw new Error(`setup: ${r.error}`);
return r.state;
}
describe("the clockwork does not waste counters on pointless targets", () => {
it("passes on drop-object rather than absorbing nothing", () => {
const state = underAttackShared("drop-object", [
{ instanceId: "absorb#T", cardId: "absorb" },
{ instanceId: "blunt#T", cardId: "blunt" },
{ instanceId: "dagger#T", cardId: "dagger" },
]);
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "pass" });
});
it("shields a drop-object aimed at its carried treasure", () => {
const state = underAttackShared("drop-object", [
{ instanceId: "full-shield#T", cardId: "full-shield" },
{ instanceId: "dagger#T", cardId: "dagger" },
], (s, defender) => {
const d = s.players.find((p) => p.id === defender)!;
const t = s.treasures.find((t) => t.owner !== defender)!;
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
});
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "counteract", instanceId: "full-shield#T" });
});
});
describe("a clogged hand gets shed, not hoarded", () => {
it("the bot discards dead weight so the end-of-turn draw has room", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
// Seven situational neutrals the brain has no play for: a dead hand.
bot.hand = Array.from({ length: 7 }, (_, i) => (
{ instanceId: `rotate-sector#${i}`, cardId: "rotate-sector" }
));
// Walk the bot's turn until it wants to end: it must shed before drawing.
for (let guard = 0; guard < 30; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "hunter", "archmage")!;
if (cmd.type === "discard") {
expect(cmd.instanceIds.length).toBe(2);
const r = applyCommand(state, "bot", cmd);
if (!r.ok) throw new Error(r.error);
state = r.state;
const next = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")!;
expect(next).toEqual({ type: "endTurn", draw: 2 });
return;
}
if (cmd.type === "endTurn") throw new Error("ended the turn without shedding a dead hand");
const r = applyCommand(state, "bot", cmd);
if (!r.ok) throw new Error(`${cmd.type}: ${r.error}`);
state = r.state;
}
throw new Error("never reached the end of the turn");
});
});
describe("the clockwork honors absorb's fine print", () => {
it("answers NO SPELL with blunt, never absorb — durations soak no points", () => {
const state = underAttackShared("no-spell", [
{ instanceId: "absorb#T", cardId: "absorb" },
{ instanceId: "blunt#T", cardId: "blunt" },
]);
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "counteract", instanceId: "blunt#T" });
});
});
describe("the clockwork wields destroy wall", () => {
it("blasts its way out when no road leads to the gold", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
// Brick the bot into a one-square cell far from everything.
bot.position = { x: 4, y: 4 };
for (const side of ["N", "S", "E", "W"] as const) {
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
}
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
bot.hand[0] = { instanceId: "destroy-wall#T", cardId: "destroy-wall" };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "destroy-wall#T" });
// And the engine accepts the blast it chose.
const r = applyCommand(state, "bot", cmd!);
expect(r.ok).toBe(true);
});
it("values destroy wall above the chaff when forced to discard", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand = [
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
{ instanceId: "trader#T", cardId: "trader" },
{ instanceId: "strength#T", cardId: "strength" },
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
{ instanceId: "full-shield#T", cardId: "full-shield" },
{ instanceId: "fireball#T", cardId: "fireball" },
{ instanceId: "number-3#T", cardId: "number-3" },
{ instanceId: "troll#T", cardId: "troll" },
];
state.pendingDiscard = "bot";
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd?.type).toBe("discard");
if (cmd?.type === "discard") {
expect(cmd.instanceIds).not.toContain("destroy-wall#T");
}
});
});
describe("the clockwork wields pass through wall", () => {
it("banks a crossing when bricked in, then steps through the wall", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { x: 4, y: 4 };
for (const side of ["N", "S", "E", "W"] as const) {
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
}
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
bot.hand[0] = { instanceId: "pass-through-wall#T", cardId: "pass-through-wall" };
const cast = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cast).toMatchObject({ type: "cast", instanceId: "pass-through-wall#T" });
let r = applyCommand(state, "bot", cast!);
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "bot")!.passWallCharges).toBe(1);
// The charge is spent on a step through the bricks.
const step = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(step?.type).toBe("move");
r = applyCommand(state, "bot", step!);
if (!r.ok) throw new Error(r.error);
const after = r.state.players.find((p) => p.id === "bot")!;
expect(cellKey(after.position)).not.toBe(cellKey({ x: 4, y: 4 }));
expect(after.passWallCharges).toBe(0);
});
});
describe("the clockwork denies the road", () => {
it("walls a thief's corridor when its treasure is being carried home", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const thief = state.players.find((p) => p.id === "other")!;
// The thief carries the bot's treasure down a one-lane tube to its home.
const t = state.treasures.find((t) => t.owner === "bot")!;
t.position = null;
t.carriedBy = "other";
thief.carriedTreasureId = t.id;
thief.position = { x: 4, y: 2 };
thief.home = { x: 4, y: 6 };
for (let y = 2; y <= 6; y++) {
state.edgeOverrides[edgeKey({ x: 4, y }, "E")] = "wall";
state.edgeOverrides[edgeKey({ x: 4, y }, "W")] = "wall";
}
state.edgeOverrides[edgeKey({ x: 4, y: 6 }, "S")] = "wall";
for (let y = 2; y <= 5; y++) {
state.edgeOverrides[edgeKey({ x: 4, y }, "S")] = "open";
}
bot.position = { x: 4, y: 4 };
bot.hand = [{ instanceId: "create-wall#T", cardId: "create-wall" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "create-wall#T" });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
// The blockade must actually sever the thief's road home.
const target = (cmd as { target: { cell: { x: number; y: number }; side: string } }).target;
expect(["N", "S"]).toContain(target.side);
expect(target.cell.x).toBe(4);
});
});
describe("the widened spellbook", () => {
it("dispels a conjured wall standing between it and the only road", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { x: 4, y: 4 };
for (const side of ["N", "S", "E", "W"] as const) {
const k = edgeKey(bot.position, side);
state.edgeOverrides[k] = "wall";
state.createdEdges[k] = true;
}
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
bot.hand = [{ instanceId: "dispel-creation#T", cardId: "dispel-creation" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "dispel-creation#T" });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
it("offers a buddy pact to the hound at its heels", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const hound = state.players.find((p) => p.id === "other")!;
hound.position = { ...bot.position };
bot.hand = [{ instanceId: "buddy#T", cardId: "buddy" }];
const cmd = automatonCommand(viewFor(state, "bot"), "worrier", "archmage");
expect(cmd).toEqual({
type: "cast", instanceId: "buddy#T",
target: { kind: "player", playerId: "other" },
});
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
});
describe("round two of the owner's tactics", () => {
it("exiles a thief carrying its gold to the far end of nowhere", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
// burn round 1 and reach the bot's turn
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const thief = state.players.find((p) => p.id === "other")!;
const t = state.treasures.find((t) => t.owner === "bot")!;
t.position = null;
t.carriedBy = "other";
thief.carriedTreasureId = t.id;
// The thief is a step from delivering; the bot watches from beside them.
thief.position = { x: thief.home.x, y: thief.home.y === 0 ? 1 : thief.home.y - 1 };
bot.position = { ...thief.position };
bot.hand = [{ instanceId: "teleport-opponent#T", cardId: "teleport-opponent" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "teleport-opponent#T" });
const dest = (cmd as { params: { cell: { x: number; y: number } } }).params.cell;
// The chosen square is a long march from the thief's own home.
const d = Math.abs(dest.x - thief.home.x) + Math.abs(dest.y - thief.home.y);
expect(d).toBeGreaterThanOrEqual(5);
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
it("casts adrenaline when two blows finish what one cannot", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position };
prey.life = 7; // fireball (5) alone cannot; fireball + dagger (3) can
bot.hand = [
{ instanceId: "adrenaline#T", cardId: "adrenaline" },
{ instanceId: "fireball#T", cardId: "fireball" },
{ instanceId: "dagger#T", cardId: "dagger" },
{ instanceId: "number-2#T", cardId: "number-2" },
];
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "adrenaline#T" });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
});
});
describe("the fireball-then-buddy lockout", () => {
it("burns the target, then signs the pact so they cannot hit back", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 24 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position };
bot.life = 8; // bleeding: the clockwork wants out of this fight
bot.hand = [
{ instanceId: "fireball#T", cardId: "fireball" },
{ instanceId: "buddy#T", cardId: "buddy" },
];
// First: the blow.
let cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "fireball#T" });
let r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
state = r.state;
r = applyCommand(state, "other", { type: "pass" });
if (!r.ok) throw new Error(r.error);
state = r.state;
// Then: the pact.
cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({
type: "cast", instanceId: "buddy#T",
target: { kind: "player", playerId: "other" },
});
r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
state = r.state;
// The pact holds: the victim cannot strike their tormentor.
expect(state.sustained.some(
(s) => s.cardId === "buddy" && s.casterId === "bot" && s.targetId === "other",
)).toBe(true);
});
});
describe("a pact once signed is honored", () => {
it("will not attack the wizard it just buddied", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 25 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const prey = state.players.find((p) => p.id === "other")!;
prey.position = { ...bot.position };
// The pact already stands; a fireball waits in hand as temptation.
state.sustained.push({
id: "fx-test", cardId: "buddy", casterId: "bot", targetId: "other",
turnsLeft: 1000, edge: undefined,
} as never);
bot.hand = [{ instanceId: "fireball#T", cardId: "fireball" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
// Anything but an attack on the pacted wizard: no cast at them, no punch.
expect(cmd?.type === "punch").toBe(false);
if (cmd?.type === "cast") {
expect((cmd as { target?: { playerId?: string } }).target?.playerId).not.toBe("other");
}
});
});
describe("no number is wasted on a turn that ends at a grab", () => {
it("gold two free steps away: the berserker charges without spending its 5", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic", "expansion1"], deckRev: 29 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const prey = state.players.find((p) => p.id === "other")!;
// Enemy far across the maze; their treasure lies one step from the bot.
prey.position = { x: 0, y: 0 };
bot.position = { x: 5, y: 5 };
const t = state.treasures.find((t) => t.owner === "other")!;
t.position = { x: 5, y: 6 };
t.carriedBy = null;
state.edgeOverrides[edgeKey({ x: 5, y: 5 }, "S")] = "open";
bot.hand = [
{ instanceId: "number-5#T", cardId: "number-5" },
{ instanceId: "number-2#T", cardId: "number-2" },
];
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd?.type).not.toBe("playNumberForMovement");
});
});
describe("the clockwork flees the dread", () => {
it("caught in FEAR's bubble, it spends its legs moving away", () => {
let { state } = createGame({ playerIds: ["bot", "grim"], seed: 42, sets: ["basic"], deckRev: 32 });
for (let guard = 0; guard < 10 && !(actingSeat(state) === "bot" && state.turn.round > 1); guard++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const grim = state.players.find((p) => p.id === "grim")!;
grim.position = { x: 2, y: 5 };
bot.position = { x: 2, y: 7 }; // two spaces inside the dread
state.sustained.push({
id: "fx-fear", cardId: "fear", casterId: "grim", targetId: "grim",
remainingTurns: 5, data: {},
} as never);
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd?.type).toBe("move");
if (cmd?.type === "move") {
const r = applyCommand(state, "bot", cmd);
if (!r.ok) throw new Error(r.error);
const after = r.state.players.find((p) => p.id === "bot")!;
const d = Math.abs(after.position.x - grim.position.x) + Math.abs(after.position.y - grim.position.y);
expect(d).toBeGreaterThan(2);
}
});
});
+88 -16
View File
@@ -9,6 +9,11 @@ import {
type SectorPlacement,
} from "../src/board";
import { buildDeck } from "../src/cards";
import { edgeKey, type Side } from "../src/board";
import { activePlayer, applyCommand, boardView, createGame, gameLos } from "../src/game";
import { createRng } from "../src/rng";
import { setupBoard } from "../src/setups";
import { giveCard } from "./helpers";
describe("card data", () => {
it("basic deck is exactly 125 cards (official 6e rulebook)", () => {
@@ -109,9 +114,7 @@ describe("board assembly", () => {
});
describe("player counts 2-6", () => {
it("assembles a coherent board for every supported count", async () => {
const { setupBoard } = await import("../src/setups");
const { createRng } = await import("../src/rng");
it("assembles a coherent board for every supported count", () => {
for (const n of [2, 3, 4, 5, 6]) {
const { board } = setupBoard(n, createRng(n * 7));
expect(board.homes.length).toBe(n);
@@ -127,8 +130,7 @@ describe("player counts 2-6", () => {
}
});
it("full games start at every count", async () => {
const { createGame } = await import("../src/game");
it("full games start at every count", () => {
for (const n of [3, 5, 6]) {
const ids = Array.from({ length: n }, (_, i) => `w${i}`);
const { state } = createGame({ playerIds: ids, seed: 99 + n, sets: ["basic", "expansion1"] });
@@ -158,12 +160,7 @@ describe("sight and the wraparound openings", () => {
});
});
import { createRng } from "../src/rng";
import { setupBoard as setupBoardForPins } from "../src/setups";
describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
const setupBoard = setupBoardForPins;
function onEdge(c: { x: number; y: number }, origin: { x: number; y: number }, side: string): boolean {
if (side === "N") return c.y === origin.y && c.x >= origin.x && c.x < origin.x + 5;
if (side === "S") return c.y === origin.y + 4 && c.x >= origin.x && c.x < origin.x + 5;
@@ -203,17 +200,16 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
}
});
it("relocation discards the aisle warp: only opposite edges connect after", async () => {
const { createGame, applyCommand, activePlayer } = await import("../src/game");
it("relocation discards the aisle warp: only opposite edges connect after", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
const bent = (warps: typeof state.board.warps) =>
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
const me = activePlayer(state);
me.hand[0] = { instanceId: "rel#1", cardId: "relocate-sector" };
const rel = giveCard(state, me.id, "relocate-sector");
// Slide the top sector from (5,0) across to (0,0): an L becomes a block.
const r = applyCommand(state, me.id, {
type: "cast", instanceId: "rel#1",
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } },
});
if (!r.ok) throw new Error(r.error);
@@ -223,8 +219,10 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
describe("the aisle warp treats its corner as adjacent — sight included", () => {
it("sight passes through the AUTO WARP, diagonals and all", () => {
const { board } = setupBoardForPins(3, createRng(7));
const aisle = board.warps.find((w) => w.aisle)!;
const { board } = setupBoard(3, createRng(7));
const aisle = board.warps.find(
(w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y,
)!;
expect(aisle).toBeDefined();
// From the aisle mouth, squares beyond the corner are visible — including
// at least one OFF the straight axis (the adjacency promise).
@@ -243,3 +241,77 @@ describe("the aisle warp treats its corner as adjacent — sight included", () =
expect(offAxis).toBeGreaterThan(0);
});
});
describe("bricking over a warp mouth", () => {
it("create wall seals a lettered opening: no passage, no sight", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 18 });
// round 1 passes
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const w = state.board.warps[0]!;
const caster = activePlayer(state);
caster.position = { ...w.from.cell };
caster.hand[0] = { instanceId: "cw#1", cardId: "create-wall" };
const r = applyCommand(state, caster.id, {
type: "cast", instanceId: "cw#1",
target: { kind: "edge", cell: w.from.cell, side: w.from.side },
});
if (!r.ok) throw new Error(r.error);
state = r.state;
// Passage: stepping into the warp is now a wall.
const step = stepTarget(boardView(state), w.from.cell, w.from.side);
expect(step.kind).toBe("blocked");
// Sight: the far mouth no longer carries warp sight.
expect(gameLos(state, w.from.cell, w.to.cell)).toBe(false);
// One tunnel: the FAR mouth is walled too, and unwalling it reopens both.
const farStep = stepTarget(boardView(state), w.to.cell, w.to.side);
expect(farStep.kind).toBe("blocked");
const caster2 = activePlayer(state);
caster2.position = { ...w.to.cell };
caster2.hand[0] = { instanceId: "dw#1", cardId: "destroy-wall" };
const r2 = applyCommand(state, caster2.id, {
type: "cast", instanceId: "dw#1",
target: { kind: "edge", cell: w.to.cell, side: w.to.side },
});
if (!r2.ok) throw new Error(r2.error);
expect(stepTarget(boardView(r2.state), w.from.cell, w.from.side).kind).toBe("warp");
});
});
describe("breaching the rim (rules rev 19)", () => {
it("destroying a perimeter wall opens both sides as a new warp", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 19 });
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
// Find a rim wall with a wrap counterpart that is NOT already an opening.
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
for (const side of ["N", "E", "S", "W"] as Side[]) {
const n = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.cells[`${n.x},${n.y}`]) continue;
if ((view.edges[edgeKey({ x, y }, side)] ?? "open") !== "wall") continue;
const caster = activePlayer(state);
caster.position = { x, y };
caster.hand[0] = { instanceId: "dw#1", cardId: "destroy-wall" };
const warpsBefore = state.board.warps.length;
const r = applyCommand(state, caster.id, {
type: "cast", instanceId: "dw#1", target: { kind: "edge", cell: { x, y }, side },
});
if (!r.ok) throw new Error(r.error);
if (r.state.board.warps.length === warpsBefore) continue; // no counterpart in this row
expect(r.state.board.warps.length).toBe(warpsBefore + 2);
const w = r.state.board.warps[warpsBefore]!;
expect(stepTarget(boardView(r.state), w.from.cell, w.from.side).kind).toBe("warp");
return;
}
}
throw new Error("setup: no breachable rim wall found");
});
});
+328 -16
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES, stepTarget } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
import { viewFor } from "../src/view";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */
@@ -249,14 +250,6 @@ describe("stack discipline", () => {
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
});
it("unimplemented cards refuse to cast with a clear error", () => {
let { state } = newGame();
const caster = activePlayer(state);
const card = giveCard(state, caster.id, "bomb"); // expansion2: historical, never implemented
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/not implemented/);
});
});
describe("speedstone", () => {
@@ -277,8 +270,7 @@ describe("speedstone", () => {
});
describe("the post-game reveal", () => {
it("shows an eliminated player's hand as they fell, not their emptied one", async () => {
const { viewFor } = await import("../src/view");
it("shows an eliminated player's hand as they fell, not their emptied one", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
@@ -350,7 +342,7 @@ describe("attacking walls and doors", () => {
expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false);
});
it("a powered fireball brings a wall down at 20 accumulated damage", () => {
it("repeated fireballs bring a wall down at 20 accumulated damage", () => {
let { state } = newGame();
state = toRound2(state);
let me = activePlayer(state);
@@ -388,7 +380,7 @@ describe("attacking walls and doors", () => {
});
});
describe("rules revision 3", () => {
describe("ward arming and chaos shields (rules rev 3)", () => {
function rev3Game(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
}
@@ -409,7 +401,6 @@ describe("rules revision 3", () => {
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
// Armed (on the owner's own turn): the trap bites for 3.
state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!;
const ownerTurnState = (() => {
let s = state;
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
@@ -735,7 +726,7 @@ describe("answering counteractions (FAQ rulings)", () => {
});
});
describe("rules revision 8", () => {
describe("speed and warp-token creation (rules rev 8)", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
state = toRound2(state);
@@ -794,3 +785,324 @@ describe("redirection (rules rev 9)", () => {
expect(cellKey(after(pbCell).to.cell)).toBe(cellKey(paCell));
});
});
describe("total stops end the exchange (rules rev 10)", () => {
function rev10Game() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
return { state, attacker, defender };
}
it("a declined answer to Force Field resolves at once — no goading re-prompt", () => {
let { state, attacker, defender } = rev10Game();
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
const lifeBefore = d.life;
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" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
});
it("a partial counter still invites the defender to stack more", () => {
let { state, attacker, defender } = rev10Game();
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
state = must(state, attacker, { type: "pass" });
expect(state.stack).not.toBeNull();
expect(state.stack!.waitingOn).toBe(defender);
});
it("a nullified shield is no stop — the exchange returns to the defender", () => {
let { state, attacker, defender } = rev10Game();
const fb = giveCard(state, attacker, "fireball");
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBeLessThan(15);
});
});
describe("escapes and elemental walls as counteractions", () => {
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const atk = giveCard(state, attacker, attackId);
const d = state.players.find((p) => p.id === defender)!;
defenderCards.forEach((c, i) => { d.hand[i] = c; });
state = must(state, attacker, {
type: "cast", instanceId: atk.instanceId, target: { kind: "player", playerId: defender },
});
return { state, attacker, defender, atk };
}
it("invisible vanishes mid-stack: the sustained goes up, the hit needs a 1", () => {
let { state, defender } = rigged("fireball", [
{ instanceId: "invisible#T", cardId: "invisible" },
{ instanceId: "number-3#T", cardId: "number-3" },
]);
state = must(state, defender, {
type: "counteract", instanceId: "invisible#T", numberInstanceIds: ["number-3#T"],
});
const fx = state.sustained.find((s) => s.cardId === "invisible" && s.targetId === defender);
expect(fx).toBeDefined();
expect(fx!.remainingTurns).toBe(3);
// The attached number left the hand with the spell.
const d = state.players.find((p) => p.id === defender)!;
expect(d.hand.some((c) => c.instanceId === "number-3#T")).toBe(false);
});
it("anti-anti cannot pin the vanishing — escape is escape", () => {
let { state, attacker, defender } = rigged("fireball", [
{ instanceId: "invisible#T", cardId: "invisible" },
]);
state = must(state, defender, { type: "counteract", instanceId: "invisible#T" });
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
const refused = applyCommand(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
expect(refused.ok).toBe(false);
});
it("waterwall meets the fireball and stops it whole", () => {
let { state, attacker, defender } = rigged("fireball", [
{ instanceId: "waterwall#T", cardId: "waterwall" },
]);
const lifeBefore = state.players.find((p) => p.id === defender)!.life;
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
state = must(state, attacker, { type: "pass" });
// A total stop: the attacker's declined answer resolves at once (rev 10+).
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
});
it("waterwall refuses everything that is not a fireball", () => {
let { state, defender } = rigged("dagger", [
{ instanceId: "waterwall#T", cardId: "waterwall" },
]);
const refused = applyCommand(state, defender, { type: "counteract", instanceId: "waterwall#T" });
expect(refused.ok).toBe(false);
});
});
describe("empathy as a counteraction", () => {
function empathyRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 15 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "empathy#T", cardId: "empathy" };
d.hand[1] = { instanceId: "number-3#T", cardId: "number-3" };
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
return { state, attacker, defender };
}
it("the blow lands on both — and the link lingers its number's turns", () => {
let { state, attacker, defender } = empathyRig();
state = must(state, defender, {
type: "counteract", instanceId: "empathy#T", numberInstanceIds: ["number-3#T"],
});
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
const fx = state.sustained.find((f) => f.cardId === "empathy" && f.targetId === defender);
expect(fx?.remainingTurns).toBe(3);
});
it("anti-anti severs the link: no mirror, no lingering spell", () => {
let { state, attacker, defender } = empathyRig();
state = must(state, defender, { type: "counteract", instanceId: "empathy#T" });
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
expect(state.sustained.some((f) => f.cardId === "empathy" && f.targetId === defender)).toBe(false);
});
});
describe("empathy mirrors resolution-hook blows (rules rev 16)", () => {
it("a believed illusion bites both wizards", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const ill = giveCard(state, attacker, "illusionary-attack");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "empathy#T", cardId: "empathy" };
state = must(state, attacker, {
type: "cast", instanceId: ill.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "fireball" },
});
state = must(state, defender, { type: "counteract", instanceId: "empathy#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const dAfter = state.players.find((p) => p.id === defender)!;
const aAfter = state.players.find((p) => p.id === attacker)!;
// Whichever way belief rolled, the two lives moved in lockstep.
expect(15 - aAfter.life).toBe(15 - dAfter.life);
});
});
describe("the reflected-attack window (rules rev 25)", () => {
function reflectRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 25 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "sudden-death");
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
// The attacker's pass meets the total stop and settles the reflection —
// which opens the returned spell's window, waiting on the caster.
state = must(state, attacker, { type: "pass" });
return { state, attacker, defender };
}
it("the returned spell opens a window: absorb soaks the reflected blast", () => {
let { state, attacker, defender } = reflectRig();
// The reflection settled into a NEW stack: the caster now defends.
expect(state.stack).not.toBeNull();
expect(state.stack!.defenderId).toBe(attacker);
expect(state.stack!.reflectedBase).toEqual({ damage: 10, duration: 0 });
giveCard(state, attacker, "absorb", "AB", 0);
state = must(state, attacker, { type: "counteract", instanceId: "absorb#AB" });
state = must(state, defender, { type: "pass" });
state = must(state, attacker, { type: "pass" });
// 10 reflected minus absorb's 3.
expect(state.players.find((p) => p.id === attacker)!.life).toBe(8);
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
});
it("a second full reflection turns it around again — the ping-pong war", () => {
let { state, attacker, defender } = reflectRig();
giveCard(state, attacker, "full-reflection", "FR2", 0);
state = must(state, attacker, { type: "counteract", instanceId: "full-reflection#FR2" });
state = must(state, defender, { type: "pass" });
// Turned around again: now the original defender must answer it.
expect(state.stack).not.toBeNull();
expect(state.stack!.defenderId).toBe(defender);
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(5);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(15);
});
it("rev 24 lands the reflected blow instantly, as stored games replay", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 24 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "sudden-death");
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, attacker, { type: "pass" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === attacker)!.life).toBe(5);
});
});
describe("a reflected lightning blast ends the caster's turn (rules rev 28)", () => {
function boltRig(deckRev: number) {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const lb = giveCard(state, attacker, "lightning-blast");
giveCard(state, attacker, "number-3", "N", 1);
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: lb.instanceId, numberInstanceIds: ["number-3#N"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, attacker, { type: "pass" });
// rev 25+: the returned bolt opens its window; the caster declines to answer.
if (state.stack) state = must(state, attacker, { type: "pass" });
return { state, attacker, defender };
}
it("FAQ: 'you lose the rest of your turn and can't draw cards'", () => {
let { state, attacker } = boltRig(28);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
expect(state.turn.actionsEnded).toBe(true);
// No more marching, and the end-of-turn draw comes up empty.
expect(applyCommand(state, attacker, { type: "move", direction: "N" }).ok).toBe(false);
const before = state.players.find((p) => p.id === attacker)!.hand.length;
state = must(state, attacker, { type: "endTurn", draw: 2 });
expect(state.players.find((p) => p.id === attacker)!.hand.length).toBe(before);
// And the stun still costs the next turn.
expect(state.players.find((p) => p.id === attacker)!.lostTurns
+ (activePlayer(state).id === attacker ? 1 : 0)).toBeGreaterThanOrEqual(1);
});
it("rev 27 keeps the old behavior: the caster plays on and draws", () => {
let { state, attacker } = boltRig(27);
expect(state.turn.actionsEnded).toBe(false);
const before = state.players.find((p) => p.id === attacker)!.hand.length;
state = must(state, attacker, { type: "endTurn", draw: 2 });
expect(state.players.find((p) => p.id === attacker)!.hand.length).toBeGreaterThan(before);
});
});
describe("fireball burns only the stones in play (rules rev 33)", () => {
it("a displayed stone dies; a hidden one stays secret and safe", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 33 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
d.hand[1] = { instanceId: "speedstone#T", cardId: "speedstone" };
d.displayed = ["powerstone#T"]; // only the powerstone is on the table
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.hand.some((c) => c.instanceId === "powerstone#T")).toBe(false);
expect(after.hand.some((c) => c.instanceId === "speedstone#T")).toBe(true);
});
});
describe("stone dead counts only the stones in play (rules rev 34)", () => {
it("hidden stones neither add damage nor betray their count", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 34 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "stone-dead");
giveCard(state, attacker, "number-3", "N", 1);
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
d.hand[1] = { instanceId: "speedstone#T", cardId: "speedstone" };
d.hand[2] = { instanceId: "bloodstone#T", cardId: "bloodstone" };
d.displayed = ["powerstone#T"]; // one on the table, two secret
state = must(state, attacker, {
type: "cast", instanceId: sd.instanceId, numberInstanceIds: ["number-3#N"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
// 3 x 1 displayed = 3 damage... minus the displayed bloodstone? It is
// hidden, so no soak either: 15 - 3 = 12.
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
});
});
+313 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, SIDES, stepTarget, type Side } from "../src/board";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
@@ -205,6 +205,16 @@ describe("monsters", () => {
});
expect(state.creatures[0]!.maxDamage).toBe(8);
// "Doubles the existing life-points OR movement rate" — the other choice.
const movesBefore = state.creatures[0]!.movesPerTurn;
const mm2 = giveCard(state, me, "mega-monster", "MM2", 3);
state = must(state, me, {
type: "cast", instanceId: mm2.instanceId, target: { kind: "creature", creatureId: id },
params: { boost: "movement" },
});
expect(state.creatures[0]!.movesPerTurn).toBe(movesBefore * 2);
expect(state.creatures[0]!.maxDamage).toBe(8);
const dc = giveCard(state, me, "dispel-creation", "DC", 2);
state = must(state, me, {
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: state.creatures[0]!.position },
@@ -402,8 +412,7 @@ describe("big man (rules rev 5)", () => {
expect(r.ok).toBe(false);
return;
}
// Seed offered no walled pocket beside the home; that is fine — the
// pushable case above pins the mechanism.
throw new Error("setup: seed offered no walled pocket beside the home");
});
it("monsters cannot enter the giant's square", () => {
@@ -440,3 +449,304 @@ describe("big man (rules rev 5)", () => {
expect(g.inPit).toBe(false);
});
});
describe("monsters roll to hit the hidden (rules rev 13)", () => {
function creatureVsInvisible(deckRev: number) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const me = activePlayer(state).id;
const victim = state.players.find((p) => p.id !== me)!;
const r = summon(state, me, "skeleton");
state = r.state;
const bones = state.creatures[0]!;
bones.justCreated = false;
bones.attackUsed = false;
bones.position = { ...victim.position };
state.sustained.push({
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
remainingTurns: 3, data: {},
});
return { state, me, victim, skeletonId: bones.id };
}
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
let { state, me, victim, skeletonId } = creatureVsInvisible(13);
const rngBefore = JSON.stringify(state.rng);
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
state = must(state, victim.id, { type: "pass" });
// The die was consumed, whichever way it landed.
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
});
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
let { state, me, victim, skeletonId } = creatureVsInvisible(12);
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
const rngBefore = JSON.stringify(state.rng);
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
state = must(state, victim.id, { type: "pass" });
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
// No die was consumed on the way.
expect(JSON.stringify(state.rng)).toBe(rngBefore);
});
});
describe("elimination sweeps the board either way (rules rev 14)", () => {
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
const victim = state.players.find((p) => p.id === "c")!;
state.creatures.push({
id: "imp1", kind: "fire-imp", controllerId: "c",
position: { ...victim.position },
damage: 0, maxDamage: 5, movesPerTurn: 0, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
state.sustained.push({
id: "fx-med", cardId: "medusa", casterId: "a", targetId: "c",
remainingTurns: 3, data: {},
});
// Both of c's treasures sit captured on living enemies' home bases.
const mine = state.treasures.filter((t) => t.owner === "c");
expect(mine.length).toBe(2);
mine[0]!.position = { ...state.players.find((p) => p.id === "a")!.home };
mine[0]!.carriedBy = null;
mine[1]!.position = { ...state.players.find((p) => p.id === "b")!.home };
mine[1]!.carriedBy = null;
const active = activePlayer(state).id;
const r = applyCommand(state, active, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "c")!.alive).toBe(false);
expect(state.creatures.some((c) => c.controllerId === "c")).toBe(false);
expect(state.sustained.some((s) => s.targetId === "c")).toBe(false);
});
});
describe("creatures walk open doorways", () => {
it("a skeleton passes a door whose lock was removed", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "door") continue;
const [kind, coords] = key.split(",").length ? key.split(":") as [string, string] : ["", ""];
const [x, y] = coords.split(",").map(Number) as [number, number];
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
state.doorStates[key] = "removed";
state.creatures.push({
id: "sk1", kind: "skeleton", controllerId: activePlayer(state).id,
position: { x, y }, damage: 0, maxDamage: 4, movesPerTurn: 3,
movementUsed: 0, attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, activePlayer(state).id, {
type: "moveCreature", creatureId: "sk1", direction: side,
});
if (!r.ok) throw new Error(r.error);
const sk = r.state.creatures.find((c) => c.id === "sk1")!;
expect(cellKey(sk.position)).toBe(cellKey({ x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) }));
return;
}
throw new Error("setup: seed 42 grew a maze with no doors");
});
});
describe("the wave carries monsters (rules rev 17)", () => {
it("a cornered skeleton takes the waterwall crush", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 17 });
state = toRound2(state);
const caster = activePlayer(state);
// A skeleton pinned against solid stone, one square from the wave's edge.
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "wall") continue;
const [kind, coords] = key.split(":") as [string, string];
if (kind !== "H") continue;
const [x, y] = coords.split(",").map(Number) as [number, number];
// Wave from this wall southward washes the square below.
const below = { x, y: y + 1 };
if (!view.cells[cellKey(below)]) continue;
state.creatures.push({
id: "sk1", kind: "skeleton", controllerId: caster.id,
position: { ...below }, damage: 0, maxDamage: 4, movesPerTurn: 3,
movementUsed: 0, attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
// Pin it: stone directly south of it.
state.squareContents[cellKey({ x, y: y + 2 })] = { kind: "stone", damage: 0, createdBy: caster.id };
caster.position = { x, y };
const stw = giveCard(state, caster.id, "stone-to-water");
const r = applyCommand(state, caster.id, {
type: "cast", instanceId: stw.instanceId,
target: { kind: "edge", cell: { x, y }, side: "S" },
});
if (!r.ok) throw new Error(r.error);
const sk = r.state.creatures.find((c) => c.id === "sk1");
// Washed against the stone: it took crush damage (or died of it).
if (sk) expect(sk.damage).toBeGreaterThan(0);
return;
}
throw new Error("setup: no horizontal wall with a floor below");
});
});
describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => {
it("refreshes each round even with the roll-off winner dead", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
state = toRound2(state);
const firstId = state.players[state.turn.firstIndex]!.id;
const dm = {
id: "dm1", kind: "democratic-monster" as const, controllerId: state.players[0]!.id,
position: { x: 0, y: 0 }, damage: 0, maxDamage: 5, movesPerTurn: 3,
movementUsed: 0, attackUsed: true, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [] as string[],
};
state.creatures.push(dm);
// The roll-off winner falls.
const first = state.players.find((p) => p.id === firstId)!;
first.alive = false;
first.finalHand = [...first.hand];
// Walk a full round of the survivors: the claw must refresh.
for (let i = 0; i < 4 && state.creatures[0]!.attackUsed; i++) {
const active = activePlayer(state);
const r = applyCommand(state, active.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
expect(state.creatures[0]!.attackUsed).toBe(false);
});
});
describe("a mid-round democratic monster claws on the next turn (rules rev 18)", () => {
it("creation spends the turn, not the round", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
state = toRound2(state);
const creator = activePlayer(state);
const r0 = summon(state, creator.id, "democratic-monster");
state = r0.state;
const dm = state.creatures.find((c) => c.kind === "democratic-monster")!;
expect(dm.attackUsed).toBe(false);
expect(dm.justCreated).toBe(true);
// Next player's turn: justCreated clears; the claw is live.
state = must(state, creator.id, { type: "endTurn", draw: 0 });
const mover = activePlayer(state);
const victim = state.players.find((p) => p.id !== mover.id)!;
const fresh = state.creatures.find((c) => c.kind === "democratic-monster")!;
expect(fresh.justCreated).toBe(false);
// March it onto the victim: the touch must open.
fresh.position = { x: victim.position.x - 1, y: victim.position.y };
const r = applyCommand(state, mover.id, { type: "moveCreature", creatureId: fresh.id, direction: "E" });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack?.creatureId ?? null).toBe(fresh.id);
});
});
describe("collapsing walls crush monsters too (rules rev 21)", () => {
function wallRig(deckRev: number) {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "wall") continue;
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
const other = { x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) };
if (!view.cells[cellKey(other)]) continue;
const caster = activePlayer(state);
caster.position = { x, y };
state.creatures.push({
id: "tr1", kind: "troll", controllerId: caster.id,
position: { ...other }, damage: 0, maxDamage: 6, movesPerTurn: 3,
movementUsed: 0, attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const dw = giveCard(state, caster.id, "destroy-wall");
const r = applyCommand(state, caster.id, {
type: "cast", instanceId: dw.instanceId, target: { kind: "edge", cell: { x, y }, side },
});
if (!r.ok) throw new Error(r.error);
return r.state;
}
throw new Error("setup: no interior wall found");
}
it("rev 21: the adjacent troll takes the four points", () => {
const state = wallRig(21);
const troll = state.creatures.find((c) => c.id === "tr1");
// Four points on a six-point troll: hurt but standing (or regenerating).
expect(troll?.damage).toBe(4);
});
it("earlier revisions leave the troll dusty but unharmed", () => {
const state = wallRig(20);
expect(state.creatures.find((c) => c.id === "tr1")!.damage).toBe(0);
});
});
describe("the self-stack resolves on a pass (rules rev 22)", () => {
function selfTouch(deckRev: number) {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const creator = activePlayer(state);
state.creatures.push({
id: "dm1", kind: "democratic-monster", controllerId: creator.id,
position: { x: creator.position.x - 1, y: creator.position.y },
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, creator.id, { type: "moveCreature", creatureId: "dm1", direction: "E" });
if (!r.ok) throw new Error(r.error);
return { state: r.state, creator: creator.id };
}
it("rev 22: passing your own monster's touch takes the claw and moves on", () => {
const { state, creator } = selfTouch(22);
if (!state.stack) return; // a wall between: the touch never opened
expect(state.stack.attackerId).toBe(creator);
expect(state.stack.defenderId).toBe(creator);
const r = applyCommand(state, creator, { type: "pass" });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack).toBeNull();
expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13);
});
it("rev 21 keeps its recorded bounce, no-op as it was", () => {
const { state, creator } = selfTouch(21);
if (!state.stack) return;
const r = applyCommand(state, creator, { type: "pass" });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack).not.toBeNull();
});
});
describe("fear holds off monsters and unwilling feet alike (rules rev 32)", () => {
it("a commanded monster cannot close within three of the fearsome", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 32 });
const a = state.players.find((p) => p.id === "a")!;
const b = state.players.find((p) => p.id === "b")!;
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
state.sustained.push({
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
remainingTurns: 5, data: {},
} as never);
b.position = { x: 2, y: 5 };
a.position = { x: 0, y: 0 };
state.creatures.push({
id: "troll-1", kind: "troll", controllerId: "a", position: { x: 2, y: 9 },
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
scorchedThisTurn: [],
} as never);
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const r = applyCommand(state, "a", { type: "moveCreature", creatureId: "troll-1", direction: "N" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("dread");
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, sustainedOn, type GameState } from "../src/game";
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -346,3 +346,131 @@ describe("cast modifiers", () => {
expect(d.lostTurns).toBe(1); // the stun still applies
});
});
describe("holding the door open (Pick Lock / Master Key)", () => {
function doorRig() {
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"], deckRev: 14 });
state = toRound2(state);
// Find a door edge; stand the acting player beside it.
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "door") continue;
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const cell = { x, y };
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
const holder = activePlayer(state);
holder.position = { ...cell };
return { state, key, cell, side, holder: holder.id };
}
throw new Error("setup: seed 42 grew a maze with no doors");
}
it("a held door outlives the turn and admits another wizard", () => {
let { state, key, cell, side, holder } = doorRig();
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side }, params: { hold: true },
});
state = must(state, holder, { type: "endTurn", draw: 0 });
expect(state.heldDoors.some((h) => h.key === key)).toBe(true);
const guest = state.players.find((p) => p.id !== holder)!;
guest.position = { ...cell };
const r = applyCommand(state, guest.id, { type: "move", direction: side });
if (!r.ok) throw new Error(r.error);
const through = r.state.players.find((p) => p.id === guest.id)!;
expect(cellKey(through.position)).toBe(cellKey(neighbor(cell, side)));
});
it("the door swings shut the moment the holder steps away", () => {
let { state, key, cell, side, holder } = doorRig();
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side }, params: { hold: true },
});
expect(state.heldDoors.some((h) => h.key === key)).toBe(true);
// March the holder until adjacency breaks; the sweep must release.
let walked = state;
let released = false;
const dirs: Side[] = ["N", "E", "S", "W"];
for (const d1 of dirs) {
const r1 = applyCommand(walked, holder, { type: "move", direction: d1 });
if (!r1.ok) continue;
if (!r1.state.heldDoors.some((h) => h.key === key)) { released = true; break; }
for (const d2 of dirs) {
const r2 = applyCommand(r1.state, holder, { type: "move", direction: d2 });
if (!r2.ok) continue;
if (!r2.state.heldDoors.some((h) => h.key === key)) { released = true; break; }
}
if (released) break;
}
expect(released).toBe(true);
});
});
describe("a held door is an open doorway to the eye (rules rev 15)", () => {
function sightRig(deckRev: number) {
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"], deckRev });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "door") continue;
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const cell = { x, y };
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
const holder = activePlayer(state);
const pursuer = state.players.find((p) => p.id !== holder.id)!;
holder.position = { ...cell };
pursuer.position = neighbor(cell, side);
return { state, cell, side, holder: holder.id, pursuer: pursuer.id };
}
throw new Error("setup: seed 42 grew a maze with no doors");
}
it("rev 15: the holder blasts the pursuer through the doorway", () => {
let { state, cell, side, holder, pursuer } = sightRig(15);
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side }, params: { hold: true },
});
const fb = giveCard(state, holder, "fireball", "FB", 1);
const lifeBefore = state.players.find((p) => p.id === pursuer)!.life;
state = must(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
state = must(state, pursuer, { type: "pass" });
expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore);
});
it("an unheld unlocked door still blocks sight", () => {
let { state, cell, side, holder, pursuer } = sightRig(15);
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side },
});
const fb = giveCard(state, holder, "fireball", "FB", 1);
const refused = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(refused.ok).toBe(false);
});
it("earlier revisions keep the blocked sightline, hold or no", () => {
let { state, cell, side, holder, pursuer } = sightRig(14);
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
type: "cast", instanceId: pick.instanceId,
target: { kind: "edge", cell, side }, params: { hold: true },
});
const fb = giveCard(state, holder, "fireball", "FB", 1);
const refused = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(refused.ok).toBe(false);
});
});
+243 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
import { cellKey, stepTarget } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
import { cellKey, edgeKey, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -161,6 +161,58 @@ describe("expansion combat cards", () => {
expect(r.error).toMatch(/blocked/);
}
});
it("idiot at rev 23 forbids item handling and punches but allows counteractions", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
// The victim carries an OPPONENT'S treasure into the spell.
const d = state.players.find((p) => p.id === defender)!;
const stolen = state.treasures.find((t) => t.owner === attacker)!;
stolen.carriedBy = defender;
stolen.position = null;
d.carriedTreasureId = stolen.id;
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// No dropping the carried treasure (no capturing it on your home, either).
const drop = applyCommand(state, defender, { type: "dropTreasure" });
expect(drop.ok).toBe(false);
if (!drop.ok) expect(drop.error).toMatch(/treasure/);
// No punching the tormentor.
expect(applyCommand(state, defender, { type: "punch", targetId: attacker }).ok).toBe(false);
// Goal-aiding spells stay castable (FAQ: "You could, however, destroy a wall").
const sp = giveCard(state, defender, "speed", "SP", 0);
expect(applyCommand(state, defender, { type: "cast", instanceId: sp.instanceId }).ok).toBe(true);
// Counteractions are expressly allowed: absorb an incoming fireball.
state = must(state, defender, { type: "endTurn", draw: 0 });
const fb = giveCard(state, attacker, "fireball", "F", 0);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
giveCard(state, defender, "absorb-spell", "AB", 1);
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AB" }).ok).toBe(true);
});
it("idiot at rev 23 has no effect on a victim carrying their own treasure", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 23,
});
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
const own = state.treasures.find((t) => t.owner === defender)!;
own.carriedBy = defender;
own.position = null;
d.carriedTreasureId = own.id;
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
});
});
describe("swap home bases", () => {
@@ -283,3 +335,192 @@ describe("ambushes (async interrupts)", () => {
expect(state.ambushes.length).toBe(0);
});
});
describe("swap meet trades carried items (rules rev 26)", () => {
function rig(deckRev: number) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sm = giveCard(state, attacker, "swap-meet");
giveCard(state, attacker, "dagger", "D", 1);
giveCard(state, defender, "blaster-wand", "W", 0);
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "dagger;blaster-wand" },
});
state = must(state, defender, { type: "pass" });
return { state, attacker, defender };
}
it("a dagger trades for a wand — both are carried items", () => {
const { state, attacker, defender } = rig(26);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
});
it("older revisions matched only object-typed cards and replay so", () => {
const { state, attacker, defender } = rig(25);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(true);
});
});
describe("swap meet trades treasures too", () => {
function treasureRig(theirsToken: string, mineToken: string) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
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)!;
// The defender hauls one of the attacker's own treasures.
const stolen = state.treasures.find((t) => t.owner === attacker)!;
stolen.position = null; stolen.carriedBy = defender; d.carriedTreasureId = stolen.id;
const sm = giveCard(state, attacker, "swap-meet");
giveCard(state, attacker, "dagger", "D", 1);
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: `${mineToken};${theirsToken}` },
});
state = must(state, defender, { type: "pass" });
return { state, attacker, defender, a, d, stolen };
}
it("a dagger buys back the treasure in the thief's arms", () => {
const { state, attacker, defender } = treasureRig("treasure", "dagger");
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
const t = state.treasures.find((tr) => tr.owner === attacker && tr.carriedBy)!;
expect(a.carriedTreasureId).toBe(t.id);
expect(t.carriedBy).toBe(attacker);
expect(d.carriedTreasureId).toBeNull();
expect(d.hand.some((c) => c.cardId === "dagger")).toBe(true);
});
it("the trade refuses to overload full arms", () => {
// The attacker also carries a treasure: claiming theirs with a dagger
// would mean two in hand — the swap quietly cannot happen.
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
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 t1 = state.treasures.find((t) => t.owner === attacker)!;
t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id;
const t2 = state.treasures.find((t) => t.owner === defender)!;
t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id;
const sm = giveCard(state, attacker, "swap-meet");
giveCard(state, attacker, "dagger", "D", 1);
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "dagger;treasure" },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t1.id);
expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t2.id);
});
it("treasure for treasure trades both armfuls", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 26 });
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 t1 = state.treasures.find((t) => t.owner === attacker)!;
t1.position = null; t1.carriedBy = attacker; a.carriedTreasureId = t1.id;
const t2 = state.treasures.find((t) => t.owner === defender)!;
t2.position = null; t2.carriedBy = defender; d.carriedTreasureId = t2.id;
const sm = giveCard(state, attacker, "swap-meet");
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "treasure;treasure" },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(t2.id);
expect(state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(t1.id);
expect(state.treasures.find((t) => t.id === t1.id)!.carriedBy).toBe(defender);
expect(state.treasures.find((t) => t.id === t2.id)!.carriedBy).toBe(attacker);
});
});
describe("full reflection hands the swap meet choice to the reflector (rev 27)", () => {
function reflectedSwapRig(reflectorChoice?: string) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 27 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const a = state.players.find((p) => p.id === attacker)!;
// The caster carries a treasure and offers a dagger; the reflector may
// instead demand the trade of their own choosing.
const t = state.treasures.find((t) => t.owner === attacker)!;
t.position = null; t.carriedBy = attacker; a.carriedTreasureId = t.id;
const sm = giveCard(state, attacker, "swap-meet");
giveCard(state, attacker, "dagger", "D", 1);
giveCard(state, defender, "full-reflection", "FR", 0);
giveCard(state, defender, "large-rock", "R", 1);
state = must(state, attacker, {
type: "cast", instanceId: sm.instanceId,
target: { kind: "player", playerId: defender },
params: { cardId: "dagger;large-rock" },
});
state = must(state, defender, {
type: "counteract", instanceId: "full-reflection#FR",
...(reflectorChoice ? { params: { cardId: reflectorChoice } } : {}),
});
state = must(state, attacker, { type: "pass" });
return { state, attacker, defender, treasureId: t.id };
}
it("the reflector rewrites the trade: their rock for the caster's treasure", () => {
const { state, attacker, defender, treasureId } = reflectedSwapRig("large-rock;treasure");
const d = state.players.find((p) => p.id === defender)!;
expect(d.carriedTreasureId).toBe(treasureId);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBeNull();
});
it("the reflector may decline: 'none' trades nothing", () => {
const { state, attacker, defender, treasureId } = reflectedSwapRig("none");
expect(state.players.find((p) => p.id === attacker)!.carriedTreasureId).toBe(treasureId);
expect(state.players.find((p) => p.id === defender)!.hand.some((c) => c.cardId === "large-rock")).toBe(true);
});
it("a bare full reflection with no choice trades nothing", () => {
const { state, attacker } = reflectedSwapRig(undefined);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "dagger")).toBe(true);
});
});
describe("go away routs around walls (rules rev 36)", () => {
it("a victim against a wall bends the line instead of standing still", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 36 });
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)!;
// Attacker west of the victim, a wall hard against the victim's east:
// the straight line away is blocked from the first step.
a.position = { x: 1, y: 5 };
d.position = { x: 2, y: 5 };
state.edgeOverrides[edgeKey({ x: 1, y: 5 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "E")] = "wall";
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "N")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 5 }, "S")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "N")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 6 }, "S")] = "open";
const ga = giveCard(state, attacker, "go-away");
giveCard(state, attacker, "number-3", "N3", 1);
state = must(state, attacker, {
type: "cast", instanceId: ga.instanceId, numberInstanceIds: ["number-3#N3"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
const dist = Math.abs(after.position.x - 1) + Math.abs(after.position.y - 5);
// Three routed spaces: strictly farther than where they stood.
expect(dist).toBeGreaterThanOrEqual(3);
expect(after.lostTurns).toBe(1);
});
});
+94 -2
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
describe("expansion terrain", () => {
@@ -161,3 +162,94 @@ describe("expansion terrain", () => {
// Wave side effects vary by geometry; the melt itself is the pinned behavior.
});
});
describe("eligibility dimming mirrors the engine", () => {
it("boobytrap tokens go anywhere but solid stone, sight be damned", () => {
let { state } = newGame();
const caster = activePlayer(state).id;
const view = viewFor(state, caster);
const lit = eligibleCellsFor(view, "boobytrap")!;
// Every board square is fair game (the fresh maze holds no solid stone),
// including squares far outside the caster's sight.
expect(lit.size).toBe(Object.keys(view.board.cells).length);
});
it("glue lights only sighted squares that hold something", () => {
let { state } = newGame();
const caster = activePlayer(state);
// Drop a dagger at the caster's feet — the one guaranteed-sighted object.
const here = { ...caster.position };
state.groundObjects[cellKey(here)] = [{ instanceId: "dagger#G", cardId: "dagger" }];
const lit = eligibleCellsFor(viewFor(state, caster.id), "glue")!;
expect(lit.has(cellKey(here))).toBe(true);
// Empty squares stay dim — glue needs something to glue down.
const view = viewFor(state, caster.id);
for (const k of lit) {
const held =
(view.groundObjects[k] ?? []).length > 0 ||
view.treasures.some((t) => t.position && cellKey(t.position) === k);
expect(held).toBe(true);
}
});
it("dispel dimming lights creatures and demands sight, whoever made them", () => {
let { state } = newGame();
const caster = activePlayer(state);
const other = state.players.find((p) => p.id !== caster.id)!;
// An enemy wraith beside the caster: created by the OTHER wizard.
state.creatures.push({
id: "w1", kind: "wraith", controllerId: other.id,
position: { x: caster.position.x, y: caster.position.y },
damage: 0, maxDamage: Infinity, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 1, wallPassUsed: 0, scorchedThisTurn: [],
});
const lit = eligibleCellsFor(viewFor(state, caster.id), "dispel-creation")!;
expect(lit.has(cellKey(caster.position))).toBe(true);
// Everything lit is created AND sighted.
const view = viewFor(state, caster.id);
const seen = sightedCellsFor(view);
for (const k of lit) expect(seen.has(k)).toBe(true);
});
});
describe("a wave's force is spent as it travels (rules rev 24)", () => {
/** Caster at B, one cell above A; the target wall is A's south edge, so
* the range-2 wave covers A (dist 0) and B (dist 1). */
function rig(deckRev: number, behind: "open" | "wall") {
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 };
for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy();
state.edgeOverrides[edgeKey(A, "S")] = "wall";
state.edgeOverrides[edgeKey(A, "N")] = "open";
state.edgeOverrides[edgeKey(B, "N")] = behind;
state.edgeOverrides[edgeKey(Bn, "N")] = "open";
me.position = { ...B };
// The other wizard waits far outside the wave.
state.players.find((p) => p.id !== me.id)!.position = { x: 0, y: 9 };
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
const after = must(state, me.id, {
type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: A, side: "S" },
});
return { me: after.players.find((p) => p.id === me.id)!, B, Bn, Bnn };
}
it("a victim at the wave's far edge is carried one space, unhurt", () => {
const { me, Bn } = rig(24, "open");
expect(cellKey(me.position)).toBe(cellKey(Bn));
expect(me.life).toBe(15);
});
it("only unspent force crushes: one space of push blocked is one damage", () => {
const { me, B } = rig(24, "wall");
expect(cellKey(me.position)).toBe(cellKey(B));
expect(me.life).toBe(14);
});
it("older revisions keep the flat full-range wash for replay fidelity", () => {
const { me, Bnn } = rig(23, "open");
expect(cellKey(me.position)).toBe(cellKey(Bnn));
expect(me.life).toBe(15);
});
});
+136
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { giveCard } from "./helpers";
import { edgeKey } from "../src/board";
import {
applyCommand,
activePlayer,
@@ -224,3 +226,137 @@ describe("treasures and victory", () => {
expect(result.ok).toBe(false);
});
});
describe("elimination by lost treasures drops what the fallen carried (rev 30)", () => {
it("the carried treasure lands where the wizard stood", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic"], deckRev: 30 });
const A = state.players.find((p) => p.id === "a")!;
const B = state.players.find((p) => p.id === "b")!;
const C = state.players.find((p) => p.id === "c")!;
// C's first treasure already sits captured on B's home...
const c1 = state.treasures.filter((t) => t.owner === "c")[0]!;
c1.position = { ...B.home };
// ...C carries one of B's treasures...
const bt = state.treasures.filter((t) => t.owner === "b")[0]!;
bt.position = null;
bt.carriedBy = "c";
C.carriedTreasureId = bt.id;
// ...and A, standing on their own home, drops C's second treasure there.
const c2 = state.treasures.filter((t) => t.owner === "c")[1]!;
c2.position = null;
c2.carriedBy = "a";
A.carriedTreasureId = c2.id;
A.position = { ...A.home };
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const r = applyCommand(state, "a", { type: "dropTreasure" });
if (!r.ok) throw new Error(r.error);
state = r.state;
// C is eliminated by lost treasures — and B's treasure lies where C stood.
const cAfter = state.players.find((p) => p.id === "c")!;
expect(cAfter.alive).toBe(false);
expect(cAfter.carriedTreasureId).toBeNull();
const btAfter = state.treasures.find((t) => t.id === bt.id)!;
expect(btAfter.carriedBy).toBeNull();
expect(btAfter.position).toEqual(cAfter.position);
});
});
describe("the Ward is played in the moment (rules rev 31)", () => {
function grabRig() {
let { state } = createGame({ playerIds: ["thief", "owner"], seed: 42, sets: ["basic"], deckRev: 31 });
const thief = state.players.find((p) => p.id === "thief")!;
const owner = state.players.find((p) => p.id === "owner")!;
giveCard(state, "owner", "ward", "W", 0);
const t = state.treasures.find((t) => t.owner === "owner" && t.position)!;
thief.position = { ...t.position! };
while (state.players[state.turn.activeIndex]!.id !== "thief") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const r = applyCommand(state, "thief", { type: "pickUpTreasure" });
if (!r.ok) throw new Error(r.error);
return { state: r.state, owner, thief };
}
it("the grab hangs on the owner; springing costs the thief 3 and the card", () => {
let { state } = grabRig();
expect(state.wardPending).toEqual({ ownerId: "owner", takerId: "thief" });
// Nobody else may act while it hangs.
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.wardPending).toBeNull();
expect(state.players.find((p) => p.id === "thief")!.life).toBe(12);
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("declining lets the thief go, Ward still in hand", () => {
let { state } = grabRig();
const r = applyCommand(state, "owner", { type: "wardChoice", play: false });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "thief")!.life).toBe(15);
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(true);
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(true);
});
it("arming is refused in this vintage", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 31 });
giveCard(state, state.players[state.turn.activeIndex]!.id, "ward", "W", 0);
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "armWard", armed: true });
expect(r.ok).toBe(false);
});
});
describe("an ambushed teleport carries its destination (rules rev 35)", () => {
it("the trap springs and the victim lands where the trapper said", () => {
let { state } = createGame({ playerIds: ["trapper", "prey"], seed: 42, sets: ["basic", "expansion1"], deckRev: 35 });
for (let guard = 0; guard < 10 && !(state.players[state.turn.activeIndex]!.id === "trapper" && state.turn.round > 1); guard++) {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const trapper = state.players.find((p) => p.id === "trapper")!;
const prey = state.players.find((p) => p.id === "prey")!;
giveCard(state, "trapper", "interrupt", "I", 0);
giveCard(state, "trapper", "teleport-opponent", "TO", 1);
// Arming without a destination is refused; with one it is stored.
const bad = applyCommand(state, "trapper", {
type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" },
spellInstanceId: "teleport-opponent#TO",
});
expect(bad.ok).toBe(false);
const dest = { x: trapper.home.x, y: trapper.home.y === 0 ? 1 : trapper.home.y - 1 };
let r = applyCommand(state, "trapper", {
type: "setAmbush", instanceId: "interrupt#I", trigger: { kind: "near" },
spellInstanceId: "teleport-opponent#TO", cell: dest,
});
if (!r.ok) throw new Error(r.error);
state = r.state;
state = applyCommand(state, "trapper", { type: "endTurn", draw: 0 }).ok
? (applyCommand(state, "trapper", { type: "endTurn", draw: 0 }) as { state: typeof state }).state : state;
// The prey walks adjacent; the trap springs; the prey passes; they land
// at dest. (Re-find both: applyCommand clones made the handles stale.)
const trapperNow = state.players.find((p) => p.id === "trapper")!;
const preyNow = state.players.find((p) => p.id === "prey")!;
const below = trapperNow.position.y >= 2;
preyNow.position = { x: trapperNow.position.x, y: trapperNow.position.y + (below ? -2 : 2) };
const dir = below ? "S" : "N";
state.edgeOverrides[edgeKey(preyNow.position, dir)] = "open";
r = applyCommand(state, "prey", { type: "move", direction: dir });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.stack?.attackCard?.cardId).toBe("teleport-opponent");
expect(state.stack?.params?.cell).toEqual(dest);
r = applyCommand(state, "prey", { type: "pass" });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "prey")!.position).toEqual(dest);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, type Cell } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
@@ -334,3 +334,227 @@ describe("6e card-face corrections", () => {
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF2" }).ok).toBe(false);
});
});
describe("relocation past the origin (rules rev 11)", () => {
function rev11Game() {
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
return state;
}
it("a sector may land at negative coordinates — the maze renormalizes", () => {
let state = rev11Game();
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const otherIdx = idx === 0 ? 1 : 0;
const otherOrigin = state.board.placements[otherIdx]!.origin;
// Land WEST of the other sector: x = otherOrigin.x - 5, likely negative.
const dest = { x: otherOrigin.x - 5, y: otherOrigin.y };
const otherPosBefore = { ...other.position };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
// The maze zero-anchors: origins snug against both axes.
const origins = state.board.placements.map((p: { origin: Cell }) => p.origin);
expect(Math.min(...origins.map((o: Cell) => o.x))).toBe(0);
expect(Math.min(...origins.map((o: Cell) => o.y))).toBe(0);
// Everyone stands on a cell the reassembled board knows.
for (const p of state.players) {
expect(state.board.cells[cellKey(p.position)]).toBe(true);
expect(state.board.cells[cellKey(p.home)]).toBe(true);
}
// The static sector's tenant rode the renormalization shift exactly.
const finalOther = state.board.placements[otherIdx]!.origin;
const shifted = state.players.find((p: { id: string }) => p.id === other.id)!;
expect(shifted.position).toEqual({
x: otherPosBefore.x + (finalOther.x - otherOrigin.x),
y: otherPosBefore.y + (finalOther.y - otherOrigin.y),
});
});
it("older revisions still refuse the negative landing", () => {
let { state } = newGame(); // helper default: rev-ungated (1)
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const rel = giveCard(state, me.id, "relocate-sector");
const refused = applyCommand(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: { x: otherOrigin.x - 5, y: otherOrigin.y - 5 } },
params: { cell: me.position },
});
expect(refused.ok).toBe(false);
});
it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
let state = rev11Game();
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const myOrigin = state.board.placements[idx]!.origin;
const otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const inMine = (c: Cell) =>
c.x >= myOrigin.x && c.x < myOrigin.x + 5 && c.y >= myOrigin.y && c.y < myOrigin.y + 5;
// Seed passengers on the moving sector.
const spot = { x: myOrigin.x + 2, y: myOrigin.y + 2 };
expect(inMine(spot)).toBe(true);
state.creatures.push({
id: "c1", kind: "troll", controllerId: me.id, position: { ...spot },
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
state.gluedCells[cellKey(spot)] = true;
state.dimWarps.push({ a: { ...spot }, b: { x: otherOrigin.x + 1, y: otherOrigin.y + 1 } });
state.boobytraps.push({ casterId: me.id, cells: [{ ...spot }], realKey: cellKey(spot) });
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
// Expected coordinates follow the final placements (move + zero-anchor).
const finalMine = state.board.placements[idx]!.origin;
const finalOther = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const moved = { x: spot.x + finalMine.x - myOrigin.x, y: spot.y + finalMine.y - myOrigin.y };
// The warp's far token sat one square inside the static sector.
const staticShifted = { x: finalOther.x + 1, y: finalOther.y + 1 };
expect(state.creatures[0]!.position).toEqual(moved);
expect(state.gluedCells[cellKey(moved)]).toBe(true);
expect(state.dimWarps[0]!.a).toEqual(moved);
expect(state.dimWarps[0]!.b).toEqual(staticShifted);
expect(state.boobytraps[0]!.cells[0]).toEqual(moved);
expect(state.boobytraps[0]!.realKey).toBe(cellKey(moved));
});
});
describe("junction alterations roll for their sector (rules rev 20)", () => {
it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => {
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 20 });
let state = toRound2(fresh);
// The 2p board is two sectors stacked: the seam runs between them.
const [p0, p1] = state.board.placements;
const seamY = Math.max(p0!.origin.y, p1!.origin.y) - 1;
const seamCell = { x: p0!.origin.x + 2, y: seamY };
const key = edgeKey(seamCell, "S");
// Conjure a wall on the seam (state surgery: the cast needs LOS we may lack).
state.edgeOverrides[key] = "wall";
state.createdEdges[key] = true;
const caster = activePlayer(state);
const rot = giveCard(state, caster.id, "rotate-sector");
const rngBefore = JSON.stringify(state.rng);
state = must(state, caster.id, {
type: "cast", instanceId: rot.instanceId,
target: { kind: "cell", cell: { x: p1!.origin.x + 2, y: p1!.origin.y + 2 } },
params: { clockwise: true },
});
// A die was consumed for the seam wall.
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
// The wall is somewhere: either the old seam key or a remapped edge.
const createdKeys = Object.keys(state.createdEdges);
expect(createdKeys.length).toBe(1);
});
});
describe("sight tracing", () => {
it("agrees with sightBetween on every pair and pins entry/exit to the mouths", () => {
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
const board = boardView(state);
const cells = Object.keys(board.cells).map((k) => {
const [x, y] = k.split(",").map(Number) as [number, number];
return { x, y };
});
let warped = 0;
for (const from of cells) {
for (const to of cells) {
const t = traceSight(board, from, to);
expect(t !== null).toBe(sightBetween(board, from, to));
if (!t) continue;
if (hasLineOfSight(board, from, to)) {
expect(t.kind).toBe("direct");
} else {
expect(t.kind).toBe("warp");
if (t.kind === "warp") {
warped++;
// Each crossing point lies on its mouth's one-cell rim segment.
const onRim = (p: { x: number; y: number }, m: { cell: Cell; side: string }) => {
if (m.side === "N") return p.y === m.cell.y && p.x > m.cell.x && p.x < m.cell.x + 1;
if (m.side === "S") return p.y === m.cell.y + 1 && p.x > m.cell.x && p.x < m.cell.x + 1;
if (m.side === "W") return p.x === m.cell.x && p.y > m.cell.y && p.y < m.cell.y + 1;
return p.x === m.cell.x + 1 && p.y > m.cell.y && p.y < m.cell.y + 1;
};
expect(onRim(t.entry, t.mouthA)).toBe(true);
expect(onRim(t.exit, t.mouthB)).toBe(true);
}
}
}
}
expect(warped).toBeGreaterThan(0);
});
});
describe("illusions are tested by choice (rules rev 29)", () => {
function shimmerRig() {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 29 });
state = toRound2(state);
const caster = activePlayer(state);
const mark = state.players.find((p) => p.id !== caster.id)!;
// Conjure the illusion on an OPEN edge beside the victim (state surgery
// for a deterministic spot; the cast itself is tested elsewhere).
const spot = emptyNeighborCell(state, mark.position);
const key = edgeKey(mark.position, spot.side);
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
state = must(state, caster.id, { type: "endTurn", draw: 0 });
return {
state, caster: caster.id, victim: mark.id, side: spot.side,
cell: { ...state.players.find((p) => p.id === mark.id)!.position },
};
}
it("an untested shimmer blocks the walker with its own message, no die rolled", () => {
const { state, victim, side } = shimmerRig();
const rngBefore = JSON.stringify(state.rng);
const r = applyCommand(state, victim, { type: "move", direction: side });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("shimmers");
expect(JSON.stringify(state.rng)).toBe(rngBefore);
});
it("the belief test is an explicit roll; the verdict then governs the wall", () => {
let { state, victim, cell, side } = shimmerRig();
state = must(state, victim, { type: "testIllusion", cell, side });
const verdict = state.illusionWalls[edgeKey(cell, side)]!.belief[victim];
expect(verdict === "believes" || verdict === "seesThrough").toBe(true);
const r = applyCommand(state, victim, { type: "move", direction: side });
if (verdict === "seesThrough") {
expect(r.ok).toBe(true);
} else {
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe("blocked by wall");
}
// A second test is refused: eyes rule once.
expect(applyCommand(state, victim, { type: "testIllusion", cell, side }).ok).toBe(false);
});
it("the creator strolls through their own fake without any test", () => {
let { state, caster } = shimmerRig();
// Skip to the caster's turn; the wall never blocks its maker.
while (activePlayer(state).id !== caster) {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
const me = state.players.find((p) => p.id === caster)!;
const other = state.players.find((p) => p.id !== caster)!;
me.position = { ...other.position };
const r = applyCommand(state, caster, { type: "move", direction: "E" });
// Blocked only if some REAL edge stands there; the illusion itself never objects.
if (!r.ok) expect(r.error).not.toContain("shimmers");
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn } from "../src/game";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
import { cellKey, edgeKey, neighbor, SIDES } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt, emptyNeighborCell } from "./helpers";
@@ -277,3 +277,17 @@ describe("control effects", () => {
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "create-wall")).toBe(true);
});
});
describe("picking one of two treasures", () => {
it("a named treasure is the one taken", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 16 });
state = toRound2(state);
const p = activePlayer(state);
const [t1, t2] = state.treasures.filter((t) => t.owner !== p.id);
t1!.position = { ...p.position }; t1!.carriedBy = null;
t2!.position = { ...p.position }; t2!.carriedBy = null;
const r = applyCommand(state, p.id, { type: "pickUpTreasure", treasureId: t2!.id });
if (!r.ok) throw new Error(r.error);
expect(r.state.players.find((q) => q.id === p.id)!.carriedTreasureId).toBe(t2!.id);
});
});
+17 -5
View File
@@ -12,11 +12,6 @@ describe("magic wands", () => {
const wand = giveCard(state, attacker, "blaster-wand");
giveCard(state, attacker, "number-2", "N", 1);
// First use without a number card is refused.
expect(applyCommand(state, attacker, {
type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender },
}).ok).toBe(false);
// Charged with a 2: fires (3 damage), one charge left, wand displayed.
state = must(state, attacker, {
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
@@ -210,3 +205,20 @@ describe("dropping objects", () => {
expect(refused.ok).toBe(false);
});
});
describe("a bare wand lights a single charge", () => {
it("'played without a number card, its power is only 1': one shot, then dust", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const bw = giveCard(state, attacker, "blaster-wand");
state = must(state, attacker, {
type: "cast", instanceId: bw.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
// The single charge fired (3 flat damage) and the wand crumbled.
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(false);
expect(state.wandCharges[bw.instanceId]).toBeUndefined();
});
});
+1 -1
View File
@@ -3,5 +3,5 @@
"compilerOptions": {
"noEmit": true
},
"include": ["src"]
"include": ["src", "test"]
}
+119
View File
@@ -0,0 +1,119 @@
// The automatons' table talk, one voice per temperament. Edit freely — the
// server picks from these pools at random, sparingly (menace over chatter).
//
// Triggers: grabGold (picked up a treasure), deliverGold (dropped one on a
// home base), dealPain (hurt somebody), takePain (got hurt), kill, die,
// summon (created a creature), buildWall, escape (self-teleported away),
// springTrap (drew a TRAP card), dodge (an attack missed them), win.
import type { AutomatonStyle } from "@wizwar/engine";
export type BanterTrigger =
| "grabGold" | "deliverGold" | "dealPain" | "takePain" | "kill" | "die"
| "summon" | "buildWall" | "escape" | "springTrap" | "dodge" | "win";
export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, string[]>>> = {
hunter: {
grabGold: [
"ACQUISITION COMPLETE.",
"YOUR GOLD HAS BEEN REALLOCATED.",
"ASSET SECURED.",
"APPRAISAL: ADEQUATE. TAKING IT ANYWAY.",
],
deliverGold: [
"DELIVERY CONFIRMED. PLEASURE DOING BUSINESS.",
"LOGISTICS IS MY LOVE LANGUAGE.",
"RECEIPT AVAILABLE UPON REQUEST.",
],
dealPain: [
"AN INEFFICIENT USE OF YOUR TURN.",
"COSTS HAVE BEEN PASSED TO THE CONSUMER.",
"THIS IS BILLABLE.",
],
takePain: [
"DAMAGE NOTED. INVOICE PENDING.",
"THAT COMES OUT OF YOUR ESTATE.",
],
kill: [
"INVENTORY TRANSFERRED. CONDOLENCES.",
"ACCOUNT CLOSED.",
],
die: [
"FILING FOR BANKRUPTCY.",
"THE MARKET HAS CORRECTED.",
],
summon: ["A SUBCONTRACTOR HAS BEEN RETAINED."],
buildWall: ["ZONING APPROVED."],
escape: ["RELOCATION EXPENSES: JUSTIFIED."],
springTrap: ["AN UNFORESEEN LIABILITY."],
dodge: ["PROJECTIONS SAID YOU WOULD MISS."],
win: ["PLEASURE DOING BUSINESS WITH ALL OF YOU."],
},
berserker: {
grabGold: [
"GOLD IS MERELY BAIT.",
"SHINY. IRRELEVANT.",
],
dealPain: [
"YES. AGAIN.",
"PAIN RECEIPT PRINTED.",
"HOLD STILL. THIS IS THE FUN PART.",
"THE MAZE PROVIDES.",
],
takePain: [
"GOOD. NOW IT IS INTERESTING.",
"I FELT THAT. DO IT AGAIN.",
],
kill: [
"SCHEDULED DEMISE: DELIVERED.",
"NEXT.",
"THE PILE GROWS.",
],
die: [
"SAVE MY SEAT.",
"I WILL BE BACK FOR THE REMATCH.",
],
summon: [
"I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY.",
"IT EATS WHAT I TELL IT TO EAT.",
],
buildWall: ["MORE CORNERS TO TRAP YOU IN."],
escape: ["A TACTICAL LEAP. NOT A RETREAT."],
springTrap: ["WHO PUT THAT THERE. RESPECT."],
dodge: ["SWING HARDER."],
win: ["LAST ONE STANDING. AS FORETOLD."],
},
worrier: {
grabGold: [
"taking this. sorry. sorry.",
"borrowing it. permanently. sorry.",
],
deliverGold: ["home. safe. breathe."],
dealPain: [
"ow. logged.",
"unnecessary!",
"that looked like it hurt. it was supposed to. i think.",
],
takePain: [
"rude.",
"i wrote it down. the list is long.",
],
kill: [
"oh no. oh no. it worked?",
"i'm not proud. mostly.",
],
die: [
"called it.",
"this is fine. this is fine. this is not fine.",
],
summon: ["please be nice to it. actually don't."],
buildWall: [
"good wall. safe wall.",
"one more wall between me and everything.",
],
escape: ["nope nope nope."],
springTrap: ["i knew the floor was suspicious."],
dodge: ["missed me. oh thank goodness. i mean: ha."],
win: ["wait. me? check again."],
},
};
+63 -45
View File
@@ -35,6 +35,7 @@ import {
getRoom,
joinRoom,
loadPersistedRooms,
runningRooms,
addAutomaton,
addChat,
driveOneAutomaton,
@@ -49,6 +50,7 @@ import {
type Room,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { BOT_LINES, type BanterTrigger } from "./banter";
// --- Abuse limits: this is a public server on a small box. -----------------
const MAX_SOCKETS = 300; // concurrent connections
@@ -69,6 +71,11 @@ const port = Number(process.env.PORT ?? 8787);
// is not reachable from the internet. Dev default stays LAN-friendly.
const host = process.env.HOST ?? "0.0.0.0";
loadPersistedRooms();
// A restart can land mid-bot-turn: without a kick, a restored room whose
// current actor is an automaton waits forever for a human to poke it.
setTimeout(() => {
for (const room of runningRooms()) runBots(room);
}, 2000);
// One process serves both the built client and the websocket, so production
// needs only a TLS proxy in front (or nothing, on a LAN).
@@ -173,7 +180,14 @@ function roomInfo(room: Room) {
started: room.state !== null,
colors: Object.fromEntries(room.colorChoices),
bots: Object.fromEntries(
[...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]),
// A mystery machine keeps its mood only while the game lives: once it
// ends, the hands go face-up and so does the temperament.
[...room.bots].map(([name, b]) => [
name,
room.state?.phase === "finished"
? `${b.tier} ${b.style}${b.secret ? " 🎭" : ""}`
: `${b.tier} ${b.secret ? "mystery" : b.style}`,
]),
),
};
}
@@ -190,51 +204,49 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
* The clockwork plays at a watchable pace: one command every beat, each
* broadcast as it lands, until the maze wants a human again.
*/
const BOT_STEP_MS = 1500;
const BOT_STEP_MS = 1000;
/** The clockwork occasionally speaks. Sparingly — menace over chatter. */
const BOT_LINES: Record<string, Record<string, string[]>> = {
hunter: {
treasurePickedUp: ["ACQUISITION COMPLETE.", "YOUR GOLD HAS BEEN REALLOCATED."],
treasureDropped: ["DELIVERY CONFIRMED. PLEASURE DOING BUSINESS."],
damaged: ["AN INEFFICIENT USE OF YOUR TURN."],
died: ["INVENTORY TRANSFERRED. CONDOLENCES."],
},
berserker: {
treasurePickedUp: ["GOLD IS MERELY BAIT."],
damaged: ["YES. AGAIN.", "PAIN RECEIPT PRINTED."],
died: ["SCHEDULED DEMISE: DELIVERED.", "NEXT."],
creatureCreated: ["I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY."],
},
worrier: {
treasurePickedUp: ["taking this. sorry. sorry."],
damaged: ["ow. logged.", "unnecessary!"],
wallCreated: ["good wall. safe wall."],
died: ["oh no. oh no. it worked?"],
},
};
/** What a step's event means to this bot its own deeds when it acted,
* its own suffering whoever caused it. Null: nothing worth a word. */
function banterTrigger(
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
): BanterTrigger | null {
if (actor === seat) {
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
if (e.type === "treasureDropped" && e.player === seat && e.onHomeOf != null) return "deliverGold";
if (e.type === "damaged" && e.player !== seat) return "dealPain";
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
}
// A counter-teleport escape resolves during the ATTACKER's step, and a
// last-standing win can land on the victim's turn: self-referential
// triggers hold whoever acted.
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
if (e.type === "gameWon" && e.player === seat) return "win";
if (e.type === "damaged" && e.player === seat) return "takePain";
if (e.type === "died" && e.player === seat) return "die";
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
return null;
}
function botRemark(room: Room, seat: string, events: { type: string; [k: string]: unknown }[]): void {
const bot = room.bots.get(seat);
if (!bot) return;
const lines = BOT_LINES[bot.style] ?? {};
for (const e of events) {
// Speak only about its own deeds, and rarely.
const mine =
(e.type === "treasurePickedUp" && e.player === seat) ||
(e.type === "treasureDropped" && e.player === seat) ||
(e.type === "damaged" && e.player !== seat) ||
(e.type === "died" && e.killedBy === seat) ||
(e.type === "creatureCreated" && e.controller === seat) ||
(e.type === "wallCreated" && e.caster === seat);
const pool = lines[e.type];
if (!mine || !pool || Math.random() > 0.5) continue;
const text = pool[Math.floor(Math.random() * pool.length)]!;
const said = addChat(room, seat, text);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
/** Every seated bot gets a chance to remark on the step the actor on its
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
for (const [seat, bot] of room.bots) {
const lines = BOT_LINES[bot.style] ?? {};
for (const e of events) {
const trigger = banterTrigger(e, seat, actor);
const pool = trigger ? lines[trigger] : undefined;
if (!pool || Math.random() > 0.5) continue;
const text = pool[Math.floor(Math.random() * pool.length)]!;
const said = addChat(room, seat, text);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
}
return; // one remark per step, whole table
}
return; // one remark per step at most
}
}
const pumping = new Set<string>();
@@ -249,6 +261,8 @@ function runBots(room: Room): void {
}
broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
// The game's end unmasks the mystery machines in the roster.
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
botRemark(room, step.seat, step.events as { type: string }[]);
setTimeout(tick, BOT_STEP_MS);
};
@@ -319,9 +333,11 @@ wss.on("connection", (socket) => {
session.roomId = room.id;
session.token = result.token;
send(socket, { type: "seat", playerId: name, token: result.token });
// Rejoining a running game: replay the chronicle so far.
// Rejoining a running game: replay the chronicle so far. The
// replayed flag keeps one-time fanfare (the opening roll-off)
// from firing again on every return to the room.
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name) });
send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
}
broadcastRoomState(room);
runBots(room);
@@ -361,6 +377,8 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
botRemark(room, session.playerId, result.events as { type: string }[]);
runBots(room);
break;
}
+20 -12
View File
@@ -6,9 +6,15 @@
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
import {
applyCommand,
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
createGame,
redactEvent,
viewFor,
type AutomatonStyle,
type AutomatonTier,
type Command,
type GameEvent,
type GameState,
@@ -17,14 +23,6 @@ import {
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats";
import {
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
type AutomatonStyle,
type AutomatonTier,
} from "@wizwar/engine";
export interface LoggedCommand {
seq: number;
@@ -56,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 9;
const RULES_REV = 36;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -93,6 +91,11 @@ export function roomCount(): number {
return rooms.size;
}
/** Every restored, still-running room — so boot can wake their bot pumps. */
export function runningRooms(): Room[] {
return [...rooms.values()].filter((r) => r.state && r.state.phase === "playing");
}
export function createRoom(hostId: PlayerId): { room: Room; token: string } {
const token = randomBytes(16).toString("hex");
const room: Room = {
@@ -272,6 +275,7 @@ function actingSeat(room: Room): PlayerId | null {
const s = room.state;
if (!s || s.phase !== "playing") return null;
return (
s.wardPending?.ownerId ??
s.stack?.waitingOn ??
s.pendingDiscard ??
s.chaosPending?.queue[0] ??
@@ -289,10 +293,12 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat);
const cmd = automatonCommand(view, bot?.style, bot?.tier) ?? automatonFallback(view);
let r = runCommand(room, seat, cmd);
const chosen = automatonCommand(view, bot?.style, bot?.tier);
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) {
r = runCommand(room, seat, automatonFallback(view));
// A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass.
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) {
@@ -509,6 +515,8 @@ export function loadPersistedRooms(): void {
room.bots.set(line.name, {
style: (line.style as AutomatonStyle) ?? "hunter",
secret: line.secret === true,
// Tier-less join lines predate tiers, when every automaton
// played the full repertoire — not the "adept" lobby default.
tier: (line.tier as AutomatonTier) ?? "archmage",
});
} else {
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Alter Ego token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Alter Ego</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 52 Q65 40 80 66 Q86 88 67 105 Q45 111 31 90Z" fill="#e7c3a1"/>
<path d="M40 73q9 -10 19 0M62 70q8 -7 15 1" fill="none"/><circle cx="53" cy="75" r="2.4" fill="#20251f" stroke="none"/><path d="M64 78q-8 8 3 11q-9 9 -19 2" fill="none"/><path d="M111 52 Q81 40 66 66 Q60 88 79 105 Q101 111 115 90Z" fill="#d9b08e"/>
<path d="M106 73q9 -10 19 0M84 70q8 -7 15 1" fill="none"/><circle cx="93" cy="75" r="2.4" fill="#20251f" stroke="none"/><path d="M82 78q8 8 3 11q9 9 19 2" fill="none"/><path d="M69 48q4-12 8 0M47 115q25-14 51 0" fill="none"/><path d="M24 67l-10-6m108 5l9-8" fill="none"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Boobytrap token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Boobytrap</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M28 105L70 49l43 56Z" fill="#f2ca4f"/><path d="M44 96l26-35 25 35Z" fill="#df5b36"/><path d="M71 70v14m0 7v2" stroke="#fff8da" stroke-width="5"/><path d="M25 62l12 5-9 8m92-11l-11 4 8 9" fill="none"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dagger token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Dagger</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M34 111L90 48l12 12-57 60Z" fill="#c9d5d2"/><path d="M87 51l10-13 13 13-10 10Z" fill="#6c5641"/><path d="M75 58l22 22"/><path d="M30 70l-13-5 10-8m82 36l15 3-9 9" fill="none"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Democratic Monster token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="12" fill="#20251f">Democratic Monster</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M71 120Q28 108 44 72q-18-14 1-25 17-8 26 8 12-23 29-10 14 13-2 29 20 29-27 46Z" fill="#df3f37"/><path d="M43 83l-20 8 18 7m57-16l23 10-21 8" fill="#df3f37"/><circle cx="62" cy="65" r="7" fill="#fff"/><circle cx="82" cy="63" r="7" fill="#fff"/><circle cx="64" cy="66" r="2"/><circle cx="80" cy="64" r="2"/><path d="M56 87q17 13 31-2" fill="none"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dimensional Warp token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Dimensional Warp</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="80" rx="47" ry="22" fill="#5c369b"/><ellipse cx="72" cy="80" rx="34" ry="14" fill="#e4bdff"/><ellipse cx="72" cy="80" rx="20" ry="7" fill="#252036"/><path d="M22 52q13-14 26-17m72 18q-13-14-27-17M22 108q13 14 27 17m71-17q-13 14-27 17" fill="none" stroke="#7849b1"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dustcloud token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Dustcloud</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M27 105q-15-17 7-27-9-24 18-24 8-23 30-9 19-10 28 9 24 2 16 25 17 18-5 30-35 15-74-4Z" fill="#68675d"/><path d="M42 80q15-14 27 1m12-12q15-9 26 5m-61 20q17-12 31 2" fill="none" stroke="#989383"/><circle cx="31" cy="117" r="3"/><circle cx="117" cy="118" r="2"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Fire Imp token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Fire Imp</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M71 119q-29-9-26-42-14-16 5-21-2-23 19-12 18-20 24 7 22 8 7 27 8 32-29 41Z" fill="#e8494a"/><path d="M52 52L39 34l23 12m29 4l16-18-6 25" fill="#e8494a"/><circle cx="63" cy="70" r="5" fill="#ffe15a"/><circle cx="84" cy="68" r="5" fill="#ffe15a"/><path d="M59 91q14 10 28-3" fill="none"/><path d="M42 102l-20 13m79-14l20 13" fill="none" stroke="#e8494a" stroke-width="7"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Killer Ooze token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Killer Ooze</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M26 115q-2-24 15-27-10-29 12-33 7-29 26-5 26-15 30 17 21 14 4 37 3 21-25 14-19 13-32-2-17 11-30-1Z" fill="#16834f"/><ellipse cx="60" cy="73" rx="10" ry="14" fill="#fff"/><ellipse cx="86" cy="72" rx="10" ry="14" fill="#fff"/><circle cx="64" cy="78" r="3"/><circle cx="82" cy="78" r="3"/><path d="M48 96q25-18 48 1l-8 15-8-11-9 12-8-12-8 11Z" fill="#173b2a"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Magic Stone token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Magic Stone</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M72 46l25 23-11 37-32 6-18-31 14-30Z" fill="#d72e78"/><path d="M50 51l20 31 27-13M70 82l16 24M37 81l33 1" fill="none" stroke="#ff83b5"/><path d="M28 55l-12-13m95 16l15-12M25 109l-13 12m100-13l14 12" fill="none" stroke="#29252d" stroke-width="5"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Magic Wand token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Magic Wand</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M37 111L94 50" stroke="#4a3a2a" stroke-width="9"/><path d="M34 114L91 53" stroke="#d8c6a7" stroke-width="3"/><circle cx="99" cy="43" r="14" fill="#f0c83e"/><path d="M99 23v-9m0 58v-9M79 43h-9m58 0h-9M85 29l-7-7m42 42l-7-7m0-28l7-7M85 57l-7 7" fill="none" stroke="#e7a81f"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Master Key token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Master Key</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="91" cy="63" r="27" fill="#d6d4c8"/><circle cx="91" cy="63" r="11" fill="#f4efd9"/><path d="M72 79L32 119" stroke="#c9c7bc" stroke-width="11"/><path d="M42 109l-13-13m24 2l-12-12"/><path d="M76 45l30 31M75 78l33-31" fill="none" stroke="#83857e"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Pit token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Pit</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M24 77l55-28 43 28-52 42Z" fill="#45484b"/><path d="M35 77l43-19 32 19-40 29Z" fill="#171b20"/><path d="M35 77l35 29m8-48l-8 48m40-29l-40 29" fill="none" stroke="#737980"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Rock token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Rock</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M30 112l8-46 26-22 34 10 18 38-22 28-43 1Z" fill="#666c68"/><path d="M38 66l30 12 30-24M68 78l-17 43m17-43l26 42" fill="none" stroke="#929994"/><ellipse cx="73" cy="122" rx="46" ry="6" fill="#252923" opacity=".25" stroke="none"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Rosebush token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Rosebush</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M29 114q8-47 40-40-4-31 19-16 29-5 29 28 21 24-11 34Z" fill="#237148"/><path d="M42 111l52-47M50 72l45 39" fill="none" stroke="#174e32" stroke-width="5"/><g fill="#d6336c"><circle cx="45" cy="83" r="8"/><circle cx="78" cy="61" r="8"/><circle cx="102" cy="88" r="9"/><circle cx="75" cy="103" r="7"/></g></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Safe token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Safe</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M38 53l61-12 14 71-66 11Z" fill="#767e80"/><path d="M51 62l40-7 8 49-43 8Z" fill="#aab1b0"/><circle cx="74" cy="82" r="13" fill="none"/><path d="M74 69v26M61 82h26m-13 0l11-8m-11 8l-9 10" fill="none"/><path d="M45 121l-2 8m62-18l4 9"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Shadow token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Shadow</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M34 116q8-33 26-39-11-28 13-37 23 8 14 34 23 12 25 41Z" fill="#172a27"/><path d="M37 88L17 75m25 26l-23 8m88-20l20-13m-17 26l20 8" fill="none" stroke="#172a27" stroke-width="8"/><path d="M65 61l7-4 7 4" fill="none" stroke="#d6f2cb"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Skeleton token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Skeleton</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="72" cy="62" r="22" fill="#eee8d5"/><circle cx="64" cy="58" r="6"/><circle cx="81" cy="58" r="6"/><path d="M69 71l3-6 4 6m-14 8h21M72 84v33M53 94l19 9 20-10M72 117l-17 13m17-13l18 13" fill="none"/><path d="M30 62l-10-9m94 10l11-11M29 98l-13 7m99-7l14 8" fill="none"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Slime token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Slime</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M25 56q23-14 48-3 24-12 47 4v46q-8 19-22 3-12 26-27 1-14 19-25 0-13 13-21-3Z" fill="#0e754a"/><path d="M25 56q23 9 48-3 24 10 47 4" fill="none" stroke="#55ac76"/><path d="M43 103v18m34-15v25m27-29v17" fill="none" stroke="#0e754a" stroke-width="8"/><ellipse cx="73" cy="129" rx="51" ry="7" fill="#3a9667" opacity=".6"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Solid Stone token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Solid Stone</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M24 111l9-48 19-17 47 2 20 18-5 48Z" fill="#9da09a"/><path d="M33 63l28 9 38-24M61 72l-7 41m7-41l29 42m9-66l-9 66" fill="none" stroke="#666a66"/><path d="M25 111q44 8 89 3" fill="none"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Tacks token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Tacks</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><g fill="#c9c7b9"><path d="M35 60l15 7-11 5 2 28-7-26-12 1Z"/><path d="M82 50l16 8-12 5 2 28-7-26-12 1Z"/><path d="M61 91l15 7-11 5 2 27-7-25-12 1Z"/><path d="M108 84l14 7-10 5 1 25-6-23-11 1Z"/></g><path d="M21 112q50 12 107 1" fill="none" stroke="#9b7d4b"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Thorn Bush token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Thorn Bush</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M27 111q4-43 34-35-2-31 19-16 27-11 27 19 26 15 8 39Z" fill="#247348"/><path d="M39 106l55-41M47 73l51 40" fill="none" stroke="#174b31" stroke-width="5"/><path d="M41 90l-13-8m28 3l-5-16m33 11l8-16m-2 34l18-4m-50 17l-7 16" fill="none" stroke="#eee8d5"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#329664"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/><circle cx="72" cy="78" r="7" fill="#ffd84d"/><path d="M43 91l-12 17m69-17l13 16M56 91l-5 24m37-24l6 23" fill="none" stroke="#d69d23"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#e34a44"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#b72d6b"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#6c3eae"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/><circle cx="72" cy="78" r="7" fill="#ffd84d"/><path d="M43 91l-12 17m69-17l13 16M56 91l-5 24m37-24l6 23" fill="none" stroke="#d69d23"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#2389a9"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/><circle cx="72" cy="78" r="7" fill="#ffd84d"/><path d="M43 91l-12 17m69-17l13 16M56 91l-5 24m37-24l6 23" fill="none" stroke="#d69d23"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Treasure</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M35 67q37-25 74 0v20H35Z" fill="#e3bd26"/><path d="M31 87h82v34H31Z" fill="#6d4a2b"/><path d="M35 93h74M51 88v32m42-32v32" fill="none" stroke="#e8c068"/><rect x="65" y="84" width="14" height="18" rx="2" fill="#f1c64d"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Troll token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Troll</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M30 112q8-46 30-45-9-25 14-31 27 3 18 31 24 3 25 44Z" fill="#d4d5b8"/><path d="M49 53l-17-15 7 27m56-12l18-15-8 28" fill="#d4d5b8"/><circle cx="62" cy="71" r="4"/><circle cx="85" cy="71" r="4"/><path d="M49 91q24 16 48 0" fill="none"/><path d="M52 94l7 11 7-8 8 12 8-12 8 9 7-13" fill="#fff"/><path d="M48 47q24-25 50 0" fill="none" stroke="#64328f" stroke-width="10"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#23764b"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
<path d="M46 66q25-32 51 0v-8L38 25 38 61Z" fill="#23764b"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
<path d="M73 81q-7 9 2 11m-12 8q11 5 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#e9e2c8" stroke-width="8"/>
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#23764b"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#d54835"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
<path d="M46 66q25-32 51 0v-8L45 25 38 61Z" fill="#d54835"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
<path d="M73 81q-7 9 2 11m-12 8q11 6 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#7e2b1f" stroke-width="8"/>
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#d54835"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#b62f75"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
<path d="M46 66q25-32 51 0v-8L52 25 38 61Z" fill="#b62f75"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
<path d="M73 81q-7 9 2 11m-12 8q11 7 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#9c235d" stroke-width="8"/>
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#b62f75"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#29345f"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
<path d="M46 66q25-32 51 0v-8L38 25 38 61Z" fill="#29345f"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
<path d="M73 81q-7 9 2 11m-12 8q11 5 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#25282b" stroke-width="8"/>
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#29345f"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#1683a6"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
<path d="M46 66q25-32 51 0v-8L45 25 38 61Z" fill="#1683a6"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
<path d="M73 81q-7 9 2 11m-12 8q11 6 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#e7e2cf" stroke-width="8"/>
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#1683a6"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wizard</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M41 124q31-28 62 0" fill="#e0a733"/><ellipse cx="72" cy="76" rx="25" ry="30" fill="#edc9a8"/>
<path d="M46 66q25-32 51 0v-8L52 25 38 61Z" fill="#e0a733"/><path d="M49 69q23-14 47 0" fill="none" stroke="#f4d467" stroke-width="4"/>
<path d="M52 74q8-11 16 0m9 0q8-11 15 0" fill="none"/><circle cx="63" cy="78" r="2.3"/><circle cx="84" cy="78" r="2.3"/>
<path d="M73 81q-7 9 2 11m-12 8q11 7 22-1" fill="none"/><path d="M48 73q-7 21 8 35M96 71q8 23-9 38" fill="none" stroke="#292723" stroke-width="8"/>
<path d="M40 56l-10-7m72 9l12-7M35 84l-13 3m83-2l15 4" fill="none" stroke="#e0a733"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wraith token">
<defs>
<filter id="w"><feTurbulence baseFrequency=".035" numOctaves="2" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale=".65"/></filter>
<filter id="s"><feGaussianBlur stdDeviation="2"/></filter>
<pattern id="paper" width="12" height="12" patternUnits="userSpaceOnUse"><rect width="12" height="12" fill="#f4efd9"/><circle cx="2" cy="4" r=".45" fill="#c8bfa4" opacity=".28"/><path d="M1 10l7-1" stroke="#d8cfb8" stroke-width=".35" opacity=".35"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="3" fill="url(#paper)" stroke="#242822" stroke-width="2"/>
<text x="72" y="19" text-anchor="middle" font-family="Trebuchet MS,Arial,sans-serif" font-weight="700" font-size="14" fill="#20251f">Wraith</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M72 38q24 2 25 30 22 9 18 32-13-7-20 7-9 25-23 30-15-7-22-30-8-15-22-7-4-23 19-32 0-27 25-30Z" fill="#172521"/><circle cx="64" cy="63" r="4" fill="#f5edc4" stroke="none"/><circle cx="81" cy="63" r="4" fill="#f5edc4" stroke="none"/><path d="M46 83L20 96m79-13l25 13" fill="none" stroke="#172521" stroke-width="13"/></g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because it is too large Load Diff
+355 -131
View File
@@ -1,7 +1,16 @@
<script lang="ts">
import type { GameView } from "@wizwar/engine";
import type { Component } from "svelte";
import type { GameView, SightTrace } from "@wizwar/engine";
import type { Side } from "@wizwar/engine";
import type { BoardFx } from "./fx";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import EdgeGlyph from "./EdgeGlyph.svelte";
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
import SightTraceOverlay from "./SightTraceOverlay.svelte";
import TokenArt from "./TokenArt.svelte";
import { FX_SPRITES } from "./fx-sprites";
const CELL = 48;
const WALL = 7;
@@ -16,8 +25,16 @@
onCreatureClick,
onWarpClick,
onCellPeek,
onIllusionClick,
onEdgePeek,
markedCell = null,
markedCells = null,
litCells = null,
markedSector = null,
ghostSlots = null,
onGhostClick,
effects = null,
sightTrace = null,
}: {
view: GameView;
edgeSelectMode?: boolean;
@@ -29,13 +46,45 @@
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
/** Long-press on a square: read the card behind whatever occupies it. */
onCellPeek?: (cell: { x: number; y: number }) => void;
/** Tap on a shimmering (untested) illusion wall: offer the belief test. */
onIllusionClick?: (cell: { x: number; y: number }, side: Side) => void;
/** Press-and-hold a wall/door/fire segment: its tooltip, for touch. */
onEdgePeek?: (tip: string) => void;
/** First square of a two-square spell: marked so the click reads as taken. */
markedCell?: { x: number; y: number } | null;
/** A multi-square placement in progress (boobytrap tokens), in click
* order — the first is the real trap, so each mark shows its number. */
markedCells?: { x: number; y: number }[] | null;
/** When set, squares NOT in this set dim — the targeting aid. */
litCells?: Set<string> | null;
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
markedSector?: { x: number; y: number } | null;
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
* beyond the maze, since an empty slot has no floor of its own to click. */
ghostSlots?: { x: number; y: number }[] | null;
onGhostClick?: (origin: { x: number; y: number }) => void;
/** Short-lived spell flourishes; purely cosmetic. */
effects?: BoardFx[] | null;
/** The sight line an attack in progress traveled — proof against "how
* can he even see me?", drawn leg by leg through any warp mouth. */
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null;
} = $props();
const SECTOR = 5;
/** Ghost slots can lie beyond the assembled maze — on any side, including
* negative coordinates (the maze renormalizes after the landing). The
* canvas grows in whichever direction holds them. */
const minBx = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.x)) * CELL - 8);
const minBy = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.y)) * CELL - 8);
const boundsW = $derived(
Math.max(view.board.width, ...(ghostSlots ?? []).map((g) => g.x + SECTOR)) * CELL + 8 - minBx,
);
const boundsH = $derived(
Math.max(view.board.height, ...(ghostSlots ?? []).map((g) => g.y + SECTOR)) * CELL + 8 - minBy,
);
// Press-and-hold peeks the square's card; a fired hold swallows the click.
let peekTimer: ReturnType<typeof setTimeout> | null = null;
let peekFired = false;
@@ -56,24 +105,12 @@
// Real token art, cropped from the owner's physical set (public/tokens/).
// Anything without an entry falls back to the vector rendering below.
const USE_TOKEN_ART = true;
const TERRAIN_ART: Record<string, string> = {
stone: "solid-stone", thornbush: "thorn-bush", rosebush: "rosebush",
ooze: "killer-ooze", dust: "dustcloud", slime: "slime",
tacks: "tacks", pit: "pit", safe: "safe",
};
const CREATURE_ART: Record<string, string> = {
skeleton: "skeleton", troll: "troll", wraith: "wraith",
"fire-imp": "fire-imp", "democratic-monster": "democratic-monster",
shadow: "shadow", "alter-ego": "alter-ego",
};
function objectArt(cardId: string): string | null {
if (cardId === "dagger") return "dagger";
if (cardId === "large-rock") return "rock";
if (cardId === "master-key") return "master-key";
if (cardId.endsWith("stone")) return "magic-stone";
if (cardId.endsWith("-wand")) return "magic-wand";
return null;
// Ongoing spells wear their look: ghostly when unseen, webbed when caught,
// stone-gray under Medusa's gaze.
function hasSpell(id: string, cardId: string): boolean {
return view.sustained.some((e) => e.cardId === cardId && e.targetId === id);
}
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
function wizardScale(id: string): number {
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
@@ -81,10 +118,10 @@
return 1;
}
function wizardArt(id: string): string {
return `/tokens/wizard-${sharedColorIndex(view, id) % 6}.png`;
return tokenArt(`wizard-${sharedColorIndex(view, id) % 6}`, "players");
}
function treasureArt(owner: string): string {
return `/tokens/treasure-${sharedColorIndex(view, owner) % 6}.png`;
return tokenArt(`treasure-${sharedColorIndex(view, owner) % 6}`, "objects");
}
function playerColor(id: string): string {
@@ -104,7 +141,14 @@
.map(([key, state]) => {
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
return { kind, x, y, state };
// A door's lock can be gone for good, jammed shut, or picked open
// for the turn — each earns its own look.
const lock = state === "door"
? (view.doorStates[key] ??
(view.heldDoorEdges.includes(key) ? "held"
: view.openDoorEdges.includes(key) ? "ajar" : null))
: null;
return { kind, x, y, state, lock };
}),
);
@@ -120,9 +164,45 @@
boxes.push({ cell: c, side: "S", x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
}
}
// Warp mouths sit on the perimeter, with no far cell: still wallable.
const seen = new Set(boxes.map((b) => `${b.cell.x},${b.cell.y},${b.side}`));
for (const w of view.board.warps) {
const c = w.from.cell;
const side = w.from.side;
const k = `${c.x},${c.y},${side}`;
if (seen.has(k)) continue;
seen.add(k);
if (side === "E") boxes.push({ cell: c, side, x: (c.x + 1) * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
if (side === "W") boxes.push({ cell: c, side, x: c.x * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
if (side === "S") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
if (side === "N") boxes.push({ cell: c, side, x: c.x * CELL + 4, y: c.y * CELL - 6, w: CELL - 8, h: 12 });
}
return boxes;
});
// FEAR's dread: three spaces in every direction, straight through walls
// ("even if walls separate you"), never diagonally — and the maze wraps,
// so the diamond continues from the opposite rim. Same measure the
// engine's refusal takes.
const fearCells = $derived.by(() => {
const out = new Set<string>();
const W = view.board.width;
const H = view.board.height;
for (const fp of view.players) {
if (!fp.alive) continue;
if (!view.sustained.some((e) => e.cardId === "fear" && e.targetId === fp.id)) continue;
for (let dx = -3; dx <= 3; dx++) {
for (let dy = -3 + Math.abs(dx); dy <= 3 - Math.abs(dx); dy++) {
const wx = ((fp.position.x + dx) % W + W) % W;
const wy = ((fp.position.y + dy) % H + H) % H;
const k = `${wx},${wy}`;
if (view.board.cells[k]) out.add(k);
}
}
}
return out;
});
// Warp pairs share a letter, like the printed openings on the real boards.
const warpLetters = $derived.by(() => {
const letters = new Map<string, string>();
@@ -137,6 +217,27 @@
return letters;
});
// Tokens glide between adjacent squares; anything farther — teleports,
// warps, drags, sector moves — snaps, because sliding a wizard across the
// whole maze would tell a false story. Keyed by identity so the DOM node
// survives the move.
const lastAt = new Map<string, { x: number; y: number }>();
function snapsTo(id: string, x: number, y: number): boolean {
const prev = lastAt.get(id);
lastAt.set(id, { x, y });
if (!prev) return true;
return Math.abs(prev.x - x) + Math.abs(prev.y - y) > CELL * 1.6;
}
const creaturesByCell = $derived.by(() => {
const map = new Map<string, typeof view.creatures>();
for (const c of view.creatures) {
const k = `${c.position.x},${c.position.y}`;
map.set(k, [...(map.get(k) ?? []), c]);
}
return map;
});
// Group players by cell so co-located wizards fan out.
const wizardsByCell = $derived.by(() => {
const map = new Map<string, typeof view.players>();
@@ -149,10 +250,27 @@
});
</script>
<!-- Explicit width/height give the svg an intrinsic size: Safari cannot
derive one from the viewBox alone and collapses the board to 0x0 inside
a max-height flex column. Doubled so CSS max-* caps still govern. -->
<svg
viewBox={`-8 -8 ${view.board.width * CELL + 16} ${view.board.height * CELL + 16}`}
viewBox={`${minBx} ${minBy} ${boundsW} ${boundsH}`}
width={boundsW * 2}
height={boundsH * 2}
class="board"
>
<!-- open ground a relocated sector may claim -->
{#each ghostSlots ?? [] as g (`${g.x},${g.y}`)}
<rect
x={g.x * CELL + 2} y={g.y * CELL + 2}
width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
class="ghost-slot"
role="button" tabindex="-1"
onclick={() => onGhostClick?.(g)}
onkeydown={() => {}}
/>
{/each}
<!-- floor -->
{#each cells as c (`${c.x},${c.y}`)}
<rect
@@ -193,12 +311,11 @@
{@const tx = t.position.x * CELL + CELL / 2 + (group.length > 1 ? (ti - (group.length - 1) / 2) * CELL * 0.3 : 0)}
{@const ty = t.position.y * CELL + CELL * 0.72}
{#if USE_TOKEN_ART}
<image
<TokenArt
href={treasureArt(t.owner)}
x={tx - CELL * 0.26} y={ty - CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
preserveAspectRatio="xMidYMid slice"
class="token-art small"
cls="token-art small"
/>
{:else}
<g class="treasure-g">
@@ -216,12 +333,11 @@
{@const sx = Number(key.split(",")[0])}
{@const sy = Number(key.split(",")[1])}
{#if USE_TOKEN_ART && TERRAIN_ART[content.kind]}
<image
href={`/tokens/${TERRAIN_ART[content.kind]}.png`}
<TokenArt
href={tokenArt(TERRAIN_ART[content.kind]!, "terrain")}
x={sx * CELL + 3} y={sy * CELL + 3}
width={CELL - 6} height={CELL - 6}
preserveAspectRatio="xMidYMid slice"
class="token-art"
cls="token-art"
/>
{:else if content.kind === "stone"}
<rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
@@ -246,11 +362,11 @@
{#each view.dimWarps as w, wi (wi)}
{#each [w.a, w.b] as tok, i (i)}
{#if USE_TOKEN_ART}
<image
href="/tokens/dimensional-warp.png"
<TokenArt
href={tokenArt("dimensional-warp", "terrain")}
x={tok.x * CELL + CELL * 0.04} y={tok.y * CELL + CELL * 0.04}
width={CELL * 0.44} height={CELL * 0.44}
preserveAspectRatio="xMidYMid slice" class="token-art"
cls="token-art"
/>
{:else}
<circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
@@ -275,7 +391,7 @@
{@const art = USE_TOKEN_ART ? objectArt(o.cardId) : null}
{#if art}
<image
href={`/tokens/${art}.png`}
href={tokenArt(art, "objects")}
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
width={CELL * 0.4} height={CELL * 0.4}
preserveAspectRatio="xMidYMid slice"
@@ -296,19 +412,18 @@
<!-- walls & doors & firewalls -->
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
{@const cls = e.state === "door" ? "door" : e.state === "firewall" ? "firewall" : "wall"}
{#if e.kind === "V"}
<rect
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
width={WALL} height={CELL + WALL}
class={cls}
/>
{#if e.state === "firewall"}
<FirewallEdge x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"} onpeek={onEdgePeek} />
{:else}
<rect
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
width={CELL + WALL} height={WALL}
class={cls}
/>
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
: e.lock === "jammed" ? "lock jammed — sealed for good"
: e.lock === "held" ? "held open by a standing wizard"
: e.lock === "ajar" ? "unlocked until end of turn" : null}
<EdgeGlyph x={e.x} y={e.y} kind={e.kind === "V" ? "V" : "H"}
state={e.state === "door" ? "door" : "wall"}
lock={(e.lock ?? null) as "removed" | "jammed" | "held" | "ajar" | null}
title={lockTitle} damage={view.wallDamage[`${e.kind}:${e.x},${e.y}`] ?? 0}
onpeek={onEdgePeek} />
{/if}
{/each}
@@ -320,10 +435,10 @@
{@const frac = Math.min(1, dmg / 20)}
{#if kind === "V"}
<line x1={(wx + 1) * CELL} y1={wy * CELL + 3} x2={(wx + 1) * CELL} y2={(wy + 1) * CELL - 3}
class="crack" style:opacity={0.35 + frac * 0.65} />
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></line>
{:else}
<line x1={wx * CELL + 3} y1={(wy + 1) * CELL} x2={(wx + 1) * CELL - 3} y2={(wy + 1) * CELL}
class="crack" style:opacity={0.35 + frac * 0.65} />
class="crack" style:opacity={0.35 + frac * 0.65}><title>battle-scarred — {dmg} damage taken</title></line>
{/if}
{/each}
@@ -333,9 +448,20 @@
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
{#if kind === "V"}
<line x1={(ix + 1) * CELL} y1={iy * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
<line x1={(ix + 1) * CELL} y1={iy * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion"><title>an illusion — your eyes see through it</title></line>
{:else}
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion"><title>an illusion — your eyes see through it</title></line>
{/if}
{/each}
<!-- untested illusions: the wall stands, but it shimmers — click to doubt it -->
{#each Object.entries(view.illusionEdges) as [key, verdict] (key)}
{#if verdict === "untested"}
{@const kind = key.split(":")[0] === "V" ? "V" as const : "H" as const}
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
<IllusionShimmer x={ix} y={iy} {kind}
onclick={() => onIllusionClick?.({ x: ix, y: iy }, kind === "V" ? "E" : "S")} />
{/if}
{/each}
@@ -383,11 +509,35 @@
/>
{/if}
<!-- wizards -->
{#each [...wizardsByCell.entries()] as [key, group] (key)}
{#each group as p, i (p.id)}
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0)}
{@const cy = p.position.y * CELL + CELL * 0.36}
<!-- placements so far of a multi-square spell, numbered in click order -->
{#each markedCells ?? [] as m, i (`${m.x},${m.y}`)}
<rect
x={m.x * CELL + 3} y={m.y * CELL + 3}
width={CELL - 6} height={CELL - 6}
rx="6" class="marked-cell"
/>
<text
x={m.x * CELL + CELL / 2} y={m.y * CELL + CELL / 2}
class="marked-count" text-anchor="middle" dominant-baseline="central"
>{i + 1}</text>
{/each}
<!-- the sector picked up, awaiting its new ground -->
{#if markedSector}
<rect
x={markedSector.x * CELL + 3} y={markedSector.y * CELL + 3}
width={SECTOR * CELL - 6} height={SECTOR * CELL - 6}
rx="8" class="marked-sector"
/>
{/if}
<!-- wizards: identity-keyed movers, so steps glide square to square -->
{#each view.players.filter((p) => p.alive) as p (p.id)}
{@const group = wizardsByCell.get(`${p.position.x},${p.position.y}`) ?? [p]}
{@const gi = group.findIndex((q) => q.id === p.id)}
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)}
{@const cy = p.position.y * CELL + CELL * 0.36}
<g class="mover" class:snap={snapsTo(`w:${p.id}`, cx, cy)} style={`transform: translate(${cx}px, ${cy}px)`}>
<g
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
@@ -399,78 +549,91 @@
>
{#if USE_TOKEN_ART}
{@const half = CELL * 0.3 * wizardScale(p.id)}
<image
<TokenArt
href={wizardArt(p.id)}
x={cx - half} y={cy - half}
x={-half} y={-half}
width={half * 2} height={half * 2}
preserveAspectRatio="xMidYMid slice"
class="token-art hit"
cls={`token-art hit${hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body") ? " ghosted" : ""}${hasSpell(p.id, "medusa") ? " stone-gazed" : ""}`}
/>
<rect
x={cx - half} y={cy - half}
x={-half} y={-half}
width={half * 2} height={half * 2}
class="wizard-ring" stroke={playerColor(p.id)}
/>
{#if hasSpell(p.id, "sticky-web")}
<g class="webbing">
<line x1={-half} y1={-half * 0.4} x2={half} y2={half * 0.5} />
<line x1={-half * 0.6} y1={half} x2={half * 0.7} y2={-half} />
<line x1={-half} y1={half * 0.7} x2={half} y2={-half * 0.2} />
</g>
{/if}
{:else}
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
<circle cx="0" cy="0" r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
<text x="0" y="4" class="wizard-label">{p.id[0]?.toUpperCase()}</text>
{/if}
{#if p.carriedTreasureId}
<circle cx={cx + CELL * 0.26} cy={cy + CELL * 0.26} r={5} class="carried" />
{@const carriedT = view.treasures.find((t) => t.id === p.carriedTreasureId)}
{#if carriedT}
<!-- Whose gold: the owner's chest rides the carrier's shoulder.
Always the drawn chest — legible at badge size, either art mode. -->
<TokenArt
href={`/tokens-svg/treasure-${sharedColorIndex(view, carriedT.owner) % 6}.svg`}
x={CELL * 0.10} y={CELL * 0.06}
width={CELL * 0.36} height={CELL * 0.36}
cls="carried-chest"
/>
{:else}
<circle cx={CELL * 0.26} cy={CELL * 0.26} r={5} class="carried" />
{/if}
{/if}
</g>
{/each}
{/each}
<!-- creatures (fanned when several share a square) -->
{#each [...view.creatures.reduce((m, c) => {
const k = `${c.position.x},${c.position.y}`;
m.set(k, [...(m.get(k) ?? []), c]);
return m;
}, new Map()).entries()] as [cKey, cGroup] (cKey)}
{#each cGroup as c, ci (c.id)}
{@const ccx = c.position.x * CELL + CELL * 0.72 - (cGroup.length > 1 ? ci * CELL * 0.26 : 0)}
{@const ccy = c.position.y * CELL + CELL * 0.7}
<g
role="button" tabindex="-1" class="creature"
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }}
onpointerdown={() => pressCell(c.position)}
onpointerup={releasePress}
onpointerleave={releasePress}
onkeydown={() => {}}
>
{#if USE_TOKEN_ART && CREATURE_ART[c.kind]}
<image
href={`/tokens/${CREATURE_ART[c.kind]}.png`}
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
preserveAspectRatio="xMidYMid slice"
class="token-art hit"
class:selected-art={c.id === selectedCreatureId}
>
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</image>
<rect
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
class="creature-ring"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
/>
{:else}
<rect
x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3"
transform={`rotate(45 ${ccx} ${ccy})`}
class="creature-body"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
>
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</rect>
<text x={ccx} y={ccy + 4} class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if}
</g>
{/each}
<!-- creatures: identity-keyed movers, fanned when several share a square -->
{#each view.creatures as c (c.id)}
{@const cGroup = creaturesByCell.get(`${c.position.x},${c.position.y}`) ?? [c]}
{@const ci = cGroup.findIndex((q) => q.id === c.id)}
{@const ccx = c.position.x * CELL + CELL * 0.72 - (cGroup.length > 1 ? ci * CELL * 0.26 : 0)}
{@const ccy = c.position.y * CELL + CELL * 0.7}
<g class="mover" class:snap={snapsTo(`c:${c.id}`, ccx, ccy)} style={`transform: translate(${ccx}px, ${ccy}px)`}>
<g
role="button" tabindex="-1" class="creature"
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }}
onpointerdown={() => pressCell(c.position)}
onpointerup={releasePress}
onpointerleave={releasePress}
onkeydown={() => {}}
>
{#if USE_TOKEN_ART && CREATURE_ART[c.kind]}
<TokenArt
href={tokenArt(CREATURE_ART[c.kind]!, "creatures")}
x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
cls={`token-art hit${c.id === selectedCreatureId ? " selected-art" : ""}`}
title={`${c.kind} (${c.controllerId}) — ${c.damage}/${Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg`}
/>
<rect
x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
class="creature-ring"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
/>
{:else}
<rect
x={-10} y={-10} width={20} height={20} rx="3"
transform="rotate(45 0 0)"
class="creature-body"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
>
<title>{c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</rect>
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if}
</g>
</g>
{/each}
<!-- edge selection hitboxes -->
@@ -493,6 +656,26 @@
{/if}
{/each}
{/if}
<!-- FEAR's bubble: every square within three WALKED spaces (warps count) -->
{#each fearCells as key (key)}
{@const bx = Number(key.split(",")[0])}
{@const by = Number(key.split(",")[1])}
<rect x={bx * CELL + 1.5} y={by * CELL + 1.5} width={CELL - 3} height={CELL - 3}
class="fear-aura" rx="4" />
{/each}
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
{#if sightTrace}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
{/if}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)}
<!-- One cast at the dispatch seam; the registry stays exhaustive. -->
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
<Sprite {fx} />
{/each}
</g>
</svg>
<style>
@@ -518,22 +701,6 @@
cursor: pointer;
}
.floor:hover { fill: #efe8d2; }
.wall {
fill: #f2ecd8;
stroke: #2b2218;
stroke-width: 1.6;
}
.door {
fill: #8b5a2b;
stroke: #2b2218;
stroke-width: 1.4;
rx: 2;
}
.firewall {
fill: #d0342c;
stroke: #7c1a14;
stroke-width: 1.2;
}
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
.rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; }
@@ -546,11 +713,11 @@
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
.trap-real { stroke: #d3352b; stroke-width: 2.5; }
.dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; }
.token-art {
:global(.token-art) {
filter: drop-shadow(0.5px 1.5px 1.5px rgba(10, 8, 4, 0.5));
pointer-events: none;
}
.token-art.hit { pointer-events: auto; cursor: pointer; }
:global(.token-art.hit) { pointer-events: auto; cursor: pointer; }
.creature-ring, .wizard-ring {
fill: none;
stroke-width: 2.5;
@@ -602,6 +769,56 @@
pointer-events: none;
animation: warp-pulse 1.6s ease-in-out infinite;
}
.marked-count {
font-family: "Oswald", sans-serif;
font-size: 15px;
font-weight: 600;
fill: #d3852b;
pointer-events: none;
}
.marked-sector {
fill: rgba(211, 133, 43, 0.12);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 10 6;
pointer-events: none;
animation: warp-pulse 1.6s ease-in-out infinite;
}
.ghost-slot {
fill: rgba(122, 162, 122, 0.10);
stroke: #7aa27a;
stroke-width: 2.5;
stroke-dasharray: 10 6;
cursor: pointer;
animation: warp-pulse 1.6s ease-in-out infinite;
}
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
.fx-layer { pointer-events: none; }
.fear-aura {
fill: rgba(90, 44, 110, 0.05);
stroke: #7a4a8a;
stroke-width: 2;
stroke-dasharray: 8 6;
pointer-events: none;
animation: fear-breathe 2.4s ease-in-out infinite;
}
@keyframes fear-breathe {
0%, 100% { opacity: 0.65; }
50% { opacity: 0.3; }
}
.mover { transition: transform 260ms cubic-bezier(0.25, 0.8, 0.35, 1); }
.mover.snap { transition: none; }
/* --- persistent ambiance: ongoing spells wear their look ------------ */
:global(.token-art.ghosted) { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
@keyframes -global-ghost-breathe { 50% { opacity: 0.22; } }
:global(.token-art.stone-gazed) { filter: grayscale(0.9) brightness(0.8); }
.webbing line {
stroke: rgba(220, 218, 205, 0.85);
stroke-width: 1.6;
pointer-events: none;
}
.warp-dest {
fill: none;
stroke: #2e7d32;
@@ -615,7 +832,10 @@
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.marked-cell, .warp-dest { animation: none; }
.marked-cell, .marked-sector, .ghost-slot, .warp-dest, .fear-aura { animation: none; }
.fx-layer { display: none; }
:global(.token-art.ghosted) { animation: none; }
.mover { transition: none; }
}
.wizard { cursor: pointer; }
.wizard-label {
@@ -624,6 +844,10 @@
text-anchor: middle; pointer-events: none;
}
.carried { fill: gold; stroke: #111; stroke-width: 1; }
:global(.carried-chest) {
filter: drop-shadow(0.5px 1px 1px rgba(0, 0, 0, 0.55));
pointer-events: none;
}
.crack {
stroke: #efe8d4;
stroke-width: 2;
+1 -16
View File
@@ -236,22 +236,7 @@
50% { box-shadow: 0 0 0 2px #c9a72a, 0 0 6px 1px rgba(201, 167, 42, 0.3); }
}
@media (prefers-reduced-motion: reduce) {
.faq-seal {
position: absolute;
bottom: 0.15rem;
right: 0.2rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Oswald", sans-serif;
font-size: 0.42rem;
letter-spacing: 0.12em;
padding: 0.1rem 0.25rem;
border-radius: 2px;
cursor: pointer;
opacity: 0.85;
}
.faq-seal:hover { opacity: 1; }
.card.glowing { animation: none; }
.card.glowing { animation: none; }
.card { transition: none; }
.card:hover, .card.selected, .card.attached { transform: none; }
}
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
// A wall or door on an edge, exactly as the board draws it. Wall-segment
// effects (locks picked, jammed, removed; doors held) are color states of
// this one glyph — the game has never used tokens for them.
let { x, y, kind, state, lock = null, title = null, damage = 0, onpeek }: {
x: number; y: number; kind: "V" | "H";
state: "wall" | "door";
lock?: "removed" | "jammed" | "held" | "ajar" | null;
title?: string | null;
/** Battle damage taken so far (walls fall at 20, doors at 15). */
damage?: number;
/** Press-and-hold (touch has no hover): report the tooltip's text. */
onpeek?: (tip: string) => void;
} = $props();
const CELL = 48;
const WALL = 7;
const cls = $derived(state === "door" ? `door${lock ? ` ${lock}` : ""}` : "wall");
// Every segment tells its points — the game's own word: "A wall takes 20
// points of damage to destroy; a door takes 15."
const needed = $derived(state === "door" ? 15 : 20);
const hp = $derived(damage > 0
? `${Math.max(0, needed - damage)} of ${needed} points left`
: `${needed} points to destroy`);
const tip = $derived(`${title ?? (state === "door" ? "a locked door" : "a wall")} — ${hp}`);
// Press-and-hold peeks the segment, mirroring the board's cell gesture.
let timer: ReturnType<typeof setTimeout> | null = null;
function press() {
if (!onpeek) return;
timer = setTimeout(() => onpeek?.(tip), 450);
}
function release() {
if (timer) clearTimeout(timer);
timer = null;
}
</script>
{#if kind === "V"}
<rect
x={(x + 1) * CELL - WALL / 2} y={y * CELL - WALL / 2}
width={WALL} height={CELL + WALL}
class={cls}
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release}
><title>{tip}</title></rect>
{:else}
<rect
x={x * CELL - WALL / 2} y={(y + 1) * CELL - WALL / 2}
width={CELL + WALL} height={WALL}
class={cls}
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release}
><title>{tip}</title></rect>
{/if}
<style>
.wall {
fill: #f2ecd8;
stroke: #2b2218;
stroke-width: 1.6;
}
.door {
fill: #8b5a2b;
stroke: #2b2218;
stroke-width: 1.4;
rx: 2;
}
/* Lock removed: pale, an open doorway forever. */
.door.removed { fill: #d4bd8e; }
/* Lock jammed: near-black, sealed. */
.door.jammed { fill: #3a2a18; stroke: #14100b; }
/* Picked or keyed open until end of turn. */
.door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
/* Held open: a wizard stands propping it. */
.door.held { fill: #d4bd8e; stroke: #2e7d32; }
</style>
+117
View File
@@ -0,0 +1,117 @@
<script lang="ts">
// A standing WALL OF FIRE: board furniture, not a flourish — it burns for
// as long as the spell holds, so the animation is pure CSS on a handful
// of shapes (no turbulence filters; those are budgeted for one-shot fx).
let { x, y, kind, onpeek }: {
x: number; y: number; kind: "V" | "H";
/** Press-and-hold (touch has no hover): report what this fire is. */
onpeek?: (tip: string) => void;
} = $props();
const TIP = "a wall of fire — passing through burns for 4";
let peekTimer: ReturnType<typeof setTimeout> | null = null;
function press() {
if (!onpeek) return;
peekTimer = setTimeout(() => onpeek?.(TIP), 450);
}
function release() {
if (peekTimer) clearTimeout(peekTimer);
peekTimer = null;
}
const uid = $props.id();
const CELL = 48;
const WALL = 7;
const cx = $derived(kind === "V" ? (x + 1) * CELL : x * CELL + CELL / 2);
const cy = $derived(kind === "V" ? y * CELL + CELL / 2 : (y + 1) * CELL);
// Tongues sit along the edge's axis; every flame rises screen-up.
const TONGUES = [
{ t: -0.38, h: 11, d: 0.0 },
{ t: -0.19, h: 15, d: 0.45 },
{ t: 0.0, h: 18, d: 0.15 },
{ t: 0.21, h: 13, d: 0.6 },
{ t: 0.4, h: 16, d: 0.3 },
];
const EMBERS = [
{ t: -0.28, d: 0.0 },
{ t: 0.08, d: 0.9 },
{ t: 0.33, d: 1.7 },
];
const px = (t: number) => (kind === "V" ? 0 : t * CELL);
const py = (t: number) => (kind === "V" ? t * CELL : 0);
</script>
<g transform={`translate(${cx} ${cy})`} class="fw">
<title>a wall of fire — passing through burns for 4</title>
<defs>
<linearGradient id={`fw-bar-${uid}`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#7c1a14" />
<stop offset="0.5" stop-color="#ef3b08" />
<stop offset="1" stop-color="#ffad22" />
</linearGradient>
<linearGradient id={`fw-tongue-${uid}`} x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#ef3b08" />
<stop offset="0.55" stop-color="#ff9d16" />
<stop offset="1" stop-color="#fff09a" />
</linearGradient>
</defs>
<!-- the burning bar itself, seated on the wall line -->
{#if kind === "V"}
<rect x={-WALL / 2} y={-CELL / 2 - 2} width={WALL} height={CELL + 4} rx="3"
fill={`url(#fw-bar-${uid})`} class="fw-bar"
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
{:else}
<rect x={-CELL / 2 - 2} y={-WALL / 2} width={CELL + 4} height={WALL} rx="3"
fill={`url(#fw-bar-${uid})`} class="fw-bar"
role="img" onpointerdown={press} onpointerup={release} onpointerleave={release} />
{/if}
<!-- tongues of flame, each licking on its own beat -->
{#each TONGUES as f (f.t)}
<path
d={`M ${px(f.t) - 3.4} ${py(f.t) + 1}
C ${px(f.t) - 3.6} ${py(f.t) - f.h * 0.45}, ${px(f.t) - 1.4} ${py(f.t) - f.h * 0.7}, ${px(f.t)} ${py(f.t) - f.h}
C ${px(f.t) + 1.4} ${py(f.t) - f.h * 0.7}, ${px(f.t) + 3.6} ${py(f.t) - f.h * 0.45}, ${px(f.t) + 3.4} ${py(f.t) + 1} Z`}
fill={`url(#fw-tongue-${uid})`}
class="fw-tongue"
style={`transform-origin: ${px(f.t)}px ${py(f.t) + 1}px; animation-delay: ${f.d}s`}
/>
{/each}
<!-- embers drifting off the top -->
{#each EMBERS as em (em.t)}
<circle cx={px(em.t)} cy={py(em.t) - 6} r="1.4" class="fw-ember"
style={`animation-delay: ${em.d}s`} />
{/each}
</g>
<style>
.fw-tongue, .fw-ember { pointer-events: none; }
.fw-bar {
stroke: #7c1a14;
stroke-width: 1;
filter: drop-shadow(0 0 3px rgba(255, 140, 30, 0.7));
animation: fw-glow 1.3s ease-in-out infinite alternate;
}
.fw-tongue {
animation: fw-lick 0.9s ease-in-out infinite alternate;
}
.fw-ember {
fill: #ffd166;
opacity: 0;
animation: fw-rise 2.6s ease-out infinite;
}
@keyframes fw-glow {
from { filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
to { filter: drop-shadow(0 0 6px rgba(255, 150, 40, 0.95)); }
}
@keyframes fw-lick {
from { transform: scale(0.95, 0.72); opacity: 0.82; }
to { transform: scale(1.04, 1.12); opacity: 1; }
}
@keyframes fw-rise {
0% { opacity: 0; transform: translateY(0); }
15% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-16px); }
}
@media (prefers-reduced-motion: reduce) {
.fw-bar, .fw-tongue, .fw-ember { animation: none; }
.fw-ember { opacity: 0.6; }
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
// The flourish workshop: every effect sprite on a loop, labeled, against a
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
// file hot-reload here mid-animation.
import type { Component } from "svelte";
import { FX_SPRITES } from "./fx-sprites";
import type { BoardFx, FxShape } from "./fx";
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
import SightTraceOverlay from "./SightTraceOverlay.svelte";
let tick = $state(0);
$effect(() => {
const t = setInterval(() => (tick += 1), 2200);
return () => clearInterval(t);
});
// One representative of every kind, staged on a 3x3 (sectors on a 5x5).
const mid = { x: 1, y: 1 };
const SAMPLES: BoardFx[] = ([
{ kind: "fireball", a: { x: 24, y: 72 }, b: { x: 120, y: 72 } },
{ kind: "waterbolt", a: { x: 24, y: 72 }, b: { x: 120, y: 72 } },
{ kind: "bolt", a: { x: 24, y: 24 }, b: { x: 120, y: 120 } },
{ kind: "burst", at: mid },
{ kind: "splash", at: mid },
{ kind: "shimmer", at: mid },
{ kind: "shield", at: mid },
{ kind: "sparkle", at: mid },
{ kind: "whiff", at: mid },
{ kind: "hit", at: mid },
{ kind: "pow", at: mid },
{ kind: "claw", at: mid },
{ kind: "absorb", at: mid },
{ kind: "portal", cell: mid, side: "E" },
{ kind: "portal-cell", at: mid },
{ kind: "streak", a: { x: 24, y: 120 }, b: { x: 120, y: 24 } },
{ kind: "soul", at: { x: 1, y: 2 } },
{ kind: "fireworks", at: mid },
{ kind: "chaos-swirl", at: mid },
{ kind: "die-drop", at: { x: 1, y: 1 }, aim: { x: 0, y: 2 } },
{ kind: "pit-fall", at: mid },
{ kind: "ooze-slip", at: mid },
{ kind: "tacks-ow", at: mid },
{ kind: "thorn-snap", at: mid },
{ kind: "slime-stuck", at: mid },
{ kind: "dust-puff", at: mid },
{ kind: "edge-dust", cell: mid, side: "S" },
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
] as FxShape[]).map((f, i): BoardFx => ({ ...f, id: i + 1 }));
const isSector = (k: string) => k.startsWith("sector-");
</script>
<div class="gallery">
<h1>The Flourish Workshop</h1>
<p class="sub">
Every effect replays each beat. Edit a file in <code>src/fx-sprites/</code>
and it hot-reloads here.
<a class="cross" href="/?tokens">→ the token workshop</a>
</p>
<div class="grid">
{#each SAMPLES as fx (fx.kind)}
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
<figure>
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cx) => cx) as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}
{/each}
{#key tick}
<g class="fx-layer"><Sprite {fx} /></g>
{/key}
</svg>
<figcaption>{fx.kind}</figcaption>
</figure>
{/each}
</div>
<h1 class="furniture-head">The standing fires</h1>
<p class="sub">
Board furniture that burns as long as its spell holds — these loop by
nature, no beat needed. Same components the live board mounts.
</p>
<div class="grid">
<figure>
<svg viewBox="-4 -4 152 152">
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}{/each}
<FirewallEdge x={1} y={0} kind="H" />
</svg>
<figcaption>wall of fire (H)</figcaption>
</figure>
<figure>
<svg viewBox="-4 -4 152 152">
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}{/each}
<FirewallEdge x={0} y={1} kind="V" />
</svg>
<figcaption>wall of fire (V)</figcaption>
</figure>
<figure>
<svg viewBox="-4 -4 152 152">
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}{/each}
<rect x={93.5} y={44.5} width={7} height={55} class="demo-wall" />
<IllusionShimmer x={1} y={1} kind="V" />
</svg>
<figcaption>illusion shimmer</figcaption>
</figure>
<figure>
<svg viewBox="-4 -4 152 152">
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}{/each}
<SightTraceOverlay from={{ x: 0, y: 2 }} to={{ x: 2, y: 0 }} trace={{ kind: "direct" }} />
</svg>
<figcaption>sight trace (direct)</figcaption>
</figure>
<figure>
<svg viewBox="-4 -4 152 152">
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}{/each}
<SightTraceOverlay from={{ x: 0, y: 2 }} to={{ x: 2, y: 1 }}
trace={{ kind: "warp", mouthA: { cell: { x: 1, y: 0 }, side: "N" },
mouthB: { cell: { x: 1, y: 2 }, side: "S" },
entry: { x: 1.5, y: 0 }, exit: { x: 1.5, y: 3 } }} />
</svg>
<figcaption>sight trace (through a warp)</figcaption>
</figure>
</div>
</div>
<style>
.gallery {
min-height: 100vh;
background: #171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
padding: 1.5rem;
}
h1 {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 1.2rem;
color: #e9e1cb;
margin: 0 0 0.2rem;
}
.sub { color: #8d8672; margin: 0 0 1.2rem; }
.furniture-head {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 1.2rem;
color: #e9e1cb;
margin: 1.6rem 0 0.2rem;
}
.demo-wall { fill: #4d4438; }
.sub code { color: #c9a72a; }
.sub .cross { color: #c9a72a; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
figure {
margin: 0;
background: #efe8d4;
border-radius: 4px;
padding: 0.5rem;
}
svg { width: 100%; display: block; }
.cell {
fill: #efe8d4;
stroke: rgba(95, 74, 51, 0.25);
stroke-width: 1;
}
.fx-layer { pointer-events: none; }
figcaption {
font-family: "Courier Prime", monospace;
font-size: 0.75rem;
color: #43331f;
text-align: center;
padding-top: 0.3rem;
}
</style>
+14
View File
@@ -4,6 +4,7 @@
import Faq from "./Faq.svelte";
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
import { FAQ_GENERAL } from "./faq-general";
import { RULEBOOK_BASE, RULEBOOK_EXPANSION, RULEBOOK_COPYRIGHT } from "./rulebook";
let {
onclose,
@@ -174,6 +175,19 @@
<h3>{section.title}</h3>
{#each section.body as p, i (i)}<p>{p}</p>{/each}
{/each}
<h2 class="faq-divider">The rulebook, verbatim</h2>
<p class="colophon">
The full text, word for word, for settling table disputes. {RULEBOOK_COPYRIGHT}
</p>
{#each RULEBOOK_BASE as section (section.title)}
<h3>{section.title}</h3>
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
{/each}
<h2 class="faq-divider">Expansion Set 1 — verbatim</h2>
{#each RULEBOOK_EXPANSION as section (section.title)}
<h3>{section.title}</h3>
{#each section.paragraphs as p, i (i)}<p>{p}</p>{/each}
{/each}
<h2 class="faq-divider">Official FAQ — general rulings</h2>
<p class="colophon">
Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
// An UNTESTED illusion wall: the wall renders solid underneath (it is a
// wall to you until your eyes rule) — this is the tell-tale shimmer laid
// over it, and the tap target that offers the belief test.
let { x, y, kind, onclick }: {
x: number; y: number; kind: "V" | "H";
onclick?: () => void;
} = $props();
const CELL = 48;
const x1 = $derived(kind === "V" ? (x + 1) * CELL : x * CELL + 2);
const y1 = $derived(kind === "V" ? y * CELL + 2 : (y + 1) * CELL);
const x2 = $derived(kind === "V" ? (x + 1) * CELL : (x + 1) * CELL - 2);
const y2 = $derived(kind === "V" ? (y + 1) * CELL - 2 : (y + 1) * CELL);
</script>
<line {x1} {y1} {x2} {y2} class="illusion-shimmer" />
{#if onclick}
<line {x1} {y1} {x2} {y2} class="illusion-hit"
role="button" tabindex="-1" aria-label="a shimmering wall — test your eyes"
onclick={(ev) => { ev.stopPropagation(); onclick?.(); }}
onkeydown={() => {}}><title>this wall shimmers oddly — tap to test your eyes</title></line>
{/if}
<style>
.illusion-shimmer {
stroke: #cfc3f0;
stroke-width: 3;
stroke-dasharray: 4 5;
stroke-linecap: round;
pointer-events: none;
animation: shimmer-drift 1.4s linear infinite;
}
.illusion-hit { stroke: transparent; stroke-width: 14; cursor: pointer; }
@keyframes shimmer-drift {
0% { stroke-dashoffset: 0; opacity: 0.9; }
50% { opacity: 0.4; }
100% { stroke-dashoffset: -18; opacity: 0.9; }
}
@media (prefers-reduced-motion: reduce) {
.illusion-shimmer { animation: none; opacity: 0.7; }
}
</style>
+21 -1
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import Board from "./Board.svelte";
import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
@@ -20,6 +23,23 @@
);
const atEnd = $derived(idx >= steps.length - 1);
// 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.
const sightTrace = $derived(stackSightTrace(step.view));
/** Each step's spells flare on the reel exactly as they did at the table. */
let boardFx = $state<BoardFx[]>([]);
$effect(() => {
const step = steps[Math.min(idx, steps.length - 1)];
if (!step || !prefs.flourishes) return;
const cancel = scheduleFx(
step.events, step.view,
(fx) => (boardFx = [...boardFx, fx]),
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
);
return () => { cancel(); boardFx = []; };
});
$effect(() => {
if (!playing) return;
const t = setInterval(() => {
@@ -47,7 +67,7 @@
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
</header>
<div class="replay-board">
<Board view={step.view} />
<Board view={step.view} effects={boardFx} {sightTrace} />
</div>
<div class="replay-caption">
<strong>{step.actor}</strong>
+54
View File
@@ -0,0 +1,54 @@
<script lang="ts">
// The sight line an attack traveled — straight when direct, leg by leg
// through both warp mouths (with pulsing rings) when the maze wrapped.
import type { SightTrace } from "@wizwar/engine";
let { from, to, trace }: {
from: { x: number; y: number };
to: { x: number; y: number };
trace: SightTrace;
} = $props();
const CELL = 48;
const A = $derived({ x: from.x * CELL + CELL / 2, y: from.y * CELL + CELL * 0.36 });
const B = $derived({ x: to.x * CELL + CELL / 2, y: to.y * CELL + CELL * 0.36 });
</script>
<g class="sight-layer" aria-hidden="true">
{#if trace.kind === "direct"}
<line x1={A.x} y1={A.y} x2={B.x} y2={B.y} class="sight-line" />
{:else}
{@const entry = { x: trace.entry.x * CELL, y: trace.entry.y * CELL }}
{@const exit = { x: trace.exit.x * CELL, y: trace.exit.y * CELL }}
<line x1={A.x} y1={A.y} x2={entry.x} y2={entry.y} class="sight-line" />
<line x1={exit.x} y1={exit.y} x2={B.x} y2={B.y} class="sight-line" />
<circle cx={entry.x} cy={entry.y} r={7} class="sight-mouth" />
<circle cx={exit.x} cy={exit.y} r={7} class="sight-mouth" />
{/if}
</g>
<style>
.sight-layer { pointer-events: none; }
.sight-line {
stroke: #c9a72a;
stroke-width: 2.5;
stroke-linecap: round;
stroke-dasharray: 7 6;
opacity: 0.85;
animation: sight-march 0.8s linear infinite;
}
.sight-mouth {
fill: none;
stroke: #c9a72a;
stroke-width: 2;
animation: sight-pulse 1.6s ease-in-out infinite;
}
@keyframes sight-march {
to { stroke-dashoffset: -13; }
}
@keyframes sight-pulse {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.sight-line, .sight-mouth { animation: none; }
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts" module>
import { SvelteMap } from "svelte/reactivity";
// SVG art must be INLINED to stay vector: an svg referenced through an
// <image> element is rasterized once at its layout size in user units and
// scales as a bitmap — blurry the moment the board zooms. Fetched files
// are cached per URL, with their ids namespaced so 45 tokens' worth of
// filter/pattern defs cannot collide in one document.
type Entry = { vb: string; inner: string };
const cache = new SvelteMap<string, Entry | "pending" | "failed">();
function load(href: string): void {
if (cache.has(href)) return;
cache.set(href, "pending");
fetch(href)
.then((r) => r.text())
.then((text) => {
const ns = href.replace(/[^a-zA-Z0-9]/g, "");
const t = text
.replace(/id="([^"]+)"/g, (_, id) => `id="${id}-${ns}"`)
.replace(/url\(#([^)]+)\)/g, (_, id) => `url(#${id}-${ns})`)
.replace(/href="#([^"]+)"/g, (_, id) => `href="#${id}-${ns}"`);
const m = /<svg([^>]*)>([\s\S]*)<\/svg>/i.exec(t);
const vb = m ? (/viewBox="([^"]*)"/.exec(m[1]!)?.[1] ?? "0 0 144 150") : "0 0 144 150";
cache.set(href, { vb, inner: m?.[2] ?? "" });
})
// A failed fetch stays failed: deleting would retrigger the effect
// and hammer a missing file forever.
.catch(() => cache.set(href, "failed"));
}
</script>
<script lang="ts">
let {
href, x, y, width, height, cls = "", title = null,
}: {
href: string;
x: number;
y: number;
width: number;
height: number;
cls?: string;
title?: string | null;
} = $props();
const isSvg = $derived(href.endsWith(".svg"));
$effect(() => {
if (isSvg) load(href);
});
const entry = $derived(isSvg ? cache.get(href) : undefined);
</script>
{#if isSvg}
{#if entry && entry !== "pending" && entry !== "failed"}
<svg {x} {y} {width} {height} viewBox={entry.vb}
preserveAspectRatio="xMidYMid slice" class={cls}>
{#if title}<title>{title}</title>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags — our own art files -->
{@html entry.inner}
</svg>
{/if}
{:else}
<image {href} {x} {y} {width} {height}
preserveAspectRatio="xMidYMid slice" class={cls}>
{#if title}<title>{title}</title>{/if}
</image>
{/if}
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
// The token workshop: every token in both arts, side by side — the
// photographed physical set and the hand-drawn SVG contingency. Open
// with /?tokens under `npm run dev`; edits to public/tokens-svg/*.svg
// show on the next refresh. Board size and close-up for each.
import TokenArt from "./TokenArt.svelte";
const GROUPS: { title: string; files: string[] }[] = [
{ title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] },
{ title: "Treasures", files: ["treasure-0", "treasure-1", "treasure-2", "treasure-3", "treasure-4", "treasure-5"] },
{
title: "Monsters",
files: ["troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow", "alter-ego"],
},
{
title: "Terrain",
files: [
"solid-stone", "thorn-bush", "rosebush", "killer-ooze", "slime", "dustcloud",
"tacks", "pit", "safe",
],
},
{
title: "Objects & markers",
files: [
"dagger", "rock", "magic-stone", "magic-wand", "master-key", "boobytrap",
"dimensional-warp",
],
},
];
// Wall-segment effects have never been tokens: the maze draws them as edge
// treatments. Shown here exactly as the board mounts them.
import EdgeGlyph from "./EdgeGlyph.svelte";
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
const EDGE_PIECES: {
caption: string;
state?: "wall" | "door";
lock?: "removed" | "jammed" | "held" | "ajar" | null;
special?: "firewall" | "shimmer" | "known-illusion";
}[] = [
{ caption: "wall", state: "wall" },
{ caption: "door (locked)", state: "door" },
{ caption: "door, picked open (ajar)", state: "door", lock: "ajar" },
{ caption: "door, held open", state: "door", lock: "held" },
{ caption: "door, lock removed", state: "door", lock: "removed" },
{ caption: "door, lock jammed", state: "door", lock: "jammed" },
{ caption: "wall of fire", special: "firewall" },
{ caption: "illusion wall (untested)", special: "shimmer" },
{ caption: "illusion wall (you know it's fake)", special: "known-illusion" },
];
</script>
<div class="gallery">
<h1>The Token Workshop</h1>
<p class="sub">
Every token, twice: the photographed physical set beside the hand-drawn
contingency. Edit <code>public/tokens-svg/</code> and refresh. Small is
board size; large is the close-up the peek modal shows.
<a href="/?fx">→ the flourish workshop</a>
</p>
{#each GROUPS as group (group.title)}
<h2>{group.title}</h2>
<div class="grid">
{#each group.files as f (f)}
<figure>
<div class="pair">
<div class="col">
<img class="big" src={`/tokens/${f}.png`} alt={`${f} (photo)`} />
<img class="small" src={`/tokens/${f}.png`} alt="" />
<span class="tag">photo</span>
</div>
<div class="col">
<svg viewBox="0 0 96 96" class="big">
<TokenArt href={`/tokens-svg/${f}.svg`} x={0} y={0} width={96} height={96} />
</svg>
<svg viewBox="0 0 28 28" class="small">
<TokenArt href={`/tokens-svg/${f}.svg`} x={0} y={0} width={28} height={28} />
</svg>
<span class="tag">drawn</span>
</div>
</div>
<figcaption>{f}</figcaption>
</figure>
{/each}
</div>
{/each}
<h2>Walls &amp; doors — as the maze draws them</h2>
<p class="sub">
Wall-segment effects have never been tokens: locks picked, jammed, and
removed, held doors, standing fires, and illusions are edge treatments,
shown here with the very components the board mounts.
</p>
<div class="grid">
{#each EDGE_PIECES as piece (piece.caption)}
<figure>
<svg viewBox="20 20 104 104" class="edge-demo">
{#each [0, 1, 2] as cy (cy)}{#each [0, 1, 2] as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}{/each}
{#if piece.special === "firewall"}
<FirewallEdge x={1} y={0} kind="H" />
{:else if piece.special === "shimmer"}
<EdgeGlyph x={1} y={0} kind="H" state="wall" />
<IllusionShimmer x={1} y={0} kind="H" />
{:else if piece.special === "known-illusion"}
<line x1={48} y1={48} x2={96} y2={48} class="known-illusion" />
{:else}
<EdgeGlyph x={1} y={0} kind="H" state={piece.state!} lock={piece.lock ?? null} />
{/if}
</svg>
<figcaption>{piece.caption}</figcaption>
</figure>
{/each}
</div>
</div>
<style>
.gallery {
min-height: 100vh;
background: #171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
padding: 1.5rem;
}
h1, h2 {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #e9e1cb;
margin: 0 0 0.2rem;
}
h1 { font-size: 1.2rem; }
h2 { font-size: 0.95rem; margin-top: 1.4rem; }
.sub { color: #8d8672; margin: 0 0 1rem; }
.sub code { color: #c9a72a; }
.sub a { color: #c9a72a; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.9rem;
}
figure {
margin: 0;
background: #efe8d4;
border-radius: 4px;
padding: 0.5rem;
}
.pair { display: flex; gap: 0.6rem; justify-content: center; }
.col { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
.big { width: 72px; height: 72px; display: block; }
.small { width: 28px; height: 28px; display: block; }
img.big, img.small { object-fit: cover; border-radius: 3px; }
.tag {
font-family: "Courier Prime", monospace;
font-size: 0.6rem;
color: #8a7a5e;
}
.edge-demo { width: 100%; display: block; }
.cell {
fill: #efe8d4;
stroke: rgba(95, 74, 51, 0.25);
stroke-width: 1;
}
/* Mirrors Board's known-illusion ghost: dashed, violet, see-through. */
.known-illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
figcaption {
font-family: "Courier Prime", monospace;
font-size: 0.72rem;
color: #43331f;
text-align: center;
padding-top: 0.35rem;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
// Which art set a token draws from: the player's preference, with the
// workshop's per-category query params (?svgPlayers=true, ...) as a
// deliberate override for editing sessions under `npm run dev`.
import { prefs } from "./prefs.svelte";
export type ArtCategory = "players" | "creatures" | "terrain" | "objects";
const q = new URLSearchParams(typeof location === "undefined" ? "" : location.search);
const PARAM: Record<ArtCategory, string> = {
players: "svgPlayers", creatures: "svgCreatures", terrain: "svgTerrain", objects: "svgObjects",
};
export function tokenArt(file: string, cat: ArtCategory): string {
const override = q.get(PARAM[cat]);
const drawn = override !== null ? override === "true" : prefs.art === "drawn";
return drawn ? `/tokens-svg/${file}.svg` : `/tokens/${file}.png`;
}
// Which token file depicts each board thing (shared by the board renderer
// and the card-peek modal). Anything without an entry has no token art.
export const TERRAIN_ART: Record<string, string> = {
stone: "solid-stone", thornbush: "thorn-bush", rosebush: "rosebush",
ooze: "killer-ooze", dust: "dustcloud", slime: "slime",
tacks: "tacks", pit: "pit", safe: "safe",
};
export const CREATURE_ART: Record<string, string> = {
skeleton: "skeleton", troll: "troll", wraith: "wraith",
"fire-imp": "fire-imp", "democratic-monster": "democratic-monster",
shadow: "shadow", "alter-ego": "alter-ego",
};
export function objectArt(cardId: string): string | null {
if (cardId === "dagger") return "dagger";
if (cardId === "large-rock") return "rock";
if (cardId === "master-key") return "master-key";
if (cardId.endsWith("stone")) return "magic-stone";
if (cardId.endsWith("-wand")) return "magic-wand";
return null;
}
+36 -7
View File
@@ -25,7 +25,12 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Death",
body: [
"The second-hand killing of a Wizard, say by falling walls or controlled MONSTERS, does not get you his cards, nor does a MONSTER get cards by killing a player!",
"Dumping Cards and Physical Objects This is explained pretty well in the basic rules, but everybody seems to forget you can do it. If you want to discard a physical object, it is NOT necessary to dump it on the board, you can just discard it to the discard pile. The board would get very crowded otherwise. This includes ARTIFACTS, if you think someone is about to take one from you and there's nothing you can do about it. This can only be done during your own turn (this is not in the rules, but will be next printing).",
],
},
{
title: "Dumping Cards and Physical Objects",
body: [
"This is explained pretty well in the basic rules, but everybody seems to forget you can do it. If you want to discard a physical object, it is NOT necessary to dump it on the board, you can just discard it to the discard pile. The board would get very crowded otherwise. This includes ARTIFACTS, if you think someone is about to take one from you and there's nothing you can do about it. This can only be done during your own turn (this is not in the rules, but will be next printing).",
],
},
{
@@ -38,8 +43,18 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Lost Turns",
body: [
"These will accumulate if you get hit by a \"lost turns\" type spell while under the effect of another \"lost turns\" spell. Also, if you lose a turn while under a duration spell, this still uses up a turn of the duration spell. If you happen to get hit by a reflected attack (yours), and you \"lose a turn\", this would be THAT turn, not your next turn. This could happen with Lightning Bolt or Hypnosis, for example. As the rules say, if you Lose a Turn, you can't do anything but play Counteractions until your next turn. This also means you can't draw cards!",
"Magic Sticks These don't have to be displayed. When you first charge it with a NUMBER card, the NUMBER card gets discarded, so it no longer counts as part of your hand.",
"Magic Stones They may be discarded as cards even after used and displayed. Nothing makes an object so permanent that it cannot be discarded. This is always a player option. MAGIC STONES are not \"created\" by the card, the card merely represents the physical object. It may be displayed and left displayed whenever you use it first, even if it is not your turn.",
],
},
{
title: "Magic Sticks",
body: [
"These don't have to be displayed. When you first charge it with a NUMBER card, the NUMBER card gets discarded, so it no longer counts as part of your hand.",
],
},
{
title: "Magic Stones",
body: [
"They may be discarded as cards even after used and displayed. Nothing makes an object so permanent that it cannot be discarded. This is always a player option. MAGIC STONES are not \"created\" by the card, the card merely represents the physical object. It may be displayed and left displayed whenever you use it first, even if it is not your turn.",
],
},
{
@@ -52,16 +67,30 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Picking Up Objects",
body: [
"No, you can't pick up 2 objects at the same time, even if they're in the same space.",
"Players as Objects There is some confusion in the rules which will probably not be resolved until next edition as to whether Wizards and MONSTERS can be treated as objects or not, for purposes of spells such as DRAG and a few others. Unfortunately, this is not well defined anywhere. The basic rules state that players are NOT objects, so spells such as TELEPORT OBJECT and ANIMATE OBJECT would have no effect on Wizards. Use common sense and house rules. You will rarely run into this as a problem, but you could. Obviously, if a card says a MONSTER can carry objects, it is NOT referring to other Wizards!",
"Spell Priority There is some question as to whether Counteractions or Attacks work first, especially in a case like SHIELDSTONE vs FIREBALL. Counteractions always work first. Thus, if someone was hit with a FIREBALL while they were carrying a BLOODSTONE, the BLOODSTONE would reduce the damage done by a point before it was destroyed. If you have 2 Counteractions you want to use, you decide for yourself which one activates first. If a spell gets counteracted, the number card (if any) that accompanied it is also discarded.",
"Throwing Objects The basic rules imply, without explicitly stating, that you can throw objects, but not treasures. That's true! You could take a Magic Stone and throw it inot a PIT 3 spaces away, if you wanted. Throwing objects at other players does not do any damage, unless a card, such as HEAVE-HO or DAGGER says so.",
],
},
{
title: "Players as Objects",
body: [
"There is some confusion in the rules which will probably not be resolved until next edition as to whether Wizards and MONSTERS can be treated as objects or not, for purposes of spells such as DRAG and a few others. Unfortunately, this is not well defined anywhere. The basic rules state that players are NOT objects, so spells such as TELEPORT OBJECT and ANIMATE OBJECT would have no effect on Wizards. Use common sense and house rules. You will rarely run into this as a problem, but you could. Obviously, if a card says a MONSTER can carry objects, it is NOT referring to other Wizards!",
],
},
{
title: "Spell Priority",
body: [
"There is some question as to whether Counteractions or Attacks work first, especially in a case like SHIELDSTONE vs FIREBALL. Counteractions always work first. Thus, if someone was hit with a FIREBALL while they were carrying a BLOODSTONE, the BLOODSTONE would reduce the damage done by a point before it was destroyed. If you have 2 Counteractions you want to use, you decide for yourself which one activates first. If a spell gets counteracted, the number card (if any) that accompanied it is also discarded.",
],
},
{
title: "Throwing Objects",
body: [
"The basic rules imply, without explicitly stating, that you can throw objects, but not treasures. That's true! You could take a Magic Stone and throw it inot a PIT 3 spaces away, if you wanted. Throwing objects at other players does not do any damage, unless a card, such as HEAVE-HO or DAGGER says so.",
],
},
{
title: "Treasures",
body: [
"If a player is eliminated, any treasure left on his home base does NOT count toward eliminating the player to whom that treasure belongs. Also, the 7th edition rules are rather badly worded and imply that you can carry 2 treasures as long as one of them is your own and the other belongs to another player. This is incorrect. You can carry only ONE treasure, and it can be EITHER one of your own or one of the opponent's.",
"Card FAQ's for Wiz-War",
],
},
];
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"absorb"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<linearGradient id={`absorb-shine-${uid}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fffdf4" />
<stop offset="0.5" stop-color="#f6f0df" />
<stop offset="1" stop-color="#d8ccb0" />
</linearGradient>
</defs>
<circle cx={c.x} cy={c.y} r="14" class="pull" />
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2"
fill={`url(#absorb-shine-${uid})`} class="card"
style={`transform-origin: ${c.x}px ${c.y}px`} />
<style>
.card {
stroke: #43331f;
stroke-width: 1.5;
animation: swallow 0.65s ease-in forwards;
}
.pull {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: rgba(180, 138, 224, 0.6);
stroke-width: 2;
stroke-dasharray: 5 6;
animation: pull-in 0.6s ease-in forwards;
}
@keyframes swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(140deg); }
}
@keyframes pull-in {
0% { opacity: 0.8; transform: scale(1.6) rotate(0deg); }
100% { opacity: 0; transform: scale(0.2) rotate(-90deg); }
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import type { FxOf } from "../fx";
let { fx }: { fx: FxOf<"bolt"> } = $props();
const uid = $props.id();
/** A fresh jagged path every strike, plus two forks off mid-joints. */
const geom = $derived.by(() => {
const a = fx.a, b = fx.b;
const segs = 6;
const joints: { x: number; y: number }[] = [a];
for (let i = 1; i < segs; i++) {
const t = i / segs;
joints.push({
x: a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16,
y: a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16,
});
}
joints.push(b);
const points = joints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
const fork = (j: { x: number; y: number }) => {
const dx = (Math.random() - 0.5) * 26, dy = (Math.random() - 0.5) * 26;
return `M ${j.x} ${j.y} l ${dx / 2} ${dy / 2} l ${dx / 2 + (Math.random() - 0.5) * 8} ${dy / 2}`;
};
return { points, forks: [fork(joints[2]!), fork(joints[4]!)] };
});
</script>
<defs>
<filter id={`bolt-glow-${uid}`} x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<g class="bolt" filter={`url(#bolt-glow-${uid})`}>
<polyline points={geom.points} class="wide" />
{#each geom.forks as d, i (i)}
<path {d} class="fork" />
{/each}
<polyline points={geom.points} class="core" />
</g>
<style>
.bolt .wide {
fill: none;
stroke: #e6a812;
stroke-width: 5.5;
stroke-linejoin: round;
opacity: 0.75;
}
.bolt .fork {
fill: none;
stroke: #f5d54a;
stroke-width: 1.6;
stroke-linejoin: round;
}
.bolt .core {
fill: none;
stroke: #fffdf0;
stroke-width: 2;
stroke-linejoin: round;
}
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
@keyframes bolt-flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
60% { opacity: 1; } 100% { opacity: 0; }
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"burst"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const EMBERS = [15, 80, 145, 210, 275, 340];
</script>
<defs>
<radialGradient id={`burst-fire-${uid}`}>
<stop offset="0" stop-color="#fffde0" />
<stop offset="0.3" stop-color="#ffd34e" />
<stop offset="0.65" stop-color="#ff8c1a" stop-opacity="0.85" />
<stop offset="1" stop-color="#ef3b08" stop-opacity="0" />
</radialGradient>
</defs>
<circle cx={c.x} cy={c.y} r="16" fill={`url(#burst-fire-${uid})`} class="bloom" />
<circle cx={c.x} cy={c.y} r="6" class="shockwave" />
{#each EMBERS as deg (deg)}
<circle
cx={c.x + 13 * Math.cos((deg * Math.PI) / 180)}
cy={c.y + 13 * Math.sin((deg * Math.PI) / 180)}
r={deg % 2 === 0 ? 2 : 1.5}
class="ember"
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.bloom {
transform-box: fill-box;
transform-origin: center;
animation: bloom 0.5s ease-out forwards;
}
.shockwave {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #ffd27a;
stroke-width: 2.5;
animation: shockwave 0.45s ease-out 0.08s forwards;
opacity: 0;
}
.ember {
fill: #ff9d19;
animation: ember-fly 0.55s ease-out forwards;
}
@keyframes bloom {
0% { opacity: 0; transform: scale(0.2); }
25% { opacity: 1; transform: scale(1.2); }
100% { opacity: 0; transform: scale(2.4); }
}
@keyframes shockwave {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(6); }
}
@keyframes ember-fly {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(2.1) rotate(24deg); }
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"chaos-swirl"> } = $props();
const c = $derived(center(fx.at));
const CARDS = [0, 72, 144, 216, 288];
</script>
<g class="chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
<circle cx={c.x} cy={c.y} r="30" class="ring inner" />
<circle cx={c.x} cy={c.y} r="55" class="ring mid" />
<circle cx={c.x} cy={c.y} r="80" class="ring outer" />
{#each CARDS as deg (deg)}
<rect
x={c.x + 46 * Math.cos((deg * Math.PI) / 180) - 5}
y={c.y + 46 * Math.sin((deg * Math.PI) / 180) - 7}
width="10" height="14" rx="1.5"
class="slip"
transform={`rotate(${deg + 25} ${c.x + 46 * Math.cos((deg * Math.PI) / 180)} ${c.y + 46 * Math.sin((deg * Math.PI) / 180)})`}
/>
{/each}
</g>
<style>
.ring {
fill: none;
stroke-width: 4;
stroke-dasharray: 30 22;
}
.ring.inner { stroke: #b48ae0; }
.ring.mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
.ring.outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
.slip {
fill: #f6f0df;
stroke: #43331f;
stroke-width: 1;
}
.chaos { animation: chaos-spin 1.4s ease-in-out forwards; }
@keyframes chaos-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
20% { opacity: 1; }
100% { opacity: 0; transform: rotate(220deg) scale(1.4); }
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"claw"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const GASHES = [
{ x: -12, cls: "g0" },
{ x: -3, cls: "g1" },
{ x: 6, cls: "g2" },
];
</script>
<defs>
<linearGradient id={`claw-cut-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e0604f" />
<stop offset="0.5" stop-color="#b3372b" />
<stop offset="1" stop-color="#6e1a12" />
</linearGradient>
</defs>
<g class="claw">
{#each GASHES as g (g.cls)}
<g class={`gash ${g.cls}`} style={`transform-origin: ${c.x + g.x}px ${c.y - 15}px`}>
<path
d={`M ${c.x + g.x} ${c.y - 14} q 4 12 10 26 q -1 1 -2 0 q -7 -13 -11 -25 z`}
fill={`url(#claw-cut-${uid})`}
/>
<path
d={`M ${c.x + g.x} ${c.y - 14} q 4 12 10 26`}
class="edge" pathLength="100"
/>
</g>
{/each}
</g>
<style>
/* Each slash reveals downward along its own length, in sequence,
while the whole strike rakes a little sideways. */
.gash { opacity: 0; animation: gash-cut 0.4s ease-out forwards; }
.gash.g1 { animation-delay: 0.06s; }
.gash.g2 { animation-delay: 0.12s; }
.edge {
fill: none;
stroke: #ffd9c4;
stroke-width: 1.4;
stroke-dasharray: 100 100;
stroke-dashoffset: 100;
animation: edge-draw 0.28s ease-out forwards;
}
.gash.g1 .edge { animation-delay: 0.06s; }
.gash.g2 .edge { animation-delay: 0.12s; }
.claw { animation: rake 0.55s ease-out forwards; }
@keyframes gash-cut {
0% { opacity: 0; transform: scaleY(0.1); }
30% { opacity: 1; transform: scaleY(1.05); }
100% { opacity: 0.9; transform: scaleY(1); }
}
@keyframes edge-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
70% { stroke-dashoffset: 0; opacity: 0.9; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes rake {
0% { opacity: 1; transform: translate(2px, -3px); }
60% { opacity: 1; transform: translate(-2px, 2px); }
100% { opacity: 0; transform: translate(-3px, 3px); }
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"die-drop"> } = $props();
const c = $derived(center(fx.at));
const aim = $derived(center(fx.aim));
const drifted = $derived(fx.at.x !== fx.aim.x || fx.at.y !== fx.aim.y);
const DEBRIS = [15, 80, 150, 210, 275, 340];
</script>
<!-- The aiming mark: where the caster prayed it would land -->
<circle cx={aim.x} cy={aim.y} r="15" class="aim" />
{#if drifted}
<line x1={aim.x} y1={aim.y} x2={c.x} y2={c.y} class="drift" />
{/if}
<!-- The gathering shadow, then the die itself, out of a clear sky -->
<ellipse cx={c.x} cy={c.y + 8} rx="16" ry="7" class="die-shadow" />
<g class="die" style={`transform-origin: ${c.x}px ${c.y}px`}>
<g transform={`translate(${c.x} ${c.y})`}>
<rect x="-15" y="-15" width="30" height="30" rx="6" class="die-body" />
<!-- a four: the drift roll made manifest -->
<circle cx="-7" cy="-7" r="3.2" class="pip" />
<circle cx="7" cy="-7" r="3.2" class="pip" />
<circle cx="-7" cy="7" r="3.2" class="pip" />
<circle cx="7" cy="7" r="3.2" class="pip" />
</g>
</g>
<!-- Impact: ring and flung grit -->
<circle cx={c.x} cy={c.y} r="14" class="impact" />
{#each DEBRIS as deg, i (deg)}
<line
x1={c.x + 14 * Math.cos((deg * Math.PI) / 180)}
y1={c.y + 14 * Math.sin((deg * Math.PI) / 180)}
x2={c.x + 26 * Math.cos((deg * Math.PI) / 180)}
y2={c.y + 26 * Math.sin((deg * Math.PI) / 180)}
class={`grit g${i % 3}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.aim {
fill: none;
stroke: #8d2f23;
stroke-width: 2;
stroke-dasharray: 5 4;
opacity: 0;
animation: aim-mark 0.5s ease-out forwards;
}
.drift {
stroke: #8d2f23;
stroke-width: 1.5;
stroke-dasharray: 3 4;
opacity: 0;
animation: drift-line 0.5s ease-out 0.55s forwards;
}
.die-shadow {
fill: rgba(43, 34, 24, 0.5);
animation: shadow-loom 0.55s ease-in forwards;
transform-box: fill-box;
transform-origin: center;
}
.die {
animation: die-fall 0.55s cubic-bezier(0.5, 0, 0.9, 0.6) forwards,
die-settle 0.9s ease-out 0.55s forwards;
}
.die-body {
fill: #efe8d4;
stroke: #2b2218;
stroke-width: 2.5;
}
.pip { fill: #2b2218; }
.impact {
fill: none;
stroke: #7c6a4f;
stroke-width: 3;
opacity: 0;
transform-box: fill-box;
transform-origin: center;
animation: impact-ring 0.5s ease-out 0.55s forwards;
}
.grit {
stroke: #7c6a4f;
stroke-width: 2;
stroke-linecap: round;
opacity: 0;
animation: grit-fly 0.4s ease-out 0.58s forwards;
}
.grit.g1 { animation-delay: 0.62s; }
.grit.g2 { animation-delay: 0.66s; }
@keyframes aim-mark {
0% { opacity: 0; transform: scale(1.6); transform-origin: center; transform-box: fill-box; }
100% { opacity: 0.8; transform: scale(1); }
}
@keyframes drift-line {
0% { opacity: 0; }
100% { opacity: 0.7; }
}
@keyframes shadow-loom {
0% { opacity: 0; transform: scale(0.2); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes die-fall {
0% { opacity: 0; transform: translateY(-150px) scale(2.4) rotate(35deg); }
30% { opacity: 1; }
100% { transform: translateY(0) scale(1) rotate(0deg); }
}
@keyframes die-settle {
0% { transform: translateY(0) scale(1.15, 0.85); }
25% { transform: translateY(-6px) scale(1); }
45% { transform: translateY(0) scale(1.05, 0.95); }
100% { transform: translateY(0) scale(1); }
}
@keyframes impact-ring {
0% { opacity: 0.9; transform: scale(0.4); }
100% { opacity: 0; transform: scale(2.4); }
}
@keyframes grit-fly {
0% { opacity: 0.9; transform: scale(0.7); }
100% { opacity: 0; transform: scale(1.8); }
}
</style>
@@ -0,0 +1,63 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, center } from "./geom";
let { fx }: { fx: FxOf<"dust-puff" | "edge-dust"> } = $props();
const uid = $props.id();
const c = $derived.by(() => {
if (fx.kind === "dust-puff") return center(fx.at);
const mid = center(fx.cell);
if (fx.side === "N") return { x: mid.x, y: fx.cell.y * CELL };
if (fx.side === "S") return { x: mid.x, y: (fx.cell.y + 1) * CELL };
if (fx.side === "W") return { x: fx.cell.x * CELL, y: mid.y };
return { x: (fx.cell.x + 1) * CELL, y: mid.y };
});
/** Edge dust blows perpendicular to the wall; loose dust just rises. */
const away = $derived.by(() => {
if (fx.kind !== "edge-dust") return { x: 0, y: -12 };
return fx.side === "N" || fx.side === "S" ? { x: 11, y: -4 } : { x: 4, y: -11 };
});
</script>
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff" />
<circle cx={c.x + 5} cy={c.y - 2} r="5" fill={`url(#dust-soft-${uid})`} class="puff back late" />
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff later" />
<g class="grit">
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
<circle cx={c.x + 1} cy={c.y - 2} r="0.9" />
</g>
</g>
<defs>
<radialGradient id={`dust-soft-${uid}`}>
<stop offset="0" stop-color="#96876a" stop-opacity="1" />
<stop offset="0.75" stop-color="#7d7358" stop-opacity="0.7" />
<stop offset="1" stop-color="#8d8266" stop-opacity="0" />
</radialGradient>
</defs>
<style>
.puff {
transform-box: fill-box;
transform-origin: center;
animation: blow-a 0.8s ease-out forwards;
}
.puff.back { animation-name: blow-b; }
.puff.late { animation-delay: 0.08s; }
.puff.later { animation-delay: 0.16s; }
.grit circle { fill: #4a4232; animation: grit-fall 0.6s ease-in forwards; }
@keyframes blow-a {
0% { opacity: 1; transform: translate(0, 0) scale(0.8); }
100% { opacity: 0; transform: translate(var(--ax), var(--ay)) scale(2); }
}
@keyframes blow-b {
0% { opacity: 1; transform: translate(0, 0) scale(0.8); }
100% { opacity: 0; transform: translate(var(--bx), var(--by)) scale(1.9); }
}
@keyframes grit-fall {
0% { opacity: 1; transform: translateY(-3px); }
100% { opacity: 0; transform: translateY(8px); }
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import type { FxOf } from "../fx";
let { fx }: { fx: FxOf<"fireball"> } = $props();
const uid = $props.id();
const a = $derived(fx.a);
const b = $derived(fx.b);
</script>
<defs>
<radialGradient id={`fireball-core-${uid}`}>
<stop offset="0" stop-color="#fffde0" />
<stop offset="0.28" stop-color="#fff09a" />
<stop offset="0.62" stop-color="#ffad22" />
<stop offset="1" stop-color="#ef3b08" stop-opacity="0.15" />
</radialGradient>
<linearGradient id={`fireball-tail-${uid}`} x1="1" x2="0">
<stop offset="0" stop-color="#fff2a1" stop-opacity="0.95" />
<stop offset="0.2" stop-color="#ff9d16" stop-opacity="0.86" />
<stop offset="0.62" stop-color="#ee3907" stop-opacity="0.42" />
<stop offset="1" stop-color="#581002" stop-opacity="0" />
</linearGradient>
<filter id={`fireball-warp-${uid}`} x="-60%" y="-160%" width="230%" height="420%">
<feTurbulence type="fractalNoise" baseFrequency="0.022 0.12" numOctaves="3" seed="7" result="noise">
<animate
attributeName="baseFrequency"
values="0.022 0.12; 0.035 0.17; 0.018 0.1"
dur="0.22s"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="14" xChannelSelector="R" yChannelSelector="B" />
<feGaussianBlur stdDeviation="1.4" />
</filter>
<filter id={`fireball-glow-${uid}`} x="-180%" y="-180%" width="460%" height="460%">
<feGaussianBlur stdDeviation="7" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g class="fireball">
<g>
<!-- Outer orange glow -->
<ellipse cx="-4" cy="0" rx="27" ry="16" fill="#ff5b0a" opacity="0.32" filter={`url(#fireball-glow-${uid})`} />
<!-- Turbulent outer flame -->
<path
d="M -8 -13 C -30 -18, -52 -8, -91 -3 C -60 1, -68 14, -98 20 C -54 17, -28 11, -7 12 Z"
fill={`url(#fireball-tail-${uid})`}
filter={`url(#fireball-warp-${uid})`}
/>
<!-- Hot inner flame -->
<path
d="M -6 -8 C -24 -10, -42 -4, -64 1 C -39 2, -32 8, -10 8 Z"
fill="#ffd34e"
opacity="0.74"
filter={`url(#fireball-warp-${uid})`}
/>
<!-- Fireball body -->
<circle r="15" fill="#ff5c09" opacity="0.72" filter={`url(#fireball-glow-${uid})`} />
<circle r="12" fill={`url(#fireball-core-${uid})`} />
<!-- White-hot highlight -->
<ellipse cx="4" cy="-3" rx="6" ry="5" fill="#fffde7" opacity="0.95" />
<!-- Sparks -->
<g fill="#ff9d19">
<circle cx="-28" cy="-17" r="2.2">
<animate attributeName="cy" values="-17; -23; -17" dur="0.16s" repeatCount="indefinite" />
</circle>
<circle cx="-46" cy="12" r="1.6">
<animate attributeName="cy" values="12; 19; 12" dur="0.2s" repeatCount="indefinite" />
</circle>
<circle cx="-70" cy="-7" r="1.4">
<animate attributeName="opacity" values="1; 0.1; 1" dur="0.14s" repeatCount="indefinite" />
</circle>
</g>
</g>
<animateMotion dur="0.42s" fill="freeze" rotate="auto" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</g>
<style>
.fireball {
opacity: 0;
animation: fireball-fade 0.55s ease-in forwards;
}
@keyframes fireball-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireworks"> } = $props();
const c = $derived(center(fx.at));
const COLORS = ["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"];
// Deliberately uneven: a real burst has no protractor.
const RAYS = [
{ deg: 8, len: 22, r: 2.8, cls: "" },
{ deg: 52, len: 17, r: 2.2, cls: "late" },
{ deg: 88, len: 24, r: 3, cls: "" },
{ deg: 141, len: 16, r: 2, cls: "later" },
{ deg: 176, len: 21, r: 2.6, cls: "late" },
{ deg: 224, len: 18, r: 2.3, cls: "" },
{ deg: 267, len: 23, r: 2.8, cls: "later" },
{ deg: 309, len: 15, r: 2, cls: "late" },
];
</script>
<g class="fireworks">
{#each RAYS as ray, i (ray.deg)}
{@const dx = Math.cos((ray.deg * Math.PI) / 180)}
{@const dy = Math.sin((ray.deg * Math.PI) / 180)}
<line
x1={c.x + 4 * dx} y1={c.y + 4 * dy}
x2={c.x + ray.len * dx} y2={c.y + ray.len * dy}
stroke={COLORS[i % 4]}
class={`trail ${ray.cls}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
<circle
cx={c.x + (ray.len + 1) * dx} cy={c.y + (ray.len + 1) * dy} r={ray.r}
fill={COLORS[i % 4]}
class={`spark ${ray.cls}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<circle cx={c.x} cy={c.y} r="3.5" class="heart" />
</g>
<style>
.trail {
stroke-width: 2;
stroke-linecap: round;
animation: trail-out 0.9s ease-out forwards;
}
.spark { animation: spark-out 1.05s ease-out forwards; }
.trail.late, .spark.late { animation-delay: 0.07s; }
.trail.later, .spark.later { animation-delay: 0.14s; }
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; }
@keyframes trail-out {
0% { opacity: 0; transform: scale(0.15); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(1.5); }
}
@keyframes spark-out {
0% { opacity: 0; transform: scale(0.15); }
30% { opacity: 1; }
80% { opacity: 0.9; transform: scale(1.55); }
100% { opacity: 0; transform: scale(1.6) translateY(4px); }
}
@keyframes heart-pop {
0% { opacity: 1; transform: scale(0.5); }
100% { opacity: 0; transform: scale(3); }
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"hit"> } = $props();
const c = $derived(center(fx.at));
const DEBRIS = [40, 165, 285];
</script>
<!-- Contact flash: an instant of white -->
<path
d={`M ${c.x} ${c.y - 9} l 2.5 6 6.5 -2 -3.5 5.5 5.5 3.5 -6.5 1 1 6.5 -5.5 -4.5 -4.5 5 0 -7 -6.5 0.5 5 -4.5 -4 -5 6.5 1.5 z`}
class="flash" style={`transform-origin: ${c.x}px ${c.y}px`}
/>
<!-- Compressed impact ring: squashed, then out -->
<ellipse cx={c.x} cy={c.y} rx="9" ry="6" class="ring" />
{#each DEBRIS as deg, i (deg)}
<line
x1={c.x + 8 * Math.cos((deg * Math.PI) / 180)}
y1={c.y + 8 * Math.sin((deg * Math.PI) / 180)}
x2={c.x + 13 * Math.cos((deg * Math.PI) / 180)}
y2={c.y + 13 * Math.sin((deg * Math.PI) / 180)}
class={`debris d${i}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.flash {
fill: #fffdf0;
stroke: #c0392b;
stroke-width: 1;
animation: flash-snap 0.28s ease-out forwards;
}
.ring {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c0392b;
stroke-width: 3;
animation: ring-punch 0.4s cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
}
.debris {
stroke: #7c221a;
stroke-width: 2;
stroke-linecap: round;
opacity: 0;
animation: debris-fly 0.35s ease-out 0.05s forwards;
}
.debris.d1 { animation-delay: 0.08s; }
.debris.d2 { animation-delay: 0.11s; }
@keyframes flash-snap {
0% { opacity: 1; transform: scale(0.4); }
35% { opacity: 1; transform: scale(1.25); }
100% { opacity: 0; transform: scale(0.9); }
}
@keyframes ring-punch {
0% { opacity: 0.95; transform: scale(0.5, 0.35); }
45% { opacity: 0.9; transform: scale(1.6, 1.3); }
100% { opacity: 0; transform: scale(2.2, 1.9); }
}
@keyframes debris-fly {
0% { opacity: 0.9; transform: scale(0.8); }
100% { opacity: 0; transform: scale(1.7); }
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"ooze-slip"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`ooze-splat-${uid}`}>
<stop offset="0" stop-color="#a5cc60" stop-opacity="0.8" />
<stop offset="0.7" stop-color="#6ea03c" stop-opacity="0.55" />
<stop offset="1" stop-color="#4a7024" stop-opacity="0" />
</radialGradient>
</defs>
<ellipse cx={c.x} cy={c.y + 8} rx="14" ry="5.5" fill={`url(#ooze-splat-${uid})`} class="splat" />
<g class="blobs">
<circle cx={c.x - 12} cy={c.y + 3} r="2.5" class="blob" />
<circle cx={c.x + 11} cy={c.y + 2} r="2" class="blob late" />
<circle cx={c.x + 3} cy={c.y - 3} r="1.7" class="blob later" />
</g>
<style>
.splat {
transform-box: fill-box;
transform-origin: center;
animation: splat-wobble 0.7s ease-out forwards;
}
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; }
.blob.late { animation-delay: 0.06s; }
.blob.later { animation-delay: 0.12s; }
@keyframes splat-wobble {
0% { opacity: 0.95; transform: scale(0.5, 1); }
35% { transform: scale(1.3, 0.85); }
65% { transform: scale(0.9, 1.05); }
100% { opacity: 0; transform: scale(1.15, 0.95); }
}
@keyframes blob-fly {
0% { opacity: 0.9; transform: translateY(0); }
50% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(2px); }
}
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pit-fall"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`pit-hole-${uid}`} cy="0.42">
<stop offset="0" stop-color="#1a130a" />
<stop offset="0.55" stop-color="#3a2c1a" stop-opacity="0.9" />
<stop offset="0.85" stop-color="#5f4a33" stop-opacity="0.55" />
<stop offset="1" stop-color="#5f4a33" stop-opacity="0" />
</radialGradient>
</defs>
<ellipse cx={c.x} cy={c.y} rx="14" ry="11" fill={`url(#pit-hole-${uid})`} class="hole" />
<!-- The far lip catches the light: reads as depth, not a dot -->
<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`} />
<g class="rim-dust">
<circle cx={c.x - 11} cy={c.y - 6} r="3" class="dust" />
<circle cx={c.x + 10} cy={c.y - 7} r="2.5" class="dust late" />
<circle cx={c.x + 1} cy={c.y - 11} r="2" class="dust later" />
</g>
<style>
.hole {
transform-box: fill-box;
transform-origin: center;
animation: hole-open 0.75s ease-out forwards;
}
.faller {
fill: rgba(120, 100, 70, 0.85);
animation: fall-in 0.55s ease-in 0.06s forwards;
}
.lip {
fill: none;
stroke: rgba(233, 225, 203, 0.7);
stroke-width: 1.6;
stroke-linecap: round;
animation: hole-open 0.75s ease-out forwards;
}
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; }
.dust.late { animation-delay: 0.24s; }
.dust.later { animation-delay: 0.32s; }
@keyframes hole-open {
0% { opacity: 0; transform: scale(0.3); }
25% { opacity: 1; transform: scale(1); }
80% { opacity: 1; }
100% { opacity: 0; transform: scale(1); }
}
@keyframes fall-in {
0% { opacity: 1; transform: scale(1) translateY(-6px); }
100% { opacity: 0; transform: scale(0.1) translateY(4px) rotate(60deg); }
}
@keyframes dust-drift {
0% { opacity: 0; transform: translateY(0) scale(1); }
30% { opacity: 0.8; }
100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL } from "./geom";
let { fx }: { fx: FxOf<"portal" | "portal-cell"> } = $props();
const uid = $props.id();
</script>
{#if fx.kind === "portal"}
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
<line {x1} {y1} {x2} {y2} class="curtain glow" />
<line {x1} {y1} {x2} {y2} class="curtain" pathLength="100" />
{:else}
<defs>
<radialGradient id={`portal-void-${uid}`}>
<stop offset="0" stop-color="#0d2430" stop-opacity="0.85" />
<stop offset="0.6" stop-color="#12414a" stop-opacity="0.5" />
<stop offset="1" stop-color="#9be3e0" stop-opacity="0.1" />
</radialGradient>
</defs>
<rect x={fx.at.x * CELL + 5} y={fx.at.y * CELL + 5}
width={CELL - 10} height={CELL - 10} rx="8"
fill={`url(#portal-void-${uid})`} class="void"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
{@const vx = fx.at.x * CELL + CELL / 2}
{@const vy = fx.at.y * CELL + CELL / 2}
<g class="swirl" style={`transform-origin: ${vx}px ${vy}px`}>
<path d={`M ${vx + 12} ${vy} A 12 12 0 0 1 ${vx - 6} ${vy + 10}`} class="arm" />
<path d={`M ${vx - 12} ${vy} A 12 12 0 0 1 ${vx + 6} ${vy - 10}`} class="arm" />
<path d={`M ${vx} ${vy + 7} A 7 7 0 0 1 ${vx - 6} ${vy - 4}`} class="arm faint" />
</g>
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="veil outer"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="veil inner"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
{/if}
<style>
/* The threshold opens, light ripples along it, and it pinches shut. */
.curtain {
stroke: #9be3e0;
stroke-dasharray: 6 5;
stroke-linecap: round;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
animation: open-ripple-pinch 0.95s ease-in-out forwards;
}
.curtain.glow {
stroke: rgba(180, 138, 224, 0.5);
stroke-dasharray: none;
filter: blur(2px);
animation: glow-swell 0.95s ease-in-out forwards;
}
@keyframes open-ripple-pinch {
0% { opacity: 0; stroke-width: 0.5; stroke-dashoffset: 0; }
22% { opacity: 1; stroke-width: 6; }
60% { stroke-width: 4.5; stroke-dashoffset: 22; }
88% { opacity: 0.9; stroke-width: 1.5; stroke-dashoffset: 34; }
100% { opacity: 0; stroke-width: 0.5; stroke-dashoffset: 38; }
}
@keyframes glow-swell {
0% { opacity: 0; stroke-width: 2; }
25% { opacity: 0.85; stroke-width: 14; }
70% { opacity: 0.6; stroke-width: 9; }
100% { opacity: 0; stroke-width: 2; }
}
/* Cell mouths: rings of energy collapse inward through the token. */
.veil {
fill: none;
stroke: #9be3e0;
stroke-dasharray: 7 5;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
}
.void { animation: void-open 0.9s ease-out forwards; }
.arm {
fill: none;
stroke: rgba(155, 227, 224, 0.7);
stroke-width: 1.8;
stroke-linecap: round;
}
.arm.faint { stroke: rgba(180, 138, 224, 0.55); }
.swirl { animation: swirl-turn 0.9s ease-in forwards; }
@keyframes swirl-turn {
0% { opacity: 0; transform: rotate(0deg) scale(1.1); }
25% { opacity: 1; }
100% { opacity: 0; transform: rotate(160deg) scale(0.4); }
}
.veil.outer { stroke-width: 2.5; animation: veil-in 0.9s ease-in forwards; }
.veil.inner {
stroke: rgba(180, 138, 224, 0.8);
stroke-width: 2;
animation: veil-in 0.9s ease-in 0.2s forwards;
opacity: 0;
}
@keyframes void-open {
0% { opacity: 0; transform: scale(0.4); }
30% { opacity: 1; transform: scale(1); }
75% { opacity: 0.9; transform: scale(0.92); }
100% { opacity: 0; transform: scale(0.5); }
}
@keyframes veil-in {
0% { opacity: 0; transform: scale(1.25); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(0.45); }
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pow"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const DASHES = [20, 95, 160, 250, 320];
</script>
<defs>
<radialGradient id={`pow-star-${uid}`}>
<stop offset="0" stop-color="#fffdf0" />
<stop offset="0.45" stop-color="#ffe94d" />
<stop offset="1" stop-color="#ffb02e" />
</radialGradient>
</defs>
<g class="pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path
d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`}
fill={`url(#pow-star-${uid})`}
/>
<circle cx={c.x} cy={c.y} r="4" class="flash" />
</g>
{#each DASHES as deg (deg)}
<line
x1={c.x + 16 * Math.cos((deg * Math.PI) / 180)}
y1={c.y + 16 * Math.sin((deg * Math.PI) / 180)}
x2={c.x + 24 * Math.cos((deg * Math.PI) / 180)}
y2={c.y + 24 * Math.sin((deg * Math.PI) / 180)}
class="dash"
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.pow path { stroke: #b3372b; stroke-width: 1.6; }
.pow .flash { fill: #ffffff; animation: flash 0.3s ease-out forwards; }
.pow { animation: pow-hit 0.5s ease-out forwards; }
.dash {
stroke: #b3372b;
stroke-width: 2.5;
stroke-linecap: round;
animation: dash-out 0.4s ease-out 0.06s forwards;
opacity: 0;
}
@keyframes pow-hit {
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
55% { transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(1.05); }
}
@keyframes flash {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(3); }
}
@keyframes dash-out {
0% { opacity: 0; transform: scale(0.7); }
30% { opacity: 0.9; }
100% { opacity: 0; transform: scale(1.3); }
}
</style>
@@ -0,0 +1,131 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, SECTOR } from "./geom";
let { fx }: { fx: FxOf<"sector-spin" | "sector-slide"> } = $props();
const S = SECTOR * CELL;
const origin = $derived(fx.kind === "sector-spin" ? fx.origin : fx.from);
const ox = $derived(origin.x * CELL);
const oy = $derived(origin.y * CELL);
const cx = $derived(ox + S / 2);
const cy = $derived(oy + S / 2);
/** Slide direction, unit-ish, for the chevron train. */
const dir = $derived.by(() => {
if (fx.kind !== "sector-slide") return { x: 0, y: 0 };
const dx = fx.to.x - fx.from.x, dy = fx.to.y - fx.from.y;
const m = Math.max(1, Math.hypot(dx, dy));
return { x: dx / m, y: dy / m };
});
const CORNERS = $derived([
{ x: ox + 6, y: oy + 6 }, { x: ox + S - 6, y: oy + 6 },
{ x: ox + 6, y: oy + S - 6 }, { x: ox + S - 6, y: oy + S - 6 },
]);
</script>
{#if fx.kind === "sector-spin"}
<g class={`grind ${fx.clockwise ? "cw" : "ccw"}`} style={`transform-origin: ${cx}px ${cy}px`}>
<rect x={ox + 2} y={oy + 2} width={S - 4} height={S - 4} class="frame" />
<!-- Curved motion arcs at the corners, pointing the way around -->
{#each [45, 135, 225, 315] as deg (deg)}
<path
d={`M ${cx + (S / 2 - 14) * Math.cos(((deg - 16) * Math.PI) / 180)} ${cy + (S / 2 - 14) * Math.sin(((deg - 16) * Math.PI) / 180)}
A ${S / 2 - 14} ${S / 2 - 14} 0 0 ${fx.clockwise ? 1 : 0}
${cx + (S / 2 - 14) * Math.cos(((deg + 16) * Math.PI) / 180)} ${cy + (S / 2 - 14) * Math.sin(((deg + 16) * Math.PI) / 180)}`}
class="arc"
/>
{/each}
</g>
{:else}
<g class="slide-group" style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`}>
<rect x={ox + 2} y={oy + 2} width={S - 4} height={S - 4} class="frame slide" />
<!-- Chevron train pointing along the journey -->
{#each [0.3, 0.5, 0.7] as t, i (t)}
<path
d={`M ${cx + dir.x * S * (t - 0.08) - dir.y * 9} ${cy + dir.y * S * (t - 0.08) - dir.x * 9}
L ${cx + dir.x * S * t} ${cy + dir.y * S * t}
L ${cx + dir.x * S * (t - 0.08) + dir.y * 9} ${cy + dir.y * S * (t - 0.08) + dir.x * 9}`}
class={`chevron c${i}`}
/>
{/each}
</g>
{/if}
<g class="corner-dust">
{#each CORNERS as p, i (i)}
<circle cx={p.x} cy={p.y} r="3.5" class={`dust d${i}`} />
{/each}
</g>
<style>
.frame {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
}
.arc {
fill: none;
stroke: #b3691f;
stroke-width: 2.5;
stroke-linecap: round;
}
.chevron {
fill: none;
stroke: #b3691f;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.chevron.c0 { animation: chev 0.5s ease-out 0.15s forwards; }
.chevron.c1 { animation: chev 0.5s ease-out 0.3s forwards; }
.chevron.c2 { animation: chev 0.5s ease-out 0.45s forwards; }
.grind { animation: grind-settle-cw 1.25s ease-in-out forwards; }
.grind.ccw { animation-name: grind-settle-ccw; }
.slide-group { animation: slide-settle 1.25s ease-in-out forwards; }
.dust {
fill: rgba(160, 150, 130, 0.85);
opacity: 0;
animation: dust-kick 0.6s ease-out 0.85s forwards;
}
.dust.d1 { animation-delay: 0.9s; }
.dust.d2 { animation-delay: 0.95s; }
.dust.d3 { animation-delay: 1s; }
@keyframes chev {
0% { opacity: 0; }
35% { opacity: 0.9; }
100% { opacity: 0; }
}
/* The quarter-turn, then a grinding shudder as it settles. */
@keyframes grind-settle-cw {
0% { opacity: 0; transform: rotate(-90deg); }
12% { opacity: 1; }
72% { opacity: 1; transform: rotate(0.6deg); }
80% { transform: rotate(-0.8deg); }
87% { transform: rotate(0.4deg); }
93% { transform: rotate(0deg); }
100% { opacity: 0; transform: rotate(0deg); }
}
@keyframes grind-settle-ccw {
0% { opacity: 0; transform: rotate(90deg); }
12% { opacity: 1; }
72% { opacity: 1; transform: rotate(-0.6deg); }
80% { transform: rotate(0.8deg); }
87% { transform: rotate(-0.4deg); }
93% { transform: rotate(0deg); }
100% { opacity: 0; transform: rotate(0deg); }
}
@keyframes slide-settle {
0% { opacity: 0; transform: translate(0, 0); }
12% { opacity: 1; }
72% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
80% { transform: translate(calc(var(--dx) - 2px), var(--dy)); }
88% { transform: translate(calc(var(--dx) + 1px), var(--dy)); }
94% { transform: translate(var(--dx), var(--dy)); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
}
@keyframes dust-kick {
0% { opacity: 0; transform: translateY(0) scale(0.7); }
30% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
}
</style>

Some files were not shown because too many files have changed in this diff Show More