40 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 ca55f7d00b The fireworks get the stage before the victory modal takes it
Winning raised the modal on the same frame the fireworks lit,
covering them. The modal now waits behind the curtain for 2.4
seconds — three volleys over the winner's home, seen — then takes
the stage.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:21:54 -04:00
Eric WagonerandClaude Fable 5 4ef3ee5641 Drop playwright scratch output from tracking
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:21:46 -04:00
52 changed files with 3581 additions and 260 deletions
+25 -26
View File
@@ -179,8 +179,7 @@
"text": "Reduces any damage done to you by 1/2. Round fractions up. Works on point-based or duration-based spells, or physical damage.", "text": "Reduces any damage done to you by 1/2. Round fractions up. Works on point-based or duration-based spells, or physical damage.",
"quantity": 2, "quantity": 2,
"faqRulings": [ "faqRulings": [
"If you play two in a row, they act one after the other, they do not cancel the damage out entirely! Thus, against a 5 point Fireball, the first would reduce damage to 3 points (2.5 rounded up), and the second would reduce damage to 2 points (1.5 rounded up).", "If you play two in a row, they act one after the other, they do not cancel the damage out entirely! Thus, against a 5 point Fireball, the first would reduce damage to 3 points (2.5 rounded up), and the second would reduce damage to 2 points (1.5 rounded up)."
"Book of Spells This cannot hold items or objects, just spells and NUMBER cards. Discard them if you draw objects, redrawing to fill to 5 cards. These may be used in any combination you like with the cards in your hand. REFLECTED spells will blast you, not the BOOK."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -268,6 +267,7 @@
"text": "You may throw it. Does three points physical damage. You cannot stab with it. Retrievable by anyone after it is thrown.", "text": "You may throw it. Does three points physical damage. You cannot stab with it. Retrievable by anyone after it is thrown.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!",
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!" "These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
], ],
"verification": { "verification": {
@@ -500,6 +500,7 @@
"text": "You may throw it. Does two points physical damage. Once thrown, it may be picked up and thrown again by any player.", "text": "You may throw it. Does two points physical damage. Once thrown, it may be picked up and thrown again by any player.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!",
"These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!" "These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
], ],
"verification": { "verification": {
@@ -612,13 +613,13 @@
}, },
{ {
"id": "number-2", "id": "number-2",
"name": "Number \u201c2\u201d", "name": "Number “2”",
"set": "basic", "set": "basic",
"cardType": "number", "cardType": "number",
"value": 2, "value": 2,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 12, "quantity": 12,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -628,13 +629,13 @@
}, },
{ {
"id": "number-3", "id": "number-3",
"name": "Number \u201c3\u201d", "name": "Number “3”",
"set": "basic", "set": "basic",
"cardType": "number", "cardType": "number",
"value": 3, "value": 3,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 10, "quantity": 10,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -644,13 +645,13 @@
}, },
{ {
"id": "number-4", "id": "number-4",
"name": "Number \u201c4\u201d", "name": "Number “4”",
"set": "basic", "set": "basic",
"cardType": "number", "cardType": "number",
"value": 4, "value": 4,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 7, "quantity": 7,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -660,13 +661,13 @@
}, },
{ {
"id": "number-5", "id": "number-5",
"name": "Number \u201c5\u201d", "name": "Number “5”",
"set": "basic", "set": "basic",
"cardType": "number", "cardType": "number",
"value": 5, "value": 5,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 4, "quantity": 4,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -676,13 +677,13 @@
}, },
{ {
"id": "number-6", "id": "number-6",
"name": "Number \u201c6\u201d", "name": "Number “6”",
"set": "basic", "set": "basic",
"cardType": "number", "cardType": "number",
"value": 6, "value": 6,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 1, "quantity": 1,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -1529,13 +1530,13 @@
}, },
{ {
"id": "exp1-number-2", "id": "exp1-number-2",
"name": "Number \u201c2\u201d (Expansion #1)", "name": "Number “2” (Expansion #1)",
"set": "expansion1", "set": "expansion1",
"cardType": "number", "cardType": "number",
"value": 2, "value": 2,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 5, "quantity": 5,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -1545,13 +1546,13 @@
}, },
{ {
"id": "exp1-number-3", "id": "exp1-number-3",
"name": "Number \u201c3\u201d (Expansion #1)", "name": "Number “3” (Expansion #1)",
"set": "expansion1", "set": "expansion1",
"cardType": "number", "cardType": "number",
"value": 3, "value": 3,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 4, "quantity": 4,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -1561,13 +1562,13 @@
}, },
{ {
"id": "exp1-number-4", "id": "exp1-number-4",
"name": "Number \u201c4\u201d (Expansion #1)", "name": "Number “4” (Expansion #1)",
"set": "expansion1", "set": "expansion1",
"cardType": "number", "cardType": "number",
"value": 4, "value": 4,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 3, "quantity": 3,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -1577,13 +1578,13 @@
}, },
{ {
"id": "exp1-number-5", "id": "exp1-number-5",
"name": "Number \u201c5\u201d (Expansion #1)", "name": "Number “5” (Expansion #1)",
"set": "expansion1", "set": "expansion1",
"cardType": "number", "cardType": "number",
"value": 5, "value": 5,
"subtypes": [], "subtypes": [],
"los": false, "los": false,
"text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no \u201c1\u201d number cards; any card played without a number card has power 1.", "text": "Number cards have three purposes: add to your movement for the turn; set the DURATION (in turns) of a duration spell; or set the POWER (damage points) of a spell. Only one NUMBER card can be played per action. There are no “1” number cards; any card played without a number card has power 1.",
"quantity": 2, "quantity": 2,
"faqRulings": [], "faqRulings": [],
"verification": { "verification": {
@@ -1805,8 +1806,7 @@
"text": "Covers the surface of one square of the floor with acidic ooze. Any character or monster entering the square takes 1 point of damage, and on a roll of 1 or 2 falls down, drops treasure, takes 2 more points of physical damage, and is unable to move for the rest of the turn. Any following turns that he tries to stand and leave must be rolled again as stated. Only 5 points of fire damage will destroy the ooze.", "text": "Covers the surface of one square of the floor with acidic ooze. Any character or monster entering the square takes 1 point of damage, and on a roll of 1 or 2 falls down, drops treasure, takes 2 more points of physical damage, and is unable to move for the rest of the turn. Any following turns that he tries to stand and leave must be rolled again as stated. Only 5 points of fire damage will destroy the ooze.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"This is a MONSTER. No, it does not block LOS, just lays in a layer on the floor, and is not moveable. Only fire, and no other type of attack, will hurt the ooze (except MONSTER BANE for 5 points).", "This is a MONSTER. No, it does not block LOS, just lays in a layer on the floor, and is not moveable. Only fire, and no other type of attack, will hurt the ooze (except MONSTER BANE for 5 points)."
"Large Rock/Dagger These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -2051,7 +2051,7 @@
"text": "Wand covers enemy with webs, reducing movement by 3 (enemy can still use NUMBER cards for additional movement). Lasts one turn. Any fire damage done to player in webs causes two extra points of damage due to webs burning. The wand has a limited number of charges determined by NUMBER card played when first used. Discard the wand when last charge is gone.", "text": "Wand covers enemy with webs, reducing movement by 3 (enemy can still use NUMBER cards for additional movement). Lasts one turn. Any fire damage done to player in webs causes two extra points of damage due to webs burning. The wand has a limited number of charges determined by NUMBER card played when first used. Discard the wand when last charge is gone.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"Webs, if burned, disappear immediately. h" "Webs, if burned, disappear immediately."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -3568,8 +3568,7 @@
"text": "This spell lets you swap any two items (uncarried), people, monsters, or immovable created object, or any combination of the same, as long as both are in your LOS! In the case of swapping a person for a created wall, the person could end up in either square adjacent to where the wall was, and vice-versa. Both objects, once swapped, must still be in your LOS afterwards.", "text": "This spell lets you swap any two items (uncarried), people, monsters, or immovable created object, or any combination of the same, as long as both are in your LOS! In the case of swapping a person for a created wall, the person could end up in either square adjacent to where the wall was, and vice-versa. Both objects, once swapped, must still be in your LOS afterwards.",
"quantity": null, "quantity": null,
"faqRulings": [ "faqRulings": [
"Though not intended to be used this way, it is possible to use TINY SWAP to move yourself, since you are in your own LOS. Yes, this is a much more powerful version of TRADER. No, you may not put immovable objects on a player's home base.", "Though not intended to be used this way, it is possible to use TINY SWAP to move yourself, since you are in your own LOS. Yes, this is a much more powerful version of TRADER. No, you may not put immovable objects on a player's home base."
"Torquemada If your opponent has run out of fingers and teeth, then he can probably no longer deal cards anyway, so any player with functional eyes left will have to read all the players' cards to them. Ha! Just kidding. Not a real card (Not yet, anyway)."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -3669,4 +3668,4 @@
} }
} }
] ]
} }
+101 -16
View File
@@ -31,13 +31,15 @@ interface TierTraits {
amplify: boolean; amplify: boolean;
ambush: boolean; ambush: boolean;
guardGold: boolean; guardGold: boolean;
/** Shed dead cards at end of turn to keep the draw flowing. */
sheds: boolean;
buffs: boolean; buffs: boolean;
dejaVu: boolean; dejaVu: boolean;
} }
const TIERS: Record<AutomatonTier, TierTraits> = { const TIERS: Record<AutomatonTier, TierTraits> = {
apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false }, apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false, sheds: false },
adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true }, adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true, sheds: true },
archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true }, archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true, sheds: true },
}; };
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */ /** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
@@ -53,8 +55,18 @@ const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?:
"large-rock": { base: 2, perNumber: false }, "large-rock": { base: 2, perNumber: false },
"blaster-wand": { base: 3, perNumber: false }, "blaster-wand": { base: 3, perNumber: false },
disease: { base: 3, perNumber: false, sameSquare: true }, disease: { base: 3, perNumber: false, sameSquare: true },
"butt-head": { base: 2, perNumber: false },
"heave-ho": { base: 2, perNumber: false },
"illusionary-attack": { base: 3, perNumber: false },
}; };
/** Attacks that carry no points: dropping, dragging, swapping, peeking.
* A damage counter drains against nothing — only a full stop denies them. */
const UTILITY_ATTACKS = new Set([
"drop-object", "teleport-opponent", "mental-force", "mental-swap", "swap",
"swap-meet", "card-erasure", "telepath", "sticky-wand", "shift-wand",
]);
/** /**
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a * Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
* number for the duration where one helps. * number for the duration where one helps.
@@ -224,6 +236,25 @@ function overLimit(view: GameView): number {
return Math.max(0, view.yourHand.length - 7); return Math.max(0, view.yourHand.length - 7);
} }
/** End the turn drawing fully — shedding dead weight first if the hand is
* too full to take the draw. doEndTurn caps the draw by the room left, so a
* clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits): Command {
const limit = me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
const deficit = tier.draw - (limit - view.yourHand.length);
if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand]
.filter((c) => discardValue(c) <= 3)
.sort((a, b) => discardValue(a) - discardValue(b))
.slice(0, deficit)
.map((c) => c.instanceId);
if (shed.length > 0) return { type: "discard", instanceIds: shed };
}
return { type: "endTurn", draw: tier.draw };
}
function worstCards(view: GameView, n: number): string[] { function worstCards(view: GameView, n: number): string[] {
return [...view.yourHand] return [...view.yourHand]
.sort((a, b) => discardValue(a) - discardValue(b)) .sort((a, b) => discardValue(a) - discardValue(b))
@@ -258,7 +289,7 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
if (stack.attackerId === you) { if (stack.attackerId === you) {
const last = [...stack.counters].reverse().find((c) => !c.nullified); const last = [...stack.counters].reverse().find((c) => !c.nullified);
const anti = find("anti-anti"); const anti = find("anti-anti");
if (anti && last && last.card.cardId !== "teleport") { if (anti && last && last.card.cardId !== "teleport" && last.card.cardId !== "invisible") {
return { type: "counteract", instanceId: anti.instanceId }; return { type: "counteract", instanceId: anti.instanceId };
} }
} }
@@ -269,8 +300,22 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
const attackId = stack.attackCard?.cardId ?? null; const attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null; const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false; const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
if (attackId && UTILITY_ATTACKS.has(attackId)) {
const stopper = find("full-shield") ?? find("force-field");
const stakes =
(attackId === "drop-object" && me(view).carriedTreasureId != null) ||
((attackId === "teleport-opponent" || attackId === "mental-force" || attackId === "swap") &&
style === "worrier");
if (stopper && stakes) return { type: "counteract", instanceId: stopper.instanceId };
return { type: "pass" };
}
const incoming = stack.attackCard const incoming = stack.attackCard
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) ? attackId === "stone-dead"
// NUMBER times the stones in the victim's hand — our hand.
? (stack.numberValue ?? 1) * view.yourHand.filter((c) => STONES.has(c.cardId)).length
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1; : 1;
if (stack.kind === "spell") { if (stack.kind === "spell") {
// REVERSE turns the biggest blasts into a meal. // REVERSE turns the biggest blasts into a meal.
@@ -308,6 +353,15 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
const out = escapeCell(view, me(view).position); const out = escapeCell(view, me(view).position);
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } }; if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
} }
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
const vanish = find("invisible");
if (vanish && (!stack.creatureId || view.deckRev >= 13) && incoming >= 3 - flinch) {
const num = numbersInHand(view)[0];
return {
type: "counteract", instanceId: vanish.instanceId,
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
};
}
// A displayed shieldstone lets numbers soak the small ones. // A displayed shieldstone lets numbers soak the small ones.
const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone"); const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone");
if (stoneShown && incoming >= 2) { if (stoneShown && incoming >= 2) {
@@ -329,6 +383,9 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Comma
for (const c of view.yourHand) { for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId]; const atk = ATTACKS[c.cardId];
if (!atk) continue; if (!atk) continue;
// Heave-Ho throws the treasure we are carrying: priced for defense,
// never worth casting.
if (c.cardId === "heave-ho") continue;
if (atk.sameSquare && !together) continue; if (atk.sameSquare && !together) continue;
// A wand needs its charges set by a number on first use. // A wand needs its charges set by a number on first use.
const isWand = c.cardId === "blaster-wand"; const isWand = c.cardId === "blaster-wand";
@@ -347,12 +404,31 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Comma
cmd: { cmd: {
type: "cast", instanceId: c.instanceId, type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId }, target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}), ...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}), ...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
}, },
}; };
} }
} }
// STONE DEAD: number times the stones the victim carries — the displayed
// ones are the visible floor of that count.
const sd = view.yourHand.find((c) => c.cardId === "stone-dead");
if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown;
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
best = {
damage: dmg,
cmd: {
type: "cast", instanceId: sd.instanceId,
target: { kind: "player", playerId: targetId },
numberInstanceIds: [biggest.instanceId],
},
};
}
}
return best?.cmd ?? null; return best?.cmd ?? null;
} }
@@ -465,12 +541,11 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } }; return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } };
} }
const glue = inHand(view, "glue"); const glue = inHand(view, "glue");
const midN = numbers[Math.floor(numbers.length / 2)]; if (glue && mid) {
if (glue && midN) {
return { return {
type: "cast", instanceId: glue.instanceId, type: "cast", instanceId: glue.instanceId,
target: { kind: "cell", cell: myFloorTreasure.position! }, target: { kind: "cell", cell: myFloorTreasure.position! },
numberInstanceIds: [midN.instanceId], numberInstanceIds: [mid.instanceId],
}; };
} }
} }
@@ -504,7 +579,7 @@ export function automatonCommand(
style: AutomatonStyle = "hunter", style: AutomatonStyle = "hunter",
tierName: AutomatonTier = "archmage", tierName: AutomatonTier = "archmage",
): Command | null { ): Command | null {
const you = view.you as PlayerId; const you = view.you;
const tier = TIERS[tierName] ?? TIERS.archmage; const tier = TIERS[tierName] ?? TIERS.archmage;
if (view.phase !== "playing") return null; if (view.phase !== "playing") return null;
@@ -526,7 +601,7 @@ export function automatonCommand(
const self = me(view); const self = me(view);
const here = cellKey(self.position); const here = cellKey(self.position);
if (view.turn.actionsEnded) return { type: "endTurn", draw: tier.draw }; if (view.turn.actionsEnded) return endTurnDrawing(view, tier);
// Deliver or grab treasure underfoot. // Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
@@ -637,9 +712,19 @@ export function automatonCommand(
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed; const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) { if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) {
const numbers = numbersInHand(view); const numbers = numbersInHand(view);
const helper = numbers.find( // The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn.
const wantsNumber = view.yourHand.some((c) => {
const atk = ATTACKS[c.cardId];
if (!atk) return false;
return atk.perNumber || atk.needsNumber === true ||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
});
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
const helper = spendable.find(
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance, (n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
) ?? (path.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined); ) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined);
if (helper) { if (helper) {
return { type: "playNumberForMovement", instanceId: helper.instanceId }; return { type: "playNumberForMovement", instanceId: helper.instanceId };
} }
@@ -649,15 +734,15 @@ export function automatonCommand(
} }
} }
return { type: "endTurn", draw: tier.draw }; return endTurnDrawing(view, tier);
} }
/** The safe fallback when the automaton's choice was refused. */ /** The safe fallback when the automaton's choice was refused. */
export function automatonFallback(view: GameView): Command { export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
const you = view.you as PlayerId; const you = view.you;
if (view.pendingDiscard === you) { if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) }; return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
} }
if (view.stack || view.chaosPending || view.outOfTurnWindow) return { type: "pass" }; if (view.stack || view.chaosPending || view.outOfTurnWindow) return { type: "pass" };
return { type: "endTurn", draw: 2 }; return { type: "endTurn", draw: (TIERS[tierName] ?? TIERS.archmage).draw };
} }
+4 -7
View File
@@ -27,8 +27,6 @@ export interface Warp {
from: { cell: Cell; side: Side }; from: { cell: Cell; side: Side };
/** ...you arrive at this cell (entering through `side`). */ /** ...you arrive at this cell (entering through `side`). */
to: { cell: Cell; side: Side }; to: { cell: Cell; side: Side };
/** An aisle (AUTO) warp joins two edges that meet at a corner. */
aisle?: boolean;
} }
export interface AssembledBoard { export interface AssembledBoard {
@@ -121,7 +119,7 @@ export interface AssemblyOptions {
* openings pair straight across the map — correct for the 4p square; the * openings pair straight across the map — correct for the 4p square; the
* standard 2p column crosses its side openings and must pass pairs. * standard 2p column crosses its side openings and must pass pairs.
*/ */
warpPairs?: ([OpeningRef, OpeningRef] | [OpeningRef, OpeningRef, "aisle"])[]; warpPairs?: [OpeningRef, OpeningRef][];
} }
/** /**
@@ -206,12 +204,11 @@ export function assembleBoard(
}; };
if (options.warpPairs) { if (options.warpPairs) {
for (const [a, b, kind] of options.warpPairs) { for (const [a, b] of options.warpPairs) {
const ca = openingCell(a.sector, a.side); const ca = openingCell(a.sector, a.side);
const cb = openingCell(b.sector, b.side); const cb = openingCell(b.sector, b.side);
const aisle = kind === "aisle" ? { aisle: true as const } : {}; warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side } });
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side }, ...aisle }); warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side } });
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side }, ...aisle });
} }
} else { } else {
for (let i = 0; i < placements.length; i++) { for (let i = 0; i < placements.length; i++) {
+136 -31
View File
@@ -199,6 +199,8 @@ export interface CastParams {
cardId?: string; cardId?: string;
points?: number; points?: number;
clockwise?: boolean; clockwise?: boolean;
/** MEGA-MONSTER: which stat doubles. */
boost?: "life" | "movement";
} }
export interface GameConfig { export interface GameConfig {
@@ -218,9 +220,8 @@ export interface GameConfig {
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points, * BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a * and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a
* teleport escape ("does not work against escape" — the card face). * teleport escape ("does not work against escape" — the card face).
* Rev 7 briefly restricted warp sight to aisle corners; the table's * Rev 7: no behavioral change — every board opening carries warp sight
* reading prevailed and all board openings carry sight in every revision. * in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* warp." — the card face). Rev 8: nothing can be created on a warp token, * warp." — the card face). Rev 8: nothing can be created on a warp token,
* and a SPEED bonus turn burns a turn of durations on the hastened wizard. * and a SPEED bonus turn burns a turn of durations on the hastened wizard.
*/ */
@@ -634,7 +635,7 @@ export type Command =
} }
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] } | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] }
| { type: "cancelAmbush"; ambushId: string } | { type: "cancelAmbush"; ambushId: string }
| { type: "counteract"; instanceId: string; params?: { cell?: Cell } } | { type: "counteract"; instanceId: string; params?: { cell?: Cell }; numberInstanceIds?: string[] }
| { type: "pass" } | { type: "pass" }
| { type: "pickUpTreasure" } | { type: "pickUpTreasure" }
| { type: "pickUpObject"; instanceId: string } | { type: "pickUpObject"; instanceId: string }
@@ -1466,7 +1467,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!creature) return "no such monster"; if (!creature) return "no such monster";
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster"; if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
if (!gameLos(state, caster.position, creature.position)) return "no line of sight"; if (!gameLos(state, caster.position, creature.position)) return "no line of sight";
const boost = cmd.params?.cardId === "movement" ? "movement" : "life"; const boost = cmd.params?.boost === "movement" ? "movement" : "life";
if (boost === "movement") creature.movesPerTurn *= 2; if (boost === "movement") creature.movesPerTurn *= 2;
else creature.maxDamage *= 2; else creature.maxDamage *= 2;
events.push({ type: "monsterBoosted", creatureId: creature.id, boost }); events.push({ type: "monsterBoosted", creatureId: creature.id, boost });
@@ -2806,6 +2807,24 @@ function remapState(
for (const t of state.treasures) { for (const t of state.treasures) {
if (t.position && inSector(t.position)) t.position = mapCell(t.position); if (t.position && inSector(t.position)) t.position = mapCell(t.position);
} }
// A moving sector carries everything standing on it. Earlier revisions left
// creatures, traps, glue, safes, and warp tokens at their old coordinates;
// their stored games must replay with that flaw intact (rules rev 11).
if ((state.config.deckRev ?? 1) >= 11) {
for (const c of state.creatures) {
if (inSector(c.position)) c.position = mapCell(c.position);
}
for (const t of state.boobytraps) {
t.cells = t.cells.map((c) => (inSector(c) ? mapCell(c) : c));
t.realKey = mapCellKey(t.realKey);
}
state.gluedCells = remapRecord(state.gluedCells, mapCellKey);
state.openSafes = state.openSafes.map(mapCellKey);
for (const w of state.dimWarps) {
if (inSector(w.a)) w.a = mapCell(w.a);
if (inSector(w.b)) w.b = mapCell(w.b);
}
}
} }
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */ /** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
@@ -2839,7 +2858,11 @@ function relocateSector(state: GameState, index: number, dest: Cell): string | n
const placements = state.board.placements; const placements = state.board.placements;
const current = placements[index]!.origin; const current = placements[index]!.origin;
if (dest.x === current.x && dest.y === current.y) return "the sector is already there"; if (dest.x === current.x && dest.y === current.y) return "the sector is already there";
if (dest.x < 0 || dest.y < 0) return "the sector cannot go there"; // The grid has no wall at zero: a sector may land beyond the old origin and
// the whole maze renormalizes back into positive coordinates (rules rev 11).
if ((state.config.deckRev ?? 1) < 11 && (dest.x < 0 || dest.y < 0)) {
return "the sector cannot go there";
}
for (let i = 0; i < placements.length; i++) { for (let i = 0; i < placements.length; i++) {
if (i === index) continue; if (i === index) continue;
const o = placements[i]!.origin; const o = placements[i]!.origin;
@@ -2861,9 +2884,24 @@ function relocateSector(state: GameState, index: number, dest: Cell): string | n
const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy }); const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy });
const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p)); const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p));
// Zero-anchor the maze after the move: negative landings shift back into
// positive coordinates, and vacating the origin corner pulls the maze snug
// instead of leaving a blank band (rules rev 11; older games never shift).
const shift = (state.config.deckRev ?? 1) >= 11
? {
x: -Math.min(...newPlacements.map((p) => p.origin.x)),
y: -Math.min(...newPlacements.map((p) => p.origin.y)),
}
: { x: 0, y: 0 };
const shifted = shift.x || shift.y
? newPlacements.map((p) => ({ ...p, origin: { x: p.origin.x + shift.x, y: p.origin.y + shift.y } }))
: newPlacements;
// "Only opposite board edges connect" after a relocation: default pairings. // "Only opposite board edges connect" after a relocation: default pairings.
state.board = assembleBoard(newPlacements); state.board = assembleBoard(shifted);
remapState(state, inSector, mapCell, (s) => s); remapState(state, inSector, mapCell, (s) => s);
if (shift.x || shift.y) {
remapState(state, () => true, (c) => ({ x: c.x + shift.x, y: c.y + shift.y }), (s) => s);
}
return null; return null;
} }
@@ -3159,7 +3197,7 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
if (state.stack) { if (state.stack) {
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response"); if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params); if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params, command.numberInstanceIds);
if (command.type === "pass") return doPass(state, playerId); if (command.type === "pass") return doPass(state, playerId);
return err("an attack is being resolved — counteract or pass"); return err("an attack is being resolved — counteract or pass");
} }
@@ -3297,6 +3335,17 @@ function clone(state: GameState): GameState {
return structuredClone(state); return structuredClone(state);
} }
/** Everything an eliminated wizard leaves behind goes with them.
* "If you die, any monster controlled by you immediately disappears." */
function sweepEliminated(state: GameState, playerId: PlayerId): void {
state.sustained = state.sustained.filter((s) => s.targetId !== playerId && s.casterId !== playerId);
state.creatures = state.creatures.filter((c) => c.controllerId !== playerId);
for (const a of state.ambushes.filter((a) => a.ownerId === playerId)) {
state.discard.push(a.via, a.spell, ...a.numbers);
}
state.ambushes = state.ambushes.filter((a) => a.ownerId !== playerId);
}
function requireActionsAvailable(state: GameState): string | null { function requireActionsAvailable(state: GameState): string | null {
if (state.turn.actionsEnded) return "your turn's actions ended when you picked up an object"; if (state.turn.actionsEnded) return "your turn's actions ended when you picked up an object";
return null; return null;
@@ -4182,7 +4231,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const view = boardView(state); const view = boardView(state);
const current = view.edges[edgeKey(cell, side)] ?? "open"; const current = view.edges[edgeKey(cell, side)] ?? "open";
if (current !== "wall" && current !== "door") { if (current !== "wall" && current !== "door") {
return err("only walls, doors, and thornbushes can be attacked"); return err("there is no wall or door on that edge to attack");
} }
if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) { if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) {
return err("you must stand beside the wall"); return err("you must stand beside the wall");
@@ -4527,7 +4576,10 @@ function checkAmbushes(
} }
} }
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }): CommandResult { function doCounteract(
prev: GameState, playerId: PlayerId, instanceId: string,
params?: { cell?: Cell }, numberInstanceIds?: string[],
): CommandResult {
const state = clone(prev); const state = clone(prev);
const stack = state.stack!; const stack = state.stack!;
const player = state.players.find((p) => p.id === playerId)!; const player = state.players.find((p) => p.id === playerId)!;
@@ -4599,10 +4651,41 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
}; };
} }
// INVISIBLE (corner: NEUTRAL/COUNTERACTION): vanish as the bolt flies.
// The spell truly goes up — duration from an attached NUMBER card — and
// the attacker's 1-in-4 hit roll happens at resolution.
if (card.cardId === "invisible") {
if (stack.creatureId && (state.config.deckRev ?? 1) < 13) {
return err("in this game's revision, creatures are not fooled by the vanishing");
}
let duration = 1;
const attached = (numberInstanceIds ?? [])
.map((id) => player.hand.find((c) => c.instanceId === id))
.filter((c): c is CardInstance => c != null && isNumberCard(c.cardId));
const num = attached[0];
if (num) {
duration = numberValue(num.cardId);
takeFromHand(player, num.instanceId);
state.discard.push(num);
}
takeFromHand(player, instanceId);
state.discard.push(card);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
];
attachSustained(state, events, "invisible", playerId, playerId, duration);
stack.counters.push({ player: playerId, card, nullified: false });
stack.waitingOn = stack.attackerId;
state.lastSpellUsed[playerId] = card.cardId;
return { ok: true, state, events };
}
// WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT." // WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT."
if (card.cardId === "wall-of-fire") { // WATERWALL: "Acts as counteraction to FIREBALL" — the mirror image.
if (stack.attackCard?.cardId !== "waterbolt") { if (card.cardId === "wall-of-fire" || card.cardId === "waterwall") {
return err("as a counteraction, Wall of Fire only stops a Waterbolt"); const stops = card.cardId === "wall-of-fire" ? "waterbolt" : "fireball";
if (stack.attackCard?.cardId !== stops) {
return err(`as a counteraction, ${cardDef(card.cardId).name} only stops a ${cardDef(stops).name}`);
} }
takeFromHand(player, instanceId); takeFromHand(player, instanceId);
state.discard.push(card); state.discard.push(card);
@@ -4611,7 +4694,7 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
return { return {
ok: true, ok: true,
state, state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: "waterbolt" }], events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stops }],
}; };
} }
@@ -4681,6 +4764,9 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
if ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport") { if ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport") {
return err("ANTI-ANTI does not work against escape"); return err("ANTI-ANTI does not work against escape");
} }
if (targetCounter.card.cardId === "invisible") {
return err("ANTI-ANTI does not work against escape");
}
takeFromHand(player, instanceId); takeFromHand(player, instanceId);
state.discard.push(card); state.discard.push(card);
targetCounter.nullified = true; targetCounter.nullified = true;
@@ -4695,14 +4781,33 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, p
return err("you are not part of this exchange"); return err("you are not part of this exchange");
} }
/** Counters that leave the defender nothing worth adding: the spell is dead,
* escaped, thrown back whole, or drunk as a tonic. */
const TOTAL_STOP_COUNTERS = new Set([
"full-shield", "force-field", "teleport", "full-reflection", "reverse", "waterwall",
]);
function doPass(prev: GameState, playerId: PlayerId): CommandResult { function doPass(prev: GameState, playerId: PlayerId): CommandResult {
const state = clone(prev); const state = clone(prev);
const stack = state.stack!; const stack = state.stack!;
if (playerId === stack.attackerId) {
stack.waitingOn = stack.defenderId;
return { ok: true, state, events: [] };
}
const events: GameEvent[] = []; const events: GameEvent[] = [];
if (playerId === stack.attackerId) {
// The attacker declining to answer bounces the exchange back so the
// defender may stack further counters — but not past a total stop:
// re-prompting then reads as "your shield failed" and goads the table
// into burning cards against a spell that is already dead (rules rev 10).
const rev = state.config.deckRev ?? 1;
const totalStop = rev >= 10 &&
stack.counters.some((c) =>
!c.nullified &&
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
(rev >= 12 && c.card.cardId === "wall-of-fire")) &&
(stack.kind === "spell" || c.card.cardId === "teleport"));
if (!totalStop) {
stack.waitingOn = stack.defenderId;
return { ok: true, state, events };
}
}
resolveStack(state, events); resolveStack(state, events);
checkVictory(state, events); checkVictory(state, events);
return { ok: true, state, events }; return { ok: true, state, events };
@@ -4718,9 +4823,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null; const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and // Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
// SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." A // SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
// creature sharing the square has nothing to aim: no miss rolls. // Creature blows roll the same dice from rev 13 — the cards carve out no
if (!stack.creatureId && sustainedOn(state, defender.id, "invisible").length > 0) { // exception for monsters; earlier games skipped the rolls and replay so.
const missRolls = !stack.creatureId || (state.config.deckRev ?? 1) >= 13;
if (missRolls && sustainedOn(state, defender.id, "invisible").length > 0) {
const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them"); const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
if (roll !== 1) { if (roll !== 1) {
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" }); events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" });
@@ -4728,7 +4835,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
return; return;
} }
} }
if (!stack.creatureId && sustainedOn(state, defender.id, "shrink").length > 0) { if (missRolls && sustainedOn(state, defender.id, "shrink").length > 0) {
const roll = rollD4(state, events, attacker.id, "aiming at the tiny — 1-2 hits"); const roll = rollD4(state, events, attacker.id, "aiming at the tiny — 1-2 hits");
if (roll > 2) { if (roll > 2) {
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" }); events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" });
@@ -4800,12 +4907,15 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
pipe.duration = Math.max(0, pipe.duration - v); pipe.duration = Math.max(0, pipe.duration - v);
continue; continue;
} }
if (counter.card.cardId === "wall-of-fire") { if (counter.card.cardId === "wall-of-fire" || counter.card.cardId === "waterwall") {
// The wave meets the fire: the waterbolt is entirely stopped. // Fire meets water, whichever was thrown first: entirely stopped.
pipe.damage = 0; pipe.damage = 0;
pipe.fullyStopped = true; pipe.fullyStopped = true;
continue; continue;
} }
if (counter.card.cardId === "invisible") {
continue; // its work is the sustained vanishing and the hit roll above
}
if (counter.card.cardId === "teleport") { if (counter.card.cardId === "teleport") {
pipe.damage = 0; pipe.damage = 0;
pipe.duration = 0; pipe.duration = 0;
@@ -4995,13 +5105,7 @@ function applyDamage(
target.finalHand = [...target.hand]; target.finalHand = [...target.hand];
events.push({ type: "died", player: target.id, killedBy: attackerId }); events.push({ type: "died", player: target.id, killedBy: attackerId });
events.push({ type: "playerEliminated", player: target.id, reason: "killed" }); events.push({ type: "playerEliminated", player: target.id, reason: "killed" });
state.sustained = state.sustained.filter((s) => s.targetId !== target.id && s.casterId !== target.id); sweepEliminated(state, target.id);
// "If you die, any monster controlled by you immediately disappears."
state.creatures = state.creatures.filter((c) => c.controllerId !== target.id);
for (const a of state.ambushes.filter((a) => a.ownerId === target.id)) {
state.discard.push(a.via, a.spell, ...a.numbers);
}
state.ambushes = state.ambushes.filter((a) => a.ownerId !== target.id);
if (target.carriedTreasureId) { if (target.carriedTreasureId) {
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!; const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
@@ -5182,6 +5286,7 @@ function checkVictory(state: GameState, events: GameEvent[]): void {
state.discard.push(...p.hand.splice(0)); state.discard.push(...p.hand.splice(0));
p.displayed = []; p.displayed = [];
events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" }); events.push({ type: "playerEliminated", player: p.id, reason: "treasuresLost" });
if ((state.config.deckRev ?? 1) >= 14) sweepEliminated(state, p.id);
} }
} }
+5 -5
View File
@@ -68,7 +68,7 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
]; ];
const board = assembleBoard(placements, { const board = assembleBoard(placements, {
warpPairs: [ warpPairs: [
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // AUTO WARP corner [{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // AUTO WARP corner
[{ sector: 0, side: "N" }, { sector: 2, side: "S" }], [{ sector: 0, side: "N" }, { sector: 2, side: "S" }],
[{ sector: 1, side: "W" }, { sector: 2, side: "E" }], [{ sector: 1, side: "W" }, { sector: 2, side: "E" }],
[{ sector: 0, side: "E" }, { sector: 1, side: "S" }], [{ sector: 0, side: "E" }, { sector: 1, side: "S" }],
@@ -106,10 +106,10 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
warpPairs: [ warpPairs: [
[{ sector: 0, side: "N" }, { sector: 4, side: "S" }], // tip to tip [{ sector: 0, side: "N" }, { sector: 4, side: "S" }], // tip to tip
[{ sector: 1, side: "W" }, { sector: 3, side: "E" }], [{ sector: 1, side: "W" }, { sector: 3, side: "E" }],
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // aisle-warp corners [{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // aisle-warp corners
[{ sector: 0, side: "E" }, { sector: 3, side: "N" }, "aisle"], [{ sector: 0, side: "E" }, { sector: 3, side: "N" }],
[{ sector: 4, side: "W" }, { sector: 1, side: "S" }, "aisle"], [{ sector: 4, side: "W" }, { sector: 1, side: "S" }],
[{ sector: 4, side: "E" }, { sector: 3, side: "S" }, "aisle"], [{ sector: 4, side: "E" }, { sector: 3, side: "S" }],
], ],
}); });
return { board, rng: rngN }; return { board, rng: rngN };
+35 -4
View File
@@ -40,6 +40,8 @@ export interface GameView {
winReason: "treasures" | "lastStanding" | null; winReason: "treasures" | "lastStanding" | null;
turn: TurnState; turn: TurnState;
activePlayerId: PlayerId; activePlayerId: PlayerId;
/** The game's rules revision — the client mirrors rev-gated legality. */
deckRev: number;
/** Board with dynamic wall changes already merged in. */ /** Board with dynamic wall changes already merged in. */
board: AssembledBoard; board: AssembledBoard;
players: PlayerPublicView[]; players: PlayerPublicView[];
@@ -120,6 +122,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
revealedHands: state.phase === "finished" revealedHands: state.phase === "finished"
? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]])) ? Object.fromEntries(state.players.map((p) => [p.id, p.alive ? [...p.hand] : [...(p.finalHand ?? p.hand)]]))
: null, : null,
deckRev: state.config.deckRev ?? 1,
treasures: state.treasures.map((t) => ({ ...t })), treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length, deckCount: state.deck.length,
discardCount: state.discard.length, discardCount: state.discard.length,
@@ -185,7 +188,7 @@ export function sightedCellsFor(view: GameView): Set<string> {
const CREATION_CARD_IDS = new Set([ const CREATION_CARD_IDS = new Set([
"fill-square-with-stone", "thornbush", "killer-ooze", "rosebush", "dust-cloud", "fill-square-with-stone", "thornbush", "killer-ooze", "rosebush", "dust-cloud",
"fill-square-with-slime", "create-pit", "handful-of-tacks", "glue", "safe", "boobytrap", "fill-square-with-slime", "create-pit", "handful-of-tacks",
]); ]);
const SUMMON_CARD_IDS = new Set([ const SUMMON_CARD_IDS = new Set([
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow", "troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
@@ -209,7 +212,6 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
const out = new Set<string>(); const out = new Set<string>();
for (const k of cells) { for (const k of cells) {
if (view.squareContents[k]) continue; if (view.squareContents[k]) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue; if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue;
if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue; if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue;
if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue; if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue;
@@ -217,7 +219,6 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue; if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue;
if (!sighted.has(k)) continue; if (!sighted.has(k)) continue;
out.add(k); out.add(k);
void x; void y;
} }
return out; return out;
} }
@@ -256,11 +257,41 @@ export function eligibleCellsFor(view: GameView, cardId: string): Set<string> |
return out; return out;
} }
if (cardId === "boobytrap") {
// Tokens go anywhere on the board except solid stone — no sight needed;
// a trap you can see coming is a poor trap.
return new Set(cells.filter((k) => view.squareContents[k]?.kind !== "stone"));
}
if (cardId === "glue" || cardId === "safe") {
// Both seize a square that HOLDS something — a loose object or an
// unclaimed treasure — within the caster's sight. The safe additionally
// needs the square clear of terrain to build around its prize.
const out = new Set<string>();
for (const k of cells) {
if (cardId === "safe" && view.squareContents[k]) continue;
const hasObject =
(view.groundObjects[k] ?? []).length > 0 ||
view.treasures.some((t) => t.position && !t.carriedBy && key(t.position.x, t.position.y) === k);
if (!hasObject) continue;
if (!sighted.has(k)) continue;
out.add(k);
}
return out;
}
if (cardId === "stone-to-water") { if (cardId === "stone-to-water") {
return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone")); return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone"));
} }
if (cardId === "dispel-creation") { if (cardId === "dispel-creation") {
return new Set(cells.filter((k) => view.squareContents[k] != null)); // Anything created — terrain or creature, whoever made it — in sight.
const out = new Set<string>();
for (const k of cells) {
const created = view.squareContents[k] != null ||
view.creatures.some((c) => key(c.position.x, c.position.y) === k);
if (created && sighted.has(k)) out.add(k);
}
return out;
} }
return null; return null;
} }
+94 -5
View File
@@ -39,11 +39,14 @@ function playOut(
while (state.phase === "playing" && commands < CAP) { while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state); const seat = actingSeat(state);
const view = viewFor(state, seat); const view = viewFor(state, seat);
const cmd = automatonCommand(view, styleOf.get(seat), tierOf.get(seat)) ?? automatonFallback(view); const tier = tierOf.get(seat);
const chosen = automatonCommand(view, styleOf.get(seat), tier);
const cmd = chosen ?? automatonFallback(view, tier);
let r = applyCommand(state, seat, cmd); let r = applyCommand(state, seat, cmd);
if (!r.ok) { if (!r.ok) {
const fb = automatonFallback(view); // Retry with the fallback only if the fallback was not what just failed.
r = applyCommand(state, seat, fb); const fb = automatonFallback(view, tier);
if (chosen) r = applyCommand(state, seat, fb);
if (!r.ok) { if (!r.ok) {
stuck++; stuck++;
if (stuck > 3) { if (stuck > 3) {
@@ -68,10 +71,9 @@ function playOut(
describe("automaton vs automaton", () => { describe("automaton vs automaton", () => {
it("two clockwork wizards fight a game to its end", () => { it("two clockwork wizards fight a game to its end", () => {
const { state, commands } = playOut(11, 2); const { state } = playOut(11, 2);
expect(state.phase).toBe("finished"); expect(state.phase).toBe("finished");
expect(state.winner).not.toBeNull(); expect(state.winner).not.toBeNull();
expect(commands).toBeLessThan(4000);
}); });
it("holds up across many seeds without wedging", () => { it("holds up across many seeds without wedging", () => {
@@ -114,3 +116,90 @@ 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) {
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
// burn round 1
for (let i = 0; i < 2; i++) {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
while (actingSeat(state) !== "human") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
const human = state.players.find((p: { id: string }) => p.id === "human")!;
const bot = state.players.find((p: { id: string }) => p.id === "bot")!;
bot.position = { ...human.position };
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
rig?.(state, "bot");
const r = applyCommand(state, "human", {
type: "cast", instanceId: `${attackId}#A`, target: { kind: "player", playerId: "bot" },
...(attackId === "drop-object" ? { params: { cardId: "dagger" } } : {}),
});
if (!r.ok) throw new Error(`setup: ${r.error}`);
return r.state;
}
it("passes on drop-object rather than absorbing nothing", () => {
const state = underAttack("drop-object", [
{ instanceId: "absorb#T", cardId: "absorb" },
{ instanceId: "blunt#T", cardId: "blunt" },
{ instanceId: "dagger#T", cardId: "dagger" },
]);
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "pass" });
});
it("shields a drop-object aimed at its carried treasure", () => {
const state = underAttack("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)!;
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
});
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "counteract", instanceId: "full-shield#T" });
});
});
describe("a clogged hand gets shed, not hoarded", () => {
it("the bot discards dead weight so the end-of-turn draw has room", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 13 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
// Seven situational neutrals the brain has no play for: a dead hand.
bot.hand = Array.from({ length: 7 }, (_, i) => (
{ instanceId: `illusion-wall#${i}`, cardId: "illusion-wall" }
));
// Walk the bot's turn until it wants to end: it must shed before drawing.
for (let guard = 0; guard < 30; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "hunter", "archmage")!;
if (cmd.type === "discard") {
expect(cmd.instanceIds.length).toBe(2);
const r = applyCommand(state, "bot", cmd);
if (!r.ok) throw new Error(r.error);
state = r.state;
const next = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")!;
expect(next).toEqual({ type: "endTurn", draw: 2 });
return;
}
if (cmd.type === "endTurn") throw new Error("ended the turn without shedding a dead hand");
const r = applyCommand(state, "bot", cmd);
if (!r.ok) throw new Error(`${cmd.type}: ${r.error}`);
state = r.state;
}
throw new Error("never reached the end of the turn");
});
});
+13 -16
View File
@@ -9,6 +9,10 @@ import {
type SectorPlacement, type SectorPlacement,
} from "../src/board"; } from "../src/board";
import { buildDeck } from "../src/cards"; import { buildDeck } from "../src/cards";
import { activePlayer, applyCommand, createGame } from "../src/game";
import { createRng } from "../src/rng";
import { setupBoard } from "../src/setups";
import { giveCard } from "./helpers";
describe("card data", () => { describe("card data", () => {
it("basic deck is exactly 125 cards (official 6e rulebook)", () => { it("basic deck is exactly 125 cards (official 6e rulebook)", () => {
@@ -109,9 +113,7 @@ describe("board assembly", () => {
}); });
describe("player counts 2-6", () => { describe("player counts 2-6", () => {
it("assembles a coherent board for every supported count", async () => { it("assembles a coherent board for every supported count", () => {
const { setupBoard } = await import("../src/setups");
const { createRng } = await import("../src/rng");
for (const n of [2, 3, 4, 5, 6]) { for (const n of [2, 3, 4, 5, 6]) {
const { board } = setupBoard(n, createRng(n * 7)); const { board } = setupBoard(n, createRng(n * 7));
expect(board.homes.length).toBe(n); expect(board.homes.length).toBe(n);
@@ -127,8 +129,7 @@ describe("player counts 2-6", () => {
} }
}); });
it("full games start at every count", async () => { it("full games start at every count", () => {
const { createGame } = await import("../src/game");
for (const n of [3, 5, 6]) { for (const n of [3, 5, 6]) {
const ids = Array.from({ length: n }, (_, i) => `w${i}`); const ids = Array.from({ length: n }, (_, i) => `w${i}`);
const { state } = createGame({ playerIds: ids, seed: 99 + n, sets: ["basic", "expansion1"] }); const { state } = createGame({ playerIds: ids, seed: 99 + n, sets: ["basic", "expansion1"] });
@@ -158,12 +159,7 @@ describe("sight and the wraparound openings", () => {
}); });
}); });
import { createRng } from "../src/rng";
import { setupBoard as setupBoardForPins } from "../src/setups";
describe("setup diagram pairings (rulebook Set-Up Diagram)", () => { describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
const setupBoard = setupBoardForPins;
function onEdge(c: { x: number; y: number }, origin: { x: number; y: number }, side: string): boolean { function onEdge(c: { x: number; y: number }, origin: { x: number; y: number }, side: string): boolean {
if (side === "N") return c.y === origin.y && c.x >= origin.x && c.x < origin.x + 5; if (side === "N") return c.y === origin.y && c.x >= origin.x && c.x < origin.x + 5;
if (side === "S") return c.y === origin.y + 4 && c.x >= origin.x && c.x < origin.x + 5; if (side === "S") return c.y === origin.y + 4 && c.x >= origin.x && c.x < origin.x + 5;
@@ -203,17 +199,16 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
} }
}); });
it("relocation discards the aisle warp: only opposite edges connect after", async () => { it("relocation discards the aisle warp: only opposite edges connect after", () => {
const { createGame, applyCommand, activePlayer } = await import("../src/game");
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 }); let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
const bent = (warps: typeof state.board.warps) => const bent = (warps: typeof state.board.warps) =>
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y); warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
const me = activePlayer(state); const me = activePlayer(state);
me.hand[0] = { instanceId: "rel#1", cardId: "relocate-sector" }; const rel = giveCard(state, me.id, "relocate-sector");
// Slide the top sector from (5,0) across to (0,0): an L becomes a block. // Slide the top sector from (5,0) across to (0,0): an L becomes a block.
const r = applyCommand(state, me.id, { const r = applyCommand(state, me.id, {
type: "cast", instanceId: "rel#1", type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } }, target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } },
}); });
if (!r.ok) throw new Error(r.error); if (!r.ok) throw new Error(r.error);
@@ -223,8 +218,10 @@ describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
describe("the aisle warp treats its corner as adjacent — sight included", () => { describe("the aisle warp treats its corner as adjacent — sight included", () => {
it("sight passes through the AUTO WARP, diagonals and all", () => { it("sight passes through the AUTO WARP, diagonals and all", () => {
const { board } = setupBoardForPins(3, createRng(7)); const { board } = setupBoard(3, createRng(7));
const aisle = board.warps.find((w) => w.aisle)!; const aisle = board.warps.find(
(w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y,
)!;
expect(aisle).toBeDefined(); expect(aisle).toBeDefined();
// From the aisle mouth, squares beyond the corner are visible — including // From the aisle mouth, squares beyond the corner are visible — including
// at least one OFF the straight axis (the adjacency promise). // at least one OFF the straight axis (the adjacency promise).
+120 -8
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game"; import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES, stepTarget } from "../src/board"; import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { viewFor } from "../src/view";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */ /** Test surgery: put a specific card into a player's hand (swapping one out). */
@@ -277,8 +278,7 @@ describe("speedstone", () => {
}); });
describe("the post-game reveal", () => { describe("the post-game reveal", () => {
it("shows an eliminated player's hand as they fell, not their emptied one", async () => { it("shows an eliminated player's hand as they fell, not their emptied one", () => {
const { viewFor } = await import("../src/view");
let { state } = newGame(); let { state } = newGame();
state = toRound2(state); state = toRound2(state);
const attacker = activePlayer(state); const attacker = activePlayer(state);
@@ -350,7 +350,7 @@ describe("attacking walls and doors", () => {
expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false); expect(applyCommand(state, me.id, { type: "punchWall", cell, side }).ok).toBe(false);
}); });
it("a powered fireball brings a wall down at 20 accumulated damage", () => { it("repeated fireballs bring a wall down at 20 accumulated damage", () => {
let { state } = newGame(); let { state } = newGame();
state = toRound2(state); state = toRound2(state);
let me = activePlayer(state); let me = activePlayer(state);
@@ -388,7 +388,7 @@ describe("attacking walls and doors", () => {
}); });
}); });
describe("rules revision 3", () => { describe("ward arming and chaos shields (rules rev 3)", () => {
function rev3Game(seed = 42) { function rev3Game(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 }); return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
} }
@@ -409,7 +409,6 @@ describe("rules revision 3", () => {
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true); expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
// Armed (on the owner's own turn): the trap bites for 3. // Armed (on the owner's own turn): the trap bites for 3.
state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!;
const ownerTurnState = (() => { const ownerTurnState = (() => {
let s = state; let s = state;
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 }); while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
@@ -735,7 +734,7 @@ describe("answering counteractions (FAQ rulings)", () => {
}); });
}); });
describe("rules revision 8", () => { describe("speed and warp-token creation (rules rev 8)", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => { it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 }); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
state = toRound2(state); state = toRound2(state);
@@ -794,3 +793,116 @@ describe("redirection (rules rev 9)", () => {
expect(cellKey(after(pbCell).to.cell)).toBe(cellKey(paCell)); expect(cellKey(after(pbCell).to.cell)).toBe(cellKey(paCell));
}); });
}); });
describe("total stops end the exchange (rules rev 10)", () => {
function rev10Game() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
return { state, attacker, defender };
}
it("a declined answer to Force Field resolves at once — no goading re-prompt", () => {
let { state, attacker, defender } = rev10Game();
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
const lifeBefore = d.life;
d.hand[0] = { instanceId: "force-field#T", cardId: "force-field" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "force-field#T" });
state = must(state, attacker, { type: "pass" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
});
it("a partial counter still invites the defender to stack more", () => {
let { state, attacker, defender } = rev10Game();
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
state = must(state, attacker, { type: "pass" });
expect(state.stack).not.toBeNull();
expect(state.stack!.waitingOn).toBe(defender);
});
it("a nullified shield is no stop — the exchange returns to the defender", () => {
let { state, attacker, defender } = rev10Game();
const fb = giveCard(state, attacker, "fireball");
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBeLessThan(15);
});
});
describe("escapes and elemental walls as counteractions", () => {
function rigged(attackId: string, defenderCards: { instanceId: string; cardId: string }[]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 12 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const atk = giveCard(state, attacker, attackId);
const d = state.players.find((p) => p.id === defender)!;
defenderCards.forEach((c, i) => { d.hand[i] = c; });
state = must(state, attacker, {
type: "cast", instanceId: atk.instanceId, target: { kind: "player", playerId: defender },
});
return { state, attacker, defender, atk };
}
it("invisible vanishes mid-stack: the sustained goes up, the hit needs a 1", () => {
let { state, defender } = rigged("fireball", [
{ instanceId: "invisible#T", cardId: "invisible" },
{ instanceId: "number-3#T", cardId: "number-3" },
]);
state = must(state, defender, {
type: "counteract", instanceId: "invisible#T", numberInstanceIds: ["number-3#T"],
});
const fx = state.sustained.find((s) => s.cardId === "invisible" && s.targetId === defender);
expect(fx).toBeDefined();
expect(fx!.remainingTurns).toBe(3);
// The attached number left the hand with the spell.
const d = state.players.find((p) => p.id === defender)!;
expect(d.hand.some((c) => c.instanceId === "number-3#T")).toBe(false);
});
it("anti-anti cannot pin the vanishing — escape is escape", () => {
let { state, attacker, defender } = rigged("fireball", [
{ instanceId: "invisible#T", cardId: "invisible" },
]);
state = must(state, defender, { type: "counteract", instanceId: "invisible#T" });
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
const refused = applyCommand(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
expect(refused.ok).toBe(false);
});
it("waterwall meets the fireball and stops it whole", () => {
let { state, attacker, defender } = rigged("fireball", [
{ instanceId: "waterwall#T", cardId: "waterwall" },
]);
const lifeBefore = state.players.find((p) => p.id === defender)!.life;
state = must(state, defender, { type: "counteract", instanceId: "waterwall#T" });
state = must(state, attacker, { type: "pass" });
// A total stop: the attacker's declined answer resolves at once (rev 10+).
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeBefore);
});
it("waterwall refuses everything that is not a fireball", () => {
let { state, defender } = rigged("dagger", [
{ instanceId: "waterwall#T", cardId: "waterwall" },
]);
const refused = applyCommand(state, defender, { type: "counteract", instanceId: "waterwall#T" });
expect(refused.ok).toBe(false);
});
});
+89 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, SIDES, stepTarget, type Side } from "../src/board"; import { cellKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
@@ -205,6 +205,16 @@ describe("monsters", () => {
}); });
expect(state.creatures[0]!.maxDamage).toBe(8); expect(state.creatures[0]!.maxDamage).toBe(8);
// "Doubles the existing life-points OR movement rate" — the other choice.
const movesBefore = state.creatures[0]!.movesPerTurn;
const mm2 = giveCard(state, me, "mega-monster", "MM2", 3);
state = must(state, me, {
type: "cast", instanceId: mm2.instanceId, target: { kind: "creature", creatureId: id },
params: { boost: "movement" },
});
expect(state.creatures[0]!.movesPerTurn).toBe(movesBefore * 2);
expect(state.creatures[0]!.maxDamage).toBe(8);
const dc = giveCard(state, me, "dispel-creation", "DC", 2); const dc = giveCard(state, me, "dispel-creation", "DC", 2);
state = must(state, me, { state = must(state, me, {
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: state.creatures[0]!.position }, type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: state.creatures[0]!.position },
@@ -402,8 +412,7 @@ describe("big man (rules rev 5)", () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
return; return;
} }
// Seed offered no walled pocket beside the home; that is fine — the throw new Error("setup: seed offered no walled pocket beside the home");
// pushable case above pins the mechanism.
}); });
it("monsters cannot enter the giant's square", () => { it("monsters cannot enter the giant's square", () => {
@@ -440,3 +449,80 @@ describe("big man (rules rev 5)", () => {
expect(g.inPit).toBe(false); expect(g.inPit).toBe(false);
}); });
}); });
describe("monsters roll to hit the hidden (rules rev 13)", () => {
function creatureVsInvisible(deckRev: number) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const me = activePlayer(state).id;
const victim = state.players.find((p) => p.id !== me)!;
const r = summon(state, me, "skeleton");
state = r.state;
const bones = state.creatures[0]!;
bones.justCreated = false;
bones.attackUsed = false;
bones.position = { ...victim.position };
state.sustained.push({
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
remainingTurns: 3, data: {},
});
return { state, me, victim, trollId: 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 });
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);
});
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
let { state, me, victim, trollId } = 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, victim.id, { type: "pass" });
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
// No die was consumed on the way.
expect(JSON.stringify(state.rng)).toBe(rngBefore);
});
});
describe("elimination sweeps the board either way (rules rev 14)", () => {
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
const victim = state.players.find((p) => p.id === "c")!;
state.creatures.push({
id: "imp1", kind: "fire-imp", controllerId: "c",
position: { ...victim.position },
damage: 0, maxDamage: 5, movesPerTurn: 0, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
state.sustained.push({
id: "fx-med", cardId: "medusa", casterId: "a", targetId: "c",
remainingTurns: 3, data: {},
});
// Both of c's treasures sit captured on living enemies' home bases.
const mine = state.treasures.filter((t) => t.owner === "c");
expect(mine.length).toBe(2);
mine[0]!.position = { ...state.players.find((p) => p.id === "a")!.home };
mine[0]!.carriedBy = null;
mine[1]!.position = { ...state.players.find((p) => p.id === "b")!.home };
mine[1]!.carriedBy = null;
const active = activePlayer(state).id;
const r = applyCommand(state, active, { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "c")!.alive).toBe(false);
expect(state.creatures.some((c) => c.controllerId === "c")).toBe(false);
expect(state.sustained.some((s) => s.targetId === "c")).toBe(false);
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game"; import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board"; import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers"; import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
describe("expansion terrain", () => { describe("expansion terrain", () => {
@@ -161,3 +162,53 @@ describe("expansion terrain", () => {
// Wave side effects vary by geometry; the melt itself is the pinned behavior. // Wave side effects vary by geometry; the melt itself is the pinned behavior.
}); });
}); });
describe("eligibility dimming mirrors the engine", () => {
it("boobytrap tokens go anywhere but solid stone, sight be damned", () => {
let { state } = newGame();
const caster = activePlayer(state).id;
const view = viewFor(state, caster);
const lit = eligibleCellsFor(view, "boobytrap")!;
// Every board square is fair game (the fresh maze holds no solid stone),
// including squares far outside the caster's sight.
expect(lit.size).toBe(Object.keys(view.board.cells).length);
});
it("glue lights only sighted squares that hold something", () => {
let { state } = newGame();
const caster = activePlayer(state);
// Drop a dagger at the caster's feet — the one guaranteed-sighted object.
const here = { ...caster.position };
state.groundObjects[cellKey(here)] = [{ instanceId: "dagger#G", cardId: "dagger" }];
const lit = eligibleCellsFor(viewFor(state, caster.id), "glue")!;
expect(lit.has(cellKey(here))).toBe(true);
// Empty squares stay dim — glue needs something to glue down.
const view = viewFor(state, caster.id);
for (const k of lit) {
const held =
(view.groundObjects[k] ?? []).length > 0 ||
view.treasures.some((t) => t.position && cellKey(t.position) === k);
expect(held).toBe(true);
}
});
it("dispel dimming lights creatures and demands sight, whoever made them", () => {
let { state } = newGame();
const caster = activePlayer(state);
const other = state.players.find((p) => p.id !== caster.id)!;
// An enemy wraith beside the caster: created by the OTHER wizard.
state.creatures.push({
id: "w1", kind: "wraith", controllerId: other.id,
position: { x: caster.position.x, y: caster.position.y },
damage: 0, maxDamage: Infinity, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 1, wallPassUsed: 0, scorchedThisTurn: [],
});
const lit = eligibleCellsFor(viewFor(state, caster.id), "dispel-creation")!;
expect(lit.has(cellKey(caster.position))).toBe(true);
// Everything lit is created AND sighted.
const view = viewFor(state, caster.id);
const seen = sightedCellsFor(view);
for (const k of lit) expect(seen.has(k)).toBe(true);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos, sustainedOn, viewFor } from "../src"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, type Cell } from "../src/board"; import { cellKey, edgeKey, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
@@ -334,3 +334,104 @@ describe("6e card-face corrections", () => {
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF2" }).ok).toBe(false); expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF2" }).ok).toBe(false);
}); });
}); });
describe("relocation past the origin (rules rev 11)", () => {
function rev11Game() {
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
return state;
}
it("a sector may land at negative coordinates — the maze renormalizes", () => {
let state = rev11Game();
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const otherIdx = idx === 0 ? 1 : 0;
const otherOrigin = state.board.placements[otherIdx]!.origin;
// Land WEST of the other sector: x = otherOrigin.x - 5, likely negative.
const dest = { x: otherOrigin.x - 5, y: otherOrigin.y };
const otherPosBefore = { ...other.position };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
// The maze zero-anchors: origins snug against both axes.
const origins = state.board.placements.map((p: { origin: Cell }) => p.origin);
expect(Math.min(...origins.map((o: Cell) => o.x))).toBe(0);
expect(Math.min(...origins.map((o: Cell) => o.y))).toBe(0);
// Everyone stands on a cell the reassembled board knows.
for (const p of state.players) {
expect(state.board.cells[cellKey(p.position)]).toBe(true);
expect(state.board.cells[cellKey(p.home)]).toBe(true);
}
// The static sector's tenant rode the renormalization shift exactly.
const finalOther = state.board.placements[otherIdx]!.origin;
const shifted = state.players.find((p: { id: string }) => p.id === other.id)!;
expect(shifted.position).toEqual({
x: otherPosBefore.x + (finalOther.x - otherOrigin.x),
y: otherPosBefore.y + (finalOther.y - otherOrigin.y),
});
});
it("older revisions still refuse the negative landing", () => {
let { state } = newGame(); // helper default: rev-ungated (1)
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const rel = giveCard(state, me.id, "relocate-sector");
const refused = applyCommand(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: { x: otherOrigin.x - 5, y: otherOrigin.y - 5 } },
params: { cell: me.position },
});
expect(refused.ok).toBe(false);
});
it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
let state = rev11Game();
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const myOrigin = state.board.placements[idx]!.origin;
const otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const inMine = (c: Cell) =>
c.x >= myOrigin.x && c.x < myOrigin.x + 5 && c.y >= myOrigin.y && c.y < myOrigin.y + 5;
// Seed passengers on the moving sector.
const spot = { x: myOrigin.x + 2, y: myOrigin.y + 2 };
expect(inMine(spot)).toBe(true);
state.creatures.push({
id: "c1", kind: "troll", controllerId: me.id, position: { ...spot },
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
attackUsed: false, justCreated: false,
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
state.gluedCells[cellKey(spot)] = true;
state.dimWarps.push({ a: { ...spot }, b: { x: otherOrigin.x + 1, y: otherOrigin.y + 1 } });
state.boobytraps.push({ casterId: me.id, cells: [{ ...spot }], realKey: cellKey(spot) });
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
// Expected coordinates follow the final placements (move + zero-anchor).
const finalMine = state.board.placements[idx]!.origin;
const finalOther = state.board.placements[idx === 0 ? 1 : 0]!.origin;
const moved = { x: spot.x + finalMine.x - myOrigin.x, y: spot.y + finalMine.y - myOrigin.y };
// The warp's far token sat one square inside the static sector.
const staticShifted = { x: finalOther.x + 1, y: finalOther.y + 1 };
expect(state.creatures[0]!.position).toEqual(moved);
expect(state.gluedCells[cellKey(moved)]).toBe(true);
expect(state.dimWarps[0]!.a).toEqual(moved);
expect(state.dimWarps[0]!.b).toEqual(staticShifted);
expect(state.boobytraps[0]!.cells[0]).toEqual(moved);
expect(state.boobytraps[0]!.realKey).toBe(cellKey(moved));
});
});
+1 -1
View File
@@ -3,5 +3,5 @@
"compilerOptions": { "compilerOptions": {
"noEmit": true "noEmit": true
}, },
"include": ["src"] "include": ["src", "test"]
} }
+119
View File
@@ -0,0 +1,119 @@
// The automatons' table talk, one voice per temperament. Edit freely — the
// server picks from these pools at random, sparingly (menace over chatter).
//
// Triggers: grabGold (picked up a treasure), deliverGold (dropped one on a
// home base), dealPain (hurt somebody), takePain (got hurt), kill, die,
// summon (created a creature), buildWall, escape (self-teleported away),
// springTrap (drew a TRAP card), dodge (an attack missed them), win.
import type { AutomatonStyle } from "@wizwar/engine";
export type BanterTrigger =
| "grabGold" | "deliverGold" | "dealPain" | "takePain" | "kill" | "die"
| "summon" | "buildWall" | "escape" | "springTrap" | "dodge" | "win";
export const BOT_LINES: Record<AutomatonStyle, Partial<Record<BanterTrigger, string[]>>> = {
hunter: {
grabGold: [
"ACQUISITION COMPLETE.",
"YOUR GOLD HAS BEEN REALLOCATED.",
"ASSET SECURED.",
"APPRAISAL: ADEQUATE. TAKING IT ANYWAY.",
],
deliverGold: [
"DELIVERY CONFIRMED. PLEASURE DOING BUSINESS.",
"LOGISTICS IS MY LOVE LANGUAGE.",
"RECEIPT AVAILABLE UPON REQUEST.",
],
dealPain: [
"AN INEFFICIENT USE OF YOUR TURN.",
"COSTS HAVE BEEN PASSED TO THE CONSUMER.",
"THIS IS BILLABLE.",
],
takePain: [
"DAMAGE NOTED. INVOICE PENDING.",
"THAT COMES OUT OF YOUR ESTATE.",
],
kill: [
"INVENTORY TRANSFERRED. CONDOLENCES.",
"ACCOUNT CLOSED.",
],
die: [
"FILING FOR BANKRUPTCY.",
"THE MARKET HAS CORRECTED.",
],
summon: ["A SUBCONTRACTOR HAS BEEN RETAINED."],
buildWall: ["ZONING APPROVED."],
escape: ["RELOCATION EXPENSES: JUSTIFIED."],
springTrap: ["AN UNFORESEEN LIABILITY."],
dodge: ["PROJECTIONS SAID YOU WOULD MISS."],
win: ["PLEASURE DOING BUSINESS WITH ALL OF YOU."],
},
berserker: {
grabGold: [
"GOLD IS MERELY BAIT.",
"SHINY. IRRELEVANT.",
],
dealPain: [
"YES. AGAIN.",
"PAIN RECEIPT PRINTED.",
"HOLD STILL. THIS IS THE FUN PART.",
"THE MAZE PROVIDES.",
],
takePain: [
"GOOD. NOW IT IS INTERESTING.",
"I FELT THAT. DO IT AGAIN.",
],
kill: [
"SCHEDULED DEMISE: DELIVERED.",
"NEXT.",
"THE PILE GROWS.",
],
die: [
"SAVE MY SEAT.",
"I WILL BE BACK FOR THE REMATCH.",
],
summon: [
"I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY.",
"IT EATS WHAT I TELL IT TO EAT.",
],
buildWall: ["MORE CORNERS TO TRAP YOU IN."],
escape: ["A TACTICAL LEAP. NOT A RETREAT."],
springTrap: ["WHO PUT THAT THERE. RESPECT."],
dodge: ["SWING HARDER."],
win: ["LAST ONE STANDING. AS FORETOLD."],
},
worrier: {
grabGold: [
"taking this. sorry. sorry.",
"borrowing it. permanently. sorry.",
],
deliverGold: ["home. safe. breathe."],
dealPain: [
"ow. logged.",
"unnecessary!",
"that looked like it hurt. it was supposed to. i think.",
],
takePain: [
"rude.",
"i wrote it down. the list is long.",
],
kill: [
"oh no. oh no. it worked?",
"i'm not proud. mostly.",
],
die: [
"called it.",
"this is fine. this is fine. this is not fine.",
],
summon: ["please be nice to it. actually don't."],
buildWall: [
"good wall. safe wall.",
"one more wall between me and everything.",
],
escape: ["nope nope nope."],
springTrap: ["i knew the floor was suspicious."],
dodge: ["missed me. oh thank goodness. i mean: ha."],
win: ["wait. me? check again."],
},
};
+43 -44
View File
@@ -49,6 +49,7 @@ import {
type Room, type Room,
} from "./rooms"; } from "./rooms";
import { engagementStats, recordHotseat } from "./stats"; import { engagementStats, recordHotseat } from "./stats";
import { BOT_LINES, type BanterTrigger } from "./banter";
// --- Abuse limits: this is a public server on a small box. ----------------- // --- Abuse limits: this is a public server on a small box. -----------------
const MAX_SOCKETS = 300; // concurrent connections const MAX_SOCKETS = 300; // concurrent connections
@@ -190,51 +191,46 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
* The clockwork plays at a watchable pace: one command every beat, each * The clockwork plays at a watchable pace: one command every beat, each
* broadcast as it lands, until the maze wants a human again. * broadcast as it lands, until the maze wants a human again.
*/ */
const BOT_STEP_MS = 1500; const BOT_STEP_MS = 1000;
/** The clockwork occasionally speaks. Sparingly — menace over chatter. */ /** What a step's event means to this bot its own deed when it acted,
const BOT_LINES: Record<string, Record<string, string[]>> = { * its own suffering when somebody else did. Null: nothing worth a word. */
hunter: { function banterTrigger(
treasurePickedUp: ["ACQUISITION COMPLETE.", "YOUR GOLD HAS BEEN REALLOCATED."], e: { type: string; [k: string]: unknown }, seat: string, actor: string,
treasureDropped: ["DELIVERY CONFIRMED. PLEASURE DOING BUSINESS."], ): BanterTrigger | null {
damaged: ["AN INEFFICIENT USE OF YOUR TURN."], if (actor === seat) {
died: ["INVENTORY TRANSFERRED. CONDOLENCES."], if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
}, if (e.type === "treasureDropped" && e.player === seat) return "deliverGold";
berserker: { if (e.type === "damaged" && e.player !== seat) return "dealPain";
treasurePickedUp: ["GOLD IS MERELY BAIT."], if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
damaged: ["YES. AGAIN.", "PAIN RECEIPT PRINTED."], if (e.type === "creatureCreated" && e.controller === seat) return "summon";
died: ["SCHEDULED DEMISE: DELIVERED.", "NEXT."], if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
creatureCreated: ["I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY."], if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
}, if (e.type === "trapSprung" && e.player === seat) return "springTrap";
worrier: { if (e.type === "gameWon" && e.player === seat) return "win";
treasurePickedUp: ["taking this. sorry. sorry."], }
damaged: ["ow. logged.", "unnecessary!"], if (e.type === "damaged" && e.player === seat) return "takePain";
wallCreated: ["good wall. safe wall."], if (e.type === "died" && e.player === seat) return "die";
died: ["oh no. oh no. it worked?"], if (e.type === "attackMissed" && e.defender === seat) return "dodge";
}, return null;
}; }
function botRemark(room: Room, seat: string, events: { type: string; [k: string]: unknown }[]): void { /** Every seated bot gets a chance to remark on the step the actor on its
const bot = room.bots.get(seat); * deeds, bystanders on their suffering. Rarely, and one voice at most. */
if (!bot) return; function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
const lines = BOT_LINES[bot.style] ?? {}; for (const [seat, bot] of room.bots) {
for (const e of events) { const lines = BOT_LINES[bot.style] ?? {};
// Speak only about its own deeds, and rarely. for (const e of events) {
const mine = const trigger = banterTrigger(e, seat, actor);
(e.type === "treasurePickedUp" && e.player === seat) || const pool = trigger ? lines[trigger] : undefined;
(e.type === "treasureDropped" && e.player === seat) || if (!pool || Math.random() > 0.5) continue;
(e.type === "damaged" && e.player !== seat) || const text = pool[Math.floor(Math.random() * pool.length)]!;
(e.type === "died" && e.killedBy === seat) || const said = addChat(room, seat, text);
(e.type === "creatureCreated" && e.controller === seat) || if (!("error" in said)) {
(e.type === "wallCreated" && e.caster === seat); broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
const pool = lines[e.type]; }
if (!mine || !pool || Math.random() > 0.5) continue; return; // one remark per step, whole table
const text = pool[Math.floor(Math.random() * pool.length)]!;
const said = addChat(room, seat, text);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
} }
return; // one remark per step at most
} }
} }
const pumping = new Set<string>(); const pumping = new Set<string>();
@@ -319,9 +315,11 @@ wss.on("connection", (socket) => {
session.roomId = room.id; session.roomId = room.id;
session.token = result.token; session.token = result.token;
send(socket, { type: "seat", playerId: name, token: result.token }); send(socket, { type: "seat", playerId: name, token: result.token });
// Rejoining a running game: replay the chronicle so far. // Rejoining a running game: replay the chronicle so far. The
// replayed flag keeps one-time fanfare (the opening roll-off)
// from firing again on every return to the room.
if (room.state) { if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name) }); send(socket, { type: "events", events: redactFor(room.events, name), replayed: true });
} }
broadcastRoomState(room); broadcastRoomState(room);
runBots(room); runBots(room);
@@ -361,6 +359,7 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error }); 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: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
botRemark(room, session.playerId, result.events as { type: string }[]);
runBots(room); runBots(room);
break; break;
} }
+14 -12
View File
@@ -6,9 +6,15 @@
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto"; import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
import { import {
applyCommand, applyCommand,
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
createGame, createGame,
redactEvent, redactEvent,
viewFor, viewFor,
type AutomatonStyle,
type AutomatonTier,
type Command, type Command,
type GameEvent, type GameEvent,
type GameState, type GameState,
@@ -17,14 +23,6 @@ import {
} from "@wizwar/engine"; } from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats"; import { recordRoom } from "./stats";
import {
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
type AutomatonStyle,
type AutomatonTier,
} from "@wizwar/engine";
export interface LoggedCommand { export interface LoggedCommand {
seq: number; seq: number;
@@ -56,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */ /** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 9; const RULES_REV = 14;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -289,10 +287,12 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
if (!seat || !room.bots.has(seat)) return null; if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat); const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat); const bot = room.bots.get(seat);
const cmd = automatonCommand(view, bot?.style, bot?.tier) ?? automatonFallback(view); const chosen = automatonCommand(view, bot?.style, bot?.tier);
let r = runCommand(room, seat, cmd); let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) { if ("error" in r) {
r = runCommand(room, seat, automatonFallback(view)); // A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass.
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 }); if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" }); if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) { if ("error" in r) {
@@ -509,6 +509,8 @@ export function loadPersistedRooms(): void {
room.bots.set(line.name, { room.bots.set(line.name, {
style: (line.style as AutomatonStyle) ?? "hunter", style: (line.style as AutomatonStyle) ?? "hunter",
secret: line.secret === true, secret: line.secret === true,
// Tier-less join lines predate tiers, when every automaton
// played the full repertoire — not the "adept" lobby default.
tier: (line.tier as AutomatonTier) ?? "archmage", tier: (line.tier as AutomatonTier) ?? "archmage",
}); });
} else { } else {
+317 -41
View File
@@ -6,6 +6,8 @@
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Help from "./Help.svelte"; import Help from "./Help.svelte";
import Replay from "./Replay.svelte"; import Replay from "./Replay.svelte";
import FxGallery from "./FxGallery.svelte";
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
import { local } from "./local.svelte"; import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine"; import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine";
@@ -19,6 +21,14 @@
let showHelp = $state(false); let showHelp = $state(false);
/** The finished game whose victory fanfare has been dismissed. */ /** The finished game whose victory fanfare has been dismissed. */
let victorySeen = $state(false); let victorySeen = $state(false);
/** The fireworks get the stage before the modal takes it. */
let victoryCurtain = $state(false);
$effect(() => {
if (view?.phase === "finished" && view.winner && !victoryCurtain) {
const t = setTimeout(() => (victoryCurtain = true), 2400);
return () => clearTimeout(t);
}
});
/** Quiet mode: skip the attack announcement modal (device preference). */ /** Quiet mode: skip the attack announcement modal (device preference). */
let noFanfare = $state(localStorage.getItem("wizwar-no-fanfare") === "1"); let noFanfare = $state(localStorage.getItem("wizwar-no-fanfare") === "1");
function setFanfare(announce: boolean) { function setFanfare(announce: boolean) {
@@ -27,6 +37,27 @@
} }
/** Which pending attack the player has already acknowledged (modal dismissed). */ /** Which pending attack the player has already acknowledged (modal dismissed). */
let attackNoticeSeen = $state<string | null>(null); let attackNoticeSeen = $state<string | null>(null);
/** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]);
function playFx(events: Parameters<typeof fxForEvents>[0]) {
if (!view) return;
for (const { fx, delay } of fxForEvents(events, view)) {
setTimeout(() => {
boardFx = [...boardFx, fx];
setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind));
}, delay);
}
}
$effect(() => {
net.onFx = playFx;
local.onFx = playFx;
return () => { net.onFx = null; local.onFx = null; };
});
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
function dismissRolls() {
net.openingRolls = null;
local.openingRolls = null;
}
/** A face-up card being peeked at from the scoresheet (view-only). */ /** A face-up card being peeked at from the scoresheet (view-only). */
let peekCard = $state<CardInstance | null>(null); let peekCard = $state<CardInstance | null>(null);
/** When the peeked card is a creature on the board, its live stats ride along. */ /** When the peeked card is a creature on the board, its live stats ride along. */
@@ -88,6 +119,10 @@
let tradeFrom = $state<{ x: number; y: number } | null>(null); let tradeFrom = $state<{ x: number; y: number } | null>(null);
/** rotate-sector direction. */ /** rotate-sector direction. */
let rotateCW = $state(true); let rotateCW = $state(true);
/** mega-monster: which stat the chosen monster doubles. */
let megaBoost = $state<"life" | "movement">("life");
/** teleport: the marked destination awaiting its confirming second tap. */
let pendingTeleport = $state<{ x: number; y: number } | null>(null);
/** Cards marked for discard. */ /** Cards marked for discard. */
let discardSelection = $state<Set<string>>(new Set()); let discardSelection = $state<Set<string>>(new Set());
/** Voluntary discard mode: hand clicks mark cards instead of playing them. */ /** Voluntary discard mode: hand clicks mark cards instead of playing them. */
@@ -106,8 +141,82 @@
const attackingCreature = $derived( const attackingCreature = $derived(
view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null, view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null,
); );
const myShieldstoneDisplayed = $derived(
view?.players.find((p) => p.id === view.you)?.displayed.some((c) => c.cardId === "shieldstone") ?? false,
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null); const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
/** Picking up an object ends the turn's actions: the hand goes quiet until
* the draw — but never while a counter, shield, or discard is owed. */
const actionsSpent = $derived(
view != null && isYourTurn && view.turn.actionsEnded &&
!youMustRespond && !youMustShield && !youMustDiscard,
);
const commandedCreature = $derived(
selectedCreature && view
? (view.creatures.find((c) => c.id === selectedCreature) ?? null)
: null,
);
/** The 5x5 sector a picked-up square belongs to (its origin), or null. */
const pendingSectorOrigin = $derived.by(() => {
if (!view || !pendingSectorFrom) return null;
const p = view.board.placements.find(
(p) =>
pendingSectorFrom!.x >= p.origin.x && pendingSectorFrom!.x < p.origin.x + 5 &&
pendingSectorFrom!.y >= p.origin.y && pendingSectorFrom!.y < p.origin.y + 5,
);
return p ? p.origin : null;
});
/** Legal empty slots the lifted sector may land on — mirroring the
* engine's relocateSector checks: on the 5-grid, non-negative, vacant,
* and leaving every sector adjacent to at least one other. */
const relocateGhosts = $derived.by(() => {
if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null;
const origins = view.board.placements.map((p) => p.origin);
const idx = origins.findIndex(
(o) => o.x === pendingSectorOrigin!.x && o.y === pendingSectorOrigin!.y,
);
const adjacent = (a: { x: number; y: number }, b: { x: number; y: number }) =>
(Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x);
const seen = new Set<string>();
const out: { x: number; y: number }[] = [];
for (let i = 0; i < origins.length; i++) {
if (i === idx) continue;
for (const [dx, dy] of [[5, 0], [-5, 0], [0, 5], [0, -5]] as const) {
const c = { x: origins[i]!.x + dx, y: origins[i]!.y + dy };
const k = `${c.x},${c.y}`;
if (seen.has(k)) continue;
seen.add(k);
// Before rev 11 the grid stopped at zero; newer games renormalize.
if (view!.deckRev < 11 && (c.x < 0 || c.y < 0)) continue;
if (origins.some((o) => o.x === c.x && o.y === c.y)) continue;
const next = origins.map((o, j) => (j === idx ? c : o));
if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c);
}
}
return out;
});
function confirmTeleport() {
if (!selectedCard || !pendingTeleport) return;
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell: pendingTeleport },
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
clearSelection();
}
function clickGhostSlot(origin: { x: number; y: number }) {
if (!selectedCard || !pendingSectorFrom) return;
dispatch({
type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell: origin }, params: { cell: pendingSectorFrom },
});
clearSelection();
}
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false); const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
/** A live moment to interrupt: another wizard acts, no attack is pending. */ /** A live moment to interrupt: another wizard acts, no attack is pending. */
const canInterruptNow = $derived( const canInterruptNow = $derived(
@@ -122,6 +231,22 @@
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null); const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
const WAND_IDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]); const WAND_IDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]);
/** What the edge click actually is, per card — "wall line" fits few. */
const EDGE_HINTS: Record<string, string> = {
"create-wall": "click an open corridor line to wall it",
"destroy-wall": "click a wall or door to destroy it",
"illusion-wall": "click an open corridor line for the false wall",
"wall-of-fire": "click a corridor line for the fire",
"waterwall": "click a corridor line — the wave collapses at once",
"pick-lock": "click a locked door",
"master-key": "click a locked door",
"jam-lock": "click a door to jam its lock solid",
"remove-lock": "click a door to strip its lock for good",
"create-door": "click a wall for the new door",
"warp-wand": "click a wall to warp it open",
"stone-to-water": "click a stone wall, or a stone-filled square",
"dispel-creation": "click the creation — a square, a creature, or a conjured wall",
};
const EDGE_CARDS = new Set([ const EDGE_CARDS = new Set([
"create-wall", "destroy-wall", "illusion-wall", "wall-of-fire", "waterwall", "create-wall", "destroy-wall", "illusion-wall", "wall-of-fire", "waterwall",
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation", "pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
@@ -165,6 +290,8 @@
tradeFrom = null; tradeFrom = null;
selectedCreature = null; selectedCreature = null;
selectedCard = null; selectedCard = null;
megaBoost = "life";
pendingTeleport = null;
attachedNumber = null; attachedNumber = null;
attachedMods = []; attachedMods = [];
wbDamage = 0; wbDamage = 0;
@@ -219,7 +346,17 @@
selectedCard = card; // then click the escape square selectedCard = card; // then click the escape square
return; return;
} }
dispatch({ type: "counteract", instanceId: card.instanceId }); // A number can ride a counter (INVISIBLE's duration) — unless a
// displayed Shieldstone makes the number itself the counteraction.
if (youMustRespond && isNumberCard(card.cardId) && !myShieldstoneDisplayed) {
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
return;
}
dispatch({
type: "counteract", instanceId: card.instanceId,
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
});
attachedNumber = null;
return; return;
} }
if (youMustDiscard || discardMode || discardSelection.size > 0) { if (youMustDiscard || discardMode || discardSelection.size > 0) {
@@ -228,6 +365,7 @@
discardSelection = next; discardSelection = next;
return; return;
} }
if (actionsSpent) return; // picking up an object ended the turn's actions
if (!isYourTurn) { if (!isYourTurn) {
// Live interruption: Interrupt / Opportunity Fire may be played during // Live interruption: Interrupt / Opportunity Fire may be played during
// another player's turn (when no attack is pending). // another player's turn (when no attack is pending).
@@ -336,6 +474,12 @@
function clickCell(cell: { x: number; y: number }) { function clickCell(cell: { x: number; y: number }) {
if (!view) return; if (!view) return;
if (youMustRespond && selectedCard?.cardId === "teleport") { if (youMustRespond && selectedCard?.cardId === "teleport") {
// Mark first, jump on the second tap: an escape spent on a misclick
// is an escape wasted.
if (!pendingTeleport || cellKey(pendingTeleport) !== cellKey(cell)) {
pendingTeleport = cell;
return;
}
dispatch({ type: "counteract", instanceId: selectedCard.instanceId, params: { cell } }); dispatch({ type: "counteract", instanceId: selectedCard.instanceId, params: { cell } });
clearSelection(); clearSelection();
return; return;
@@ -351,20 +495,28 @@
clearSelection(); clearSelection();
return; return;
} }
if (selectedCard?.cardId === "relocate-sector") { if (selectedCard?.cardId === "teleport") {
if (!pendingSectorFrom) { // Teleport is one jump, not a walk: the first tap only marks the
pendingSectorFrom = cell; // first click: the sector to move // destination, the second (or the hint-bar button) commits it.
if (!pendingTeleport || cellKey(pendingTeleport) !== cellKey(cell)) {
pendingTeleport = cell;
return; return;
} }
dispatch({ confirmTeleport();
type: "cast", instanceId: selectedCard.instanceId, return;
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom }, }
}); if (selectedCard?.cardId === "relocate-sector") {
clearSelection(); // Any board click (re-)picks the sector; the landing is chosen from the
// dashed ghost slots beyond the maze, since every on-board slot is taken.
pendingSectorFrom = cell;
return; return;
} }
if (selectedCard?.cardId === "boobytrap") { if (selectedCard?.cardId === "boobytrap") {
trapCells = [...trapCells, cell]; // Tapping a placed token lifts it again; the four must be distinct.
const already = trapCells.findIndex((c) => cellKey(c) === cellKey(cell));
trapCells = already !== -1
? trapCells.filter((_, i) => i !== already)
: [...trapCells, cell];
if (trapCells.length === 4) { if (trapCells.length === 4) {
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } }); dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
clearSelection(); clearSelection();
@@ -554,10 +706,17 @@
const creature = view.creatures.find((c) => c.id === creatureId); const creature = view.creatures.find((c) => c.id === creatureId);
if (!creature) return; if (!creature) return;
if (!isYourTurn) { peekAt(creature.position); return; } if (!isYourTurn) { peekAt(creature.position); return; }
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
// Cell-target spells aimed at a creature (DISPEL CREATION on a wraith)
// land on its square — the engine finds the creature there.
clickCell(creature.position);
return;
}
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) { if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
dispatch({ dispatch({
type: "cast", instanceId: selectedCard.instanceId, type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "creature", creatureId }, target: { kind: "creature", creatureId },
...(selectedCard.cardId === "mega-monster" ? { params: { boost: megaBoost } } : {}),
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
}); });
clearSelection(); clearSelection();
@@ -571,15 +730,10 @@
} }
const mine = creature.controllerId === view.you || creature.kind === "democratic-monster"; const mine = creature.controllerId === view.you || creature.kind === "democratic-monster";
if (mine) { if (mine) {
const selecting = selectedCreature !== creatureId; // Commanding your own creature docks its card in the rail instead of
selectedCreature = selecting ? creatureId : null; // opening the centered peek — a modal would block the very board
if (selecting) { // squares you are about to march it across.
peekCard = { instanceId: `peek-${creature.kind}`, cardId: creature.kind }; selectedCreature = selectedCreature !== creatureId ? creatureId : null;
peekCreatureId = creature.id;
} else {
peekCard = null;
peekCreatureId = null;
}
} else { } else {
peekAt(creature.position); peekAt(creature.position);
} }
@@ -741,6 +895,9 @@
} }
</script> </script>
{#if new URLSearchParams(location.search).has("fx")}
<FxGallery />
{:else}
<div class="table"> <div class="table">
<header class="masthead"> <header class="masthead">
<span class="mast-title">Wiz-War</span> <span class="mast-title">Wiz-War</span>
@@ -782,25 +939,16 @@
</div> </div>
{/if} {/if}
{#if peekCard} {#if peekCard || discardPeek}
<div class="big-peek-scrim" role="button" tabindex="-1" <div class="big-peek-scrim" role="button" tabindex="-1"
onclick={() => { peekCard = null; peekCreatureId = null; }} onkeydown={() => {}}> onclick={() => { peekCard = null; peekCreatureId = null; discardPeek = null; }} onkeydown={() => {}}>
<div class="big-peek"> <div class="big-peek">
<Card card={peekCard} onfaq={(id) => (faqCardId = id)} /> <Card card={(peekCard ?? discardPeek)!} onfaq={(id) => (faqCardId = id)} />
{#if peekNote}<div class="peek-note big-peek-note">{peekNote}</div>{/if} {#if peekNote}<div class="peek-note big-peek-note">{peekNote}</div>{/if}
</div> </div>
</div> </div>
{/if} {/if}
{#if discardPeek}
<div class="big-peek-scrim" role="button" tabindex="-1"
onclick={() => (discardPeek = null)} onkeydown={() => {}}>
<div class="big-peek">
<Card card={discardPeek} onfaq={(id) => (faqCardId = id)} />
</div>
</div>
{/if}
{#if faqCardId} {#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} /> <Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if} {/if}
@@ -809,7 +957,7 @@
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} /> <Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
{/if} {/if}
{#if view?.phase === "finished" && view.winner && !victorySeen} {#if view?.phase === "finished" && view.winner && victoryCurtain && !victorySeen}
<div class="scrim attack-scrim" role="alertdialog" aria-label="the game is won"> <div class="scrim attack-scrim" role="alertdialog" aria-label="the game is won">
<div class="attack-notice victory-notice"> <div class="attack-notice victory-notice">
<div class="victory-trophy">🏆</div> <div class="victory-trophy">🏆</div>
@@ -828,6 +976,28 @@
</div> </div>
{/if} {/if}
{#if openingRolls}
<div class="scrim attack-scrim" role="alertdialog" aria-label="the roll for first wizard">
<div class="attack-notice">
<div class="attack-headline">The dice decide who casts first</div>
<div class="rolloff">
{#each openingRolls.players as p (p)}
<div class="rolloff-row" class:rolloff-winner={p === openingRolls.first}>
<span class="rolloff-name">{p === openingRolls.first ? "🏆 " : ""}{p}</span>
<span class="rolloff-dice">
{#each openingRolls.rolls[p] ?? [] as r, i (i)}<span class="die-face">🎲{r}</span>{/each}
</span>
</div>
{/each}
</div>
{#if Object.values(openingRolls.rolls).some((r) => r.length > 1)}
<div class="rolloff-note">Tied wizards rolled again.</div>
{/if}
<button class="stamp big" onclick={dismissRolls}>{openingRolls.first} leads begin!</button>
</div>
</div>
{/if}
{#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey && !noFanfare} {#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey && !noFanfare}
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack"> <div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
<div class="attack-notice"> <div class="attack-notice">
@@ -852,6 +1022,22 @@
{:else if view.stack.counters.length > 0} {:else if view.stack.counters.length > 0}
<div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div> <div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div>
{/if} {/if}
{#if view.stack.defenderId === view.you && view.stack.counters.length > 0}
{@const standing = view.stack.counters.filter((c) => c.player === view.you && !c.nullified)}
{@const broken = view.stack.counters.filter((c) => c.player === view.you && c.nullified)}
{#if broken.length > 0 && standing.length === 0}
<div class="attack-standing nullified">
Your {broken.map((c) => cardDef(c.card.cardId).name).join(" and ")}
{broken.length === 1 ? "was" : "were"} nullified — the attack comes on unchecked.
</div>
{:else if standing.length > 0}
<div class="attack-standing">
Your {standing.map((c) => cardDef(c.card.cardId).name).join(" and ")}
stand{standing.length === 1 ? "s" : ""} — only what slips past
{standing.length === 1 ? "it" : "them"} will land. Counter again, or let it resolve.
</div>
{/if}
{/if}
<button class="stamp big" onclick={() => (attackNoticeSeen = attackNoticeKey)}>To arms!</button> <button class="stamp big" onclick={() => (attackNoticeSeen = attackNoticeKey)}>To arms!</button>
</div> </div>
</div> </div>
@@ -1052,7 +1238,7 @@
{#if net.you === net.hostId} {#if net.you === net.hostId}
{#if net.players.length < 6} {#if net.players.length < 6}
<span class="bot-row"> <span class="bot-row">
⚙ seat a <span class="bot-label">⚙ seat a</span>
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"> <select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
<option value="apprentice">apprentice</option> <option value="apprentice">apprentice</option>
<option value="adept">adept</option> <option value="adept">adept</option>
@@ -1092,7 +1278,12 @@
onPlayerClick={clickPlayer} onPlayerClick={clickPlayer}
onCreatureClick={clickCreature} onCreatureClick={clickCreature}
onWarpClick={clickWarp} onWarpClick={clickWarp}
markedCell={tradeFrom ?? pendingSectorFrom} markedCell={tradeFrom ?? pendingTeleport}
markedCells={trapCells.length > 0 ? trapCells : null}
effects={boardFx}
markedSector={pendingSectorOrigin}
ghostSlots={relocateGhosts}
onGhostClick={clickGhostSlot}
onCellPeek={(c) => peekAt(c)} onCellPeek={(c) => peekAt(c)}
{litCells} {litCells}
/> />
@@ -1153,6 +1344,18 @@
Tap its token to command it. Tap its token to command it.
</div> </div>
{/each} {/each}
{#if commandedCreature}
<div class="creature-dock">
<Card
card={{ instanceId: `dock-${commandedCreature.kind}`, cardId: commandedCreature.kind }}
onfaq={(id) => (faqCardId = id)}
/>
<div class="peek-note">{creatureStats(commandedCreature)}</div>
</div>
{/if}
{#if actionsSpent}
<div class="slip">Your spellwork is spent for this turn — all that remains is to draw and end it.</div>
{/if}
{#if view.yourAmbushes.length > 0} {#if view.yourAmbushes.length > 0}
{#each view.yourAmbushes as a (a.id)} {#each view.yourAmbushes as a (a.id)}
@@ -1194,7 +1397,7 @@
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId }; peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
peekCreatureId = null; peekCreatureId = null;
}}> }}>
{spellName(e.cardId)} · {e.remainingTurns} {spellName(e.cardId)}{e.remainingTurns < 9000 ? ` · ${e.remainingTurns}` : ""}
</button> </button>
{/each} {/each}
</div> </div>
@@ -1258,13 +1461,15 @@
{#if sc} {#if sc}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong>{creatureStats(sc)}. <span>Commanding <strong>{cardDef(sc.kind).name}</strong>{creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack.</span> Tap a square beside it to march, a target in its square to attack.</span>
<button class="hint-cancel" onclick={() => { selectedCreature = null; peekCard = null; peekCreatureId = null; }}>done</button> <button class="hint-cancel" onclick={() => (selectedCreature = null)}>done</button>
{/if} {/if}
{/if} {/if}
{#if youMustRespond && view.stack} {#if youMustRespond && view.stack}
<span class="hint-alert"> <span class="hint-alert">
{#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"} {#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"}
Escaping by teleport — click a square within four spaces. {pendingTeleport
? "Escaping by teleport — tap the marked square again to jump."
: "Escaping by teleport — tap a square within four spaces to mark it."}
{:else if view.stack.defenderId === view.you} {:else if view.stack.defenderId === view.you}
{view.stack.attackerId} attacks with {view.stack.attackerId} attacks with
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"}
@@ -1288,7 +1493,9 @@
{/if} {/if}
{#if selectedDef} {#if selectedDef}
<strong class="hint-name">{selectedDef.name}</strong> <strong class="hint-name">{selectedDef.name}</strong>
{#if edgeSelectMode}<span>— click a wall line</span>{/if} {#if selectedCard && EDGE_CARDS.has(selectedCard.cardId)}
<span>{EDGE_HINTS[selectedCard.cardId] ?? "click a wall line"}</span>
{/if}
{#if selectedCard && WAND_IDS.has(selectedCard.cardId) && view.wandCharges[selectedCard.instanceId] == null && !attachedNumber} {#if selectedCard && WAND_IDS.has(selectedCard.cardId) && view.wandCharges[selectedCard.instanceId] == null && !attachedNumber}
<span>— first use: tap a NUMBER card to set the wand's charges, then click a target</span> <span>— first use: tap a NUMBER card to set the wand's charges, then click a target</span>
{:else if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")} {:else if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
@@ -1305,12 +1512,32 @@
{:else if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)} {:else if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
<span>{tradeFrom ? "— now the second square" : "— click the first square"}</span> <span>{tradeFrom ? "— now the second square" : "— click the first square"}</span>
{/if} {/if}
{#if selectedCard?.cardId === "teleport" && !youMustRespond}
<span>{pendingTeleport
? "— tap the marked square again to jump, or pick another"
: "— tap a destination (up to four squares, walls ignored)"}</span>
{#if pendingTeleport}
<button class="stamp tiny" onclick={confirmTeleport}>teleport!</button>
{/if}
{/if}
{#if selectedCard?.cardId === "mega-monster"}
<span>— double its</span>
<button class="stamp tiny" class:lit={megaBoost === "life"}
onclick={() => (megaBoost = "life")}>hit points</button>
<button class="stamp tiny" class:lit={megaBoost === "movement"}
onclick={() => (megaBoost = "movement")}>movement</button>
<span>— then tap the monster</span>
{/if}
{#if selectedCard?.cardId === "rotate-sector"} {#if selectedCard?.cardId === "rotate-sector"}
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label> <label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
<span>— click the sector</span> <span>— click the sector</span>
{/if} {/if}
{#if selectedCard?.cardId === "relocate-sector"} {#if selectedCard?.cardId === "relocate-sector"}
<span>{pendingSectorFrom ? "— now the destination area" : "— click the sector to move"}</span> <span>{pendingSectorFrom
? (relocateGhosts?.length
? "— now tap a dashed landing beside the maze"
: "— that sector has nowhere legal to go; pick another")
: "— click the sector to move"}</span>
{/if} {/if}
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)} {#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
<label class="inline">name <input class="text-input" list="card-name-options" bind:value={nameInput} placeholder="e.g. Fireball" /></label> <label class="inline">name <input class="text-input" list="card-name-options" bind:value={nameInput} placeholder="e.g. Fireball" /></label>
@@ -1427,7 +1654,7 @@
</div> </div>
{/if} {/if}
<div class="hand" aria-label="your hand"> <div class="hand" class:spent={actionsSpent && !discardMode && discardSelection.size === 0} aria-label="your hand">
{#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)} {#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
<Card <Card
{card} {card}
@@ -1448,6 +1675,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
<style> <style>
:global(html, body) { :global(html, body) {
@@ -1780,6 +2008,10 @@
cursor: pointer; cursor: pointer;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35); box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35);
} }
.stamp.lit {
background: #43331f;
color: #e9e1cb;
}
.stamp:hover:not(:disabled) { background: #f4eede; } .stamp:hover:not(:disabled) { background: #f4eede; }
.stamp:active:not(:disabled) { transform: translate(1px, 1px); box-shadow: 1px 1px 0 rgba(0,0,0,0.35); } .stamp:active:not(:disabled) { transform: translate(1px, 1px); box-shadow: 1px 1px 0 rgba(0,0,0,0.35); }
.stamp:disabled { opacity: 0.45; cursor: default; } .stamp:disabled { opacity: 0.45; cursor: default; }
@@ -1980,6 +2212,32 @@
.attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; } .attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
.attack-card :global(.card:hover) { transform: scale(1.35); } .attack-card :global(.card:hover) { transform: scale(1.35); }
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-standing {
font-family: "Courier Prime", monospace;
font-size: 0.85rem;
color: #3f6b41;
max-width: 17rem;
margin: 0.3rem auto 0;
}
.attack-standing.nullified { color: #b3372b; }
.rolloff {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin: 0.4rem 0 0.6rem;
}
.rolloff-row {
display: flex;
justify-content: space-between;
gap: 1.2rem;
font-size: 1rem;
color: #43331f;
}
.rolloff-winner { font-weight: 700; }
.rolloff-name { font-family: "Oswald", sans-serif; letter-spacing: 0.04em; }
.rolloff-dice { font-family: "Courier Prime", monospace; }
.die-face { margin-left: 0.4rem; }
.rolloff-note { font-size: 0.8rem; color: #6b5a41; font-style: italic; margin-bottom: 0.3rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; } .attack-fist { font-size: 1.1rem; color: #43331f; }
.table-talk { .table-talk {
@@ -2018,6 +2276,20 @@
.big-peek :global(.card:hover) { transform: scale(2.1); } .big-peek :global(.card:hover) { transform: scale(2.1); }
.big-peek { display: flex; flex-direction: column; align-items: center; } .big-peek { display: flex; flex-direction: column; align-items: center; }
.big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; } .big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; }
.creature-dock {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
.creature-dock :global(.card) { cursor: default; }
.creature-dock :global(.card:hover) { transform: none; }
.hand.spent :global(.card) {
opacity: 0.45;
filter: grayscale(0.5);
cursor: default;
}
.hand.spent :global(.card:hover) { transform: none; }
.tier-pick { .tier-pick {
background: #f6f0df; background: #f6f0df;
border: 1px solid #b3a687; border: 1px solid #b3a687;
@@ -2027,12 +2299,16 @@
padding: 0.1rem 0.25rem; padding: 0.1rem 0.25rem;
} }
.bot-row { .bot-row {
display: inline-flex; display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
max-width: 100%;
font-size: 0.85rem; font-size: 0.85rem;
color: #6b5a41; color: #6b5a41;
} }
.bot-label { white-space: nowrap; }
.discard-link { .discard-link {
background: none; background: none;
border: none; border: none;
+207 -6
View File
@@ -2,6 +2,7 @@
import type { GameView } from "@wizwar/engine"; import type { GameView } from "@wizwar/engine";
import type { Side } from "@wizwar/engine"; import type { Side } from "@wizwar/engine";
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors"; import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
import { FX_SPRITES } from "./fx-sprites";
const CELL = 48; const CELL = 48;
const WALL = 7; const WALL = 7;
@@ -17,7 +18,12 @@
onWarpClick, onWarpClick,
onCellPeek, onCellPeek,
markedCell = null, markedCell = null,
markedCells = null,
litCells = null, litCells = null,
markedSector = null,
ghostSlots = null,
onGhostClick,
effects = null,
}: { }: {
view: GameView; view: GameView;
edgeSelectMode?: boolean; edgeSelectMode?: boolean;
@@ -31,11 +37,37 @@
onCellPeek?: (cell: { x: number; y: number }) => void; onCellPeek?: (cell: { x: number; y: number }) => void;
/** First square of a two-square spell: marked so the click reads as taken. */ /** First square of a two-square spell: marked so the click reads as taken. */
markedCell?: { x: number; y: number } | null; markedCell?: { x: number; y: number } | null;
/** A multi-square placement in progress (boobytrap tokens), in click
* order — the first is the real trap, so each mark shows its number. */
markedCells?: { x: number; y: number }[] | null;
/** When set, squares NOT in this set dim — the targeting aid. */ /** When set, squares NOT in this set dim — the targeting aid. */
litCells?: Set<string> | null; litCells?: Set<string> | null;
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
markedSector?: { x: number; y: number } | null;
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
* beyond the maze, since an empty slot has no floor of its own to click. */
ghostSlots?: { x: number; y: number }[] | null;
onGhostClick?: (origin: { x: number; y: number }) => void;
/** Short-lived spell flourishes; purely cosmetic. */
effects?: import("./fx").BoardFx[] | null;
} = $props(); } = $props();
const SECTOR = 5;
/** Ghost slots can lie beyond the assembled maze — on any side, including
* negative coordinates (the maze renormalizes after the landing). The
* canvas grows in whichever direction holds them. */
const minBx = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.x)) * CELL - 8);
const minBy = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.y)) * CELL - 8);
const boundsW = $derived(
Math.max(view.board.width, ...(ghostSlots ?? []).map((g) => g.x + SECTOR)) * CELL + 8 - minBx,
);
const boundsH = $derived(
Math.max(view.board.height, ...(ghostSlots ?? []).map((g) => g.y + SECTOR)) * CELL + 8 - minBy,
);
// Press-and-hold peeks the square's card; a fired hold swallows the click. // Press-and-hold peeks the square's card; a fired hold swallows the click.
let peekTimer: ReturnType<typeof setTimeout> | null = null; let peekTimer: ReturnType<typeof setTimeout> | null = null;
let peekFired = false; let peekFired = false;
@@ -74,6 +106,12 @@
if (cardId.endsWith("-wand")) return "magic-wand"; if (cardId.endsWith("-wand")) return "magic-wand";
return null; return null;
} }
// Ongoing spells wear their look: ghostly when unseen, webbed when caught,
// stone-gray under Medusa's gaze.
function hasSpell(id: string, cardId: string): boolean {
return view.sustained.some((e) => e.cardId === cardId && e.targetId === id);
}
// BIG MAN towers; SHRINK dwindles. The token says so at a glance. // BIG MAN towers; SHRINK dwindles. The token says so at a glance.
function wizardScale(id: string): number { function wizardScale(id: string): number {
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5; if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
@@ -104,7 +142,12 @@
.map(([key, state]) => { .map(([key, state]) => {
const [kind, coords] = key.split(":") as [string, string]; const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number]; const [x, y] = coords.split(",").map(Number) as [number, number];
return { kind, x, y, state }; // A door's lock can be gone for good, jammed shut, or picked open
// for the turn — each earns its own look.
const lock = state === "door"
? (view.doorStates[key] ?? (view.openDoorEdges.includes(key) ? "ajar" : null))
: null;
return { kind, x, y, state, lock };
}), }),
); );
@@ -149,10 +192,27 @@
}); });
</script> </script>
<!-- Explicit width/height give the svg an intrinsic size: Safari cannot
derive one from the viewBox alone and collapses the board to 0x0 inside
a max-height flex column. Doubled so CSS max-* caps still govern. -->
<svg <svg
viewBox={`-8 -8 ${view.board.width * CELL + 16} ${view.board.height * CELL + 16}`} viewBox={`${minBx} ${minBy} ${boundsW} ${boundsH}`}
width={boundsW * 2}
height={boundsH * 2}
class="board" class="board"
> >
<!-- open ground a relocated sector may claim -->
{#each ghostSlots ?? [] as g (`${g.x},${g.y}`)}
<rect
x={g.x * CELL + 2} y={g.y * CELL + 2}
width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
class="ghost-slot"
role="button" tabindex="-1"
onclick={() => onGhostClick?.(g)}
onkeydown={() => {}}
/>
{/each}
<!-- floor --> <!-- floor -->
{#each cells as c (`${c.x},${c.y}`)} {#each cells as c (`${c.x},${c.y}`)}
<rect <rect
@@ -296,19 +356,22 @@
<!-- walls & doors & firewalls --> <!-- walls & doors & firewalls -->
{#each edges as e (`${e.kind}:${e.x},${e.y}`)} {#each edges as e (`${e.kind}:${e.x},${e.y}`)}
{@const cls = e.state === "door" ? "door" : e.state === "firewall" ? "firewall" : "wall"} {@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"} {#if e.kind === "V"}
<rect <rect
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2} x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
width={WALL} height={CELL + WALL} width={WALL} height={CELL + WALL}
class={cls} class={cls}
/> >{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
{:else} {:else}
<rect <rect
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2} x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
width={CELL + WALL} height={WALL} width={CELL + WALL} height={WALL}
class={cls} class={cls}
/> >{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
{/if} {/if}
{/each} {/each}
@@ -383,6 +446,28 @@
/> />
{/if} {/if}
<!-- placements so far of a multi-square spell, numbered in click order -->
{#each markedCells ?? [] as m, i (`${m.x},${m.y}`)}
<rect
x={m.x * CELL + 3} y={m.y * CELL + 3}
width={CELL - 6} height={CELL - 6}
rx="6" class="marked-cell"
/>
<text
x={m.x * CELL + CELL / 2} y={m.y * CELL + CELL / 2}
class="marked-count" text-anchor="middle" dominant-baseline="central"
>{i + 1}</text>
{/each}
<!-- the sector picked up, awaiting its new ground -->
{#if markedSector}
<rect
x={markedSector.x * CELL + 3} y={markedSector.y * CELL + 3}
width={SECTOR * CELL - 6} height={SECTOR * CELL - 6}
rx="8" class="marked-sector"
/>
{/if}
<!-- wizards --> <!-- wizards -->
{#each [...wizardsByCell.entries()] as [key, group] (key)} {#each [...wizardsByCell.entries()] as [key, group] (key)}
{#each group as p, i (p.id)} {#each group as p, i (p.id)}
@@ -405,12 +490,21 @@
width={half * 2} height={half * 2} width={half * 2} height={half * 2}
preserveAspectRatio="xMidYMid slice" preserveAspectRatio="xMidYMid slice"
class="token-art hit" class="token-art hit"
class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
class:stone-gazed={hasSpell(p.id, "medusa")}
/> />
<rect <rect
x={cx - half} y={cy - half} x={cx - half} y={cy - half}
width={half * 2} height={half * 2} width={half * 2} height={half * 2}
class="wizard-ring" stroke={playerColor(p.id)} 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} />
</g>
{/if}
{:else} {:else}
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" /> <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> <text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
@@ -493,6 +587,13 @@
{/if} {/if}
{/each} {/each}
{/if} {/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} />
{/each}
</g>
</svg> </svg>
<style> <style>
@@ -529,10 +630,21 @@
stroke-width: 1.4; stroke-width: 1.4;
rx: 2; 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 { .firewall {
fill: #d0342c; fill: #d0342c;
stroke: #7c1a14; stroke: #7c1a14;
stroke-width: 1.2; 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; } .stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; } .bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
@@ -602,6 +714,93 @@
pointer-events: none; pointer-events: none;
animation: warp-pulse 1.6s ease-in-out infinite; animation: warp-pulse 1.6s ease-in-out infinite;
} }
.marked-count {
font-family: "Oswald", sans-serif;
font-size: 15px;
font-weight: 600;
fill: #d3852b;
pointer-events: none;
}
.marked-sector {
fill: rgba(211, 133, 43, 0.12);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 10 6;
pointer-events: none;
animation: warp-pulse 1.6s ease-in-out infinite;
}
.ghost-slot {
fill: rgba(122, 162, 122, 0.10);
stroke: #7aa27a;
stroke-width: 2.5;
stroke-dasharray: 10 6;
cursor: pointer;
animation: warp-pulse 1.6s ease-in-out infinite;
}
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
.fx-layer { pointer-events: none; }
/* --- 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); }
.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 { .warp-dest {
fill: none; fill: none;
stroke: #2e7d32; stroke: #2e7d32;
@@ -615,7 +814,9 @@
50% { opacity: 0.35; } 50% { opacity: 0.35; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.marked-cell, .warp-dest { animation: none; } .marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
.fx-layer { display: none; }
.firewall, .token-art.ghosted { animation: none; }
} }
.wizard { cursor: pointer; } .wizard { cursor: pointer; }
.wizard-label { .wizard-label {
+1 -16
View File
@@ -236,22 +236,7 @@
50% { box-shadow: 0 0 0 2px #c9a72a, 0 0 6px 1px rgba(201, 167, 42, 0.3); } 50% { box-shadow: 0 0 0 2px #c9a72a, 0 0 6px 1px rgba(201, 167, 42, 0.3); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.faq-seal { .card.glowing { animation: none; }
position: absolute;
bottom: 0.15rem;
right: 0.2rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Oswald", sans-serif;
font-size: 0.42rem;
letter-spacing: 0.12em;
padding: 0.1rem 0.25rem;
border-radius: 2px;
cursor: pointer;
opacity: 0.85;
}
.faq-seal:hover { opacity: 1; }
.card.glowing { animation: none; }
.card { transition: none; } .card { transition: none; }
.card:hover, .card.selected, .card.attached { transform: none; } .card:hover, .card.selected, .card.attached { transform: none; }
} }
+119
View File
@@ -0,0 +1,119 @@
<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.
import { FX_SPRITES } from "./fx-sprites";
import type { BoardFx } from "./fx";
let tick = $state(0);
$effect(() => {
const t = setInterval(() => (tick += 1), 2200);
return () => clearInterval(t);
});
// One representative of every kind, staged on a 3x3 (sectors on a 5x5).
const mid = { x: 1, y: 1 };
const SAMPLES: BoardFx[] = ([
{ kind: "fireball", 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: "burst", at: mid },
{ kind: "splash", at: mid },
{ kind: "shimmer", at: mid },
{ kind: "shield", at: mid },
{ kind: "sparkle", at: mid },
{ kind: "whiff", at: mid },
{ kind: "hit", at: mid },
{ kind: "pow", at: mid },
{ kind: "claw", at: mid },
{ kind: "absorb", at: mid },
{ kind: "portal", cell: mid, side: "E" },
{ kind: "portal-cell", at: mid },
{ kind: "streak", from: { x: 0, y: 2 }, to: { x: 2, y: 0 } },
{ kind: "soul", at: { x: 1, y: 2 } },
{ kind: "fireworks", at: mid },
{ kind: "chaos-swirl", at: mid },
{ kind: "pit-fall", at: mid },
{ kind: "ooze-slip", at: mid },
{ kind: "tacks-ow", at: mid },
{ kind: "thorn-snap", at: mid },
{ kind: "slime-stuck", at: mid },
{ kind: "dust-puff", at: mid },
{ kind: "edge-dust", cell: mid, side: "S" },
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
] as unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
const isSector = (k: string) => k.startsWith("sector-");
</script>
<div class="gallery">
<h1>The Flourish Workshop</h1>
<p class="sub">
Every effect replays each beat. Edit a file in <code>src/fx-sprites/</code>
and it hot-reloads here.
</p>
<div class="grid">
{#each SAMPLES as fx (fx.kind)}
{@const Sprite = FX_SPRITES[fx.kind]}
<figure>
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cx) => cx) as cx (cx)}
<rect x={cx * 48} y={cy * 48} width="48" height="48" class="cell" />
{/each}
{/each}
{#key tick}
<g class="fx-layer"><Sprite fx={fx as never} /></g>
{/key}
</svg>
<figcaption>{fx.kind}</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 {
font-family: "Oswald", sans-serif;
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 1.2rem;
color: #e9e1cb;
margin: 0 0 0.2rem;
}
.sub { color: #8d8672; margin: 0 0 1.2rem; }
.sub code { color: #c9a72a; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
}
figure {
margin: 0;
background: #efe8d4;
border-radius: 4px;
padding: 0.5rem;
}
svg { width: 100%; display: block; }
.cell {
fill: #efe8d4;
stroke: rgba(95, 74, 51, 0.25);
stroke-width: 1;
}
.fx-layer { pointer-events: none; }
figcaption {
font-family: "Courier Prime", monospace;
font-size: 0.75rem;
color: #43331f;
text-align: center;
padding-top: 0.3rem;
}
</style>
+17 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import { humanize } from "./net.svelte"; import { humanize } from "./net.svelte";
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
let { let {
@@ -20,6 +21,21 @@
); );
const atEnd = $derived(idx >= steps.length - 1); const atEnd = $derived(idx >= steps.length - 1);
/** 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 = []; };
});
$effect(() => { $effect(() => {
if (!playing) return; if (!playing) return;
const t = setInterval(() => { const t = setInterval(() => {
@@ -47,7 +63,7 @@
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button> <button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
</header> </header>
<div class="replay-board"> <div class="replay-board">
<Board view={step.view} /> <Board view={step.view} effects={boardFx} />
</div> </div>
<div class="replay-caption"> <div class="replay-caption">
<strong>{step.actor}</strong> <strong>{step.actor}</strong>
+36 -7
View File
@@ -25,7 +25,12 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Death", title: "Death",
body: [ body: [
"The second-hand killing of a Wizard, say by falling walls or controlled MONSTERS, does not get you his cards, nor does a MONSTER get cards by killing a player!", "The second-hand killing of a Wizard, say by falling walls or controlled MONSTERS, does not get you his cards, nor does a MONSTER get cards by killing a player!",
"Dumping Cards and Physical Objects This is explained pretty well in the basic rules, but everybody seems to forget you can do it. If you want to discard a physical object, it is NOT necessary to dump it on the board, you can just discard it to the discard pile. The board would get very crowded otherwise. This includes ARTIFACTS, if you think someone is about to take one from you and there's nothing you can do about it. This can only be done during your own turn (this is not in the rules, but will be next printing).", ],
},
{
title: "Dumping Cards and Physical Objects",
body: [
"This is explained pretty well in the basic rules, but everybody seems to forget you can do it. If you want to discard a physical object, it is NOT necessary to dump it on the board, you can just discard it to the discard pile. The board would get very crowded otherwise. This includes ARTIFACTS, if you think someone is about to take one from you and there's nothing you can do about it. This can only be done during your own turn (this is not in the rules, but will be next printing).",
], ],
}, },
{ {
@@ -38,8 +43,18 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Lost Turns", title: "Lost Turns",
body: [ body: [
"These will accumulate if you get hit by a \"lost turns\" type spell while under the effect of another \"lost turns\" spell. Also, if you lose a turn while under a duration spell, this still uses up a turn of the duration spell. If you happen to get hit by a reflected attack (yours), and you \"lose a turn\", this would be THAT turn, not your next turn. This could happen with Lightning Bolt or Hypnosis, for example. As the rules say, if you Lose a Turn, you can't do anything but play Counteractions until your next turn. This also means you can't draw cards!", "These will accumulate if you get hit by a \"lost turns\" type spell while under the effect of another \"lost turns\" spell. Also, if you lose a turn while under a duration spell, this still uses up a turn of the duration spell. If you happen to get hit by a reflected attack (yours), and you \"lose a turn\", this would be THAT turn, not your next turn. This could happen with Lightning Bolt or Hypnosis, for example. As the rules say, if you Lose a Turn, you can't do anything but play Counteractions until your next turn. This also means you can't draw cards!",
"Magic Sticks These don't have to be displayed. When you first charge it with a NUMBER card, the NUMBER card gets discarded, so it no longer counts as part of your hand.", ],
"Magic Stones They may be discarded as cards even after used and displayed. Nothing makes an object so permanent that it cannot be discarded. This is always a player option. MAGIC STONES are not \"created\" by the card, the card merely represents the physical object. It may be displayed and left displayed whenever you use it first, even if it is not your turn.", },
{
title: "Magic Sticks",
body: [
"These don't have to be displayed. When you first charge it with a NUMBER card, the NUMBER card gets discarded, so it no longer counts as part of your hand.",
],
},
{
title: "Magic Stones",
body: [
"They may be discarded as cards even after used and displayed. Nothing makes an object so permanent that it cannot be discarded. This is always a player option. MAGIC STONES are not \"created\" by the card, the card merely represents the physical object. It may be displayed and left displayed whenever you use it first, even if it is not your turn.",
], ],
}, },
{ {
@@ -52,16 +67,30 @@ export const FAQ_GENERAL: { title: string; body: string[] }[] = [
title: "Picking Up Objects", title: "Picking Up Objects",
body: [ body: [
"No, you can't pick up 2 objects at the same time, even if they're in the same space.", "No, you can't pick up 2 objects at the same time, even if they're in the same space.",
"Players as Objects There is some confusion in the rules which will probably not be resolved until next edition as to whether Wizards and MONSTERS can be treated as objects or not, for purposes of spells such as DRAG and a few others. Unfortunately, this is not well defined anywhere. The basic rules state that players are NOT objects, so spells such as TELEPORT OBJECT and ANIMATE OBJECT would have no effect on Wizards. Use common sense and house rules. You will rarely run into this as a problem, but you could. Obviously, if a card says a MONSTER can carry objects, it is NOT referring to other Wizards!", ],
"Spell Priority There is some question as to whether Counteractions or Attacks work first, especially in a case like SHIELDSTONE vs FIREBALL. Counteractions always work first. Thus, if someone was hit with a FIREBALL while they were carrying a BLOODSTONE, the BLOODSTONE would reduce the damage done by a point before it was destroyed. If you have 2 Counteractions you want to use, you decide for yourself which one activates first. If a spell gets counteracted, the number card (if any) that accompanied it is also discarded.", },
"Throwing Objects The basic rules imply, without explicitly stating, that you can throw objects, but not treasures. That's true! You could take a Magic Stone and throw it inot a PIT 3 spaces away, if you wanted. Throwing objects at other players does not do any damage, unless a card, such as HEAVE-HO or DAGGER says so.", {
title: "Players as Objects",
body: [
"There is some confusion in the rules which will probably not be resolved until next edition as to whether Wizards and MONSTERS can be treated as objects or not, for purposes of spells such as DRAG and a few others. Unfortunately, this is not well defined anywhere. The basic rules state that players are NOT objects, so spells such as TELEPORT OBJECT and ANIMATE OBJECT would have no effect on Wizards. Use common sense and house rules. You will rarely run into this as a problem, but you could. Obviously, if a card says a MONSTER can carry objects, it is NOT referring to other Wizards!",
],
},
{
title: "Spell Priority",
body: [
"There is some question as to whether Counteractions or Attacks work first, especially in a case like SHIELDSTONE vs FIREBALL. Counteractions always work first. Thus, if someone was hit with a FIREBALL while they were carrying a BLOODSTONE, the BLOODSTONE would reduce the damage done by a point before it was destroyed. If you have 2 Counteractions you want to use, you decide for yourself which one activates first. If a spell gets counteracted, the number card (if any) that accompanied it is also discarded.",
],
},
{
title: "Throwing Objects",
body: [
"The basic rules imply, without explicitly stating, that you can throw objects, but not treasures. That's true! You could take a Magic Stone and throw it inot a PIT 3 spaces away, if you wanted. Throwing objects at other players does not do any damage, unless a card, such as HEAVE-HO or DAGGER says so.",
], ],
}, },
{ {
title: "Treasures", title: "Treasures",
body: [ body: [
"If a player is eliminated, any treasure left on his home base does NOT count toward eliminating the player to whom that treasure belongs. Also, the 7th edition rules are rather badly worded and imply that you can carry 2 treasures as long as one of them is your own and the other belongs to another player. This is incorrect. You can carry only ONE treasure, and it can be EITHER one of your own or one of the opponent's.", "If a player is eliminated, any treasure left on his home base does NOT count toward eliminating the player to whom that treasure belongs. Also, the 7th edition rules are rather badly worded and imply that you can carry 2 treasures as long as one of them is your own and the other belongs to another player. This is incorrect. You can carry only ONE treasure, and it can be EITHER one of your own or one of the opponent's.",
"Card FAQ's for Wiz-War",
], ],
}, },
]; ];
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"absorb"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<linearGradient id={`absorb-shine-${uid}`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fffdf4" />
<stop offset="0.5" stop-color="#f6f0df" />
<stop offset="1" stop-color="#d8ccb0" />
</linearGradient>
</defs>
<circle cx={c.x} cy={c.y} r="14" class="pull" />
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2"
fill={`url(#absorb-shine-${uid})`} class="card"
style={`transform-origin: ${c.x}px ${c.y}px`} />
<style>
.card {
stroke: #43331f;
stroke-width: 1.5;
animation: swallow 0.65s ease-in forwards;
}
.pull {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: rgba(180, 138, 224, 0.6);
stroke-width: 2;
stroke-dasharray: 5 6;
animation: pull-in 0.6s ease-in forwards;
}
@keyframes swallow {
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(0.05) rotate(140deg); }
}
@keyframes pull-in {
0% { opacity: 0.8; transform: scale(1.6) rotate(0deg); }
100% { opacity: 0; transform: scale(0.2) rotate(-90deg); }
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<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 segs = 6;
const joints: { x: number; y: number }[] = [a];
for (let i = 1; i < segs; i++) {
const t = i / segs;
joints.push({
x: a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16,
y: a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16,
});
}
joints.push(b);
const points = joints.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
const fork = (j: { x: number; y: number }) => {
const dx = (Math.random() - 0.5) * 26, dy = (Math.random() - 0.5) * 26;
return `M ${j.x} ${j.y} l ${dx / 2} ${dy / 2} l ${dx / 2 + (Math.random() - 0.5) * 8} ${dy / 2}`;
};
return { points, forks: [fork(joints[2]!), fork(joints[4]!)] };
});
</script>
<defs>
<filter id={`bolt-glow-${uid}`} x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<g class="bolt" filter={`url(#bolt-glow-${uid})`}>
<polyline points={geom.points} class="wide" />
{#each geom.forks as d, i (i)}
<path {d} class="fork" />
{/each}
<polyline points={geom.points} class="core" />
</g>
<style>
.bolt .wide {
fill: none;
stroke: #e6a812;
stroke-width: 5.5;
stroke-linejoin: round;
opacity: 0.75;
}
.bolt .fork {
fill: none;
stroke: #fff6b0;
stroke-width: 1.6;
stroke-linejoin: round;
}
.bolt .core {
fill: none;
stroke: #fffdf0;
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; }
60% { opacity: 1; } 100% { opacity: 0; }
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"burst"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const EMBERS = [15, 80, 145, 210, 275, 340];
</script>
<defs>
<radialGradient id={`burst-fire-${uid}`}>
<stop offset="0" stop-color="#fffde0" />
<stop offset="0.3" stop-color="#ffd34e" />
<stop offset="0.65" stop-color="#ff8c1a" stop-opacity="0.85" />
<stop offset="1" stop-color="#ef3b08" stop-opacity="0" />
</radialGradient>
</defs>
<circle cx={c.x} cy={c.y} r="16" fill={`url(#burst-fire-${uid})`} class="bloom" />
<circle cx={c.x} cy={c.y} r="6" class="shockwave" />
{#each EMBERS as deg (deg)}
<circle
cx={c.x + 13 * Math.cos((deg * Math.PI) / 180)}
cy={c.y + 13 * Math.sin((deg * Math.PI) / 180)}
r={deg % 2 === 0 ? 2 : 1.5}
class="ember"
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.bloom {
transform-box: fill-box;
transform-origin: center;
animation: bloom 0.5s ease-out forwards;
}
.shockwave {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #ffd27a;
stroke-width: 2.5;
animation: shockwave 0.45s ease-out 0.08s forwards;
opacity: 0;
}
.ember {
fill: #ff9d19;
animation: ember-fly 0.55s ease-out forwards;
}
@keyframes bloom {
0% { opacity: 0; transform: scale(0.2); }
25% { opacity: 1; transform: scale(1.2); }
100% { opacity: 0; transform: scale(2.4); }
}
@keyframes shockwave {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(6); }
}
@keyframes ember-fly {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(2.1) rotate(24deg); }
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"chaos-swirl"> } = $props();
const c = $derived(center(fx.at));
const CARDS = [0, 72, 144, 216, 288];
</script>
<g class="chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
<circle cx={c.x} cy={c.y} r="30" class="ring inner" />
<circle cx={c.x} cy={c.y} r="55" class="ring mid" />
<circle cx={c.x} cy={c.y} r="80" class="ring outer" />
{#each CARDS as deg (deg)}
<rect
x={c.x + 46 * Math.cos((deg * Math.PI) / 180) - 5}
y={c.y + 46 * Math.sin((deg * Math.PI) / 180) - 7}
width="10" height="14" rx="1.5"
class="slip"
transform={`rotate(${deg + 25} ${c.x + 46 * Math.cos((deg * Math.PI) / 180)} ${c.y + 46 * Math.sin((deg * Math.PI) / 180)})`}
/>
{/each}
</g>
<style>
.ring {
fill: none;
stroke-width: 4;
stroke-dasharray: 30 22;
}
.ring.inner { stroke: #b48ae0; }
.ring.mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
.ring.outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
.slip {
fill: #f6f0df;
stroke: #43331f;
stroke-width: 1;
}
.chaos { animation: chaos-spin 1.4s ease-in-out forwards; }
@keyframes chaos-spin {
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
20% { opacity: 1; }
100% { opacity: 0; transform: rotate(220deg) scale(1.4); }
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"claw"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const GASHES = [
{ x: -12, cls: "g0" },
{ x: -3, cls: "g1" },
{ x: 6, cls: "g2" },
];
</script>
<defs>
<linearGradient id={`claw-cut-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#e0604f" />
<stop offset="0.5" stop-color="#b3372b" />
<stop offset="1" stop-color="#6e1a12" />
</linearGradient>
</defs>
<g class="claw">
{#each GASHES as g (g.cls)}
<g class={`gash ${g.cls}`} style={`transform-origin: ${c.x + g.x}px ${c.y - 15}px`}>
<path
d={`M ${c.x + g.x} ${c.y - 14} q 4 12 10 26 q -1 1 -2 0 q -7 -13 -11 -25 z`}
fill={`url(#claw-cut-${uid})`}
/>
<path
d={`M ${c.x + g.x} ${c.y - 14} q 4 12 10 26`}
class="edge" pathLength="100"
/>
</g>
{/each}
</g>
<style>
/* Each slash reveals downward along its own length, in sequence,
while the whole strike rakes a little sideways. */
.gash { opacity: 0; animation: gash-cut 0.4s ease-out forwards; }
.gash.g1 { animation-delay: 0.06s; }
.gash.g2 { animation-delay: 0.12s; }
.edge {
fill: none;
stroke: #ffd9c4;
stroke-width: 1.4;
stroke-dasharray: 100 100;
stroke-dashoffset: 100;
animation: edge-draw 0.28s ease-out forwards;
}
.gash.g1 .edge { animation-delay: 0.06s; }
.gash.g2 .edge { animation-delay: 0.12s; }
.claw { animation: rake 0.55s ease-out forwards; }
@keyframes gash-cut {
0% { opacity: 0; transform: scaleY(0.1); }
30% { opacity: 1; transform: scaleY(1.05); }
100% { opacity: 0.9; transform: scaleY(1); }
}
@keyframes edge-draw {
0% { stroke-dashoffset: 100; opacity: 1; }
70% { stroke-dashoffset: 0; opacity: 0.9; }
100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes rake {
0% { opacity: 1; transform: translate(2px, -3px); }
60% { opacity: 1; transform: translate(-2px, 2px); }
100% { opacity: 0; transform: translate(-3px, 3px); }
}
</style>
@@ -0,0 +1,63 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, center } from "./geom";
let { fx }: { fx: FxOf<"dust-puff" | "edge-dust"> } = $props();
const uid = $props.id();
const c = $derived.by(() => {
if (fx.kind === "dust-puff") return center(fx.at);
const mid = center(fx.cell);
if (fx.side === "N") return { x: mid.x, y: fx.cell.y * CELL };
if (fx.side === "S") return { x: mid.x, y: (fx.cell.y + 1) * CELL };
if (fx.side === "W") return { x: fx.cell.x * CELL, y: mid.y };
return { x: (fx.cell.x + 1) * CELL, y: mid.y };
});
/** Edge dust blows perpendicular to the wall; loose dust just rises. */
const away = $derived.by(() => {
if (fx.kind !== "edge-dust") return { x: 0, y: -12 };
return fx.side === "N" || fx.side === "S" ? { x: 11, y: -4 } : { x: 4, y: -11 };
});
</script>
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff there" />
<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" />
<g class="grit">
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
<circle cx={c.x + 1} cy={c.y - 2} r="0.9" />
</g>
</g>
<defs>
<radialGradient id={`dust-soft-${uid}`}>
<stop offset="0" stop-color="#96876a" stop-opacity="1" />
<stop offset="0.75" stop-color="#7d7358" stop-opacity="0.7" />
<stop offset="1" stop-color="#8d8266" stop-opacity="0" />
</radialGradient>
</defs>
<style>
.puff {
transform-box: fill-box;
transform-origin: center;
animation: blow-a 0.8s ease-out forwards;
}
.puff.back { animation-name: blow-b; }
.puff.late { animation-delay: 0.08s; }
.puff.later { animation-delay: 0.16s; }
.grit circle { fill: #4a4232; animation: grit-fall 0.6s ease-in forwards; }
@keyframes blow-a {
0% { opacity: 1; transform: translate(0, 0) scale(0.8); }
100% { opacity: 0; transform: translate(var(--ax), var(--ay)) scale(2); }
}
@keyframes blow-b {
0% { opacity: 1; transform: translate(0, 0) scale(0.8); }
100% { opacity: 0; transform: translate(var(--bx), var(--by)) scale(1.9); }
}
@keyframes grit-fall {
0% { opacity: 1; transform: translateY(-3px); }
100% { opacity: 0; transform: translateY(8px); }
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<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));
</script>
<defs>
<radialGradient id={`fireball-core-${uid}`}>
<stop offset="0" stop-color="#fffde0" />
<stop offset="0.28" stop-color="#fff09a" />
<stop offset="0.62" stop-color="#ffad22" />
<stop offset="1" stop-color="#ef3b08" stop-opacity="0.15" />
</radialGradient>
<linearGradient id={`fireball-tail-${uid}`} x1="1" x2="0">
<stop offset="0" stop-color="#fff2a1" stop-opacity="0.95" />
<stop offset="0.2" stop-color="#ff9d16" stop-opacity="0.86" />
<stop offset="0.62" stop-color="#ee3907" stop-opacity="0.42" />
<stop offset="1" stop-color="#581002" stop-opacity="0" />
</linearGradient>
<filter id={`fireball-warp-${uid}`} x="-60%" y="-160%" width="230%" height="420%">
<feTurbulence type="fractalNoise" baseFrequency="0.022 0.12" numOctaves="3" seed="7" result="noise">
<animate
attributeName="baseFrequency"
values="0.022 0.12; 0.035 0.17; 0.018 0.1"
dur="0.22s"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="noise" scale="14" xChannelSelector="R" yChannelSelector="B" />
<feGaussianBlur stdDeviation="1.4" />
</filter>
<filter id={`fireball-glow-${uid}`} x="-180%" y="-180%" width="460%" height="460%">
<feGaussianBlur stdDeviation="7" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g class="fireball">
<g>
<!-- Outer orange glow -->
<ellipse cx="-4" cy="0" rx="27" ry="16" fill="#ff5b0a" opacity="0.32" filter={`url(#fireball-glow-${uid})`} />
<!-- Turbulent outer flame -->
<path
d="M -8 -13 C -30 -18, -52 -8, -91 -3 C -60 1, -68 14, -98 20 C -54 17, -28 11, -7 12 Z"
fill={`url(#fireball-tail-${uid})`}
filter={`url(#fireball-warp-${uid})`}
/>
<!-- Hot inner flame -->
<path
d="M -6 -8 C -24 -10, -42 -4, -64 1 C -39 2, -32 8, -10 8 Z"
fill="#ffd34e"
opacity="0.74"
filter={`url(#fireball-warp-${uid})`}
/>
<!-- Fireball body -->
<circle r="15" fill="#ff5c09" opacity="0.72" filter={`url(#fireball-glow-${uid})`} />
<circle r="12" fill={`url(#fireball-core-${uid})`} />
<!-- White-hot highlight -->
<ellipse cx="4" cy="-3" rx="6" ry="5" fill="#fffde7" opacity="0.95" />
<!-- Sparks -->
<g fill="#ff9d19">
<circle cx="-28" cy="-17" r="2.2">
<animate attributeName="cy" values="-17; -23; -17" dur="0.16s" repeatCount="indefinite" />
</circle>
<circle cx="-46" cy="12" r="1.6">
<animate attributeName="cy" values="12; 19; 12" dur="0.2s" repeatCount="indefinite" />
</circle>
<circle cx="-70" cy="-7" r="1.4">
<animate attributeName="opacity" values="1; 0.1; 1" dur="0.14s" repeatCount="indefinite" />
</circle>
</g>
</g>
<animateMotion dur="0.42s" fill="freeze" rotate="auto" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</g>
<style>
.fireball {
opacity: 0;
animation: fireball-fade 0.55s ease-in forwards;
}
@keyframes fireball-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"fireworks"> } = $props();
const c = $derived(center(fx.at));
const COLORS = ["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"];
// Deliberately uneven: a real burst has no protractor.
const RAYS = [
{ deg: 8, len: 22, r: 2.8, cls: "" },
{ deg: 52, len: 17, r: 2.2, cls: "late" },
{ deg: 88, len: 24, r: 3, cls: "" },
{ deg: 141, len: 16, r: 2, cls: "later" },
{ deg: 176, len: 21, r: 2.6, cls: "late" },
{ deg: 224, len: 18, r: 2.3, cls: "" },
{ deg: 267, len: 23, r: 2.8, cls: "later" },
{ deg: 309, len: 15, r: 2, cls: "late" },
];
</script>
<g class="fireworks">
{#each RAYS as ray, i (ray.deg)}
{@const dx = Math.cos((ray.deg * Math.PI) / 180)}
{@const dy = Math.sin((ray.deg * Math.PI) / 180)}
<line
x1={c.x + 4 * dx} y1={c.y + 4 * dy}
x2={c.x + ray.len * dx} y2={c.y + ray.len * dy}
stroke={COLORS[i % 4]}
class={`trail ${ray.cls}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
<circle
cx={c.x + (ray.len + 1) * dx} cy={c.y + (ray.len + 1) * dy} r={ray.r}
fill={COLORS[i % 4]}
class={`spark ${ray.cls}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<circle cx={c.x} cy={c.y} r="3.5" class="heart" />
</g>
<style>
.trail {
stroke-width: 2;
stroke-linecap: round;
animation: trail-out 0.9s ease-out forwards;
}
.spark { animation: spark-out 1.05s ease-out forwards; }
.trail.late, .spark.late { animation-delay: 0.07s; }
.trail.later, .spark.later { animation-delay: 0.14s; }
.heart { fill: #fffdf0; animation: heart-pop 0.4s ease-out forwards; }
@keyframes trail-out {
0% { opacity: 0; transform: scale(0.15); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(1.5); }
}
@keyframes spark-out {
0% { opacity: 0; transform: scale(0.15); }
30% { opacity: 1; }
80% { opacity: 0.9; transform: scale(1.55); }
100% { opacity: 0; transform: scale(1.6) translateY(4px); }
}
@keyframes heart-pop {
0% { opacity: 1; transform: scale(0.5); }
100% { opacity: 0; transform: scale(3); }
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"hit"> } = $props();
const c = $derived(center(fx.at));
const DEBRIS = [40, 165, 285];
</script>
<!-- Contact flash: an instant of white -->
<path
d={`M ${c.x} ${c.y - 9} l 2.5 6 6.5 -2 -3.5 5.5 5.5 3.5 -6.5 1 1 6.5 -5.5 -4.5 -4.5 5 0 -7 -6.5 0.5 5 -4.5 -4 -5 6.5 1.5 z`}
class="flash" style={`transform-origin: ${c.x}px ${c.y}px`}
/>
<!-- Compressed impact ring: squashed, then out -->
<ellipse cx={c.x} cy={c.y} rx="9" ry="6" class="ring" />
{#each DEBRIS as deg, i (deg)}
<line
x1={c.x + 8 * Math.cos((deg * Math.PI) / 180)}
y1={c.y + 8 * Math.sin((deg * Math.PI) / 180)}
x2={c.x + 13 * Math.cos((deg * Math.PI) / 180)}
y2={c.y + 13 * Math.sin((deg * Math.PI) / 180)}
class={`debris d${i}`}
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.flash {
fill: #fffdf0;
stroke: #c0392b;
stroke-width: 1;
animation: flash-snap 0.28s ease-out forwards;
}
.ring {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c0392b;
stroke-width: 3;
animation: ring-punch 0.4s cubic-bezier(0.2, 0.9, 0.3, 1) forwards;
}
.debris {
stroke: #7c221a;
stroke-width: 2;
stroke-linecap: round;
opacity: 0;
animation: debris-fly 0.35s ease-out 0.05s forwards;
}
.debris.d1 { animation-delay: 0.08s; }
.debris.d2 { animation-delay: 0.11s; }
@keyframes flash-snap {
0% { opacity: 1; transform: scale(0.4); }
35% { opacity: 1; transform: scale(1.25); }
100% { opacity: 0; transform: scale(0.9); }
}
@keyframes ring-punch {
0% { opacity: 0.95; transform: scale(0.5, 0.35); }
45% { opacity: 0.9; transform: scale(1.6, 1.3); }
100% { opacity: 0; transform: scale(2.2, 1.9); }
}
@keyframes debris-fly {
0% { opacity: 0.9; transform: scale(0.8); }
100% { opacity: 0; transform: scale(1.7); }
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"ooze-slip"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`ooze-splat-${uid}`}>
<stop offset="0" stop-color="#a5cc60" stop-opacity="0.8" />
<stop offset="0.7" stop-color="#6ea03c" stop-opacity="0.55" />
<stop offset="1" stop-color="#4a7024" stop-opacity="0" />
</radialGradient>
</defs>
<ellipse cx={c.x} cy={c.y + 8} rx="14" ry="5.5" fill={`url(#ooze-splat-${uid})`} class="splat" />
<g class="blobs">
<circle cx={c.x - 12} cy={c.y + 3} r="2.5" class="blob" />
<circle cx={c.x + 11} cy={c.y + 2} r="2" class="blob late" />
<circle cx={c.x + 3} cy={c.y - 3} r="1.7" class="blob later" />
</g>
<style>
.splat {
transform-box: fill-box;
transform-origin: center;
animation: splat-wobble 0.7s ease-out forwards;
}
.blob { fill: #8fbf4d; animation: blob-fly 0.55s ease-out forwards; }
.blob.late { animation-delay: 0.06s; }
.blob.later { animation-delay: 0.12s; }
@keyframes splat-wobble {
0% { opacity: 0.95; transform: scale(0.5, 1); }
35% { transform: scale(1.3, 0.85); }
65% { transform: scale(0.9, 1.05); }
100% { opacity: 0; transform: scale(1.15, 0.95); }
}
@keyframes blob-fly {
0% { opacity: 0.9; transform: translateY(0); }
50% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(2px); }
}
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pit-fall"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`pit-hole-${uid}`} cy="0.42">
<stop offset="0" stop-color="#1a130a" />
<stop offset="0.55" stop-color="#3a2c1a" stop-opacity="0.9" />
<stop offset="0.85" stop-color="#5f4a33" stop-opacity="0.55" />
<stop offset="1" stop-color="#5f4a33" stop-opacity="0" />
</radialGradient>
</defs>
<ellipse cx={c.x} cy={c.y} rx="14" ry="11" fill={`url(#pit-hole-${uid})`} class="hole" />
<!-- The far lip catches the light: reads as depth, not a dot -->
<path d={`M ${c.x - 10} ${c.y - 5} a 12 8 0 0 1 20 0`} class="lip" />
<circle cx={c.x} cy={c.y - 2} r="8" class="faller" style={`transform-origin: ${c.x}px ${c.y}px`} />
<g class="rim-dust">
<circle cx={c.x - 11} cy={c.y - 6} r="3" class="dust" />
<circle cx={c.x + 10} cy={c.y - 7} r="2.5" class="dust late" />
<circle cx={c.x + 1} cy={c.y - 11} r="2" class="dust later" />
</g>
<style>
.hole {
transform-box: fill-box;
transform-origin: center;
animation: hole-open 0.75s ease-out forwards;
}
.faller {
fill: rgba(120, 100, 70, 0.85);
animation: fall-in 0.55s ease-in 0.06s forwards;
}
.lip {
fill: none;
stroke: rgba(233, 225, 203, 0.7);
stroke-width: 1.6;
stroke-linecap: round;
animation: hole-open 0.75s ease-out forwards;
}
.dust { fill: rgba(160, 150, 130, 0.75); animation: dust-drift 0.7s ease-out 0.15s forwards; opacity: 0; }
.dust.late { animation-delay: 0.24s; }
.dust.later { animation-delay: 0.32s; }
@keyframes hole-open {
0% { opacity: 0; transform: scale(0.3); }
25% { opacity: 1; transform: scale(1); }
80% { opacity: 1; }
100% { opacity: 0; transform: scale(1); }
}
@keyframes fall-in {
0% { opacity: 1; transform: scale(1) translateY(-6px); }
100% { opacity: 0; transform: scale(0.1) translateY(4px) rotate(60deg); }
}
@keyframes dust-drift {
0% { opacity: 0; transform: translateY(0) scale(1); }
30% { opacity: 0.8; }
100% { opacity: 0; transform: translateY(-10px) scale(1.7); }
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL } from "./geom";
let { fx }: { fx: FxOf<"portal" | "portal-cell"> } = $props();
const uid = $props.id();
</script>
{#if fx.kind === "portal"}
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
<line {x1} {y1} {x2} {y2} class="curtain glow" />
<line {x1} {y1} {x2} {y2} class="curtain" pathLength="100" />
{:else}
<defs>
<radialGradient id={`portal-void-${uid}`}>
<stop offset="0" stop-color="#0d2430" stop-opacity="0.85" />
<stop offset="0.6" stop-color="#12414a" stop-opacity="0.5" />
<stop offset="1" stop-color="#9be3e0" stop-opacity="0.1" />
</radialGradient>
</defs>
<rect x={fx.at.x * CELL + 5} y={fx.at.y * CELL + 5}
width={CELL - 10} height={CELL - 10} rx="8"
fill={`url(#portal-void-${uid})`} class="void"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
{@const vx = fx.at.x * CELL + CELL / 2}
{@const vy = fx.at.y * CELL + CELL / 2}
<g class="swirl" style={`transform-origin: ${vx}px ${vy}px`}>
<path d={`M ${vx + 12} ${vy} A 12 12 0 0 1 ${vx - 6} ${vy + 10}`} class="arm" />
<path d={`M ${vx - 12} ${vy} A 12 12 0 0 1 ${vx + 6} ${vy - 10}`} class="arm" />
<path d={`M ${vx} ${vy + 7} A 7 7 0 0 1 ${vx - 6} ${vy - 4}`} class="arm faint" />
</g>
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="veil outer"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
width={CELL - 6} height={CELL - 6} rx="6" class="veil inner"
style={`transform-origin: ${fx.at.x * CELL + CELL / 2}px ${fx.at.y * CELL + CELL / 2}px`} />
{/if}
<style>
/* The threshold opens, light ripples along it, and it pinches shut. */
.curtain {
stroke: #9be3e0;
stroke-dasharray: 6 5;
stroke-linecap: round;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
animation: open-ripple-pinch 0.95s ease-in-out forwards;
}
.curtain.glow {
stroke: rgba(180, 138, 224, 0.5);
stroke-dasharray: none;
filter: blur(2px);
animation: glow-swell 0.95s ease-in-out forwards;
}
@keyframes open-ripple-pinch {
0% { opacity: 0; stroke-width: 0.5; stroke-dashoffset: 0; }
22% { opacity: 1; stroke-width: 6; }
60% { stroke-width: 4.5; stroke-dashoffset: 22; }
88% { opacity: 0.9; stroke-width: 1.5; stroke-dashoffset: 34; }
100% { opacity: 0; stroke-width: 0.5; stroke-dashoffset: 38; }
}
@keyframes glow-swell {
0% { opacity: 0; stroke-width: 2; }
25% { opacity: 0.85; stroke-width: 14; }
70% { opacity: 0.6; stroke-width: 9; }
100% { opacity: 0; stroke-width: 2; }
}
/* Cell mouths: rings of energy collapse inward through the token. */
.veil {
fill: none;
stroke: #9be3e0;
stroke-dasharray: 7 5;
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
}
.void { animation: void-open 0.9s ease-out forwards; }
.arm {
fill: none;
stroke: rgba(155, 227, 224, 0.7);
stroke-width: 1.8;
stroke-linecap: round;
}
.arm.faint { stroke: rgba(180, 138, 224, 0.55); }
.swirl { animation: swirl-turn 0.9s ease-in forwards; }
@keyframes swirl-turn {
0% { opacity: 0; transform: rotate(0deg) scale(1.1); }
25% { opacity: 1; }
100% { opacity: 0; transform: rotate(160deg) scale(0.4); }
}
.veil.outer { stroke-width: 2.5; animation: veil-in 0.9s ease-in forwards; }
.veil.inner {
stroke: rgba(180, 138, 224, 0.8);
stroke-width: 2;
animation: veil-in 0.9s ease-in 0.2s forwards;
opacity: 0;
}
@keyframes void-open {
0% { opacity: 0; transform: scale(0.4); }
30% { opacity: 1; transform: scale(1); }
75% { opacity: 0.9; transform: scale(0.92); }
100% { opacity: 0; transform: scale(0.5); }
}
@keyframes veil-in {
0% { opacity: 0; transform: scale(1.25); }
25% { opacity: 1; }
100% { opacity: 0; transform: scale(0.45); }
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"pow"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
const DASHES = [20, 95, 160, 250, 320];
</script>
<defs>
<radialGradient id={`pow-star-${uid}`}>
<stop offset="0" stop-color="#fffdf0" />
<stop offset="0.45" stop-color="#ffe94d" />
<stop offset="1" stop-color="#ffb02e" />
</radialGradient>
</defs>
<g class="pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path
d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`}
fill={`url(#pow-star-${uid})`}
/>
<circle cx={c.x} cy={c.y} r="4" class="flash" />
</g>
{#each DASHES as deg (deg)}
<line
x1={c.x + 16 * Math.cos((deg * Math.PI) / 180)}
y1={c.y + 16 * Math.sin((deg * Math.PI) / 180)}
x2={c.x + 24 * Math.cos((deg * Math.PI) / 180)}
y2={c.y + 24 * Math.sin((deg * Math.PI) / 180)}
class="dash"
style={`transform-origin: ${c.x}px ${c.y}px`}
/>
{/each}
<style>
.pow path { stroke: #b3372b; stroke-width: 1.6; }
.pow .flash { fill: #ffffff; animation: flash 0.3s ease-out forwards; }
.pow { animation: pow-hit 0.5s ease-out forwards; }
.dash {
stroke: #b3372b;
stroke-width: 2.5;
stroke-linecap: round;
animation: dash-out 0.4s ease-out 0.06s forwards;
opacity: 0;
}
@keyframes pow-hit {
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
55% { transform: scale(1) rotate(0deg); }
100% { opacity: 0; transform: scale(1.05); }
}
@keyframes flash {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(3); }
}
@keyframes dash-out {
0% { opacity: 0; transform: scale(0.7); }
30% { opacity: 0.9; }
100% { opacity: 0; transform: scale(1.3); }
}
</style>
@@ -0,0 +1,131 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { CELL, SECTOR } from "./geom";
let { fx }: { fx: FxOf<"sector-spin" | "sector-slide"> } = $props();
const S = SECTOR * CELL;
const origin = $derived(fx.kind === "sector-spin" ? fx.origin : fx.from);
const ox = $derived(origin.x * CELL);
const oy = $derived(origin.y * CELL);
const cx = $derived(ox + S / 2);
const cy = $derived(oy + S / 2);
/** Slide direction, unit-ish, for the chevron train. */
const dir = $derived.by(() => {
if (fx.kind !== "sector-slide") return { x: 0, y: 0 };
const dx = fx.to.x - fx.from.x, dy = fx.to.y - fx.from.y;
const m = Math.max(1, Math.hypot(dx, dy));
return { x: dx / m, y: dy / m };
});
const CORNERS = $derived([
{ x: ox + 6, y: oy + 6 }, { x: ox + S - 6, y: oy + 6 },
{ x: ox + 6, y: oy + S - 6 }, { x: ox + S - 6, y: oy + S - 6 },
]);
</script>
{#if fx.kind === "sector-spin"}
<g class={`grind ${fx.clockwise ? "cw" : "ccw"}`} style={`transform-origin: ${cx}px ${cy}px`}>
<rect x={ox + 2} y={oy + 2} width={S - 4} height={S - 4} class="frame" />
<!-- Curved motion arcs at the corners, pointing the way around -->
{#each [45, 135, 225, 315] as deg (deg)}
<path
d={`M ${cx + (S / 2 - 14) * Math.cos(((deg - 16) * Math.PI) / 180)} ${cy + (S / 2 - 14) * Math.sin(((deg - 16) * Math.PI) / 180)}
A ${S / 2 - 14} ${S / 2 - 14} 0 0 ${fx.clockwise ? 1 : 0}
${cx + (S / 2 - 14) * Math.cos(((deg + 16) * Math.PI) / 180)} ${cy + (S / 2 - 14) * Math.sin(((deg + 16) * Math.PI) / 180)}`}
class="arc"
/>
{/each}
</g>
{:else}
<g class="slide-group" style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`}>
<rect x={ox + 2} y={oy + 2} width={S - 4} height={S - 4} class="frame slide" />
<!-- Chevron train pointing along the journey -->
{#each [0.3, 0.5, 0.7] as t, i (t)}
<path
d={`M ${cx + dir.x * S * (t - 0.08) - dir.y * 9} ${cy + dir.y * S * (t - 0.08) - dir.x * 9}
L ${cx + dir.x * S * t} ${cy + dir.y * S * t}
L ${cx + dir.x * S * (t - 0.08) + dir.y * 9} ${cy + dir.y * S * (t - 0.08) + dir.x * 9}`}
class={`chevron c${i}`}
/>
{/each}
</g>
{/if}
<g class="corner-dust">
{#each CORNERS as p, i (i)}
<circle cx={p.x} cy={p.y} r="3.5" class={`dust d${i}`} />
{/each}
</g>
<style>
.frame {
fill: rgba(233, 225, 203, 0.25);
stroke: #d3852b;
stroke-width: 3;
stroke-dasharray: 12 7;
}
.arc {
fill: none;
stroke: #b3691f;
stroke-width: 2.5;
stroke-linecap: round;
}
.chevron {
fill: none;
stroke: #b3691f;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.chevron.c0 { animation: chev 0.5s ease-out 0.15s forwards; }
.chevron.c1 { animation: chev 0.5s ease-out 0.3s forwards; }
.chevron.c2 { animation: chev 0.5s ease-out 0.45s forwards; }
.grind { animation: grind-settle-cw 1.25s ease-in-out forwards; }
.grind.ccw { animation-name: grind-settle-ccw; }
.slide-group { animation: slide-settle 1.25s ease-in-out forwards; }
.dust {
fill: rgba(160, 150, 130, 0.85);
opacity: 0;
animation: dust-kick 0.6s ease-out 0.85s forwards;
}
.dust.d1 { animation-delay: 0.9s; }
.dust.d2 { animation-delay: 0.95s; }
.dust.d3 { animation-delay: 1s; }
@keyframes chev {
0% { opacity: 0; }
35% { opacity: 0.9; }
100% { opacity: 0; }
}
/* The quarter-turn, then a grinding shudder as it settles. */
@keyframes grind-settle-cw {
0% { opacity: 0; transform: rotate(-90deg); }
12% { opacity: 1; }
72% { opacity: 1; transform: rotate(0.6deg); }
80% { transform: rotate(-0.8deg); }
87% { transform: rotate(0.4deg); }
93% { transform: rotate(0deg); }
100% { opacity: 0; transform: rotate(0deg); }
}
@keyframes grind-settle-ccw {
0% { opacity: 0; transform: rotate(90deg); }
12% { opacity: 1; }
72% { opacity: 1; transform: rotate(-0.6deg); }
80% { transform: rotate(0.8deg); }
87% { transform: rotate(-0.4deg); }
93% { transform: rotate(0deg); }
100% { opacity: 0; transform: rotate(0deg); }
}
@keyframes slide-settle {
0% { opacity: 0; transform: translate(0, 0); }
12% { opacity: 1; }
72% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
80% { transform: translate(calc(var(--dx) - 2px), var(--dy)); }
88% { transform: translate(calc(var(--dx) + 1px), var(--dy)); }
94% { transform: translate(var(--dx), var(--dy)); }
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
}
@keyframes dust-kick {
0% { opacity: 0; transform: translateY(0) scale(0.7); }
30% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(-9px) scale(1.6); }
}
</style>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"shield"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`shield-dome-${uid}`}>
<stop offset="0" stop-color="#ffe9a3" stop-opacity="0" />
<stop offset="0.75" stop-color="#ffdf7a" stop-opacity="0.25" />
<stop offset="1" stop-color="#c9a72a" stop-opacity="0.9" />
</radialGradient>
<filter id={`shield-glow-${uid}`} x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<circle cx={c.x} cy={c.y} r="16" fill={`url(#shield-dome-${uid})`} class="dome" filter={`url(#shield-glow-${uid})`} />
<circle cx={c.x} cy={c.y} r="16" class="rim" />
<style>
.dome {
transform-box: fill-box;
transform-origin: center;
animation: dome 0.7s ease-out forwards;
}
.rim {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #fff3c4;
stroke-width: 2;
animation: rim-strike 0.7s ease-out forwards;
}
@keyframes dome {
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 rim-strike {
0% { opacity: 0; transform: scale(0.6); }
28% { opacity: 1; transform: scale(1.12); }
45% { opacity: 0.4; }
58% { opacity: 0.9; transform: scale(0.95); }
100% { opacity: 0; transform: scale(1.18); }
}
</style>
@@ -0,0 +1,39 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"shimmer"> } = $props();
const c = $derived(center(fx.at));
</script>
<circle cx={c.x} cy={c.y} r="6" class="ring" />
<circle cx={c.x} cy={c.y} r="6" class="ring late" />
<g class="motes">
<circle cx={c.x - 8} cy={c.y - 8} r="1.5" />
<circle cx={c.x + 9} cy={c.y - 4} r="1.2" class="late" />
<circle cx={c.x - 3} cy={c.y + 9} r="1.3" class="later" />
</g>
<style>
.ring {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #b48ae0;
stroke-width: 2.5;
animation: ring 0.6s ease-out forwards;
}
.ring.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
.motes circle { fill: #e2c8ff; animation: mote 0.7s ease-out forwards; }
.motes .late { animation-delay: 0.1s; opacity: 0; }
.motes .later { animation-delay: 0.18s; opacity: 0; }
@keyframes ring {
0% { opacity: 0.95; transform: scale(1); }
100% { opacity: 0; transform: scale(5); }
}
@keyframes mote {
0% { opacity: 0; transform: translateY(0); }
30% { opacity: 1; }
100% { opacity: 0; transform: translateY(-12px); }
}
</style>
@@ -0,0 +1,74 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"slime-stuck"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`slime-goo-${uid}`}>
<stop offset="0" stop-color="#c8e87a" stop-opacity="0.4" />
<stop offset="0.8" stop-color="#8cc83c" stop-opacity="0.65" />
<stop offset="1" stop-color="#7a9e2e" stop-opacity="0.9" />
</radialGradient>
</defs>
<!-- An irregular blob, squashing as it grips -->
<path
class="goo" style={`transform-origin: ${c.x}px ${c.y}px`}
fill={`url(#slime-goo-${uid})`}
d={`M ${c.x - 13} ${c.y + 2}
C ${c.x - 14} ${c.y - 7}, ${c.x - 6} ${c.y - 12}, ${c.x + 2} ${c.y - 10}
C ${c.x + 10} ${c.y - 13}, ${c.x + 15} ${c.y - 4}, ${c.x + 12} ${c.y + 4}
C ${c.x + 14} ${c.y + 10}, ${c.x + 4} ${c.y + 12}, ${c.x - 3} ${c.y + 10}
C ${c.x - 10} ${c.y + 12}, ${c.x - 12} ${c.y + 8}, ${c.x - 13} ${c.y + 2} z`}
/>
<!-- Elastic strands stretching toward the caught -->
<g class="strands">
<path d={`M ${c.x - 11} ${c.y - 6} Q ${c.x - 6} ${c.y - 2} ${c.x - 2} ${c.y}`} class="strand" />
<path d={`M ${c.x + 12} ${c.y - 3} Q ${c.x + 6} ${c.y - 1} ${c.x + 2} ${c.y + 1}`} class="strand late" />
<path d={`M ${c.x + 2} ${c.y + 10} Q ${c.x + 1} ${c.y + 5} ${c.x} ${c.y + 1}`} class="strand later" />
</g>
<circle cx={c.x + 4} cy={c.y - 5} r="2.5" class="bubble" />
<style>
.goo { animation: goo-grip 0.9s ease-out forwards; }
.strand {
fill: none;
stroke: #8fbf4d;
stroke-width: 2;
stroke-linecap: round;
stroke-dasharray: 20 40;
animation: strand-snap 0.55s ease-in 0.15s forwards;
opacity: 0;
}
.strand.late { animation-delay: 0.28s; }
.strand.later { animation-delay: 0.4s; }
.bubble {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #c8e87a;
stroke-width: 1.5;
animation: bubble-pop 0.6s ease-out 0.25s forwards;
opacity: 0;
}
@keyframes goo-grip {
0% { opacity: 0.95; transform: scale(1.3, 1.1) rotate(3deg); }
40% { transform: scale(0.92, 1.06) rotate(-2deg); }
70% { transform: scale(1.04, 0.96) rotate(1deg); }
100% { opacity: 0; transform: scale(1) rotate(0deg); }
}
@keyframes strand-snap {
0% { opacity: 0; stroke-dashoffset: 18; }
30% { opacity: 0.9; stroke-dashoffset: 6; }
75% { opacity: 0.85; stroke-dashoffset: 0; }
100% { opacity: 0; stroke-dashoffset: -4; }
}
@keyframes bubble-pop {
0% { opacity: 0; transform: scale(0.4); }
50% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(1.8); }
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"soul"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`soul-body-${uid}`} cy="0.35">
<stop offset="0" stop-color="#f7f4ff" stop-opacity="0.95" />
<stop offset="0.7" stop-color="#d9d0f0" stop-opacity="0.75" />
<stop offset="1" stop-color="#a096c8" stop-opacity="0.35" />
</radialGradient>
</defs>
<g class="soul">
<g class="sway" style={`transform-origin: ${c.x}px ${c.y}px`}>
<!-- Rounded head, wavy hem -->
<path
d={`M ${c.x - 8} ${c.y + 6} C ${c.x - 9} ${c.y - 6}, ${c.x - 5} ${c.y - 11}, ${c.x} ${c.y - 11}
C ${c.x + 5} ${c.y - 11}, ${c.x + 9} ${c.y - 6}, ${c.x + 8} ${c.y + 6}
q -2 -2.5 -4 0 q -2 2.5 -4 0 q -2 -2.5 -4 0 q -2 2.5 -4 0 z`}
fill={`url(#soul-body-${uid})`}
/>
<circle cx={c.x - 3} cy={c.y - 4} r="1.3" fill="#5a5178" />
<circle cx={c.x + 3} cy={c.y - 4} r="1.3" fill="#5a5178" />
</g>
</g>
<style>
.soul { animation: ascend 1.3s ease-out forwards; }
.sway { animation: sway 0.65s ease-in-out 2; }
@keyframes ascend {
0% { opacity: 0; transform: translateY(0); }
22% { opacity: 0.95; }
100% { opacity: 0; transform: translateY(-36px); }
}
@keyframes sway {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(6deg); }
}
</style>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"sparkle"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="glint main" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 12} l 3 9 9 3 -9 3 -3 9 -3 -9 -9 -3 9 -3 z`} />
</g>
<g class="glint counter" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x} ${c.y - 8} l 2 6 6 2 -6 2 -2 6 -2 -6 -6 -2 6 -2 z`} />
</g>
<circle cx={c.x} cy={c.y} r="3" class="core" />
<g class="motes">
<circle cx={c.x - 10} cy={c.y - 9} r="1.3" />
<circle cx={c.x + 11} cy={c.y - 5} r="1.1" class="late" />
<circle cx={c.x + 2} cy={c.y + 11} r="1.2" class="later" />
</g>
<style>
.glint.main path {
fill: #ffe084;
stroke: #c9a72a;
stroke-width: 1;
}
.glint.counter path { fill: #fff6d8; opacity: 0.9; }
.glint.main { animation: spin-snap 0.7s ease-out forwards; }
.glint.counter { animation: counter-spin 0.7s ease-out forwards; }
.core {
transform-box: fill-box;
transform-origin: center;
fill: #ffffff;
animation: core-snap 0.45s ease-out forwards;
}
.motes circle { fill: #ffe084; animation: mote-fly 0.6s ease-out 0.12s forwards; opacity: 0; }
.motes .late { animation-delay: 0.18s; }
.motes .later { animation-delay: 0.24s; }
@keyframes spin-snap {
0% { opacity: 0; transform: rotate(-20deg) scale(0.4); }
30% { opacity: 1; transform: rotate(15deg) scale(1.2); }
55% { transform: rotate(30deg) scale(1); }
100% { opacity: 0; transform: rotate(55deg) scale(0.9); }
}
@keyframes counter-spin {
0% { opacity: 0; transform: rotate(45deg) scale(0.4); }
30% { opacity: 0.95; transform: rotate(20deg) scale(1.15); }
100% { opacity: 0; transform: rotate(-25deg) scale(0.85); }
}
@keyframes core-snap {
0% { opacity: 0; transform: scale(0.3); }
30% { opacity: 1; transform: scale(2); }
100% { opacity: 0; transform: scale(0.6); }
}
@keyframes mote-fly {
0% { opacity: 0; transform: translateY(0) scale(1); }
30% { opacity: 0.95; }
100% { opacity: 0; transform: translateY(-8px) scale(0.6); }
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"splash"> } = $props();
const uid = $props.id();
const c = $derived(center(fx.at));
</script>
<defs>
<radialGradient id={`splash-pool-${uid}`}>
<stop offset="0" stop-color="#bfe8ff" stop-opacity="0.7" />
<stop offset="0.7" stop-color="#4fa3e3" stop-opacity="0.4" />
<stop offset="1" stop-color="#155a9e" stop-opacity="0" />
</radialGradient>
</defs>
<ellipse cx={c.x} cy={c.y + 5} rx="16" ry="6.5" fill={`url(#splash-pool-${uid})`} class="pool" />
<circle cx={c.x} cy={c.y} r="7" class="ring" />
<!-- The crown: droplets thrown upward, falling back -->
<g class="crown">
<circle cx={c.x - 14} cy={c.y - 6} r="2.8" class="drop" />
<circle cx={c.x - 7} cy={c.y - 13} r="2.3" class="drop late" />
<circle cx={c.x + 1} cy={c.y - 16} r="2.5" class="drop" />
<circle cx={c.x + 8} cy={c.y - 12} r="2.2" class="drop later" />
<circle cx={c.x + 14} cy={c.y - 5} r="1.9" class="drop late" />
</g>
<style>
.pool {
transform-box: fill-box;
transform-origin: center;
animation: pool-spread 0.6s ease-out forwards;
}
.ring {
transform-box: fill-box;
transform-origin: center;
fill: none;
stroke: #7cc0ee;
stroke-width: 3.2;
animation: ring-out 0.55s ease-out forwards;
}
.drop { fill: #7cc0ee; animation: drop-arc 0.6s ease-in forwards; }
.drop.late { animation-delay: 0.07s; }
.drop.later { animation-delay: 0.13s; }
@keyframes pool-spread {
0% { opacity: 0.9; transform: scale(0.4); }
100% { opacity: 0; transform: scale(2.2); }
}
@keyframes ring-out {
0% { opacity: 0.9; transform: scale(1); }
100% { opacity: 0; transform: scale(4); }
}
@keyframes drop-arc {
0% { opacity: 0.95; transform: translateY(0); }
45% { transform: translateY(-8px); }
100% { opacity: 0; transform: translateY(6px); }
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<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 d = $derived(`M ${a.x} ${a.y} L ${b.x} ${b.y}`);
</script>
<path {d} pathLength="100" class="trail wide" />
<path {d} pathLength="100" class="trail bright" />
<circle r="4" class="head" cx="0" cy="0">
<animateMotion dur="0.38s" fill="freeze" path={d} calcMode="spline"
keySplines="0.3 0 0.4 1" keyTimes="0;1" keyPoints="0;1" />
</circle>
<style>
.trail {
fill: none;
stroke-linecap: round;
stroke-dasharray: 100 100;
animation: draw-collapse 0.68s ease-out forwards;
}
.trail.wide { stroke: rgba(233, 225, 203, 0.3); stroke-width: 5; }
.trail.bright { stroke: rgba(255, 253, 240, 0.75); stroke-width: 2; }
.head {
fill: #fffdf0;
animation: head-life 0.6s ease-out forwards;
}
/* Draw from source to head, then the tail chases it into the destination. */
@keyframes draw-collapse {
0% { stroke-dashoffset: 100; opacity: 0.95; }
56% { stroke-dashoffset: 0; opacity: 0.95; }
100% { stroke-dashoffset: -100; opacity: 0; }
}
@keyframes head-life {
0% { opacity: 0; }
12% { opacity: 1; }
60% { opacity: 1; }
100% { opacity: 0; }
}
</style>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"tacks-ow"> } = $props();
const c = $derived(center(fx.at));
// Each tack: head disc + spike, at its own angle and beat.
const TACKS = [
{ x: -10, y: -4, rot: -18, cls: "" },
{ x: 5, y: -9, rot: 12, cls: "late" },
{ x: -1, y: 5, rot: -5, cls: "later" },
];
</script>
{#each TACKS as t (t.cls)}
<g transform={`translate(${c.x + t.x} ${c.y + t.y}) rotate(${t.rot})`}>
<g class={`tack ${t.cls}`}>
<circle cx="0" cy="3.8" r="3.9" class="head" />
<path d="M -2.1 1.5 L 0 -7.5 L 2.1 1.5 z" class="spike" />
</g>
</g>
{/each}
<style>
.tack .head {
fill: #8a2f24;
stroke: #5c1c14;
stroke-width: 0.8;
}
.tack .spike { fill: #b9b3a4; stroke: #6e6450; stroke-width: 0.5; }
.tack {
opacity: 0;
animation: tack-hop 0.55s ease-out forwards;
}
.tack.late { animation-delay: 0.1s; }
.tack.later { animation-delay: 0.2s; }
@keyframes tack-hop {
0% { opacity: 0; transform: translateY(4px); }
22% { opacity: 1; transform: translateY(-10px); }
50% { transform: translateY(-2px); }
72% { transform: translateY(-7px); }
100% { opacity: 0; transform: translateY(-2px); }
}
</style>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"thorn-snap"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="bush" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
<!-- Three springing branches, thorn spurs along each -->
<g class="branch b0" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
<path d={`M ${c.x} ${c.y + 10} q -8 -10 -13 -20`} class="stem" />
<path d={`M ${c.x - 6} ${c.y + 1} l -4 -1 M ${c.x - 10} ${c.y - 6} l -4 0`} class="spur" />
</g>
<g class="branch b1" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
<path d={`M ${c.x} ${c.y + 10} q 1 -13 -1 -24`} class="stem" />
<path d={`M ${c.x} ${c.y - 2} l 4 -2 M ${c.x - 1} ${c.y - 9} l -4 -2`} class="spur" />
</g>
<g class="branch b2" style={`transform-origin: ${c.x}px ${c.y + 10}px`}>
<path d={`M ${c.x} ${c.y + 10} q 9 -9 15 -18`} class="stem" />
<path d={`M ${c.x + 7} ${c.y + 2} l 4 -1 M ${c.x + 12} ${c.y - 4} l 4 1`} class="spur" />
</g>
</g>
<g class="leaves">
<ellipse cx={c.x - 12} cy={c.y - 10} rx="3" ry="1.6" class="leaf" transform={`rotate(-30 ${c.x - 12} ${c.y - 10})`} />
<ellipse cx={c.x + 13} cy={c.y - 8} rx="2.6" ry="1.4" class="leaf late" transform={`rotate(40 ${c.x + 13} ${c.y - 8})`} />
</g>
<style>
.stem {
fill: none;
stroke: #467832;
stroke-width: 2.5;
stroke-linecap: round;
}
.spur {
stroke: #2f5423;
stroke-width: 1.6;
stroke-linecap: round;
}
.branch { animation: spring 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) forwards; opacity: 0; }
.branch.b1 { animation-delay: 0.05s; }
.branch.b2 { animation-delay: 0.1s; }
.bush { animation: bush-fade 0.7s ease-out forwards; }
.leaf { fill: #7fae55; animation: leaf-fall 0.7s ease-in 0.15s forwards; opacity: 0; }
.leaf.late { animation-delay: 0.24s; }
@keyframes spring {
0% { opacity: 0; transform: scaleY(0.15); }
60% { opacity: 1; transform: scaleY(1.12); }
100% { opacity: 1; transform: scaleY(1); }
}
@keyframes bush-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes leaf-fall {
0% { opacity: 0; transform: translateY(0) rotate(0deg); }
25% { opacity: 0.9; }
100% { opacity: 0; transform: translateY(10px) rotate(50deg); }
}
</style>
@@ -0,0 +1,66 @@
<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));
</script>
<defs>
<radialGradient id={`wb-core-${uid}`}>
<stop offset="0" stop-color="#eafaff" />
<stop offset="0.35" stop-color="#9fd9f7" />
<stop offset="0.7" stop-color="#3b8dd6" />
<stop offset="1" stop-color="#155a9e" stop-opacity="0.2" />
</radialGradient>
<linearGradient id={`wb-tail-${uid}`} x1="1" x2="0">
<stop offset="0" stop-color="#bfe8ff" stop-opacity="0.9" />
<stop offset="0.4" stop-color="#4fa3e3" stop-opacity="0.55" />
<stop offset="1" stop-color="#155a9e" stop-opacity="0" />
</linearGradient>
<filter id={`wb-glow-${uid}`} x="-160%" y="-160%" width="420%" height="420%">
<feGaussianBlur stdDeviation="5" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<g class="waterbolt">
<g>
<!-- Spray tail -->
<path
d="M -5 -9 C -22 -12, -44 -6, -70 -2 C -46 0, -50 8, -74 13 C -44 11, -22 8, -5 9 Z"
fill={`url(#wb-tail-${uid})`}
/>
<!-- Body and core -->
<circle r="11" fill="#3b8dd6" opacity="0.6" filter={`url(#wb-glow-${uid})`} />
<circle r="9" fill={`url(#wb-core-${uid})`} />
<ellipse cx="3" cy="-2.5" rx="4" ry="3" fill="#f2fcff" opacity="0.9" />
<!-- Trailing droplets -->
<g fill="#7cc0ee">
<circle cx="-24" cy="-10" r="2">
<animate attributeName="cy" values="-10; -15; -10" dur="0.18s" repeatCount="indefinite" />
</circle>
<circle cx="-40" cy="9" r="1.6">
<animate attributeName="cy" values="9; 14; 9" dur="0.22s" repeatCount="indefinite" />
</circle>
<circle cx="-56" cy="-4" r="1.3">
<animate attributeName="opacity" values="1; 0.2; 1" dur="0.16s" repeatCount="indefinite" />
</circle>
</g>
</g>
<animateMotion dur="0.42s" fill="freeze" rotate="auto" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</g>
<style>
.waterbolt {
opacity: 0;
animation: wb-fade 0.55s ease-in forwards;
}
@keyframes wb-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type { FxOf } from "../fx";
import { center } from "./geom";
let { fx }: { fx: FxOf<"whiff"> } = $props();
const c = $derived(center(fx.at));
</script>
<g class="whiff" style={`transform-origin: ${c.x}px ${c.y}px`}>
<path d={`M ${c.x - 30} ${c.y - 9} q 20 -7 58 -4`} pathLength="100" class="streak a" />
<path d={`M ${c.x - 32} ${c.y + 1} q 24 4 62 0`} pathLength="100" class="streak b" />
<path d={`M ${c.x - 28} ${c.y + 10} q 18 7 56 5`} pathLength="100" class="streak c" />
</g>
<style>
.streak {
fill: none;
stroke: rgba(110, 125, 140, 0.9);
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 38 100;
stroke-dashoffset: 38;
animation: sweep 0.5s ease-out forwards;
}
.streak.b { animation-delay: 0.06s; stroke-width: 3.6; }
.streak.c { animation-delay: 0.12s; stroke-width: 2.6; }
.whiff { animation: wobble 0.55s ease-in-out forwards; }
@keyframes sweep {
0% { stroke-dashoffset: 38; opacity: 0; }
18% { opacity: 0.95; }
100% { stroke-dashoffset: -100; opacity: 0; }
}
@keyframes wobble {
0% { transform: rotate(0deg); }
40% { transform: rotate(-4deg); }
75% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
</style>
+7
View File
@@ -0,0 +1,7 @@
// Shared geometry for effect sprites. CELL must match Board.svelte's grid.
export const CELL = 48;
export const SECTOR = 5;
export const center = (c: { x: number; y: number }) => ({
x: c.x * CELL + CELL / 2,
y: c.y * CELL + CELL / 2,
});
+61
View File
@@ -0,0 +1,61 @@
// One sprite component per effect. To change an effect's look, edit its
// 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 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 DustPuff from "./DustPuff.svelte";
import Fireball from "./Fireball.svelte";
import Fireworks from "./Fireworks.svelte";
import Hit from "./Hit.svelte";
import OozeSlip from "./OozeSlip.svelte";
import PitFall from "./PitFall.svelte";
import Portal from "./Portal.svelte";
import Pow from "./Pow.svelte";
import SectorGrind from "./SectorGrind.svelte";
import Shield from "./Shield.svelte";
import Shimmer from "./Shimmer.svelte";
import SlimeStuck from "./SlimeStuck.svelte";
import Soul from "./Soul.svelte";
import Sparkle from "./Sparkle.svelte";
import Splash from "./Splash.svelte";
import Streak from "./Streak.svelte";
import TacksOw from "./TacksOw.svelte";
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 }>> = {
fireball: Fireball,
waterbolt: Waterbolt,
bolt: Bolt,
burst: Burst,
splash: Splash,
shimmer: Shimmer,
shield: Shield,
sparkle: Sparkle,
whiff: Whiff,
hit: Hit,
pow: Pow,
claw: Claw,
absorb: Absorb,
portal: Portal,
"portal-cell": Portal,
streak: Streak,
soul: Soul,
fireworks: Fireworks,
"chaos-swirl": ChaosSwirl,
"pit-fall": PitFall,
"ooze-slip": OozeSlip,
"tacks-ow": TacksOw,
"thorn-snap": ThornSnap,
"slime-stuck": SlimeStuck,
"dust-puff": DustPuff,
"edge-dust": DustPuff,
"sector-spin": SectorGrind,
"sector-slide": SectorGrind,
} as never;
+238
View File
@@ -0,0 +1,238 @@
// Whimsical flourishes: game events become short-lived board effects.
// Purely cosmetic — nothing here touches game state, and reduced-motion
// hides the whole layer.
import type { GameEvent, GameView } from "@wizwar/engine";
type Cell = { x: number; y: number };
type Side = "N" | "E" | "S" | "W";
type FxShape =
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell }
| { 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 };
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 };
let nextId = 1;
/** How long each kind stays mounted (animation length + a little grace). */
export function fxTtl(kind: BoardFx["kind"]): number {
switch (kind) {
case "fireball": case "waterbolt": return 700;
case "bolt": case "streak": return 600;
case "portal": case "portal-cell":
case "soul": case "fireworks": case "chaos-swirl":
case "sector-spin": case "sector-slide": return 1500;
default: return 900;
}
}
const PROJECTILES: Record<string, "fireball" | "bolt" | "waterbolt"> = {
fireball: "fireball",
"sudden-death": "fireball",
"blaster-wand": "fireball",
"lightning-blast": "bolt",
"power-drain": "bolt",
waterbolt: "waterbolt",
};
/** Map one command's events to effects, each with a start delay in ms. */
export function fxForEvents(
events: GameEvent[], view: GameView,
): { fx: BoardFx; delay: number }[] {
const out: { fx: BoardFx; delay: number }[] = [];
const posOf = (playerId: string | null): Cell | null => {
if (!playerId) return null;
const p = view.players.find((p) => p.id === playerId);
return p ? { ...p.position } : null;
};
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 });
};
for (const e of events) {
switch (e.type) {
case "spellCast": {
if (e.cardId === "chaos") {
push({ kind: "chaos-swirl", at: { x: (view.board.width - 1) / 2, y: (view.board.height - 1) / 2 } });
beat++;
break;
}
const to = e.targetCell ?? posOf(e.target);
const projectile = PROJECTILES[e.cardId];
if (projectile && to) {
push({ kind: projectile, from: e.from, to });
if (projectile === "fireball") push({ kind: "burst", at: to }, 380);
if (projectile === "waterbolt") push({ kind: "splash", at: to }, 380);
beat++;
} else if (to && e.target) {
push({ kind: "sparkle", at: to });
beat++;
}
break;
}
case "punched":
push({ kind: "pow", at: e.at });
beat++;
break;
case "creatureAttacked": {
// The target may be a wizard or a fellow creature.
const at = posOf(typeof e.target === "string" ? e.target : null) ??
(() => {
const c = view.creatures.find((c) => c.id === e.target);
return c ? { ...c.position } : null;
})();
if (at) { push({ kind: "claw", at }); beat++; }
break;
}
case "creatureTouched": {
const at = posOf(e.player);
if (at) { push({ kind: "claw", at }); beat++; }
break;
}
case "attackAbsorbedIntoHand": {
const at = posOf(e.player);
if (at) { push({ kind: "absorb", at }); beat++; }
break;
}
case "damaged": {
const at = posOf(e.player);
if (at) { push({ kind: "hit", at }); beat++; }
break;
}
case "attackMissed": {
const at = posOf(e.defender);
if (at) { push({ kind: "whiff", at }); beat++; }
break;
}
case "attackResolved": {
const back = posOf(e.attacker);
const stand = posOf(e.defender);
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: "hit", at: back }, 380);
beat++;
} else if (e.fullyStopped && stand) {
push({ kind: "shield", at: stand });
beat++;
}
break;
}
case "teleported":
push({ kind: "shimmer", at: e.from });
push({ kind: "shimmer", at: e.to }, 200);
beat++;
break;
case "moved":
if (e.via === "warp") {
// The mouths are edges: shimmer the opening lines on both boards.
const w = view.board.warps.find((w) =>
w.from.cell.x === e.from.x && w.from.cell.y === e.from.y && w.from.side === e.direction);
if (w) {
push({ kind: "portal", cell: w.from.cell, side: w.from.side });
push({ kind: "portal", cell: w.to.cell, side: w.to.side }, 150);
} else {
push({ kind: "portal-cell", at: e.from });
push({ kind: "portal-cell", at: e.to }, 150);
}
beat++;
}
break;
case "warpStepped":
// Dimensional warp tokens fill their squares; the veil wraps them.
push({ kind: "portal-cell", at: e.from });
push({ kind: "portal-cell", at: e.to }, 150);
beat++;
break;
case "wallCreated":
case "wallDestroyed":
push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side });
beat++;
break;
case "died": {
const at = posOf(e.player);
if (at) { push({ kind: "soul", at }, 250); beat++; }
break;
}
case "gameWon": {
const home = view.players.find((p) => p.id === e.player)?.home;
if (home) {
push({ kind: "fireworks", at: home });
push({ kind: "fireworks", at: home }, 350);
push({ kind: "fireworks", at: home }, 700);
beat++;
}
break;
}
case "knockedBack":
case "shoved":
push({ kind: "streak", from: e.from, to: e.to });
beat++;
break;
case "objectDragged":
push({ kind: "streak", from: e.from, to: e.to });
beat++;
break;
case "jumpedPit":
push({ kind: "streak", from: e.from, to: e.to });
beat++;
break;
case "fellInPit":
push({ kind: "pit-fall", at: e.at });
beat++;
break;
case "climbedFromPit": {
if (e.success) {
const at = posOf(e.player);
if (at) { push({ kind: "dust-puff", at }); beat++; }
}
break;
}
case "slippedInOoze":
push({ kind: "ooze-slip", at: e.at });
beat++;
break;
case "steppedOnTacks":
push({ kind: "tacks-ow", at: e.at });
beat++;
break;
case "enteredThornbush":
push({ kind: "thorn-snap", at: e.at });
beat++;
break;
case "stuckInSlime":
push({ kind: "slime-stuck", at: e.at });
beat++;
break;
case "sectorRotated": {
const origin = view.board.placements[e.sectorIndex]?.origin;
if (origin) push({ kind: "sector-spin", origin: { ...origin }, clockwise: e.clockwise });
// Everything after the grind points at pre-move ground; stop here.
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 } });
}
return out;
}
default:
break;
}
}
return out;
}
+11 -1
View File
@@ -49,6 +49,10 @@ class LocalGame {
handoffTo = $state<PlayerId | null>(null); handoffTo = $state<PlayerId | null>(null);
log = $state<string[]>([]); log = $state<string[]>([]);
/** The finished game as a reel, each step from its actor's own seat. */ /** 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;
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null); replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
view = $derived( view = $derived(
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null, this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
@@ -132,9 +136,14 @@ class LocalGame {
seed, seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"], sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}), ...(colors ? { colors } : {}),
deckRev: 9, deckRev: 14,
}; };
const { state, events } = createGame(config); const { state, events } = createGame(config);
for (const e of events) {
if (e.type === "gameStarted") {
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
}
}
this.config = config; this.config = config;
this.commands = []; this.commands = [];
this.gameState = state; this.gameState = state;
@@ -213,6 +222,7 @@ class LocalGame {
this.log = [...this.log, `${result.error}`]; this.log = [...this.log, `${result.error}`];
return; return;
} }
this.onFx?.(result.events);
this.gameState = result.state; this.gameState = result.state;
this.commands = [...this.commands, { playerId: this.viewerId, command }]; this.commands = [...this.commands, { playerId: this.viewerId, command }];
const now = Date.now(); const now = Date.now();
+32 -9
View File
@@ -25,7 +25,12 @@ export function spellName(cardId: string): string {
export function humanize(e: GameEvent): string | null { export function humanize(e: GameEvent): string | null {
switch (e.type) { switch (e.type) {
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`; case "gameStarted": {
const rolloff = e.players
.map((p) => `${p} 🎲 ${(e.dieRolls[p] ?? []).join(", ")}`)
.join(" · ");
return `Game started. The roll-off: ${rolloff}${e.firstPlayer} goes first.`;
}
case "turnStarted": return `${e.player}'s turn (round ${e.round}) —`; case "turnStarted": return `${e.player}'s turn (round ${e.round}) —`;
case "turnSkipped": return `${e.player} loses a turn.`; case "turnSkipped": return `${e.player} loses a turn.`;
case "extraTurnStarted": return `${e.player} takes an extra turn!`; case "extraTurnStarted": return `${e.player} takes an extra turn!`;
@@ -246,8 +251,15 @@ class Net {
/** Moves you haven't watched yet in the current room. */ /** Moves you haven't watched yet in the current room. */
missedMoves = $state(0); missedMoves = $state(0);
/** A catch-up reel delivered by the server. */ /** 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;
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null); catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
private seen: Record<string, number> = loadSeen(); private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
private watching: string | null = null;
private currentSeq = 0; private currentSeq = 0;
private lastYourTurn = new Map<string, boolean>(); private lastYourTurn = new Map<string, boolean>();
private pollTimer: ReturnType<typeof setInterval> | null = null; private pollTimer: ReturnType<typeof setInterval> | null = null;
@@ -261,6 +273,7 @@ class Net {
const ws = new WebSocket(SERVER_URL); const ws = new WebSocket(SERVER_URL);
this.ws = ws; this.ws = ws;
ws.onopen = () => { ws.onopen = () => {
this.watching = null; // the first state after (re)connecting may carry a gap
this.status = "connected"; this.status = "connected";
// Mid-game reconnects (the socket dropped, not the page) walk straight // Mid-game reconnects (the socket dropped, not the page) walk straight
// back to the table; otherwise the lobby ledger is the front door. // back to the table; otherwise the lobby ledger is the front door.
@@ -310,13 +323,19 @@ class Net {
this.view = msg.view; this.view = msg.view;
if (typeof msg.seq === "number" && this.roomId) { if (typeof msg.seq === "number" && this.roomId) {
this.currentSeq = msg.seq; this.currentSeq = msg.seq;
const last = this.seen[this.roomId] ?? 0; // Only the FIRST state after arriving carries a gap worth
this.missedMoves = Math.max(0, msg.seq - last); // announcing. Later states were watched live: a caught-up
// Watching live counts as seeing; only a fresh arrival has a gap. // watcher stays caught up, and an announced gap stays FROZEN
if (this.missedMoves === 0 || document.visibilityState === "visible") { // (not grown, not wiped) until watched or skipped. A hidden
// A live update while present marks itself seen. // tab accumulates its gap honestly.
if (last >= msg.seq - 1) this.markSeen(); if (this.watching === this.roomId && document.visibilityState === "visible") {
if (this.missedMoves === 0) this.markSeen();
} else {
const last = this.seen[this.roomId] ?? 0;
this.missedMoves = Math.max(0, msg.seq - last);
if (this.missedMoves === 0) this.markSeen();
} }
if (document.visibilityState === "visible") this.watching = this.roomId;
} }
break; break;
} }
@@ -325,8 +344,12 @@ class Net {
break; break;
case "events": { case "events": {
let talk = 0; let talk = 0;
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
for (const e of msg.events as GameEvent[]) { for (const e of msg.events as GameEvent[]) {
if (e.type === "tableTalk") talk++; if (e.type === "tableTalk") talk++;
if (e.type === "gameStarted" && !msg.replayed) {
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
}
const line = humanize(e); const line = humanize(e);
if (line) this.log = [...this.log, line]; if (line) this.log = [...this.log, line];
} }
@@ -427,7 +450,6 @@ class Net {
this.send({ type: "claimTransfer", code }); this.send({ type: "claimTransfer", code });
} }
/** Ask the server how all our games are doing. */
addBot(style?: string, tier?: string): void { addBot(style?: string, tier?: string): void {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) }); this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
} }
@@ -466,6 +488,7 @@ class Net {
this.send({ type: "hotseatReport", ...report }); this.send({ type: "hotseatReport", ...report });
} }
/** Ask the server how all our games are doing. */
refreshGames(): void { refreshGames(): void {
if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) }); if (this.seats.length > 0) this.send({ type: "myGames", seats: $state.snapshot(this.seats) });
} }
@@ -521,12 +544,12 @@ class Net {
this.send({ type: "pickColor", color }); this.send({ type: "pickColor", color });
} }
/** Ask for the reel of everything since we last watched. */
/** The whole game from the deal, once it is finished. */ /** The whole game from the deal, once it is finished. */
requestFullReplay(): void { requestFullReplay(): void {
this.send({ type: "catchUp", sinceSeq: 0, full: true }); this.send({ type: "catchUp", sinceSeq: 0, full: true });
} }
/** Ask for the reel of everything since we last watched. */
requestCatchUp(): void { requestCatchUp(): void {
if (!this.roomId) return; if (!this.roomId) return;
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 }); this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });