94 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
140 changed files with 6598 additions and 1969 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
+46 -2
View File
@@ -360,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 },
};
@@ -372,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];
@@ -420,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
+106 -7
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;
@@ -62,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>;
@@ -75,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. */
@@ -84,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 {
@@ -116,6 +136,7 @@ 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] : [],
@@ -137,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 } })),
@@ -146,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] })),
@@ -170,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;
@@ -179,11 +221,57 @@ 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([
@@ -280,8 +368,19 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
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") {
// Anything created — terrain or creature, whoever made it — in sight.
+355 -10
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";
@@ -117,8 +118,7 @@ describe("automaton vs automaton", () => {
});
});
describe("the clockwork does not waste counters on pointless targets", () => {
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: any, defender: string) => void) {
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++) {
@@ -131,8 +131,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
const human = state.players.find((p: { id: string }) => p.id === "human")!;
const bot = state.players.find((p: { id: string }) => p.id === "bot")!;
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 };
@@ -143,10 +143,11 @@ describe("the clockwork does not waste counters on pointless targets", () => {
});
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 = underAttack("drop-object", [
const state = underAttackShared("drop-object", [
{ instanceId: "absorb#T", cardId: "absorb" },
{ instanceId: "blunt#T", cardId: "blunt" },
{ instanceId: "dagger#T", cardId: "dagger" },
@@ -156,12 +157,12 @@ describe("the clockwork does not waste counters on pointless targets", () => {
});
it("shields a drop-object aimed at its carried treasure", () => {
const state = underAttack("drop-object", [
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: { id: string }) => p.id === defender)!;
const t = s.treasures.find((t: { owner: string }) => t.owner !== 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");
@@ -180,7 +181,7 @@ describe("a clogged hand gets shed, not hoarded", () => {
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: `illusion-wall#${i}`, cardId: "illusion-wall" }
{ 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++) {
@@ -203,3 +204,347 @@ describe("a clogged hand gets shed, not hoarded", () => {
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);
}
});
});
+76 -1
View File
@@ -9,7 +9,8 @@ import {
type SectorPlacement,
} from "../src/board";
import { buildDeck } from "../src/cards";
import { activePlayer, applyCommand, createGame } from "../src/game";
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";
@@ -240,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");
});
});
+208 -8
View File
@@ -250,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", () => {
@@ -906,3 +898,211 @@ describe("escapes and elemental walls as counteractions", () => {
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);
});
});
+238 -14
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 Cell, 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";
@@ -466,28 +466,23 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => {
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
remainingTurns: 3, data: {},
});
return { state, me, victim, trollId: bones.id };
return { state, me, victim, skeletonId: bones.id };
}
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
// Deterministic seed: this particular swing misses and the blow dissipates.
let { state, me, victim, trollId } = creatureVsInvisible(13);
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
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" });
const rolled = state.rng;
expect(rolled).toBeDefined();
const after = state.players.find((p) => p.id === victim.id)!;
const missed = after.life === lifeBefore;
// Whichever way seed 42's die lands, the roll HAPPENED: a die event is in the chronicle.
expect(missed || after.life < lifeBefore).toBe(true);
// 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, trollId } = creatureVsInvisible(12);
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: trollId, targetId: victim.id });
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.
@@ -526,3 +521,232 @@ describe("elimination sweeps the board either way (rules rev 14)", () => {
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);
});
});
+43 -2
View File
@@ -1,6 +1,6 @@
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";
@@ -212,3 +212,44 @@ describe("eligibility dimming mirrors the engine", () => {
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, createGame, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, type Cell } from "../src/board";
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";
@@ -435,3 +435,126 @@ describe("relocation past the origin (rules rev 11)", () => {
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();
});
});
+25 -6
View File
@@ -35,6 +35,7 @@ import {
getRoom,
joinRoom,
loadPersistedRooms,
runningRooms,
addAutomaton,
addChat,
driveOneAutomaton,
@@ -70,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).
@@ -174,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}`,
]),
),
};
}
@@ -193,22 +206,25 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
*/
const BOT_STEP_MS = 1000;
/** What a step's event means to this bot its own deed when it acted,
* its own suffering when somebody else did. Null: nothing worth a word. */
/** 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) return "deliverGold";
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 === "teleported" && e.player === seat && e.by === seat) return "escape";
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
if (e.type === "gameWon" && e.player === seat) return "win";
}
// 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";
@@ -245,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);
};
@@ -359,6 +377,7 @@ 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;
+7 -1
View File
@@ -54,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 = 14;
const RULES_REV = 36;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -91,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 = {
@@ -270,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] ??
@@ -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
+232 -209
View File
@@ -1,7 +1,15 @@
<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;
@@ -17,6 +25,8 @@
onCreatureClick,
onWarpClick,
onCellPeek,
onIllusionClick,
onEdgePeek,
markedCell = null,
markedCells = null,
litCells = null,
@@ -24,6 +34,7 @@
ghostSlots = null,
onGhostClick,
effects = null,
sightTrace = null,
}: {
view: GameView;
edgeSelectMode?: boolean;
@@ -35,6 +46,10 @@
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
@@ -49,11 +64,13 @@
ghostSlots?: { x: number; y: number }[] | null;
onGhostClick?: (origin: { x: number; y: number }) => void;
/** Short-lived spell flourishes; purely cosmetic. */
effects?: import("./fx").BoardFx[] | null;
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
@@ -88,24 +105,6 @@
// 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 {
@@ -119,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 {
@@ -145,7 +144,9 @@
// 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.openDoorEdges.includes(key) ? "ajar" : null))
? (view.doorStates[key] ??
(view.heldDoorEdges.includes(key) ? "held"
: view.openDoorEdges.includes(key) ? "ajar" : null))
: null;
return { kind, x, y, state, lock };
}),
@@ -163,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>();
@@ -180,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>();
@@ -253,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">
@@ -276,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" />
@@ -306,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}
@@ -335,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"
@@ -356,22 +412,18 @@
<!-- walls & doors & firewalls -->
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
{@const cls = e.state === "door" ? `door${e.lock ? ` ${e.lock}` : ""}` : e.state === "firewall" ? "firewall" : "wall"}
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
: e.lock === "jammed" ? "lock jammed — sealed for good"
: e.lock === "ajar" ? "unlocked until end of turn" : null}
{#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 lockTitle}<title>{lockTitle}</title>{/if}</rect>
{#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}
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
{@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}
@@ -383,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}
@@ -396,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}
@@ -468,11 +531,13 @@
/>
{/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}
<!-- 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); }}
@@ -484,87 +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"
class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
class:stone-gazed={hasSpell(p.id, "medusa")}
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={cx - half} y1={cy - half * 0.4} x2={cx + half} y2={cy + half * 0.5} />
<line x1={cx - half * 0.6} y1={cy + half} x2={cx + half * 0.7} y2={cy - half} />
<line x1={cx - half} y1={cy + half * 0.7} x2={cx + half} y2={cy - half * 0.2} />
<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 -->
@@ -587,11 +656,24 @@
{/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)}
{@const Sprite = FX_SPRITES[fx.kind]}
<Sprite fx={fx as never} />
<!-- 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>
@@ -619,33 +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;
}
/* 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; }
.firewall {
fill: #d0342c;
stroke: #7c1a14;
stroke-width: 1.2;
animation: firewall-lick 1.1s ease-in-out infinite alternate;
}
@keyframes firewall-lick {
from { fill: #d0342c; filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
to { fill: #ef6c3a; filter: drop-shadow(0 0 6px rgba(255, 140, 30, 0.9)); }
}
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
.rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; }
@@ -658,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;
@@ -740,67 +795,30 @@
.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 ------------ */
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
@keyframes ghost-breathe { 50% { opacity: 0.22; } }
.token-art.stone-gazed { filter: grayscale(0.9) brightness(0.8); }
: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;
}
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
.fx-dust.late { animation-delay: 0.09s; }
.fx-dust.later { animation-delay: 0.17s; }
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
@keyframes fx-flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
60% { opacity: 1; } 100% { opacity: 0; }
}
@keyframes fx-ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
@keyframes fx-ring-small {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(2.6); }
}
@keyframes fx-shield-pulse {
0% { opacity: 0; transform: scale(0.6); }
30% { opacity: 1; transform: scale(1.1); }
60% { transform: scale(0.95); }
100% { opacity: 0; transform: scale(1.15); }
}
@keyframes fx-drop {
0% { opacity: 0.9; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-14px); }
}
@keyframes fx-drift {
0% { opacity: 0.8; transform: translateY(0) scale(1); }
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
}
@keyframes fx-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 fx-claw-rake {
0% { opacity: 0; transform: translateY(-6px); }
20% { opacity: 1; }
100% { opacity: 0; transform: translateY(6px); }
}
@keyframes fx-swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
}
@keyframes fx-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
30% { opacity: 1; }
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
}
.warp-dest {
fill: none;
stroke: #2e7d32;
@@ -814,9 +832,10 @@
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
.marked-cell, .marked-sector, .ghost-slot, .warp-dest, .fear-aura { animation: none; }
.fx-layer { display: none; }
.firewall, .token-art.ghosted { animation: none; }
:global(.token-art.ghosted) { animation: none; }
.mover { transition: none; }
}
.wizard { cursor: pointer; }
.wizard-label {
@@ -825,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;
+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>
+82 -9
View File
@@ -1,9 +1,13 @@
<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. Not linked from anywhere; harmless.
// file hot-reload here mid-animation.
import type { Component } from "svelte";
import { FX_SPRITES } from "./fx-sprites";
import type { BoardFx } from "./fx";
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(() => {
@@ -14,9 +18,9 @@
// One representative of every kind, staged on a 3x3 (sectors on a 5x5).
const mid = { x: 1, y: 1 };
const SAMPLES: BoardFx[] = ([
{ kind: "fireball", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
{ kind: "waterbolt", from: { x: 0, y: 1 }, to: { x: 2, y: 1 } },
{ kind: "bolt", from: { x: 0, y: 0 }, to: { x: 2, y: 2 } },
{ 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 },
@@ -29,10 +33,11 @@
{ kind: "absorb", at: mid },
{ kind: "portal", cell: mid, side: "E" },
{ kind: "portal-cell", at: mid },
{ kind: "streak", from: { x: 0, y: 2 }, to: { x: 2, y: 0 } },
{ 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 },
@@ -42,7 +47,7 @@
{ 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 unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
] as FxShape[]).map((f, i): BoardFx => ({ ...f, id: i + 1 }));
const isSector = (k: string) => k.startsWith("sector-");
</script>
@@ -52,10 +57,11 @@
<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]}
{@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)}
@@ -64,13 +70,70 @@
{/each}
{/each}
{#key tick}
<g class="fx-layer"><Sprite fx={fx as never} /></g>
<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>
@@ -90,7 +153,17 @@
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));
+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>
+15 -11
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import Board from "./Board.svelte";
import { humanize } from "./net.svelte";
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
import { scheduleFx, type BoardFx } from "./fx";
import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
let {
@@ -21,19 +23,21 @@
);
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) return;
const timers: ReturnType<typeof setTimeout>[] = [];
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
timers.push(setTimeout(() => {
boardFx = [...boardFx, fx];
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
}, delay));
}
return () => { timers.forEach(clearTimeout); boardFx = []; };
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(() => {
@@ -63,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} effects={boardFx} />
<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;
}
+2 -4
View File
@@ -1,13 +1,12 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
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 = center(fx.from), b = center(fx.to);
const a = fx.a, b = fx.b;
const segs = 6;
const joints: { x: number; y: number }[] = [a];
for (let i = 1; i < segs; i++) {
@@ -52,7 +51,7 @@
}
.bolt .fork {
fill: none;
stroke: #fff6b0;
stroke: #f5d54a;
stroke-width: 1.6;
stroke-linejoin: round;
}
@@ -62,7 +61,6 @@
stroke-width: 2;
stroke-linejoin: round;
}
.bolt .fork { stroke: #f5d54a; }
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
@keyframes bolt-flicker {
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
+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>
+2 -2
View File
@@ -20,9 +20,9 @@
</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 there" />
<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 there later" />
<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" />
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireball"> } = $props();
const uid = $props.id();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
const a = $derived(fx.a);
const b = $derived(fx.b);
</script>
<defs>
+2 -3
View File
@@ -1,10 +1,9 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"streak"> } = $props();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
const a = $derived(fx.a);
const b = $derived(fx.b);
const d = $derived(`M ${a.x} ${a.y} L ${b.x} ${b.y}`);
</script>
+2 -3
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"waterbolt"> } = $props();
const uid = $props.id();
const a = $derived(center(fx.from));
const b = $derived(center(fx.to));
const a = $derived(fx.a);
const b = $derived(fx.b);
</script>
<defs>
+5 -3
View File
@@ -2,12 +2,13 @@
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
// register it here.
import type { Component } from "svelte";
import type { BoardFx } from "../fx";
import type { BoardFx, FxOf } from "../fx";
import Absorb from "./Absorb.svelte";
import Bolt from "./Bolt.svelte";
import Burst from "./Burst.svelte";
import ChaosSwirl from "./ChaosSwirl.svelte";
import Claw from "./Claw.svelte";
import DieDrop from "./DieDrop.svelte";
import DustPuff from "./DustPuff.svelte";
import Fireball from "./Fireball.svelte";
import Fireworks from "./Fireworks.svelte";
@@ -29,7 +30,7 @@ import ThornSnap from "./ThornSnap.svelte";
import Waterbolt from "./Waterbolt.svelte";
import Whiff from "./Whiff.svelte";
export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
export const FX_SPRITES: { [K in BoardFx["kind"]]: Component<{ fx: FxOf<K> }> } = {
fireball: Fireball,
waterbolt: Waterbolt,
bolt: Bolt,
@@ -49,6 +50,7 @@ export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
soul: Soul,
fireworks: Fireworks,
"chaos-swirl": ChaosSwirl,
"die-drop": DieDrop,
"pit-fall": PitFall,
"ooze-slip": OozeSlip,
"tacks-ow": TacksOw,
@@ -58,4 +60,4 @@ export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
"edge-dust": DustPuff,
"sector-spin": SectorGrind,
"sector-slide": SectorGrind,
} as never;
};
+122 -16
View File
@@ -5,17 +5,25 @@
import type { GameEvent, GameView } from "@wizwar/engine";
type Cell = { x: number; y: number };
/** A point in board pixels (travelling effects anchor to token centers). */
type Pt = { x: number; y: number };
type Side = "N" | "E" | "S" | "W";
type FxShape =
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell }
import { CELL } from "./fx-sprites/geom";
const cellMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 });
/** Where a wizard token's center sits in a cell (mirrors Board.svelte). */
const wizMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL * 0.36 });
export type FxShape =
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; a: Pt; b: Pt }
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
| "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell }
| { kind: "portal"; cell: Cell; side: Side }
| { kind: "sector-spin"; origin: Cell; clockwise: boolean }
| { kind: "sector-slide"; from: Cell; to: Cell }
| { kind: "edge-dust"; cell: Cell; side: Side };
| { kind: "edge-dust"; cell: Cell; side: Side }
| { kind: "die-drop"; at: Cell; aim: Cell };
export type BoardFx = FxShape & { id: number };
/** The narrow type of one effect kind (for sprite components). */
export type FxOf<K extends BoardFx["kind"]> = BoardFx & { kind: K };
@@ -30,6 +38,7 @@ export function fxTtl(kind: BoardFx["kind"]): number {
case "portal": case "portal-cell":
case "soul": case "fireworks": case "chaos-swirl":
case "sector-spin": case "sector-slide": return 1500;
case "die-drop": return 1600;
default: return 900;
}
}
@@ -53,6 +62,48 @@ export function fxForEvents(
const p = view.players.find((p) => p.id === playerId);
return p ? { ...p.position } : null;
};
/** A wizard token's exact center, fan-out included (mirrors Board.svelte). */
const wizardAnchor = (playerId: string): Pt | null => {
const p = view.players.find((p) => p.id === playerId && p.alive);
if (!p) return null;
const group = view.players.filter(
(q) => q.alive && q.position.x === p.position.x && q.position.y === p.position.y,
);
const i = group.findIndex((q) => q.id === playerId);
return {
x: p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0),
y: p.position.y * CELL + CELL * 0.36,
};
};
const creatureAnchor = (creatureId: string): Pt | null => {
const c = view.creatures.find((c) => c.id === creatureId);
if (!c) return null;
const group = view.creatures.filter(
(q) => q.position.x === c.position.x && q.position.y === c.position.y,
);
const i = group.findIndex((q) => q.id === creatureId);
return {
x: c.position.x * CELL + CELL * 0.72 - (group.length > 1 ? i * CELL * 0.26 : 0),
y: c.position.y * CELL + CELL * 0.7,
};
};
/** Best anchor for a spot: the named token if it stands there, else the
* token-height point of the cell, else its plain center. */
const anchorAt = (cell: Cell, id?: string | null): Pt => {
if (id) {
const a = wizardAnchor(id) ?? creatureAnchor(id);
if (a) return a;
}
const standing = view.players.find(
(p) => p.alive && p.position.x === cell.x && p.position.y === cell.y,
);
if (standing) return wizardAnchor(standing.id) ?? wizMid(cell);
const crouching = view.creatures.find(
(c) => c.position.x === cell.x && c.position.y === cell.y,
);
if (crouching) return creatureAnchor(crouching.id) ?? cellMid(cell);
return cellMid(cell);
};
let beat = 0; // successive visuals from one command stagger slightly
const push = (fx: FxShape, extraDelay = 0) => {
out.push({ fx: { ...fx, id: nextId++ }, delay: beat * 220 + extraDelay });
@@ -69,7 +120,7 @@ export function fxForEvents(
const to = e.targetCell ?? posOf(e.target);
const projectile = PROJECTILES[e.cardId];
if (projectile && to) {
push({ kind: projectile, from: e.from, to });
push({ kind: projectile, a: anchorAt(e.from, e.caster), b: anchorAt(to, e.target) });
if (projectile === "fireball") push({ kind: "burst", at: to }, 380);
if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380);
beat++;
@@ -85,7 +136,7 @@ export function fxForEvents(
break;
case "creatureAttacked": {
// The target may be a wizard or a fellow creature.
const at = posOf(typeof e.target === "string" ? e.target : null) ??
const at = posOf(e.target) ??
(() => {
const c = view.creatures.find((c) => c.id === e.target);
return c ? { ...c.position } : null;
@@ -119,7 +170,7 @@ export function fxForEvents(
if ((e.redirected || e.reflectedDamage > 0) && back && stand) {
// The spell turns in the air and goes home.
const kind = (e.attackCardId && PROJECTILES[e.attackCardId]) || "bolt";
push({ kind, from: stand, to: back });
push({ kind, a: anchorAt(stand, e.defender), b: anchorAt(back, e.attacker) });
push({ kind: "hit", at: back }, 380);
beat++;
} else if (e.fullyStopped && stand) {
@@ -128,6 +179,11 @@ export function fxForEvents(
}
break;
}
case "homeBasesSwapped":
push({ kind: "shimmer", at: e.aHome });
push({ kind: "shimmer", at: e.bHome }, 200);
beat++;
break;
case "teleported":
push({ kind: "shimmer", at: e.from });
push({ kind: "shimmer", at: e.to }, 200);
@@ -154,11 +210,37 @@ export function fxForEvents(
push({ kind: "portal-cell", at: e.to }, 150);
beat++;
break;
case "warpOpened":
push({ kind: "portal", cell: e.a.cell, side: e.a.side });
push({ kind: "portal", cell: e.b.cell, side: e.b.side }, 200);
beat++;
break;
case "wallCreated":
case "wallDestroyed":
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
beat++;
break;
case "stoneTurnedToWater": {
// The wall (or block) bursts into water: splash both sides of the
// vanished edge, or the freed square itself.
if (e.at) {
push({ kind: "splash", at: e.at });
} else if (e.edge) {
const n = {
x: e.edge.cell.x + (e.edge.side === "E" ? 1 : e.edge.side === "W" ? -1 : 0),
y: e.edge.cell.y + (e.edge.side === "S" ? 1 : e.edge.side === "N" ? -1 : 0),
};
push({ kind: "splash", at: e.edge.cell });
push({ kind: "splash", at: n }, 120);
}
beat++;
break;
}
case "washedBack":
// The collapsing wave carries them: a streak per victim.
push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
beat++;
break;
case "died": {
const at = posOf(e.player);
if (at) { push({ kind: "soul", at }, 250); beat++; }
@@ -176,21 +258,28 @@ export function fxForEvents(
}
case "knockedBack":
case "shoved":
push({ kind: "streak", from: e.from, to: e.to });
push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
beat++;
break;
case "objectDragged":
push({ kind: "streak", from: e.from, to: e.to });
push({ kind: "streak", a: cellMid(e.from), b: anchorAt(e.to, e.what) });
beat++;
break;
case "jumpedPit":
push({ kind: "streak", from: e.from, to: e.to });
push({ kind: "streak", a: wizMid(e.from), b: anchorAt(e.to, e.player) });
beat++;
break;
case "fellInPit":
push({ kind: "pit-fall", at: e.at });
beat++;
break;
case "thumbOfGod":
push({ kind: "die-drop", at: e.landedAt, aim: e.aimedAt });
beat += 2; // the scattered tokens' streaks follow the impact
break;
case "tokenScattered":
push({ kind: "streak", a: cellMid(e.from), b: cellMid(e.to) });
break;
case "climbedFromPit": {
if (e.success) {
const at = posOf(e.player);
@@ -221,13 +310,9 @@ export function fxForEvents(
return out;
}
case "sectorRelocated": {
// The event records pre-normalization origins; the view holds the
// truth. Shift the recorded start by the same correction.
const trueTo = view.board.placements[e.sectorIndex]?.origin;
if (trueTo) {
const dx = trueTo.x - e.to.x, dy = trueTo.y - e.to.y;
push({ kind: "sector-slide", from: { x: e.from.x + dx, y: e.from.y + dy }, to: { ...trueTo } });
}
// The event carries origins in final coordinates — the view at fx
// time is still the PRE-move board and cannot be trusted for this.
push({ kind: "sector-slide", from: { ...e.finalFrom }, to: { ...e.finalTo } });
return out;
}
default:
@@ -236,3 +321,24 @@ export function fxForEvents(
}
return out;
}
/** Schedule a batch's effects into `add`, expiring each after its run.
* Returns a cancel that stops pending starts and sweeps what began. */
export function scheduleFx(
events: GameEvent[], view: GameView,
add: (fx: BoardFx) => void, remove: (id: number) => void,
): () => void {
const timers: ReturnType<typeof setTimeout>[] = [];
const started: number[] = [];
for (const { fx, delay } of fxForEvents(events, view)) {
timers.push(setTimeout(() => {
started.push(fx.id);
add(fx);
timers.push(setTimeout(() => remove(fx.id), fxTtl(fx.kind)));
}, delay));
}
return () => {
timers.forEach(clearTimeout);
started.forEach(remove);
};
}
+100
View File
@@ -0,0 +1,100 @@
// The council of three: each automaton temperament reads YOUR view — the
// same cards and board you see, nothing more — and says what it would do
// in your robes. Purely advisory; nothing is dispatched.
import { automatonCommand, cardDef, type Command, type GameView } from "@wizwar/engine";
import type { AutomatonStyle } from "@wizwar/engine";
const DIRECTION: Record<string, string> = { N: "north", S: "south", E: "east", W: "west" };
const VOICES: Record<AutomatonStyle, { name: string; glyph: string; mood: string }> = {
hunter: { name: "The Hunter", glyph: "🏹", mood: "eyes on the gold" },
berserker: { name: "The Berserker", glyph: "🔥", mood: "eyes on the blood" },
worrier: { name: "The Worrier", glyph: "🛡", mood: "eyes on the exits" },
};
function nameOf(view: GameView, instanceId: string): string {
const c = view.yourHand.find((c) => c.instanceId === instanceId);
return c ? cardDef(c.cardId).name : "a card";
}
function describe(view: GameView, cmd: Command): string {
switch (cmd.type) {
case "move":
return `step ${DIRECTION[cmd.direction]}`;
case "playNumberForMovement":
return `play the ${nameOf(view, cmd.instanceId)} for extra movement`;
case "cast": {
const card = nameOf(view, cmd.instanceId);
const at =
cmd.target?.kind === "player" ? ` at ${cmd.target.playerId}`
: cmd.target?.kind === "cell" ? ` at the square (${cmd.target.cell.x}, ${cmd.target.cell.y})`
: cmd.target?.kind === "edge" ? ` on the wall line beside (${cmd.target.cell.x}, ${cmd.target.cell.y})`
: cmd.target?.kind === "creature" ? " on the monster"
: "";
const num = cmd.numberInstanceIds?.length
? ` with ${cmd.numberInstanceIds.map((id) => nameOf(view, id)).join(" and ")}`
: "";
return `cast ${card}${at}${num}`;
}
case "counteract":
return `answer with ${nameOf(view, cmd.instanceId)}`;
case "pass":
return "let it resolve — nothing in hand is worth spending on this";
case "punch":
return `punch ${cmd.targetId}`;
case "punchWall":
return "punch the wall";
case "pickUpTreasure":
return "pick up the treasure here (that ends the turn's actions)";
case "pickUpObject":
return "pick up the object here (that ends the turn's actions)";
case "dropTreasure":
return "drop the treasure here";
case "dropObject":
return `drop ${nameOf(view, cmd.instanceId)} here`;
case "warpStep":
return "step through the warp underfoot";
case "testIllusion":
return "test that shimmering wall — doubting it is free";
case "moveCreature":
return `march the monster ${DIRECTION[cmd.direction]}`;
case "creatureAttack":
return `set the monster on ${cmd.targetId}`;
case "setAmbush":
return `lay an ambush with ${nameOf(view, cmd.instanceId)}`;
case "armWard":
return cmd.armed ? "arm the Ward over the gold" : "stand the Ward down";
case "discard":
return `shed ${cmd.instanceIds.map((id) => nameOf(view, id)).join(", ")} to make room`;
case "endTurn":
return cmd.draw > 0 ? `end the turn and draw ${cmd.draw}` : "end the turn";
default:
return "bide";
}
}
export interface TableHint {
name: string;
glyph: string;
mood: string;
advice: string;
}
/** What each temperament would do from this seat, phrased for a novice. */
export function tableHints(view: GameView): TableHint[] {
const styles: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
const out: TableHint[] = [];
for (const s of styles) {
const v = VOICES[s];
let advice: string;
try {
const cmd = automatonCommand(view, s, "archmage");
advice = cmd ? `I would ${describe(view, cmd)}.` : "Nothing calls for us right now — wait for your moment.";
} catch {
advice = "…I have no counsel here.";
}
out.push({ name: v.name, glyph: v.glyph, mood: v.mood, advice });
}
return out;
}
+2 -2
View File
@@ -48,11 +48,11 @@ class LocalGame {
/** Set while the device should be handed to the named player. */
handoffTo = $state<PlayerId | null>(null);
log = $state<string[]>([]);
/** The finished game as a reel, each step from its actor's own seat. */
/** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
/** Board flourishes: the app hooks in to animate command results. */
onFx: ((events: GameEvent[]) => void) | null = null;
/** The finished game as a reel, each step from its actor's own seat. */
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
view = $derived(
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
@@ -136,7 +136,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 14,
deckRev: 36,
};
const { state, events } = createGame(config);
for (const e of events) {
+21 -7
View File
@@ -39,8 +39,9 @@ export function humanize(e: GameEvent): string | null {
case "punched": return `${e.attacker} punches ${e.target}!`;
case "spellCast": {
const num = e.numberValue ? ` with a ${e.numberValue}` : "";
const amp = e.amplifies ? `, AMPLIFIED${e.amplifies > 1 ? ` ×${2 ** e.amplifies}` : ""},` : "";
const at = e.target ? ` at ${e.target}` : "";
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${at}.`;
return `${e.caster} casts ${cardDef(e.cardId).name}${num}${amp}${at}.`;
}
case "counteractionPlayed": return `${e.player} counters with ${cardDef(e.cardId).name}!`;
case "counterNullified": return `${cardDef(e.card.cardId).name} is nullified by Anti-Anti!`;
@@ -49,18 +50,27 @@ export function humanize(e: GameEvent): string | null {
if (e.redirected) return `The spell is reflected back at ${e.attacker}!`;
if (e.fullyStopped) return `The attack is completely stopped.`;
return null; // the damaged event tells the story
case "damaged": return `${e.player} takes ${e.amount} damage (${e.source}) — ${e.lifeAfter} life left.`;
case "damaged": {
const soak = e.soaks?.map((s) => `${s.what} soaks ${s.amount}`).join(", ");
return `${e.player} takes ${e.amount} damage (${e.source}${soak ? `${soak}` : ""}) — ${e.lifeAfter} life left.`;
}
case "stunned": return `${e.player} is stunned and loses a turn!`;
case "knockedBack": return `${e.player} is knocked back ${e.squares} square(s)!`;
case "stonesDestroyed": return `${e.player}'s magic stones are destroyed!`;
case "wallCreated": return `A wall appears!`;
case "wallDestroyed": return e.wasDoor ? `A door is blasted to rubble!` : `A wall crumbles!`;
case "warpOpened": return `The outer wall breaches clean through — a new warp opens across the maze!`;
case "extraTurnGranted": return `${e.player} speeds up — extra turn banked.`;
case "trapSprung": return `${e.player} walked into an old TRAP! Lose a turn.`;
case "wardWindow": return `The grab hangs in the air — ${e.owner} clutches something…`;
case "trapSprung": return e.cardId === "gift-from-below"
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
: `${e.player} walked into an old TRAP! Lose a turn.`;
case "attackMissed": return e.because === "invisible"
? `The attack passes through empty air — ${e.defender} is invisible!`
: `${e.defender} is too small to hit — the attack misses!`;
case "damageImmune": return `${e.player} is stone — the damage has no effect.`;
case "damageImmune": return e.because === "bloodstone"
? `${e.player}'s bloodstone drinks the whole blow — no damage.`
: `${e.player} is stone — the damage has no effect.`;
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
@@ -68,12 +78,15 @@ export function humanize(e: GameEvent): string | null {
? `${e.player} teleports across the maze!`
: `${e.player} is teleported away by ${e.by}!`;
case "positionsSwapped": return `${e.a} and ${e.b} swap places!`;
case "homeBasesSwapped": return `${e.a} and ${e.b} swap home bases — the maze's loyalties shift!`;
case "cardErased": return e.found
? `${e.player}'s ${e.cardId ? cardDef(e.cardId).name : "card"} is erased from their mind!`
: `${e.player} wasn't holding that card — the erasure fizzles.`;
case "cardsStolen": return `${e.to} steals ${e.count} card(s) from ${e.from}'s thoughts!`;
case "handRevealed": return `${e.to} reads ${e.player}'s mind — their hand is revealed.`;
case "doorUnlocked": return `${e.player} unlocks a door.`;
case "doorHeld": return `${e.player} holds the door open.`;
case "doorReleased": return `The held door swings shut.`;
case "doorsRelocked": return `The door swings shut and relocks.`;
case "doorJammed": return `${e.player} jams a door's lock solid.`;
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
@@ -130,6 +143,7 @@ export function humanize(e: GameEvent): string | null {
case "illusionBelieved": return e.believed ? `${e.player} flinches — the illusion feels real!` : `${e.player} laughs off the illusion.`;
case "itemStolen": return `${e.to} picks ${e.from}'s pocket.`;
case "itemsSwapped": return `${e.a} and ${e.b} swap items.`;
case "swapFizzled": return `${e.player}'s trade comes to nothing — the named items were not there to swap.`;
case "wardSprung": return `${e.owner}'s treasure was WARDED — it bites ${e.victim}!`;
case "curseRemoved": return `${e.caster} lifts a curse from ${e.target}.`;
case "objectEnchanted": return `An object gleams with Swarthmore's enchantment.`;
@@ -250,11 +264,11 @@ class Net {
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
/** Moves you haven't watched yet in the current room. */
missedMoves = $state(0);
/** A catch-up reel delivered by the server. */
/** The opening roll-off, shown once as the boards flip. */
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
/** Board flourishes: the app hooks in to animate live event batches. */
onFx: ((events: GameEvent[]) => void) | null = null;
/** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
@@ -326,8 +340,8 @@ class Net {
// Only the FIRST state after arriving carries a gap worth
// announcing. Later states were watched live: a caught-up
// watcher stays caught up, and an announced gap stays FROZEN
// (not grown, not wiped) until watched or skipped. A hidden
// tab accumulates its gap honestly.
// until watched or skipped. A hidden tab accumulates its gap
// honestly.
if (this.watching === this.roomId && document.visibilityState === "visible") {
if (this.missedMoves === 0) this.markSeen();
} else {
+47
View File
@@ -0,0 +1,47 @@
// Player preferences: purely client-side taste, saved on this device.
// Nothing here touches the rules — a preference may change what you SEE
// or what routine commands the client sends on your behalf, never what
// is legal.
export interface Prefs {
/** Token art: the photographed cardboard, or the hand-drawn vectors. */
art: "photo" | "drawn";
/** Standing on lone grabbable gold at end of turn: pick it up first. */
autoGrab: boolean;
/** Spell flourishes (the animated effects layer). */
flourishes: boolean;
/** Pre-filled wizard name for creating and joining games. */
wizardName: string;
/** Preferred wizard color (0-5), claimed in lobbies when free. */
color: number | null;
}
const KEY = "wizwar-prefs";
function load(): Prefs {
const fallback: Prefs = { art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null };
try {
const raw = localStorage.getItem(KEY);
if (!raw) return fallback;
const p = JSON.parse(raw) as Partial<Prefs>;
return {
art: p.art === "drawn" ? "drawn" : "photo",
autoGrab: p.autoGrab === true,
flourishes: p.flourishes !== false,
wizardName: typeof p.wizardName === "string" ? p.wizardName.slice(0, 20) : "",
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
};
} catch {
return fallback;
}
}
export const prefs = $state<Prefs>(load());
export function savePrefs(): void {
try {
localStorage.setItem(KEY, JSON.stringify(prefs));
} catch {
// A full or blocked localStorage loses persistence, not the session.
}
}
+215
View File
@@ -0,0 +1,215 @@
// The rulebook, verbatim, as the table's court of final appeal. Base rules
// are the 5th-edition text the 6th edition shipped with; expansion rules are
// the Expansion Set 1 sections (this edition has no Expansion 2). Both from
// the designer's own wizwar.com (archived 2003-2004).
export interface RuleSection {
title: string;
paragraphs: string[];
}
export const RULEBOOK_BASE: RuleSection[] = [
{
"title": "INTRODUCTION",
"paragraphs": [
"Wiz War is a game for magical combat in a stone labyrinth. Players attempt to bring two of their opponent's treasures back to their home bases while at the same time guarding their own treasures against theft. A game usually takes from fifteen minutes to an hour to play, and can be played with two or more players."
]
},
{
"title": "WINNING",
"paragraphs": [
"There are two ways you can win Wiz War. One way is to eliminate all the other players in wizardly battle. The other way is to obtain two \"treasure chests\" from any of the other players (they need not both be from the same player), then return them, one at a time, to your home bases, and drop them there.",
"Alternatively, it is also possible to lose the game by allowing both of the treasures that you protect to be taken to another players' home bases and left there. The moment that both of your treasures sit on other players' home bases, you are out of the game."
]
},
{
"title": "EQUIPMENT",
"paragraphs": [
"A four section playing board, a deck of 124 cards, one sheet of cardboard tokens, a rule booklet, and one die (numbered from 1-4). It may be 8-sided or 4-sided, depending on what game edition you have.",
"*(6E: 125 cards, two sheets of tokens — see Section 1.)*"
]
},
{
"title": "STARTING THE GAME",
"paragraphs": [
"Each player chooses one of the four sectors, face down, at random. Put these sectors together in any of the setups shown on the last page, depending on the number of players, and turn all the sectors over simultaneously. This is now your playing board.",
"Each player starts his playing piece in the exact center of the sector he chose. This center square is his HOME BASE, to which he will try to bring two of his opponents' treasures.",
"In each player's sector, his two treasure chests are placed on the small circles evident on each sector board. For the rest of the game, the other players will try to steal these, and he will try to protect them. He in turn will try to steal theirs.",
"Shuffle the cards and deal seven, face down, to each player. Put the remainder of the deck aside as a drawing stack. If \"TRAP!\" is drawn on the deal, discard it and redraw.",
"Take a sheet of paper and write the players' names and the number \"15\" below each. These are your life-points, and you will lose or gain points as the game progresses. If you get down to \"0\" points, you are considered dead, and you are out of the game. There is no upper limit to the number of points you might gain through actions in the game.",
"Turns go clockwise, starting with the player rolling highest on the die."
]
},
{
"title": "THE BOARD",
"paragraphs": [
"Notice the letters on the setup diagram on the last page of the rules. These boards have open sides; that is, if you leave a board at point \"A\" on either side, you will reenter at point \"A\" on the opposite side. By the same token, if you smash down a wall right next to the \"A\" exit, then you can enter the opposite board edge at the point relative to where you smashed the wall.",
"The apparent double wall at the junction of any two sectors is not double at all; it should be treated as a single wall.",
"The AUTO WARP is only used in the three-player game. It does not count as a space; you go directly from one end to the other. For all purposes, treat the connected board edges as though they are adjacent. It serves to connect the three sectors together in such a way as to prevent any unfair advantages for any particular board. If a sector gets RELOCATED through use of the RELOCATE card, then the AUTO WARP is discarded, and only opposite board edges connect. ROTATION has no effect on the AUTO WARP.",
"If casting a spell, or checking line of sight through the AUTO WARP, treat it as a straight line, and the two connected boards as though they were adjacent.",
"Doors are the narrow sections in the walls that look a bit like doors. They are considered locked at all times, and require special cards from the deck to be opened. After passage through a door, it automatically relocks itself.",
"You can, if you like, cast a spell through an opened door without passing through yourself.",
"You can't follow someone through a door unless they state they are holding it open for you. There is no way, besides a REMOVE LOCK card, to jam a door open."
]
},
{
"title": "MOVEMENT",
"paragraphs": [
"You may move up to three spaces per turn. If you wish, during any turn you may add one NUMBER card to your movement to increase your allowed movement for your turn. If the NUMBER card was \"4\", then you could move up to seven spaces that turn. The die is not used for movement."
]
},
{
"title": "OBJECTS",
"paragraphs": [
"**Fixed/Movable Objects:** Fixed objects cannot be moved. These include such things as walls, firewalls, illusion walls, thorn bushes, doors, and solid stone squares. Movable objects include magic stones, treasure chests, the DAGGER, the LARGE ROCK, the WIZARDBLADE, and other players. Other players cannot be picked up, of course, but they can be DRAGged with the DRAG spell. Generally speaking, movable objects do not block line of sight, while fixed objects do.",
"**Dropping and Retrieving Objects:** Sometimes you will have objects in your card hand that you do not wish to carry at the time, since you are limited to seven cards in your hand and you may wish to get new cards. In that case, you can drop any or all of them. To do this, you merely state that you are dropping an object, and put an appropriate cardboard token representing that object on the space that your playing piece occupies. You can do this any time during your turn. Anyone else may pick this object up at a later time, if they land on it. The card designating the object, if there is one, is placed next to the sector where the object was dropped. Another player picking this object up must take the card for it into his own hand.",
"If an object is thrown, it is considered to have landed in the targeted square unless some barrier, such as a THORNBUSH, is in the way. In that case the object lands immediately before the barrier.",
"**YOUR TURN ENDS IF YOU PICK UP ANY OBJECT**, and you may perform no more actions that turn, except for drawing cards. You can't pick up an object out of turn, even if you TELEPORT onto it.",
"Treasures are treated like other objects, but **YOU CAN ONLY CARRY ONE AT A TIME!** After all, these things are pretty heavy. You can carry any number of other objects along with the treasure, though. Treasures do not count as a card in your hand. Treasures may NOT be thrown.",
"You can carry your own treasure, and may even go retrieve your treasure after someone has placed it on their own home base, in addition to taking yet another player's treasure off of someone else's home base. If you are carrying a treasure, keep the token with the playing piece and do not remove it from the board.",
"Objects cannot be forcibly taken from you unless another player has a DROP OBJECT spell card that allows him to do so, or unless he kills you. He cannot paralyze you with MEDUSA, then take everything you have. He must use a card that directly applies to the removal or destruction of a physical object."
]
},
{
"title": "THE CARDS",
"paragraphs": [
"There are five different kinds of cards in the game. They are called ATTACK cards, NEUTRAL cards, COUNTER-ACTION cards, NUMBER cards, and MAGIC STONES.",
"**ATTACK** cards say \"Attack\" in the upper left-hand corner of each card. **ONLY ONE ATTACK CAN BE USED DURING EACH PLAYER TURN.** In the upper right-hand corner, the abbreviation \"L.O.S.\" might appear. This means that a spell can only be used if a player has a clear line of sight to the person or object the spell is to be cast upon. Line of sight is defined as the line going from the center of the attacker's square to the center of the target's square. The center is the dot marked on each space. In the case of a CREATE WALL spell, the center would be the center of the wall to be created. If this line of sight is interrupted by any part of a wall, the spell cannot be cast.",
"*(6E: the boards have no printed dots — eyeball the square centers. See Section 1.)*",
"**NEUTRAL** cards are non-offensive or non-defensive spells. ANY NUMBER of Neutral spells can be cast during a player's turn. It is not impossible for a player to use his entire hand in one turn.",
"**COUNTERACTION** cards are the only cards that can be used OUT OF TURN. They can be used (but aren't usually) during a normal turn as a NEUTRAL card, but their primary use is as a defense against attacks. They are played immediately following an attack by the player who was attacked. You CANNOT counteract a Neutral card. You can, however, use an ABSORB or BLUNT against indirect damage caused to you (e.g., by a falling DESTROYed WALL).",
"More than one COUNTERACTION card can be used to defend against a single attack, either used sequentially or added together. It is also possible to COUNTERACT a COUNTERACTION card, so an attack may bounce back and forth a few times before coming to rest. In case a COUNTERACTION card only stops half of a spell, always round the resulting damage UP to the nearest whole number. If all damage from a spell is stopped, any secondary effects, such as a lost turn, are also stopped.",
"A player is not obligated to play a COUNTERACTION card if he does not wish to.",
"COUNTERACTIONS are instantaneous; they only affect the one attack spell they were played against.",
"**NUMBER** cards have three purposes. First, they can add to your movement, as mentioned earlier. Second, they can affect the DURATION of a spell, the NUMBER then representing the number of turns that the spell lasts. A duration spell starts when cast and ends at the start of one of the caster's next turns. Duration-based spells are marked as such on each card. Third, they can represent the POWER of a spell, so that a LIGHTNING BLAST with a \"5\" NUMBER card would do five points of damage to an opponent (plus one turn lost due to stunning, as mentioned on the card). This damage would then be subtracted from the victim's point total.",
"Only one NUMBER card can be played per action. So in one turn, a player may use a NUMBER card to enhance his movement, use another to ATTACK with, and use yet another to become INVISIBLE (if he has the card) for a certain number of turns, and so on.",
"**MAGIC STONES** are magical gems that bestow a special power upon the holder. Stones may be discarded like any other card if a player does not wish to keep them, or they may be dropped. The cards are permanent, and the power they bestow may be used as often as the opportunity arises. The name of each magical stone contains the word \"STONE\" and the description on each card is prefaced by the word \"POWER\".",
"Cards are used once, then discarded face up onto a discard pile. There are exceptions! These include physical objects such as MAGIC STONES, the DAGGER, the LARGE ROCK, the WIZARDBLADE, and the MASTER KEY. Physical objects are not removed from your hand unless they are dropped or thrown. Of all the physical objects, MAGIC STONES and the MASTER KEY have one other requirement: They must be displayed face up in front of you after you have used them. They are still considered part of the seven-card hand. If any cards are to be randomly lost or taken from a player's hand, these cards must be taken back into the hand before the choice is made.",
"If you kill an opponent, you get all of his cards, but you must immediately discard enough to bring your hand down to seven cards.",
"Some cards involve physical actions, like picking locks, removing locks, jamming locks, and throwing daggers. These are not spells."
]
},
{
"title": "THE TURN",
"paragraphs": [
"You can do a lot during your turn. You may move, attack once, and use as many NEUTRAL and COUNTERACTION cards as you like. As an example, you play a \"3\" NUMBER card and say \"I'm moving six spaces this turn.\" Then you move two spaces into L.O.S. with another player, cast an attack, move back three spaces, create a wall, shrink yourself, then move back one more space. The order you use doesn't matter, and you can break up your movement while performing other actions. If you pick up an object during your turn, your turn ends the moment you pick it up.",
"Losing a turn means you can do NOTHING for that entire turn, except COUNTERACTION.",
"For the purpose of lost turns, or spell duration, a \"turn\" starts when a spell is cast, and ends at the beginning of one of the caster's turns."
]
},
{
"title": "COMBAT",
"paragraphs": [
"There is no combat during the first round of turns. Combat consists of taking a card from your hand and playing it against your opponent during your turn. If that player has a COUNTERACTION card, he may play it then. If you want to COUNTERACT his COUNTERACTION, and have the card to do so, you may. You may attack only once per turn. Spells automatically hit their target if not COUNTERACTed. If a spell misses for some reason, it dissipates harmlessly, and does NOT hit some object behind the target!",
"Any damage done to either player in the form of damage points is removed from the 15 points on his score sheet. If you get to \"0\" points, you are dead.",
"Damage points, points, damage, and point-based spells all refer to the same thing: things that take away points from you, an opponent, or object (such as a wall or door). They have nothing to do with duration-based spells.",
"If a person is eliminated from the game by being killed, then the last attacker gets all the dead person's cards. If a player is eliminated by having lost both his treasures to opponents' home bases, his cards are discarded.",
"You cannot physically attack through any type of wall, firewall, or bush. You cannot attack yourself. You cannot attack on the first round of turns, even if you have the SPEED spell.",
"*(6E: \"You cannot attack yourself\" is stated in the 6E rulebook; FAQ errata: the first-round restriction should read \"no ATTACKS during the first round\", not \"no combat\" — e.g. Destroy Wall next to someone on turn one is legal.)*",
"ANY CARD MAY BE PLAYED WITHOUT A NUMBER CARD, but its power is only \"1\". There are no \"1\" NUMBER cards in the deck.",
"When all else fails, you may PUNCH your opponent. This requires no card, but does constitute your attack for the turn. A punch does one point of damage, and you must be in the same square as your opponent to do it. If the object to be hit fills an entire square, you may be in an adjacent square to hit it. It is possible, though time-consuming, to punch a wall down. A wall takes 20 points of damage to destroy; a door takes 15. Any attack against an inanimate object counts as your one attack for the turn. The only inanimate objects you can attack are the walls, doors, and thornbushes.",
"It is possible to walk by an opponent or cast a spell past him without affecting him at all, if you want to.",
"Any deal can be made while in L.O.S. with another player, including, but not limited to, trading cards.",
"PHYSICAL DAMAGE and MAGICAL DAMAGE both take damage points from a player, but on rare occasions (designated on the cards) they are treated differently. Physical damage would include things like damage from the LARGE ROCK, the DAGGER, a PUNCH from another player, or a wall that falls on you from a DESTROY WALL spell. Magical damage would include any damage caused as a direct result of a spell, such as walking through a FIREWALL, getting hit with the WIZARDBLADE, or being hit with a LIGHTNINGBLAST."
]
},
{
"title": "CARDS AND ACTIONS THAT CHANGE THE MAP",
"paragraphs": [
"If you add a wall, destroy a wall, create a bush or firewall, etc., then you must take an appropriate cardboard token representing this and place it where the item was created.",
"You CANNOT create an object on a player's home base, or on any space already occupied by a player or object (unless it is a wall, which doesn't actually exist ON a space, but in between them). You cannot, for example, cast a FIREWALL where an ILLUSION WALL already exists, or two THORNBUSHES in the same square.",
"Walls cannot be cast diagonally. They must be cast on the lines between spaces.",
"If a card has no duration mentioned on it, the effect is either permanent (such as CREATE WALL) or instantaneous (such as FIREBALL).",
"If someone has cast CREATE WALL or DESTROY WALL on a junction between sectors, and some other moron decides he wants to use ROTATE SECTOR on one of them, then roll the die for a 50-50% chance to see which one of the two sectors the \"alteration\" stays with."
]
},
{
"title": "GETTING NEW CARDS",
"paragraphs": [
"You start with seven cards. You may draw up to two per turn, but may never have more than seven cards in your hand at one time. If for some reason you do, you must immediately discard enough cards to bring your hand down to the seven card limit. Draw new cards only at the end of YOUR turn.",
"If you want to discard cards from your hand just for the sake of getting new cards, you may. This includes cards which represent permanent objects, which give you the option of dropping the object or discarding it."
]
},
{
"title": "THE DIE",
"paragraphs": [
"This is referred to as a D4 in the game. It is either 8-sided (numbered from 1-4 twice) or 4-sided, depending on which game edition you have. It is NOT used to roll movement.",
"Whenever a random direction is called for in the course of the game, pick a number representing the direction you are trying to go, or trying to cast a spell (if you are BLINKed), and then roll for that number.",
"If you need to roll a 50-50% chance, just call 1,2 as low and 3,4 as high, determining which roll represents what, beforehand."
]
},
{
"title": "DEATH",
"paragraphs": [
"A dead player is out of the game and may not win even if two treasures are placed on his home base. His home base is no longer valid for the purpose of eliminating other players. His treasures, though, are still valid to use to win the game. A player who dies while performing an action that would normally win the game is considered to have died first, and is eliminated from the game.",
"---"
]
}
];
export const RULEBOOK_EXPANSION: RuleSection[] = [
{
"title": "MONSTERS",
"paragraphs": [
"Expansion 1 introduces spells that create monsters. Any monster will follow the commands of its creator (unless otherwise noted on the cards, like the Imp), even when out of line-of-sight. They move any time during the controlling player's turn, so if a player gets an extra turn, or loses a turn, so does the monster (Imp excluded, again). Monsters, unless otherwise noted on the card, may not hurt their creators, carry items, cast spells or use cards from your hand. They are permanent creations, until killed. You may not add NUMBER cards to their movement, but you can cast NEUTRAL spells such as MIST-BODY and SPEED on them.",
"For the few instances where MONSTERS can carry items, they can't use those items at all (unless the card specifically says otherwise).",
"Monsters attack once per turn. They cannot attack the turn they are created, but may move on that turn. Creating a monster counts as your attack only on the turn it is created. You and your monster may both have attacks on later turns. Monsters can attack other monsters. Players may not attack a creature that they control. If a monster attacks a player and kills him, the controlling player DOES NOT get the dead player's cards.",
"If you cast SHADOW and later lose a turn, and you wish to maintain the SHADOW during your lost turn, it will still cost you a life point. If there are other \"lasting\" effects on you that you have no control over, these also occur even if you've lost a turn.",
"If you cast BUDDY, you can hurt the other player with a monster's attack without breaking the BUDDY spell, and he can do the same to you. A monster's attack is not YOUR attack.",
"If a monster sees an illusion, he has the same chance to believe the illusion as a player does.",
"A monster controlled by you does NOT count as a card in your hand. If you die, any monster controlled by you immediately disappears.",
"Monsters, like Wizards, will suffer the ill effects of objects in the corridor (like a Thornbush) just like Wizards. Also, if a card specifies \"Opponent\" or \"Anyone\" as a target, this includes Monsters.",
"Wizards can run past monsters without taking damage from them, just as they can run by other Wizards without getting punched. The obvious exceptions to this (like the Buck and the Fire Imp) are noted on the cards."
]
},
{
"title": "FLYING AND LEVITATING",
"paragraphs": [
"Things that fly, float, or levitate are immune to the effects of ground-based effects such as TACKS, KILLER OOZE, and WATERWALL."
]
},
{
"title": "MAGIC STICKS",
"paragraphs": [
"You can recognize a magic stick because the word \"Stick\" is in the name of the card. Each stick carries a number of \"charges\" set by the number card played when the stick is first used. This number tells you how many times the stick can be used before it gets discarded. Place a token on the card when played indicating how many charges the stick has, and leave the stick displayed. Any stick operates a maximum of once per turn. Once it is used up it is useless and may not be recharged, or retrieved from the discard pile. If ownership of a magic stick is taken by an opponent, then whatever charges are left on the stick are also transferred. If the initial number of charges has not been set, then the new owner may do so. AMPLIFY and ADD will both work when setting the initial number of charges.",
"Magic Sticks do not burn. They do, of course, count as a card in your hand.",
"SPEED or ADRENALINE will NOT allow you to use a Magic Stick more than once in your turn. While the spells speed YOU up (and your monsters, whose turns are slaved to you), they do not speed up the limits of the wand. This isolation of the wand from you allows you to use it even with NO SPELL cast on you.",
"ABSORB SPELL, of course, has no effect on Sticks, since they are objects and not spells, even though what Magic Sticks produce are considered spells."
]
},
{
"title": "OTHER NOTES FOR THE FIRST EXPANSION",
"paragraphs": [
"There are tokens on the counter sheet marked with a number of Charges. These are to help keep track of how many charges a Magic Stick has left on it (place the token in front of you after you have charged up the stick). You needn't use them if you can keep it all straight in your head.",
"If you don't like a card in your edition of Wiz War, don't use it.",
"The tokens for Magic Sticks and Magic Stones aren't meant to be carried by a Wizard. They are there in case the player drops an item, to show where on the board it was dropped.",
"If you REUSE an object-creating spell, there may not be enough tokens to represent the new object. In this case, you'll have to rough it. Use a blank token, for example, with your own drawing on it.",
"The rules that let you discard cards that you don't want in your hand obviously do NOT apply to cursed objects (such as LOAD STONE). Naturally you CAN discard (crush) ARTIFACTS during your turn (or Magic Sticks, or Magic Stones, etc.). You may not discard or drop objects out-of-turn.",
"Some card effects let you perform multiple attacks in a turn, but certain objects which can produce attacks are limited to one use per turn. Since this is a limitation on the object, this means you can't use the object multiple times, regardless of the effect that exists on your Wizard. He must use other spells or physical attacks to get his quota of attacks in."
]
},
{
"title": "TEAM PLAY OPTION (thanks to Jeff Ingalls)",
"paragraphs": [
"If you have 4 or 6 players, it may be fun to try team play. For 4 players, the 1st and 3rd are one team, the 2nd and 4th the other. For 6 players, use 3 teams of 2. Winning is determined by either killing the other players, or by getting 3 of the opponents' treasures on either of your team's home bases in any combination. You may not attack your teammates (unless a teammate is under someone else's control). Discussion of strategy is allowed, but you may not reveal or trade cards with your teammate unless in L.O.S."
]
},
{
"title": "TWO PLAYER VARIANT",
"paragraphs": [
"For better board access for two players, cross the B and C exits on the 2 boards (see the diagram in the basic set). Thus, each player has a B exit and a C exit in his sector. It's a lot more fun this way."
]
},
{
"title": "DOORS VARIANT",
"paragraphs": [
"You can, if you like, play with the optional rule that within each sector a Wizard can open his doors without a key. Suggestion by Tracy Johnston.",
"---"
]
}
];
export const RULEBOOK_COPYRIGHT =
"Base rules © 1985 Jolly Games. Expansion rules © 1991 Jolly Games. " +
"Reproduced from the designer's own wizwar.com (archived 20032004). " +
"Wiz-War's current edition is published by Steve Jackson Games.";
+8
View File
@@ -119,4 +119,12 @@ export const HOW_TO_PLAY: RulesSection[] = [
"Games are saved move-by-move on the server and survive anything. The lobby lists every game you're in and whose turn it is; the tab title and favicon flip when a turn is yours, and “notify me on my turn” adds browser notifications. “Transfer seat” hands a game to another device with a spoken phrase.",
],
},
{
title: "The automatons",
body: [
"Any empty seat can be filled with an automaton — a clockwork wizard that plays by the same rules and the same information a human seat gets. Add one in the lobby before the game starts, choosing its temperament: a HUNTER marches for treasure, a BERSERKER hunts wizards over gold, a WORRIER keeps its distance and guards what it has. Pick MYSTERY and the maze deals a temperament in secret — you'll have to read its play to guess its mood, and the roster only unmasks it (🎭) when the game ends.",
"The difficulty picker sets how much the clockwork has to work with, never how fairly it plays: an APPRENTICE draws one card a turn and knows a modest spellbook, an ADEPT draws two and defends its gold, and an ARCHMAGE knows every trick in the deck — ambushes, amplifies, walls blasted through, roads denied. Every tier plays its cards correctly; none of them cheats, peeks, or rolls loaded dice.",
"The automatons chat a little, hold grudges not at all, and take exactly one action per second so you can watch them think. If one does something that looks brilliant, it probably learned it from a human at this table.",
],
},
];
+12
View File
@@ -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

+10
View File
@@ -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

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Destroy Wall 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">Destroy Wall</text>
<g filter="url(#w)" stroke="#252923" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><g fill="#777d78"><path d="M25 76h28v19H23Z"/><path d="M56 70h31v22H56Z"/><path d="M91 78h28v19H88Z"/></g><path d="M72 58l-8 16 13-4-5 17 18-23-12 4 4-14Z" fill="#f1c340"/><path d="M27 54l-12-9m101 9l13-10M23 110l-12 7m108-7l12 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="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

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