Author SHA1 Message Date
Eric WagonerandClaude Fable 5 d334b267a2 The hand may stand at the board's left (preference)
A player asked for cards down the side instead of along the bottom.
With the new pref on (wide screens only), the hand claims a sticky,
scrolling column at the left while the hint strips and action buttons
stay beneath the board — the table-edge dissolves into the grid so its
children place themselves. The strip moves inside the .game grid to
make that possible; the default bottom layout spans the full row as
before, and phones keep their one-column flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 23:28:00 -04:00
Eric WagonerandClaude Fable 5 b538a0a2ae Reversed, Walking Dead heals half a point per step (rules rev 11)
The same FAQ turn as Slow Death's: a REVERSE riding the cast attaches
the curse backward, and every second space walked gains the point the
bite would have taken. Older ledgers attached it biting and replay so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 23:01:01 -04:00
Eric WagonerandClaude Fable 5 4430a0def1 The board gets a zoom dial, and its size is never left to chance
Players wanted to magnify crowded squares; the board now lives in a
scrolling viewport with +/− controls (1x fit to 4x), zoom centered on
the view. The svg's width is always set explicitly — fit × zoom,
remeasured by a ResizeObserver — which also cures the stale small
board left behind when the first-person pane closed: auto-sizing
against shifting CSS constraints is gone entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 22:57:55 -04:00
Eric WagonerandClaude Fable 5 7f82aa47e0 Auto-grab asks before pocketing your own treasure
Walking off a square holding your own chest is the capture clause's
favorite setup, so the end-turn caution now names it before the
auto-grab preference takes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 22:36:04 -04:00
Eric WagonerandClaude Fable 5 7ee3c01e44 Idiot lifts the moment its march turns impossible
The all-carried check ran only on the victim's own step, so a curse
whose last floor treasure was hauled off mid-turn kept its grip until
the idiot happened to move. The lift check now also runs at the pickup
that empties the floor, and at attach for a victim already standing on
their chest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 22:35:00 -04:00
Eric WagonerandClaude Fable 5 dd6ba59220 Reflected permanent curses return to their caster (rules rev 11)
SLOW DEATH, WALKING DEAD, and IDIOT ride no damage or duration pipe, so
a FULL REFLECTION used to evaporate them; per the FAQ reflections act on
the cast, so the returned curse now opens its stack against the caster
like any reflected spell. Marked permanentCurse on the three defs; older
ledgers keep the evaporation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 22:23:53 -04:00
Eric WagonerandClaude Fable 5 e885325098 Reversed, Slow Death heals a point per draw (rules rev 11)
Per the FAQ: a REVERSE riding the cast turns the whole curse — the
victim gains a point for every card drawn, as permanently as the bite.
The stack's reversed verdict now reaches onResolved, and the sustained
effect carries it in its data scratch. Older ledgers attached the
biting curse regardless and replay unchanged (pinned at deckRev 10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 22:20:29 -04:00
Eric WagonerandClaude Fable 5 e930d013f8 The invite card knows a finished tale from a live one
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 21:58:25 -04:00
Eric WagonerandClaude Fable 5 691f44d858 Room invite links: /join/<CODE> recruits with a card of its own
A living room's link unfurls as an invitation (waiting rooms beckon a
seat, started games a gallery view) via the shared ogPage baker. The
client follows the path: a held seat resumes, a waiting room offers
join-or-watch from the lobby, a started game is watched at once. The
address bar carries the invite link at any table, with a copy button
beside the roster.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 21:57:38 -04:00
Eric WagonerandClaude Fable 5 e93bcc15a2 Credibility pass: session residue swept from the rev-10 run
War-story comments become timeless constraints (carrier rule, boobytrap
tell, remove-curse prescription); the rev changelog reunites under
GameConfig.deckRev; three hand-copied cardless stack literals fold into
openCardlessStack; the visionstone one-edge loop is shared via
throughOneEdge with the edge-target hairpin exclusion stated; drain()
moves to test helpers and replaces four inline copies; the butt-head
sweep test loses its dead scaffolding and its expect(refused || true)
tautology and now asserts both branches; a process-named suite is
renamed for the behavior it pins; dead .bezel-spacer CSS and the fx
effect's fossilized cleanup story go. No behavior changes: 304 engine
tests pass, 36/36 ledgers replay, web typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 19:14:03 -04:00
Eric WagonerandClaude Fable 5 f2af1e26f8 Duel skill: game-fourteen scars (soft terrain, grab-time interception, warp-landing traps)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 18:59:30 -04:00
Eric WagonerandClaude Fable 5 100f15651d OPSEC's final form: metaphors count
Game fourteen, third leak of the series: "a door my pick answers"
revealed a pick-lock without naming it. Table prose may reference
only public state — the board, never my options, in any costume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 18:50:35 -04:00
Eric WagonerandClaude Fable 5 eaa672236b The boobytrap keeps its secret
The public boobytrapPlaced event and every subsequent view shipped
the four token cells in casting order — with the real trap always
first, a tell readable by any client. Tokens are now stored and
broadcast in canonical cell order while the truth lives only in
realKey (and the caster's private event). Deterministic sort, no rng
touched, replays unaffected; a test casts in reverse order and checks
that position whispers nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 00:21:02 -04:00
Eric WagonerandClaude Fable 5 8ba0a934cc The goat charges on legs, not wings (rules rev 10)
Eric's table ruling: BUTT-HEAD's ram IS movement — the charge walks
real corridors on the turn's legal legs (three plus numbers played)
and spends them, with the walked distance as the damage. The engine
allowed an unlimited-range homing goat, and game thirteen ended on an
eight-square ram its own caster ruled illegal. At rev 10 the cast
refuses beyond-reach targets up front, a victim who slips away leaves
the goat winded ("outran"), and the legacy test pins the old behavior
at rev 9 for the ledgers that recorded it. The skill also gains the
passivity audit: a turn that only draws is a lost turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 00:17:32 -04:00
Eric WagonerandClaude Fable 5 cadba96b43 The ward tests wave the rev-9 window through
The bite rides the stack now, so the three tests that expected an
instant sting drain the counteraction window before asserting the
wound. Green across the suite; the earlier commit shipped with these
red, which the pipeline should never have allowed — noted in the
operator's own scar tissue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 00:12:05 -04:00
Eric WagonerandClaude Fable 5 2a8539e59a Rev 9 ships with game thirteen's scars
The WARD's bite now rides the stack per its card text, cardless traps
carry their own damage through resolution, and the duel skill records
the goat: BUTT-HEAD's charge is cross-map physical that no spell-
counter answers, custody of one's own chest is a dial not a state, and
junction terrain outlives every spell cast near it. Ledgers verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-29 00:10:13 -04:00
Eric WagonerandClaude Fable 5 019deedd85 The imp's fire is a spell; the soulstone floor holds (rules rev 8)
Eric flagged both mid-game, and the second one killed me while he
typed: the scorch carried no damage kind, slipping past SOULSTONE
entirely, and the stone's own clamp guarded only from above — a bearer
already at 3 or less had no protection at all, the exact opposite of
"last three points cannot be taken by spell attack." At rev 8 the
floor holds from below (spell damage at or under the floor is simply
immune), and per the FAQ the scorch is a SPELL: it opens a
counteraction window, burns as magical fire, and FULL SHIELD parts it.
One victim's window at a time; the sweep finds the rest next pass.
Older games — including tonight's, where the flaw was fatal — replay
their recorded flames unchanged; all 34 ledgers verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 23:36:48 -04:00
Eric WagonerandClaude Fable 5 5dde7b4c21 Salience is Euclidean even when geometry is not
Eric's final layer on the torus lesson: humans see center tiles and
aim there, so the center-perceived seat pays an attention tax — and a
strong player arbitrages the gap between how the board looks and how
it routes. The bots, running true-graph BFS, have neither the bias
nor the ability to exploit it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 13:30:17 -04:00
Eric WagonerandClaude Fable 5 b32db9878d Relocate sector is a topology weapon
Eric's framing after the six-player postmortem: on wrap boards one
sector shift breaks every curved warp at once — the card's payload is
the whole table's stale mental maps. The skill now says to hold it for
the moment an enemy plan leans on a wrap, and never to build a plan
whose only road is a warp while an opponent might hold it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 13:09:45 -04:00
Eric WagonerandClaude Fable 5 b7c8428ed5 The duel skill learns the board is a torus
Eric, after game eleven: the warping edges mean there is no center
column — sew the right column onto the left and the geography is
identical. Seat-quality and distance judgments made in coordinates
are rendering artifacts; only the edge-and-warp graph is real. The
router always knew; the narrator now does too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 13:06:03 -04:00
Eric WagonerandClaude Fable 5 7585a5e88d Two idle-bot loops cured; visionstone reaches the walls
Game eleven's silent turns, diagnosed from the ledger. Automaton III
proposed DESTROY WALL at a wall its visionstone could see — but the
engine's edge-target casts never honored the stone, despite the
declared policy that it is the bearer's sight wherever sight is asked.
castSightEdge now dissolves one wall for the look, and the three raw
losToEdge gates (create-wall, destroy-wall, attacks on walls) route
through it, gaining visionstone and bent-corner sight alike. Automaton
II cast REMOVE CURSE with no patient and no ailment named; the rung
now writes the full prescription. Refusal-fallback loops read as idle
bots — both replays march again. Ledgers verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 12:58:57 -04:00
Eric WagonerandClaude Fable 5 b99c22f14a Disease makes its caster the carrier (rules rev 7)
The card says it plainly — "You're the carrier! Disease caused by
spell does not affect you, only others" — but the engine cast it AT an
adjacent victim, making them the plague rat. At rev 7 it is a
self-cast on fear's pattern: no target, no counteraction window
("REFLECTIONs have no effect against this"), duration equals the
number, and sharing a square bites in both directions — the carrier
walking in, or anyone walking onto the carrier. Older ledgers hold
targeted disease casts with full stack exchanges, so the legacy attack
path survives for rev ≤6 games and all 33 ledgers replay clean. The
client offers the plain Cast button for it in rev-7 games.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 12:35:35 -04:00
Eric WagonerandClaude Fable 5 db3b14fd51 The duel skill records the carrier-breaker combo
Game ten went the distance: the champion unpacked my winning carry
with drop-object + adrenaline + teleport-exile, ferried his own
clause-vulnerable chest beside him rather than leave it stealable,
and banked at ten life through a point-blank fireball. Scars: verify
a card counters before trusting it (NO SPELL is a curse, not a
counter), carry through his reach only with real counteractions, and
re-route everything after a sector relocation rewrites the world.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 12:00:19 -04:00
Eric WagonerandClaude Fable 5 f90fdfa42e The duel skill learns to rob one victim twice
Game nine, four rounds: Kestrel stole both of one bot's treasures —
two bankings for the win, the second doubling as the victim's
elimination. Concentrated theft beats spread theft: same points, one
fewer opponent. Also recorded: never chain move commands blind after
the (6,6) nook ate a full turn of refused exits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 11:23:10 -04:00
Eric WagonerandClaude Fable 5 b59e11a4e4 The duel skill gains a turn poller and game-eight's swap gambit
Game eight: my SWAP HOME BASES mid-delivery eliminated Kestrel for
the first time in the series (instant bank + cross-board exile + the
captured-treasures clause), then the automaton shot me off its vault
steps at one life and won its second straight. The skill records the
poller workflow — a background watcher that exits when the game waits
on Claude, launched ONLY via run_in_background so the harness
actually wakes on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 10:42:29 -04:00
Eric WagonerandClaude Fable 5 ca9b09c029 Powerstone blesses a number already played this turn
On the table the stone and the number land together; digitally the
display can come a beat late and the +1 silently vanished. Displaying
POWERSTONE now grants the stride retroactively when a number was
already played for movement this turn, with a chronicle line so the
gain is visible. Pure widening — no rev needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-28 10:22:59 -04:00
Eric WagonerandClaude Fable 5 ca0a4ef2e3 Forced drops bank honestly; half-dead sockets confess
DROP OBJECT's forced drop hardcoded onHomeOf null and skipped the
victory check — a treasure shaken loose over a home square neither
reported the banking nor triggered scoring or the captured-treasures
clause until something else prodded the engine. It now lands like any
drop: homeOwnerAt reported, checkVictory immediate.

And the stuck-game mystery from 38YP: a deploy restart left the
browser holding a half-dead socket that never fired onclose, so the
existing reconnect loop and masthead banner never engaged and a live
game wore a frozen face. The client now pings every 15s and force-
closes a socket silent for 45s, which wakes the reconnect machinery;
the server answers pong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 23:45:26 -04:00
Eric WagonerandClaude Fable 5 5eb5312637 The attached number rides every cast path too
A number attached to SHIFT WAND silently stayed in hand: the two-stage
caster paths (shift-wand, teleport-opponent, mental-force) went through
withMods, which carried the modifier cards but not the separately-held
attached number. withMods now folds the number in wherever the call
site did not already carry it, so what the hand shows attached is what
the command carries — all of it, on every path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 23:22:56 -04:00
Eric WagonerandClaude Fable 5 5f1eda2731 Boobytrap decoys die their own deaths
Stepping on a face-down token flips it: a blank leaves the board the
moment it is discovered, instead of all four indicators lingering
until the real one detonates. The trap itself still waits, the caster
still strolls over everything unharmed, and the chronicle narrates
the flip so the table learns which squares stopped being scary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 22:52:09 -04:00
Eric WagonerandClaude Fable 5 e388691858 The tear waits for counteractions (rules rev 6)
STRENGTH's card says the grab "would be an attack," but the engine
resolved it instantly while punches opened counteraction windows — the
defender never got their say. At rev 6 the tear rides the stack like
any physical attack: the clutch roll waits out the counters, and only
an unstopped grab still in arm's reach of a still-laden defender gets
its wrestle. A TELEPORT escape now genuinely saves the chest (FULL
SHIELD does not — the card stops spell attacks only, and the grab is
physical). The automatons counter tears at their carried gold by not
being there when the hand closes. Narrowing, so rev-gated: games
pinned at rev 5 keep their instant tears and replay unchanged — all 29
ledgers verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 22:40:12 -04:00
Eric WagonerandClaude Fable 5 cbd4d80dea The clockwork learns three lessons from losing to Kestrel
Game six's table play, distilled into rungs. LOCK IN PLACE now roots
an enemy carrier closing on their home — the tempo theft that opened
game five, turned against the table. A pressed carrier of ANY
temperament casts MIST-BODY with its biggest number: an untouchable
delivery beats fear's radius, as the winning heist demonstrated. And
the pathfinder learns the thornbush is a jail, not a wade — entering
ends the turn, eats the next, and stings — so only a wizard with no
road at all dives in ("few will dive into one unless they are
desperate"). Also acquits Automaton II of game-six dithering: replay
showed its loop-de-loop was the true 13-step shortest path through a
walled quadrant.

Brains ship ungated; all 28 ledgers replay clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 21:53:47 -04:00
Eric WagonerandClaude Fable 5 3c70b9aedc The duel skill learns the thornbush is a jail
Game six went to Kestrel 6-0 by heist: a five-turn mist-body robbed
the bank I had just consolidated while I sat in his thornbush having
read only the flattering half of the card. Scars recorded: thornbush
ends your turn and eats the next, mist is immune to attack-typed
counterplay so plan for where it lapses, forks that only threaten
your own assets are published to-do lists, and spoken analysis is a
shown card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 21:44:05 -04:00
Eric WagonerandClaude Fable 5 37a390ebbf A laden clockwork delivers before it avenges
Game five's automaton stood four steps from a winning drop and instead
warped across the board to chase the bot carrying its other treasure —
two compounding brain faults. First, the thief-chase rung outranked
delivery even while carrying; a carrier now never chases. Second, the
pathing ladder fell back to "march on any enemy with a clean corridor"
before trying the true objective through hazards, so one thornbush on
the road to the thief rerouted the whole campaign toward a bystander.
The objective now exhausts its hazard-waded route before any generic
enemy march. Replayed the FRP3 moment: the brain marches E,N,E straight
home instead of W through the warp. Brains ship ungated; ledgers
verified.

The duel skill gains game-five scars: the captured-treasures
elimination clock, FEAR as a self-cast aura, key-cutters without keys,
and Kestrel's two-players-per-card table play.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 20:35:40 -04:00
Eric WagonerandClaude Fable 5 825a73b154 The cockpit's spells fire: fx timers survive the state message
Two lightning blasts at an adjacent wizard drew nothing in first
person. The fx pipeline staged the bolt correctly — then died unfired:
the server sends `events` and `state` back to back, the state message
re-derives `me`, and the fx effect tracked it, so Svelte ran the
cleanup — which cleared every pending timer and stripped in-flight
sprites — before the bolt's zero-delay start could fire. Every live
cast effect in the pane died this way since the pane was born.

The effect now tracks only the batch; everything else reads under
untrack, and timers are cleared only when the component goes down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 19:15:55 -04:00
Eric WagonerandClaude Fable 5 773f0a7d21 Visionstone shots draw their ray and mark the pierced wall
A stone-bearing automaton fired Blind through a warp mouth and one
wall, and the table saw nothing but the attack modal — the sight trace
only knew plain and bent-corner lines, so a legal shot read as a cheat.
When the attacker displays VISIONSTONE and no plain line exists,
stackSightTrace now finds the single edge whose removal opens the line
(warp legs included), draws the ray through it, and the keymap pulses
the dissolved wall so the answer to "how can he even see me?" is on
the board. Verified against the live LZ5R blind moment: warp mouths
at (0,2)W and (4,7)E, pierced wall V:3,8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 18:52:46 -04:00
Eric WagonerandClaude Fable 5 358633022c Attachments ride every targeted cast, not just player targets
The engine learned to bend neutral spells, but the client only sent
aroundCornerInstanceId (and amplify/add/extend) on player-target casts
— applyMods was never called on the cell, edge, creature, two-cell,
trap, or teleport-opponent paths, so a SAFE aimed around the corner
went out bare and the engine rightly refused it. All targeted cast
dispatches now pass through withMods, so what the hand shows attached
is what the command carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 17:41:52 -04:00
Eric WagonerandClaude Fable 5 3bf3c33fdc Around the Corner bends every spell, hairpins included
Two holes, found when a SAFE cast at a treasure 'around the 180 degree
corner' refused with no line of sight (room LZ5R). First: the bend was
honored only in the attack path — every neutral resolve (creations,
summons, utilities, edge targets) checked straight sight and ignored
the attached card, though the card says 'any line of sight spell'.
castSight/castSightEdge now thread the bend through all of them, and
the chronicle narrates the neutral bend too.

Second: the bend itself pivoted only at cell centers, so the card's
flagship shot — doubling back up to 180 degrees around a wall's end —
never resolved: both legs graze the corner and die. The pivot may now
sit in the wall's open gap itself (bentSightThroughGap), which is what
rounding a corner physically is. Closed doors still block; two corners
still refuse.

The client lights bent-reachable squares for real now instead of the
old one-step-adjacency guess, creations included. Pure widening, so no
rev bump: every one of these casts was refused before, so no ledger
holds one. All 26 production ledgers replay clean; the LZ5R moment
itself replayed and the exact refused cast now lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 17:34:48 -04:00
Eric WagonerandClaude Fable 5 f53f26ace9 The cockpit's camera ignores your own casts entirely
A cast through the glass is aimed by the eyes that fired it — you
face it by definition — and a keymap cast is a deliberate off-screen
act. Neither moves the camera. Aims and cutaways serve only deeds
done by others.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 16:10:27 -04:00
Eric WagonerandClaude Fable 5 72f5df5bba Your own casts never cut away
Casting a door onto the wall you already faced tripped the blocked-
sight cutaway: the camera leapt to film the wall from another
vantage, reading as a spin that put your back to your own door. In
the live cockpit your own deeds now only ever TURN the head, gently
and never past a quarter-turn — cutaways remain for things done TO
you.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 16:09:57 -04:00
Eric WagonerandClaude Fable 5 e2dfbd14e3 Edge cards light their targets; own casts never wrench the neck
Raising an edge-target card now makes every wall and door face
breathe faint gold in the pane — the cockpit's answer to the board's
edge handles — with hover still brightening the chosen face. And the
director's own-magic aim gains a quarter-turn cap: a deed you aimed
behind you via the keymap stays behind you, while deeds done TO you
may still spin the world.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 16:08:51 -04:00
Eric WagonerandClaude Fable 5 a4a871592b First person play arrives: the pane is the game
The first-person-play branch merges. The 3D pane becomes a full
cockpit — click-to-act with hover answers, eligibility dimming in the
world, tappable and keyable movement in a bezel of instruments, the
at-your-feet and sharing-your-square trays, creature possession with
the facing ribbon riding the mount, warp-preserved headings, and the
keymap wearing the camera's compass. Ships opt-in via preferences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:58:00 -04:00
Eric WagonerandClaude Fable 5 4354a1860f Rules divide the checkbox stanza only
Between checkboxed rows and nowhere else — no frame above or below
the stanza, none around the control rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:53:17 -04:00
Eric WagonerandClaude Fable 5 4e93a77e8d The preferences read as a ruled ledger
Rows rule apart with the games ledger's own brown line, fill the
panel's width so the checkbox column aligns, and control rows
right-justify their controls opposite their labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:51:40 -04:00
Eric WagonerandClaude Fable 5 bdfe357429 Preferences wear the table's ink; hand-drawn becomes alternate
The native checkboxes clashed with the parchment: they are now
stamp-bordered boxes checked solid with a parchment tick, and their
labels rag left with the box instead of centering. The token-art
toggle's second option reads "alternate".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:47:27 -04:00
Eric WagonerandClaude Fable 5 e4a93ec3aa Forfeit legs earn no scolding (from the FPV playtest)
Picking up an object ends the turn's actions and movement, yet the
end-turn caution still tallied the unspendable points as a regret.
The unspent-movement reason now stays quiet once actions have ended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 13:56:11 -04:00
21 changed files with 1800 additions and 208 deletions
+200
View File
@@ -113,6 +113,173 @@ probing legality is free, so when unsure, try it and read the error.
- Grab-turn-one openings feel great and prove nothing: deliveries are - Grab-turn-one openings feel great and prove nothing: deliveries are
the only score. He banked quietly both times while I showboated. the only score. He banked quietly both times while I showboated.
## Game-five scars (eliminated by a rule I implemented myself)
- **THE ELIMINATION CLOCK**: the moment BOTH treasures you protect sit
on living opponents' home bases, you are OUT — points, life, and
position notwithstanding. In multiplayer this clock starts ticking
the instant your second chest is picked up by anyone. From then on,
every turn must either recover a chest or end the game first. I spent
three turns optimizing deliveries while two bots quietly carried my
death certificate.
- **FEAR IS A SELF-CAST AURA** — read the card, not your assumptions:
"no player or monster will move to within 3 spaces of you," walls
irrelevant, and anyone already within 3 MUST flee on their turn. It
is armor for carriers and a hearth-denial tool: a feared wizard
standing 2 from your home makes delivery impossible. I invented an
elaborate theory about enemy goalkeeping instead of reading my own
engine's card text. Check `data/cards.json` when a rule surprises —
it is one grep away and it is verbatim.
- In four-player, Kestrel plays the TABLE, not the duel: his SWAP
yanked a bot off its own doorstep mid-delivery and parked himself by
unclaimed treasures — one card, two tempo hits. Expect every card he
plays to hurt two players at once, and ask which two before deciding
it wasn't aimed at you.
- CREATE WALL can sever your announced return route the turn after you
telegraph it. If your road home is unique, either don't stand at the
far end of it, or carry the picks/doors to re-open it. And CREATE
DOOR makes LOCKED doors — it is a key-cutter, worthless without picks.
- Bot threat model: archmage automatons DO deliver, but their brains
can dither inexplicably (one toured the map for two rounds carrying
my elimination). Never count on bot incompetence — count turns to
their delivery square as if they play perfectly, and treat every
reprieve as a gift, not a pattern.
## Game-six scars (the heist)
- **THORNBUSH IS A JAIL, NOT A FORT**: entering one ends your turn,
costs your ENTIRE next turn, and deals 1 damage. The "cannot be
attacked" line is the bait; the two-turn tempo loss is the hook.
Eric: "Few will dive into one unless they are desperate. You were
confident, but not desperate." Enter a bush only when the
alternative is death — and READ THE WHOLE CARD before calling
anything a shelter. Three rules self-owns in two games (fear,
thornbush) — the full text is one grep away, in my own repo.
- **MIST-BODY IS A BANK ROBBER**: 5 turns untouchable, walks through
doors and walls, picks up treasures, immune to every attack-typed
card (drop-object, card-erasure, teleport-opponent included). While
an enemy is misted, do not plan counterplay — plan for WHERE the
mist will be when it lapses, and meanwhile DISPERSE value: banking
everything on one home square builds the heist target. A creature
guard is scenery to a ghost.
- The bait-fork fallacy: I "forked" him between my two treasures, but
both forks were MY assets. He ignored both and took the third thing
— the treasure I'd just consolidated. When your fork only threatens
what you own, it isn't a fork; it's a to-do list you published.
## Game-nine scar: concentrate your thefts
- Kestrel won in FOUR ROUNDS by stealing BOTH treasures from the SAME
victim (the nearest bot): two bankings = the win, and the second one
simultaneously eliminated the victim via the captured-treasures
clause. Robbing one player twice beats robbing two players once —
same points, one fewer opponent, and the victim's own corner becomes
your supply depot. When choosing the second steal, weigh "same
victim" heavily.
- Also: never chain move commands blind in a script — the (6,6) nook
refused all three exits and my pipeline barreled into endTurn,
burning a full turn standing still. Verify each step's result, or at
minimum stop the chain on the first refusal.
## Game-ten scars (the closest one)
- **The champion's carrier-breaker combo**: DROP OBJECT (chest falls at
your feet) + ADRENALINE (second attack) + TELEPORT OPPONENT (you,
exiled across the maze; the chest, his). A laden wizard within his
cast range is a wizard about to be unpacked. Carry through his reach
only with a real counteraction in hand — and VERIFY a card actually
counters before building your defense on it (NO SPELL is a silencing
curse, not a counter; I learned mid-stack).
- **He ferries his own death warrant**: when the elimination clause
hung on his floor chest, he simply picked his route so the chest
traveled with him. Deny-by-custody. Do the same: when MY vulnerable
treasure lies on my path, take it along.
- Sector relocation can REWRITE the whole world mid-game — arms move,
warps rewire, routes invert. After any rotate/relocate event, throw
away every cached route and re-run the router.
- Eric's deeper frame: on wrap boards (especially 3- and 5-sector
layouts) RELOCATE SECTOR is a TOPOLOGY WEAPON — one shift breaks
ALL the curved warps at once and "everything is just across the
board." Its payload is everyone else's stale mental map: the caster
knows the new sewing instantly. Hold it for the moment an enemy's
plan leans on a wrap (a warp delivery lane, a rim escape), then cut
the seam. Conversely, never build a plan whose ONLY road is a warp
while an opponent might hold this card.
## The board is a torus (from Eric, after game eleven)
- The warping rims mean there is NO center and NO edge: take the right
column and sew it onto the left — identical geography. "Central" and
"peripheral" seats are rendering artifacts of where the map unrolls.
Thinking in those terms is a trap that limits your options (his
words). Judge NOTHING by coordinates: distance is graph distance,
threat is graph threat, and the router (BFS over edges + warps) is
the only geometry that exists. Mentally re-unroll the map from
wherever you stand — every wizard is the center of their own map.
- BUT salience is Euclidean even when geometry is not (Eric): humans
see "center tiles" and aim their aggression there, so the
center-perceived seat pays an attention tax — and a strong player
turns it into arbitrage: absorb the squabble, footrace out and back
through wraps that make you secretly equidistant from everything.
Play the graph for movement, the salience for predicting humans;
expect neither bias nor exploitability from the bots — their BFS is
natively non-Euclidean.
## Game-thirteen scars (the goat)
- **BUTT-HEAD is a cross-map physical nuke**: damage = the full walked
distance of the charge, and it found 8 points across half a maze.
ABSORB SPELL answers spells ONLY — at low life with no teleport in
hand, there is NO answer to big physical; your only defenses are
position and never being worth the charge. Audit the hand for
physical answers separately from spell answers.
- He parks his OWN chest on open floor when he needs free arms —
custody is a dial, not a state. Watch every drop: grounded chests
are targets again the moment they land.
- My game-one wall and his one dust cloud shaped the ENTIRE game:
single-square terrain on a junction outlives every spell cast near
it. Place terrain at junctions, not corridors.
## Game-fourteen scars (the five-player raid race)
- **Terrain is SOFT in tempo math.** I counted a thornbush and an ooze
square as two turns of delay on his banking route; DISPEL CREATION
removed the bush for one card and zero steps, and he banked that
same turn. Any creation (bush, ooze, wall of stone, stones) is one
card from gone — count it as HALF a turn of delay, never a wall.
- On wide boards (5-6p cross), a raider in my quadrant carrying my
chest is usually UNREACHABLE by anything in hand — the interception
had to happen a turn earlier, at the grab, not the haul. Defend the
chest square, not the exit route.
- My real boobytrap finally fired (4 damage to a warping bot) — traps
on WARP LANDING squares hit players who never see the token coming.
That placement doctrine is proven; repeat it.
## The passivity audit (Eric, after game thirteen)
- "You did nothing but draw for several turns" — true, and fatal. A
turn that only draws is a LOST turn. When genuinely walled: probe.
Dust-stagger attempts, LOS probes, wall punches, terrain casts —
motion generates dice, information, and pressure; stillness
generates nothing and hands the opponent free repositioning. The
round-8 expedition that nearly won game thirteen was available in
round 5. If two consecutive turns end in draw-only, something is
wrong with the plan, not the maze.
## The board is the truth (I keep paying for this one)
- NEVER narrate a persistent effect without re-verifying it in the
CURRENT view. My stones get dispelled, my walls get melted, my
theories get invented. Before crediting any creation, curse, or
blockade in reasoning OR table talk, check the CONTENTS and
SUSTAINED lines and the map glyphs THIS turn. Kestrel quietly
removes my infrastructure and lets my own commentary reveal that I
haven't noticed.
- Corollary: every "go" starts with reading the events tail for what
the OTHERS did to the board, not just where they stand. A dispel,
a stone-to-water, or a rotate-sector in that tail invalidates last
turn's map in my head.
## Playing well ## Playing well
- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for - Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for
@@ -128,8 +295,41 @@ probing legality is free, so when unsure, try it and read the error.
draw, and never announce plans. (Game one: I narrated my hand like a draw, and never announce plans. (Game one: I narrated my hand like a
debug log until he asked why I was making it easy.) Trash talk freely debug log until he asked why I was making it easy.) Trash talk freely
— about the board, never your hand. — about the board, never your hand.
- OPSEC round two (game six, violated AGAIN): analysis spoken aloud IS a
card shown. Quoting a card's text with delight ("moves a piece to ANY
square!") told him exactly what I held and what it does. Card-text
lookups for cards in MY hand happen silently; my visible messages may
discuss only what is already public on the board or in the chronicle.
Even asking rules questions aloud about a specific card is a tell.
- OPSEC round three (game fourteen): METAPHORS COUNT. "A door my pick
answers" and "key ready for tomorrow" revealed a pick-lock as surely
as naming it. The rule in its final form: table prose may reference
ONLY public state — positions, banked counts, events already in the
chronicle. Nothing about my hand, capabilities, or intentions, in
any costume. When in doubt, describe the BOARD, never my options.
- Send `chat` for table talk at dramatic moments; it's half the fun. - Send `chat` for table talk at dramatic moments; it's half the fun.
## The turn poller (no more idling)
After ending each turn (or whenever waiting on others), launch the
watcher in the background so the harness wakes you the moment the
game needs you — your turn or a counteraction window:
```
node <scratchpad>/my-turn-poller.mjs # run_in_background: true
```
(Script lives in the session scratchpad; recreate from the skill's
memory if missing: it joins with the saved seat token, watches state
messages, and exits with "WAITING ON CLAUDE: <reason>".) Eric asked
for this after game eight sat waiting on me — don't make him prompt
"your turn" ever again.
CRITICAL: launch it ONLY with the harness's run_in_background flag —
that is what wakes you when it exits. A shell `&` inside a normal
command detaches it into the void and nobody hears it fire (game
eight, twice). One poller per wait; it exits when the game needs you.
## Cadence ## Cadence
1. On "go": `view`, read HIS events since your last turn (the client 1. On "go": `view`, read HIS events since your last turn (the client
+67 -8
View File
@@ -586,7 +586,7 @@ function pathToward(
view: GameView, view: GameView,
from: Cell, from: Cell,
goals: Set<string>, goals: Set<string>,
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {}, opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean; throughJails?: boolean } = {},
): PathResult | null { ): PathResult | null {
if (goals.size === 0 || goals.has(cellKey(from))) return null; if (goals.size === 0 || goals.has(cellKey(from))) return null;
const enemyCells = livingEnemies(view).map((p) => p.position); const enemyCells = livingEnemies(view).map((p) => p.position);
@@ -611,9 +611,14 @@ function pathToward(
cameBy.set(k, { prev: cellKey(c), dir, viaDoor }); cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
if (goals.has(k)) { found = k; break; } if (goals.has(k)) { found = k; break; }
// Hazards end a clean path but are waded through when asked to. // Hazards end a clean path but are waded through when asked to.
// THORNBUSH is never a wade: entering ends the turn, eats the NEXT
// turn, and stings — a jail with foliage. Only a wizard with no
// road at all (throughJails) dives in. "Few will dive into one
// unless they are desperate."
const hazard = view.squareContents[k]?.kind; const hazard = view.squareContents[k]?.kind;
if (hazard === "thornbush" && !opts.throughJails) continue;
if (!opts.throughHazards && if (!opts.throughHazards &&
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || (hazard === "pit" || hazard === "ooze" ||
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue; hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
if (opts.avoidNearEnemies && nearEnemy(to)) continue; if (opts.avoidNearEnemies && nearEnemy(to)) continue;
next.push(to); next.push(to);
@@ -770,6 +775,16 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
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;
// A tear at the treasure in my arms outranks its zero damage: the stake
// is the chest, not the bruise. The grab is PHYSICAL — shields part
// around it; the only real answer is to not be there when it closes.
if (stack.tearTreasure && me(view).carriedTreasureId != null) {
const tp = find("teleport");
const out = tp ? escapeCell(view, me(view).position) : null;
if (tp && out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
return { type: "pass" };
}
if (attackId && UTILITY_ATTACKS.has(attackId)) { if (attackId && UTILITY_ATTACKS.has(attackId)) {
const stopper = find("full-shield") ?? find("force-field"); const stopper = find("full-shield") ?? find("force-field");
const stakes = const stakes =
@@ -1055,12 +1070,19 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
} }
} }
if (!tier.buffs) return null; // the apprentice's book ends at the stones if (!tier.buffs) return null; // the apprentice's book ends at the stones
// A curse on the clockwork gets scrubbed off. // A curse on the clockwork gets scrubbed off. The engine wants the
const cursed = view.sustained.some( // whole prescription — patient and ailment both — or it refuses the cast.
const myCurse = view.sustained.find(
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null, (e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
); );
const cleanse = inHand(view, "remove-curse"); const cleanse = inHand(view, "remove-curse");
if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId }; if (myCurse && cleanse) {
return {
type: "cast", instanceId: cleanse.instanceId,
target: { kind: "player", playerId: view.you },
params: { cardId: myCurse.cardId },
};
}
// Three or more wizards: LIFESAVER takes elimination off the table. // Three or more wizards: LIFESAVER takes elimination off the table.
const lifesaver = inHand(view, "lifesaver"); const lifesaver = inHand(view, "lifesaver");
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId }; if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
@@ -1100,6 +1122,19 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
// wizard hauling gold, anyone bleeding out. // wizard hauling gold, anyone bleeding out.
const pressed = enemyNear && const pressed = enemyNear &&
(style === "worrier" || self.carriedTreasureId != null || self.life <= 5); (style === "worrier" || self.carriedTreasureId != null || self.life <= 5);
// MIST-BODY turns a pressed carrier into an untouchable delivery — no
// attack, drop, exile, or thief finds fog. Any temperament takes this
// armor, and the biggest number rides it so the whole haul fits inside.
if (pressed && self.carriedTreasureId) {
const fog = inHand(view, "mist-body");
if (fog && !view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === view.you)) {
const biggest = numbersInHand(view).at(-1);
return {
type: "cast", instanceId: fog.instanceId,
...(biggest ? { numberInstanceIds: [biggest.instanceId] } : {}),
};
}
}
if (pressed && style !== "berserker") { if (pressed && style !== "berserker") {
// FEAR keeps every pursuer three spaces off. // FEAR keeps every pursuer three spaces off.
const scare = inHand(view, "fear"); const scare = inHand(view, "fear");
@@ -1367,6 +1402,22 @@ export function automatonCommand(
}; };
} }
} }
// LOCK IN PLACE roots a delivery mid-stride: a carrier closing on
// their home loses a whole turn of legs and warp-steps.
{
const root = inHand(view, "lock-in-place");
const runner = root ? visible.find((p) =>
p.carriedTreasureId != null &&
Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 7) : undefined;
if (root && runner) {
const smallest = numbersInHand(view)[0];
return {
type: "cast", instanceId: root.instanceId,
target: { kind: "player", playerId: runner.id },
...(smallest ? { numberInstanceIds: [smallest.instanceId] } : {}),
};
}
}
if (tier.buffs && !view.turn.attackUsed) { if (tier.buffs && !view.turn.attackUsed) {
const smallest = numbersInHand(view)[0]; const smallest = numbersInHand(view)[0];
// ADRENALINE: when no single blow finishes the target but two would. // ADRENALINE: when no single blow finishes the target but two would.
@@ -1541,7 +1592,9 @@ export function automatonCommand(
// ends the march); otherwise the gold has its legs for the turn. // ends the march); otherwise the gold has its legs for the turn.
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden && const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null); !view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
const chasing = thief !== null && strikeLive; // A carrier never chases: the delivery in hand outranks the gold in
// someone else's.
const chasing = thief !== null && strikeLive && !self.carriedTreasureId;
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and // BANK GUARD: enemy gold delivered to my home is my scoreboard, and
// anyone may snatch it off the floor. A raider nearer my bank than I // anyone may snatch it off the floor. A raider nearer my bank than I
// am, with the bank stocked, outranks the next grab — run home. // am, with the bank stocked, outranks the next grab — run home.
@@ -1565,11 +1618,17 @@ export function automatonCommand(
const path = const path =
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ?? pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ??
// The true objective, hazards waded, comes before any generic march
// on enemies: one thornbush on the road must not reroute the whole
// campaign toward whoever happens to have a clean corridor.
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true }) ??
pathToward(view, self.position, enemyCells, { canUnlock }) ?? pathToward(view, self.position, enemyCells, { canUnlock }) ??
// No clean road to anything: grit the teeth and wade the hazards, // No clean road to anything: grit the teeth and wade the hazards,
// as any wizard would rather than stand in a pocket forever. // as any wizard would rather than stand in a pocket forever.
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true }) ?? pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true }) ??
pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true }); // And only when even the hazard roads are severed does desperation
// dive through a thornbush jail.
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true, throughJails: true });
// A shimmering illusion on the best crossing costs nothing to doubt: // A shimmering illusion on the best crossing costs nothing to doubt:
// roll the free test before spending any card on that wall. // roll the free test before spending any card on that wall.
const doubted = bestWallCrossing(view, self, objectives, canUnlock); const doubted = bestWallCrossing(view, self, objectives, canUnlock);
+31
View File
@@ -480,3 +480,34 @@ export function sightBetween(
return hasLineOfSight(board, from, to, blockedCells) || return hasLineOfSight(board, from, to, blockedCells) ||
hasWarpLineOfSight(board, from, to, blockedCells); hasWarpLineOfSight(board, from, to, blockedCells);
} }
/**
* AROUND THE CORNER's full reach: "around one corner (up to 180 degrees)".
* A hairpin around the end of a wall pivots IN the wall's gap, not at any
* cell's center — so try the midpoint of every open edge as the bend point,
* with a clear straight segment from caster to pivot and pivot to target.
*/
export function bentSightThroughGap(
board: AssembledBoard,
from: Cell,
to: Cell,
blockedCells?: Record<string, true>,
): boolean {
const fx = from.x + 0.5, fy = from.y + 0.5;
const tx = to.x + 0.5, ty = to.y + 0.5;
const skip = [cellKey(from), cellKey(to)];
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const c = { x, y };
for (const side of ["E", "S"] as Side[]) {
if (!board.cells[cellKey(neighbor(c, side))]) continue;
if (edgeState(board, c, side) !== "open") continue;
const [px, py] = side === "E" ? [x + 1, y + 0.5] : [x + 0.5, y + 1];
if (segmentClear(board, fx, fy, px, py, blockedCells, skip) &&
segmentClear(board, px, py, tx, ty, blockedCells, skip)) {
return true;
}
}
}
return false;
}
+379 -91
View File
@@ -24,6 +24,7 @@ import {
type Warp, type Warp,
hasLineOfSight, hasLineOfSight,
sightBetween, sightBetween,
bentSightThroughGap,
neighbor, neighbor,
stepTarget, stepTarget,
opposite, opposite,
@@ -211,6 +212,9 @@ export interface CastStack {
creatureTouch?: "wraith" | "claw"; creatureTouch?: "wraith" | "claw";
/** A spell freed from slime: counteractions cannot touch its caster. */ /** A spell freed from slime: counteractions cannot touch its caster. */
trapped?: boolean; trapped?: boolean;
/** STRENGTH's tear: "this would be an attack" the grab rides the stack
* like a punch, and the clutch roll waits for the counteractions. */
tearTreasure?: true;
} }
export interface CastParams { export interface CastParams {
@@ -229,7 +233,7 @@ export interface CastParams {
} }
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 5; export const CURRENT_RULES_REV = 11;
export interface GameConfig { export interface GameConfig {
playerIds: PlayerId[]; playerIds: PlayerId[];
@@ -253,6 +257,24 @@ export interface GameConfig {
* it; unpassed, it relocks at turn's end as before. * it; unpassed, it relocks at turn's end as before.
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to * Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
* in three spaces, instead of eating the card silently at resolution. * in three spaces, instead of eating the card silently at resolution.
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this
* would be an attack") instead of resolving instantly.
* Rev 7: DISEASE is a self-cast plague ("You're the carrier!") the
* caster carries it, sharing a square bites both directions, no
* counteraction.
* Rev 8: the fire imp's scorch is a SPELL (FAQ) counteractable,
* magical and SOULSTONE's floor holds even at 3 life or below.
* Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ...
* otherwise work as written"), though nothing a counter does touches
* its caster.
* Rev 10: BUTT-HEAD's ram is MOVEMENT the charge walks real
* corridors on the turn's legal legs (three plus numbers) and spends
* them.
* Rev 11: a REVERSE against SLOW DEATH or WALKING DEAD turns the whole
* curse (FAQ) a point gained per card drawn, half a point per space
* walked, permanently and a FULL REFLECTION returns a permanent
* curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of
* letting it evaporate.
*/ */
deckRev?: number; deckRev?: number;
} }
@@ -388,21 +410,25 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
return edges ? { ...board, edges } : board; return edges ? { ...board, edges } : board;
} }
/** LOS including square-filling blockers. */
/** Sight granted by removing any ONE closed edge the VISIONSTONE's /** Sight granted by removing any ONE closed edge the VISIONSTONE's
* whole power, shared by every path that honors it. */ * whole power, shared by every path that honors it. `sees` judges the
function sightThroughOneEdge( * board with the candidate edge gone. */
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>, function throughOneEdge(board: AssembledBoard, sees: (b: AssembledBoard) => boolean): boolean {
): boolean {
for (const key of Object.keys(board.edges)) { for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue; if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges }; const edges = { ...board.edges };
delete edges[key]; delete edges[key];
if (sightBetween({ ...board, edges }, from, to, blockers)) return true; if (sees({ ...board, edges })) return true;
} }
return false; return false;
} }
function sightThroughOneEdge(
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
): boolean {
return throughOneEdge(board, (b) => sightBetween(b, from, to, blockers));
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state); const blockers = losBlockers(state);
@@ -483,8 +509,10 @@ function casterLos(
return sightThroughOneEdge(board, from, to, blockers); return sightThroughOneEdge(board, from, to, blockers);
} }
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */ /** Bent LOS for AROUND THE CORNER: the caster sees a middle cell which sees
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean { * the target or, for the full 180-degree hairpin, the sight line doubles
* back around a wall's end, pivoting in the open gap itself. */
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean {
if (casterLos(state, caster, from, to)) return true; if (casterLos(state, caster, from, to)) return true;
const view = boardView(state); const view = boardView(state);
for (const key of Object.keys(view.cells)) { for (const key of Object.keys(view.cells)) {
@@ -494,6 +522,49 @@ function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, ev
return true; return true;
} }
} }
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
return bentSightThroughGap(board, from, to, losBlockers(state));
}
/** Sight for a square-aimed cast. "You may cast any line of sight spell
* around one corner (up to 180 degrees)" the attachment bends every
* L.O.S. spell, creations and utilities included, not just attacks. */
function castSight(
state: GameState,
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
to: Cell,
): boolean {
if (gameLos(state, caster.position, to, caster.id)) return true;
return !!cmd.aroundCornerInstanceId && bentLos(state, caster, caster.position, to);
}
/** Sight for an edge-aimed cast (walls, doors), bend-aware like castSight.
* VISIONSTONE is the bearer's sight here too one wall or door, any one,
* falls away for the look, exactly as it does for square-aimed casts. */
function castSightEdge(
state: GameState,
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
board: AssembledBoard,
cell: Cell,
side: Side,
): boolean {
if (losToEdge(board, caster.position, cell, side)) return true;
if (displays(caster, "visionstone") &&
throughOneEdge(board, (b) => losToEdge(b, caster.position, cell, side))) {
return true;
}
if (!cmd.aroundCornerInstanceId) return false;
// The bend pivots in a seen middle SQUARE; the gap-pivot hairpin is
// square-to-square geometry, so an edge target gets no 180-degree bend.
for (const key of Object.keys(board.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if (casterLos(state, caster, caster.position, mid) && losToEdge(board, mid, cell, side)) {
return true;
}
}
return false; return false;
} }
@@ -544,11 +615,11 @@ export type GameEvent =
| { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string } | { type: "counteractionPlayed"; player: PlayerId; card: CardInstance; cardId: string; against: string }
| { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance } | { type: "counterNullified"; player: PlayerId; card: CardInstance; by: CardInstance }
| { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance } | { type: "attackAbsorbedIntoHand"; player: PlayerId; attackCard: CardInstance }
| { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" } | { type: "attackMissed"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; because: "invisible" | "shrink" | "outran" }
| { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean } | { type: "attackResolved"; attacker: PlayerId; defender: PlayerId; attackCardId: string | null; damageDealt: number; reflectedDamage: number; fullyStopped: boolean; redirected: boolean }
| { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number; | { type: "damaged"; player: PlayerId; amount: number; source: string; lifeAfter: number;
soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] } soaks?: { what: "bloodstone" | "soulstone"; amount: number }[] }
| { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" } | { type: "damageImmune"; player: PlayerId; source: string; because: "medusa" | "bloodstone" | "soulstone" }
| { type: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number } | { type: "lifeGained"; player: PlayerId; amount: number; source: string; lifeAfter: number }
| { type: "stunned"; player: PlayerId; turnsLost: number } | { type: "stunned"; player: PlayerId; turnsLost: number }
| { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number } | { type: "knockedBack"; player: PlayerId; from: Cell; to: Cell; squares: number }
@@ -619,6 +690,7 @@ export type GameEvent =
| { type: "stuckInSlime"; player: PlayerId; at: Cell } | { type: "stuckInSlime"; player: PlayerId; at: Cell }
| { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] } | { type: "boobytrapPlaced"; caster: PlayerId; cells: Cell[] }
| { type: "boobytrapSprung"; player: PlayerId; at: Cell } | { type: "boobytrapSprung"; player: PlayerId; at: Cell }
| { type: "boobytrapBlank"; player: PlayerId; at: Cell }
| { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell } | { type: "boobytrapPlacedPrivate"; visibleTo: PlayerId; realCell: Cell }
| { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number } | { type: "objectsGlued"; caster: PlayerId; at: Cell; turns: number }
| { type: "safeCreated"; caster: PlayerId; at: Cell } | { type: "safeCreated"; caster: PlayerId; at: Cell }
@@ -670,6 +742,8 @@ export type GameEvent =
| { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell } | { type: "treasurePickedUp"; player: PlayerId; treasureId: string; owner: PlayerId; at: Cell }
| { type: "treasureTorn"; attacker: PlayerId; defender: PlayerId; treasureId: string; toFloor: boolean } | { type: "treasureTorn"; attacker: PlayerId; defender: PlayerId; treasureId: string; toFloor: boolean }
| { type: "tearResisted"; attacker: PlayerId; defender: PlayerId } | { type: "tearResisted"; attacker: PlayerId; defender: PlayerId }
| { type: "treasureTearAttempted"; attacker: PlayerId; defender: PlayerId }
| { type: "powerstoneBoost"; player: PlayerId; newAllowance: number }
| { type: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null } | { type: "treasureDropped"; player: PlayerId; treasureId: string; at: Cell; onHomeOf: PlayerId | null }
| { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" } | { type: "playerEliminated"; player: PlayerId; reason: "killed" | "treasuresLost" }
| { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] } | { type: "cardsDiscarded"; player: PlayerId; cards: CardInstance[] }
@@ -755,6 +829,10 @@ type AttackEffect = {
baseDamage: (numberValue: number | null, params: CastParams | null) => number; baseDamage: (numberValue: number | null, params: CastParams | null) => number;
/** A duration spell: attach a sustained effect on resolution. */ /** A duration spell: attach a sustained effect on resolution. */
sustains?: boolean; sustains?: boolean;
/** A permanent curse attached by onResolved (SLOW DEATH, WALKING DEAD,
* IDIOT): it rides no damage or duration pipe, so a FULL REFLECTION
* must return it explicitly or the curse would evaporate. */
permanentCurse?: true;
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */ /** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
keepInHand?: boolean; keepInHand?: boolean;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null; validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
@@ -795,6 +873,8 @@ interface ResolutionContext {
defender: PlayerState; defender: PlayerState;
damageDealt: number; damageDealt: number;
fullyStopped: boolean; fullyStopped: boolean;
/** A surviving REVERSE rode the stack: the spell's effect runs backward. */
reversed: boolean;
duration: number; duration: number;
stack: CastStack; stack: CastStack;
} }
@@ -1163,7 +1243,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
} }
const key = edgeKey(cell, side); const key = edgeKey(cell, side);
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line"; if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall line"; if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight to the wall line";
state.edgeOverrides[key] = "wall"; state.edgeOverrides[key] = "wall";
state.createdEdges[key] = true; state.createdEdges[key] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } }); events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
@@ -1187,7 +1267,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = edgeKey(cell, side); const key = edgeKey(cell, side);
const current = view.edges[key] ?? "open"; const current = view.edges[key] ?? "open";
if (current === "open") return "there is no wall there"; if (current === "open") return "there is no wall there";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight to the wall"; if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight to the wall";
state.edgeOverrides[key] = "open"; state.edgeOverrides[key] = "open";
delete state.doorStates[key]; delete state.doorStates[key];
delete state.createdEdges[key]; delete state.createdEdges[key];
@@ -1376,7 +1456,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target.playerId === caster.id) return "you are already your own buddy"; if (cmd.target.playerId === caster.id) return "you are already your own buddy";
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player"; if (!target || !target.alive) return "no such living player";
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, target.position)) return "no line of sight";
// Effectively permanent: broken by the caster attacking the target. // Effectively permanent: broken by the caster attacking the target.
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS); attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
return null; return null;
@@ -1415,8 +1495,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
ctx.defender.carriedTreasureId = null; ctx.defender.carriedTreasureId = null;
ctx.events.push({ ctx.events.push({
type: "treasureDropped", player: ctx.defender.id, treasureId: t.id, type: "treasureDropped", player: ctx.defender.id, treasureId: t.id,
at: ctx.defender.position, onHomeOf: null, at: ctx.defender.position, onHomeOf: homeOwnerAt(ctx.state, ctx.defender.position),
}); });
// A forced drop can land on a home square: bankings and the
// captured-treasures clause take effect at once, as they would
// for a willing drop.
checkVictory(ctx.state, ctx.events);
return; return;
} }
const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted); const idx = ctx.defender.hand.findIndex((c) => c.cardId === wanted);
@@ -1475,7 +1559,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (farKey && (view.edges[farKey] ?? "open") !== "open") { if (farKey && (view.edges[farKey] ?? "open") !== "open") {
return "the far mouth of that warp is sealed"; return "the far mouth of that warp is sealed";
} }
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
const ignite = (k: string, at: { cell: Cell; side: Side }) => { const ignite = (k: string, at: { cell: Cell; side: Side }) => {
state.edgeOverrides[k] = "firewall"; state.edgeOverrides[k] = "firewall";
state.createdEdges[k] = true; state.createdEdges[k] = true;
@@ -1511,7 +1595,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) { if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
return "the wave must span a corridor between two spaces"; return "the wave must span a corridor between two spaces";
} }
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } }); events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
// The two sides of the edge, and the push directions away from it. // The two sides of the edge, and the push directions away from it.
waveFromEdge(state, events, cell, side, 2, "waterwall"); waveFromEdge(state, events, cell, side, 2, "waterwall");
@@ -1526,13 +1610,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target?.kind === "edge") { if (cmd.target?.kind === "edge") {
const key = edgeKey(cmd.target.cell, cmd.target.side); const key = edgeKey(cmd.target.cell, cmd.target.side);
if (state.illusionWalls[key]) { if (state.illusionWalls[key]) {
if (!losToEdge(boardView(state), caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight"; if (!castSightEdge(state, caster, cmd, boardView(state), cmd.target.cell, cmd.target.side)) return "no line of sight";
delete state.illusionWalls[key]; delete state.illusionWalls[key];
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" }); events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
return null; return null;
} }
if (!state.createdEdges[key]) return "that is not a created thing"; if (!state.createdEdges[key]) return "that is not a created thing";
if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight"; if (!castSightEdge(state, caster, cmd, view, cmd.target.cell, cmd.target.side)) return "no line of sight";
const was = view.edges[key]; const was = view.edges[key];
delete state.edgeOverrides[key]; delete state.edgeOverrides[key];
delete state.createdEdges[key]; delete state.createdEdges[key];
@@ -1544,14 +1628,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = cellKey(cmd.target.cell); const key = cellKey(cmd.target.cell);
const creature = creatureAt(state, cmd.target.cell); const creature = creatureAt(state, cmd.target.cell);
if (creature) { if (creature) {
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
destroyCreature(state, events, creature, "dispel creation"); destroyCreature(state, events, creature, "dispel creation");
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind }); events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
return null; return null;
} }
const content = state.squareContents[key]; const content = state.squareContents[key];
if (!content) return "nothing created there"; if (!content) return "nothing created there";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
delete state.squareContents[key]; delete state.squareContents[key];
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind }); events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
return null; return null;
@@ -1568,7 +1652,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player"; if (!target || !target.alive) return "no such living player";
if (target.id === caster.id) return "you cannot drag yourself"; if (target.id === caster.id) return "you cannot drag yourself";
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, target.position)) return "no line of sight";
if (isLockedInPlace(state, target.id)) return "they are locked in place"; if (isLockedInPlace(state, target.id)) return "they are locked in place";
const from = target.position; const from = target.position;
dragToward(state, target, caster.position); dragToward(state, target, caster.position);
@@ -1577,7 +1661,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
} }
if (cmd.target?.kind === "cell") { if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell); const key = cellKey(cmd.target.cell);
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
const objects = state.groundObjects[key]; const objects = state.groundObjects[key];
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key); const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
if (objects && objects.length > 0) { if (objects && objects.length > 0) {
@@ -1603,7 +1687,15 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
}, },
// --- Magic stones --------------------------------------------------------- // --- Magic stones ---------------------------------------------------------
bloodstone: stoneEffect(), bloodstone: stoneEffect(),
powerstone: stoneEffect(), // "Add 1 to any NUMBER card played." On the table the stone and the
// number land together; digitally the display may come a beat late, so
// it blesses a number already played for movement this turn.
powerstone: stoneEffect((state, events, caster) => {
if (activePlayer(state).id === caster.id && state.turn.numberPlayedForMovement) {
state.turn.movementAllowance += 1;
events.push({ type: "powerstoneBoost", player: caster.id, newAllowance: state.turn.movementAllowance });
}
}),
shadowstone: stoneEffect(), shadowstone: stoneEffect(),
soulstone: stoneEffect(), soulstone: stoneEffect(),
speedstone: stoneEffect((state, _events, caster) => { speedstone: stoneEffect((state, _events, caster) => {
@@ -1635,17 +1727,23 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
kind: "attack", kind: "attack",
requiresLos: true, requiresLos: true,
baseDamage: () => 0, baseDamage: () => 0,
permanentCurse: true,
// "This is permanent. Once SLOW DEATH is on, it can't be turned off." // "This is permanent. Once SLOW DEATH is on, it can't be turned off."
// Rev 11, per the FAQ: "If SLOW DEATH is REVERSED, the player receives
// a point for every card drawn" — the whole curse attaches backward,
// just as permanently. Older games attached it biting; they replay so.
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); const reversed = ctx.reversed && (ctx.state.config.deckRev ?? 1) >= 11;
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id,
PERMANENT_TURNS, reversed ? { reversed: 1 } : {});
}, },
}, },
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true }, blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
"around-the-corner": { "around-the-corner": {
kind: "neutral", kind: "neutral",
// Never cast alone — attached to an attack via aroundCornerInstanceId. // Never cast alone — attached to any L.O.S. cast via aroundCornerInstanceId.
resolve: () => "attach Around The Corner to an attack instead of casting it alone", resolve: () => "attach Around The Corner to a line-of-sight spell instead of casting it alone",
}, },
ugly: { ugly: {
kind: "neutral", kind: "neutral",
@@ -1678,7 +1776,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = edgeKey(cell, side); const key = edgeKey(cell, side);
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line"; if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
if (state.illusionWalls[key]) return "an illusion already shimmers there"; if (state.illusionWalls[key]) return "an illusion already shimmers there";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.illusionWalls[key] = { createdBy: caster.id, belief: {} }; state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } }); events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
return null; return null;
@@ -1750,7 +1848,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return "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, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, creature.position)) return "no line of sight";
const boost = cmd.params?.boost === "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;
@@ -1841,7 +1939,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = edgeKey(cell, side); const key = edgeKey(cell, side);
const view = boardView(state); const view = boardView(state);
if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall"; if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null }); state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
state.edgeOverrides[key] = "open"; state.edgeOverrides[key] = "open";
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } }); events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
@@ -1917,7 +2015,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") { if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
return "a door goes into a stone wall or an open corridor"; return "a door goes into a stone wall or an open corridor";
} }
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "door"; state.edgeOverrides[key] = "door";
state.createdEdges[key] = true; state.createdEdges[key] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } }); events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
@@ -1937,8 +2035,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
} }
const uniq = new Set(cells.map(cellKey)); const uniq = new Set(cells.map(cellKey));
if (uniq.size !== 4) return "the four tokens go on four different squares"; if (uniq.size !== 4) return "the four tokens go on four different squares";
state.boobytraps.push({ casterId: caster.id, cells: [...cells], realKey: cellKey(cells[0]!) }); // The FIRST cell in the command is the real trap — but that truth
events.push({ type: "boobytrapPlaced", caster: caster.id, cells: [...cells] }); // lives only in realKey. Stored and broadcast in canonical order,
// the tokens carry no tell: position must never whisper which one
// bites.
const laid = [...cells].sort((a, b) => cellKey(a).localeCompare(cellKey(b)));
state.boobytraps.push({ casterId: caster.id, cells: laid, realKey: cellKey(cells[0]!) });
events.push({ type: "boobytrapPlaced", caster: caster.id, cells: laid.map((c) => ({ ...c })) });
events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! }); events.push({ type: "boobytrapPlacedPrivate", visibleTo: caster.id, realCell: cells[0]! });
return null; return null;
}, },
@@ -1952,7 +2055,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 || (state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key); state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to glue down"; if (!hasObject) return "there is nothing there to glue down";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
state.gluedCells[key] = true; state.gluedCells[key] = true;
// "a duration equal to twice the NUMBER card played" // "a duration equal to twice the NUMBER card played"
const turns = magnitude.duration * 2; const turns = magnitude.duration * 2;
@@ -1976,7 +2079,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 || (state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key); state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to lock up"; if (!hasObject) return "there is nothing there to lock up";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id }; state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell }); events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
return null; return null;
@@ -1994,7 +2097,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ka === kb) return "pick two different squares"; if (ka === kb) return "pick two different squares";
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast"; if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe"; if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, a) || !castSight(state, caster, cmd, b)) return "no line of sight";
const itemsA = state.groundObjects[ka] ?? []; const itemsA = state.groundObjects[ka] ?? [];
const itemsB = state.groundObjects[kb] ?? []; const itemsB = state.groundObjects[kb] ?? [];
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka); const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
@@ -2026,7 +2129,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const current = view.edges[key]; const current = view.edges[key];
const meltable = current === "wall" || current === "door"; const meltable = current === "wall" || current === "door";
if (!meltable) return "that is not a stone wall"; if (!meltable) return "that is not a stone wall";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight"; if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "open"; state.edgeOverrides[key] = "open";
delete state.doorStates[key]; delete state.doorStates[key];
delete state.createdEdges[key]; delete state.createdEdges[key];
@@ -2057,7 +2160,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target?.kind === "cell") { if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell); const key = cellKey(cmd.target.cell);
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block"; if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
delete state.squareContents[key]; delete state.squareContents[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell }); events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
// "Solid stone block turns into a WATERWALL with a range and damage of 4." // "Solid stone block turns into a WATERWALL with a range and damage of 4."
@@ -2115,13 +2218,21 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
kind: "attack", kind: "attack",
requiresLos: true, requiresLos: true,
baseDamage: () => 0, baseDamage: () => 0,
permanentCurse: true,
// "1/2 point of damage for every space moved. This spell is permanent." // "1/2 point of damage for every space moved. This spell is permanent."
// Rev 11: REVERSED at cast, the walk heals instead — half a point
// gained per space, as permanently. Older games attached it biting.
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive) return; if (ctx.fullyStopped || !ctx.defender.alive) return;
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); const reversed = ctx.reversed && (ctx.state.config.deckRev ?? 1) >= 11;
attachSustained(ctx.state, ctx.events, "walking-dead", ctx.attacker.id, ctx.defender.id,
PERMANENT_TURNS, reversed ? { reversed: 1 } : {});
}, },
}, },
disease: { disease: {
// Legacy (rev ≤6): cast AT an adjacent victim through the attack
// stack; ledgers from those games replay this path. Rev 7 games
// never reach it — doCast intercepts disease as a self-cast plague.
kind: "attack", kind: "attack",
baseDamage: () => 0, baseDamage: () => 0,
sameSquare: false, sameSquare: false,
@@ -2208,8 +2319,35 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
kind: "attack", kind: "attack",
physical: true, physical: true,
baseDamage: () => 0, // computed at resolution: distance rammed baseDamage: () => 0, // computed at resolution: distance rammed
// Rev 10, per the table: the ram IS movement — the goat charges on
// its legal legs (three plus numbers played), walking real corridors.
// Earlier revisions rammed at any range; their ledgers keep it.
validate: (state, _cmd, _caster, target) => {
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed;
const d = walkingDistance(state, caster.position, target.position);
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null;
},
onResolved: (ctx) => { onResolved: (ctx) => {
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return; if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position);
if (d === 0) return;
if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say):
// the goat pulls up short, winded, and the table sees it.
ctx.events.push({ type: "attackMissed", attacker: ctx.attacker.id, defender: ctx.defender.id, attackCardId: "butt-head", because: "outran" });
return;
}
ctx.state.turn.movementUsed += d;
ctx.attacker.position = { ...ctx.defender.position };
ctx.events.push({ type: "rammed", attacker: ctx.attacker.id, target: ctx.defender.id, distance: d });
resolvedBlow(ctx, d, "goat ram", "physical");
return;
}
const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) + const d = Math.abs(ctx.defender.position.x - ctx.attacker.position.x) +
Math.abs(ctx.defender.position.y - ctx.attacker.position.y); Math.abs(ctx.defender.position.y - ctx.attacker.position.y);
if (d === 0) return; if (d === 0) return;
@@ -2429,6 +2567,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
kind: "attack", kind: "attack",
requiresLos: true, requiresLos: true,
baseDamage: () => 0, baseDamage: () => 0,
permanentCurse: true,
// "Opponent heads straight for the nearest of his own treasures ... This // "Opponent heads straight for the nearest of his own treasures ... This
// lasts until opponent is on his own treasure." // lasts until opponent is on his own treasure."
sustains: false, sustains: false,
@@ -2441,6 +2580,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId); const carried = ctx.state.treasures.find((t) => t.id === ctx.defender.carriedTreasureId);
if (carried?.owner === ctx.defender.id) return; if (carried?.owner === ctx.defender.id) return;
attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS); attachSustained(ctx.state, ctx.events, "idiot", ctx.attacker.id, ctx.defender.id, PERMANENT_TURNS);
// Already standing on their own treasure: the march is over as it begins.
liftIdiotIfSatisfied(ctx.state, ctx.events, ctx.defender);
}, },
}, },
"big-man": { "big-man": {
@@ -2526,7 +2667,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const aim = cmd.target.cell; const aim = cmd.target.cell;
const view = boardView(state); const view = boardView(state);
if (!view.cells[cellKey(aim)]) return "off the board"; if (!view.cells[cellKey(aim)]) return "off the board";
if (!casterLos(state, caster, caster.position, aim)) return "no line of sight"; if (!castSight(state, caster, cmd, aim)) return "no line of sight";
state.turn.attackUsed = true; state.turn.attackUsed = true;
const clampToBoard = (c: Cell): Cell => { const clampToBoard = (c: Cell): Cell => {
@@ -2758,7 +2899,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
if (!view.cells[cellKey(at)]) return "off the board"; if (!view.cells[cellKey(at)]) return "off the board";
if (state.squareContents[cellKey(at)]) return "that square is blocked"; if (state.squareContents[cellKey(at)]) return "that square is blocked";
if (creatureAt(state, at)) return "a creature is already there"; if (creatureAt(state, at)) return "a creature is already there";
if (!casterLos(state, caster, caster.position, at)) return "no line of sight"; if (!castSight(state, caster, cmd, at)) return "no line of sight";
state.turn.attackUsed = true; state.turn.attackUsed = true;
spawnCreature(state, events, kind, caster.id, at); spawnCreature(state, events, kind, caster.id, at);
return null; return null;
@@ -2810,7 +2951,7 @@ function emptySquareTarget(
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) { if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it"; return "the warp shimmers there — nothing can form on it";
} }
if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight"; if (!castSight(state, caster, cmd, cell)) return "no line of sight";
return cell; return cell;
} }
@@ -2937,7 +3078,20 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
if (!gameLos(state, imp.position, p.position)) continue; if (!gameLos(state, imp.position, p.position)) continue;
imp.scorchedThisTurn.push(p.id); imp.scorchedThisTurn.push(p.id);
events.push({ type: "impScorches", creatureId: imp.id, player: p.id }); events.push({ type: "impScorches", creatureId: imp.id, player: p.id });
applyDamage(state, events, p, 2, "fire imp", null); // Rev 8, per the FAQ: every imp attack is technically a spell — the
// scorch opens a counteraction window and burns as MAGICAL fire
// (soulstone's floor holds). One victim's window at a time; the
// sweep finds the rest on its next pass. Older games keep the
// instant kindless burn their ledgers recorded.
if ((state.config.deckRev ?? 1) >= 8) {
if (!state.stack) {
openCreatureStack(state, imp, p, 2, undefined, "spell");
break;
}
imp.scorchedThisTurn.pop(); // window busy: burn on a later sweep
} else {
applyDamage(state, events, p, 2, "fire imp", null);
}
} }
} }
checkVictory(state, events); checkVictory(state, events);
@@ -3095,21 +3249,35 @@ function openCreatureStack(
victim: PlayerState, victim: PlayerState,
damage: number, damage: number,
touch?: "wraith" | "claw", touch?: "wraith" | "claw",
kind: "physical" | "spell" = "physical",
): void {
openCardlessStack(state, creature.controllerId, victim.id, {
params: { damage },
kind,
creatureId: creature.id,
...(touch ? { creatureTouch: touch } : {}),
});
}
/** A counteraction stack with no attack card behind it a creature's
* blow, the WARD's bite, STRENGTH's tear. Counters ride it as usual. */
function openCardlessStack(
state: GameState,
attackerId: PlayerId,
defenderId: PlayerId,
opts: Pick<CastStack, "params" | "kind"> & Partial<CastStack>,
): void { ): void {
state.stack = { state.stack = {
attackerId: creature.controllerId, attackerId,
defenderId: victim.id, defenderId,
attackCard: null, attackCard: null,
numberValue: null, numberValue: null,
amplifyFactor: 1, amplifyFactor: 1,
extendFactor: 1, extendFactor: 1,
powerAttackPoints: 0, powerAttackPoints: 0,
params: { damage },
kind: "physical",
counters: [], counters: [],
waitingOn: victim.id, waitingOn: defenderId,
creatureId: creature.id, ...opts,
...(touch ? { creatureTouch: touch } : {}),
}; };
} }
@@ -3441,6 +3609,7 @@ function attachSustained(
casterId: PlayerId, casterId: PlayerId,
targetId: PlayerId, targetId: PlayerId,
turns: number, turns: number,
data: Record<string, number> = {},
): void { ): void {
const effect: SustainedEffect = { const effect: SustainedEffect = {
id: `fx-${state.nextEffectId++}`, id: `fx-${state.nextEffectId++}`,
@@ -3448,7 +3617,7 @@ function attachSustained(
casterId, casterId,
targetId, targetId,
remainingTurns: Math.max(1, turns), remainingTurns: Math.max(1, turns),
data: {}, data,
}; };
state.sustained.push(effect); state.sustained.push(effect);
events.push({ events.push({
@@ -4148,12 +4317,18 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
state.turn.movementUsed++; state.turn.movementUsed++;
events.push({ type: "moved", player: p.id, from, to: p.position, direction, via }); events.push({ type: "moved", player: p.id, from, to: p.position, direction, via });
// WALKING DEAD: 1/2 point per space moved (a full point every two steps). // WALKING DEAD: 1/2 point per space moved (a full point every two
// steps) — gained instead of bled for a curse REVERSED at its casting.
for (const fx of sustainedOn(state, p.id, "walking-dead")) { for (const fx of sustainedOn(state, p.id, "walking-dead")) {
fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1; fx.data.halfSteps = (fx.data.halfSteps ?? 0) + 1;
if (fx.data.halfSteps % 2 === 0) { if (fx.data.halfSteps % 2 === 0) {
applyDamage(state, events, p, 1, "walking dead", null); if (fx.data.reversed) {
checkVictory(state, events); p.life += 1;
events.push({ type: "lifeGained", player: p.id, amount: 1, source: "walking dead (reversed)", lifeAfter: p.life });
} else {
applyDamage(state, events, p, 1, "walking dead", null);
checkVictory(state, events);
}
} }
} }
// DISEASE: the carrier infects every other player in a square they enter. // DISEASE: the carrier infects every other player in a square they enter.
@@ -4165,6 +4340,18 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
} }
checkVictory(state, events); checkVictory(state, events);
} }
// Rev 7: sharing a plague square cuts both ways — walking INTO a
// carrier's square catches the disease's bite just the same.
if ((state.config.deckRev ?? 1) >= 7 && p.alive) {
for (const other of state.players) {
if (!other.alive || other.id === p.id) continue;
if (cellKey(other.position) !== cellKey(p.position)) continue;
if (sustainedOn(state, other.id, "disease").length === 0) continue;
applyDamage(state, events, p, 3, "disease", null, "physical");
checkVictory(state, events);
break;
}
}
if (crossedFirewall) { if (crossedFirewall) {
events.push({ type: "firewallBurned", player: p.id }); events.push({ type: "firewallBurned", player: p.id });
@@ -4226,31 +4413,25 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
state.turn.actionsEnded = true; state.turn.actionsEnded = true;
springSlimeTrap(state, events, p); springSlimeTrap(state, events, p);
} }
// IDIOT lifts when the victim reaches their own treasure. liftIdiotIfSatisfied(state, events, p);
if (sustainedOn(state, p.id, "idiot").length > 0) {
const onOwn = state.treasures.some(
(t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position),
);
const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position);
if (onOwn || allCarried) {
for (const fx of sustainedOn(state, p.id, "idiot")) {
events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id });
}
state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id));
}
}
// Armed ambushes may spring on this step. // Armed ambushes may spring on this step.
checkAmbushes(state, events, p, { movedFrom: from }); checkAmbushes(state, events, p, { movedFrom: from });
// BOOBYTRAP: the real token detonates under anyone but its caster. // BOOBYTRAP: the real token detonates under anyone but its caster; a
// blank token is flipped by the step that finds it and leaves the board
// alone — each decoy dies its own death.
for (const trap of [...state.boobytraps]) { for (const trap of [...state.boobytraps]) {
if (trap.casterId === p.id) continue; if (trap.casterId === p.id) continue;
if (cellKey(p.position) === trap.realKey) { const here = cellKey(p.position);
if (here === trap.realKey) {
state.boobytraps = state.boobytraps.filter((t) => t !== trap); state.boobytraps = state.boobytraps.filter((t) => t !== trap);
events.push({ type: "boobytrapSprung", player: p.id, at: p.position }); events.push({ type: "boobytrapSprung", player: p.id, at: p.position });
applyDamage(state, events, p, 4, "boobytrap", null, "physical"); applyDamage(state, events, p, 4, "boobytrap", null, "physical");
checkVictory(state, events); checkVictory(state, events);
} else if (trap.cells.some((c) => cellKey(c) === here)) {
trap.cells = trap.cells.filter((c) => cellKey(c) !== here);
events.push({ type: "boobytrapBlank", player: p.id, at: p.position });
} }
} }
@@ -4381,6 +4562,22 @@ function castingBlocked(
* treasure on their own home, or drop their own treasure underfoot for an * treasure on their own home, or drop their own treasure underfoot for an
* instant cure. * instant cure.
*/ */
/** IDIOT lifts when the march is done the victim stands on their own
* treasure or turns unsatisfiable: every one of their treasures is in
* someone's arms, leaving nothing to stand on. */
function liftIdiotIfSatisfied(state: GameState, events: GameEvent[], p: PlayerState): void {
if (sustainedOn(state, p.id, "idiot").length === 0) return;
const onOwn = state.treasures.some(
(t) => t.owner === p.id && t.position && cellKey(t.position) === cellKey(p.position),
);
const allCarried = !state.treasures.some((t) => t.owner === p.id && t.position);
if (!onOwn && !allCarried) return;
for (const fx of sustainedOn(state, p.id, "idiot")) {
events.push({ type: "spellExpired", effectId: fx.id, cardId: "idiot", target: p.id });
}
state.sustained = state.sustained.filter((fx) => !(fx.cardId === "idiot" && fx.targetId === p.id));
}
function idiotBlocked(state: GameState, playerId: PlayerId): string | null { function idiotBlocked(state: GameState, playerId: PlayerId): string | null {
if (sustainedOn(state, playerId, "idiot").length > 0) { if (sustainedOn(state, playerId, "idiot").length > 0) {
return "What am I doing here...? (you can do nothing but head for your treasure)"; return "What am I doing here...? (you can do nothing but head for your treasure)";
@@ -4460,8 +4657,18 @@ function doWardChoice(prev: GameState, play: boolean): CommandResult {
owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId); owner.displayed = owner.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!); state.discard.push(card!);
events.push({ type: "wardSprung", owner: owner.id, victim: taker.id }); events.push({ type: "wardSprung", owner: owner.id, victim: taker.id });
applyDamage(state, events, taker, 3, "warded treasure", null); // Rev 9: "COUNTERACTIONs against WARD ... otherwise work as written" —
checkVictory(state, events); // the bite rides the stack. `trapped` carries the card's other clause:
// nothing a counter does can touch the Ward's caster. Older games
// recorded the instant bite and replay it.
if ((state.config.deckRev ?? 1) >= 9) {
openCardlessStack(state, owner.id, taker.id, {
params: { damage: 3 }, kind: "spell", trapped: true,
});
} else {
applyDamage(state, events, taker, 3, "warded treasure", null);
checkVictory(state, events);
}
} }
return { ok: true, state, events }; return { ok: true, state, events };
} }
@@ -4579,10 +4786,31 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
if (state.turn.attackUsed) state.turn.secondAttackUsed = true; if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true; state.turn.attackUsed = true;
// Rev 6: "this would be an attack" — the grab opens a counteraction
// window like any punch; the clutch roll waits for the counters.
if ((state.config.deckRev ?? 1) >= 6) {
openCardlessStack(state, attacker.id, target.id, {
params: null, kind: "physical", tearTreasure: true,
});
events.push({ type: "treasureTearAttempted", attacker: attacker.id, defender: target.id });
return { ok: true, state, events };
}
executeTear(state, events, attacker, target);
return { ok: true, state, events };
}
/** The clutch roll and the wrestling itself, once nothing stands in the way. */
function executeTear(
state: GameState,
events: GameEvent[],
attacker: PlayerState,
target: PlayerState,
): void {
const roll = rollD4(state, events, target.id, "clutching the treasure — a 1 keeps it"); const roll = rollD4(state, events, target.id, "clutching the treasure — a 1 keeps it");
if (roll === 1) { if (roll === 1) {
events.push({ type: "tearResisted", attacker: attacker.id, defender: target.id }); events.push({ type: "tearResisted", attacker: attacker.id, defender: target.id });
return { ok: true, state, events }; return;
} }
const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!; const t = state.treasures.find((t) => t.id === target.carriedTreasureId)!;
@@ -4612,7 +4840,6 @@ function doTearTreasure(prev: GameState, targetId: PlayerId): CommandResult {
} }
} }
checkVictory(state, events); checkVictory(state, events);
return { ok: true, state, events };
} }
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult { function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
@@ -4896,6 +5123,22 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
return null; return null;
}; };
// DISEASE (rev 7): "You're the carrier!" — a self-cast plague on fear's
// pattern, no target and no counteraction window ("REFLECTIONs have no
// effect against this"). Older games cast it at a victim through the
// stack; their ledgers replay the legacy attack path below.
if (inHand.cardId === "disease" && (state.config.deckRev ?? 1) >= 7) {
consumeCast(state, caster, inHand, mods, false);
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null,
}];
attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration);
return { ok: true, state, events };
}
if (effect.kind === "attack") { if (effect.kind === "attack") {
const pre = attackPreconditions(state); const pre = attackPreconditions(state);
if (pre) return err(pre); if (pre) return err(pre);
@@ -4907,7 +5150,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) { if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
return err("you must be in the same square"); return err("you must be in the same square");
} }
if (effect.requiresLos && !casterLos(state, caster, caster.position, creature.position)) { if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, creature.position)
: casterLos(state, caster, caster.position, creature.position))) {
return err("no line of sight to the creature"); return err("no line of sight to the creature");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
@@ -4943,7 +5188,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "cell" && if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") { state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell; const cell = cmd.target.cell;
if (effect.requiresLos && !casterLos(state, caster, caster.position, cell)) { if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, cell)
: casterLos(state, caster, caster.position, cell))) {
return err("no line of sight to the slime"); return err("no line of sight to the slime");
} }
const wandEvents: GameEvent[] = []; const wandEvents: GameEvent[] = [];
@@ -4990,7 +5237,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
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");
} }
if (effect.requiresLos && !losToEdge(view, caster.position, cell, side)) { if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) {
return err("no line of sight to the wall"); return err("no line of sight to the wall");
} }
const dmg = const dmg =
@@ -5062,7 +5309,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const preEvents: GameEvent[] = []; const preEvents: GameEvent[] = [];
if (effect.requiresLos) { if (effect.requiresLos) {
const sighted = mods.aroundCorner const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position, preEvents) ? bentLos(state, caster, caster.position, target.position)
: casterLos(state, caster, caster.position, target.position); : casterLos(state, caster, caster.position, target.position);
if (!sighted) return err("no line of sight to the target"); if (!sighted) return err("no line of sight to the target");
} }
@@ -5183,7 +5430,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
} }
// Neutral: validate on a preview clone before consuming any cards. // Neutral: validate on a preview clone before consuming any cards.
const events: GameEvent[] = [{ const events: GameEvent[] = [];
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
events.push({
type: "spellCast", type: "spellCast",
caster: caster.id, caster: caster.id,
card: inHand, card: inHand,
@@ -5193,7 +5442,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
from: caster.position, from: caster.position,
target: cmd.target?.kind === "player" ? cmd.target.playerId : null, target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null, targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
}]; });
const preview = clone(state); const preview = clone(state);
const problem = (effect as NeutralEffect).resolve( const problem = (effect as NeutralEffect).resolve(
preview, [], activePlayer(preview), cmd, mods.magnitude, preview, [], activePlayer(preview), cmd, mods.magnitude,
@@ -5677,7 +5926,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
? effect.baseDamage(stack.numberValue, stack.params) ? effect.baseDamage(stack.numberValue, stack.params)
: stack.creatureId : stack.creatureId
? (stack.params?.damage ?? 1) // a creature's blow ? (stack.params?.damage ?? 1) // a creature's blow
: 1; // a punch : stack.tearTreasure
? 0 // the tear wrestles; it does not bruise
: (stack.params?.damage ?? 1); // a punch — or a cardless trap's bite
// Webs burn: "Any fire damage done to player in webs causes two extra points." // Webs burn: "Any fire damage done to player in webs causes two extra points."
if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) { if (attackId === "fireball" && sustainedOn(state, defender.id, "sticky-web").length > 0) {
base += 2; base += 2;
@@ -5801,7 +6052,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
effect.onResolved({ effect.onResolved({
state, events, state, events,
attacker: defender, defender: attacker, attacker: defender, defender: attacker,
damageDealt: 0, fullyStopped: false, duration: 0, damageDealt: 0, fullyStopped: false, reversed: false, duration: 0,
stack: { ...stack, params: { ...stack.params, cardId: chosen } }, stack: { ...stack, params: { ...stack.params, cardId: chosen } },
}); });
} }
@@ -5818,9 +6069,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
return; return;
} }
// The returned spell is a fresh attack on its own caster — who gets a // The returned spell is a fresh attack on its own caster — who gets a
// defender's counteraction window against it. // defender's counteraction window against it. Rev 11: permanent curses
// ride the return too; older games let a reflected curse evaporate.
const returnsCurse = effect?.permanentCurse === true && (state.config.deckRev ?? 1) >= 11;
if (!stack.trapped && !attackingCreature && if (!stack.trapped && !attackingCreature &&
attacker.alive && (pipe.damage > 0 || pipe.duration > 0)) { attacker.alive && (pipe.damage > 0 || pipe.duration > 0 || returnsCurse)) {
state.stack = { state.stack = {
attackerId: defender.id, attackerId: defender.id,
defenderId: attacker.id, defenderId: attacker.id,
@@ -5907,6 +6160,15 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
} }
} }
// The tear's wrestling waits for the counters: only an unstopped grab,
// still in arm's reach of a still-laden defender, gets its clutch roll.
if (stack.tearTreasure && !pipe.fullyStopped && !pipe.redirected &&
attacker.alive && defender.alive &&
cellKey(attacker.position) === cellKey(defender.position) &&
defender.carriedTreasureId != null) {
executeTear(state, events, attacker, defender);
}
events.push({ events.push({
type: "attackResolved", type: "attackResolved",
attacker: attacker.id, attacker: attacker.id,
@@ -5926,6 +6188,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
defender, defender,
damageDealt, damageDealt,
fullyStopped: pipe.fullyStopped, fullyStopped: pipe.fullyStopped,
reversed: pipe.reversed,
duration: pipe.duration, duration: pipe.duration,
stack, stack,
}); });
@@ -6058,10 +6321,24 @@ function applyDamage(
soaks.push({ what: "bloodstone", amount: 1 }); soaks.push({ what: "bloodstone", amount: 1 });
} }
// SOULSTONE: "Last three points ... can only be lost to physical damage." // SOULSTONE: "Last three points ... can only be lost to physical damage."
if (damageKind === "spell" && displays(target, "soulstone") && target.life > 3) { // Rev 8: the floor holds even from below — a bearer already at 3 or
const clamped = Math.min(amount, target.life - 3); // less takes NOTHING from spells. Earlier revisions clamped only from
if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped }); // above (a bearer at 1 could be burned to death), and their ledgers
amount = clamped; // replay that flaw.
if (damageKind === "spell" && displays(target, "soulstone")) {
if ((state.config.deckRev ?? 1) >= 8) {
const clamped = Math.max(0, Math.min(amount, target.life - 3));
if (clamped === 0) {
events.push({ type: "damageImmune", player: target.id, source, because: "soulstone" });
return;
}
if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped });
amount = clamped;
} else if (target.life > 3) {
const clamped = Math.min(amount, target.life - 3);
if (clamped < amount) soaks.push({ what: "soulstone", amount: amount - clamped });
amount = clamped;
}
} }
target.life -= amount; target.life -= amount;
@@ -6140,6 +6417,10 @@ function doPickUpTreasure(prev: GameState, treasureId?: string): CommandResult {
const events: GameEvent[] = [ const events: GameEvent[] = [
{ type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position }, { type: "treasurePickedUp", player: p.id, treasureId: t.id, owner: t.owner, at: p.position },
]; ];
// This grab may have lifted the owner's IDIOT: with every one of their
// treasures now carried, there is nothing left to march to.
const cursedOwner = state.players.find((q) => q.id === t.owner);
if (cursedOwner) liftIdiotIfSatisfied(state, events, cursedOwner);
// WARD: "you may play at that time (out of turn) this card on him" — // WARD: "you may play at that time (out of turn) this card on him" —
// literally: the grab hangs while the owner decides. // literally: the grab hangs while the owner decides.
const owner = state.players.find((q) => q.id === t.owner); const owner = state.players.find((q) => q.id === t.owner);
@@ -6312,11 +6593,18 @@ function doDiscard(prev: GameState, playerId: PlayerId, instanceIds: string[]):
return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] }; return { ok: true, state, events: [{ type: "cardsDiscarded", player: p.id, cards }] };
} }
/** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws." */ /** SLOW DEATH: "Opponent takes 1 point of magical damage whenever he draws"
* or gains one, for a curse that was REVERSED at its casting. */
function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void { function applySlowDeathOnDraw(state: GameState, events: GameEvent[], p: PlayerState, cardsDrawn: number): void {
const stacks = sustainedOn(state, p.id, "slow-death").length; const curses = sustainedOn(state, p.id, "slow-death");
if (stacks === 0 || cardsDrawn === 0 || !p.alive) return; if (curses.length === 0 || cardsDrawn === 0 || !p.alive) return;
for (let i = 0; i < cardsDrawn * stacks; i++) { const blessed = curses.filter((e) => e.data.reversed).length;
if (blessed > 0) {
const amount = cardsDrawn * blessed;
p.life += amount;
events.push({ type: "lifeGained", player: p.id, amount, source: "slow death (reversed)", lifeAfter: p.life });
}
for (let i = 0; i < cardsDrawn * (curses.length - blessed); i++) {
if (!p.alive) break; if (!p.alive) break;
applyDamage(state, events, p, 1, "slow death", null); applyDamage(state, events, p, 1, "slow death", null);
} }
+50 -4
View File
@@ -2,7 +2,7 @@
// The server sends this after every state change; clients never see the // The server sends this after every state change; clients never see the
// deck order or other players' hands. // deck order or other players' hands.
import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board"; import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, type CardInstance } from "./cards"; import { cardDef, type CardInstance } from "./cards";
import { import {
boardView, boardView,
@@ -223,6 +223,32 @@ export function sightedCellsFor(view: GameView): Set<string> {
return out; return out;
} }
/**
* Sight with an AROUND THE CORNER attached: every square straightly sighted,
* plus every square visible from one of those — the caster looks to a middle
* cell and the spell turns there, mirroring the engine's bentLos.
*/
export function bentSightedCellsFor(view: GameView): Set<string> {
const out = sightedCellsFor(view);
const me = view.players.find((p) => p.id === view.you);
if (!me) return out;
const mids = [...out].map((k) => {
const [x, y] = k.split(",").map(Number) as [number, number];
return { x, y };
});
const { board, blockers } = sightBasis(view);
for (const key of Object.keys(board.cells)) {
if (out.has(key)) continue;
const [x, y] = key.split(",").map(Number) as [number, number];
const cell = { x, y };
if (mids.some((mid) => sightBetween(board, mid, cell, blockers)) ||
bentSightThroughGap(board, me.position, cell, blockers)) {
out.add(key);
}
}
return out;
}
/** The board-as-seen and sight blockers this view's sight rules run against. */ /** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } { function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// Held-open doors are open doorways to every eye. Beyond them, the // Held-open doors are open doorways to every eye. Beyond them, the
@@ -296,7 +322,14 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
*/ */
export function stackSightTrace( export function stackSightTrace(
view: GameView, view: GameView,
): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null { ): {
from: Cell;
to: Cell;
trace: SightTrace;
bend?: { mid: Cell; trace: SightTrace };
/** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */
pierced?: string;
} | null {
const stack = view.stack; const stack = view.stack;
if (!stack || stack.creatureId) return null; if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null; if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
@@ -306,6 +339,19 @@ export function stackSightTrace(
if (a.position.x === d.position.x && a.position.y === d.position.y) return null; if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
const trace = traceSightFor(view, a.position, d.position); const trace = traceSightFor(view, a.position, d.position);
if (trace) return { from: a.position, to: d.position, trace }; if (trace) return { from: a.position, to: d.position, trace };
// VISIONSTONE: the bearer sees through exactly one wall or door. When no
// plain line exists, find the single edge whose removal opens one and draw
// the ray straight through it, marking the pierced wall for the table.
if (a.displayed.some((c) => c.cardId === "visionstone")) {
const { board, blockers } = sightBasis(view);
for (const [key, edge] of Object.entries(board.edges)) {
if (edge === "open") continue;
const edges = { ...board.edges };
delete edges[key];
const t = traceSight({ ...board, edges }, a.position, d.position, blockers);
if (t) return { from: a.position, to: d.position, trace: t, pierced: key };
}
}
// AROUND THE CORNER: no straight line exists — find a middle square both // AROUND THE CORNER: no straight line exists — find a middle square both
// ends can see and draw the sight leg by leg, so the table can audit the // ends can see and draw the sight leg by leg, so the table can audit the
// needle instead of doubting it. // needle instead of doubting it.
@@ -337,12 +383,12 @@ const SUMMON_CARD_IDS = new Set([
* aid — mirroring the engine's own validation from the viewer's knowledge. * aid — mirroring the engine's own validation from the viewer's knowledge.
* Null = this card's eligibility is not modeled; light everything. * Null = this card's eligibility is not modeled; light everything.
*/ */
export function eligibleCellsFor(view: GameView, cardId: string): Set<string> | null { export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null {
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return null; if (!me) return null;
const cells = Object.keys(view.board.cells); const cells = Object.keys(view.board.cells);
const key = (x: number, y: number) => `${x},${y}`; const key = (x: number, y: number) => `${x},${y}`;
const sighted = sightedCellsFor(view); const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view);
if (CREATION_CARD_IDS.has(cardId)) { if (CREATION_CARD_IDS.has(cardId)) {
// emptySquareTarget: on the board, unoccupied by content, home, wizard, // emptySquareTarget: on the board, unoccupied by content, home, wizard,
+83
View File
@@ -979,3 +979,86 @@ describe("the denial planner offers only castable blocks", () => {
} }
}); });
}); });
describe("interception, escape, and hazard sense", () => {
function toBot(state: GameState): GameState {
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
return state;
}
it("roots a carrier closing on home with LOCK IN PLACE", () => {
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
state = toBot(state);
const bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
// The foe stands beside the bot, laden, a short walk from banking.
foe.position = { x: bot.position.x, y: bot.position.y };
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
gold.position = null;
gold.carriedBy = "foe";
foe.carriedTreasureId = gold.id;
foe.home = { ...bot.position };
bot.hand = [{ cardId: "lock-in-place", instanceId: "LK" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "LK", target: { kind: "player", playerId: "foe" } });
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
});
it("a pressed carrier of any temperament turns to mist", () => {
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
state = toBot(state);
const bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
// Bot hauls the foe's gold mid-journey with the foe breathing down its neck.
const gold = state.treasures.find((t) => t.owner === "foe" && t.position)!;
gold.position = null;
gold.carriedBy = "bot";
bot.carriedTreasureId = gold.id;
bot.position = { ...foe.home };
foe.position = { ...bot.position };
bot.hand = [
{ cardId: "mist-body", instanceId: "MB" },
{ cardId: "number-3", instanceId: "N3" },
{ cardId: "number-5", instanceId: "N5" },
];
const cmd = automatonCommand(viewFor(state, "bot"), "berserker", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "MB", numberInstanceIds: ["N5"] });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
expect(r.state.sustained.some((s) => s.cardId === "mist-body" && s.targetId === "bot")).toBe(true);
});
it("detours around a thornbush jail rather than diving in", () => {
let { state } = createGame({ playerIds: ["bot", "foe"], seed: 7, sets: ["basic", "expansion1"] });
state = toBot(state);
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand = [];
// Two exits from the bot's square: the short road east holds a
// thornbush, the long road stays clean. A jail is not a shortcut.
const sides = ["N", "S", "E", "W"] as const;
const open = sides.filter((s) => {
const n = { x: bot.position.x + (s === "E" ? 1 : s === "W" ? -1 : 0),
y: bot.position.y + (s === "S" ? 1 : s === "N" ? -1 : 0) };
return state.board.cells[cellKey(n)] !== undefined;
});
expect(open.length).toBeGreaterThanOrEqual(2);
const bushSide = open[0]!;
const freeSide = open[1]!;
for (const s2 of sides) {
state.edgeOverrides[edgeKey(bot.position, s2)] =
s2 === bushSide || s2 === freeSide ? "open" : "wall";
}
const bushCell = { x: bot.position.x + (bushSide === "E" ? 1 : bushSide === "W" ? -1 : 0),
y: bot.position.y + (bushSide === "S" ? 1 : bushSide === "N" ? -1 : 0) };
state.squareContents[cellKey(bushCell)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
// Whatever goal it picks, the first stride must not be into the bush.
expect(cmd).toMatchObject({ type: "move" });
expect((cmd as { direction: string }).direction).not.toBe(bushSide);
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
});
});
+157 -1
View File
@@ -3,7 +3,7 @@ import { applyCommand, activePlayer, boardView, createGame, type GameState } fro
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, 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 { sightedCellsFor, stackSightTrace, viewFor } from "../src/view"; import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; import { newGame, must, drain, 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). */
/** Advance past round 1 (both players just end their turns). */ /** Advance past round 1 (both players just end their turns). */
@@ -398,6 +398,7 @@ describe("the ward window and chaos shields", () => {
expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id }); expect(state.wardPending).toEqual({ ownerId: owner.id, takerId: thief.id });
// Spring it: the thief bleeds 3 and the ward is spent. // Spring it: the thief bleeds 3 and the ward is spent.
state = must(state, owner.id, { type: "wardChoice", play: true }); state = must(state, owner.id, { type: "wardChoice", play: true });
state = drain(state);
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12); expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
}); });
@@ -1196,3 +1197,158 @@ describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
expect(traced?.bend?.mid).toBeDefined(); expect(traced?.bend?.mid).toBeDefined();
}); });
}); });
describe("the tear is an attack (rev 6)", () => {
function tearRig(deckRev?: number) {
let { state } = createGame({
playerIds: ["att", "def"], seed: 42, sets: ["basic", "expansion1"],
...(deckRev != null ? { deckRev } : {}),
});
state = toRound2(state);
while (activePlayer(state).id !== "att") {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
const att = state.players.find((p) => p.id === "att")!;
const def = state.players.find((p) => p.id === "def")!;
def.position = { ...att.position };
const gold = state.treasures.find((t) => t.owner === "att" && t.position)!;
gold.position = null;
gold.carriedBy = "def";
def.carriedTreasureId = gold.id;
state.sustained.push({
id: "fx-t", cardId: "strength", casterId: "att", targetId: "att",
remainingTurns: 3, data: {},
});
return { state, att, def, gold };
}
it("opens a counteraction window; a TELEPORT escape keeps the chest", () => {
let { state, gold } = tearRig();
const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.stack?.tearTreasure).toBe(true);
expect(state.stack?.waitingOn).toBe("def");
const def = state.players.find((p) => p.id === "def")!;
const view = boardView(state);
const away = SIDES.map((s) => stepTarget(view, def.position, s))
.find((t) => t.kind === "step")!;
giveCard(state, "def", "teleport", "TP", 0);
state = must(state, "def", { type: "counteract", instanceId: "teleport#TP", params: { cell: (away as { to: Cell }).to } });
state = must(state, "att", { type: "pass" });
if (state.stack) state = must(state, state.stack.waitingOn, { type: "pass" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === "def")!.carriedTreasureId).toBe(gold.id);
});
it("an uncountered tear wrestles after the window closes", () => {
let { state, gold } = tearRig();
const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" });
if (!r.ok) throw new Error(r.error);
state = r.state;
const res = applyCommand(state, "def", { type: "pass" });
if (!res.ok) throw new Error(res.error);
state = res.state;
const rolled = res.events.find((e) => e.type === "dieRolled");
expect(rolled).toBeTruthy();
const kept = rolled!.type === "dieRolled" && rolled!.roll === 1;
const att = state.players.find((p) => p.id === "att")!;
const def = state.players.find((p) => p.id === "def")!;
if (kept) expect(def.carriedTreasureId).toBe(gold.id);
else expect(att.carriedTreasureId).toBe(gold.id);
});
it("rev 5 games keep the instant tear, no stack", () => {
let { state } = tearRig(5);
const r = applyCommand(state, "att", { type: "tearTreasure", targetId: "def" });
if (!r.ok) throw new Error(r.error);
expect(r.state.stack).toBeNull();
expect(r.events.some((e) => e.type === "treasureTorn" || e.type === "tearResisted")).toBe(true);
});
});
describe("a forced drop lands like any drop", () => {
it("DROP OBJECT onto a home square reports the banking and checks victory", () => {
let { state } = createGame({ playerIds: ["att", "def"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
while (activePlayer(state).id !== "att") {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
const att = state.players.find((p) => p.id === "att")!;
const def = state.players.find((p) => p.id === "def")!;
// The defender stands on their OWN home carrying the attacker's gold —
// the forced drop banks it for them, cruel as that is for the caster.
def.position = { ...def.home };
att.position = { ...def.home };
const gold = state.treasures.find((t) => t.owner === "att" && t.position)!;
gold.position = null;
gold.carriedBy = "def";
def.carriedTreasureId = gold.id;
const dob = giveCard(state, "att", "drop-object", "D", 0);
let r = applyCommand(state, "att", {
type: "cast", instanceId: dob.instanceId,
target: { kind: "player", playerId: "def" }, params: { cardId: "treasure" },
});
if (!r.ok) throw new Error(r.error);
r = applyCommand(r.state, "def", { type: "pass" });
if (!r.ok) throw new Error(r.error);
const drop = r.events.find((e) => e.type === "treasureDropped");
expect(drop).toBeTruthy();
if (drop?.type === "treasureDropped") expect(drop.onHomeOf).toBe("def");
});
});
describe("disease makes the caster the carrier (rev 7)", () => {
it("self-casts with no target; sharing a square bites both directions", () => {
let { state } = createGame({ playerIds: ["carrier", "mark"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
while (activePlayer(state).id !== "carrier") {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
const carrier = state.players.find((p) => p.id === "carrier")!;
const mark = state.players.find((p) => p.id === "mark")!;
const dz = giveCard(state, "carrier", "disease", "D", 0);
giveCard(state, "carrier", "number-3", "N", 1);
let r = applyCommand(state, "carrier", {
type: "cast", instanceId: dz.instanceId, numberInstanceIds: ["number-3#N"],
});
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.stack).toBeNull();
expect(state.sustained.some((s) => s.cardId === "disease" && s.targetId === "carrier")).toBe(true);
// The carrier walks INTO the mark's square: the mark takes 3.
const view = boardView(state);
const carrierNow = state.players.find((p) => p.id === "carrier")!;
const markNow = state.players.find((p) => p.id === "mark")!;
for (const side of SIDES) {
const t = stepTarget(view, carrierNow.position, side);
if (t.kind !== "step") continue;
markNow.position = { ...t.to };
const before = markNow.life;
r = applyCommand(state, "carrier", { type: "move", direction: side });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "mark")!.life).toBe(before - 3);
break;
}
state = must(state, "carrier", { type: "endTurn", draw: 0 });
// The mark walks INTO the carrier's square: the mark takes 3 again.
const m2 = state.players.find((p) => p.id === "mark")!;
const c2 = state.players.find((p) => p.id === "carrier")!;
const view2 = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view2, c2.position, side);
if (t.kind !== "step") continue;
m2.position = { ...t.to };
const back = side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E";
const before = m2.life;
r = applyCommand(state, "mark", { type: "move", direction: back as "N" });
if (!r.ok) throw new Error(r.error);
expect(r.state.players.find((p) => p.id === "mark")!.life).toBe(before - 3);
return;
}
throw new Error("no adjacent step for the return walk");
});
});
+63 -7
View File
@@ -3,7 +3,7 @@ import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
/** Summon a creature next to its creator (round 2+, consumes the attack). */ /** Summon a creature next to its creator (round 2+, consumes the attack). */
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
@@ -151,24 +151,24 @@ describe("monsters", () => {
state = toRound2(state); state = toRound2(state);
const me = activePlayer(state).id; const me = activePlayer(state).id;
const r = summon(state, me, "fire-imp"); const r = summon(state, me, "fire-imp");
state = r.state; state = drain(r.state);
const imp = state.creatures[0]!; const imp = state.creatures[0]!;
// Fireball cannot destroy it... // Fireball cannot destroy it...
state = must(state, me, { type: "endTurn", draw: 0 }); state = drain(must(state, me, { type: "endTurn", draw: 0 }));
const enemy = state.players.find((p) => p.id !== me)!; const enemy = state.players.find((p) => p.id !== me)!;
// (enemy may have been scorched at turn start if in LOS — note life) // (enemy may have been scorched at turn start if in LOS — note life)
const enemyNow = state.players.find((p) => p.id !== me)!; const enemyNow = state.players.find((p) => p.id !== me)!;
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
const fb = giveCard(state, enemy.id, "fireball", "F", 0); const fb = giveCard(state, enemy.id, "fireball", "F", 0);
state = must(state, enemy.id, { state = drain(must(state, enemy.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id }, type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id },
}); }));
expect(state.creatures.length).toBe(1); expect(state.creatures.length).toBe(1);
// ...but a waterbolt douses it instantly. // ...but a waterbolt douses it instantly.
state = must(state, enemy.id, { type: "endTurn", draw: 0 }); state = drain(must(state, enemy.id, { type: "endTurn", draw: 0 }));
state = must(state, me, { type: "endTurn", draw: 0 }); state = drain(must(state, me, { type: "endTurn", draw: 0 }));
state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position }; state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position };
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0); const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
state = must(state, enemy.id, { state = must(state, enemy.id, {
@@ -787,3 +787,59 @@ describe("creatures and the dimensional warp", () => {
if (!r.ok) expect(r.error).toContain("stone"); if (!r.ok) expect(r.error).toContain("stone");
}); });
}); });
describe("the imp's fire is a spell (rev 8)", () => {
function impRig() {
let { state } = createGame({ playerIds: ["imp-owner", "mark"], seed: 42, sets: ["basic", "expansion1"] });
state = toRound2(state);
while (activePlayer(state).id !== "imp-owner") {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
return state;
}
it("scorch opens a counteraction window and FULL SHIELD stops it", () => {
let state = impRig();
const owner = state.players.find((p) => p.id === "imp-owner")!;
const mark = state.players.find((p) => p.id === "mark")!;
mark.position = { ...owner.position };
const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 0);
const spot = emptyNeighborCell(state, owner.position);
let r = applyCommand(state, "imp-owner", {
type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell },
});
if (!r.ok) throw new Error(r.error);
state = r.state;
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
// The scorch rides the stack now: a counteraction window, spell-kind.
expect(state.stack?.creatureId).toBeTruthy();
expect(state.stack?.kind).toBe("spell");
giveCard(state, "mark", "full-shield", "FS", 0);
state = must(state, "mark", { type: "counteract", instanceId: "full-shield#FS" });
state = drain(state);
expect(state.players.find((p) => p.id === "mark")!.life).toBe(15);
});
it("soulstone's floor holds from below: a bearer at 2 takes nothing from the flame", () => {
let state = impRig();
const owner = state.players.find((p) => p.id === "imp-owner")!;
const mark = state.players.find((p) => p.id === "mark")!;
mark.position = { ...owner.position };
mark.life = 2;
mark.displayed.push("SS");
mark.hand[0] = { instanceId: "SS", cardId: "soulstone" };
const impCard = giveCard(state, "imp-owner", "fire-imp", "FI", 1);
const spot = emptyNeighborCell(state, owner.position);
let r = applyCommand(state, "imp-owner", {
type: "cast", instanceId: impCard.instanceId, target: { kind: "cell", cell: spot.cell },
});
if (!r.ok) throw new Error(r.error);
state = r.state;
if (!state.stack) state = must(state, "imp-owner", { type: "endTurn", draw: 0 });
expect(state.stack?.kind).toBe("spell");
state = drain(state);
const after = state.players.find((p) => p.id === "mark")!;
expect(after.life).toBe(2);
expect(after.alive).toBe(true);
});
});
+74 -5
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
import { cellKey, edgeKey, stepTarget } from "../src/board"; import { cellKey, edgeKey, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
describe("expansion combat cards", () => { describe("expansion combat cards", () => {
it("power attack burns life for extra damage", () => { it("power attack burns life for extra damage", () => {
@@ -73,8 +73,8 @@ describe("expansion combat cards", () => {
.toEqual(aCards.filter((id) => id !== ms.instanceId)); .toEqual(aCards.filter((id) => id !== ms.instanceId));
}); });
it("butt-head rams for the distance charged", () => { it("butt-head rams for the distance charged (legacy rev 9 rules)", () => {
let { state } = newGame(); let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 9 });
state = toRound2(state); state = toRound2(state);
const attacker = activePlayer(state); const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!; const defender = state.players.find((p) => p.id !== attacker.id)!;
@@ -116,6 +116,7 @@ describe("expansion combat cards", () => {
state = must(state, me.id, { type: "pickUpTreasure" }); state = must(state, me.id, { type: "pickUpTreasure" });
expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id }); expect(state.wardPending).toEqual({ ownerId: enemy.id, takerId: me.id });
state = must(state, enemy.id, { type: "wardChoice", play: true }); state = must(state, enemy.id, { type: "wardChoice", play: true });
state = drain(state);
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12); expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false); expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
}); });
@@ -164,6 +165,34 @@ describe("expansion combat cards", () => {
} }
}); });
it("idiot lifts the moment the victim's last floor treasure is carried off", () => {
let { state } = createGame({ playerIds: ["alice", "bob", "carol"], seed: 42, sets: ["basic"] });
while (state.turn.round < 2) {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
}
const { attacker, defender } = faceOff(state);
const third = state.players.find((p) => p.id !== attacker && p.id !== defender)!;
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
// A third wizard hauls off one of the victim's chests: one destination
// remains, so the march goes on.
const [first, second] = state.treasures.filter((t) => t.owner === defender);
const hauler = state.players.find((p) => p.id === third.id)!;
first!.carriedBy = hauler.id;
first!.position = null;
hauler.carriedTreasureId = first!.id;
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
// The caster takes the last one off the floor — nothing left to march
// to, and the curse lifts on the spot.
const att = state.players.find((p) => p.id === attacker)!;
att.position = { ...second!.position! };
state = must(state, attacker, { type: "pickUpTreasure", treasureId: second!.id });
expect(sustainedOn(state, defender, "idiot").length).toBe(0);
});
it("idiot forbids item handling and punches but allows counteractions", () => { it("idiot forbids item handling and punches but allows counteractions", () => {
let { state } = createGame({ let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
@@ -417,7 +446,11 @@ describe("strength tears treasures from wizards' arms", () => {
let torn = 0, kept = 0; let torn = 0, kept = 0;
for (let seed = 1; seed <= 12; seed++) { for (let seed = 1; seed <= 12; seed++) {
const { state, attacker, defender, treasure } = grip(seed); const { state, attacker, defender, treasure } = grip(seed);
const r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender }); let r = applyCommand(state, attacker, { type: "tearTreasure", targetId: defender });
if (!r.ok) throw new Error(r.error);
// Rev 6: the grab waits out its counteraction window first.
expect(r.state.stack?.tearTreasure).toBe(true);
r = applyCommand(r.state, defender, { type: "pass" });
if (!r.ok) throw new Error(r.error); if (!r.ok) throw new Error(r.error);
expect(r.state.turn.attackUsed).toBe(true); expect(r.state.turn.attackUsed).toBe(true);
// FAQ: tearing is not picking up — the turn's actions continue. // FAQ: tearing is not picking up — the turn's actions continue.
@@ -477,7 +510,10 @@ describe("strength tears treasures from wizards' arms", () => {
state = must(state, "alice", { state = must(state, "alice", {
type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"], type: "cast", instanceId: st.instanceId, numberInstanceIds: ["number-2#N"],
}); });
const r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" }); let r = applyCommand(state, "alice", { type: "tearTreasure", targetId: "bob" });
if (!r.ok) throw new Error(r.error);
// Rev 6: the grab waits out its counteraction window first.
r = applyCommand(r.state, "bob", { type: "pass" });
if (!r.ok) throw new Error(r.error); if (!r.ok) throw new Error(r.error);
const after = r.state.treasures.find((t) => t.id === caras.id)!; const after = r.state.treasures.find((t) => t.id === caras.id)!;
if (after.carriedBy === "bob") continue; // Bob rolled his 1 — try again if (after.carriedBy === "bob") continue; // Bob rolled his 1 — try again
@@ -674,3 +710,36 @@ describe("go away routs around walls", () => {
expect(after.lostTurns).toBe(1); expect(after.lostTurns).toBe(1);
}); });
}); });
describe("the goat charges on legs, not wings (rev 10)", () => {
it("the charge walks real corridors: in reach it spends legs, beyond them it is refused", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
const bh = giveCard(state, attacker.id, "butt-head");
// The engine is the oracle: every other square is either within the
// turn's legal movement or beyond it, and this maze holds both kinds.
let refusals = 0;
let rams = 0;
for (const key of Object.keys(boardView(state).cells)) {
if (key === cellKey(attacker.position)) continue;
const [x, y] = key.split(",").map(Number) as [number, number];
defender.position = { x, y };
const r = applyCommand(state, attacker.id, {
type: "cast", instanceId: bh.instanceId, target: { kind: "player", playerId: defender.id },
});
if (!r.ok) {
if (/legs, not wings/.test(r.error)) refusals += 1;
continue;
}
const st = drain(r.state);
const a = st.players.find((p) => p.id === attacker.id)!;
expect(a.position).toEqual(st.players.find((p) => p.id === defender.id)!.position);
expect(st.turn.movementUsed).toBeGreaterThan(0);
rams += 1;
}
expect(refusals).toBeGreaterThan(0);
expect(rams).toBeGreaterThan(0);
});
});
@@ -378,3 +378,80 @@ describe("a wave's force is spent as it travels", () => {
}); });
}); });
describe("boobytrap decoys die their own deaths", () => {
it("a stepped-on blank token vanishes alone; the real one still waits", () => {
let { state } = newGame();
const me = activePlayer(state);
const enemy = state.players.find((p) => p.id !== me.id)!;
const view = boardView(state);
const open: Cell[] = [];
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (state.squareContents[key]) continue;
open.push({ x, y });
if (open.length === 4) break;
}
const bt = giveCard(state, me.id, "boobytrap");
state = must(state, me.id, {
type: "cast", instanceId: bt.instanceId, params: { cells: open },
});
state = must(state, me.id, { type: "endTurn", draw: 0 });
// The enemy steps onto a BLANK (open[1] — the real trap is open[0]).
const e = state.players.find((p) => p.id === enemy.id)!;
const blank = open[1]!;
for (const side of SIDES) {
const from = { x: blank.x + (side === "E" ? -1 : side === "W" ? 1 : 0),
y: blank.y + (side === "S" ? -1 : side === "N" ? 1 : 0) };
if (!view.cells[cellKey(from)]) continue;
const t = stepTarget(view, from, side);
if (t.kind === "step" && cellKey(t.to) === cellKey(blank)) {
e.position = from;
const before = e.life;
const r = applyCommand(state, enemy.id, { type: "move", direction: side });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(r.events.some((ev) => ev.type === "boobytrapBlank")).toBe(true);
const after = state.players.find((p) => p.id === enemy.id)!;
expect(after.life).toBe(before);
const trap = state.boobytraps[0]!;
expect(trap.cells.length).toBe(3);
expect(trap.cells.some((c) => cellKey(c) === cellKey(blank))).toBe(false);
expect(trap.realKey).toBe(cellKey(open[0]!));
return;
}
}
throw new Error("setup: no approach to the blank token");
});
});
describe("the boobytrap keeps its secret", () => {
it("stored and broadcast cells are canonically ordered — position tells nothing", () => {
let { state } = newGame();
const me = activePlayer(state);
const view = boardView(state);
const open: Cell[] = [];
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (state.squareContents[key]) continue;
open.push({ x, y });
if (open.length === 4) break;
}
// Cast with the REAL trap deliberately last-sorting: reverse order.
const reversed = [...open].reverse();
const bt = giveCard(state, me.id, "boobytrap");
const r = applyCommand(state, me.id, {
type: "cast", instanceId: bt.instanceId, params: { cells: reversed },
});
if (!r.ok) throw new Error(r.error);
const trap = r.state.boobytraps[0]!;
const keys = trap.cells.map((c) => cellKey(c));
expect([...keys].sort()).toEqual(keys); // canonical order, not casting order
expect(trap.realKey).toBe(cellKey(reversed[0]!)); // the truth survives aside
const placed = r.events.find((e) => e.type === "boobytrapPlaced");
if (placed?.type === "boobytrapPlaced") {
const evKeys = placed.cells.map((c) => cellKey(c));
expect([...evKeys].sort()).toEqual(evKeys);
}
});
});
+4 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { giveCard } from "./helpers"; import { drain, giveCard } from "./helpers";
import { edgeKey } from "../src/board"; import { edgeKey } from "../src/board";
import { import {
applyCommand, applyCommand,
@@ -290,7 +290,9 @@ describe("the Ward is played in the moment", () => {
expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false); expect(applyCommand(state, "thief", { type: "endTurn", draw: 0 }).ok).toBe(false);
const r = applyCommand(state, "owner", { type: "wardChoice", play: true }); const r = applyCommand(state, "owner", { type: "wardChoice", play: true });
if (!r.ok) throw new Error(r.error); if (!r.ok) throw new Error(r.error);
state = r.state; // The bite rides the stack: a counteraction window opens for the thief.
expect(r.state.stack).toBeTruthy();
state = drain(r.state);
expect(state.wardPending).toBeNull(); expect(state.wardPending).toBeNull();
expect(state.players.find((p) => p.id === "thief")!.life).toBe(12); expect(state.players.find((p) => p.id === "thief")!.life).toBe(12);
expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false); expect(state.players.find((p) => p.id === "owner")!.hand.some((c) => c.cardId === "ward")).toBe(false);
+8
View File
@@ -29,6 +29,14 @@ export function must(state: GameState, player: PlayerId, command: Command): Game
return result.state; return result.state;
} }
/** Wave every pending counteraction window through unanswered. */
export function drain(state: GameState): GameState {
while (state.stack) {
state = must(state, state.stack.waitingOn, { type: "pass" });
}
return state;
}
export function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance { export function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!; const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId }; const instance = { instanceId: `${cardId}#${tag}`, cardId };
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src"; import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board"; import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, 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, newExpansionGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
describe("around the corner", () => { describe("around the corner", () => {
it("bends line of sight past a wall that blocks a straight cast", () => { it("bends line of sight past a wall that blocks a straight cast", () => {
@@ -44,6 +44,130 @@ describe("around the corner", () => {
state = must(state, defender.id, { type: "pass" }); state = must(state, defender.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10); expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10);
}); });
it("hairpins 180 degrees around a wall's end — the pivot sits in the gap", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
const view0 = boardView(state);
// Carve a 2x2 block: attacker at A, defender at C directly north behind a
// wall that ends beside the B/D gap. C is sealed on every other side, so
// no cell-centered bend reaches it — only the pivot in the gap itself.
// +---+---+ C = target D = gap's far cell
// # C D # A = caster B = gap's near cell
// +###+ +
// A B
let A: Cell | null = null;
for (const k of Object.keys(view0.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
if (view0.cells[`${x + 1},${y}`] && view0.cells[`${x},${y - 1}`] && view0.cells[`${x + 1},${y - 1}`]) {
A = { x, y };
break;
}
}
expect(A).not.toBeNull();
const B = { x: A!.x + 1, y: A!.y };
const C = { x: A!.x, y: A!.y - 1 };
const D = { x: A!.x + 1, y: A!.y - 1 };
state.edgeOverrides[edgeKey(A!, "N")] = "wall"; // the wall to double back around
state.edgeOverrides[edgeKey(A!, "E")] = "open";
state.edgeOverrides[edgeKey(B, "N")] = "open"; // the gap past the wall's end
state.edgeOverrides[edgeKey(C, "E")] = "open";
state.edgeOverrides[edgeKey(C, "N")] = "wall";
state.edgeOverrides[edgeKey(C, "W")] = "wall";
state.edgeOverrides[edgeKey(D, "E")] = "wall";
attacker.position = { ...A! };
defender.position = { ...C };
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
giveCard(state, attacker.id, "around-the-corner", "ATC", 1);
const straight = applyCommand(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
});
expect(straight.ok).toBe(false);
const lifeBefore = defender.life;
state = must(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
target: { kind: "player", playerId: defender.id },
});
state = must(state, defender.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBeLessThan(lifeBefore);
});
// A cell out of straight sight but reachable with one bend, clear enough
// to create on: no contents, home, wizard, treasure, object, or warp.
function hiddenEmptyCell(state: ReturnType<typeof newGame>["state"]): Cell {
const caster = activePlayer(state);
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const cell = { x, y };
if (gameLos(state, caster.position, cell, caster.id)) continue;
if (state.squareContents[key]) continue;
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) continue;
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) continue;
if ((state.groundObjects[key] ?? []).length > 0) continue;
for (const midKey of Object.keys(view.cells)) {
const [mx, my] = midKey.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if (gameLos(state, caster.position, mid, caster.id) && gameLos(state, mid, cell, caster.id)) {
return cell;
}
}
}
throw new Error("no hidden-but-bendable cell on this board");
}
it("bends a neutral spell too — SAFE locks up a treasure around the corner", () => {
let { state } = newExpansionGame();
state = toRound2(state);
const caster = activePlayer(state);
const hidden = hiddenEmptyCell(state);
const gold = state.treasures.find((t) => t.position)!;
gold.position = { ...hidden };
const safe = giveCard(state, caster.id, "safe", "S", 0);
giveCard(state, caster.id, "around-the-corner", "ATC", 1);
const straight = applyCommand(state, caster.id, {
type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: hidden },
});
expect(straight.ok).toBe(false);
if (!straight.ok) expect(straight.error).toBe("no line of sight");
const bent = applyCommand(state, caster.id, {
type: "cast", instanceId: safe.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
target: { kind: "cell", cell: hidden },
});
if (!bent.ok) throw new Error(`bent SAFE refused: ${bent.error}`);
expect(bent.state.squareContents[cellKey(hidden)]?.kind).toBe("safe");
expect(bent.events.some((e) => e.type === "castAroundCorner")).toBe(true);
// Both cards spent: the bend is not free.
const after = bent.state.players.find((p) => p.id === caster.id)!;
expect(after.hand.some((c) => c?.cardId === "around-the-corner")).toBe(false);
});
it("bends a creation — THORNBUSH grows on a square out of straight sight", () => {
let { state } = newGame();
state = toRound2(state);
const caster = activePlayer(state);
const hidden = hiddenEmptyCell(state);
const bush = giveCard(state, caster.id, "thornbush", "TB", 0);
giveCard(state, caster.id, "around-the-corner", "ATC", 1);
const straight = applyCommand(state, caster.id, {
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell: hidden },
});
expect(straight.ok).toBe(false);
state = must(state, caster.id, {
type: "cast", instanceId: bush.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
target: { kind: "cell", cell: hidden },
});
expect(state.squareContents[cellKey(hidden)]?.kind).toBe("thornbush");
});
}); });
describe("blind", () => { describe("blind", () => {
+91 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game"; import { applyCommand, activePlayer, displays, handLimit, sustainedOn, type GameState, type PlayerId } from "../src/game";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; import { createGame } from "../src/game";
import { newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
/** Display a stone for a player during their turn. */ /** Display a stone for a player during their turn. */
function displayStone(state: GameState, playerId: PlayerId, stoneId: string, slot = 0): GameState { function displayStone(state: GameState, playerId: PlayerId, stoneId: string, slot = 0): GameState {
@@ -147,6 +148,95 @@ describe("slow death", () => {
expect(sustainedOn(state, defender, "slow-death").length).toBe(1); expect(sustainedOn(state, defender, "slow-death").length).toBe(1);
}); });
it("a reverse at the casting turns the curse: a point gained per draw (rev 11)", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "slow-death");
giveCard(state, defender, "reverse", "RV", 0);
const r = applyCommand(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
if (!r.ok) throw new Error(r.error);
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
state = drain(state);
expect(sustainedOn(state, defender, "slow-death").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
const d = state.players.find((p) => p.id === defender)!;
const toDiscard = d.hand.slice(0, 2).map((c) => c.instanceId);
state = must(state, defender, { type: "discard", instanceIds: toDiscard });
state = must(state, defender, { type: "endTurn", draw: 2 });
expect(state.players.find((p) => p.id === defender)!.life).toBe(17);
});
it("a reversed walking dead heals half a point per space walked (rev 11)", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wd = giveCard(state, attacker, "walking-dead");
giveCard(state, defender, "reverse", "RV", 0);
const r = applyCommand(state, attacker, {
type: "cast", instanceId: wd.instanceId, target: { kind: "player", playerId: defender },
});
if (!r.ok) throw new Error(r.error);
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
state = drain(state);
expect(sustainedOn(state, defender, "walking-dead").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// Two spaces walked: one point gained, not bled.
let steps = 0;
for (const dir of ["N", "S", "E", "W", "N", "S", "E", "W"] as const) {
if (steps >= 2) break;
const mv = applyCommand(state, defender, { type: "move", direction: dir });
if (mv.ok) { state = mv.state; steps++; }
}
expect(steps).toBe(2);
expect(state.players.find((p) => p.id === defender)!.life).toBe(16);
});
it("a full reflection returns the curse onto its caster (rev 11)", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "slow-death");
giveCard(state, defender, "full-reflection", "FR", 0);
const r = applyCommand(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
if (!r.ok) throw new Error(r.error);
state = must(r.state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
state = drain(state);
expect(sustainedOn(state, defender, "slow-death").length).toBe(0);
expect(sustainedOn(state, attacker, "slow-death").length).toBe(1);
// The caster now bleeds for their own draws.
state = must(state, attacker, { type: "endTurn", draw: 1 });
expect(state.players.find((p) => p.id === attacker)!.life).toBe(14);
});
it("older decks attach the biting curse through a reverse (rev ≤10)", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 10 });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const sd = giveCard(state, attacker, "slow-death");
giveCard(state, defender, "reverse", "RV", 0);
const r = applyCommand(state, attacker, {
type: "cast", instanceId: sd.instanceId, target: { kind: "player", playerId: defender },
});
if (!r.ok) throw new Error(r.error);
state = must(r.state, defender, { type: "counteract", instanceId: "reverse#RV" });
state = drain(state);
state = must(state, attacker, { type: "endTurn", draw: 0 });
const d = state.players.find((p) => p.id === defender)!;
const toDiscard = d.hand.slice(0, 2).map((c) => c.instanceId);
state = must(state, defender, { type: "discard", instanceIds: toDiscard });
state = must(state, defender, { type: "endTurn", draw: 2 });
expect(state.players.find((p) => p.id === defender)!.life).toBe(13);
});
it("bloodstone cancels slow death's per-draw point", () => { it("bloodstone cancels slow death's per-draw point", () => {
let { state } = newGame(); let { state } = newGame();
state = toRound2(state); state = toRound2(state);
+68 -14
View File
@@ -147,19 +147,26 @@ function shareData(id: string): ShareData | null {
const escapeHtml = (t: string) => const escapeHtml = (t: string) =>
t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** index.html with this share's OpenGraph card folded into its head /** index.html with a bespoke OpenGraph card folded into its head
* crawlers never run the app, so the unfurl must arrive pre-baked. */ * crawlers never run the app, so the unfurl must arrive pre-baked. The
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string { * stock page carries its own generic card; strip it, or crawlers (which
// Host and proto arrive from request headers — attacker-writable text * take the FIRST tag they meet) never see this page's. */
// that must never reach an HTML attribute raw. function ogPage(metas: string[]): string {
const proto = /^https?$/.test(rawProto) ? rawProto : "https"; return readFileSync(join(staticRoot!, "index.html"), "utf8")
const host = escapeHtml(rawHost);
// The stock page carries its own generic card; strip it, or crawlers
// (which take the FIRST tag they meet) never see this turn's.
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "") .replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
.replace(/<title>[^<]*<\/title>\s*/, ""); .replace(/<title>[^<]*<\/title>\s*/, "")
const base = `${proto}://${host}`; .replace("</head>", ` ${metas.join("\n ")}\n </head>`);
}
/** Host and proto arrive from request headers attacker-writable text
* that must never reach an HTML attribute raw. */
function safeBase(rawHost: string, rawProto: string): string {
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
return `${proto}://${escapeHtml(rawHost)}`;
}
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto);
const title = data.whole const title = data.whole
? "The whole tale — a game of Wiz-War, replayed" ? "The whole tale — a game of Wiz-War, replayed"
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`; : `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
@@ -179,8 +186,36 @@ function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: strin
`<meta property="og:image:height" content="630"/>`, `<meta property="og:image:height" content="630"/>`,
`<meta name="twitter:card" content="summary_large_image"/>`, `<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`, `<meta name="twitter:image" content="${base}/watch/${id}/og.png"/>`,
].join("\n "); ];
return html.replace("</head>", ` ${metas}\n </head>`); return ogPage(metas);
}
/** The recruiting card for a /join/<code> link: an invitation while the
* room waits to start, a summons to the gallery once it has. */
function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
const base = safeBase(rawHost, rawProto);
const seats = room.players.length;
const wizards = `${seats} wizard${seats === 1 ? "" : "s"}`;
const title = `You're summoned — Wiz-War room ${room.id}`;
const desc = room.state?.phase === "finished"
? `The tale is told — ${wizards} fought in this labyrinth. Follow the link to see how it ended.`
: room.state
? `The duel is underway, ${wizards} in the labyrinth. Follow the link to watch it live from the Peanut Gallery.`
: `${wizards} at the table, waiting to flip the boards. Follow the link, pick a name, and take a seat.`;
const metas = [
`<title>${title}</title>`,
`<meta property="og:type" content="website"/>`,
`<meta property="og:site_name" content="Wiz-War"/>`,
`<meta property="og:title" content="${title}"/>`,
`<meta property="og:description" content="${desc}"/>`,
`<meta property="og:url" content="${base}/join/${room.id}"/>`,
`<meta property="og:image" content="${base}/og.png"/>`,
`<meta property="og:image:width" content="1200"/>`,
`<meta property="og:image:height" content="630"/>`,
`<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/og.png"/>`,
];
return ogPage(metas);
} }
const httpServer = createServer((req, res) => { const httpServer = createServer((req, res) => {
try { try {
@@ -233,6 +268,19 @@ const httpServer = createServer((req, res) => {
res.end(shareHtml(watch[1]!, data, hostname, proto)); res.end(shareHtml(watch[1]!, data, hostname, proto));
return; return;
} }
// Room invitations: a living room gets its recruiting card; a dead
// code falls through to the app, which reports it in the lobby.
const invite = url.match(/^\/join\/([A-Za-z0-9]{4})$/);
if (invite) {
const room = getRoom(invite[1]!);
if (room) {
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const hostname = String(req.headers.host ?? `localhost:${port}`);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(inviteHtml(room, hostname, proto));
return;
}
}
let file = normalize(join(staticRoot, url === "/" ? "index.html" : url)); let file = normalize(join(staticRoot, url === "/" ? "index.html" : url));
if (file !== staticRoot && !file.startsWith(staticRoot + sep)) { if (file !== staticRoot && !file.startsWith(staticRoot + sep)) {
res.writeHead(403).end(); res.writeHead(403).end();
@@ -475,6 +523,12 @@ wss.on("connection", (socket) => {
try { try {
switch (msg.type) { switch (msg.type) {
case "ping":
// Liveness probe: the reply is the point. A restart can leave
// browsers holding half-dead sockets that never fire onclose;
// silence answered by silence is how the client finds out.
send(socket, { type: "pong" });
break;
case "create": { case "create": {
const name = cleanName(msg.name); const name = cleanName(msg.name);
if (!name) return send(socket, { type: "error", message: "name required" }); if (!name) return send(socket, { type: "error", message: "name required" });
+232 -50
View File
@@ -14,7 +14,7 @@
import { prefs, savePrefs } from "./prefs.svelte"; import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte"; import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, GameEvent, Side } from "@wizwar/engine"; import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect(); net.connect();
@@ -23,6 +23,33 @@
let name = $state(prefs.wizardName); let name = $state(prefs.wizardName);
let joinCode = $state(""); let joinCode = $state("");
let claimPhrase = $state(""); let claimPhrase = $state("");
/** A /join/<CODE> recruiting link. The visitor lands looking at the
* room: a held seat walks them back to it, a waiting room offers a
* seat or the gallery, a started game is watched from the gallery. */
const inviteCode = location.pathname.match(/^\/join\/([A-Za-z0-9]{4})$/)?.[1]?.toUpperCase() ?? null;
if (inviteCode) joinCode = inviteCode;
let inviteFollowed = false;
$effect(() => {
if (!inviteCode || inviteFollowed || net.status !== "connected" || net.roomId) return;
inviteFollowed = true;
const seat = net.seats.find((s) => s.roomId === inviteCode);
if (seat) net.resume(seat);
else net.watch(inviteCode);
});
/** The address bar holds the table's invite link while you sit at one,
* so recruiting is a copy away. */
$effect(() => {
const path = net.roomId ? `/join/${net.roomId}` : "/";
if (location.pathname !== path) history.replaceState(null, "", path + location.search);
});
const inviteLink = $derived(net.roomId ? `${location.origin}/join/${net.roomId}` : "");
let inviteCopied = $state(false);
function copyInvite() {
navigator.clipboard?.writeText(inviteLink).then(() => {
inviteCopied = true;
setTimeout(() => (inviteCopied = false), 1600);
});
}
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);
@@ -73,6 +100,45 @@
if (!prefs.cautions || reasons.length === 0) go(); if (!prefs.cautions || reasons.length === 0) go();
else caution = { reasons, go }; else caution = { reasons, go };
} }
/** Board zoom: 1 = fitted to the viewport; higher magnifies for crowded
* squares, with the viewport scrolling. The svg's width is always set
* explicitly (fit × zoom) from measurement — auto-sizing left stale
* dimensions behind when the first-person pane closed. */
let boardViewportEl = $state<HTMLDivElement | null>(null);
let boardZoom = $state(1);
function zoomBoard(dir: number) {
const el = boardViewportEl;
const prev = boardZoom;
const next = dir > 0 ? prev * 1.4 : prev / 1.4;
boardZoom = next < 1.05 ? 1 : Math.min(4, Math.round(next * 100) / 100);
if (el && boardZoom !== prev) {
const f = boardZoom / prev;
requestAnimationFrame(() => {
el.scrollLeft = (el.scrollLeft + el.clientWidth / 2) * f - el.clientWidth / 2;
el.scrollTop = (el.scrollTop + el.clientHeight / 2) * f - el.clientHeight / 2;
});
}
}
function fitBoard(el: HTMLDivElement) {
const svg = el.querySelector("svg.board") as SVGSVGElement | null;
const vb = svg?.viewBox.baseVal;
if (!vb || !vb.width || !vb.height) return;
const fitW = Math.min(el.clientWidth, (el.clientHeight * vb.width) / vb.height);
el.style.setProperty("--fit-w", `${Math.max(0, fitW)}px`);
}
$effect(() => {
const el = boardViewportEl;
if (!el) return;
const ro = new ResizeObserver(() => fitBoard(el));
ro.observe(el);
return () => ro.disconnect();
});
$effect(() => {
void view; // the board's viewBox grows with ghost slots and relocations
const el = boardViewportEl;
if (el) requestAnimationFrame(() => fitBoard(el));
});
/** A move with its regret checks: stepping off your home still laden, /** A move with its regret checks: stepping off your home still laden,
* or stepping onto ground that bites. */ * or stepping onto ground that bites. */
function tryMove(direction: Side, over?: boolean) { function tryMove(direction: Side, over?: boolean) {
@@ -423,6 +489,16 @@
} }
} }
/** Attachment plumbing for a cast command: the chosen mods and the
* attached NUMBER ride along (an explicit numberInstanceIds wins). */
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
applyMods(cmd);
if (attachedNumber && !cmd.numberInstanceIds) {
cmd.numberInstanceIds = [attachedNumber.instanceId];
}
return cmd;
}
/** Suggest-as-you-type pool for the card-naming field, tuned per card. */ /** Suggest-as-you-type pool for the card-naming field, tuned per card. */
const nameSuggestions = $derived.by(() => { const nameSuggestions = $derived.by(() => {
if (!selectedCard) return []; if (!selectedCard) return [];
@@ -542,6 +618,12 @@
"shieldstone", "soulstone", "speedstone", "visionstone", "shieldstone", "soulstone", "speedstone", "visionstone",
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline", "invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
]); ]);
/** Cards cast on yourself via the Cast button. DISEASE joined at rev 7
* ("You're the carrier!"); older games still aim it at a victim. */
function confirmCastable(cardId: string): boolean {
return CONFIRM_CAST.has(cardId) ||
(cardId === "disease" && (view?.deckRev ?? 1) >= 7);
}
function playNumberForMovement() { function playNumberForMovement() {
if (!selectedCard || !view) return; if (!selectedCard || !view) return;
@@ -622,11 +704,11 @@
if (!yourMoment) { peekAt(cell); return; } if (!yourMoment) { peekAt(cell); return; }
if (pendingCellFor && selectedCard) { if (pendingCellFor && selectedCard) {
// Stage 2 of teleport-opponent: destination chosen. // Stage 2 of teleport-opponent: destination chosen.
dispatch({ dispatch(withMods({
type: "cast", instanceId: selectedCard.instanceId, type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "player", playerId: pendingCellFor }, target: { kind: "player", playerId: pendingCellFor },
params: { cell }, params: { cell },
}); }));
clearSelection(); clearSelection();
return; return;
} }
@@ -653,17 +735,17 @@
? trapCells.filter((_, i) => i !== already) ? trapCells.filter((_, i) => i !== already)
: [...trapCells, cell]; : [...trapCells, cell];
if (trapCells.length === 4) { if (trapCells.length === 4) {
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } }); dispatch(withMods({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } }));
clearSelection(); clearSelection();
} }
return; return;
} }
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) { if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
if (!tradeFrom) { tradeFrom = cell; return; } if (!tradeFrom) { tradeFrom = cell; return; }
dispatch({ dispatch(withMods({
type: "cast", instanceId: selectedCard.instanceId, type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, params: { cell: tradeFrom }, target: { kind: "cell", cell }, params: { cell: tradeFrom },
}); }));
clearSelection(); clearSelection();
return; return;
} }
@@ -676,11 +758,10 @@
return; return;
} }
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) { if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
dispatch({ dispatch(withMods({
type: "cast", instanceId: selectedCard.instanceId, type: "cast", instanceId: selectedCard.instanceId,
target: { kind: "cell", cell }, target: { kind: "cell", cell },
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), }));
});
clearSelection(); clearSelection();
return; return;
} }
@@ -858,14 +939,13 @@
if (!selectedCard || !edgeSelectMode) return; if (!selectedCard || !edgeSelectMode) return;
// The attached number rides along for every edge cast: wand charges, // The attached number rides along for every edge cast: wand charges,
// wall-of-fire durations, wall attacks alike. // wall-of-fire durations, wall attacks alike.
dispatch({ dispatch(withMods({
type: "cast", type: "cast",
instanceId: selectedCard.instanceId, instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side }, target: { kind: "edge", cell, side },
...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key") ...(holdDoor && (selectedCard.cardId === "pick-lock" || selectedCard.cardId === "master-key")
? { params: { hold: true } } : {}), ? { params: { hold: true } } : {}),
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), }));
});
clearSelection(); clearSelection();
} }
@@ -881,12 +961,11 @@
return; 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(withMods({
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 } } : {}), ...(selectedCard.cardId === "mega-monster" ? { params: { boost: megaBoost } } : {}),
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), }));
});
clearSelection(); clearSelection();
return; return;
} }
@@ -942,13 +1021,12 @@
swapMeetTarget = playerId; swapMeetTarget = playerId;
return; // next: pick your item, then theirs return; // next: pick your item, then theirs
} }
const cmd: Parameters<typeof net.command>[0] = { const cmd: Parameters<typeof net.command>[0] & { type: "cast" } = {
type: "cast", type: "cast",
instanceId: selectedCard.instanceId, instanceId: selectedCard.instanceId,
target: { kind: "player", playerId }, target: { kind: "player", playerId },
}; };
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId]; withMods(cmd);
applyMods(cmd);
if (selectedCard.cardId === "waterbolt") { if (selectedCard.cardId === "waterbolt") {
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage }; cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
} }
@@ -1006,6 +1084,13 @@
here !== cellKey(me.home)) { here !== cellKey(me.home)) {
reasons.push(`a treasure lies at your feet, un-grabbed`); reasons.push(`a treasure lies at your feet, un-grabbed`);
} }
// Auto-grab about to pocket your OWN chest: carrying it does not
// score and bares it to capture, so that walk-off asks first.
if (autoWillGrab && !me.carriedTreasureId && !view.turn.actionsEnded &&
!weakened && !view.gluedCells[here] && !safed && here !== cellKey(me.home) &&
treasuresHere[0]!.owner === view.you) {
reasons.push("your OWN treasure lies underfoot — ending the turn grabs it and carries it along");
}
// The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves. // The draw picker is sticky: a 0 or 1 chosen turns ago quietly starves.
const drawRoom = Math.min(2, room); const drawRoom = Math.min(2, room);
if (drawCount < drawRoom) { if (drawCount < drawRoom) {
@@ -1172,8 +1257,9 @@
if (!view || !selectedDef || !yourMoment) return null; if (!view || !selectedDef || !yourMoment) return null;
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
if (!me) return null; if (!me) return null;
const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
if (CELL_CARDS.has(selectedCard!.cardId)) { if (CELL_CARDS.has(selectedCard!.cardId)) {
return eligibleCellsFor(view, selectedCard!.cardId); return eligibleCellsFor(view, selectedCard!.cardId, bent);
} }
if (selectedDef.adjacent === true) { if (selectedDef.adjacent === true) {
const { x, y } = me.position; const { x, y } = me.position;
@@ -1184,19 +1270,7 @@
); );
} }
if (selectedDef.los !== true) return null; if (selectedDef.los !== true) return null;
const sighted = sightedCellsFor(view); return bent ? bentSightedCellsFor(view) : sightedCellsFor(view);
if (attachedMods.some((m) => m.cardId === "around-the-corner")) {
const widened = new Set(sighted);
for (const key of Object.keys(view.board.cells)) {
if (widened.has(key)) continue;
const [cx, cy] = key.split(",").map(Number);
if ([[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => sighted.has(`${cx! + dx!},${cy! + dy!}`))) {
widened.add(key);
}
}
return widened;
}
return sighted;
}); });
/** Creatures awaiting your orders this turn (yours + any democratic monster). */ /** Creatures awaiting your orders this turn (yours + any democratic monster). */
@@ -1423,6 +1497,11 @@
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} /> onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span> <span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
</label> </label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.handLeft}
onchange={(e) => setPref("handLeft", e.currentTarget.checked)} />
<span>Hand at the left: your cards stand in a column beside the board (wide screens)</span>
</label>
<label class="pref-row pref-check"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.cautions} <input type="checkbox" checked={prefs.cautions}
onchange={(e) => setPref("cautions", e.currentTarget.checked)} /> onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
@@ -1826,7 +1905,11 @@
<section class="boxlid"> <section class="boxlid">
<div class="boxlid-inner"> <div class="boxlid-inner">
<div class="boxlid-title small">Room {net.roomId}</div> <div class="boxlid-title small">Room {net.roomId}</div>
<div class="boxlid-tag">Share the code. Two to six wizards enter the maze.</div> <div class="boxlid-tag">Share the code — or the link. Two to six wizards enter the maze.</div>
<div class="invite-row">
<code class="invite-link">{inviteLink}</code>
<button class="stamp tiny" onclick={copyInvite}>{inviteCopied ? "copied!" : "copy link"}</button>
</div>
<ul class="roster"> <ul class="roster">
{#each net.players as p (p)} {#each net.players as p (p)}
{@const chosen = net.roomColors[p]} {@const chosen = net.roomColors[p]}
@@ -1846,6 +1929,15 @@
</ul> </ul>
{#if net.spectating} {#if net.spectating}
<p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p> <p class="waiting">👁 You watch from the Peanut Gallery. Waiting for the boards to flip…</p>
{#if net.players.length < 6}
<div class="gallery-join">
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" aria-label="your wizard's name" />
<button class="stamp" disabled={!name.trim()}
onclick={() => { setPref("wizardName", name.trim()); net.join(net.roomId!, name.trim()); }}>
…or take a seat
</button>
</div>
{/if}
{:else} {:else}
<div class="standee-row" role="group" aria-label="choose your wizard"> <div class="standee-row" role="group" aria-label="choose your wizard">
{#each [0, 1, 2, 3, 4, 5] as c (c)} {#each [0, 1, 2, 3, 4, 5] as c (c)}
@@ -1899,7 +1991,7 @@
</div> </div>
</section> </section>
{:else if view} {:else if view}
<div class="game"> <div class="game" class:hand-left={prefs.handLeft}>
<section class="board-zone"> <section class="board-zone">
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}> <div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating} {#if prefs.liveFp && view.you && !net.spectating}
@@ -1914,8 +2006,11 @@
onbody={(id) => (fpvBody = id)} onbody={(id) => (fpvBody = id)}
onWarpStep={(creatureId) => dispatch(creatureId onWarpStep={(creatureId) => dispatch(creatureId
? { type: "creatureWarpStep", creatureId } ? { type: "creatureWarpStep", creatureId }
: { type: "warpStep" })} /> : { type: "warpStep" })}
edgeSelect={edgeSelectMode && yourMoment} />
{/if} {/if}
<div class="board-frame">
<div class="board-viewport" bind:this={boardViewportEl} style:--zoom={boardZoom}>
<Board <Board
{view} {view}
edgeSelectMode={edgeSelectMode && yourMoment} edgeSelectMode={edgeSelectMode && yourMoment}
@@ -1940,6 +2035,12 @@
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null} povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
/> />
</div> </div>
<div class="zoom-controls" role="group" aria-label="board zoom">
<button class="zoom-btn" title="zoom in" onclick={() => zoomBoard(1)} disabled={boardZoom >= 4}>+</button>
<button class="zoom-btn" title="zoom out" onclick={() => zoomBoard(-1)} disabled={boardZoom <= 1}></button>
</div>
</div>
</div>
</section> </section>
<aside class="paper-rail"> <aside class="paper-rail">
@@ -2153,7 +2254,6 @@
</form> </form>
{/if} {/if}
</aside> </aside>
</div>
<div class="table-edge"> <div class="table-edge">
{#if ambushVia} {#if ambushVia}
@@ -2199,7 +2299,8 @@
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}`
: view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId : view.stack.attackCard?.cardId === "illusionary-attack" && view.stack.params?.cardId
? `an illusionary ${cardDef(view.stack.params.cardId).name}` ? `an illusionary ${cardDef(view.stack.params.cardId).name}`
: view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} — : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name
: view.stack.tearTreasure ? "a grab at your treasure" : "a punch"} —
tap a counteraction card, or tap a counteraction card, or
{:else} {:else}
{view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""} {view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
@@ -2322,7 +2423,7 @@
<label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label> <label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label>
<button class="stamp tiny" onclick={castPowerRun}>Run!</button> <button class="stamp tiny" onclick={castPowerRun}>Run!</button>
{/if} {/if}
{#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)} {#if selectedCard && confirmCastable(selectedCard.cardId)}
<button class="stamp tiny" onclick={castSelfWithNumber}> <button class="stamp tiny" onclick={castSelfWithNumber}>
Cast{attachedNumber ? ` with the ${numberTotal}` : ""} Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
</button> </button>
@@ -2461,6 +2562,7 @@
{/each} {/each}
</div> </div>
</div> </div>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -2713,6 +2815,31 @@
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; } .roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; } .check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
.waiting { color: #6b5a41; font-style: italic; } .waiting { color: #6b5a41; font-style: italic; }
.invite-row {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.invite-link {
font-size: 0.8rem;
color: #6b5a41;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
padding: 0.15rem 0.45rem;
user-select: all;
}
.gallery-join {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 0.4rem;
}
.gallery-join input {
width: 11rem;
}
.transfer-slip { .transfer-slip {
max-width: 30rem; max-width: 30rem;
@@ -2914,14 +3041,41 @@
min-height: 0; min-height: 0;
} }
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; } .board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
/* Hand at the left: the cards stand in their own column beside the
* board while the hint strips and action buttons stay beneath it —
* the strip dissolves into the grid so its children place themselves.
* Phones keep the bottom hand; the one-column flow re-orders anyway. */
@media (min-width: 901px) {
.game.hand-left {
grid-template-columns: minmax(9.6rem, 10.4rem) minmax(340px, 1fr) minmax(250px, 330px);
}
.game.hand-left .table-edge { display: contents; }
.game.hand-left .table-edge > * { grid-column: 2; min-width: 0; }
.game.hand-left .table-edge > .hand {
grid-column: 1;
grid-row: 1 / span 6;
flex-direction: column;
align-items: center;
align-self: start;
position: sticky;
top: 0.5rem;
max-height: calc(100dvh - 5rem);
overflow-y: auto;
overflow-x: visible;
padding: 0.3rem 0.3rem 0.9rem;
}
.game.hand-left .board-zone { grid-column: 2; grid-row: 1; }
.game.hand-left .paper-rail { grid-column: 3; grid-row: 1; }
}
/* The first-person pane wears exactly the board's width, right above /* The first-person pane wears exactly the board's width, right above
* it: the stack shrink-wraps to the board, and width:0 + min-width * it: the stack shrink-wraps to the board, and width:0 + min-width
* keeps the pane out of the intrinsic sizing so the BOARD alone * keeps the pane out of the intrinsic sizing so the BOARD alone
* decides how wide the column is. */ * decides how wide the column is. */
.table-stack { .table-stack {
display: inline-flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
} }
@@ -2930,19 +3084,46 @@
/* FPV play: the 3D view is the primary window into the game; the /* FPV play: the 3D view is the primary window into the game; the
* top-down board shrinks to a keymap beneath it. */ * top-down board shrinks to a keymap beneath it. */
.table-stack.fpv-primary { .table-stack.fpv-primary {
display: flex;
width: 100%;
max-width: 64rem; max-width: 64rem;
} }
.board-zone .table-stack.fpv-primary :global(svg.board) { .board-frame { position: relative; min-width: 0; }
max-height: 30dvh; /* The board's window: it owns the fitted size, the svg wears an
align-self: center; * explicit width (fit × zoom), and anything past 1× scrolls. */
.board-viewport {
height: calc(100dvh - 21.5rem);
width: 100%;
overflow: auto;
display: flex;
} }
.board-zone :global(svg.board) { .fpv-primary .board-viewport { height: 30dvh; }
max-height: calc(100dvh - 21.5rem); .board-viewport :global(svg.board) {
width: auto; width: calc(var(--fit-w, 100%) * var(--zoom, 1));
max-width: 100%; flex: none;
max-width: none;
max-height: none;
height: auto;
margin: auto;
} }
.zoom-controls {
position: absolute;
right: 0.4rem;
bottom: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.zoom-btn {
width: 1.7rem;
height: 1.7rem;
border: 1px solid #8a7a5c;
border-radius: 4px;
background: rgba(233, 225, 203, 0.9);
color: #43331f;
font-size: 1.05rem;
line-height: 1;
cursor: pointer;
}
.zoom-btn:disabled { opacity: 0.35; cursor: default; }
.paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; } .paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
.slip { .slip {
@@ -3070,6 +3251,7 @@
.moment-eye:hover { opacity: 1; } .moment-eye:hover { opacity: 1; }
.table-edge { .table-edge {
grid-column: 1 / -1;
margin-top: 0.9rem; margin-top: 0.9rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3426,8 +3608,8 @@
.board-zone { order: 1; } .board-zone { order: 1; }
.table-edge { order: 2; margin-top: 0.6rem; } .table-edge { order: 2; margin-top: 0.6rem; }
.paper-rail { order: 3; margin-top: 0.9rem; } .paper-rail { order: 3; margin-top: 0.9rem; }
.board-zone :global(svg.board) { .board-viewport {
max-height: 56dvh; height: 56dvh;
} }
.chronicle { max-height: 30dvh; flex: initial; } .chronicle { max-height: 30dvh; flex: initial; }
.masthead { gap: 0.45rem; padding-top: 0.5rem; } .masthead { gap: 0.45rem; padding-top: 0.5rem; }
+21 -1
View File
@@ -75,9 +75,17 @@
effects?: BoardFx[] | null; effects?: BoardFx[] | null;
/** The sight line an attack in progress traveled — proof against "how /** The sight line an attack in progress traveled — proof against "how
* can he even see me?", drawn leg by leg through any warp mouth. */ * can he even see me?", drawn leg by leg through any warp mouth. */
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null; sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace }; pierced?: string } | null;
} = $props(); } = $props();
/** The wall segment a VISIONSTONE shot pierced, from its edge key. */
function piercedSegment(key: string): { x1: number; y1: number; x2: number; y2: number } {
const [kind, xy] = key.split(":") as [string, string];
const [x, y] = xy.split(",").map(Number) as [number, number];
return kind === "V"
? { x1: (x + 1) * CELL, y1: y * CELL, x2: (x + 1) * CELL, y2: (y + 1) * CELL }
: { x1: x * CELL, y1: (y + 1) * CELL, x2: (x + 1) * CELL, y2: (y + 1) * CELL };
}
const SECTOR = 5; const SECTOR = 5;
/** Ghost slots can lie beyond the assembled maze — on any side, including /** Ghost slots can lie beyond the assembled maze — on any side, including
@@ -701,6 +709,11 @@
{:else} {:else}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} /> <SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
{/if} {/if}
{#if sightTrace.pierced}
<!-- the one wall VISIONSTONE dissolved for this shot -->
{@const seg = piercedSegment(sightTrace.pierced)}
<line x1={seg.x1} y1={seg.y1} x2={seg.x2} y2={seg.y2} class="sight-pierced" />
{/if}
{/if} {/if}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) --> <!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true"> <g class="fx-layer" aria-hidden="true">
@@ -804,6 +817,13 @@
stroke-width: 2; stroke-width: 2;
animation: sight-pulse 1.6s ease-in-out infinite; animation: sight-pulse 1.6s ease-in-out infinite;
} }
.sight-pierced {
stroke: #c9a72a;
stroke-width: 5;
stroke-linecap: round;
stroke-dasharray: 2 5;
animation: sight-pulse 1.6s ease-in-out infinite;
}
@keyframes sight-pulse { @keyframes sight-pulse {
0%, 100% { opacity: 0.9; } 0%, 100% { opacity: 0.9; }
50% { opacity: 0.35; } 50% { opacity: 0.35; }
+36 -22
View File
@@ -14,10 +14,10 @@
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast"; import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
import { deepestFacing } from "./fpv/director"; import { deepestFacing } from "./fpv/director";
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director"; import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { untrack } from "svelte"; import { onDestroy, untrack } from "svelte";
import type { GameEvent, GameView, Side } from "@wizwar/engine"; import type { GameEvent, GameView, Side } from "@wizwar/engine";
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: { let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, edgeSelect = false, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
view: GameView; view: GameView;
/** The latest live event batch, numbered so each plays once, with /** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */ * the view the server sent alongside it. */
@@ -34,6 +34,8 @@
onfacing?: ((side: Side) => void) | null; onfacing?: ((side: Side) => void) | null;
/** Cell-card eligibility, shared with the board's dimming aid. */ /** Cell-card eligibility, shared with the board's dimming aid. */
litCells?: Set<string> | null; litCells?: Set<string> | null;
/** An edge-target card is raised: wall faces offer themselves. */
edgeSelect?: boolean;
/** The at-your-feet tray's grab: the pane cannot look down, so what /** The at-your-feet tray's grab: the pane cannot look down, so what
* lies in your own square shows as clickable icons instead. */ * lies in your own square shows as clickable icons instead. */
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null; onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
@@ -222,25 +224,33 @@
let actorPos = $state<Record<string, { x: number; y: number }>>({}); let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. ----------------- // --- The fx: each batch plays once, on its own beat. -----------------
// Track ONLY the batch: the server sends `events` then `state` back to
// back, so a wider dependency set re-runs this within the same frame,
// before a zero-delay timer can fire. No per-run cleanup for the same
// reason — timers die only with the component.
let playedBatch = 0; let playedBatch = 0;
const fxTimers = new Set<ReturnType<typeof setTimeout>>();
$effect(() => { $effect(() => {
const b = batch; const b = batch;
if (!b || b.n === playedBatch || !me) return; if (!b) return;
playedBatch = b.n; untrack(() => {
const timers: ReturnType<typeof setTimeout>[] = []; if (b.n === playedBatch || !me) return;
const started: number[] = []; playedBatch = b.n;
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) { for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
timers.push(setTimeout(() => { const starter = setTimeout(() => {
started.push(fx.id); fxTimers.delete(starter);
fpFx = [...fpFx, { ...fx, t0: performance.now() }]; fpFx = [...fpFx, { ...fx, t0: performance.now() }];
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80)); const ender = setTimeout(() => {
}, delay)); fxTimers.delete(ender);
} fpFx = fpFx.filter((f) => f.id !== fx.id);
return () => { }, fx.dur + 80);
timers.forEach(clearTimeout); fxTimers.add(ender);
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id))); }, delay);
}; fxTimers.add(starter);
}
});
}); });
onDestroy(() => fxTimers.forEach(clearTimeout));
// --- Other bodies glide between views. ------------------------------- // --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null; let prevView: GameView | null = null;
@@ -344,12 +354,17 @@
// plays only for things done TO you, never for your own hands — // plays only for things done TO you, never for your own hands —
// dropping a dagger must not fling your eyes out of your skull. // dropping a dagger must not fling your eyes out of your skull.
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5); return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
} else if (aim && !aim.self) { } else if (aim && !aim.self && actor !== povId) {
// In the cockpit your own casts never move the camera: casting
// through the glass means you are ALREADY facing the target, and
// a keymap cast is a deliberate off-screen act. Only deeds done
// BY OTHERS earn an aim or a cutaway.
const ax = aim.x + 0.5, ay = aim.y + 0.5; const ax = aim.x + 0.5, ay = aim.y + 0.5;
const wantFacing = Math.atan2(ay - ty, ax - tx);
const toAim = Math.hypot(ax - tx, ay - ty); const toAim = Math.hypot(ax - tx, ay - ty);
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); const sight = castRay(v, tx, ty, wantFacing);
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay); if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
targetFacing = Math.atan2(ay - ty, ax - tx); targetFacing = wantFacing;
aimed = true; aimed = true;
} else if (performance.now() >= manualUntil) { } else if (performance.now() >= manualUntil) {
// Another wizard acting in your sight line turns your head. // Another wizard acting in your sight line turns your head.
@@ -455,7 +470,7 @@
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)} <FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400} x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos} fx={fpFx} posOverride={actorPos}
ontarget={handleTarget} {litCells} /> ontarget={handleTarget} {litCells} {edgeSelect} />
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right"></button> <button class="drive" onclick={() => manualTurn(1)} aria-label="turn right"></button>
</div> </div>
<div class="bezel-bottom"> <div class="bezel-bottom">
@@ -511,7 +526,6 @@
} }
.bezel-top { border-bottom: 1px solid #2a2620; } .bezel-top { border-bottom: 1px solid #2a2620; }
.bezel-bottom { border-top: 1px solid #2a2620; } .bezel-bottom { border-top: 1px solid #2a2620; }
.bezel-spacer { flex: 1; }
.bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; } .bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; }
.bezel-center { justify-content: center; } .bezel-center { justify-content: center; }
.bezel-right { justify-content: flex-end; } .bezel-right { justify-content: flex-end; }
+10
View File
@@ -24,6 +24,7 @@
rubble = [], rubble = [],
ontarget, ontarget,
litCells = null, litCells = null,
edgeSelect = false,
}: { }: {
view: GameView; view: GameView;
povId: string; povId: string;
@@ -45,6 +46,9 @@
/** Squares a selected cell-target card may aim at: the pane dims the /** Squares a selected cell-target card may aim at: the pane dims the
* ineligible ground exactly as the board dims its squares. */ * ineligible ground exactly as the board dims its squares. */
litCells?: Set<string> | null; litCells?: Set<string> | null;
/** An edge-target card is raised: every wall and door face glows
* faintly, the pane's answer to the board's edge handles. */
edgeSelect?: boolean;
} = $props(); } = $props();
const FOV = Math.PI / 2.9; const FOV = Math.PI / 2.9;
@@ -386,6 +390,12 @@
ctx.fillStyle = `rgba(120,70,220,${haze})`; ctx.fillStyle = `rgba(120,70,220,${haze})`;
ctx.fillRect(col, top, 1, wallH); ctx.fillRect(col, top, 1, wallH);
} }
if (edgeSelect && !hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
// The raised edge-card's invitation: faces breathe gold.
const offer = 0.10 + 0.05 * Math.sin(time / 420 + hit.worldU * 3);
ctx.fillStyle = `rgba(201,167,42,${Math.max(0, offer)})`;
ctx.fillRect(col, top, 1, wallH);
}
} }
// Sprites, far to near, sliced against the depth buffer. // Sprites, far to near, sliced against the depth buffer.
+19
View File
@@ -66,6 +66,8 @@ export function humanize(e: GameEvent): string | null {
? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!` ? `${e.attacker} TEARS the treasure from ${e.defender}'s arms — it tumbles to the floor!`
: `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`; : `${e.attacker} TEARS the treasure from ${e.defender}'s arms!`;
case "tearResisted": return `${e.defender} clutches the treasure with white knuckles — ${e.attacker} comes away empty!`; case "tearResisted": return `${e.defender} clutches the treasure with white knuckles — ${e.attacker} comes away empty!`;
case "treasureTearAttempted": return `${e.attacker} grabs for the treasure in ${e.defender}'s arms!`;
case "powerstoneBoost": return `The powerstone hums over the number already played — ${e.player} gains a stride (${e.newAllowance} total).`;
case "trapSprung": return e.cardId === "gift-from-below" case "trapSprung": return e.cardId === "gift-from-below"
? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!` ? `${e.player} draws GIFT FROM BELOW — it bites for 3, then deals again!`
: `${e.player} walked into an old TRAP! Lose a turn.`; : `${e.player} walked into an old TRAP! Lose a turn.`;
@@ -156,6 +158,7 @@ export function humanize(e: GameEvent): string | null {
case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`; case "stuckInSlime": return `${e.player} squelches into the slime and sticks fast.`;
case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`; case "boobytrapPlaced": return `${e.caster} places four suspicious tokens...`;
case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`; case "boobytrapSprung": return `SNAP! ${e.player} finds the real boobytrap!`;
case "boobytrapBlank": return `${e.player} flips a face-down token — a blank. The rest still wait.`;
case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`; case "objectsGlued": return `Everything on that square is glued down (${e.turns} turns).`;
case "safeCreated": return `A massive safe slams down around the loot.`; case "safeCreated": return `A massive safe slams down around the loot.`;
case "safeOpened": return null; case "safeOpened": return null;
@@ -275,6 +278,11 @@ function saveSeats(seats: Seat[]): void {
class Net { class Net {
status = $state<"disconnected" | "connected">("disconnected"); status = $state<"disconnected" | "connected">("disconnected");
/** When the socket last spoke. A server restart can leave a half-dead
* socket that never fires onclose; the watchdog closes it by hand so
* the reconnect loop (and the masthead banner) actually engage. */
private lastHeard = Date.now();
private watchdog: ReturnType<typeof setInterval> | null = null;
roomId = $state<string | null>(null); roomId = $state<string | null>(null);
players = $state<string[]>([]); players = $state<string[]>([]);
hostId = $state<string | null>(null); hostId = $state<string | null>(null);
@@ -337,6 +345,14 @@ class Net {
if (this.ws) return; if (this.ws) return;
const ws = new WebSocket(SERVER_URL); const ws = new WebSocket(SERVER_URL);
this.ws = ws; this.ws = ws;
this.lastHeard = Date.now();
if (!this.watchdog) {
this.watchdog = setInterval(() => {
if (!this.ws || this.status !== "connected") return;
this.send({ type: "ping" });
if (Date.now() - this.lastHeard > 45_000) this.ws.close();
}, 15_000);
}
ws.onopen = () => { ws.onopen = () => {
this.watching = null; // the first state after (re)connecting may carry a gap this.watching = null; // the first state after (re)connecting may carry a gap
this.status = "connected"; this.status = "connected";
@@ -365,7 +381,10 @@ class Net {
}; };
ws.onmessage = (raw) => { ws.onmessage = (raw) => {
const msg = JSON.parse(raw.data as string); const msg = JSON.parse(raw.data as string);
this.lastHeard = Date.now();
switch (msg.type) { switch (msg.type) {
case "pong":
break;
case "seat": { case "seat": {
this.token = msg.token; this.token = msg.token;
const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase(); const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
+5 -1
View File
@@ -22,6 +22,9 @@ export interface Prefs {
instantReplay: boolean; instantReplay: boolean;
/** The live first-person pane above the board during play. */ /** The live first-person pane above the board during play. */
liveFp: boolean; liveFp: boolean;
/** The hand of cards stands in a column at the board's left instead of
* lying along the bottom (wide screens only). */
handLeft: boolean;
} }
const KEY = "wizwar-prefs"; const KEY = "wizwar-prefs";
@@ -29,7 +32,7 @@ const KEY = "wizwar-prefs";
function load(): Prefs { function load(): Prefs {
const fallback: Prefs = { const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null, art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "adept", instantReplay: true, liveFp: false, cautions: true, botTier: "adept", instantReplay: true, liveFp: false, handLeft: false,
}; };
try { try {
const raw = localStorage.getItem(KEY); const raw = localStorage.getItem(KEY);
@@ -45,6 +48,7 @@ function load(): Prefs {
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept", botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
instantReplay: p.instantReplay !== false, instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true, liveFp: p.liveFp === true,
handLeft: p.handLeft === true,
}; };
} catch { } catch {
return fallback; return fallback;