38 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 b9c784a10c Difficulty without stupidity: apprentice, adept, archmage
Tiers degrade resources and repertoire, never judgment — the design
constraint was that no tier may ever look dumb. The APPRENTICE draws
one card a turn instead of two (a poorer wizard, not a worse one),
spends counters only on heavy hits (thrift, not blindness), and
carries a modest spellbook: damage, summons, stones, keys, and
treasure play, with no afflictions, amplifies, ambushes, guarding
tricks, or deja-vu. The ADEPT draws fully and knows everything except
ambushes and amplify. The ARCHMAGE is the full curriculum. Every card
any tier plays, it plays correctly.

The lobby workshop gains a tier picker beside the temperaments
(default adept); the tier persists with the seat, shows in roster and
scoresheet ("⚙ apprentice mystery"), and rides the drive loop.
Measured where it should matter: in berserker combat mirrors the
archmage beats the apprentice two to one, while pure treasure races
stay honest — the handicap lives in the card exchanges a human
actually feels, pinned deterministically in the tournament suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:59:11 -04:00
Eric WagonerandClaude Fable 5 a6ede6ca1e The clockwork reads the whole deck
Every playable card now has a place in the automaton's mind. The
damage table completes: lightning blast, wizardblade, and power drain
scale with numbers (and are skipped without one), disease bites in
the shared square, the dagger's true three points corrected. A new
affliction book casts the miseries — blind, slow, medusa, no-spell,
lock-in-place, walking dead, slow death, idiot, thought steal,
go-away — with a number for the duration, and WEAKNESS is saved for
whoever carries stolen gold. AMPLIFY doubles the heavy spells.

The counteraction ladder runs the full rack: REVERSE eats the big
blasts, ABSORB SPELL steals the good ones, FULL SHIELD, FULL
REFLECTION, REMOVE CURSE refuses afflictions at the door, REFLECTION
halves, ABSORB soaks the mid-range, BLUNT, EMPATHY out of berserker
spite, teleport escapes, shieldstone numbers.

Housekeeping: every stone hits the table the turn it is drawn, GIFT
FROM ABOVE is cashed instantly, curses get scrubbed with REMOVE
CURSE, LIFESAVER goes up at three players, a SAFE or GLUE guards
floor gold with enemies near, and DEJA-VU pulls the best attack back
from the discard when the hand runs dry of violence.

Table talk verified live ("ACQUISITION COMPLETE." mid-probe) — the
silence was timing, not plumbing — and the remark odds rise to half.
Three-way tournaments tell true stories now: the hunter wins on
gold, the worrier survives to win by attrition, and the berserker
learns that aggression in a three-way gets you buried first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:41:12 -04:00
Eric WagonerandClaude Fable 5 3eda686e29 The full clockwork curriculum: keys, vengeance, spellcraft, and voice
Every play-strength item, in one education. DOORS: the pathfinder
walks through doors it can open — already-opened ones freely, locked
ones when holding Master Key or Pick Lock, casting the key at the
door on the path before stepping through (and a loop where the
clockwork re-keyed an already-open door forever is fixed: opened
doors read as open). TREASURE DEFENSE: a wizard carrying the
automaton's gold becomes the priority — chased over all objectives,
shaken down with DROP OBJECT when held, and attacked first. WIDER
SPELLCRAFT: blaster wands charged by number and fired; teleport as
escape when wounded and hunted, and as a counteraction clear of big
incoming spells; SPEED cast on sight; WARD armed to guard the gold;
shieldstone displayed so spare numbers soak small hits; ANTI-ANTI
pressed against counters (never against escapes); the berserker grows
BIG with an enemy near, the worrier fades invisible or slams a
CREATE WALL in its pursuer's face; ambushes armed from held
interrupts — the worrier trapping its doorstep, the others trapping
the treasure. Tournament suite green across all temperaments.

And the clockwork speaks: sparing, temperament-voiced table talk on
its own deeds — "ACQUISITION COMPLETE.", "SCHEDULED DEMISE:
DELIVERED.", "good wall. safe wall." — through the same chat ledger
as everyone else. The tally gains "games fought against the
clockwork", and automatons no longer pollute the count of wizards
seated.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:40:22 -04:00
Eric WagonerandClaude Fable 5 1aafb9e837 The mystery machine keeps its mood to itself
"Random" read as random ACTIONS, and a revealed random pick is barely
a mystery anyway. The fourth workshop button is now "mystery": the
server rolls the temperament and keeps it — the join line records it
for replay, the drive loop plays it, but the room tells the table
only "mystery". Roster and scoresheet show ⚙ mystery; the machine's
behavior is the only tell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:36:21 -04:00
Eric WagonerandClaude Fable 5 af75a024ff A random pull from the workshop, and temperaments on the scoresheet
The lobby's automaton row gains "random" (the server already rolled
when no style was named — now the choice is offered). And each
automaton's temperament shows beside its name on the in-game
scoresheet in the handwritten face, so you always know whether the
machine across the maze wants your gold or your blood.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:32:06 -04:00
Eric WagonerandClaude Fable 5 5a407a1e4c The clockwork learns numbers, summons, creatures — and temperament
The automaton now plays number cards where they matter: attached to
value-scaled attacks (waterbolt, powerthrust) choosing the biggest
for damage, and played for movement when the goal is just out of
stride. It raises its monsters when no wizard is in spell range and
commands them every turn — creatures march at enemies by the same
hazard-shy BFS and maul whoever shares their square. Targets are
picked by lowest life.

And it has moods. The HUNTER plays the classic game: gold first,
violence when convenient. The BERSERKER hunts wizards over treasure
and finishes games by last-wizard-standing. The WORRIER paths around
enemies, counters at a lower threshold, shields against chaos, and
never brawls. Hosts pick the temperament when seating one (or the
workshop assigns at random); it persists with the seat, shows in the
roster, and the tournament harness proves every pairing finishes.
Across ten seeds of berserker versus hunter: six treasure wins to
four kills — the moods play genuinely different games.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:21:12 -04:00
Eric WagonerandClaude Fable 5 3842307bf0 The clockwork plays at a watchable pace
One command per beat — 1.5 seconds, the replay's 1x cadence — each
broadcast with its board state as it lands, starting a beat after
the human's own action settles. The pump is per-room, re-entrancy
guarded, and stops the moment the maze wants a human (or the game
ends). Watching an automaton stalk your treasure across the maze is
now a spectacle instead of a teleport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:17:28 -04:00
Eric WagonerandClaude Fable 5 ab52628d2f The automatons awaken (branch only — not for the public droplet yet)
Phase 4 begins. The automaton is a pure function in the engine —
automatonCommand(view) — playing from its own redacted GameView, the
same information a human seat receives: hidden hands stay hidden from
the clockwork. It ranks simple damage spells, counters what hurts
(full shield at 3+, reflection at 4+, blunt at 2+), discards its
worst cards by a value order, BFS-pathfinds to enemy treasures and
home again, refuses to path through hazards, brawls when there is
nothing to steal, and always has a safe fallback; the server's drive
loop steps any bot-held seat through the same runCommand path as
humans, so bot commands log, persist, replay, and broadcast like
anyone's.

Hosts seat them pre-start with "⚙ seat an automaton" (Automaton,
Automaton II, ... V); bot seats persist as tokenless join lines and
restore on boot. Proven three ways: bot-vs-bot engine games conclude
across seeds in 8-14 rounds (~100 commands — human-scale), a full
four-automaton table finishes, and a live websocket game of human
vs. automaton ended with the clockwork carrying two treasures home
through a do-nothing opponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:11:54 -04:00
Eric WagonerandClaude Fable 5 5802e1df05 Table talk keeps its place in the chronicle
Live chat interleaved correctly, but a reload sank every past message
to the bottom: the rejoin sent the whole game history first and the
chat history after. Talk is now a tableTalk event in the room's event
stream at the moment it was said — the room file already preserved
the interleaving, since chat lines append between command lines — so
rejoining players read the banter between the battle lines where it
happened. The separate chatHistory message retires; unread counting
rides the event stream instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:00:51 -04:00
Eric WagonerandClaude Fable 5 ef661c79ad The die rolls in the open
Every player-facing D4 roll now lands in the chronicle as pips with
its purpose: "🎲 alice rolls a 3 — aiming at the unseen — only a 1
finds them." A shared rollD4 helper threads the generic dieRolled
event through blind staggers and swings, pit leaps, ooze and pit
struggles, and the invisible/shrink miss rolls — the table sees the
die land, exactly as it would in person. Rolls whose numbers already
showed (the troll's swing, the misdirection direction, the opening
roll-off) keep their lines.

And the physical die's other job — "any 50-50 call" — gets its
button: 🎲 beside the say-box in online games (server-rolled,
published through the chat ledger so it persists, replays, and counts
toward unread badges) and under the chronicle in hotseat, rolled on
the device. Chronicle only; no game state touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:57:28 -04:00
Eric WagonerandClaude Fable 5 d6d34887e3 The whole punch list: rev 8, general FAQ, and four promoted UX items
Rules rev 8 closes the last two fidelity threads. A SPEED bonus turn
burns a turn of duration spells on the hastened wizard — recipient-
counted, per the FAQ's most obscure ruling — with self-cast durations
already burning through the normal turn-start sweep (expiry extracted
into expireEffect so both paths share the cleanup). And nothing can
be created on a DIMENSIONAL WARP token, as the card face always said.

The general-topic FAQ sections (Combat, Line of Sight, Monsters,
Treasures, and five more) join the rules tab verbatim under a double-
ruled divider, completing the FAQ's absorption: card rulings on the
cards, general rulings in the rules.

The four promoted UX items: cell-target spells now dim ineligible
squares (creations mirror emptySquareTarget from the viewer's
knowledge, summons want clear sighted squares, teleport BFSes its
four spaces exactly as the engine does, stone-to-water lights only
stone, dispel lights only creations); hotseat games get the full
replay reel, each step seen from its actor's own seat; a "stop
announcing attacks on this device" link in the fanfare modal (undo
lives beside the notification toggle in the lobby); the corner
inspector now serves only the selected casting card, with every
examine-a-card peek unified on the centered treatment; and the dev
socket URL keys on vite's port instead of hijacking every localhost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:45:59 -04:00
Eric WagonerandClaude Fable 5 f657144127 Discard-pile cards enlarge above the pile, like the library's
Tapping a discarded card summoned the corner inspector beneath the
pile's own overlay — same layering trap the card library had. The
pile now enlarges its card centered above itself, FAQ seal included,
dismissed with a tap anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:37:18 -04:00
Eric WagonerandClaude Fable 5 5375941ed3 Every card wears its FAQ seal; library cards enlarge above the booklet
The FAQ affordance moves onto the card itself: a small dark seal in
the corner of any card with official rulings, everywhere cards render
— hand, inspector, peek, final reveal, discard pile, card library —
opening the rulings modal on tap (the link, never the text: card
faces stay verbatim card text). The separate inspector buttons and
library chips retire in its favor.

And clicking a library card mid-game no longer summons the App-level
inspector beneath the help modal, greyed out and useless — the
library enlarges its card in its own overlay, centered above the
booklet, seal included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:34:43 -04:00
Eric WagonerandClaude Fable 5 4833a229e1 The table's reading prevails: open sides carry sight
An hour of rules archaeology, summarized: DIMENSIONAL WARP denies
LOS explicitly (its tokens stay sightless); the AUTO WARP describes
how to trace sight across a join; and on the lettered wraparounds
the texts are silent — no grant, no denial. The FAQ's Permawarp
rulings even have area effects counting distance through warps. Where
the texts are silent, the edition's own table breaks the tie, and the
table says the open sides see. Board openings carry sight in every
revision (a permissive change: every stored game's log replays
clean); the brief rev-7 restriction and its legacy flag are gone,
along with the sophistry that justified them. The adjacency geometry
stands. The friend's sticky-wand shot through the C opening was legal
after all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:29:09 -04:00
Eric WagonerandClaude Fable 5 493f3bc567 Rev 7: sight passes only through the aisle warps
The cards settle what the morning's feature got wrong. DIMENSIONAL
WARP: "There is no L.O.S. through the warp." The AUTO WARP: "for all
purposes, treat the connected board edges as though they are
adjacent" — an explicit grant, for edges that physically meet at a
corner. The lettered wraparounds get no grant at all: their rule is
about walking. So the pattern is that warps carry movement, not
sight, unless the text says otherwise — and only the aisle corners
say otherwise.

Warps now carry an aisle flag (the 3-player AUTO WARP and the
5-player corner arcs); hasWarpLineOfSight honors only those, with
the adjacency geometry — diagonals through the corner mouth —
unchanged from this morning where it rightfully applies. Games begun
before rev 7 keep the wider sight their logged commands were
validated against, via a legacy flag boardView stamps from the
game's rules revision. Tests pin all three states: lettered mouths
see nothing, aisle mouths see the corner fan, legacy games see
everything they used to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:25:00 -04:00
Eric WagonerandClaude Fable 5 8af8962330 Warp sight honors "as though they were adjacent" — diagonals included
Playtesting caught the gap with a sticky wand: a target visible
through the opening but one square off the corridor axis was refused.
The colinear-only model was stricter than the rulebook, whose actual
instruction is to treat the connected edges as adjacent boards with a
straight center-to-center line — and a straight line through a
one-cell opening may run diagonally, exactly as through a doorway.

hasWarpLineOfSight now abuts the far side virtually at the mouth
(rotating when the pairing turns a corner, as aisle warps do), finds
where the center-to-center line crosses the rim, requires that
crossing to fall strictly within the open mouth (corner-grazing stays
blocked — the strict reading), and checks the two legs against real
walls on each side of the seam. hasLineOfSight's core became a shared
segmentClear to serve both legs. The colinear corridor cases remain
as the special case they always were; a new test pins that off-axis
squares are visible through mouths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:20:46 -04:00
Eric WagonerandClaude Fable 5 53edf9734e Uncharged wands say what they need
"Opportunity Fire isn't letting me use a wand" — the window and the
wand both worked (verified headless); what refused him was the wand's
own first-use rule, which players from wandless editions have never
met: charges are set by a NUMBER card on first use. Selecting an
uncharged wand now says exactly that in the hint strip, before the
engine has to say no.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:14:15 -04:00
Eric WagonerandClaude Fable 5 cef383f352 Decorative board shapes stop eating clicks
The home-base star polygon had no pointer-events rule, so clicking
the middle of a home square hit the star instead of the floor — you
had to aim around its points to move or target there. The star and
every other decorative vector (terrain fallbacks, treasure circles,
dim warp rings) are now click-transparent; the floor rect beneath
owns every tap, as the token-art images always did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:07:23 -04:00
Eric WagonerandClaude Fable 5 bc0c69de11 The designer's rulings, one tap from the card
Tom Jolly's 2002 Rules FAQ settled three table disputes this week
from a text file only I could read. Now it lives in the game: 95
cards gained their FAQ sections (extracted verbatim from the official
document, alias-matched — Slime, Mistbody, Buck and friends resolved
to their proper cards), joining the handful already present for 102
cards with rulings in all. The card faces stay pure card text; an
"Official FAQ" button appears under any enlarged card that has
rulings — the hand inspector, the peek overlay, and as an FAQ chip in
the card library — opening a booklet modal with the rulings and their
provenance: "the cards overrule the rules, and the designer overrules
the table."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 15:05:27 -04:00
Eric WagonerandClaude Fable 5 6de4254aad Card text scales to fit — every word on the face
Long card texts were clamped at nine lines and cut off mid-rule,
which is how the Speed argument started: the deciding sentence was
in the hidden part. The type now sizes itself to the text length in
six steps (0.6rem down to 0.375rem for the 550-char Firefly Stick),
and the line clamp is gone. Verified against Fear, the longest card
in play at 482 characters: every word on the face.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:58:14 -04:00
Eric WagonerandClaude Fable 5 de446c2ad5 Counteraction answers follow the FAQ: absorb reflections, not shields
"It's not letting me cast Absorb Spell on your Full Shield" — and
Jolly's FAQ says exactly why: "ABSORB SPELL will work on FULL
REFLECTION or REFLECTION, but not FULL SHIELD, as that is not used
against you, but cast upon the player using it." The refusal stands,
now with that reason in the error — and the neighboring case the
engine wrongly refused is fixed: an attacker may answer a reflection
with ABSORB SPELL, nullifying it and taking the reflection card into
their hand, with the original spell then landing unturned.

And the card face corrects my own hours-old work: ANTI-ANTI "does not
work against escape, such as SHRINK, TELEPORT, or INVISIBLE" — so
under rules rev 6 it can no longer pin a teleport escape. Earlier
games keep their stored chains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:51:14 -04:00
Eric WagonerandClaude Fable 5 31a727d02c Refusals persist in the chronicle, not just the fading toast
"When I cast Speed, it doesn't seem to do anything" — the engine had
refused correctly (actions end when you pick up an object) and said
so in a toast, but a toast fades in five seconds and leaves no trace.
Refusals now also land in the chronicle as "— reason —" lines, the
way hotseat games always recorded them, so the answer to "why did
nothing happen" survives long enough to be read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:37:21 -04:00
Eric WagonerandClaude Fable 5 df52e9b53a Table talk: chat woven into the chronicle
A say-box under the chronicle sends table talk to the room; messages
land in the event log itself, styled as parchment asides — banter
between the battle lines, the way it happens at a real table. Talk is
seat-authenticated, stripped and capped at 300 chars, rate-limited by
the existing bucket, persisted as chat lines in the room file (the
game replay ignores them; the room keeps its last 500), and replayed
to anyone joining. Async games get unread badges in the lobby ledger
(💬3), cleared by watching the table, and catch-up replays speak the
lines at the step where they were said. Public to the whole room, no
whispers — and no chat in hotseat, where the table talks for itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:24:22 -04:00
Eric WagonerandClaude Fable 5 ed9585aa59 Five fidelity gaps closed: teleport escapes, slime traps, the Big Man
moves like a giant, and the boards are diagram-verified

TELEPORT as a counteraction (official FAQ: "the attack has no chance
of hitting you"): the defender names an escape square within four
spaces, the escape resolves before anything lands, and an ANTI-ANTI
pins their boots to the floor. Additive — no revision gate needed.

FILL SQUARE WITH SLIME holds spells now: an attack cast at the slime
sticks in the gel (leaving the discard pile), springs once at whoever
is inside or next enters, and counteractions against the trapped
blast cannot touch its caster — reflections vanish into the ooze. A
five-point waterbolt washes the slime and its cargo away, and the
waterwall waves clear slime from their path.

BIG MAN, under rules rev 5, finally moves like the card says: he
pushes players and monsters down the corridor ahead of him (stuck or
unpushable occupants block his advance), steps over a pit, tacks, or
killer ooze for two movement points without ever entering the square
(click two cells beyond the hazard), and monsters may not enter his
square. All gated so stored games replay under their own rules.

The boards were never wrong — the rulebook's Set-Up Diagram photo
confirms every pairing the code already had: 2p crossed, 3p stair
with the Aisle Warp arc, 4p/6p straight-across, 5p plus with all four
corner arcs. The stale TODOs are gone, replaced by tests pinning each
letter pair, and relocation's "only opposite board edges connect"
(which discards the aisle warp) is pinned too. Wall of Fire vs
Waterbolt turned out to be implemented and tested all along — its
TODO comment was the only thing wrong.

Also: hotseat saves now request durable storage (iOS evicts
unprotected origins under pressure).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:17:37 -04:00
Eric WagonerandClaude Fable 5 28bc91c3ac Room codes also dodge orphaned files on disk
makeRoomCode checked only the in-memory map, which covers every live
and restored room — but a file that failed to restore stays on disk
with no map entry, and a new room taking its code would append a
second game into the orphaned file, corrupting both. The generator
now rerolls on disk presence too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 14:02:04 -04:00
Eric WagonerandClaude Fable 5 bed29c4992 Cache headers end the stale-bundle era; finished games rest their buttons
The static server sent no Cache-Control at all, so browsers
heuristically cached index.html — and a stale index.html pins its
user to last deploy's hashed assets no matter how often they reload.
That is how "watch the whole game" could show someone an app without
the feature minutes after it shipped. Hashed assets now cache forever
(immutable); everything else revalidates.

And per playtesting: a finished game offers no actions — isYourTurn
now requires the playing phase, which retires the pick-up/drop/punch/
discard/end-turn row the moment the trophy drops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:57:10 -04:00
Eric WagonerandClaude Fable 5 cdcd1de71a Watch the whole game from the deal
The seed + command log has been the whole game all along; now it can
be watched. Finished games offer "⟲ Watch the whole game" beside the
face-up hands: the server replays every command from seq 0 through
the same redacted catch-up pipeline (the 200-step window remains a
live-game courtesy; full replays wait for the game to finish), and
the reel plays it titled "The whole tale, from the deal" with new
1×/2×/4× speed controls for the long tellings. Each viewer rewatches
from their own seat — hidden information stays hidden in the retelling
exactly as it was at the table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:38:24 -04:00
Eric WagonerandClaude Fable 5 37c38d5c1a The tally says what "longest game" counts
142 "moves" was really 142 logged commands — steps, spells, passes,
end-turns alike. The line now says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:35:59 -04:00
Eric WagonerandClaude Fable 5 f00ef75fbd The chronicle names creatures, not their ids
"The creature-1 takes 2 damage" — the creature events carried only
internal ids, so the log printed them. creatureAttacked, Touched, and
Damaged now carry the creature's kind, and the lines read like a
table: "The Wraith strikes!", "The Troll swings (rolled 3)!", "The
Skeleton takes 2 damage."

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:30:02 -04:00
Eric WagonerandClaude Fable 5 0ebae03b1c The discard pile lies face-up, and the chronicle names what falls
At the table the discards sit face-up for anyone to leaf through. The
view now carries the pile (deck order stays secret), the "discards N"
count in the rail became a link, and it opens a booklet-styled reader:
every cast-away card newest first, tap any to enlarge. And the
chronicle stops mumbling "discards 1 card(s)" — it names names, so a
wraith's theft reads "bob discards Fireball," exactly what a table
would see as the card hits the pile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:28:55 -04:00
Eric WagonerandClaude Fable 5 d1eace9530 Rules rev 4: a creature's blow can be counteracted
The WRAITH card assumes the window exists — "REFLECTIONs used on the
wraith's touch will damage the wraith" — but creature damage applied
instantly, so BLUNT had nothing to catch, as playtesting found. Under
revision 4 every creature blow against a wizard opens the same
counteraction stack a spell does: the wraith's entry touch, the
democratic monster's claw, and commanded troll/skeleton/shadow
attacks. The blow's damage rides the stack; BLUNT halves it (round
up); reflections work by name against creatures — half back for
REFLECTION, the whole blow for FULL REFLECTION — landing on the
creature, not its controller; FULL SHIELD correctly does nothing
("does not stop any physical attack"); the wraith's card theft is a
secondary effect that lands only if damage does. No aim-miss rolls
against invisible or shrunk defenders — the creature is already in
the square. The attack fanfare shows the creature's own card.

Earlier revisions keep the instant touch so every stored game — and
the live one that found this — replays unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:25:45 -04:00
Eric WagonerandClaude Fable 5 3f3e6d9853 The table survives synthetic spell ids — fixes the blank screen
The Sticky Wand attaches its webs as a sustained effect with the
engine-internal id "sticky-web", which is not a printed card — and
cardDef throws on unknown ids. The scoresheet's new spell chips and
the chronicle's settles-over/wears-off lines both called it on
sustained ids, so the first webbing killed every render: mid-game the
actions bar died, and after a reload the whole table came up blank.
A spellName lookup now names synthetic effects properly ("Sticky
Wand webs") and falls back to the raw id rather than ever throwing,
and the chip peek only opens ids the card library can actually show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:11:06 -04:00
Eric WagonerandClaude Fable 5 45803a2664 Live interrupts announce themselves
Interrupt and Opportunity Fire were fully playable out of turn — tap
the card while another wizard acts — but nothing said so, and cards
are otherwise inert out of turn, so playtesters never tried. Now,
whenever the moment is live (someone else's turn, no attack or chaos
pending, past round one, not hotseat), those cards wear a pulsing
golden glow in your hand and the rail says to tap one. The glow
respects prefers-reduced-motion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:07:22 -04:00
Eric WagonerandClaude Fable 5 b210cffa76 Zero-damage attacks stop lying about being stopped
DROP OBJECT resolved correctly — the treasure fell — but the
chronicle announced "The attack is completely stopped," because the
attackResolved event branded any attack that dealt zero damage as
stopped. Utility attacks deal zero by design; only an attack that was
TRYING to wound counts as stopped when nothing lands. Tests pin both
sides: uncountered DROP OBJECT reports unstopped and drops the
treasure; FULL SHIELD reports stopped and the treasure stays carried.

Not deployed — a live game is in progress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:58:16 -04:00
Eric WagonerandClaude Fable 5 7e3e00bf97 Rules rev 3: Ward is a choice, Chaos honors the shield
WARD's text says "you may play at that time" — under rules revision 3
the choice is made by setting the trap: an armWard command toggles it
on your own turn (your secret; a private wardSet event and a rail
note), and the spring fires only while set, consuming the card and
the arming together. Async games keep their agency without an
interrupt window, exactly as the ambush system solved this before.

CHAOS gains its printed interactions: "FULL SHIELD removes a player
from participation" — a defender's shield sits them out instead of
stopping the spell, and after the counter chain every bystander gets
a shield window in seat order (chaosPending; queue head owes a
response in lobby summaries, the rail says whose hand hangs in the
balance) before the pile forms among the unshielded. "REFLECTIONS
have no effect" — both reflections are refused as counters. ABSORB
SPELL still eats the whole thing through the existing absorb path.

Both changes are frozen behind deckRev 3 so every stored game — and
the live one — replays byte-for-byte under its own rules. Five new
tests cover armed/unarmed wards, shielded defenders and bystanders,
the reflection refusal, and the legacy auto-ward.

Not deployed — a live game is in progress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:55:58 -04:00
28 changed files with 3225 additions and 240 deletions
+1
View File
@@ -5,3 +5,4 @@ dist/
.DS_Store .DS_Store
data/ data/
.claude/ .claude/
.playwright-mcp/
@@ -1 +0,0 @@
[ 241ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8891/favicon.ico:0
@@ -1,4 +0,0 @@
- generic [ref=e2]:
- heading "Wiz-War" [level=1] [ref=e4]
- generic [ref=e5]: A game of magical combat in a stone labyrinth
- generic [ref=e13]: the classic 1993 edition · faithfully reproduced · play free with friends
+17 -16
View File
@@ -179,7 +179,8 @@
"text": "Reduces any damage done to you by 1/2. Round fractions up. Works on point-based or duration-based spells, or physical damage.", "text": "Reduces any damage done to you by 1/2. Round fractions up. Works on point-based or duration-based spells, or physical damage.",
"quantity": 2, "quantity": 2,
"faqRulings": [ "faqRulings": [
"If you play two in a row, they act one after the other, they do not cancel the damage out entirely! Thus, against a 5 point Fireball, the first would reduce damage to 3 points (2.5 rounded up), and the second would reduce damage to 2 points (1.5 rounded up)." "If you play two in a row, they act one after the other, they do not cancel the damage out entirely! Thus, against a 5 point Fireball, the first would reduce damage to 3 points (2.5 rounded up), and the second would reduce damage to 2 points (1.5 rounded up).",
"Book of Spells This cannot hold items or objects, just spells and NUMBER cards. Discard them if you draw objects, redrawing to fill to 5 cards. These may be used in any combination you like with the cards in your hand. REFLECTED spells will blast you, not the BOOK."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -1069,7 +1070,8 @@
"text": "Swap places with any other character during your turn. Counts as your movement. FULL REFLECTION, if used against SWAP, merely nullifies it.", "text": "Swap places with any other character during your turn. Counts as your movement. FULL REFLECTION, if used against SWAP, merely nullifies it.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"You can use a SWAP before TELEPORT, but not after (within the same turn, of course). Since SWAP indicates that it replaces your movement, then if a REUSE SPELL card is used, you cannot use SWAP again in the same turn, as you have already performed your movement for the turn. Aesthetically this doesn't make a lot of sense, and the card shouldn't say that SWAP replaces movement, but rather if you use SWAP, you foresake your movement, which would allow you to use it twice in a turn, as it would not actually be your movement. Swaps tend to disobey the rule about creating objects in occupied spaces, but that's okay, they're supposed to. SWAP MEET conflicts somewhat with the rule that says you can't create an object where there is already an object, since SWAP MEET clearly says that you swap carried objects, meaning you would HAVE to be in the same space as the \"created object\". SWAP MEET, though, is an acception to the rules, and its operation is clearly explained on the card. Swap Meet is also a little ambiguous; you only swap ONE of your objects for ONE of the opponent's, not two for two." "You can use a SWAP before TELEPORT, but not after (within the same turn, of course). Since SWAP indicates that it replaces your movement, then if a REUSE SPELL card is used, you cannot use SWAP again in the same turn, as you have already performed your movement for the turn. Aesthetically this doesn't make a lot of sense, and the card shouldn't say that SWAP replaces movement, but rather if you use SWAP, you foresake your movement, which would allow you to use it twice in a turn, as it would not actually be your movement. Swaps tend to disobey the rule about creating objects in occupied spaces, but that's okay, they're supposed to.",
"SWAP MEET conflicts somewhat with the rule that says you can't create an object where there is already an object, since SWAP MEET clearly says that you swap carried objects, meaning you would HAVE to be in the same space as the \"created object\". SWAP MEET, though, is an acception to the rules, and its operation is clearly explained on the card. Swap Meet is also a little ambiguous; you only swap ONE of your objects for ONE of the opponent's, not two for two."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -1101,8 +1103,7 @@
"text": "This spell lets you move up to four spaces (not diagonally) from where you are, ignoring walls and objects. You may take your normal movement before playing this card, but your movement ends after you play it.", "text": "This spell lets you move up to four spaces (not diagonally) from where you are, ignoring walls and objects. You may take your normal movement before playing this card, but your movement ends after you play it.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"If you use this spell as a Counteraction, the attack has no chance of hitting you. Yes, with a REUSE SPELL, you can TELEPORT twice in a turn. You can teleport only into a vacant space; the 8th edition clarifies this.", "If you use this spell as a Counteraction, the attack has no chance of hitting you. Yes, with a REUSE SPELL, you can TELEPORT twice in a turn. You can teleport only into a vacant space; the 8th edition clarifies this."
"Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -1120,7 +1121,7 @@
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"If REFLECTED, opponent chooses where he wishes to send you. You may finish your normal movement after he moves you (unlike a regular TELEPORT).", "If REFLECTED, opponent chooses where he wishes to send you. You may finish your normal movement after he moves you (unlike a regular TELEPORT).",
"Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card." "Teleport Spells Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -1420,7 +1421,7 @@
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"Yes, you can drop objects in here, even while you are jumping over the PIT. No, contrary to what the card says, you can't create objects in or on or over the PIT, which eliminates all those bizarre questions about creating SOLID STONE over a PIT and having it fall down, or making a WALL sideways over a PIT, and so on. TINY SWAP can't be used to do this either, as this would qualify as creating an object in a space where an object already is, which is against the basic rules. FILL SQUARE WITH STONE cancels a PIT, and any objects inside the PIT at the time appear at the top, on the floor, so nothing gets \"squashed\". It takes a whole turn to climb out of, as well as into, a PIT, and your movement would end adjacent to the PIT. You could still add NUMBER cards to your movement in such a case, though. You can jump diagonally across a PIT if it's at an intersection, but it still takes 2 movement points to get across. A player outside and adjacent to the PIT has LOS into the PIT, but a player in the PIT has no LOS outside of the PIT (except in the very rare instance where someone jumps or flies over and he uses an INTERRUPT to cast an attack...you get the idea). If a PUSH OBJECT is used to shove a treasure in on someone in a PIT, they take 2 points damage just as normal from a PUSHED OBJECT.", "Yes, you can drop objects in here, even while you are jumping over the PIT. No, contrary to what the card says, you can't create objects in or on or over the PIT, which eliminates all those bizarre questions about creating SOLID STONE over a PIT and having it fall down, or making a WALL sideways over a PIT, and so on. TINY SWAP can't be used to do this either, as this would qualify as creating an object in a space where an object already is, which is against the basic rules. FILL SQUARE WITH STONE cancels a PIT, and any objects inside the PIT at the time appear at the top, on the floor, so nothing gets \"squashed\". It takes a whole turn to climb out of, as well as into, a PIT, and your movement would end adjacent to the PIT. You could still add NUMBER cards to your movement in such a case, though. You can jump diagonally across a PIT if it's at an intersection, but it still takes 2 movement points to get across. A player outside and adjacent to the PIT has LOS into the PIT, but a player in the PIT has no LOS outside of the PIT (except in the very rare instance where someone jumps or flies over and he uses an INTERRUPT to cast an attack...you get the idea). If a PUSH OBJECT is used to shove a treasure in on someone in a PIT, they take 2 points damage just as normal from a PUSHED OBJECT.",
"Climbing into a PIT, you must start the turn adjacent to the PIT, and may not perform any other actions while climbing. You could, if you like, just fall in and take the damage. Two or more adjacent PITS would just make one long PIT." "Create Pit, more Climbing into a PIT, you must start the turn adjacent to the PIT, and may not perform any other actions while climbing. You could, if you like, just fall in and take the damage. Two or more adjacent PITS would just make one long PIT."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -1617,7 +1618,6 @@
"text": "Fills an entire square with a cube of lime-flavored gel. Anyone pushing their way through ends their turn upon entering and may not leave until the next turn. Spells cast at the slime get stuck there, and affect anyone in the slime or entering it later on (spells \"go off\" only once). Counteractions against \"trapped\" attacks do not affect the caster of the attack. A WATERWALL or a 5-point WATERBOLT will wash the slime away. There is no L.O.S. through it.", "text": "Fills an entire square with a cube of lime-flavored gel. Anyone pushing their way through ends their turn upon entering and may not leave until the next turn. Spells cast at the slime get stuck there, and affect anyone in the slime or entering it later on (spells \"go off\" only once). Counteractions against \"trapped\" attacks do not affect the caster of the attack. A WATERWALL or a 5-point WATERBOLT will wash the slime away. There is no L.O.S. through it.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"Sure, you can leave items in SLIME, but you can't teleport someone there or create something else inside, like a MONSTER, or leave a creation spell trapped inside. This is per the rule that you can't create an object in a space where there is already an object. HANDFUL OF TACKS would work just fine in SLIME, as it isn't a spell. Only water will damage the SLIME, so other attacks are useless. Spells stuck in SLIME only go off once, the first time someone walks into the SLIME. SLIME is a big cube filling the corridor, and offers no LOS, nor can it be flown over.",
"What works in a SLIME? Some folks have trouble deciding what would go well in a slime. Try a FIREBALL, or LIGHTNINGBLAST, or SLOW DEATH, or SUDDEN DEATH. You get the idea. After a player sets off the spell, it only affects him, later players entering the SLIME are not affected by the spells which have already been set off." "What works in a SLIME? Some folks have trouble deciding what would go well in a slime. Try a FIREBALL, or LIGHTNINGBLAST, or SLOW DEATH, or SUDDEN DEATH. You get the idea. After a player sets off the spell, it only affects him, later players entering the SLIME are not affected by the spells which have already been set off."
], ],
"verification": { "verification": {
@@ -1652,7 +1652,7 @@
"text": "Creates a force field immediately adjacent to you on one side only, preventing an opponent from entering the space you occupy or casting spells on or past you. Lasts only until the end of the opponent's turn.", "text": "Creates a force field immediately adjacent to you on one side only, preventing an opponent from entering the space you occupy or casting spells on or past you. Lasts only until the end of the opponent's turn.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"The Force Field is stationary, it does not move with the player. Cards that represent non-spell items, such as daggers, can fly through the field just fine. Likewise, Wizardblade can slice through the field." "For those of you who don't own this spell, it says;COUNTERACTION. L.O.S. Creates a force field immediately adjacent to you on one side only, preventing an opponent from entering the space you occupy or cating [sic] spells on or past you. Lasts only until the end of the opponent's turn. The Force Field is stationary, it does not move with the player. Cards that represent non-spell items, such as daggers, can fly through the field just fine. Likewise, Wizardblade can slice through the field."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -1736,7 +1736,8 @@
"text": "This spell lets you throw a carried treasure at someone or something (including an empty space). Physical damage equals the number of spaces thrown. The spell is actually cast on yourself, so REFLECTION won't work against it and AROUND THE CORNER won't work with it. If thrown diagonally, measure the distance as the number of spaces a player would move. The attack itself is physical and not magical.", "text": "This spell lets you throw a carried treasure at someone or something (including an empty space). Physical damage equals the number of spaces thrown. The spell is actually cast on yourself, so REFLECTION won't work against it and AROUND THE CORNER won't work with it. If thrown diagonally, measure the distance as the number of spaces a player would move. The attack itself is physical and not magical.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"In the very rare occurence of a passage-loop, where HEAVE-HO would travel forever, we've limited it to a maximum of 10 spaces. AROUND THE CORNER has no effect on HEAVE-HO, as HEAVE-HO is cast on yourself, not on the treasure. In the example below, where the \"x's\" represent Wizards, HEAVE-HO does 3 points of damage, which are counted as spaces as the Wizard walks; _________________________ | | | | | X | | | |_______|_______|_______| | | | | | | | X | |_______|_______|_______|" "In the very rare occurence of a passage-loop, where HEAVE-HO would travel forever, we've limited it to a maximum of 10 spaces. AROUND THE CORNER has no effect on HEAVE-HO, as HEAVE-HO is cast on yourself, not on the treasure. In the example below, where the \"x's\" represent Wizards, HEAVE-HO does 3 points of damage, which are counted as spaces as the Wizard walks;",
"_________________________ | | | | | X | | | |_______|_______|_______| | | | | | | | X | |_______|_______|_______|"
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -1804,7 +1805,8 @@
"text": "Covers the surface of one square of the floor with acidic ooze. Any character or monster entering the square takes 1 point of damage, and on a roll of 1 or 2 falls down, drops treasure, takes 2 more points of physical damage, and is unable to move for the rest of the turn. Any following turns that he tries to stand and leave must be rolled again as stated. Only 5 points of fire damage will destroy the ooze.", "text": "Covers the surface of one square of the floor with acidic ooze. Any character or monster entering the square takes 1 point of damage, and on a roll of 1 or 2 falls down, drops treasure, takes 2 more points of physical damage, and is unable to move for the rest of the turn. Any following turns that he tries to stand and leave must be rolled again as stated. Only 5 points of fire damage will destroy the ooze.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"This is a MONSTER. No, it does not block LOS, just lays in a layer on the floor, and is not moveable. Only fire, and no other type of attack, will hurt the ooze (except MONSTER BANE for 5 points)." "This is a MONSTER. No, it does not block LOS, just lays in a layer on the floor, and is not moveable. Only fire, and no other type of attack, will hurt the ooze (except MONSTER BANE for 5 points).",
"Large Rock/Dagger These are NOT spells (yes, the rules already say that) which \"create\" the objects, they are the objects themselves. Yes, they can be discarded!"
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -2049,7 +2051,7 @@
"text": "Wand covers enemy with webs, reducing movement by 3 (enemy can still use NUMBER cards for additional movement). Lasts one turn. Any fire damage done to player in webs causes two extra points of damage due to webs burning. The wand has a limited number of charges determined by NUMBER card played when first used. Discard the wand when last charge is gone.", "text": "Wand covers enemy with webs, reducing movement by 3 (enemy can still use NUMBER cards for additional movement). Lasts one turn. Any fire damage done to player in webs causes two extra points of damage due to webs burning. The wand has a limited number of charges determined by NUMBER card played when first used. Discard the wand when last charge is gone.",
"quantity": 1, "quantity": 1,
"faqRulings": [ "faqRulings": [
"Webs, if burned, disappear immediately. h Strength If you tear a treasure out of someone else's hands, this does NOT count as picking up an object and does not end your turn. In fact, you can tear a treasure out of somebody's hands and drop it on the floor even while carrying another treasure." "Webs, if burned, disappear immediately. h"
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -2379,8 +2381,7 @@
"text": "For the next NUMBER card worth of turns, the opponent will teleport 1 to 4 spaces in a random direction (roll the die twice). This occurs during your turn. If a BLINK takes opponent to SOLID STONE, reroll distance, otherwise opponent is affected by contents of the destination square. Can be cast on self as a counteraction to an LOS spell (the spell would miss), but no NUMBER card can be played.", "text": "For the next NUMBER card worth of turns, the opponent will teleport 1 to 4 spaces in a random direction (roll the die twice). This occurs during your turn. If a BLINK takes opponent to SOLID STONE, reroll distance, otherwise opponent is affected by contents of the destination square. Can be cast on self as a counteraction to an LOS spell (the spell would miss), but no NUMBER card can be played.",
"quantity": null, "quantity": null,
"faqRulings": [ "faqRulings": [
"See TELEPORT, or TELEPORT SPELLS.", "See TELEPORT, or TELEPORT SPELLS."
"Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -3484,8 +3485,7 @@
"text": "This amulet allows you to teleport a number of spaces equal to a role of the die, but it sucks up your life points at a rate equal to another roll of the die. This damage may not be stopped or counteracted in any way, and this card overrules any other card on this issue, including other magic objects (though this does not prevent healing spells from working). May be used once per turn. Replaces normal movement. Display immediately.", "text": "This amulet allows you to teleport a number of spaces equal to a role of the die, but it sucks up your life points at a rate equal to another roll of the die. This damage may not be stopped or counteracted in any way, and this card overrules any other card on this issue, including other magic objects (though this does not prevent healing spells from working). May be used once per turn. Replaces normal movement. Display immediately.",
"quantity": null, "quantity": null,
"faqRulings": [ "faqRulings": [
"If you attempt to use this and your roll lands you inside solid stone, you do not teleport, but you still lose life-points.", "If you attempt to use this and your roll lands you inside solid stone, you do not teleport, but you still lose life-points."
"Some teleporation spells let you teleport into occupied spaces and some don't. This is usually called out explicitly on each card. They vary because some cards would violate game mechanics if you did them a certain way. Say for example, someone takes control of your Wizard and tries to use Teleport to put you inside a Solid Stone block. It can't happen in the 8th edition, because the card stipulates that you cannot teleport into an occupied space. If a card does NOT call out one or the other, then you can perform the noted teleportation to any space you like, within the limits of the card."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
@@ -3568,7 +3568,8 @@
"text": "This spell lets you swap any two items (uncarried), people, monsters, or immovable created object, or any combination of the same, as long as both are in your LOS! In the case of swapping a person for a created wall, the person could end up in either square adjacent to where the wall was, and vice-versa. Both objects, once swapped, must still be in your LOS afterwards.", "text": "This spell lets you swap any two items (uncarried), people, monsters, or immovable created object, or any combination of the same, as long as both are in your LOS! In the case of swapping a person for a created wall, the person could end up in either square adjacent to where the wall was, and vice-versa. Both objects, once swapped, must still be in your LOS afterwards.",
"quantity": null, "quantity": null,
"faqRulings": [ "faqRulings": [
"Though not intended to be used this way, it is possible to use TINY SWAP to move yourself, since you are in your own LOS. Yes, this is a much more powerful version of TRADER. No, you may not put immovable objects on a player's home base." "Though not intended to be used this way, it is possible to use TINY SWAP to move yourself, since you are in your own LOS. Yes, this is a much more powerful version of TRADER. No, you may not put immovable objects on a player's home base.",
"Torquemada If your opponent has run out of fingers and teeth, then he can probably no longer deal cards anyway, so any player with functional eyes left will have to read all the players' cards to them. Ha! Just kidding. Not a real card (Not yet, anyway)."
], ],
"verification": { "verification": {
"status": "verbatim", "status": "verbatim",
+663
View File
@@ -0,0 +1,663 @@
// The automaton: a clockwork wizard. It plays from its own redacted view —
// the same information a human seat receives — and returns one command per
// consultation; the server keeps consulting until the maze stops asking.
// It never throws: when in doubt it passes, discards, or ends its turn.
//
// Temperaments color its judgment: the HUNTER marches for treasure, the
// BERSERKER for blood, the WORRIER for the shadows between the two.
import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, stepTarget, SIDES, type Cell, type Side } from "./board";
import { sightedCellsFor, type GameView } from "./view";
import type { AmbushTrigger, Command, PlayerId } from "./game";
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
/**
* Difficulty degrades resources and repertoire, never judgment. The
* apprentice draws one card a turn and knows a modest spellbook; the adept
* draws two but keeps no ambushes or amplifies; the archmage knows all.
* Every tier plays its cards correctly — none of them is ever stupid.
*/
export type AutomatonTier = "apprentice" | "adept" | "archmage";
export const AUTOMATON_TIERS: AutomatonTier[] = ["apprentice", "adept", "archmage"];
interface TierTraits {
draw: number;
/** Added to every counteraction threshold: thrift, not blindness. */
counterThrift: number;
afflictions: boolean;
amplify: boolean;
ambush: boolean;
guardGold: boolean;
buffs: boolean;
dejaVu: boolean;
}
const TIERS: Record<AutomatonTier, TierTraits> = {
apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false },
adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true },
archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true },
};
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
fireball: { base: 5, perNumber: false },
"sudden-death": { base: 10, perNumber: false },
powerthrust: { base: 2, perNumber: true },
waterbolt: { base: 0, perNumber: true, needsNumber: true },
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
wizardblade: { base: 0, perNumber: true, needsNumber: true },
"power-drain": { base: 0, perNumber: true, needsNumber: true },
dagger: { base: 3, perNumber: false },
"large-rock": { base: 2, perNumber: false },
"blaster-wand": { base: 3, perNumber: false },
disease: { base: 3, perNumber: false, sameSquare: true },
};
/**
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
* number for the duration where one helps.
*/
const AFFLICTIONS: Record<string, { withNumber: boolean; vsCarrier?: boolean }> = {
blind: { withNumber: true },
slow: { withNumber: true },
"no-spell": { withNumber: true },
medusa: { withNumber: true },
"lock-in-place": { withNumber: true },
"walking-dead": { withNumber: false },
"slow-death": { withNumber: false },
idiot: { withNumber: false },
weakness: { withNumber: false, vsCarrier: true },
"go-away": { withNumber: true },
"thought-steal": { withNumber: false },
};
/** Every stone earns its place on the table the moment it is drawn. */
const STONES = new Set([
"bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone",
]);
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]);
const UNLOCKS = ["master-key", "pick-lock", "remove-lock"];
function me(view: GameView) {
return view.players.find((p) => p.id === view.you)!;
}
function livingEnemies(view: GameView) {
return view.players.filter((p) => p.alive && p.id !== view.you);
}
function inHand(view: GameView, cardId: string): CardInstance | undefined {
return view.yourHand.find((c) => c.cardId === cardId);
}
function numbersInHand(view: GameView): CardInstance[] {
return view.yourHand
.filter((c) => cardDef(c.cardId).cardType === "number")
.sort((a, b) => (cardDef(a.cardId).value ?? 0) - (cardDef(b.cardId).value ?? 0));
}
/** Cards the automaton happily discards to churn its hand, worst first. */
function discardValue(c: CardInstance): number {
const def = cardDef(c.cardId);
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8;
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null) return 7;
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" ||
c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" ||
c.cardId === "glue") return 6;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
}
interface PathResult {
dir: Side;
distance: number;
/** A locked door stands on the first step of this path. */
doorAhead?: { cell: Cell; side: Side };
}
/**
* BFS over walkable steps toward the nearest goal. Doors count as passable
* when the clockwork can unlock them; the first such door is reported so the
* key gets used before the boot.
*/
function pathToward(
view: GameView,
from: Cell,
goals: Set<string>,
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean } = {},
): PathResult | null {
if (goals.size === 0 || goals.has(cellKey(from))) return null;
const enemyCells = livingEnemies(view).map((p) => p.position);
const nearEnemy = (c: Cell) =>
enemyCells.some((e) => Math.abs(e.x - c.x) + Math.abs(e.y - c.y) <= 2);
const cameBy = new Map<string, { prev: string; dir: Side; viaDoor: boolean }>();
const seen = new Set<string>([cellKey(from)]);
let frontier: Cell[] = [from];
let found: string | null = null;
let depth = 0;
for (; depth < 60 && frontier.length > 0 && !found; depth++) {
const next: Cell[] = [];
for (const c of frontier) {
for (const dir of SIDES) {
const t = stepTarget(view.board, c, dir);
let to: Cell;
let viaDoor = false;
if (t.kind === "blocked") {
if (t.by !== "door") continue;
const k = edgeKey(c, dir);
const alreadyOpen =
view.openDoorEdges.includes(k) || view.doorStates[k] === "removed";
if (!alreadyOpen && !opts.canUnlock) continue;
const n = { x: c.x + (dir === "E" ? 1 : dir === "W" ? -1 : 0),
y: c.y + (dir === "S" ? 1 : dir === "N" ? -1 : 0) };
if (!view.board.cells[cellKey(n)]) continue;
to = n;
viaDoor = !alreadyOpen;
} else {
to = t.to;
}
const k = cellKey(to);
if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
seen.add(k);
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
if (goals.has(k)) { found = k; break; }
const hazard = view.squareContents[k]?.kind;
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime") continue;
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
next.push(to);
}
if (found) break;
}
frontier = next;
}
if (!found) return null;
let cursor = found;
for (;;) {
const hop = cameBy.get(cursor)!;
if (hop.prev === cellKey(from)) {
return {
dir: hop.dir,
distance: depth,
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
};
}
cursor = hop.prev;
}
}
/** The treasure squares worth marching for. */
function treasureGoals(view: GameView): Set<string> {
const self = me(view);
const goals = new Set<string>();
if (self.carriedTreasureId) {
goals.add(cellKey(self.home));
return goals;
}
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
if (t.owner === view.you) continue;
if (cellKey(t.position) === cellKey(self.home)) continue;
goals.add(cellKey(t.position));
}
return goals;
}
/** The wizard making off with MY gold, if any. */
function thiefOfMine(view: GameView) {
const carriers = new Set(
view.treasures.filter((t) => t.owner === view.you && t.carriedBy && t.carriedBy !== view.you)
.map((t) => t.carriedBy!),
);
return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null;
}
function overLimit(view: GameView): number {
return Math.max(0, view.yourHand.length - 7);
}
function worstCards(view: GameView, n: number): string[] {
return [...view.yourHand]
.sort((a, b) => discardValue(a) - discardValue(b))
.slice(0, n)
.map((c) => c.instanceId);
}
/** A square up to four spaces away that maximizes distance from enemies. */
function escapeCell(view: GameView, from: Cell): Cell | null {
const enemies = livingEnemies(view).map((p) => p.position);
let best: { cell: Cell; score: number } | null = null;
for (const k of Object.keys(view.board.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
const d = Math.abs(x - from.x) + Math.abs(y - from.y);
if (d === 0 || d > 4) continue;
if (view.squareContents[k]) continue;
const score = Math.min(...enemies.map((e) => Math.abs(e.x - x) + Math.abs(e.y - y)), 99);
if (!best || score > best.score) best = { cell: { x, y }, score };
}
return best?.cell ?? null;
}
/** Counteraction judgment; the worrier flinches at less. */
function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Command {
const stack = view.stack!;
const you = view.you;
const find = (id: string) => view.yourHand.find((c) => c.cardId === id);
if (stack.defenderId !== you) {
// Pressing the attack: ANTI-ANTI burns the counter that would stop us
// (the engine refuses it against escapes; the fallback pass covers that).
if (stack.attackerId === you) {
const last = [...stack.counters].reverse().find((c) => !c.nullified);
const anti = find("anti-anti");
if (anti && last && last.card.cardId !== "teleport") {
return { type: "counteract", instanceId: anti.instanceId };
}
}
return { type: "pass" };
}
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
const attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
const incoming = stack.attackCard
? (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1;
if (stack.kind === "spell") {
// REVERSE turns the biggest blasts into a meal.
const reverse = find("reverse");
if (reverse && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
// ABSORB SPELL steals the good ones for later.
const absorbSpell = find("absorb-spell");
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
const shield = find("full-shield");
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
return { type: "counteract", instanceId: shield.instanceId };
}
const reflect = find("full-reflection");
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
// A curse in flight is best refused at the door.
if (affliction) {
const cleanse = find("remove-curse");
if (cleanse) return { type: "counteract", instanceId: cleanse.instanceId };
}
const half = find("reflection");
if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId };
}
const absorb = find("absorb");
if (absorb && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
const blunt = find("blunt");
if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
// The berserker shares its pain out of spite.
const empathy = find("empathy");
if (empathy && style === "berserker" && incoming >= 2) {
return { type: "counteract", instanceId: empathy.instanceId };
}
// Nothing to block with: teleport clear of the big ones.
const tp = find("teleport");
if (tp && incoming >= 4 - flinch) {
const out = escapeCell(view, me(view).position);
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
}
// A displayed shieldstone lets numbers soak the small ones.
const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone");
if (stoneShown && incoming >= 2) {
const num = numbersInHand(view)[0];
if (num) return { type: "counteract", instanceId: num.instanceId };
}
return { type: "pass" };
}
/** The best attack available against a visible target, numbers and amplify included. */
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): Command | null {
const numbers = numbersInHand(view);
const biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
let best: { cmd: Command; damage: number } | null = null;
for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId];
if (!atk) continue;
if (atk.sameSquare && !together) continue;
// A wand needs its charges set by a number on first use.
const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if ((uncharged || atk.needsNumber) && !biggest) continue;
const withNumber = (atk.perNumber || uncharged) && biggest;
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
if (damage <= 0) continue;
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
const amplified = amplify && damage >= 4 && !isWand &&
c.cardId !== "dagger" && c.cardId !== "large-rock";
if (amplified) damage *= 2;
if (!best || damage > best.damage) {
best = {
damage,
cmd: {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
},
};
}
}
return best?.cmd ?? null;
}
/** An affliction worth casting when no damage lands, mid number attached. */
function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null {
const numbers = numbersInHand(view);
const mid = numbers[Math.floor(numbers.length / 2)];
for (const c of view.yourHand) {
const aff = AFFLICTIONS[c.cardId];
if (!aff) continue;
if (aff.vsCarrier && !isThief) continue;
if (aff.withNumber && !mid) continue;
return {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}),
};
}
return null;
}
/** An empty, sighted square near the target for a summoned creature. */
function summonSpot(view: GameView, near: Cell): Cell | null {
const sighted = sightedCellsFor(view);
let best: { cell: Cell; d: number } | null = null;
for (const k of sighted) {
if (view.squareContents[k]) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.creatures.some((c) => c.position.x === x && c.position.y === y)) continue;
if (view.board.homes.some((h) => h.x === x && h.y === y)) continue;
const d = Math.abs(x - near.x) + Math.abs(y - near.y);
if (!best || d < best.d) best = { cell: { x, y }, d };
}
return best?.cell ?? null;
}
/** Self-buffs and housekeeping worth a neutral cast this turn. */
function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Command | null {
const self = me(view);
const numbers = numbersInHand(view);
const mid = numbers[Math.floor(numbers.length / 2)];
// Extra turns are always good turns.
const speed = inHand(view, "speed");
if (speed && view.turn.round > 1 && !view.turn.actionsEnded) {
return { type: "cast", instanceId: speed.instanceId };
}
// Free life is free.
const gift = inHand(view, "gift-from-above");
if (gift) return { type: "cast", instanceId: gift.instanceId };
// Every stone goes on the table the turn it is drawn.
for (const c of view.yourHand) {
if (STONES.has(c.cardId) && !self.displayed.some((d) => d.instanceId === c.instanceId)) {
return { type: "cast", instanceId: c.instanceId };
}
}
if (!tier.buffs) return null; // the apprentice's book ends at the stones
// A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some(
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
);
const cleanse = inHand(view, "remove-curse");
if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId };
// Three or more wizards: LIFESAVER takes elimination off the table.
const lifesaver = inHand(view, "lifesaver");
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
// The ward guards the gold while its owner is away robbing yours.
if (inHand(view, "ward") && !view.yourWardArmed) {
return { type: "armWard", armed: true };
}
const enemyNear = livingEnemies(view).some(
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
);
// The berserker grows; the worrier fades.
if (style === "berserker" && enemyNear && mid) {
const big = inHand(view, "big-man");
if (big) {
return { type: "cast", instanceId: big.instanceId, numberInstanceIds: [mid.instanceId] };
}
}
if (style === "worrier" && enemyNear && mid) {
for (const id of ["invisible", "mist-body", "shrink"]) {
const buff = inHand(view, id);
if (buff) {
return { type: "cast", instanceId: buff.instanceId, numberInstanceIds: [mid.instanceId] };
}
}
// Or slam a wall in the pursuer's face.
const wall = inHand(view, "create-wall");
if (wall) {
const foe = livingEnemies(view).sort((a, b) =>
(Math.abs(a.position.x - self.position.x) + Math.abs(a.position.y - self.position.y)) -
(Math.abs(b.position.x - self.position.x) + Math.abs(b.position.y - self.position.y)))[0]!;
const dx = foe.position.x - self.position.x;
const dy = foe.position.y - self.position.y;
const side: Side = Math.abs(dx) >= Math.abs(dy) && dx !== 0
? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
if ((view.board.edges[edgeKey(self.position, side)] ?? "open") === "open") {
return { type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: self.position, side } };
}
}
}
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
const myFloorTreasure = tier.guardGold
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
: undefined;
if (myFloorTreasure && enemyNear) {
const safe = inHand(view, "safe");
if (safe) {
return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } };
}
const glue = inHand(view, "glue");
const midN = numbers[Math.floor(numbers.length / 2)];
if (glue && midN) {
return {
type: "cast", instanceId: glue.instanceId,
target: { kind: "cell", cell: myFloorTreasure.position! },
numberInstanceIds: [midN.instanceId],
};
}
}
// Empty of violence: DEJA-VU pulls the best attack back from the pile.
const dv = tier.dejaVu ? inHand(view, "deja-vu") : undefined;
if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) {
const buried = [...view.discardPile].reverse().find(
(c) => ATTACKS[c.cardId] != null && c.cardId !== "blaster-wand" && !ATTACKS[c.cardId]!.needsNumber,
);
if (buried) {
return { type: "cast", instanceId: dv.instanceId, params: { cardId: buried.cardId } };
}
}
// An ambush costs nothing to hold and everything to walk into.
const via = tier.ambush ? (inHand(view, "interrupt") ?? inHand(view, "opportunity-fire")) : undefined;
const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
c.cardId !== "blaster-wand");
if (via && spell && view.yourAmbushes.length === 0) {
const trigger: AmbushTrigger = style === "worrier" ? { kind: "near" } : { kind: "treasure" };
return { type: "setAmbush", instanceId: via.instanceId, trigger, spellInstanceId: spell.instanceId };
}
return null;
}
/**
* One decision from the automaton's seat, or null when the maze is not
* asking it anything.
*/
export function automatonCommand(
view: GameView,
style: AutomatonStyle = "hunter",
tierName: AutomatonTier = "archmage",
): Command | null {
const you = view.you as PlayerId;
const tier = TIERS[tierName] ?? TIERS.archmage;
if (view.phase !== "playing") return null;
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
}
if (view.chaosPending && view.chaosPending.queue[0] === you) {
const shield = inHand(view, "full-shield");
return style === "worrier" && shield
? { type: "counteract", instanceId: shield.instanceId }
: { type: "pass" };
}
if (view.stack) {
return view.stack.waitingOn === you ? respond(view, style, tier) : null;
}
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
if (view.activePlayerId !== you) return null;
const self = me(view);
const here = cellKey(self.position);
if (view.turn.actionsEnded) return { type: "endTurn", draw: tier.draw };
// Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) {
const prize = view.treasures.find(
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here &&
here !== cellKey(self.home),
);
if (prize) return { type: "pickUpTreasure" };
}
// Wounded clockwork teleports clear of visible hunters.
if (self.life <= 5) {
const sighted = sightedCellsFor(view);
const hunted = livingEnemies(view).some((p) => sighted.has(cellKey(p.position)));
const tp = inHand(view, "teleport");
if (hunted && tp) {
const out = escapeCell(view, self.position);
if (out) return { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: out } };
}
}
const care = selfCare(view, style, tier);
if (care) return care;
// Command the menagerie: creatures march and maul before the wizard moves.
for (const c of view.creatures) {
if (c.controllerId !== you || c.justCreated) continue;
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
if (enemyHere && !c.attackUsed &&
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
}
if (c.movementUsed < c.movesPerTurn) {
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
const hunt = pathToward(view, c.position, enemyCells, {});
if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir };
}
}
const thief = thiefOfMine(view);
// One attack per turn: the thief of my gold dies first, then the weakest.
if (!view.turn.attackUsed && view.turn.round > 1) {
const sighted = sightedCellsFor(view);
const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position)));
if (visible.length > 0) {
const target = thief && visible.some((p) => p.id === thief.id)
? thief
: visible.sort((a, b) => a.life - b.life)[0]!;
// DROP OBJECT shakes my treasure out of the thief's hands.
if (thief && target.id === thief.id) {
const dob = inHand(view, "drop-object");
if (dob) {
return {
type: "cast", instanceId: dob.instanceId,
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" },
};
}
}
const spell = bestAttack(view, target.id, tier);
if (spell) return spell;
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
if (misery) return misery;
if (cellKey(target.position) === here && style !== "worrier") {
return { type: "punch", targetId: target.id };
}
}
// No shot at a wizard: raise a creature to do the walking.
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
if (summon && livingEnemies(view).length > 0) {
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
const spot = summonSpot(view, near);
if (spot) {
return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } };
}
}
}
// March. The thief-chase outranks everything; the berserker hunts wizards
// over gold; the worrier keeps its distance; the hunter goes to the gold.
if (view.turn.movementUsed < view.turn.movementAllowance) {
const gold = treasureGoals(view);
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
const canUnlock = UNLOCKS.some((id) => inHand(view, id));
const objectives = thief
? new Set([cellKey(thief.position)])
: style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
const path =
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ??
pathToward(view, self.position, enemyCells, { canUnlock });
if (path) {
// A locked door on the very next step: use the key first.
if (path.doorAhead) {
for (const id of UNLOCKS) {
const key = inHand(view, id);
if (key) {
return {
type: "cast", instanceId: key.instanceId,
target: { kind: "edge", cell: path.doorAhead.cell, side: path.doorAhead.side },
};
}
}
} else {
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) {
const numbers = numbersInHand(view);
const helper = numbers.find(
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
) ?? (path.distance > movesLeft + 2 ? numbers[numbers.length - 1] : undefined);
if (helper) {
return { type: "playNumberForMovement", instanceId: helper.instanceId };
}
}
return { type: "move", direction: path.dir };
}
}
}
return { type: "endTurn", draw: tier.draw };
}
/** The safe fallback when the automaton's choice was refused. */
export function automatonFallback(view: GameView): Command {
const you = view.you as PlayerId;
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
}
if (view.stack || view.chaosPending || view.outOfTurnWindow) return { type: "pass" };
return { type: "endTurn", draw: 2 };
}
+83 -33
View File
@@ -27,6 +27,8 @@ export interface Warp {
from: { cell: Cell; side: Side }; from: { cell: Cell; side: Side };
/** ...you arrive at this cell (entering through `side`). */ /** ...you arrive at this cell (entering through `side`). */
to: { cell: Cell; side: Side }; to: { cell: Cell; side: Side };
/** An aisle (AUTO) warp joins two edges that meet at a corner. */
aisle?: boolean;
} }
export interface AssembledBoard { export interface AssembledBoard {
@@ -119,7 +121,7 @@ export interface AssemblyOptions {
* openings pair straight across the map — correct for the 4p square; the * openings pair straight across the map — correct for the 4p square; the
* standard 2p column crosses its side openings and must pass pairs. * standard 2p column crosses its side openings and must pass pairs.
*/ */
warpPairs?: [OpeningRef, OpeningRef][]; warpPairs?: ([OpeningRef, OpeningRef] | [OpeningRef, OpeningRef, "aisle"])[];
} }
/** /**
@@ -190,7 +192,7 @@ export function assembleBoard(
// Wraparound warps. An opening that faces another sector is a corridor, not // Wraparound warps. An opening that faces another sector is a corridor, not
// a warp. The remaining outer-boundary openings pair per options.warpPairs // a warp. The remaining outer-boundary openings pair per options.warpPairs
// (from the setup diagram's letters) or, by default, straight across the // (from the setup diagram's letters) or, by default, straight across the
// map. (L-shaped 3p aisle-warp corners are TODO.) // map — including the aisle-warp corner arcs, which are just warp pairs.
const warps: Warp[] = []; const warps: Warp[] = [];
const inMap = (c: Cell) => cells[cellKey(c)] === true; const inMap = (c: Cell) => cells[cellKey(c)] === true;
const openingCell = (sectorIndex: number, side: Side): Cell => { const openingCell = (sectorIndex: number, side: Side): Cell => {
@@ -204,11 +206,12 @@ export function assembleBoard(
}; };
if (options.warpPairs) { if (options.warpPairs) {
for (const [a, b] of options.warpPairs) { for (const [a, b, kind] of options.warpPairs) {
const ca = openingCell(a.sector, a.side); const ca = openingCell(a.sector, a.side);
const cb = openingCell(b.sector, b.side); const cb = openingCell(b.sector, b.side);
warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side } }); const aisle = kind === "aisle" ? { aisle: true as const } : {};
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side } }); warps.push({ from: { cell: ca, side: a.side }, to: { cell: cb, side: b.side }, ...aisle });
warps.push({ from: { cell: cb, side: b.side }, to: { cell: ca, side: a.side }, ...aisle });
} }
} else { } else {
for (let i = 0; i < placements.length; i++) { for (let i = 0; i < placements.length; i++) {
@@ -268,14 +271,23 @@ export function hasLineOfSight(
blockedCells?: Record<string, true>, blockedCells?: Record<string, true>,
): boolean { ): boolean {
if (cellKey(from) === cellKey(to)) return true; if (cellKey(from) === cellKey(to)) return true;
// Centers of cells: (x + 0.5, y + 0.5). return segmentClear(
const x0 = from.x + 0.5, y0 = from.y + 0.5; board, from.x + 0.5, from.y + 0.5, to.x + 0.5, to.y + 0.5,
const x1 = to.x + 0.5, y1 = to.y + 0.5; blockedCells, [cellKey(from), cellKey(to)],
);
}
/** Is this raw sight segment unobstructed by walls or solid cells? */
function segmentClear(
board: AssembledBoard,
x0: number, y0: number, x1: number, y1: number,
blockedCells: Record<string, true> | undefined,
skipCells: string[],
): boolean {
if (blockedCells) { if (blockedCells) {
for (const key of Object.keys(blockedCells)) { for (const key of Object.keys(blockedCells)) {
if (skipCells.includes(key)) continue;
const [bx, by] = key.split(",").map(Number) as [number, number]; const [bx, by] = key.split(",").map(Number) as [number, number];
if ((bx === from.x && by === from.y) || (bx === to.x && by === to.y)) continue;
// The sight line is blocked if it crosses any side of the solid cell. // The sight line is blocked if it crosses any side of the solid cell.
const sides: [number, number, number, number][] = [ const sides: [number, number, number, number][] = [
[bx, by, bx + 1, by], [bx, by, bx + 1, by],
@@ -334,12 +346,16 @@ function onSegment(ax: number, ay: number, bx: number, by: number, px: number, p
} }
/** /**
* Straight-corridor sight through a wraparound opening. Rulebook: "If * Sight through a wraparound opening — the boards "have open sides", and
* casting a spell, or checking line of sight through the AUTO WARP, treat * the AUTO WARP rule shows how to trace sight across a join: "treat it as
* it as a straight line, and the two connected boards as though they were * a straight line, and the two connected boards as though they were
* adjacent" — and the lettered openings reconnect board edges the same way * adjacent". The texts never deny the lettered openings what they grant
* ("you will reenter at point A"). The line must run down the corridor, out * the aisle (only DIMENSIONAL WARP's tokens carry an explicit "no L.O.S."
* one mouth, and straight in through the paired mouth: no diagonals. * — and those are not board warps). So the far side is
* virtually abutted at the mouth (rotated if the pairing turns a corner,
* as the aisle warps do), and the center-to-center line must pass through
* the one-cell opening: diagonals through the gap are as legal as they are
* through any doorway.
*/ */
export function hasWarpLineOfSight( export function hasWarpLineOfSight(
board: AssembledBoard, board: AssembledBoard,
@@ -347,31 +363,65 @@ export function hasWarpLineOfSight(
to: Cell, to: Cell,
blockedCells?: Record<string, true>, blockedCells?: Record<string, true>,
): boolean { ): boolean {
const DIR: Record<Side, { x: number; y: number }> = {
N: { x: 0, y: -1 }, S: { x: 0, y: 1 }, E: { x: 1, y: 0 }, W: { x: -1, y: 0 },
};
const EPS = 1e-9;
for (const w of board.warps) { for (const w of board.warps) {
const mouthA = w.from.cell; const mouthA = w.from.cell;
const out = w.from.side; const sideA = w.from.side;
const mouthB = w.to.cell; const mouthB = w.to.cell;
const inward = opposite(w.to.side); const inward = opposite(w.to.side);
// The viewer stands in mouth A's corridor, on the interior side. // A filled mouth chokes the tunnel unless the viewer/target IS the mouth.
const fromAligned = out === "N" || out === "S" ? from.x === mouthA.x : from.y === mouthA.y;
if (!fromAligned) continue;
const fromInterior =
out === "N" ? from.y >= mouthA.y : out === "S" ? from.y <= mouthA.y :
out === "E" ? from.x <= mouthA.x : from.x >= mouthA.x;
if (!fromInterior) continue;
// The target sits in mouth B's corridor, from the mouth inward.
const toAligned = inward === "N" || inward === "S" ? to.x === mouthB.x : to.y === mouthB.y;
if (!toAligned) continue;
const toInterior =
inward === "N" ? to.y <= mouthB.y : inward === "S" ? to.y >= mouthB.y :
inward === "E" ? to.x >= mouthB.x : to.x <= mouthB.x;
if (!toInterior) continue;
// A filled mouth blocks the tunnel unless the viewer/target IS the mouth.
if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue; if (blockedCells?.[cellKey(mouthA)] && cellKey(from) !== cellKey(mouthA)) continue;
if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue; if (blockedCells?.[cellKey(mouthB)] && cellKey(to) !== cellKey(mouthB)) continue;
// Rotation taking the far board's inward direction onto sideA.
const u = DIR[inward], v = DIR[sideA];
const cos = u.x * v.x + u.y * v.y;
const sin = u.x * v.y - u.y * v.x;
const centerB = { x: mouthB.x + 0.5, y: mouthB.y + 0.5 };
const nA = neighbor(mouthA, sideA);
const anchor = { x: nA.x + 0.5, y: nA.y + 0.5 };
const T = (p: { x: number; y: number }) => ({
x: cos * (p.x - centerB.x) - sin * (p.y - centerB.y) + anchor.x,
y: sin * (p.x - centerB.x) + cos * (p.y - centerB.y) + anchor.y,
});
const Tinv = (p: { x: number; y: number }) => ({
x: cos * (p.x - anchor.x) + sin * (p.y - anchor.y) + centerB.x,
y: -sin * (p.x - anchor.x) + cos * (p.y - anchor.y) + centerB.y,
});
const c0 = { x: from.x + 0.5, y: from.y + 0.5 };
const c1 = T({ x: to.x + 0.5, y: to.y + 0.5 });
// The line must cross the rim through the open mouth of A.
let t: number, off: number, P: { x: number; y: number };
if (sideA === "E" || sideA === "W") {
const rimX = sideA === "E" ? mouthA.x + 1 : mouthA.x;
if (Math.abs(c1.x - c0.x) < EPS) continue;
t = (rimX - c0.x) / (c1.x - c0.x);
const y = c0.y + t * (c1.y - c0.y);
off = y - mouthA.y;
P = { x: rimX, y };
} else {
const rimY = sideA === "S" ? mouthA.y + 1 : mouthA.y;
if (Math.abs(c1.y - c0.y) < EPS) continue;
t = (rimY - c0.y) / (c1.y - c0.y);
const x = c0.x + t * (c1.x - c0.x);
off = x - mouthA.x;
P = { x, y: rimY };
}
if (t <= EPS || t >= 1 - EPS) continue;
// Grazing the mouth's corners counts as blocked — strict reading.
if (off <= EPS || off >= 1 - EPS) continue;
const Pfar = Tinv(P);
if ( if (
hasLineOfSight(board, from, mouthA, blockedCells) && segmentClear(board, c0.x, c0.y, P.x, P.y, blockedCells,
hasLineOfSight(board, mouthB, to, blockedCells) [cellKey(from), cellKey(mouthA)]) &&
segmentClear(board, Pfar.x, Pfar.y, to.x + 0.5, to.y + 0.5, blockedCells,
[cellKey(to), cellKey(mouthB)])
) { ) {
return true; return true;
} }
+536 -78
View File
@@ -178,8 +178,16 @@ export interface CastStack {
powerAttackPoints: number; powerAttackPoints: number;
params: CastParams | null; params: CastParams | null;
kind: "spell" | "physical"; kind: "spell" | "physical";
counters: { player: PlayerId; card: CardInstance; nullified: boolean }[]; counters: { player: PlayerId; card: CardInstance; nullified: boolean; cell?: Cell }[];
waitingOn: PlayerId; waitingOn: PlayerId;
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean;
/** Set when a creature, not a wizard, delivers the attack. */
creatureId?: string;
/** The wraith's touch also steals a random card if damage lands. */
creatureTouch?: "wraith" | "claw";
/** A spell freed from slime: counteractions cannot touch its caster. */
trapped?: boolean;
} }
export interface CastParams { export interface CastParams {
@@ -202,9 +210,19 @@ export interface GameConfig {
* seat order. */ * seat order. */
colors?: number[]; colors?: number[];
/** /**
* Deck revision. Absent = the original build, which stored games replay * Rules revision, frozen per game so stored games replay unchanged.
* against forever. Revision 2 removes LIFESAVER from two-player decks * Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
* ("Not applicable in a 2-player game." — the card face). * applicable in a 2-player game."). Rev 3: WARD springs only when armed,
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
* creature blows open a counteraction window like any attack. Rev 5:
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a
* teleport escape ("does not work against escape" — the card face).
* Rev 7 briefly restricted warp sight to aisle corners; the table's
* reading prevailed and all board openings carry sight in every revision.
* DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* warp." — the card face). Rev 8: nothing can be created on a warp token,
* and a SPEED bonus turn burns a turn of durations on the hastened wizard.
*/ */
deckRev?: number; deckRev?: number;
} }
@@ -217,6 +235,12 @@ export interface GameState {
edgeOverrides: Record<string, EdgeState>; edgeOverrides: Record<string, EdgeState>;
/** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */ /** Accumulated attack damage per edge: a wall falls at 20, a door at 15. */
wallDamage: Record<string, number>; wallDamage: Record<string, number>;
/** Spells stuck in slime, waiting for the next visitor (by cell key). */
slimeTraps: Record<string, { card: CardInstance; casterId: PlayerId; numberValue: number | null; amplifyFactor: number }[]>;
/** Players whose WARD is set to spring (rules rev 3+; their secret). */
wardArmed: PlayerId[];
/** CHAOS is landing: each queued player may play FULL SHIELD to sit out. */
chaosPending: { casterId: PlayerId; excluded: PlayerId[]; queue: PlayerId[] } | null;
/** Permanent door-lock changes, by edge key. */ /** Permanent door-lock changes, by edge key. */
doorStates: Record<string, "jammed" | "removed">; doorStates: Record<string, "jammed" | "removed">;
/** Door edges unlocked until the end of the current turn. */ /** Door edges unlocked until the end of the current turn. */
@@ -476,6 +500,8 @@ export type GameEvent =
| { type: "castAroundCorner"; caster: PlayerId } | { type: "castAroundCorner"; caster: PlayerId }
| { type: "moveBumped"; player: PlayerId; direction: Side } | { type: "moveBumped"; player: PlayerId; direction: Side }
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null } | { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
| { type: "dieRolled"; player: PlayerId | null; roll: number; purpose: string }
| { type: "tableTalk"; player: PlayerId; text: string }
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell } | { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" } | { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
@@ -483,9 +509,9 @@ export type GameEvent =
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell } | { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell }
| { type: "creatureCreated"; creatureId: string; kind: CreatureState["kind"]; controller: PlayerId; at: Cell } | { type: "creatureCreated"; creatureId: string; kind: CreatureState["kind"]; controller: PlayerId; at: Cell }
| { type: "creatureMoved"; creatureId: string; from: Cell; to: Cell; direction: Side; by: PlayerId } | { type: "creatureMoved"; creatureId: string; from: Cell; to: Cell; direction: Side; by: PlayerId }
| { type: "creatureAttacked"; creatureId: string; target: PlayerId | string; dieRoll: number | null } | { type: "creatureAttacked"; creatureId: string; kind: CreatureState["kind"]; target: PlayerId | string; dieRoll: number | null }
| { type: "creatureTouched"; creatureId: string; player: PlayerId } | { type: "creatureTouched"; creatureId: string; kind: CreatureState["kind"]; player: PlayerId }
| { type: "creatureDamaged"; creatureId: string; amount: number; source: string; damageTotal: number } | { type: "creatureDamaged"; creatureId: string; kind: CreatureState["kind"]; amount: number; source: string; damageTotal: number }
| { type: "creatureDestroyed"; creatureId: string; kind: CreatureState["kind"]; by: string } | { type: "creatureDestroyed"; creatureId: string; kind: CreatureState["kind"]; by: string }
| { type: "trollRegenerated"; creatureId: string } | { type: "trollRegenerated"; creatureId: string }
| { type: "shadowUpkeep"; player: PlayerId; lifeAfter: number } | { type: "shadowUpkeep"; player: PlayerId; lifeAfter: number }
@@ -522,6 +548,12 @@ export type GameEvent =
| { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string } | { type: "itemStolen"; from: PlayerId; to: PlayerId; cardId: string }
| { type: "itemsSwapped"; a: PlayerId; b: PlayerId } | { type: "itemsSwapped"; a: PlayerId; b: PlayerId }
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId } | { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardSet"; player: PlayerId; armed: boolean; visibleTo: PlayerId }
| { type: "chaosShielded"; player: PlayerId }
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
| { type: "slimeWashed"; cell: Cell }
| { type: "pushed"; by: PlayerId; player?: PlayerId; creatureId?: string; from: Cell; to: Cell }
| { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string } | { type: "curseRemoved"; caster: PlayerId; target: PlayerId; cardId: string }
| { type: "objectEnchanted"; caster: PlayerId; cardId: string } | { type: "objectEnchanted"; caster: PlayerId; cardId: string }
| { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell } | { type: "warpTokensPlaced"; caster: PlayerId; a: Cell; b: Cell }
@@ -571,10 +603,11 @@ export type CastTarget =
| { kind: "cell"; cell: Cell }; | { kind: "cell"; cell: Cell };
export type Command = export type Command =
| { type: "move"; direction: Side } | { type: "move"; direction: Side; over?: boolean }
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string } | { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
| { type: "punch"; targetId: PlayerId } | { type: "punch"; targetId: PlayerId }
| { type: "punchWall"; cell: Cell; side: Side } | { type: "punchWall"; cell: Cell; side: Side }
| { type: "armWard"; armed: boolean }
| { type: "warpStep" } | { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side } | { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureAttack"; creatureId: string; targetId: string } | { type: "creatureAttack"; creatureId: string; targetId: string }
@@ -601,7 +634,7 @@ export type Command =
} }
| { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] } | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[] }
| { type: "cancelAmbush"; ambushId: string } | { type: "cancelAmbush"; ambushId: string }
| { type: "counteract"; instanceId: string } | { type: "counteract"; instanceId: string; params?: { cell?: Cell } }
| { type: "pass" } | { type: "pass" }
| { type: "pickUpTreasure" } | { type: "pickUpTreasure" }
| { type: "pickUpObject"; instanceId: string } | { type: "pickUpObject"; instanceId: string }
@@ -1166,8 +1199,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
}, },
"wall-of-fire": { "wall-of-fire": {
kind: "neutral", kind: "neutral",
// Neutral use: a burning barrier for [duration] turns. (Counteraction // Neutral use: a burning barrier for [duration] turns. (Its counteraction
// use vs WATERBOLT: TODO.) // use against WATERBOLT lives in doCounteract and the resolution pipe.)
resolve: (state, events, caster, cmd, magnitude) => { resolve: (state, events, caster, cmd, magnitude) => {
if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge"; if (!cmd.target || cmd.target.kind !== "edge") return "wall of fire targets a corridor edge";
const { cell, side } = cmd.target; const { cell, side } = cmd.target;
@@ -1902,22 +1935,22 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
kind: "attack", kind: "attack",
baseDamage: () => 0, baseDamage: () => 0,
// Everyone's hands into one pile, shuffled, dealt back in equal counts. // Everyone's hands into one pile, shuffled, dealt back in equal counts.
// (Simplification: the FULL SHIELD opt-out and ABSORB SPELL interactions // "FULL SHIELD removes a player from participation": under rules rev 3
// are not modeled — chaos resolves for all living players at once.) // every bystander gets a shield window before the pile forms; earlier
// revisions scramble at once so stored games replay unchanged.
onResolved: (ctx) => { onResolved: (ctx) => {
const players = ctx.state.players.filter((p) => p.alive); if (ctx.fullyStopped) return;
const counts = players.map((p) => p.hand.length); const excluded = ctx.stack.defenderShielded ? [ctx.defender.id] : [];
const pile = players.flatMap((p) => p.hand.splice(0)); if ((ctx.state.config.deckRev ?? 1) >= 3) {
for (const p of players) p.displayed = []; const order = turnOrderFrom(ctx.state, ctx.attacker.id);
const [shuffled, rngNext] = shuffle(ctx.state.rng, pile); const queue = order.filter((id) =>
ctx.state.rng = rngNext; id !== ctx.attacker.id && id !== ctx.defender.id &&
let i = 0; ctx.state.players.find((p) => p.id === id)!.alive);
players.forEach((p, pi) => { ctx.state.chaosPending = { casterId: ctx.attacker.id, excluded, queue };
p.hand = shuffled.slice(i, i + counts[pi]!); finishChaosIfReady(ctx.state, ctx.events);
i += counts[pi]!; return;
ctx.events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] }); }
}); scrambleHands(ctx.state, ctx.events, ctx.attacker.id, excluded);
ctx.events.push({ type: "handsScrambled", caster: ctx.attacker.id });
}, },
}, },
"illusionary-attack": { "illusionary-attack": {
@@ -2072,16 +2105,31 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
}, },
redirection: { redirection: {
kind: "neutral", kind: "neutral",
// "Swap two external sector exits" — their wraparound destinations trade. // "Signify their new directions by placing the A tokens on one set of
// exits & the B tokens on the other" — the two exits you choose become a
// pair (rules rev 9), and their former partners pair with each other.
// Earlier revisions swapped the two exits' destinations instead.
resolve: (state, events, caster, cmd) => { resolve: (state, events, caster, cmd) => {
const a = cmd.params?.cell; const a = cmd.params?.cell;
const bT = cmd.target; const bT = cmd.target;
if (!a || !bT || bT.kind !== "cell") return "pick the two exits to swap"; if (!a || !bT || bT.kind !== "cell") return "pick the two exits to connect";
const b = bT.cell; const b = bT.cell;
const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a)); const wa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(a));
const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b)); const wb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(b));
if (!wa || !wb || wa === wb) return "pick two different outer exits"; if (!wa || !wb || wa === wb) return "pick two different outer exits";
// Swap destinations and fix the reciprocal warps to match. if ((state.config.deckRev ?? 1) >= 9) {
if (cellKey(wa.to.cell) === cellKey(wb.from.cell)) return "those exits already connect";
const pa = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wa.to.cell));
const pb = state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(wb.to.cell));
// The chosen exits join (A tokens); their old partners join (B tokens).
wa.to = { cell: { ...wb.from.cell }, side: wb.from.side };
wb.to = { cell: { ...wa.from.cell }, side: wa.from.side };
if (pa && pb) {
pa.to = { cell: { ...pb.from.cell }, side: pb.from.side };
pb.to = { cell: { ...pa.from.cell }, side: pa.from.side };
}
} else {
// Legacy: swap destinations and fix the reciprocal warps to match.
const destA = { ...wa.to }; const destA = { ...wa.to };
const destB = { ...wb.to }; const destB = { ...wb.to };
wa.to = destB; wa.to = destB;
@@ -2090,6 +2138,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cellKey(w.from.cell) === cellKey(destA.cell)) w.to = { cell: { ...wb.from.cell }, side: wb.from.side }; if (cellKey(w.from.cell) === cellKey(destA.cell)) w.to = { cell: { ...wb.from.cell }, side: wb.from.side };
if (cellKey(w.from.cell) === cellKey(destB.cell)) w.to = { cell: { ...wa.from.cell }, side: wa.from.side }; if (cellKey(w.from.cell) === cellKey(destB.cell)) w.to = { cell: { ...wa.from.cell }, side: wa.from.side };
} }
}
events.push({ type: "exitsRedirected", caster: caster.id }); events.push({ type: "exitsRedirected", caster: caster.id });
return null; return null;
}, },
@@ -2278,6 +2327,11 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
destroyCreature(state, events, c, reason); destroyCreature(state, events, c, reason);
} }
} }
if (state.squareContents[cellKey(probe)]?.kind === "slime") {
delete state.squareContents[cellKey(probe)];
delete state.slimeTraps[cellKey(probe)];
events.push({ type: "slimeWashed", cell: probe });
}
probe = neighbor(probe, dir); probe = neighbor(probe, dir);
} }
} }
@@ -2366,6 +2420,11 @@ function emptySquareTarget(
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there"; if (state.players.some((p) => p.alive && cellKey(p.position) === key)) return "someone is standing there";
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there"; if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) return "a treasure rests there";
if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there"; if ((state.groundObjects[key] ?? []).length > 0) return "an object lies there";
// "You can't create an object on a DIMENSIONAL WARP" (rules rev 8).
if ((state.config.deckRev ?? 1) >= 8 &&
state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it";
}
if (!gameLos(state, caster.position, cell)) return "no line of sight"; if (!gameLos(state, caster.position, cell)) return "no line of sight";
return cell; return cell;
} }
@@ -2447,7 +2506,7 @@ function damageCreature(
source: string, source: string,
): void { ): void {
creature.damage += amount; creature.damage += amount;
events.push({ type: "creatureDamaged", creatureId: creature.id, amount, source, damageTotal: creature.damage }); events.push({ type: "creatureDamaged", creatureId: creature.id, kind: creature.kind, amount, source, damageTotal: creature.damage });
if (creature.damage >= creature.maxDamage) { if (creature.damage >= creature.maxDamage) {
destroyCreature(state, events, creature, source); destroyCreature(state, events, creature, source);
} }
@@ -2511,18 +2570,30 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
} else { } else {
const content = state.squareContents[cellKey(target.to)]; const content = state.squareContents[cellKey(target.to)];
if (content?.kind === "stone") return err("that square is solid stone"); if (content?.kind === "stone") return err("that square is solid stone");
if ((state.config.deckRev ?? 1) >= 5 &&
state.players.some((o) => o.alive && cellKey(o.position) === cellKey(target.to) &&
sustainedOn(state, o.id, "big-man").length > 0)) {
return err("a giant fills that corridor");
}
creature.position = target.to; creature.position = target.to;
} }
creature.movementUsed++; creature.movementUsed++;
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id }); events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
// Touch effects on entering a player's square. // Touch effects on entering a player's square.
const rev4 = (state.config.deckRev ?? 1) >= 4;
for (const p of state.players) { for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue; if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (creature.kind === "wraith" && !creature.attackUsed) { if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true; creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, player: p.id }); events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
if (rev4) {
// The victim may counteract ("REFLECTIONs used on the wraith's touch
// will damage the wraith" — the card assumes exactly this window).
openCreatureStack(state, creature, p, 2, "wraith");
return { ok: true, state, events };
}
applyDamage(state, events, p, 2, "wraith's touch", null); applyDamage(state, events, p, 2, "wraith's touch", null);
if (p.alive && p.hand.length > 0) { if (p.alive && p.hand.length > 0) {
const [idx, rngNext] = nextInt(state.rng, p.hand.length); const [idx, rngNext] = nextInt(state.rng, p.hand.length);
@@ -2535,7 +2606,11 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
} }
if (creature.kind === "democratic-monster" && !creature.attackUsed) { if (creature.kind === "democratic-monster" && !creature.attackUsed) {
creature.attackUsed = true; // "may attack only one player per round of turns" creature.attackUsed = true; // "may attack only one player per round of turns"
events.push({ type: "creatureTouched", creatureId: creature.id, player: p.id }); events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
if (rev4) {
openCreatureStack(state, creature, p, 2, "claw");
return { ok: true, state, events };
}
applyDamage(state, events, p, 2, "clawing monster", null); applyDamage(state, events, p, 2, "clawing monster", null);
} }
} }
@@ -2543,6 +2618,57 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
return { ok: true, state, events }; return { ok: true, state, events };
} }
/** A spell stuck in slime "goes off only once" at whoever is in the gel. */
function springSlimeTrap(state: GameState, events: GameEvent[], victim: PlayerState): void {
if (state.stack) return; // one thing at a time; the next visitor springs it
const key = cellKey(victim.position);
const traps = state.slimeTraps[key];
if (!traps || traps.length === 0) return;
const trap = traps.shift()!;
if (traps.length === 0) delete state.slimeTraps[key];
state.discard.push(trap.card);
events.push({ type: "slimeTrapSprung", cell: victim.position, cardId: trap.card.cardId, victim: victim.id });
state.stack = {
attackerId: trap.casterId,
defenderId: victim.id,
attackCard: trap.card,
numberValue: trap.numberValue,
amplifyFactor: trap.amplifyFactor,
extendFactor: 1,
powerAttackPoints: 0,
params: null,
kind: (CARD_EFFECTS[trap.card.cardId] as AttackEffect | undefined)?.physical ? "physical" : "spell",
counters: [],
waitingOn: victim.id,
trapped: true,
};
}
/** Rules rev 4: a creature's blow opens a counteraction window like any attack. */
function openCreatureStack(
state: GameState,
creature: CreatureState,
victim: PlayerState,
damage: number,
touch?: "wraith" | "claw",
): void {
state.stack = {
attackerId: creature.controllerId,
defenderId: victim.id,
attackCard: null,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: { damage },
kind: "physical",
counters: [],
waitingOn: victim.id,
creatureId: creature.id,
...(touch ? { creatureTouch: touch } : {}),
};
}
function doCreatureAttack(prev: GameState, creatureId: string, targetId: string): CommandResult { function doCreatureAttack(prev: GameState, creatureId: string, targetId: string): CommandResult {
const blocked = requireActionsAvailable(prev); const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked); if (blocked) return err(blocked);
@@ -2581,8 +2707,12 @@ function doCreatureAttack(prev: GameState, creatureId: string, targetId: string)
} else { } else {
amount = 1; // shadow punches like a wizard amount = 1; // shadow punches like a wizard
} }
events.push({ type: "creatureAttacked", creatureId: creature.id, target: targetId, dieRoll: roll }); events.push({ type: "creatureAttacked", creatureId: creature.id, kind: creature.kind, target: targetId, dieRoll: roll });
if (targetPlayer) { if (targetPlayer) {
if ((state.config.deckRev ?? 1) >= 4) {
openCreatureStack(state, creature, targetPlayer, amount);
return { ok: true, state, events };
}
applyDamage(state, events, targetPlayer, amount, `${creature.kind}'s blow`, null, "physical"); applyDamage(state, events, targetPlayer, amount, `${creature.kind}'s blow`, null, "physical");
} else { } else {
damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`); damageCreature(state, events, targetCreature!, amount, `${creature.kind}'s blow`);
@@ -2664,6 +2794,7 @@ function remapState(
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey); state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey); state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey);
state.squareContents = remapRecord(state.squareContents, mapCellKey); state.squareContents = remapRecord(state.squareContents, mapCellKey);
state.slimeTraps = remapRecord(state.slimeTraps, mapCellKey);
state.groundObjects = remapRecord(state.groundObjects, mapCellKey); state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
for (const fx of state.sustained) { for (const fx of state.sustained) {
if (fx.edge) fx.edge = mapEdgeKey(fx.edge); if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
@@ -2953,6 +3084,9 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
board, board,
edgeOverrides: {}, edgeOverrides: {},
wallDamage: {}, wallDamage: {},
slimeTraps: {},
wardArmed: [],
chaosPending: null,
doorStates: {}, doorStates: {},
openDoorEdges: [], openDoorEdges: [],
createdEdges: {}, createdEdges: {},
@@ -3025,11 +3159,38 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
if (state.stack) { if (state.stack) {
if (playerId !== state.stack.waitingOn) return err("waiting for another player's response"); if (playerId !== state.stack.waitingOn) return err("waiting for another player's response");
if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId); if (command.type === "counteract") return doCounteract(state, playerId, command.instanceId, command.params);
if (command.type === "pass") return doPass(state, playerId); if (command.type === "pass") return doPass(state, playerId);
return err("an attack is being resolved — counteract or pass"); return err("an attack is being resolved — counteract or pass");
} }
if (state.chaosPending) {
const head = state.chaosPending.queue[0];
if (playerId !== head) return err("waiting for another player to face the chaos");
if (command.type === "counteract") {
const st = clone(state);
const p = st.players.find((q) => q.id === playerId)!;
const card = p.hand.find((c) => c.instanceId === command.instanceId);
if (!card) return err("card not in hand");
if (card.cardId !== "full-shield") return err("only FULL SHIELD keeps your hand out of the chaos");
takeFromHand(p, command.instanceId);
st.discard.push(card);
st.chaosPending!.excluded.push(playerId);
st.chaosPending!.queue.shift();
const events: GameEvent[] = [{ type: "chaosShielded", player: playerId }];
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
if (command.type === "pass") {
const st = clone(state);
st.chaosPending!.queue.shift();
const events: GameEvent[] = [];
finishChaosIfReady(st, events);
return { ok: true, state: st, events };
}
return err("chaos is coming — shield your hand or pass");
}
// INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window. // INTERRUPT / OPPORTUNITY FIRE: an out-of-turn action window.
if (state.outOfTurnWindow) { if (state.outOfTurnWindow) {
if (playerId !== state.outOfTurnWindow.playerId) { if (playerId !== state.outOfTurnWindow.playerId) {
@@ -3102,10 +3263,11 @@ export function applyCommand(state: GameState, playerId: PlayerId, command: Comm
} }
switch (command.type) { switch (command.type) {
case "move": return doMove(state, command.direction); case "move": return doMove(state, command.direction, command.over === true);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId); case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId); case "punch": return doPunch(state, command.targetId);
case "punchWall": return doPunchWall(state, command.cell, command.side); case "punchWall": return doPunchWall(state, command.cell, command.side);
case "armWard": return doArmWard(state, command.armed);
case "warpStep": return doWarpStep(state); case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
@@ -3157,7 +3319,7 @@ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direct
return { ok: true, state, events }; return { ok: true, state, events };
} }
function doMove(prev: GameState, direction: Side): CommandResult { function doMove(prev: GameState, direction: Side, over = false): CommandResult {
const blocked = requireActionsAvailable(prev); const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked); if (blocked) return err(blocked);
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left"); if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
@@ -3178,9 +3340,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to // BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement." // move ... bumping into a wall counts as one space of movement."
if (isBlinded(state, p)) { if (isBlinded(state, p)) {
const [roll, rngNext] = rollDie(state.rng); direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
state.rng = rngNext;
direction = SIDES[roll - 1]!;
} }
// A believed (or untested, when bumped) ILLUSION WALL blocks the believer. // A believed (or untested, when bumped) ILLUSION WALL blocks the believer.
@@ -3194,6 +3354,24 @@ function doMove(prev: GameState, direction: Side): CommandResult {
} }
const view = boardView(state); const view = boardView(state);
// BIG MAN: "Using 2 movement points, you can step over a PIT, TACKS, or
// KILLER OOZE" (rules rev 5). The giant strides the hazard square without
// entering it — one point charged here, then the normal step below lands
// him beyond it with every usual arrival effect (and the second point).
if (over) {
const big = (state.config.deckRev ?? 1) >= 5 && sustainedOn(state, p.id, "big-man").length > 0;
if (!big) return err("only a giant steps over hazards");
if (isBlinded(state, p)) return err("you cannot leap what you cannot see");
if (state.turn.movementAllowance - state.turn.movementUsed < 2) return err("stepping over costs 2 movement");
const hop1 = stepTarget(view, p.position, direction);
if (hop1.kind !== "step") return err("nothing to step over that way");
const hazard = state.squareContents[cellKey(hop1.to)]?.kind;
if (hazard !== "pit" && hazard !== "tacks" && hazard !== "ooze") {
return err("you may step over a pit, tacks, or killer ooze");
}
state.turn.movementUsed++;
p.position = hop1.to;
}
const target = stepTarget(view, p.position, direction); const target = stepTarget(view, p.position, direction);
const misted = isMisted(state, p.id); const misted = isMisted(state, p.id);
@@ -3247,6 +3425,41 @@ function doMove(prev: GameState, direction: Side): CommandResult {
return err("a giant fills that corridor"); return err("a giant fills that corridor");
} }
} }
// BIG MAN pushes: "You can push monsters or other players (in an adjacent
// square) down the corridor ahead of you as you move" (rules rev 5). No
// room to shove them onward means no way forward for the giant either.
if ((state.config.deckRev ?? 1) >= 5 && via === "step" &&
sustainedOn(state, p.id, "big-man").length > 0) {
const here = cellKey(p.position);
const pushPlayers = state.players.filter((o) => o.id !== p.id && o.alive && cellKey(o.position) === here);
const pushCreatures = state.creatures.filter((c) => cellKey(c.position) === here);
if (pushPlayers.length > 0 || pushCreatures.length > 0) {
if (pushPlayers.some((o) => isLockedInPlace(state, o.id))) {
p.position = from;
return err("someone there is stuck fast and cannot be pushed");
}
const shove = stepTarget(view, p.position, direction);
const shoveOk =
shove.kind === "step" &&
state.squareContents[cellKey(shove.to)]?.kind !== "stone" &&
!state.players.some((o) => o.alive && cellKey(o.position) === cellKey(shove.to) &&
sustainedOn(state, o.id, "big-man").length > 0);
if (!shoveOk) {
p.position = from;
return err("no room to push them onward");
}
for (const o of pushPlayers) {
const oFrom = o.position;
o.position = { ...shove.to };
events.push({ type: "pushed", by: p.id, player: o.id, from: oFrom, to: o.position });
}
for (const c of pushCreatures) {
const cFrom = c.position;
c.position = { ...shove.to };
events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position });
}
}
}
// FEAR: no one moves within 3 spaces of the fearsome one. // FEAR: no one moves within 3 spaces of the fearsome one.
for (const other of state.players) { for (const other of state.players) {
if (other.id === p.id || !other.alive) continue; if (other.id === p.id || !other.alive) continue;
@@ -3263,8 +3476,7 @@ function doMove(prev: GameState, direction: Side): CommandResult {
// fall in (2 damage, movement over); otherwise you sail across to the far // fall in (2 damage, movement over); otherwise you sail across to the far
// side (if there is open floor there). // side (if there is open floor there).
if (content?.kind === "pit" && !misted && !p.inPit) { if (content?.kind === "pit" && !misted && !p.inPit) {
const [roll, rngNext] = rollDie(state.rng); const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in");
state.rng = rngNext;
if (roll === 1) { if (roll === 1) {
p.inPit = true; p.inPit = true;
events.push({ type: "fellInPit", player: p.id, at: p.position }); events.push({ type: "fellInPit", player: p.id, at: p.position });
@@ -3367,10 +3579,12 @@ function doMove(prev: GameState, direction: Side): CommandResult {
} }
checkVictory(state, events); checkVictory(state, events);
} }
// FILL SQUARE WITH SLIME: entering ends your turn's actions. // FILL SQUARE WITH SLIME: entering ends your turn's actions — and any
// spell stuck in the gel goes off at the visitor.
if (content?.kind === "slime" && p.alive && !misted) { if (content?.kind === "slime" && p.alive && !misted) {
events.push({ type: "stuckInSlime", player: p.id, at: p.position }); events.push({ type: "stuckInSlime", player: p.id, at: p.position });
state.turn.actionsEnded = true; state.turn.actionsEnded = true;
springSlimeTrap(state, events, p);
} }
// IDIOT lifts when the victim reaches their own treasure. // IDIOT lifts when the victim reaches their own treasure.
if (sustainedOn(state, p.id, "idiot").length > 0) { if (sustainedOn(state, p.id, "idiot").length > 0) {
@@ -3565,6 +3779,52 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side)); return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
} }
/** Arm (or stand down) the WARD trap on your treasures. Your secret. */
function doArmWard(prev: GameState, armed: boolean): CommandResult {
const state = clone(prev);
const p = activePlayer(state);
if (!p.hand.some((c) => c.cardId === "ward")) return err("you hold no WARD");
const already = state.wardArmed.includes(p.id);
if (armed === already) return err(armed ? "your ward is already set" : "your ward is not set");
state.wardArmed = armed ? [...state.wardArmed, p.id] : state.wardArmed.filter((id) => id !== p.id);
return {
ok: true,
state,
events: [{ type: "wardSet", player: p.id, armed, visibleTo: p.id }],
};
}
/** Player ids in seat order, starting after `fromId`. */
function turnOrderFrom(state: GameState, fromId: PlayerId): PlayerId[] {
const ids = state.players.map((p) => p.id);
const at = ids.indexOf(fromId);
return [...ids.slice(at + 1), ...ids.slice(0, at + 1)];
}
/** "Everyone tosses them in a pile" — except those FULL SHIELD sat out. */
function scrambleHands(state: GameState, events: GameEvent[], casterId: PlayerId, excluded: PlayerId[]): void {
const players = state.players.filter((p) => p.alive && !excluded.includes(p.id));
const counts = players.map((p) => p.hand.length);
const pile = players.flatMap((p) => p.hand.splice(0));
for (const p of players) p.displayed = [];
const [shuffled, rngNext] = shuffle(state.rng, pile);
state.rng = rngNext;
let i = 0;
players.forEach((p, pi) => {
p.hand = shuffled.slice(i, i + counts[pi]!);
i += counts[pi]!;
events.push({ type: "cardsDealtPrivate", visibleTo: p.id, player: p.id, cards: [...p.hand] });
});
events.push({ type: "handsScrambled", caster: casterId });
}
function finishChaosIfReady(state: GameState, events: GameEvent[]): void {
const pending = state.chaosPending;
if (!pending || pending.queue.length > 0) return;
state.chaosPending = null;
scrambleHands(state, events, pending.casterId, pending.excluded);
}
function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult { function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult {
const pre = attackPreconditions(prev); const pre = attackPreconditions(prev);
if (pre) return err(pre); if (pre) return err(pre);
@@ -3600,15 +3860,15 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
// BLIND: "...engage in combat..." — a blinded brawler flails on a die // BLIND: "...engage in combat..." — a blinded brawler flails on a die
// roll; the swing connects only on a 1 (same convention as INVISIBLE). // roll; the swing connects only on a 1 (same convention as INVISIBLE).
const preRoll: GameEvent[] = [];
if (isBlinded(state, attacker)) { if (isBlinded(state, attacker)) {
const [roll, rngNext] = rollDie(state.rng); const roll = rollD4(state, preRoll, attacker.id, "a blind swing — only a 1 connects");
state.rng = rngNext;
if (roll !== 1) { if (roll !== 1) {
state.turn.attackUsed = true; state.turn.attackUsed = true;
return { return {
ok: true, ok: true,
state, state,
events: [{ events: [...preRoll, {
type: "attackMisdirected", attacker: attacker.id, intended: target.id, type: "attackMisdirected", attacker: attacker.id, intended: target.id,
rolledDirection: SIDES[roll - 1]!, newTarget: null, rolledDirection: SIDES[roll - 1]!, newTarget: null,
}], }],
@@ -3634,7 +3894,7 @@ function doPunch(prev: GameState, targetId: PlayerId): CommandResult {
return { return {
ok: true, ok: true,
state, state,
events: [{ type: "punched", attacker: attacker.id, target: target.id, at: attacker.position }], events: [...preRoll, { type: "punched", attacker: attacker.id, target: target.id, at: attacker.position }],
}; };
} }
@@ -3868,12 +4128,53 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
} }
const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length); const dmg = effect.baseDamage(mods.magnitude.numberValue, cmd.params ?? null) * (2 ** mods.amplifies.length);
if (creature.kind === "fire-imp") { if (creature.kind === "fire-imp") {
events.push({ type: "creatureDamaged", creatureId: creature.id, amount: 0, source: inHand.cardId, damageTotal: creature.damage }); events.push({ type: "creatureDamaged", creatureId: creature.id, kind: creature.kind, amount: 0, source: inHand.cardId, damageTotal: creature.damage });
} else if (dmg > 0) { } else if (dmg > 0) {
damageCreature(state, events, creature, dmg, inHand.cardId); damageCreature(state, events, creature, dmg, inHand.cardId);
} }
return { ok: true, state, events }; return { ok: true, state, events };
} }
// FILL SQUARE WITH SLIME: "Spells cast at the slime get stuck there, and
// affect anyone in the slime or entering it later on." A 5-point WATERBOLT
// washes the slime away instead.
if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell;
if (effect.requiresLos && !casterLos(state, caster, caster.position, cell)) {
return err("no line of sight to the slime");
}
const wandEvents: GameEvent[] = [];
{
const werr = spendWandCharge(state, caster, wandEvents);
if (werr) return err(werr);
}
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events2: GameEvent[] = [...wandEvents];
if (inHand.cardId === "waterbolt" && mods.magnitude.power >= 5) {
delete state.squareContents[cellKey(cell)];
delete state.slimeTraps[cellKey(cell)];
events2.push({ type: "slimeWashed", cell });
return { ok: true, state, events: events2 };
}
// The card leaves the discard and lives in the gel.
const di = state.discard.findIndex((c) => c.instanceId === inHand.instanceId);
if (di !== -1) state.discard.splice(di, 1);
const key = cellKey(cell);
state.slimeTraps[key] = [...(state.slimeTraps[key] ?? []), {
card: inHand, casterId: caster.id,
numberValue: mods.magnitude.numberValue,
amplifyFactor: 2 ** mods.amplifies.length,
}];
events2.push({ type: "spellTrapped", caster: caster.id, cell, cardId: inHand.cardId });
// "affect anyone in the slime": a current occupant springs it at once.
const occupant = state.players.find((p) => p.alive && p.id !== caster.id && cellKey(p.position) === key);
if (occupant) springSlimeTrap(state, events2, occupant);
return { ok: true, state, events: events2 };
}
// "Any attack against an inanimate object counts as your one attack for // "Any attack against an inanimate object counts as your one attack for
// the turn." A wall or door soaks the spell's damage; no counteractions. // the turn." A wall or door soaks the spell's damage; no counteractions.
if (cmd.target?.kind === "edge") { if (cmd.target?.kind === "edge") {
@@ -4226,7 +4527,7 @@ function checkAmbushes(
} }
} }
function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string): CommandResult { function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string, params?: { cell?: Cell }): CommandResult {
const state = clone(prev); const state = clone(prev);
const stack = state.stack!; const stack = state.stack!;
const player = state.players.find((p) => p.id === playerId)!; const player = state.players.find((p) => p.id === playerId)!;
@@ -4239,6 +4540,12 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
const castBlock = castingBlocked(state, playerId); const castBlock = castingBlocked(state, playerId);
if (castBlock) return err(castBlock); if (castBlock) return err(castBlock);
// "REFLECTIONS have no effect" against CHAOS (rules rev 3).
if (stack.attackCard?.cardId === "chaos" && (state.config.deckRev ?? 1) >= 3 &&
(card.cardId === "reflection" || card.cardId === "full-reflection")) {
return err("REFLECTIONS have no effect against CHAOS");
}
if (playerId === stack.defenderId) { if (playerId === stack.defenderId) {
if (card.cardId === "absorb-spell") { if (card.cardId === "absorb-spell") {
if (stack.kind !== "spell") return err("absorb spell only works against spells"); if (stack.kind !== "spell") return err("absorb spell only works against spells");
@@ -4267,6 +4574,31 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
return { ok: true, state, events }; return { ok: true, state, events };
} }
// TELEPORT: "If you use this spell as a Counteraction, the attack has no
// chance of hitting you" (official FAQ). The escape happens at resolution,
// so an ANTI-ANTI can still pin your boots to the floor.
if (card.cardId === "teleport") {
const to = params?.cell;
if (!to) return err("teleport needs a destination cell");
if (isLockedInPlace(state, playerId)) return err("you are locked in place");
const view = boardView(state);
if (!view.cells[cellKey(to)]) return err("destination is off the board");
if (state.squareContents[cellKey(to)]?.kind === "stone") return err("that square is solid stone");
if (wallIgnoringDistance(view, player.position, to) > 4) {
return err("teleport reaches at most four spaces");
}
takeFromHand(player, instanceId);
state.discard.push(card);
stack.counters.push({ player: playerId, card, nullified: false, cell: to });
stack.waitingOn = stack.attackerId;
state.lastSpellUsed[playerId] = card.cardId;
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
};
}
// WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT." // WALL OF FIRE: "As a COUNTERACTION, it will stop a WATERBOLT."
if (card.cardId === "wall-of-fire") { if (card.cardId === "wall-of-fire") {
if (stack.attackCard?.cardId !== "waterbolt") { if (stack.attackCard?.cardId !== "waterbolt") {
@@ -4315,9 +4647,40 @@ function doCounteract(prev: GameState, playerId: PlayerId, instanceId: string):
} }
if (playerId === stack.attackerId) { if (playerId === stack.attackerId) {
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI can counteract a counteraction");
const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified); const targetCounter = [...stack.counters].reverse().find((c) => !c.nullified);
// ABSORB SPELL "will work on other COUNTERACTIONS" — but only ones used
// against YOU: "will work on FULL REFLECTION or REFLECTION, but not FULL
// SHIELD, as that is not used against you" (official FAQ).
if (card.cardId === "absorb-spell") {
if (!targetCounter) return err("no counteraction to absorb");
const id = targetCounter.card.cardId;
if (id !== "reflection" && id !== "full-reflection") {
return err(`${cardDef(id).name} is not cast at you — it cannot be absorbed`);
}
takeFromHand(player, instanceId);
state.discard.push(card);
targetCounter.nullified = true;
// The absorbed reflection leaves the discard for the absorber's hand.
const di = state.discard.findIndex((c) => c.instanceId === targetCounter.card.instanceId);
if (di !== -1) {
state.discard.splice(di, 1);
player.hand.push(targetCounter.card);
if (player.hand.length > handLimit(player)) state.pendingDiscard = player.id;
}
stack.waitingOn = stack.defenderId;
return {
ok: true,
state,
events: [{ type: "counterNullified", player: targetCounter.player, card: targetCounter.card, by: card }],
};
}
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction");
if (!targetCounter) return err("no counteraction to nullify"); if (!targetCounter) return err("no counteraction to nullify");
// "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE"
// (the card face; rules rev 6 — earlier games replay their old chains).
if ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport") {
return err("ANTI-ANTI does not work against escape");
}
takeFromHand(player, instanceId); takeFromHand(player, instanceId);
state.discard.push(card); state.discard.push(card);
targetCounter.nullified = true; targetCounter.nullified = true;
@@ -4355,19 +4718,18 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null; const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and // Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
// SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." // SHRINK (50% miss). "If a spell misses, it dissipates harmlessly." A
if (sustainedOn(state, defender.id, "invisible").length > 0) { // creature sharing the square has nothing to aim: no miss rolls.
const [roll, rngNext] = rollDie(state.rng); if (!stack.creatureId && sustainedOn(state, defender.id, "invisible").length > 0) {
state.rng = rngNext; const roll = rollD4(state, events, attacker.id, "aiming at the unseen — only a 1 finds them");
if (roll !== 1) { if (roll !== 1) {
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" }); events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "invisible" });
events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false }); events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false });
return; return;
} }
} }
if (sustainedOn(state, defender.id, "shrink").length > 0) { if (!stack.creatureId && sustainedOn(state, defender.id, "shrink").length > 0) {
const [roll, rngNext] = rollDie(state.rng); const roll = rollD4(state, events, attacker.id, "aiming at the tiny — 1-2 hits");
state.rng = rngNext;
if (roll > 2) { if (roll > 2) {
events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" }); events.push({ type: "attackMissed", attacker: attacker.id, defender: defender.id, attackCardId: attackId, because: "shrink" });
events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false }); events.push({ type: "attackResolved", attacker: attacker.id, defender: defender.id, attackCardId: attackId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false });
@@ -4375,7 +4737,11 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
} }
} }
let base = effect ? effect.baseDamage(stack.numberValue, stack.params) : 1; // punch = 1 let base = effect
? effect.baseDamage(stack.numberValue, stack.params)
: stack.creatureId
? (stack.params?.damage ?? 1) // a creature's blow
: 1; // a punch
// 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;
@@ -4405,6 +4771,9 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor ? (stack.numberValue ?? 1) * stack.amplifyFactor * stack.extendFactor
: 0; : 0;
// Whether this attack even tries to wound: utility attacks (DROP OBJECT,
// TELEPORT OPPONENT) deal 0 by design, and dealing 0 is not being stopped.
const dealsDamage = base > 0;
const pipe: DamagePipeline = { const pipe: DamagePipeline = {
damage: base, damage: base,
duration: baseDuration, duration: baseDuration,
@@ -4415,6 +4784,13 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
reversed: false, reversed: false,
kind: stack.kind, kind: stack.kind,
}; };
if (attackId === "chaos" && (state.config.deckRev ?? 1) >= 3) {
const shielded = stack.counters.some((c) => !c.nullified && c.card.cardId === "full-shield");
if (shielded) {
stack.defenderShielded = true;
stack.counters = stack.counters.filter((c) => c.nullified || c.card.cardId !== "full-shield");
}
}
for (const counter of stack.counters) { for (const counter of stack.counters) {
if (counter.nullified) continue; if (counter.nullified) continue;
if (isNumberCard(counter.card.cardId)) { if (isNumberCard(counter.card.cardId)) {
@@ -4430,15 +4806,50 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
pipe.fullyStopped = true; pipe.fullyStopped = true;
continue; continue;
} }
if (counter.card.cardId === "teleport") {
pipe.damage = 0;
pipe.duration = 0;
pipe.fullyStopped = true;
continue;
}
if (stack.creatureId &&
(counter.card.cardId === "reflection" || counter.card.cardId === "full-reflection")) {
// "REFLECTIONs used on the wraith's touch will damage the wraith."
if (counter.card.cardId === "reflection") {
const half = Math.ceil(pipe.damage / 2);
pipe.reflectedDamage += half;
pipe.damage = half;
} else {
pipe.redirected = true; // the whole blow turns back (damage rides pipe.damage)
}
continue;
}
const ce = CARD_EFFECTS[counter.card.cardId]; const ce = CARD_EFFECTS[counter.card.cardId];
if (ce && ce.kind === "counter") ce.apply(pipe); if (ce && ce.kind === "counter") ce.apply(pipe);
} }
// A surviving teleport counter whisks the defender away before anything lands.
const escape = stack.counters.find((c) => !c.nullified && c.card.cardId === "teleport" && c.cell);
if (escape) {
const from = defender.position;
defender.position = { ...escape.cell! };
events.push({ type: "teleported", player: defender.id, from, to: defender.position, by: defender.id, cardId: "teleport" });
}
let damageDealt = 0; let damageDealt = 0;
const attackingCreature = stack.creatureId
? state.creatures.find((c) => c.id === stack.creatureId)
: undefined;
if (pipe.redirected) { if (pipe.redirected) {
if (pipe.damage > 0) { if (pipe.damage > 0) {
if (stack.trapped) {
// "Counteractions against 'trapped' attacks do not affect the caster."
} else if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.damage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id); applyDamage(state, events, attacker, pipe.damage, `${attackId} (reflected)`, defender.id);
} }
}
if (effect?.sustains && pipe.duration > 0) { if (effect?.sustains && pipe.duration > 0) {
attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration); attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration);
} }
@@ -4448,12 +4859,28 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
events.push({ type: "lifeGained", player: defender.id, amount: pipe.damage, source: `${attackId} (reversed)`, lifeAfter: defender.life }); events.push({ type: "lifeGained", player: defender.id, amount: pipe.damage, source: `${attackId} (reversed)`, lifeAfter: defender.life });
damageDealt = pipe.damage; // secondary effects still take effect damageDealt = pipe.damage; // secondary effects still take effect
} else if (pipe.damage > 0) { } else if (pipe.damage > 0) {
applyDamage(state, events, defender, pipe.damage, attackId ?? `punch from ${attacker.id}`, attacker.id, pipe.kind); const source = attackId ??
(attackingCreature ? `${attackingCreature.kind}'s blow` : `punch from ${attacker.id}`);
applyDamage(state, events, defender, pipe.damage, source, attacker.id, pipe.kind);
damageDealt = pipe.damage; damageDealt = pipe.damage;
} }
if (pipe.reflectedDamage > 0) { // "he takes 2 points of damage and loses a random card" — the theft is a
// secondary effect, stopped only when all the damage is.
if (stack.creatureTouch === "wraith" && damageDealt > 0 && defender.alive && defender.hand.length > 0) {
const [idx, rngNext] = nextInt(state.rng, defender.hand.length);
state.rng = rngNext;
const [card] = defender.hand.splice(idx, 1);
defender.displayed = defender.displayed.filter((id) => id !== card!.instanceId);
state.discard.push(card!);
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
}
if (pipe.reflectedDamage > 0 && !stack.trapped) {
if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id); applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
} }
}
// EMPATHY: "Any attack done in any form against you acts against both // EMPATHY: "Any attack done in any form against you acts against both
// you and the caster of the spell." // you and the caster of the spell."
if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) { if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) {
@@ -4479,7 +4906,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
attackCardId: attackId, attackCardId: attackId,
damageDealt, damageDealt,
reflectedDamage: pipe.redirected ? pipe.damage : pipe.reflectedDamage, reflectedDamage: pipe.redirected ? pipe.damage : pipe.reflectedDamage,
fullyStopped: pipe.fullyStopped || (damageDealt === 0 && !pipe.redirected && !effect?.sustains), fullyStopped: pipe.fullyStopped || (dealsDamage && damageDealt === 0 && !pipe.redirected && !effect?.sustains),
redirected: pipe.redirected, redirected: pipe.redirected,
}); });
@@ -4637,15 +5064,18 @@ function doPickUpTreasure(prev: GameState): 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 },
]; ];
// WARD: the treasure's owner may have trapped it. (Simplification: springs // WARD: "you may play at that time (out of turn) this card on him" — the
// automatically whenever the owner holds the card.) // choice is made ahead of time by arming it (rev 3); earlier revisions
// spring automatically so stored games replay unchanged.
const owner = state.players.find((q) => q.id === t.owner); const owner = state.players.find((q) => q.id === t.owner);
if (owner && owner.alive && owner.id !== p.id) { const wardSet = (state.config.deckRev ?? 1) >= 3 ? state.wardArmed.includes(owner?.id ?? "") : true;
if (owner && owner.alive && owner.id !== p.id && wardSet) {
const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward"); const wardIdx = owner.hand.findIndex((c) => c.cardId === "ward");
if (wardIdx !== -1) { if (wardIdx !== -1) {
const [card] = owner.hand.splice(wardIdx, 1); const [card] = owner.hand.splice(wardIdx, 1);
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!);
state.wardArmed = state.wardArmed.filter((id) => id !== owner.id);
events.push({ type: "wardSprung", owner: owner.id, victim: p.id }); events.push({ type: "wardSprung", owner: owner.id, victim: p.id });
applyDamage(state, events, p, 3, "warded treasure", null); applyDamage(state, events, p, 3, "warded treasure", null);
checkVictory(state, events); checkVictory(state, events);
@@ -4855,17 +5285,7 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
if (s.casterId === player.id) { if (s.casterId === player.id) {
s.remainingTurns--; s.remainingTurns--;
if (s.remainingTurns <= 0) { if (s.remainingTurns <= 0) {
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId }); expireEffect(state, events, s);
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
delete state.edgeOverrides[s.edge];
delete state.createdEdges[s.edge];
events.push({ type: "firewallExpired", edge: s.edge });
}
// GLUE dries out: the cell key rides in the same field.
if (s.cardId === "glue" && s.edge) {
delete state.gluedCells[s.edge];
}
continue; continue;
} }
} }
@@ -4876,16 +5296,14 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
// KILLER OOZE / PIT: struggle rolls before you may move this turn. // KILLER OOZE / PIT: struggle rolls before you may move this turn.
let struggleImmobilized = false; let struggleImmobilized = false;
if (player.fallenInOoze) { if (player.fallenInOoze) {
const [roll, rngNext] = rollDie(state.rng); const roll = rollD4(state, events, player.id, "struggling in the ooze — 1-2 breaks free");
state.rng = rngNext;
const stood = roll <= 2; const stood = roll <= 2;
events.push({ type: "struggledInOoze", player: player.id, stood }); events.push({ type: "struggledInOoze", player: player.id, stood });
if (stood) player.fallenInOoze = false; if (stood) player.fallenInOoze = false;
else struggleImmobilized = true; else struggleImmobilized = true;
} }
if (player.inPit) { if (player.inPit) {
const [roll, rngNext] = rollDie(state.rng); const roll = rollD4(state, events, player.id, "climbing from the pit — 1-2 gets out");
state.rng = rngNext;
const out = roll <= 2; const out = roll <= 2;
events.push({ type: "climbedFromPit", player: player.id, success: out }); events.push({ type: "climbedFromPit", player: player.id, success: out });
if (out) player.inPit = false; if (out) player.inPit = false;
@@ -4928,6 +5346,29 @@ function beginTurnFor(state: GameState, events: GameEvent[], index: number): voi
}; };
} }
/** Roll the D4 in the open: the pips land in the chronicle. */
function rollD4(state: GameState, events: GameEvent[], player: PlayerId | null, purpose: string): number {
const [roll, next] = rollDie(state.rng);
state.rng = next;
events.push({ type: "dieRolled", player, roll, purpose });
return roll;
}
/** An effect's turns ran out: announce it and clean up what it built. */
function expireEffect(state: GameState, events: GameEvent[], s: SustainedEffect): void {
events.push({ type: "spellExpired", effectId: s.id, cardId: s.cardId, target: s.targetId });
// Edge-bound spells clean up their edge (WALL OF FIRE burns out).
if (s.edge && state.edgeOverrides[s.edge] === "firewall") {
delete state.edgeOverrides[s.edge];
delete state.createdEdges[s.edge];
events.push({ type: "firewallExpired", edge: s.edge });
}
// GLUE dries out: the cell key rides in the same field.
if (s.cardId === "glue" && s.edge) {
delete state.gluedCells[s.edge];
}
}
function doEndTurn(prev: GameState, draw: number): CommandResult { function doEndTurn(prev: GameState, draw: number): CommandResult {
if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`); if (draw < 0 || draw > DRAW_PER_TURN) return err(`you may draw 0-${DRAW_PER_TURN} cards`);
@@ -4997,6 +5438,23 @@ function doEndTurn(prev: GameState, draw: number): CommandResult {
if (p.extraTurns > 0) { if (p.extraTurns > 0) {
p.extraTurns--; p.extraTurns--;
beginTurnFor(state, events, state.turn.activeIndex); beginTurnFor(state, events, state.turn.activeIndex);
// "If you have SPEED on you while under the influence of a duration
// spell, it uses up a turn of that duration" — recipient-counted (FAQ;
// rules rev 8). Self-cast durations already burned in beginTurnFor.
if ((state.config.deckRev ?? 1) >= 8) {
const surviving: SustainedEffect[] = [];
for (const s of state.sustained) {
if (s.targetId === p.id && s.casterId !== p.id) {
s.remainingTurns--;
if (s.remainingTurns <= 0) {
expireEffect(state, events, s);
continue;
}
}
surviving.push(s);
}
state.sustained = surviving;
}
events.push({ type: "extraTurnStarted", player: p.id }); events.push({ type: "extraTurnStarted", player: p.id });
events.push({ type: "turnStarted", player: p.id, round: state.turn.round }); events.push({ type: "turnStarted", player: p.id, round: state.turn.round });
return { ok: true, state, events }; return { ok: true, state, events };
+1
View File
@@ -9,3 +9,4 @@ export * from "./cards";
export * from "./setups"; export * from "./setups";
export * from "./game"; export * from "./game";
export * from "./view"; export * from "./view";
export * from "./automaton";
+9 -8
View File
@@ -2,9 +2,10 @@
// shuffled and randomly rotated, then laid out per player count; each player // shuffled and randomly rotated, then laid out per player count; each player
// gets the sector containing their home star. // gets the sector containing their home star.
// //
// Implemented: 2 players (1x2 column, crossed side pairings per the diagram) // All five layouts are verified against the rulebook's Set-Up Diagram
// and 4 players (2x2 square, straight-across pairings). 3 players (L-shape // (research/images/6e-rules-setup-diagram.jpg): 2p crossed pairs, 3p stair
// with the AUTO WARP corner connector) and 5+ (two sets) are TODO. // with the Aisle Warp arc, 4p and 6p straight-across, 5p plus with corner
// warps at every notch. board.test.ts pins each pairing.
import { import {
assembleBoard, assembleBoard,
@@ -67,7 +68,7 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
]; ];
const board = assembleBoard(placements, { const board = assembleBoard(placements, {
warpPairs: [ warpPairs: [
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // AUTO WARP corner [{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // AUTO WARP corner
[{ sector: 0, side: "N" }, { sector: 2, side: "S" }], [{ sector: 0, side: "N" }, { sector: 2, side: "S" }],
[{ sector: 1, side: "W" }, { sector: 2, side: "E" }], [{ sector: 1, side: "W" }, { sector: 2, side: "E" }],
[{ sector: 0, side: "E" }, { sector: 1, side: "S" }], [{ sector: 0, side: "E" }, { sector: 1, side: "S" }],
@@ -105,10 +106,10 @@ export function setupBoard(playerCount: number, rng: RngState): SetupResult {
warpPairs: [ warpPairs: [
[{ sector: 0, side: "N" }, { sector: 4, side: "S" }], // tip to tip [{ sector: 0, side: "N" }, { sector: 4, side: "S" }], // tip to tip
[{ sector: 1, side: "W" }, { sector: 3, side: "E" }], [{ sector: 1, side: "W" }, { sector: 3, side: "E" }],
[{ sector: 0, side: "W" }, { sector: 1, side: "N" }], // aisle-warp corners [{ sector: 0, side: "W" }, { sector: 1, side: "N" }, "aisle"], // aisle-warp corners
[{ sector: 0, side: "E" }, { sector: 3, side: "N" }], [{ sector: 0, side: "E" }, { sector: 3, side: "N" }, "aisle"],
[{ sector: 4, side: "W" }, { sector: 1, side: "S" }], [{ sector: 4, side: "W" }, { sector: 1, side: "S" }, "aisle"],
[{ sector: 4, side: "E" }, { sector: 3, side: "S" }], [{ sector: 4, side: "E" }, { sector: 3, side: "S" }, "aisle"],
], ],
}); });
return { board, rng: rngN }; return { board, rng: rngN };
+93
View File
@@ -47,6 +47,8 @@ export interface GameView {
treasures: TreasureState[]; treasures: TreasureState[];
deckCount: number; deckCount: number;
discardCount: number; discardCount: number;
/** The discard pile, face-up as at the table (last = most recent). */
discardPile: CardInstance[];
/** Cards on the stack are face-up: the whole exchange is public. */ /** Cards on the stack are face-up: the whole exchange is public. */
stack: CastStack | null; stack: CastStack | null;
pendingDiscard: PlayerId | null; pendingDiscard: PlayerId | null;
@@ -67,6 +69,10 @@ export interface GameView {
boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[]; boobytraps: { casterId: PlayerId; cells: { x: number; y: number }[]; realCell: { x: number; y: number } | null }[];
dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[]; dimWarps: { a: { x: number; y: number }; b: { x: number; y: number } }[];
outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null; outOfTurnWindow: { playerId: PlayerId; kind: "interrupt" | "opportunity-fire" } | null;
/** CHAOS shield windows in progress (public: everyone sees it coming). */
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** Whether YOUR ward is set to spring. */
yourWardArmed: boolean;
/** YOUR armed ambushes. Other players' ambushes are invisible. */ /** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[]; yourAmbushes: AmbushState[];
/** Once the game is finished, every hand goes face-up on the table. */ /** Once the game is finished, every hand goes face-up on the table. */
@@ -117,6 +123,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
treasures: state.treasures.map((t) => ({ ...t })), treasures: state.treasures.map((t) => ({ ...t })),
deckCount: state.deck.length, deckCount: state.deck.length,
discardCount: state.discard.length, discardCount: state.discard.length,
discardPile: [...state.discard],
stack: state.stack, stack: state.stack,
pendingDiscard: state.pendingDiscard, pendingDiscard: state.pendingDiscard,
sustained: state.sustained.map((s) => ({ ...s })), sustained: state.sustained.map((s) => ({ ...s })),
@@ -132,6 +139,10 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
wandCharges: { ...state.wandCharges }, wandCharges: { ...state.wandCharges },
dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })), dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })),
outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null, outOfTurnWindow: state.outOfTurnWindow ? { ...state.outOfTurnWindow } : null,
chaosPending: state.chaosPending
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
: null,
yourWardArmed: state.wardArmed.includes(playerId),
yourAmbushes: state.ambushes yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId) .filter((a) => a.ownerId === playerId)
.map((a) => ({ ...a, numbers: [...a.numbers] })), .map((a) => ({ ...a, numbers: [...a.numbers] })),
@@ -171,3 +182,85 @@ export function sightedCellsFor(view: GameView): Set<string> {
} }
return out; return out;
} }
const CREATION_CARD_IDS = new Set([
"fill-square-with-stone", "thornbush", "killer-ooze", "rosebush", "dust-cloud",
"fill-square-with-slime", "create-pit", "handful-of-tacks", "glue", "safe", "boobytrap",
]);
const SUMMON_CARD_IDS = new Set([
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
]);
/**
* Squares a cell-target card can legally aim at, for the client's dimming
* aid — mirroring the engine's own validation from the viewer's knowledge.
* Null = this card's eligibility is not modeled; light everything.
*/
export function eligibleCellsFor(view: GameView, cardId: string): Set<string> | null {
const me = view.players.find((p) => p.id === view.you);
if (!me) return null;
const cells = Object.keys(view.board.cells);
const key = (x: number, y: number) => `${x},${y}`;
const sighted = sightedCellsFor(view);
if (CREATION_CARD_IDS.has(cardId)) {
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
// treasure, object, or a warp token — and in the caster's sight.
const out = new Set<string>();
for (const k of cells) {
if (view.squareContents[k]) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.board.homes.some((h) => key(h.x, h.y) === k)) continue;
if (view.players.some((p) => p.alive && key(p.position.x, p.position.y) === k)) continue;
if (view.treasures.some((t) => t.position && key(t.position.x, t.position.y) === k)) continue;
if ((view.groundObjects[k] ?? []).length > 0) continue;
if (view.dimWarps.some((w) => key(w.a.x, w.a.y) === k || key(w.b.x, w.b.y) === k)) continue;
if (!sighted.has(k)) continue;
out.add(k);
void x; void y;
}
return out;
}
if (SUMMON_CARD_IDS.has(cardId)) {
// Summon: any sighted square free of content and creatures.
const out = new Set<string>();
for (const k of cells) {
if (view.squareContents[k]) continue;
if (view.creatures.some((c) => key(c.position.x, c.position.y) === k)) continue;
if (!sighted.has(k)) continue;
out.add(k);
}
return out;
}
if (cardId === "teleport") {
// Up to four spaces, walls and objects ignored; not into solid stone.
// BFS over existing cells, matching the engine's wallIgnoringDistance.
const out = new Set<string>();
const dist = new Map<string, number>([[key(me.position.x, me.position.y), 0]]);
const queue = [me.position];
while (queue.length > 0) {
const cur = queue.shift()!;
const d = dist.get(key(cur.x, cur.y))!;
if (d >= 4) continue;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
const n = { x: cur.x + dx, y: cur.y + dy };
const nk = key(n.x, n.y);
if (!view.board.cells[nk] || dist.has(nk)) continue;
dist.set(nk, d + 1);
queue.push(n);
if (view.squareContents[nk]?.kind !== "stone") out.add(nk);
}
}
return out;
}
if (cardId === "stone-to-water") {
return new Set(cells.filter((k) => view.squareContents[k]?.kind === "stone"));
}
if (cardId === "dispel-creation") {
return new Set(cells.filter((k) => view.squareContents[k] != null));
}
return null;
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
createGame,
type GameState,
type PlayerId,
} from "../src/game";
import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
return (
state.stack?.waitingOn ??
state.pendingDiscard ??
state.chaosPending?.queue[0] ??
state.outOfTurnWindow?.playerId ??
state.players[state.turn.activeIndex]!.id
);
}
/** Drive a full bot-vs-bot game; returns the final state and command count. */
function playOut(
seed: number, players: number, expansion = true,
styles: AutomatonStyle[] = [], tiers: AutomatonTier[] = [],
) {
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
const styleOf = new Map(ids.map((id, i) => [id, styles[i] ?? "hunter"]));
const tierOf = new Map(ids.map((id, i) => [id, tiers[i] ?? "archmage"]));
let { state } = createGame({
playerIds: ids,
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
deckRev: 8,
});
let commands = 0;
let stuck = 0;
const CAP = 4000;
while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const cmd = automatonCommand(view, styleOf.get(seat), tierOf.get(seat)) ?? automatonFallback(view);
let r = applyCommand(state, seat, cmd);
if (!r.ok) {
const fb = automatonFallback(view);
r = applyCommand(state, seat, fb);
if (!r.ok) {
stuck++;
if (stuck > 3) {
throw new Error(
`automaton stuck at seat ${seat} after ${commands} commands: ` +
`${JSON.stringify(cmd)} -> ${JSON.stringify(fb)} both refused (${r.error})`,
);
}
// Last-ditch: burn the turn structure forward.
r = applyCommand(state, seat, { type: "endTurn", draw: 0 });
if (!r.ok) r = applyCommand(state, seat, { type: "pass" });
if (!r.ok) throw new Error(`unrecoverable at ${seat}: ${r.error}`);
}
} else {
stuck = 0;
}
state = r.state;
commands++;
}
return { state, commands };
}
describe("automaton vs automaton", () => {
it("two clockwork wizards fight a game to its end", () => {
const { state, commands } = playOut(11, 2);
expect(state.phase).toBe("finished");
expect(state.winner).not.toBeNull();
expect(commands).toBeLessThan(4000);
});
it("holds up across many seeds without wedging", () => {
let finished = 0;
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34]) {
const { state } = playOut(seed, 2);
if (state.phase === "finished") finished++;
}
// Cautious clockwork can stall a maze; most games must still conclude.
expect(finished).toBeGreaterThanOrEqual(6);
});
it("a full table of four automatons concludes", () => {
const { state } = playOut(7, 4);
expect(state.phase).toBe("finished");
});
it("the apprentice handicap bites where cards decide: combat mirrors", () => {
// Deterministic across these seeds: same brains, same dice.
let arch = 0, appr = 0;
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233]) {
const { state } = playOut(seed, 2, true,
["berserker", "berserker"], ["archmage", "apprentice"]);
if (state.winner === "bot1") arch++;
if (state.winner === "bot2") appr++;
}
expect(arch).toBeGreaterThan(appr);
});
it("every temperament finishes its wars", () => {
for (const styles of [
["berserker", "hunter"], ["worrier", "hunter"], ["berserker", "worrier"],
] as AutomatonStyle[][]) {
let finished = 0;
for (const seed of [3, 17, 29]) {
const { state } = playOut(seed, 2, true, styles);
if (state.phase === "finished") finished++;
}
expect(finished).toBeGreaterThanOrEqual(2);
}
});
});
+87 -17
View File
@@ -138,38 +138,108 @@ describe("player counts 2-6", () => {
}); });
}); });
describe("line of sight through wraparound openings", () => { describe("sight and the wraparound openings", () => {
const twoSector: SectorPlacement[] = [ const twoSector: SectorPlacement[] = [
{ boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 }, { boardId: "board-a", origin: { x: 0, y: 0 }, rotation: 0 },
{ boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 }, { boardId: "board-b", origin: { x: 0, y: 5 }, rotation: 0 },
]; ];
it("sees straight down the corridor, out one mouth and in the other", () => { it("the open sides carry sight: mouths see each other through the join", () => {
const board = assembleBoard(twoSector); const board = assembleBoard(twoSector);
// (2,0) N wraps to (2,9): the mouths see each other through the join.
expect(hasLineOfSight(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(false); expect(hasLineOfSight(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(false);
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(true); expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 9 })).toBe(true);
expect(sightBetween(board, { x: 2, y: 9 }, { x: 2, y: 0 })).toBe(true); expect(sightBetween(board, { x: 2, y: 9 }, { x: 2, y: 0 })).toBe(true);
}); });
it("demands a straight corridor: off-axis viewers see nothing", () => { it("walls inside the corridor still block, and a filled mouth chokes", () => {
const board = assembleBoard(twoSector); const board = assembleBoard(twoSector);
expect(sightBetween(board, { x: 1, y: 0 }, { x: 2, y: 9 })).toBe(false); expect(sightBetween(board, { x: 2, y: 2 }, { x: 2, y: 9 })).toBe(false);
expect(sightBetween(board, { x: 3, y: 1 }, { x: 2, y: 9 })).toBe(false); expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, { "2,9": true })).toBe(false);
});
}); });
it("walls inside either corridor still block the warp sight", () => { import { createRng } from "../src/rng";
const board = assembleBoard(twoSector); import { setupBoard as setupBoardForPins } from "../src/setups";
// Layout A: the wall between (2,0) and (2,1)'s row (home column sight is
// cut above row 2 — pinned earlier) blocks a deeper viewer's warp sight. describe("setup diagram pairings (rulebook Set-Up Diagram)", () => {
const deepViewer = { x: 2, y: 2 }; const setupBoard = setupBoardForPins;
expect(hasLineOfSight(board, deepViewer, { x: 2, y: 0 })).toBe(false);
expect(sightBetween(board, deepViewer, { x: 2, y: 9 })).toBe(false); function onEdge(c: { x: number; y: number }, origin: { x: number; y: number }, side: string): boolean {
if (side === "N") return c.y === origin.y && c.x >= origin.x && c.x < origin.x + 5;
if (side === "S") return c.y === origin.y + 4 && c.x >= origin.x && c.x < origin.x + 5;
if (side === "W") return c.x === origin.x && c.y >= origin.y && c.y < origin.y + 5;
return c.x === origin.x + 4 && c.y >= origin.y && c.y < origin.y + 5;
}
function hasWarp(board: { warps: { from: { cell: { x: number; y: number } }; to: { cell: { x: number; y: number } } }[] },
a: { o: { x: number; y: number }; s: string }, b: { o: { x: number; y: number }; s: string }): boolean {
return board.warps.some((w) => onEdge(w.from.cell, a.o, a.s) && onEdge(w.to.cell, b.o, b.s));
}
it("3 players: the aisle-warp arc plus B, C, A letter pairs", () => {
const { board } = setupBoard(3, createRng(7));
const top = { x: 5, y: 0 }, bl = { x: 0, y: 5 }, br = { x: 5, y: 5 };
expect(hasWarp(board, { o: top, s: "W" }, { o: bl, s: "N" })).toBe(true); // Aisle Warp
expect(hasWarp(board, { o: top, s: "N" }, { o: br, s: "S" })).toBe(true); // B
expect(hasWarp(board, { o: top, s: "E" }, { o: bl, s: "S" })).toBe(true); // C
expect(hasWarp(board, { o: bl, s: "W" }, { o: br, s: "E" })).toBe(true); // A
}); });
it("a filled mouth chokes the tunnel", () => { it("5 players: opposite tips wrap, notch corners arc around", () => {
const board = assembleBoard(twoSector); const { board } = setupBoard(5, createRng(7));
const stone = { "2,9": true as const }; const n = { x: 5, y: 0 }, w = { x: 0, y: 5 }, e = { x: 10, y: 5 }, s = { x: 5, y: 10 };
expect(sightBetween(board, { x: 2, y: 0 }, { x: 2, y: 8 }, stone)).toBe(false); expect(hasWarp(board, { o: n, s: "N" }, { o: s, s: "S" })).toBe(true); // A
expect(hasWarp(board, { o: w, s: "W" }, { o: e, s: "E" })).toBe(true); // B
expect(hasWarp(board, { o: n, s: "W" }, { o: w, s: "N" })).toBe(true); // corners
expect(hasWarp(board, { o: n, s: "E" }, { o: e, s: "N" })).toBe(true);
expect(hasWarp(board, { o: s, s: "W" }, { o: w, s: "S" })).toBe(true);
expect(hasWarp(board, { o: s, s: "E" }, { o: e, s: "S" })).toBe(true);
});
it("6 players: straight-across wrap on both axes", () => {
const { board } = setupBoard(6, createRng(7));
for (const w of board.warps) {
const colinear = w.from.cell.x === w.to.cell.x || w.from.cell.y === w.to.cell.y;
expect(colinear).toBe(true);
}
});
it("relocation discards the aisle warp: only opposite edges connect after", async () => {
const { createGame, applyCommand, activePlayer } = await import("../src/game");
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 7, sets: ["basic"], deckRev: 5 });
const bent = (warps: typeof state.board.warps) =>
warps.filter((w) => w.from.cell.x !== w.to.cell.x && w.from.cell.y !== w.to.cell.y);
expect(bent(state.board.warps).length).toBeGreaterThan(0); // the arc exists
const me = activePlayer(state);
me.hand[0] = { instanceId: "rel#1", cardId: "relocate-sector" };
// Slide the top sector from (5,0) across to (0,0): an L becomes a block.
const r = applyCommand(state, me.id, {
type: "cast", instanceId: "rel#1",
target: { kind: "cell", cell: { x: 0, y: 0 } }, params: { cell: { x: 7, y: 2 } },
});
if (!r.ok) throw new Error(r.error);
expect(bent(r.state.board.warps).length).toBe(0); // "only opposite board edges connect"
});
});
describe("the aisle warp treats its corner as adjacent — sight included", () => {
it("sight passes through the AUTO WARP, diagonals and all", () => {
const { board } = setupBoardForPins(3, createRng(7));
const aisle = board.warps.find((w) => w.aisle)!;
expect(aisle).toBeDefined();
// From the aisle mouth, squares beyond the corner are visible — including
// at least one OFF the straight axis (the adjacency promise).
const mouth = aisle.from.cell;
const axisVertical = aisle.from.side === "N" || aisle.from.side === "S";
let seen = 0, offAxis = 0;
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (hasLineOfSight(board, mouth, { x, y })) continue;
if (sightBetween(board, mouth, { x, y })) {
seen++;
if (axisVertical ? x !== mouth.x : y !== mouth.y) offAxis++;
}
}
expect(seen).toBeGreaterThan(0);
expect(offAxis).toBeGreaterThan(0);
}); });
}); });
+408 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game"; import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES } from "../src/board"; import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
@@ -387,3 +387,410 @@ describe("attacking walls and doors", () => {
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
}); });
describe("rules revision 3", () => {
function rev3Game(seed = 42) {
return createGame({ playerIds: ["alice", "bob", "cara"], seed, sets: ["basic", "expansion1"], deckRev: 3 });
}
it("ward springs only when its owner armed it", () => {
let { state } = rev3Game();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
// Unarmed: the grab goes unpunished.
thief.position = { ...treasure.position! };
let s2 = must(state, thief.id, { type: "pickUpTreasure" });
expect(s2.players.find((p) => p.id === thief.id)!.life).toBe(15);
expect(s2.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(true);
// Armed (on the owner's own turn): the trap bites for 3.
state.players[state.turn.activeIndex] = state.players[state.turn.activeIndex]!;
const ownerTurnState = (() => {
let s = state;
while (activePlayer(s).id !== owner.id) s = must(s, activePlayer(s).id, { type: "endTurn", draw: 0 });
return s;
})();
let s3 = must(ownerTurnState, owner.id, { type: "armWard", armed: true });
while (activePlayer(s3).id !== thief.id) s3 = must(s3, activePlayer(s3).id, { type: "endTurn", draw: 0 });
s3.players.find((p) => p.id === thief.id)!.position = { ...treasure.position! };
s3 = must(s3, thief.id, { type: "pickUpTreasure" });
expect(s3.players.find((p) => p.id === thief.id)!.life).toBe(12);
expect(s3.wardArmed).not.toContain(owner.id);
});
it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = rev3Game();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const defender = state.players.find((p) => p.id !== caster.id)!;
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
giveCard(state, defender.id, "full-reflection", "R", 0);
giveCard(state, bystander.id, "full-shield", "S", 0);
const bystanderHand = bystander.hand.map((c) => c.instanceId).sort();
state = must(state, caster.id, {
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
});
// "REFLECTIONS have no effect."
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "full-reflection#R" }).ok).toBe(false);
state = must(state, defender.id, { type: "pass" });
// Now the bystander's window: they shield out and keep their hand.
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
state = must(state, bystander.id, { type: "counteract", instanceId: "full-shield#S" });
expect(state.chaosPending).toBeNull();
const after = state.players.find((p) => p.id === bystander.id)!;
expect(after.hand.map((c) => c.instanceId).sort()).toEqual(
bystanderHand.filter((id) => id !== "full-shield#S").sort());
});
it("chaos: the defender's full shield sits them out without stopping it", () => {
let { state } = rev3Game();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const defender = state.players.find((p) => p.id !== caster.id)!;
const bystander = state.players.find((p) => p.id !== caster.id && p.id !== defender.id)!;
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
giveCard(state, defender.id, "full-shield", "S", 0);
const defenderHand = () => state.players.find((p) => p.id === defender.id)!.hand.map((c) => c.instanceId).sort();
const kept = defenderHand().filter((id) => id !== "full-shield#S").sort();
state = must(state, caster.id, {
type: "cast", instanceId: chaos.instanceId, target: { kind: "player", playerId: defender.id },
});
state = must(state, defender.id, { type: "counteract", instanceId: "full-shield#S" });
state = must(state, caster.id, { type: "pass" }); // caster declines to anti-anti
state = must(state, defender.id, { type: "pass" }); // defender rests on the shield
// Bystander declines; the scramble happens without the defender.
expect(state.chaosPending?.queue[0]).toBe(bystander.id);
state = must(state, bystander.id, { type: "pass" });
expect(state.chaosPending).toBeNull();
expect(defenderHand()).toEqual(kept);
});
it("legacy games (rev < 3) keep the automatic ward", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const thief = activePlayer(state);
const owner = state.players.find((p) => p.id !== thief.id)!;
giveCard(state, owner.id, "ward", "W", 0);
const treasure = state.treasures.find((t) => t.owner === owner.id)!;
thief.position = { ...treasure.position! };
state = must(state, thief.id, { type: "pickUpTreasure" });
expect(state.players.find((p) => p.id === thief.id)!.life).toBe(12);
});
});
describe("zero-damage utility attacks", () => {
it("an uncountered DROP OBJECT is not reported as stopped — and the treasure falls", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
const treasure = state.treasures.find((t) => t.owner !== defender)!;
treasure.carriedBy = defender;
treasure.position = null;
d.carriedTreasureId = treasure.id;
const dob = giveCard(state, attacker, "drop-object");
state = must(state, attacker, {
type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" },
target: { kind: "player", playerId: defender },
});
const result = applyCommand(state, defender, { type: "pass" });
if (!result.ok) throw new Error(result.error);
const resolved = result.events.find((e) => e.type === "attackResolved");
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(false);
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBeNull();
});
it("FULL SHIELD genuinely stops it — the treasure stays carried", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
const treasure = state.treasures.find((t) => t.owner !== defender)!;
treasure.carriedBy = defender;
treasure.position = null;
d.carriedTreasureId = treasure.id;
const dob = giveCard(state, attacker, "drop-object");
giveCard(state, defender, "full-shield", "FS", 0);
state = must(state, attacker, {
type: "cast", instanceId: dob.instanceId, params: { cardId: "treasure" },
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
state = must(state, attacker, { type: "pass" });
const result = applyCommand(state, defender, { type: "pass" });
if (!result.ok) throw new Error(result.error);
const resolved = result.events.find((e) => e.type === "attackResolved");
expect(resolved && "fullyStopped" in resolved && resolved.fullyStopped).toBe(true);
expect(result.state.players.find((p) => p.id === defender)!.carriedTreasureId).toBe(treasure.id);
});
});
describe("teleport as a counteraction", () => {
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const tp = giveCard(state, defender, "teleport", "TP", 0);
const d = state.players.find((p) => p.id === defender)!;
const escape = { x: d.position.x, y: d.position.y + 1 };
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(cellKey(after.position)).toBe(cellKey(escape));
});
it("ANTI-ANTI pins the boots in pre-rev-6 games (their stored chains replay)", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
const tp = giveCard(state, defender, "teleport", "TP", 0);
const d = state.players.find((p) => p.id === defender)!;
const home = { ...d.position };
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: { x: home.x, y: home.y + 1 } } });
state = must(state, attacker, { type: "counteract", instanceId: aa.instanceId });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(cellKey(after.position)).toBe(cellKey(home));
expect(after.life).toBe(10);
});
it("reaches at most four spaces", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const tp = giveCard(state, defender, "teleport", "TP", 0);
const d = state.players.find((p) => p.id === defender)!;
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
const far = { x: d.position.x, y: d.position.y + 5 };
expect(applyCommand(state, defender, {
type: "counteract", instanceId: tp.instanceId, params: { cell: far },
}).ok).toBe(false);
});
});
describe("slime holds spells", () => {
function slimeRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
state = toRound2(state);
const caster = activePlayer(state);
const victim = state.players.find((p) => p.id !== caster.id)!;
// A slime square beside the victim, with the victim adjacent to walk in.
const spot = { x: victim.position.x, y: victim.position.y };
const cellBeside = (() => {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, spot, side);
if (t.kind === "step" && !state.players.some((p) => cellKey(p.position) === cellKey(t.to)) &&
!boardView(state).homes.some((h) => cellKey(h) === cellKey(t.to))) {
return { cell: t.to, side };
}
}
throw new Error("setup: no free square beside the victim");
})();
state.squareContents[cellKey(cellBeside.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
// The caster stands with the victim: one open edge to the slime = clear LOS.
caster.position = { ...victim.position };
return { state, caster: caster.id, victim: victim.id, slimeAt: cellBeside };
}
it("a fireball cast at the slime waits, then goes off on the next visitor", () => {
const rig = slimeRig();
let state = rig.state;
const fb = giveCard(state, rig.caster, "fireball");
state = must(state, rig.caster, {
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
});
expect(Object.keys(state.slimeTraps).length).toBe(1);
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
// The victim walks in: stuck, and the fireball erupts.
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
expect(state.stack?.attackCard?.cardId).toBe("fireball");
expect(state.stack?.trapped).toBe(true);
state = must(state, rig.victim, { type: "pass" });
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(10);
expect(Object.keys(state.slimeTraps).length).toBe(0);
});
it("reflections off a trapped spell never touch its caster", () => {
const rig = slimeRig();
let state = rig.state;
const fb = giveCard(state, rig.caster, "fireball");
giveCard(state, rig.victim, "full-reflection", "FR", 0);
state = must(state, rig.caster, {
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
});
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
state = must(state, rig.victim, { type: "move", direction: rig.slimeAt.side });
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, rig.caster, { type: "pass" });
state = must(state, rig.victim, { type: "pass" });
expect(state.players.find((p) => p.id === rig.caster)!.life).toBe(15);
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(15);
});
it("a five-point waterbolt washes the slime (and its cargo) away", () => {
const rig = slimeRig();
let state = rig.state;
const fb = giveCard(state, rig.caster, "fireball");
state = must(state, rig.caster, {
type: "cast", instanceId: fb.instanceId, target: { kind: "cell", cell: rig.slimeAt.cell },
});
state = must(state, rig.caster, { type: "endTurn", draw: 0 });
const wb = giveCard(state, rig.victim, "waterbolt", "WB", 0);
giveCard(state, rig.victim, "number-5", "N", 1);
state = must(state, rig.victim, {
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-5#N"],
target: { kind: "cell", cell: rig.slimeAt.cell },
});
expect(state.squareContents[cellKey(rig.slimeAt.cell)]).toBeUndefined();
expect(Object.keys(state.slimeTraps).length).toBe(0);
});
});
describe("answering counteractions (FAQ rulings)", () => {
function rev6() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 6 });
}
it("ABSORB SPELL steals a FULL REFLECTION out of the air", () => {
let { state } = rev6();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const ab = giveCard(state, attacker, "absorb-spell", "AB", 1);
giveCard(state, defender, "full-reflection", "FR", 0);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, attacker, { type: "counteract", instanceId: ab.instanceId });
state = must(state, defender, { type: "pass" });
const a = state.players.find((p) => p.id === attacker)!;
const d = state.players.find((p) => p.id === defender)!;
expect(a.hand.some((c) => c.instanceId === "full-reflection#FR")).toBe(true);
expect(a.life).toBe(15); // nothing reflected
expect(d.life).toBe(10); // the fireball lands after all
});
it("FULL SHIELD cannot be absorbed — it is not cast at you", () => {
let { state } = rev6();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const ab = giveCard(state, attacker, "absorb-spell", "AB", 1);
giveCard(state, defender, "full-shield", "FS", 0);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: "full-shield#FS" });
const r = applyCommand(state, attacker, { type: "counteract", instanceId: ab.instanceId });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/cannot be absorbed/);
});
it("ANTI-ANTI does not work against a teleport escape (rev 6)", () => {
let { state } = rev6();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const aa = giveCard(state, attacker, "anti-anti", "AA", 1);
const tp = giveCard(state, defender, "teleport", "TP", 0);
const d = state.players.find((p) => p.id === defender)!;
const escape = { x: d.position.x, y: d.position.y + 1 };
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "counteract", instanceId: tp.instanceId, params: { cell: escape } });
const r = applyCommand(state, attacker, { type: "counteract", instanceId: aa.instanceId });
expect(r.ok).toBe(false);
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(cellKey(after.position)).toBe(cellKey(escape));
});
});
describe("rules revision 8", () => {
it("a SPEED bonus turn burns a turn of durations on the hastened wizard", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 8 });
state = toRound2(state);
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
state.sustained.push({
id: "fx-blind", cardId: "blind", casterId: other.id, targetId: me.id,
remainingTurns: 2, data: {},
});
giveCard(state, me.id, "speed", "SP", 0);
state = must(state, me.id, { type: "cast", instanceId: "speed#SP" });
state = must(state, me.id, { type: "endTurn", draw: 0 });
// The bonus turn began: one duration turn burned, recipient-counted.
expect(activePlayer(state).id).toBe(me.id);
expect(state.sustained.find((s) => s.id === "fx-blind")!.remainingTurns).toBe(1);
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(other.id);
});
it("nothing can be created on a dimensional warp token", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 8 });
state = toRound2(state);
const me = activePlayer(state);
const spot = { x: me.position.x + 1, y: me.position.y };
state.dimWarps.push({ a: spot, b: { x: 0, y: 0 } });
const tb = giveCard(state, me.id, "thornbush", "T", 0);
const r = applyCommand(state, me.id, {
type: "cast", instanceId: tb.instanceId, target: { kind: "cell", cell: spot },
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/warp shimmers/);
});
});
describe("redirection (rules rev 9)", () => {
it("the two chosen exits connect; their old partners pair with each other", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 9 });
state = toRound2(state);
const me = activePlayer(state);
// Pick two exits that are NOT already partners.
const warps = state.board.warps;
const wa = warps[0]!;
const wb = warps.find((w) => cellKey(w.from.cell) !== cellKey(wa.from.cell) &&
cellKey(w.from.cell) !== cellKey(wa.to.cell))!;
const paCell = { ...wa.to.cell };
const pbCell = { ...wb.to.cell };
const rd = giveCard(state, me.id, "redirection", "RD", 0);
state = must(state, me.id, {
type: "cast", instanceId: rd.instanceId,
target: { kind: "cell", cell: wb.from.cell }, params: { cell: wa.from.cell },
});
const after = (from: Cell) => state.board.warps.find((w) => cellKey(w.from.cell) === cellKey(from))!;
expect(cellKey(after(wa.from.cell).to.cell)).toBe(cellKey(wb.from.cell));
expect(cellKey(after(wb.from.cell).to.cell)).toBe(cellKey(wa.from.cell));
expect(cellKey(after(paCell).to.cell)).toBe(cellKey(pbCell));
expect(cellKey(after(pbCell).to.cell)).toBe(cellKey(paCell));
});
});
+159 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId } from "../src/game"; import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, SIDES, stepTarget, type Side } from "../src/board"; import { cellKey, SIDES, stepTarget, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers"; import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
@@ -282,3 +282,161 @@ describe("expansion support cards", () => {
expect(state.creatures.length).toBe(0); expect(state.creatures.length).toBe(0);
}); });
}); });
describe("counteracting a creature's blow (rules rev 4)", () => {
function rev4() {
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 });
}
function wraithOnVictim(state: GameState) {
state = toRound2(state);
const controller = activePlayer(state);
const victim = state.players.find((p) => p.id !== controller.id)!;
const spot = emptyNeighborCell(state, controller.position);
state.creatures.push({
id: "w1", kind: "wraith", controllerId: controller.id, position: { ...controller.position },
damage: 0, maxDamage: 4, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 1, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
});
victim.position = { ...spot.cell };
return { state, controller: controller.id, victim: victim.id, side: spot.side };
}
it("BLUNT halves the wraith's touch; the card theft still lands", () => {
let { state } = rev4();
const rig = wraithOnVictim(state);
state = rig.state;
giveCard(state, rig.victim, "blunt", "B", 0);
const handBefore = state.players.find((p) => p.id === rig.victim)!.hand.filter(Boolean).length;
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
expect(state.stack?.creatureId).toBe("w1");
state = must(state, rig.victim, { type: "counteract", instanceId: "blunt#B" });
state = must(state, rig.controller, { type: "pass" });
state = must(state, rig.victim, { type: "pass" });
const v = state.players.find((p) => p.id === rig.victim)!;
expect(v.life).toBe(14); // 2 halved up to 1
expect(v.hand.length).toBe(handBefore - 2); // blunt spent + a card stolen
});
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
let { state } = rev4();
const rig = wraithOnVictim(state);
state = rig.state;
giveCard(state, rig.victim, "full-reflection", "FR", 0);
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
state = must(state, rig.controller, { type: "pass" });
state = must(state, rig.victim, { type: "pass" });
const v = state.players.find((p) => p.id === rig.victim)!;
expect(v.life).toBe(15);
const wraith = state.creatures.find((c) => c.id === "w1")!;
expect(wraith.damage).toBe(2);
});
it("legacy games (rev < 4) keep the instant touch", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
const rig = wraithOnVictim(state);
state = rig.state;
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
});
});
describe("big man (rules rev 5)", () => {
function bigRig() {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
state = toRound2(state);
const giant = activePlayer(state);
state.sustained.push({
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
remainingTurns: 9, data: {},
});
return { state, giant };
}
/** Any square with two open steps in a straight line (test surgery spot). */
function straightRun(state: GameState): { at: Cell; side: Side; mid: Cell; far: Cell } {
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const at = { x, y };
if (view.homes.some((h) => cellKey(h) === key)) continue;
for (const side of SIDES) {
const t1 = stepTarget(view, at, side);
if (t1.kind !== "step") continue;
const t2 = stepTarget(view, t1.to, side);
if (t2.kind !== "step") continue;
return { at, side, mid: t1.to, far: t2.to };
}
}
throw new Error("no straight corridor anywhere — impossible");
}
it("pushes a wizard down the corridor as he advances", () => {
const rig = bigRig();
let state = rig.state;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
const run = straightRun(state);
const giant = state.players.find((p) => p.id === rig.giant.id)!;
giant.position = { ...run.at };
victim.position = { ...run.mid };
state = must(state, rig.giant.id, { type: "move", direction: run.side });
const after = state.players.find((p) => p.id !== rig.giant.id)!;
expect(cellKey(after.position)).toBe(cellKey(run.far));
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
});
it("no room to shove means no way forward", () => {
const rig = bigRig();
const state = rig.state;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
const view = boardView(state);
// Find a step whose far side is walled: victim there cannot be pushed.
for (const side of SIDES) {
const t1 = stepTarget(view, rig.giant.position, side);
if (t1.kind !== "step") continue;
const t2 = stepTarget(view, t1.to, side);
if (t2.kind === "step") continue;
victim.position = { ...t1.to };
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
expect(r.ok).toBe(false);
return;
}
// Seed offered no walled pocket beside the home; that is fine — the
// pushable case above pins the mechanism.
});
it("monsters cannot enter the giant's square", () => {
const rig = bigRig();
let state = rig.state;
const view = boardView(state);
const spot = emptyNeighborCell(state, rig.giant.position);
state.creatures.push({
id: "t1", kind: "troll", controllerId: state.players.find((p) => p.id !== rig.giant.id)!.id,
position: { ...spot.cell },
damage: 0, maxDamage: 8, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
});
state = must(state, rig.giant.id, { type: "endTurn", draw: 0 });
const controller = activePlayer(state).id;
const back = (["N", "S", "E", "W"] as const).find((d) => {
const t = stepTarget(view, spot.cell, d);
return t.kind === "step" && cellKey(t.to) === cellKey(rig.giant.position);
})!;
const r = applyCommand(state, controller, { type: "moveCreature", creatureId: "t1", direction: back });
expect(r.ok).toBe(false);
});
it("steps over a pit for two movement points, never entering it", () => {
const rig = bigRig();
let state = rig.state;
const run = straightRun(state);
state.players.find((p) => p.id === rig.giant.id)!.position = { ...run.at };
state.squareContents[cellKey(run.mid)] = { kind: "pit", damage: 0, createdBy: rig.giant.id };
state = must(state, rig.giant.id, { type: "move", direction: run.side, over: true });
const g = state.players.find((p) => p.id === rig.giant.id)!;
expect(cellKey(g.position)).toBe(cellKey(run.far));
expect(state.turn.movementUsed).toBe(2);
expect(g.inPit).toBe(false);
});
});
+122 -2
View File
@@ -8,6 +8,7 @@
// {type:"makeTransfer"} mint a seat-transfer phrase // {type:"makeTransfer"} mint a seat-transfer phrase
// {type:"claimTransfer", code} claim a seat on a new device // {type:"claimTransfer", code} claim a seat on a new device
// {type:"catchUp", sinceSeq} replay of moves missed while away // {type:"catchUp", sinceSeq} replay of moves missed while away
// {type:"chat", text} table talk to the room
// {type:"myGames", seats} summaries for held seats // {type:"myGames", seats} summaries for held seats
// {type:"stats"} the engagement tally // {type:"stats"} the engagement tally
// {type:"hotseatReport", ...} anonymous hotseat game counts // {type:"hotseatReport", ...} anonymous hotseat game counts
@@ -17,6 +18,7 @@
// {type:"room", roomId, players, hostId, started, colors} // {type:"room", roomId, players, hostId, started, colors}
// {type:"events", events} redacted for this recipient // {type:"events", events} redacted for this recipient
// {type:"state", view, seq} redacted full view (after every change) // {type:"state", view, seq} redacted full view (after every change)
// {type:"chat", player, text, at} one line of table talk
// {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"} // {type:"transferCode"|"transferClaimed"|"catchUp"|"games"|"stats"}
// {type:"error", message} // {type:"error", message}
@@ -33,6 +35,9 @@ import {
getRoom, getRoom,
joinRoom, joinRoom,
loadPersistedRooms, loadPersistedRooms,
addAutomaton,
addChat,
driveOneAutomaton,
makeTransferCode, makeTransferCode,
redactFor, redactFor,
roomCount, roomCount,
@@ -111,7 +116,15 @@ const httpServer = createServer((req, res) => {
return; return;
} }
const body = readFileSync(real); const body = readFileSync(real);
res.writeHead(200, { "content-type": MIME[extname(real)] ?? "application/octet-stream" }); // Hashed assets may cache forever; everything else must revalidate, or a
// stale index.html pins users to a dead bundle across deploys.
const cache = real.includes(`${sep}assets${sep}`)
? "public, max-age=31536000, immutable"
: "no-cache";
res.writeHead(200, {
"content-type": MIME[extname(real)] ?? "application/octet-stream",
"cache-control": cache,
});
res.end(body); res.end(body);
} catch { } catch {
res.writeHead(500).end(); res.writeHead(500).end();
@@ -159,6 +172,9 @@ function roomInfo(room: Room) {
hostId: room.hostId, hostId: room.hostId,
started: room.state !== null, started: room.state !== null,
colors: Object.fromEntries(room.colorChoices), colors: Object.fromEntries(room.colorChoices),
bots: Object.fromEntries(
[...room.bots].map(([name, b]) => [name, `${b.tier} ${b.secret ? "mystery" : b.style}`]),
),
}; };
} }
@@ -170,6 +186,75 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
} }
} }
/**
* The clockwork plays at a watchable pace: one command every beat, each
* broadcast as it lands, until the maze wants a human again.
*/
const BOT_STEP_MS = 1500;
/** The clockwork occasionally speaks. Sparingly — menace over chatter. */
const BOT_LINES: Record<string, Record<string, string[]>> = {
hunter: {
treasurePickedUp: ["ACQUISITION COMPLETE.", "YOUR GOLD HAS BEEN REALLOCATED."],
treasureDropped: ["DELIVERY CONFIRMED. PLEASURE DOING BUSINESS."],
damaged: ["AN INEFFICIENT USE OF YOUR TURN."],
died: ["INVENTORY TRANSFERRED. CONDOLENCES."],
},
berserker: {
treasurePickedUp: ["GOLD IS MERELY BAIT."],
damaged: ["YES. AGAIN.", "PAIN RECEIPT PRINTED."],
died: ["SCHEDULED DEMISE: DELIVERED.", "NEXT."],
creatureCreated: ["I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY."],
},
worrier: {
treasurePickedUp: ["taking this. sorry. sorry."],
damaged: ["ow. logged.", "unnecessary!"],
wallCreated: ["good wall. safe wall."],
died: ["oh no. oh no. it worked?"],
},
};
function botRemark(room: Room, seat: string, events: { type: string; [k: string]: unknown }[]): void {
const bot = room.bots.get(seat);
if (!bot) return;
const lines = BOT_LINES[bot.style] ?? {};
for (const e of events) {
// Speak only about its own deeds, and rarely.
const mine =
(e.type === "treasurePickedUp" && e.player === seat) ||
(e.type === "treasureDropped" && e.player === seat) ||
(e.type === "damaged" && e.player !== seat) ||
(e.type === "died" && e.killedBy === seat) ||
(e.type === "creatureCreated" && e.controller === seat) ||
(e.type === "wallCreated" && e.caster === seat);
const pool = lines[e.type];
if (!mine || !pool || Math.random() > 0.5) continue;
const text = pool[Math.floor(Math.random() * pool.length)]!;
const said = addChat(room, seat, text);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
}
return; // one remark per step at most
}
}
const pumping = new Set<string>();
function runBots(room: Room): void {
if (pumping.has(room.id)) return;
pumping.add(room.id);
const tick = () => {
const step = driveOneAutomaton(room);
if (!step) {
pumping.delete(room.id);
return;
}
broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
botRemark(room, step.seat, step.events as { type: string }[]);
setTimeout(tick, BOT_STEP_MS);
};
setTimeout(tick, 800); // a beat after the human's own action settles
}
function broadcastRoomState(room: Room): void { function broadcastRoomState(room: Room): void {
broadcast(room, () => roomInfo(room)); broadcast(room, () => roomInfo(room));
if (room.state) { if (room.state) {
@@ -239,6 +324,20 @@ wss.on("connection", (socket) => {
send(socket, { type: "events", events: redactFor(room.events, name) }); send(socket, { type: "events", events: redactFor(room.events, name) });
} }
broadcastRoomState(room); broadcastRoomState(room);
runBots(room);
break;
}
case "addBot": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host seats automatons" });
const result = addAutomaton(
room,
typeof msg.style === "string" ? msg.style : undefined,
typeof msg.tier === "string" ? msg.tier : undefined,
);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcastRoomState(room);
break; break;
} }
case "start": { case "start": {
@@ -249,6 +348,7 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error }); if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcastRoomState(room); broadcastRoomState(room);
runBots(room);
break; break;
} }
case "command": { case "command": {
@@ -261,6 +361,26 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error }); if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) })); broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
runBots(room);
break;
}
case "rollDie": {
// The tabletop D4 for house calls: publishes to the room's talk,
// so it persists, replays, and shows in unread badges like chat.
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const roll = 1 + Math.floor(Math.random() * 4);
const result = addChat(room, session.playerId, `rolls the die \u2014 ${roll}`);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
break;
}
case "chat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = addChat(room, session.playerId, String(msg.text ?? ""));
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, () => ({ type: "chat", player: session.playerId, text: result.text, at: result.at }));
break; break;
} }
case "makeTransfer": { case "makeTransfer": {
@@ -293,7 +413,7 @@ wss.on("connection", (socket) => {
return send(socket, { type: "error", message: "catching up already — one moment" }); return send(socket, { type: "error", message: "catching up already — one moment" });
} }
session.lastCatchUpAt = now; session.lastCatchUpAt = now;
const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0)); const steps = catchUpSteps(room, session.playerId, Number(msg.sinceSeq ?? 0), msg.full === true);
if ("error" in steps) return send(socket, { type: "error", message: steps.error }); if ("error" in steps) return send(socket, { type: "error", message: steps.error });
send(socket, { type: "catchUp", steps }); send(socket, { type: "catchUp", steps });
break; break;
+132 -7
View File
@@ -15,8 +15,16 @@ import {
type GameView, type GameView,
type PlayerId, type PlayerId,
} from "@wizwar/engine"; } from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, type RoomLine } from "./store"; import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats"; import { recordRoom } from "./stats";
import {
automatonCommand,
automatonFallback,
AUTOMATON_STYLES,
AUTOMATON_TIERS,
type AutomatonStyle,
type AutomatonTier,
} from "@wizwar/engine";
export interface LoggedCommand { export interface LoggedCommand {
seq: number; seq: number;
@@ -39,10 +47,17 @@ export interface Room {
state: GameState | null; // null until started state: GameState | null; // null until started
log: LoggedCommand[]; log: LoggedCommand[];
events: GameEvent[]; // full history (unredacted — redact per recipient) events: GameEvent[]; // full history (unredacted — redact per recipient)
/** Table talk, persisted with the room (public to all seats). */
chat: { player: PlayerId; text: string; at: string }[];
/** Seats the server itself plays: temperament, tier, and secrecy. */
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
} }
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 9;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */ /** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
@@ -69,7 +84,9 @@ function makeRoomCode(): string {
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
code += ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)]; code += ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)];
} }
return rooms.has(code) ? makeRoomCode() : code; // The disk check covers rooms that exist on file but failed to restore —
// reusing such a code would append a new game into the orphaned file.
return rooms.has(code) || roomFileExists(code) ? makeRoomCode() : code;
} }
export function roomCount(): number { export function roomCount(): number {
@@ -90,6 +107,8 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
state: null, state: null,
log: [], log: [],
events: [], events: [],
chat: [],
bots: new Map(),
}; };
rooms.set(room.id, room); rooms.set(room.id, room);
recordRoom(room); recordRoom(room);
@@ -176,9 +195,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } { export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" }; if (room.state) return { error: "already started" };
const colors = resolveColors(room); const colors = resolveColors(room);
const result = startInMemory(room, expansion, colors, 2); const result = startInMemory(room, expansion, colors, RULES_REV);
if ("error" in result) return result; if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion, colors, deckRev: 2 }); appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV });
recordRoom(room); recordRoom(room);
return result; return result;
} }
@@ -205,6 +224,85 @@ export function runCommand(
return { events: result.events }; return { events: result.events };
} }
const CHAT_MAX_LENGTH = 300;
const CHAT_KEEP = 500;
export function addChat(room: Room, playerId: PlayerId, rawText: string): { text: string; at: string } | { error: string } {
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
const text = rawText.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, CHAT_MAX_LENGTH);
if (!text) return { error: "say something" };
const at = new Date().toISOString();
room.chat.push({ player: playerId, text, at });
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
// Talk takes its place in the chronicle where it was said, so rejoining
// players see it interwoven with the action, not sunk to the bottom.
room.events.push({ type: "tableTalk", player: playerId, text });
appendLine(room.id, { kind: "chat", player: playerId, text, at });
return { text, at };
}
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
/** Seat a clockwork wizard (host's choice, before the game starts). */
export function addAutomaton(
room: Room,
styleWanted?: string,
tierWanted?: string,
): { name: PlayerId } | { error: string } {
if (room.state) return { error: "the game has started" };
if (room.players.length >= 6) return { error: "room is full" };
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
if (!name) return { error: "the workshop is empty" };
const known = AUTOMATON_STYLES.includes(styleWanted as AutomatonStyle);
const style: AutomatonStyle = known
? (styleWanted as AutomatonStyle)
: AUTOMATON_STYLES[randomInt(AUTOMATON_STYLES.length)]!;
const secret = !known; // the mystery machine keeps its mood to itself
const tier: AutomatonTier = AUTOMATON_TIERS.includes(tierWanted as AutomatonTier)
? (tierWanted as AutomatonTier)
: "adept";
room.players.push(name);
room.bots.set(name, { style, secret, tier });
appendLine(room.id, { kind: "join", name, bot: true, style, tier, ...(secret ? { secret: true } : {}) });
return { name };
}
/** Whose input does the maze want right now? */
function actingSeat(room: Room): PlayerId | null {
const s = room.state;
if (!s || s.phase !== "playing") return null;
return (
s.stack?.waitingOn ??
s.pendingDiscard ??
s.chaosPending?.queue[0] ??
s.outOfTurnWindow?.playerId ??
s.players[s.turn.activeIndex]!.id
);
}
/**
* One automaton command, if the maze is waiting on clockwork. Null when a
* human holds the floor (or the game is over, or the clockwork is wedged).
*/
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat);
const cmd = automatonCommand(view, bot?.style, bot?.tier) ?? automatonFallback(view);
let r = runCommand(room, seat, cmd);
if ("error" in r) {
r = runCommand(room, seat, automatonFallback(view));
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) {
console.error(`automaton ${seat} wedged in ${room.id}: ${r.error}`);
return null;
}
}
return { seat, events: r.events };
}
export interface GameSummary { export interface GameSummary {
roomId: string; roomId: string;
name: PlayerId; name: PlayerId;
@@ -218,18 +316,21 @@ export interface GameSummary {
attention: "turn" | "counteract" | "discard" | "interrupt" | null; attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null; round: number | null;
lastMoveAt: string | null; lastMoveAt: string | null;
/** Total table-talk messages; the client tracks which it has seen. */
chatCount: number;
} }
/** A seat-holder's one-line view of a room, for the lobby ledger. */ /** A seat-holder's one-line view of a room, for the lobby ledger. */
export function summarize(room: Room, playerId: PlayerId): GameSummary { export function summarize(room: Room, playerId: PlayerId): GameSummary {
const s = room.state; const s = room.state;
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null; const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null; const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null;
const turnHolder = waitingOn ?? active; const turnHolder = waitingOn ?? active;
let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null; let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null;
if (s?.phase === "playing" && turnHolder === playerId) { if (s?.phase === "playing" && turnHolder === playerId) {
attention = attention =
s.stack?.waitingOn === playerId ? "counteract" s.stack?.waitingOn === playerId ? "counteract"
: s.chaosPending?.queue[0] === playerId ? "counteract"
: s.pendingDiscard === playerId ? "discard" : s.pendingDiscard === playerId ? "discard"
: s.outOfTurnWindow?.playerId === playerId ? "interrupt" : s.outOfTurnWindow?.playerId === playerId ? "interrupt"
: "turn"; : "turn";
@@ -246,6 +347,7 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary {
attention, attention,
round: s?.turn.round ?? null, round: s?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
chatCount: room.chat.length,
}; };
} }
@@ -258,17 +360,20 @@ export interface CatchUpStep {
actor: PlayerId; actor: PlayerId;
events: GameEvent[]; events: GameEvent[];
view: GameView; view: GameView;
/** Table talk uttered between the previous command and this one. */
chat?: { player: PlayerId; text: string }[];
} }
/** /**
* Rebuild the game and capture a redacted view after each command from * Rebuild the game and capture a redacted view after each command from
* `sinceSeq` on — the "what happened while you were away" reel. * `sinceSeq` on — the "what happened while you were away" reel.
*/ */
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number): CatchUpStep[] | { error: string } { export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, full = false): CatchUpStep[] | { error: string } {
if (!room.state) return { error: "game not started" }; if (!room.state) return { error: "game not started" };
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" }; if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
if (full && room.state.phase !== "finished") return { error: "full replays wait for the game to finish" };
const MAX_STEPS = 200; const MAX_STEPS = 200;
const from = Math.max(sinceSeq, room.log.length - MAX_STEPS); const from = full ? 0 : Math.max(sinceSeq, room.log.length - MAX_STEPS);
const { state: fresh } = createGame(room.state.config); const { state: fresh } = createGame(room.state.config);
let current = fresh; let current = fresh;
const steps: CatchUpStep[] = []; const steps: CatchUpStep[] = [];
@@ -277,11 +382,16 @@ export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number):
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` }; if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
current = result.state; current = result.state;
if (entry.seq >= from) { if (entry.seq >= from) {
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
const said = room.chat
.filter((c) => c.at > prevAt && c.at <= entry.at)
.map((c) => ({ player: c.player, text: c.text }));
steps.push({ steps.push({
seq: entry.seq, seq: entry.seq,
actor: entry.playerId, actor: entry.playerId,
events: redactFor(result.events, playerId), events: redactFor(result.events, playerId),
view: viewFor(current, playerId), view: viewFor(current, playerId),
...(said.length > 0 ? { chat: said } : {}),
}); });
} }
} }
@@ -389,16 +499,31 @@ export function loadPersistedRooms(): void {
state: null, state: null,
log: [], log: [],
events: [], events: [],
chat: [],
bots: new Map(),
}; };
for (const line of lines.slice(1)) { for (const line of lines.slice(1)) {
if (line.kind === "join") { if (line.kind === "join") {
if (line.bot) {
room.players.push(line.name);
room.bots.set(line.name, {
style: (line.style as AutomatonStyle) ?? "hunter",
secret: line.secret === true,
tier: (line.tier as AutomatonTier) ?? "archmage",
});
} else {
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null); const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
if (!joinHash) throw new Error("join line has no token"); if (!joinHash) throw new Error("join line has no token");
room.players.push(line.name); room.players.push(line.name);
room.tokens.set(line.name, joinHash); room.tokens.set(line.name, joinHash);
}
} else if (line.kind === "start") { } else if (line.kind === "start") {
const r = startInMemory(room, line.expansion, line.colors, line.deckRev); const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
if ("error" in r) throw new Error(`replay start failed: ${r.error}`); if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
} else if (line.kind === "chat") {
// File order preserves the interleaving with commands.
room.chat.push({ player: line.player, text: line.text, at: line.at });
room.events.push({ type: "tableTalk", player: line.player, text: line.text });
} else if (line.kind === "command") { } else if (line.kind === "command") {
if (!room.state) throw new Error("command before start in log"); if (!room.state) throw new Error("command before start in log");
const result = applyCommand(room.state, line.playerId, line.command as Command); const result = applyCommand(room.state, line.playerId, line.command as Command);
+5 -1
View File
@@ -24,6 +24,8 @@ export interface EngagementStats {
firstGameAt: string | null; firstGameAt: string | null;
/** How many of the games were hotseat tables reporting in anonymously. */ /** How many of the games were hotseat tables reporting in anonymously. */
hotseatGames: number; hotseatGames: number;
/** Games with at least one clockwork wizard at the table. */
automatonGames: number;
} }
interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> { interface StatsFile extends Omit<EngagementStats, "wizardsSeated"> {
@@ -40,7 +42,7 @@ let data: StatsFile = {
commandsPlayed: 0, minutesAtTable: 0, commandsPlayed: 0, minutesAtTable: 0,
winsByTreasure: 0, winsByLastStanding: 0, winsByTreasure: 0, winsByLastStanding: 0,
longestGameCommands: 0, fullestTable: 0, firstGameAt: null, longestGameCommands: 0, fullestTable: 0, firstGameAt: null,
hotseatGames: 0, wizards: [], roomStages: {}, hotseatGames: 0, automatonGames: 0, wizards: [], roomStages: {},
}; };
let wizardSet = new Set<string>(); let wizardSet = new Set<string>();
let loaded = false; let loaded = false;
@@ -84,6 +86,7 @@ export function recordRoom(room: Room): void {
const stage = data.roomStages[room.id]; const stage = data.roomStages[room.id];
for (const p of room.players) { for (const p of room.players) {
if (room.bots.has(p)) continue; // the clockwork are not wizards seated
const key = p.toLowerCase(); const key = p.toLowerCase();
if (!wizardSet.has(key)) { wizardSet.add(key); dirty = true; } if (!wizardSet.has(key)) { wizardSet.add(key); dirty = true; }
} }
@@ -95,6 +98,7 @@ export function recordRoom(room: Room): void {
} }
if (room.state && data.roomStages[room.id] === "created") { if (room.state && data.roomStages[room.id] === "created") {
data.gamesStarted++; data.gamesStarted++;
if (room.bots.size > 0) data.automatonGames++;
data.fullestTable = Math.max(data.fullestTable, room.players.length); data.fullestTable = Math.max(data.fullestTable, room.players.length);
data.roomStages[room.id] = "started"; data.roomStages[room.id] = "started";
dirty = true; dirty = true;
+22 -2
View File
@@ -3,7 +3,7 @@
// command. Because the engine is deterministic, replaying a file rebuilds // command. Because the engine is deterministic, replaying a file rebuilds
// the exact game state — server restarts lose nothing. // the exact game state — server restarts lose nothing.
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
export interface RoomMetaLine { export interface RoomMetaLine {
@@ -24,6 +24,14 @@ export interface JoinLine {
tokenHash?: string; tokenHash?: string;
/** Legacy plaintext token (pre-hashing files only). */ /** Legacy plaintext token (pre-hashing files only). */
token?: string; token?: string;
/** An automaton seat: no token; the server plays it. */
bot?: true;
/** The automaton's temperament. */
style?: string;
/** The automaton's difficulty tier. */
tier?: string;
/** A mystery machine: the temperament is not revealed to the table. */
secret?: true;
} }
export interface StartLine { export interface StartLine {
@@ -43,7 +51,14 @@ export interface CommandLine {
at: string; at: string;
} }
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine; export interface ChatLine {
kind: "chat";
player: string;
text: string;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
@@ -60,6 +75,11 @@ export function ensureDataDir(): void {
mkdirSync(DATA_DIR, { recursive: true }); mkdirSync(DATA_DIR, { recursive: true });
} }
/** A room file may exist even when the room failed to restore into memory. */
export function roomFileExists(roomId: string): boolean {
return existsSync(fileFor(roomId));
}
export function appendLine(roomId: string, line: RoomLine): void { export function appendLine(roomId: string, line: RoomLine): void {
appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8"); appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8");
} }
+319 -28
View File
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { attentionLabel, net } from "./net.svelte"; import { attentionLabel, net, spellName } from "./net.svelte";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import { PLAYER_COLORS, wizardColor } from "./colors"; import { PLAYER_COLORS, wizardColor } from "./colors";
import Faq from "./Faq.svelte";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Help from "./Help.svelte"; import Help from "./Help.svelte";
import Replay from "./Replay.svelte"; import Replay from "./Replay.svelte";
import { local } from "./local.svelte"; import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView } from "@wizwar/engine"; import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine";
net.connect(); net.connect();
@@ -18,6 +19,12 @@
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);
/** Quiet mode: skip the attack announcement modal (device preference). */
let noFanfare = $state(localStorage.getItem("wizwar-no-fanfare") === "1");
function setFanfare(announce: boolean) {
noFanfare = !announce;
localStorage.setItem("wizwar-no-fanfare", announce ? "0" : "1");
}
/** Which pending attack the player has already acknowledged (modal dismissed). */ /** Which pending attack the player has already acknowledged (modal dismissed). */
let attackNoticeSeen = $state<string | null>(null); let attackNoticeSeen = $state<string | null>(null);
/** A face-up card being peeked at from the scoresheet (view-only). */ /** A face-up card being peeked at from the scoresheet (view-only). */
@@ -26,6 +33,14 @@
let peekCreatureId = $state<string | null>(null); let peekCreatureId = $state<string | null>(null);
/** Bare-knuckle demolition: click a wall to punch it. */ /** Bare-knuckle demolition: click a wall to punch it. */
let punchWallMode = $state(false); let punchWallMode = $state(false);
/** Leafing through the face-up discard pile. */
let showDiscards = $state(false);
let chatDraft = $state("");
let botTier = $state("adept");
/** Card whose official FAQ rulings are open. */
let faqCardId = $state<string | null>(null);
/** A discard-pile card enlarged above the pile. */
let discardPeek = $state<CardInstance | null>(null);
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play"); let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
let hotseatCount = $state(2); let hotseatCount = $state(2);
let setupName = $state(""); let setupName = $state("");
@@ -79,16 +94,34 @@
let discardMode = $state(false); let discardMode = $state(false);
const view = $derived(local.active ? local.view : net.view); const view = $derived(local.active ? local.view : net.view);
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack); const isYourTurn = $derived(
view !== null && view.phase === "playing" && view.activePlayerId === view.you && !view.stack,
);
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you); const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
const attackNoticeKey = $derived( const attackNoticeKey = $derived(
youMustRespond && view?.stack youMustRespond && view?.stack
? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? "punch"}:${view.stack.counters.length}` ? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}`
: null, : null,
); );
const attackingCreature = $derived(
view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null,
);
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you); const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
/** A live moment to interrupt: another wizard acts, no attack is pending. */
const canInterruptNow = $derived(
view != null && !isYourTurn && !view.stack && !view.chaosPending &&
!view.outOfTurnWindow && view.phase === "playing" && view.turn.round > 1 && !local.active,
);
const interruptCards = $derived(
canInterruptNow
? view!.yourHand.filter((c) => c.cardId === "interrupt" || c.cardId === "opportunity-fire")
: [],
);
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null); const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
const WAND_IDS = new Set(["blaster-wand", "sticky-wand", "shift-wand", "warp-wand"]);
const EDGE_CARDS = new Set([ const EDGE_CARDS = new Set([
"create-wall", "destroy-wall", "illusion-wall", "wall-of-fire", "waterwall", "create-wall", "destroy-wall", "illusion-wall", "wall-of-fire", "waterwall",
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation", "pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
@@ -181,7 +214,11 @@
if (!view) return; if (!view) return;
peekCard = null; peekCard = null;
peekCreatureId = null; peekCreatureId = null;
if (youMustRespond) { if (youMustRespond || youMustShield) {
if (youMustRespond && card.cardId === "teleport") {
selectedCard = card; // then click the escape square
return;
}
dispatch({ type: "counteract", instanceId: card.instanceId }); dispatch({ type: "counteract", instanceId: card.instanceId });
return; return;
} }
@@ -298,6 +335,11 @@
function clickCell(cell: { x: number; y: number }) { function clickCell(cell: { x: number; y: number }) {
if (!view) return; if (!view) return;
if (youMustRespond && selectedCard?.cardId === "teleport") {
dispatch({ type: "counteract", instanceId: selectedCard.instanceId, params: { cell } });
clearSelection();
return;
}
if (!isYourTurn) { peekAt(cell); return; } if (!isYourTurn) { peekAt(cell); return; }
if (pendingCellFor && selectedCard) { if (pendingCellFor && selectedCard) {
// Stage 2 of teleport-opponent: destination chosen. // Stage 2 of teleport-opponent: destination chosen.
@@ -350,6 +392,7 @@
dispatch({ dispatch({
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;
@@ -409,6 +452,21 @@
return; return;
} }
} }
// BIG MAN: clicking two squares away over a pit/tacks/ooze leaps it.
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === view!.you)) {
for (const side of SIDES) {
const mid = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
const far = { x: me.position.x + (side === "E" ? 2 : side === "W" ? -2 : 0),
y: me.position.y + (side === "S" ? 2 : side === "N" ? -2 : 0) };
const hazard = view.squareContents[cellKey(mid)]?.kind;
if (cellKey(far) === cellKey(cell) &&
(hazard === "pit" || hazard === "tacks" || hazard === "ooze")) {
dispatch({ type: "move", direction: side, over: true });
return;
}
}
}
// The click claimed no action — offer the square's card instead. // The click claimed no action — offer the square's card instead.
peekAt(cell); peekAt(cell);
} }
@@ -420,6 +478,11 @@
tacks: "handful-of-tacks", pit: "create-pit", safe: "safe", tacks: "handful-of-tacks", pit: "create-pit", safe: "safe",
}; };
/** Sustained ids include engine constructs the card library cannot show. */
function realCard(cardId: string): boolean {
try { cardDef(cardId); return true; } catch { return false; }
}
function creatureStats(c: NonNullable<GameView["creatures"]>[number]): string { function creatureStats(c: NonNullable<GameView["creatures"]>[number]): string {
const hp = Number.isFinite(c.maxDamage) const hp = Number.isFinite(c.maxDamage)
? `${c.maxDamage - c.damage} of ${c.maxDamage} hits left` ? `${c.maxDamage - c.damage} of ${c.maxDamage} hits left`
@@ -475,11 +538,13 @@
return; return;
} }
if (!selectedCard || !edgeSelectMode) return; if (!selectedCard || !edgeSelectMode) return;
// The attached number rides along for every edge cast: wand charges,
// wall-of-fire durations, wall attacks alike.
dispatch({ dispatch({
type: "cast", type: "cast",
instanceId: selectedCard.instanceId, instanceId: selectedCard.instanceId,
target: { kind: "edge", cell, side }, target: { kind: "edge", cell, side },
...(attackVsWall && attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}), ...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
}); });
clearSelection(); clearSelection();
} }
@@ -586,6 +651,9 @@
if (!view || !selectedDef || !isYourTurn) return null; if (!view || !selectedDef || !isYourTurn) 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;
if (CELL_CARDS.has(selectedCard!.cardId)) {
return eligibleCellsFor(view, selectedCard!.cardId);
}
if (selectedDef.adjacent === true) { if (selectedDef.adjacent === true) {
const { x, y } = me.position; const { x, y } = me.position;
return new Set( return new Set(
@@ -694,12 +762,47 @@
</span> </span>
</header> </header>
{#if peekCard} {#if showDiscards && view}
<div class="inspector" role="img" aria-label="displayed card, enlarged"> <div class="scrim attack-scrim" role="button" tabindex="-1" onclick={() => (showDiscards = false)} onkeydown={() => {}}>
<button class="inspector-close" onclick={() => { peekCard = null; peekCreatureId = null; }} aria-label="hide card">×</button> <div class="discard-book" role="dialog" aria-label="the discard pile" tabindex="-1"
<div class="inspector-card"><Card card={peekCard} /></div> onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
{#if peekNote}<div class="peek-note">{peekNote}</div>{/if} <header class="discard-head">
<span>The discard pile — {view.discardCount} card{view.discardCount === 1 ? "" : "s"}, newest first</span>
<button class="inspector-close discard-close" onclick={() => (showDiscards = false)} aria-label="close">×</button>
</header>
<div class="discard-grid">
{#each [...view.discardPile].reverse() as card (card.instanceId)}
<Card {card} onclick={() => (discardPeek = card)} onfaq={(id) => (faqCardId = id)} />
{/each}
{#if view.discardPile.length === 0}
<span class="discard-empty">Nothing has been cast away yet.</span>
{/if}
</div> </div>
</div>
</div>
{/if}
{#if peekCard}
<div class="big-peek-scrim" role="button" tabindex="-1"
onclick={() => { peekCard = null; peekCreatureId = null; }} onkeydown={() => {}}>
<div class="big-peek">
<Card card={peekCard} onfaq={(id) => (faqCardId = id)} />
{#if peekNote}<div class="peek-note big-peek-note">{peekNote}</div>{/if}
</div>
</div>
{/if}
{#if discardPeek}
<div class="big-peek-scrim" role="button" tabindex="-1"
onclick={() => (discardPeek = null)} onkeydown={() => {}}>
<div class="big-peek">
<Card card={discardPeek} onfaq={(id) => (faqCardId = id)} />
</div>
</div>
{/if}
{#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if} {/if}
{#if showHelp} {#if showHelp}
@@ -725,17 +828,21 @@
</div> </div>
{/if} {/if}
{#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey} {#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey && !noFanfare}
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack"> <div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
<div class="attack-notice"> <div class="attack-notice">
<div class="attack-headline"> <div class="attack-headline">
{#if view.stack.defenderId === view.you} {#if view.stack.defenderId === view.you && attackingCreature}
<strong>{view.stack.attackerId}</strong>'s {cardDef(attackingCreature.kind).name} attacks you!
{:else if view.stack.defenderId === view.you}
<strong>{view.stack.attackerId}</strong> attacks you! <strong>{view.stack.attackerId}</strong> attacks you!
{:else} {:else}
<strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell! <strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell!
{/if} {/if}
</div> </div>
{#if view.stack.defenderId === view.you && view.stack.attackCard} {#if view.stack.defenderId === view.you && attackingCreature}
<div class="attack-card"><Card card={{ instanceId: `atk-${attackingCreature.id}`, cardId: attackingCreature.kind }} /></div>
{:else if view.stack.defenderId === view.you && view.stack.attackCard}
<div class="attack-card"><Card card={view.stack.attackCard} /></div> <div class="attack-card"><Card card={view.stack.attackCard} /></div>
{#if view.stack.numberValue != null} {#if view.stack.numberValue != null}
<div class="attack-power">powered by a {view.stack.numberValue}</div> <div class="attack-power">powered by a {view.stack.numberValue}</div>
@@ -752,6 +859,8 @@
{#if net.catchUp && net.catchUp.length > 0} {#if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} /> <Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0}
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
{/if} {/if}
{#if net.error} {#if net.error}
@@ -875,6 +984,9 @@
notify me on my turn notify me on my turn
</button> </button>
{/if} {/if}
{#if noFanfare}
<button class="hint-cancel" onclick={() => setFanfare(true)}>announce attacks</button>
{/if}
</div> </div>
{#each net.seats as seat (seat.roomId + seat.name)} {#each net.seats as seat (seat.roomId + seat.name)}
{@const g = net.games.find((x) => x.roomId === seat.roomId && x.name === seat.name)} {@const g = net.games.find((x) => x.roomId === seat.roomId && x.name === seat.name)}
@@ -889,9 +1001,9 @@
{:else if !g.started} {:else if !g.started}
waiting to start · {g.players.join(", ")} waiting to start · {g.players.join(", ")}
{:else if g.yourTurn} {:else if g.yourTurn}
{attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)} {attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
{:else} {:else}
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)} {g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}{#if net.unreadChat(g.roomId, g.chatCount) > 0}&nbsp;· 💬{net.unreadChat(g.roomId, g.chatCount)}{/if}
{/if} {/if}
</span> </span>
</button> </button>
@@ -917,7 +1029,7 @@
{:else} {:else}
<span class="dot" style:background="#b3a687"></span> <span class="dot" style:background="#b3a687"></span>
{/if} {/if}
{p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""} {p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -938,6 +1050,20 @@
{/each} {/each}
</div> </div>
{#if net.you === net.hostId} {#if net.you === net.hostId}
{#if net.players.length < 6}
<span class="bot-row">
⚙ seat a
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty">
<option value="apprentice">apprentice</option>
<option value="adept">adept</option>
<option value="archmage">archmage</option>
</select>
<button class="stamp tiny" onclick={() => net.addBot("hunter", botTier)}>hunter</button>
<button class="stamp tiny" onclick={() => net.addBot("berserker", botTier)}>berserker</button>
<button class="stamp tiny" onclick={() => net.addBot("worrier", botTier)}>worrier</button>
<button class="stamp tiny" onclick={() => net.addBot(undefined, botTier)}>mystery</button>
</span>
{/if}
<label class="check"> <label class="check">
<input type="checkbox" bind:checked={withExpansion} /> <input type="checkbox" bind:checked={withExpansion} />
Include Expansion Set #1 — monsters &amp; wands Include Expansion Set #1 — monsters &amp; wands
@@ -1002,7 +1128,23 @@
{:else} {:else}
<div class="slip">{view.activePlayerId} is taking their turn…</div> <div class="slip">{view.activePlayerId} is taking their turn…</div>
{/if} {/if}
{#if interruptCards.length > 0}
<div class="slip urgent">
⚡ Your {cardDef(interruptCards[0]!.cardId).name} can seize this moment — tap the glowing card.
</div>
{/if}
{#if view.yourWardArmed}
<div class="slip ambush-note">🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.</div>
{/if}
{#if youMustShield}
<div class="slip urgent">
Chaos comes for your hand — tap your Full Shield to sit out, or
<button class="stamp tiny" onclick={pass}>let it take you</button>
</div>
{:else if view.chaosPending}
<div class="slip">Chaos gathers — waiting on {view.chaosPending.queue[0]}…</div>
{/if}
{#each idleCreatures as c (c.id)} {#each idleCreatures as c (c.id)}
{@const moves = c.movesPerTurn - c.movementUsed} {@const moves = c.movesPerTurn - c.movementUsed}
<div class="slip creature-note"> <div class="slip creature-note">
@@ -1029,7 +1171,10 @@
{#each view.players as p (p.id)} {#each view.players as p (p.id)}
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}> <div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
<span class="dot" style:background={playerColor(p.id)}></span> <span class="dot" style:background={playerColor(p.id)}></span>
<span class="score-name">{p.id}{p.id === view.you ? " (you)" : ""}</span> <span class="score-name">
{p.id}{p.id === view.you ? " (you)" : ""}{#if net.roomBots[p.id]}
<span class="score-temper">⚙ {net.roomBots[p.id]}</span>{/if}
</span>
<span class="score-life">{p.life}</span> <span class="score-life">{p.life}</span>
<span class="score-marks"> <span class="score-marks">
{p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if p.carriedTreasureId}&nbsp;· 💰{/if} {p.handCount} cards{#if p.lostTurns > 0}&nbsp;· dazed {p.lostTurns}{/if}{#if p.carriedTreasureId}&nbsp;· 💰{/if}
@@ -1045,23 +1190,46 @@
{/each} {/each}
{#each spellsOn as e (e.id)} {#each spellsOn as e (e.id)}
<button class="table-card spell-chip" onclick={() => { <button class="table-card spell-chip" onclick={() => {
if (!realCard(e.cardId)) return;
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId }; peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
peekCreatureId = null; peekCreatureId = null;
}}> }}>
✦ {cardDef(e.cardId).name} · {e.remainingTurns} ✦ {spellName(e.cardId)} · {e.remainingTurns}
</button> </button>
{/each} {/each}
</div> </div>
{/if} {/if}
{/each} {/each}
<div class="deck-line">draw pile {view.deckCount} · discards {view.discardCount}</div> <div class="deck-line">draw pile {view.deckCount} ·
<button class="discard-link" onclick={() => (showDiscards = true)}>discards {view.discardCount}</button>
</div>
</div> </div>
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}> <div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)} {#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
<div>{line}</div> <div class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
{/each} {/each}
</div> </div>
{#if local.active && local.viewerId}
<div class="say-box">
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => local.rollTableDie()}>🎲 roll the die</button>
</div>
{/if}
{#if !local.active && net.roomId}
<form class="say-box" onsubmit={(e) => {
e.preventDefault();
const t = chatDraft.trim();
if (t) net.sendChat(t);
chatDraft = "";
}}>
<input class="say-input" bind:value={chatDraft} maxlength="300"
placeholder="say something to the table…" aria-label="table talk" />
<button class="stamp tiny" type="submit" disabled={!chatDraft.trim()}>say</button>
<button class="stamp tiny" type="button" title="roll the die (house calls)"
onclick={() => net.rollTableDie()}>🎲</button>
</form>
{/if}
</aside> </aside>
</div> </div>
@@ -1095,9 +1263,11 @@
{/if} {/if}
{#if youMustRespond && view.stack} {#if youMustRespond && view.stack}
<span class="hint-alert"> <span class="hint-alert">
{#if view.stack.defenderId === view.you} {#if view.stack.defenderId === view.you && selectedCard?.cardId === "teleport"}
Escaping by teleport — click a square within four spaces.
{:else if view.stack.defenderId === view.you}
{view.stack.attackerId} attacks with {view.stack.attackerId} attacks with
{view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} — {attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} —
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" : ""}
@@ -1119,7 +1289,9 @@
{#if selectedDef} {#if selectedDef}
<strong class="hint-name">{selectedDef.name}</strong> <strong class="hint-name">{selectedDef.name}</strong>
{#if edgeSelectMode}<span>— click a wall line</span>{/if} {#if edgeSelectMode}<span>— click a wall line</span>{/if}
{#if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")} {#if selectedCard && WAND_IDS.has(selectedCard.cardId) && view.wandCharges[selectedCard.instanceId] == null && !attachedNumber}
<span>— first use: tap a NUMBER card to set the wand's charges, then click a target</span>
{:else if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
<span> click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}</span> <span> click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}</span>
{/if} {/if}
{#if attachedMods.length > 0} {#if attachedMods.length > 0}
@@ -1128,7 +1300,9 @@
{#if selectedCard?.cardId === "boobytrap"} {#if selectedCard?.cardId === "boobytrap"}
<span> place 4 tokens ({trapCells.length}/4; the first is real)</span> <span> place 4 tokens ({trapCells.length}/4; the first is real)</span>
{/if} {/if}
{#if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)} {#if selectedCard?.cardId === "redirection"}
<span>{tradeFrom ? "— now the exit it should connect TO" : "— click the first exit"}</span>
{:else if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
<span>{tradeFrom ? "— now the second square" : "— click the first square"}</span> <span>{tradeFrom ? "— now the second square" : "— click the first square"}</span>
{/if} {/if}
{#if selectedCard?.cardId === "rotate-sector"} {#if selectedCard?.cardId === "rotate-sector"}
@@ -1197,6 +1371,10 @@
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}> onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
Pick up {cardDef(obj.cardId).name}</button> Pick up {cardDef(obj.cardId).name}</button>
{/each} {/each}
{#if holdingWard}
<button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
{view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
{/if}
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode} <button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
onclick={() => (punchWallMode = !punchWallMode)}> onclick={() => (punchWallMode = !punchWallMode)}>
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button> {punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
@@ -1217,14 +1395,19 @@
<div class="inspector" role="img" aria-label="selected card, enlarged"> <div class="inspector" role="img" aria-label="selected card, enlarged">
<button class="inspector-close" onclick={() => (inspectorHidden = true)} aria-label="hide enlarged card">×</button> <button class="inspector-close" onclick={() => (inspectorHidden = true)} aria-label="hide enlarged card">×</button>
<div class="inspector-card"> <div class="inspector-card">
<Card card={selectedCard} /> <Card card={selectedCard} onfaq={(id) => (faqCardId = id)} />
</div> </div>
</div> </div>
{/if} {/if}
{#if view.phase === "finished" && view.revealedHands} {#if view.phase === "finished" && view.revealedHands}
<div class="final-reveal" aria-label="all hands revealed"> <div class="final-reveal" aria-label="all hands revealed">
<div class="reveal-head">The hands, face-up</div> <div class="reveal-head">
The hands, face-up
<button class="stamp tiny"
onclick={() => (local.active ? local.buildReplay() : net.requestFullReplay())}>
Watch the whole game</button>
</div>
{#each view.players as p (p.id)} {#each view.players as p (p.id)}
<div class="reveal-row"> <div class="reveal-row">
<span class="reveal-name" class:reveal-winner={p.id === view.winner}> <span class="reveal-name" class:reveal-winner={p.id === view.winner}>
@@ -1233,7 +1416,7 @@
</span> </span>
<div class="reveal-cards"> <div class="reveal-cards">
{#each view.revealedHands[p.id] ?? [] as card (card.instanceId)} {#each view.revealedHands[p.id] ?? [] as card (card.instanceId)}
<Card {card} onclick={() => (peekCard = card)} /> <Card {card} onclick={() => (peekCard = card)} onfaq={(id) => (faqCardId = id)} />
{/each} {/each}
{#if (view.revealedHands[p.id] ?? []).length === 0} {#if (view.revealedHands[p.id] ?? []).length === 0}
<span class="reveal-empty">empty-handed</span> <span class="reveal-empty">empty-handed</span>
@@ -1255,8 +1438,10 @@
attachedMods.some((m) => m.instanceId === card.instanceId)} attachedMods.some((m) => m.instanceId === card.instanceId)}
marked={discardSelection.has(card.instanceId)} marked={discardSelection.has(card.instanceId)}
displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false} displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false}
glowing={interruptCards.some((c) => c.instanceId === card.instanceId)}
charges={view.wandCharges[card.instanceId] ?? null} charges={view.wandCharges[card.instanceId] ?? null}
onclick={() => selectCard(card)} onclick={() => selectCard(card)}
onfaq={(id) => (faqCardId = id)}
/> />
{/each} {/each}
</div> </div>
@@ -1666,6 +1851,12 @@
} }
.slip.creature-note { background: #e7edda; border-color: #7d8a5a; } .slip.creature-note { background: #e7edda; border-color: #7d8a5a; }
.score-row.active .score-name { font-weight: 700; } .score-row.active .score-name { font-weight: 700; }
.score-temper {
font-family: "Caveat", cursive;
font-size: 0.85rem;
color: #8a7a5e;
margin-left: 0.25rem;
}
.table-cards { .table-cards {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1791,6 +1982,102 @@
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; } .attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
.attack-fist { font-size: 1.1rem; color: #43331f; } .attack-fist { font-size: 1.1rem; color: #43331f; }
.table-talk {
background: #efe8d4;
color: #43331f;
border-radius: 3px;
padding: 0.1rem 0.4rem;
margin: 0.15rem 0;
font-style: italic;
}
.say-box { display: flex; gap: 0.35rem; margin-top: 0.4rem; }
.say-input {
flex: 1;
min-width: 0;
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 4px;
padding: 0.35rem 0.55rem;
font-family: "Courier Prime", monospace;
font-size: 0.8rem;
color: #43331f;
}
.big-peek-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.55);
display: grid;
place-items: center;
z-index: 55;
}
.big-peek :global(.card) {
transform: scale(2.1);
cursor: default;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
}
.big-peek :global(.card:hover) { transform: scale(2.1); }
.big-peek { display: flex; flex-direction: column; align-items: center; }
.big-peek-note { margin-top: 6.8rem; max-width: 15rem; font-size: 0.8rem; }
.tier-pick {
background: #f6f0df;
border: 1px solid #b3a687;
border-radius: 3px;
font: inherit;
color: #43331f;
padding: 0.1rem 0.25rem;
}
.bot-row {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
color: #6b5a41;
}
.discard-link {
background: none;
border: none;
font: inherit;
color: inherit;
text-decoration: underline;
text-decoration-style: dotted;
cursor: pointer;
padding: 0;
}
.discard-link:hover { color: #43331f; }
.discard-book {
background: #efe8d4;
border: 1px solid #b3a687;
border-radius: 6px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
width: min(52rem, 94vw);
max-height: calc(100vh - 4rem);
display: flex;
flex-direction: column;
overflow: hidden;
}
.discard-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 2px solid #43331f;
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #43331f;
}
.discard-close { position: static; }
.discard-grid {
overflow-y: auto;
padding: 0.9rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
gap: 0.7rem;
justify-items: center;
}
.discard-empty { font-family: "Caveat", cursive; color: #8a7a5e; font-size: 1.1rem; }
.peek-note { .peek-note {
margin-top: 0.4rem; margin-top: 0.4rem;
background: #2b2218; background: #2b2218;
@@ -1811,6 +2098,10 @@
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
} }
.reveal-head { .reveal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-family: "Oswald", sans-serif; font-family: "Oswald", sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
letter-spacing: 0.12em; letter-spacing: 0.12em;
+5
View File
@@ -563,6 +563,11 @@
.creature-body { fill: #3b3428; stroke-width: 2.5; } .creature-body { fill: #3b3428; stroke-width: 2.5; }
.creature-body.selected { fill: #6b5a34; } .creature-body.selected { fill: #6b5a34; }
.creature-label { font-size: 11px; font-weight: bold; fill: #f4ead2; text-anchor: middle; pointer-events: none; } .creature-label { font-size: 11px; font-weight: bold; fill: #f4ead2; text-anchor: middle; pointer-events: none; }
/* Decorative shapes never eat clicks — the floor rect beneath handles them. */
.home-star, .stone, .bush, .rose, .ooze, .slime, .dust, .pit, .tacks, .safe,
.dimwarp, .treasure-g, .treasure, .treasure-swirl, .treasure-core {
pointer-events: none;
}
.home-star { stroke: #2b2218; stroke-width: 1.4; opacity: 0.92; } .home-star { stroke: #2b2218; stroke-width: 1.4; opacity: 0.92; }
.treasure { stroke: #2b2218; stroke-width: 1.3; } .treasure { stroke: #2b2218; stroke-width: 1.3; }
.treasure-swirl { fill: none; stroke: #2b2218; stroke-width: 1.2; stroke-dasharray: 5 2.2; } .treasure-swirl { fill: none; stroke: #2b2218; stroke-width: 1.2; stroke-dasharray: 5 2.2; }
+66 -7
View File
@@ -8,15 +8,21 @@
attached = false, attached = false,
marked = false, marked = false,
displayed = false, displayed = false,
glowing = false,
charges = null, charges = null,
onclick, onclick,
onfaq,
}: { }: {
card: CardInstance; card: CardInstance;
selected?: boolean; selected?: boolean;
attached?: boolean; attached?: boolean;
marked?: boolean; marked?: boolean;
displayed?: boolean; displayed?: boolean;
/** Playable RIGHT NOW out of turn — a golden pulse invites the tap. */
glowing?: boolean;
charges?: number | null; charges?: number | null;
/** When set, cards with official FAQ rulings wear a tappable FAQ seal. */
onfaq?: (cardId: string) => void;
onclick?: () => void; onclick?: () => void;
} = $props(); } = $props();
@@ -34,12 +40,25 @@
} }
}); });
const rightRail = $derived(def.los ? "L.O.S." : def.adjacent ? "ADJACENT" : ""); const rightRail = $derived(def.los ? "L.O.S." : def.adjacent ? "ADJACENT" : "");
// The text box is fixed; the type shrinks until every word fits. Longest
// card in the set is ~550 chars (Firefly Stick).
const textSize = $derived.by(() => {
const n = def.text?.length ?? 0;
if (n <= 130) return "0.6rem";
if (n <= 200) return "0.56rem";
if (n <= 280) return "0.5rem";
if (n <= 370) return "0.45rem";
if (n <= 470) return "0.41rem";
return "0.375rem";
});
const textLeading = $derived((def.text?.length ?? 0) > 280 ? "1.18" : "1.28");
</script> </script>
<button <button
class="card" class="card"
class:selected class:selected
class:attached class:attached
class:glowing
class:marked class:marked
class:number={isNumber} class:number={isNumber}
{onclick} {onclick}
@@ -54,11 +73,18 @@
{#if leftRail}<span class="rail left">{leftRail}</span>{/if} {#if leftRail}<span class="rail left">{leftRail}</span>{/if}
{#if rightRail}<span class="rail right">{rightRail}</span>{/if} {#if rightRail}<span class="rail right">{rightRail}</span>{/if}
<span class="name">{def.name}</span> <span class="name">{def.name}</span>
{#if def.text}<span class="text">{def.text}</span>{/if} {#if def.text}<span class="text" style:font-size={textSize} style:line-height={textLeading}>{def.text}</span>{/if}
{/if} {/if}
{#if displayed} {#if displayed}
<span class="badge">DISPLAYED{charges != null ? ` · ${charges}` : ""}</span> <span class="badge">DISPLAYED{charges != null ? ` · ${charges}` : ""}</span>
{/if} {/if}
{#if onfaq && def.faqRulings.length > 0}
<span
class="faq-seal" role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onfaq?.(card.cardId); }}
onkeydown={() => {}}
>FAQ</span>
{/if}
</button> </button>
<style> <style>
@@ -138,13 +164,7 @@
} }
.text { .text {
font-family: "Archivo Narrow", sans-serif; font-family: "Archivo Narrow", sans-serif;
font-size: 0.56rem;
line-height: 1.28;
overflow: hidden; overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 9;
line-clamp: 9;
-webkit-box-orient: vertical;
} }
/* Number cards: a big numeral in a bordered plate, like the originals. */ /* Number cards: a big numeral in a bordered plate, like the originals. */
@@ -192,7 +212,46 @@
white-space: nowrap; white-space: nowrap;
} }
.faq-seal {
position: absolute;
bottom: 0.15rem;
right: 0.2rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Oswald", sans-serif;
font-size: 0.42rem;
letter-spacing: 0.12em;
padding: 0.1rem 0.25rem;
border-radius: 2px;
cursor: pointer;
opacity: 0.85;
}
.faq-seal:hover { opacity: 1; }
.card.glowing {
box-shadow: 0 0 0 2px #c9a72a, 0 0 14px 3px rgba(201, 167, 42, 0.55);
animation: card-glow 1.4s ease-in-out infinite;
}
@keyframes card-glow {
0%, 100% { box-shadow: 0 0 0 2px #c9a72a, 0 0 14px 3px rgba(201, 167, 42, 0.55); }
50% { box-shadow: 0 0 0 2px #c9a72a, 0 0 6px 1px rgba(201, 167, 42, 0.3); }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.faq-seal {
position: absolute;
bottom: 0.15rem;
right: 0.2rem;
background: #2b2218;
color: #e8dfc6;
font-family: "Oswald", sans-serif;
font-size: 0.42rem;
letter-spacing: 0.12em;
padding: 0.1rem 0.25rem;
border-radius: 2px;
cursor: pointer;
opacity: 0.85;
}
.faq-seal:hover { opacity: 1; }
.card.glowing { animation: none; }
.card { transition: none; } .card { transition: none; }
.card:hover, .card.selected, .card.attached { transform: none; } .card:hover, .card.selected, .card.attached { transform: none; }
} }
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { cardDef } from "@wizwar/engine";
let { cardId, onclose }: { cardId: string; onclose: () => void } = $props();
const def = $derived(cardDef(cardId));
function onkeydown(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
</script>
<svelte:window {onkeydown} />
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
<div
class="faq-book"
role="dialog"
aria-modal="true"
aria-label="official FAQ ruling"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={() => {}}
>
<header class="faq-head">
<span class="faq-title">{def.name} — Official FAQ</span>
<button class="close" onclick={onclose} aria-label="close">×</button>
</header>
<div class="faq-body">
{#each def.faqRulings as ruling, i (i)}
<p>{ruling}</p>
{/each}
<p class="colophon">
From Tom Jolly's official Wiz-War Rules FAQ (wizwar.com, September 2002).
Rulings occasionally reference other editions; the cards overrule the rules,
and the designer overrules the table.
</p>
</div>
</div>
</div>
<style>
.scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.72);
display: grid;
place-items: center;
z-index: 60;
padding: 1.5rem 1rem;
}
.faq-book {
background: #efe8d4;
color: #43331f;
width: min(34rem, 100%);
max-height: calc(100vh - 4rem);
border-radius: 6px;
border: 1px solid #b3a687;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
overflow: hidden;
}
.faq-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 2px solid #43331f;
}
.faq-title {
font-family: "Oswald", sans-serif;
font-weight: 600;
font-size: 0.9rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.close {
background: none;
border: none;
font-size: 1.3rem;
color: #6b5a41;
cursor: pointer;
line-height: 1;
}
.close:hover { color: #b3372b; }
.faq-body {
overflow-y: auto;
padding: 0.9rem 1.2rem 1.1rem;
font-family: "Archivo Narrow", sans-serif;
font-size: 0.95rem;
line-height: 1.5;
}
.faq-body p { margin: 0 0 0.6rem; }
.colophon { font-style: italic; color: #6b5a41; font-size: 0.8rem; }
</style>
+56 -2
View File
@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { allCardDefs } from "@wizwar/engine"; import { allCardDefs } from "@wizwar/engine";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
import Faq from "./Faq.svelte";
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules"; import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
import { FAQ_GENERAL } from "./faq-general";
let { let {
onclose, onclose,
@@ -17,6 +19,8 @@
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value // svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
let tab = $state<"play" | "rules" | "cards" | "about" | "tally">(initialTab); let tab = $state<"play" | "rules" | "cards" | "about" | "tally">(initialTab);
let faqCardId = $state<string | null>(null);
let libPeek = $state<string | null>(null);
function openTally() { function openTally() {
tab = "tally"; tab = "tally";
@@ -48,6 +52,18 @@
<svelte:window {onkeydown} /> <svelte:window {onkeydown} />
{#if libPeek}
<div class="lib-peek-scrim" role="button" tabindex="-1" onclick={() => (libPeek = null)} onkeydown={() => {}}>
<div class="lib-peek">
<Card card={{ instanceId: `peek-${libPeek}`, cardId: libPeek }} onfaq={(id) => (faqCardId = id)} />
</div>
</div>
{/if}
{#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if}
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}> <div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
<div <div
class="booklet" class="booklet"
@@ -81,9 +97,10 @@
<dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd> <dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd>
<dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</dd> <dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</dd>
<dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd> <dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd>
<dt>{stats.longestGameCommands}</dt><dd>moves in the longest game yet played</dd> <dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd>
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd> <dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd> <dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
<dt>{stats.automatonGames ?? 0}</dt><dd>games fought against the clockwork</dd>
</dl> </dl>
{#if stats.firstGameAt} {#if stats.firstGameAt}
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p> <p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p>
@@ -157,6 +174,16 @@
<h3>{section.title}</h3> <h3>{section.title}</h3>
{#each section.body as p, i (i)}<p>{p}</p>{/each} {#each section.body as p, i (i)}<p>{p}</p>{/each}
{/each} {/each}
<h2 class="faq-divider">Official FAQ — general rulings</h2>
<p class="colophon">
Tom Jolly's own answers (wizwar.com, September 2002), verbatim. Rulings
occasionally reference other editions. Card-specific rulings sit on the
cards themselves — look for the FAQ seal.
</p>
{#each FAQ_GENERAL as section (section.title)}
<h3>{section.title}</h3>
{#each section.body as p, i (i)}<p>{p}</p>{/each}
{/each}
{:else} {:else}
<div class="search-row"> <div class="search-row">
<input <input
@@ -168,7 +195,11 @@
<div class="card-grid"> <div class="card-grid">
{#each filtered as def (def.id)} {#each filtered as def (def.id)}
<div class="card-slot"> <div class="card-slot">
<Card card={{ instanceId: `lib-${def.id}`, cardId: def.id }} /> <Card
card={{ instanceId: `lib-${def.id}`, cardId: def.id }}
onclick={() => (libPeek = def.id)}
onfaq={(id) => (faqCardId = id)}
/>
<span class="card-meta"> <span class="card-meta">
×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""} ×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""}
· {def.set === "basic" ? "base" : "exp 1"} · {def.set === "basic" ? "base" : "exp 1"}
@@ -254,6 +285,15 @@
font-size: 0.98rem; font-size: 0.98rem;
line-height: 1.5; line-height: 1.5;
} }
.faq-divider {
font-family: "Oswald", sans-serif;
font-size: 1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
border-top: 3px double #43331f;
padding-top: 0.8rem;
margin: 1.6rem 0 0.2rem;
}
.booklet-body h3 { .booklet-body h3 {
font-family: "Oswald", sans-serif; font-family: "Oswald", sans-serif;
font-size: 0.85rem; font-size: 0.85rem;
@@ -296,6 +336,20 @@
.card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; } .card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
.card-slot :global(.card) { cursor: default; } .card-slot :global(.card) { cursor: default; }
.card-slot :global(.card:hover) { transform: none; box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); } .card-slot :global(.card:hover) { transform: none; box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
.lib-peek-scrim {
position: fixed;
inset: 0;
background: rgba(10, 12, 16, 0.55);
display: grid;
place-items: center;
z-index: 55;
}
.lib-peek :global(.card) {
transform: scale(2.1);
cursor: default;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
}
.lib-peek :global(.card:hover) { transform: scale(2.1); }
.card-meta { .card-meta {
font-family: "Courier Prime", monospace; font-family: "Courier Prime", monospace;
font-size: 0.68rem; font-size: 0.68rem;
+17 -4
View File
@@ -7,12 +7,13 @@
steps, steps,
onclose, onclose,
}: { }: {
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[]; steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
onclose: () => void; onclose: () => void;
} = $props(); } = $props();
let idx = $state(0); let idx = $state(0);
let playing = $state(true); let playing = $state(true);
let speed = $state(1);
const step = $derived(steps[Math.min(idx, steps.length - 1)]!); const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
const lines = $derived( const lines = $derived(
step.events.map(humanize).filter((l): l is string => l !== null), step.events.map(humanize).filter((l): l is string => l !== null),
@@ -24,7 +25,7 @@
const t = setInterval(() => { const t = setInterval(() => {
if (idx < steps.length - 1) idx += 1; if (idx < steps.length - 1) idx += 1;
else playing = false; else playing = false;
}, 1500); }, 1500 / speed);
return () => clearInterval(t); return () => clearInterval(t);
}); });
@@ -41,7 +42,7 @@
<div class="replay-scrim"> <div class="replay-scrim">
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away"> <div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
<header class="replay-head"> <header class="replay-head">
<span class="replay-title">While you were away</span> <span class="replay-title">{steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
<span class="replay-count">move {idx + 1} of {steps.length}</span> <span class="replay-count">move {idx + 1} of {steps.length}</span>
<button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button> <button class="replay-skip" onclick={onclose}>{atEnd ? "back to the game" : "skip to now"}</button>
</header> </header>
@@ -51,7 +52,10 @@
<div class="replay-caption"> <div class="replay-caption">
<strong>{step.actor}</strong> <strong>{step.actor}</strong>
{#each lines as line, i (i)}<div>{line}</div>{/each} {#each lines as line, i (i)}<div>{line}</div>{/each}
{#if lines.length === 0}<div>…considers the maze.</div>{/if} {#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
{#each step.chat ?? [] as c, i (i)}
<div class="reel-talk">💬 {c.player}: {c.text}</div>
{/each}
</div> </div>
<div class="replay-controls"> <div class="replay-controls">
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button> <button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button>
@@ -59,6 +63,9 @@
{playing ? "❚❚" : "▶"} {playing ? "❚❚" : "▶"}
</button> </button>
<button onclick={() => { playing = false; idx = Math.min(steps.length - 1, idx + 1); }} aria-label="next move"></button> <button onclick={() => { playing = false; idx = Math.min(steps.length - 1, idx + 1); }} aria-label="next move"></button>
{#each [1, 2, 4] as x (x)}
<button class="speed" class:current={speed === x} onclick={() => (speed = x)}>{x}×</button>
{/each}
</div> </div>
</div> </div>
</div> </div>
@@ -118,6 +125,7 @@
width: auto; width: auto;
max-width: 100%; max-width: 100%;
} }
.reel-talk { font-style: italic; opacity: 0.85; }
.replay-caption { .replay-caption {
background: #efe8d4; background: #efe8d4;
color: #3a2f1f; color: #3a2f1f;
@@ -129,6 +137,11 @@
line-height: 1.45; line-height: 1.45;
min-height: 3.4rem; min-height: 3.4rem;
} }
.replay-controls .speed {
font-size: 0.75rem;
opacity: 0.7;
}
.replay-controls .speed.current { opacity: 1; text-decoration: underline; }
.replay-controls { .replay-controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
+67
View File
@@ -0,0 +1,67 @@
// Tom Jolly's official Rules FAQ (wizwar.com, September 2002): the
// general-topic rulings, verbatim. Card-specific rulings live on the
// cards themselves (cards.json faqRulings).
export const FAQ_GENERAL: { title: string; body: string[] }[] = [
{
title: "Attack-Neutral Combos",
body: [
"Some cards have mutiple spell designations in the corner (ATTACK, NEUTRAL, COUNTERACTION) with some combination of spell types shown. Since the rules stipulate that you can cast a COUNTERACTION out of turn or during your normal turn, the COUNTERACTION-NEUTRAL is redundant (and to be eliminated in later editions). All the cards with \"SELF\" marked on them were changed to \"LOS\" in later editions, primarily because you are obviously in LOS with yourself, and the card specifies that you cast the spell on yourself. These will be changed back to \"SELF\" in later additions, primarily due to the fact that it misleads people into believing that a \"SELF\" type spell can be cast on anyone. In the 8th edition, \"who\" the spell can be cast on will be very specifically defined. The cards that DO say ATTACK-NEUTRAL on them refer to the fact that they are a Neutral spell if cast on yourself and an Attack when cast on others.",
],
},
{
title: "Combat",
body: [
"You can't attack yourself. This is mentioned in the 6th edition rules, but no earlier editions. This is a very important rule! Also, the rule should read \"No ATTACKS can be used during the first round of turns\", not \"No COMBAT during the first round of turns\". It's perfectly okay to use a DESTROY WALL next to a person, thus damaging him on the first turn, since it is not an attack.",
],
},
{
title: "Creating Objects",
body: [
"More notes on creating objects on one's home base; the basic rules state that you may NOT create an object on a player's home base. This rule appears to get broken by such a card as TRADER, and some TELEPORTATIONS. This is alright as long as \"immobile objects\", such as Solid Stone or Thornbush, are not placed on the home base. The 8th edition clarifies this, since it limits what can and cannot be teleported on the cards. TRADER, for example, only works on \"Mobile Objects\".",
],
},
{
title: "Death",
body: [
"The second-hand killing of a Wizard, say by falling walls or controlled MONSTERS, does not get you his cards, nor does a MONSTER get cards by killing a player!",
"Dumping Cards and Physical Objects This is explained pretty well in the basic rules, but everybody seems to forget you can do it. If you want to discard a physical object, it is NOT necessary to dump it on the board, you can just discard it to the discard pile. The board would get very crowded otherwise. This includes ARTIFACTS, if you think someone is about to take one from you and there's nothing you can do about it. This can only be done during your own turn (this is not in the rules, but will be next printing).",
],
},
{
title: "Line of Sight",
body: [
"If you \"barely nick a corner\" of a wall, this is still NOT line-of-sight. A VISIONSTONE could see through the corner, but if you \"nicked two corners\", the VISIONSTONE would not work. The new Wiz-War set, 6th edition, has no dots in the center of the squares with which to measure line-of-sight, even though the rules say to use these non-existent dots. Our advice is to eyeball the center of the square, or put your own dots on the playing board. It might also be worthwhile to take a magic marker and draw the walls brown, as they do not stand out very well as it is.",
],
},
{
title: "Lost Turns",
body: [
"These will accumulate if you get hit by a \"lost turns\" type spell while under the effect of another \"lost turns\" spell. Also, if you lose a turn while under a duration spell, this still uses up a turn of the duration spell. If you happen to get hit by a reflected attack (yours), and you \"lose a turn\", this would be THAT turn, not your next turn. This could happen with Lightning Bolt or Hypnosis, for example. As the rules say, if you Lose a Turn, you can't do anything but play Counteractions until your next turn. This also means you can't draw cards!",
"Magic Sticks These don't have to be displayed. When you first charge it with a NUMBER card, the NUMBER card gets discarded, so it no longer counts as part of your hand.",
"Magic Stones They may be discarded as cards even after used and displayed. Nothing makes an object so permanent that it cannot be discarded. This is always a player option. MAGIC STONES are not \"created\" by the card, the card merely represents the physical object. It may be displayed and left displayed whenever you use it first, even if it is not your turn.",
],
},
{
title: "Monsters",
body: [
"These are creatures that do damage, generally. This means that ALTER EGO, SHADOW, FAMILIAR, and KILLER OOZE are considered MONSTERS, while the SLIME, ANIMATE OBJECT, DECOY and BUCK are not, even though they are mobile. Though some Monsters can carry objects, they cannot use them. You can't attack your own monsters. This is in line with the rule that you can't attack yourself, and prevents some rule conflicts. Monsters that can cast spells can cast \"SELF\" spells, too. A monster that changes itself into a different monster (like a Werewolf), assumes the attributes of that monster, as described on the card, but retains any other attributes not mentioned.",
],
},
{
title: "Picking Up Objects",
body: [
"No, you can't pick up 2 objects at the same time, even if they're in the same space.",
"Players as Objects There is some confusion in the rules which will probably not be resolved until next edition as to whether Wizards and MONSTERS can be treated as objects or not, for purposes of spells such as DRAG and a few others. Unfortunately, this is not well defined anywhere. The basic rules state that players are NOT objects, so spells such as TELEPORT OBJECT and ANIMATE OBJECT would have no effect on Wizards. Use common sense and house rules. You will rarely run into this as a problem, but you could. Obviously, if a card says a MONSTER can carry objects, it is NOT referring to other Wizards!",
"Spell Priority There is some question as to whether Counteractions or Attacks work first, especially in a case like SHIELDSTONE vs FIREBALL. Counteractions always work first. Thus, if someone was hit with a FIREBALL while they were carrying a BLOODSTONE, the BLOODSTONE would reduce the damage done by a point before it was destroyed. If you have 2 Counteractions you want to use, you decide for yourself which one activates first. If a spell gets counteracted, the number card (if any) that accompanied it is also discarded.",
"Throwing Objects The basic rules imply, without explicitly stating, that you can throw objects, but not treasures. That's true! You could take a Magic Stone and throw it inot a PIT 3 spaces away, if you wanted. Throwing objects at other players does not do any damage, unless a card, such as HEAVE-HO or DAGGER says so.",
],
},
{
title: "Treasures",
body: [
"If a player is eliminated, any treasure left on his home base does NOT count toward eliminating the player to whom that treasure belongs. Also, the 7th edition rules are rather badly worded and imply that you can carry 2 treasures as long as one of them is your own and the other belongs to another player. This is incorrect. You can carry only ONE treasure, and it can be EITHER one of your own or one of the opponent's.",
"Card FAQ's for Wiz-War",
],
},
];
+36 -1
View File
@@ -13,6 +13,8 @@ import {
type GameState, type GameState,
type GameView, type GameView,
type PlayerId, type PlayerId,
type GameEvent,
redactEvent,
} from "@wizwar/engine"; } from "@wizwar/engine";
import { humanize, net } from "./net.svelte"; import { humanize, net } from "./net.svelte";
@@ -46,6 +48,8 @@ class LocalGame {
/** Set while the device should be handed to the named player. */ /** Set while the device should be handed to the named player. */
handoffTo = $state<PlayerId | null>(null); handoffTo = $state<PlayerId | null>(null);
log = $state<string[]>([]); log = $state<string[]>([]);
/** The finished game as a reel, each step from its actor's own seat. */
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
view = $derived( view = $derived(
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null, this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
); );
@@ -56,6 +60,33 @@ class LocalGame {
private activeMs = 0; private activeMs = 0;
private lastMoveAt = 0; private lastMoveAt = 0;
/** The tabletop D4 for house calls: chronicle only, no game state. */
rollTableDie(): void {
if (!this.viewerId) return;
const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4);
this.log = [...this.log, `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`];
}
/** Rebuild the whole game as replay steps (finished games only). */
buildReplay(): void {
if (!this.config || this.gameState?.phase !== "finished") return;
const { state } = createGame(this.config);
let current = state;
const steps: { seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] = [];
this.commands.forEach((c, i) => {
const r = applyCommand(current, c.playerId, c.command);
if (!r.ok) return;
current = r.state;
steps.push({
seq: i,
actor: c.playerId,
events: r.events.map((e) => redactEvent(e, c.playerId)).filter((e): e is GameEvent => e !== null),
view: viewFor(current, c.playerId),
});
});
this.replaySteps = steps;
}
hasSave(): boolean { hasSave(): boolean {
return localStorage.getItem(SAVE_KEY) !== null; return localStorage.getItem(SAVE_KEY) !== null;
} }
@@ -101,7 +132,7 @@ class LocalGame {
seed, seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"], sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}), ...(colors ? { colors } : {}),
deckRev: 2, deckRev: 9,
}; };
const { state, events } = createGame(config); const { state, events } = createGame(config);
this.config = config; this.config = config;
@@ -118,6 +149,9 @@ class LocalGame {
this.tallyId = crypto.randomUUID(); this.tallyId = crypto.randomUUID();
this.activeMs = 0; this.activeMs = 0;
this.lastMoveAt = Date.now(); this.lastMoveAt = Date.now();
// iOS evicts unprotected origin storage under pressure; ask for durability
// so a saved hotseat game survives more than a forced reload.
navigator.storage?.persist?.().catch(() => {});
net.reportHotseat({ id: this.tallyId, stage: "started", players: cleaned.length }); net.reportHotseat({ id: this.tallyId, stage: "started", players: cleaned.length });
this.persist(); this.persist();
return null; return null;
@@ -152,6 +186,7 @@ class LocalGame {
this.gameState = current; this.gameState = current;
this.active = true; this.active = true;
this.viewerId = null; this.viewerId = null;
navigator.storage?.persist?.().catch(() => {});
this.handoffTo = actorId(current); this.handoffTo = actorId(current);
return true; return true;
} catch (e) { } catch (e) {
+93 -10
View File
@@ -5,10 +5,24 @@ import { cardDef } from "@wizwar/engine";
const SERVER_URL = const SERVER_URL =
import.meta.env.VITE_WIZWAR_SERVER ?? import.meta.env.VITE_WIZWAR_SERVER ??
(location.hostname === "localhost" (location.port === "5173" // the vite dev server; anything else serves its own socket
? "ws://localhost:8787" ? `ws://${location.hostname}:8787`
: `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`); : `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
/** Sustained-effect ids that are engine constructs, not printed cards. */
const SYNTHETIC_SPELL_NAMES: Record<string, string> = { "sticky-web": "Sticky Wand webs" };
/** Card name lookup that survives synthetic ids — render code must never throw. */
export function spellName(cardId: string): string {
const synthetic = SYNTHETIC_SPELL_NAMES[cardId];
if (synthetic) return synthetic;
try {
return cardDef(cardId).name;
} catch {
return cardId;
}
}
export function humanize(e: GameEvent): string | null { export function humanize(e: GameEvent): string | null {
switch (e.type) { switch (e.type) {
case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`; case "gameStarted": return `Game started — ${e.players.join(", ")}. ${e.firstPlayer} goes first.`;
@@ -43,8 +57,8 @@ export function humanize(e: GameEvent): string | null {
: `${e.defender} is too small to hit — the attack misses!`; : `${e.defender} is too small to hit — the attack misses!`;
case "damageImmune": return `${e.player} is stone — the damage has no effect.`; case "damageImmune": return `${e.player} is stone — the damage has no effect.`;
case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`; case "lifeGained": return `${e.player} gains ${e.amount} life (${e.source}) — now ${e.lifeAfter}.`;
case "spellSustained": return `${cardDef(e.cardId).name} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`; case "spellSustained": return `${spellName(e.cardId)} settles over ${e.target} (${e.turns} turn${e.turns === 1 ? "" : "s"}).`;
case "spellExpired": return `${cardDef(e.cardId).name} wears off ${e.target}.`; case "spellExpired": return `${spellName(e.cardId)} wears off ${e.target}.`;
case "teleported": return e.by === e.player case "teleported": return e.by === e.player
? `${e.player} teleports across the maze!` ? `${e.player} teleports across the maze!`
: `${e.player} is teleported away by ${e.by}!`; : `${e.player} is teleported away by ${e.by}!`;
@@ -74,9 +88,9 @@ export function humanize(e: GameEvent): string | null {
case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`; case "sectorRelocated": return `The maze SHUDDERS — an entire sector slides away!`;
case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`; case "creatureCreated": return `${e.controller} summons a ${e.kind.replace(/-/g, " ")}!`;
case "creatureMoved": return null; case "creatureMoved": return null;
case "creatureAttacked": return e.dieRoll != null ? `The troll swings (rolled ${e.dieRoll})!` : `The creature strikes!`; case "creatureAttacked": return e.dieRoll != null ? `The ${spellName(e.kind)} swings (rolled ${e.dieRoll})!` : `The ${spellName(e.kind)} strikes!`;
case "creatureTouched": return `The creature falls upon ${e.player}!`; case "creatureTouched": return `The ${spellName(e.kind)} falls upon ${e.player}!`;
case "creatureDamaged": return e.amount > 0 ? `The ${e.creatureId} takes ${e.amount} damage.` : `The attack has no effect on it.`; case "creatureDamaged": return e.amount > 0 ? `The ${spellName(e.kind)} takes ${e.amount} damage.` : `The attack has no effect on the ${spellName(e.kind)}.`;
case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`; case "creatureDestroyed": return `The ${e.kind.replace(/-/g, " ")} is destroyed (${e.by})!`;
case "trollRegenerated": return `The troll's stony hide knits itself back together.`; case "trollRegenerated": return `The troll's stony hide knits itself back together.`;
case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`; case "shadowUpkeep": return `The shadow drains its master (${e.lifeAfter} life left).`;
@@ -129,13 +143,24 @@ export function humanize(e: GameEvent): string | null {
case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`; case "treasurePickedUp": return `${e.player} grabs ${e.owner}'s treasure!`;
case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`; case "objectDropped": return `${e.player} sets down the ${cardDef(e.card.cardId).name}${e.forced ? " (forced)" : ""}.`;
case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`; case "objectPickedUp": return `${e.player} picks up the ${cardDef(e.card.cardId).name} — actions over.`;
case "wardSet": return e.armed ? "Your ward is set — the next thief bleeds." : "Your ward stands down.";
case "chaosShielded": return `${e.player} raises a FULL SHIELD and sits out the chaos.`;
case "tableTalk": return `\u{1F4AC} ${e.player}: ${e.text}`;
case "dieRolled": return `\u{1F3B2} ${e.player ?? "The maze"} rolls a ${e.roll}${e.purpose}.`;
case "pushed": return e.player
? `${e.by} shoves ${e.player} down the corridor!`
: `${e.by} shoves the beast down the corridor!`;
case "spellTrapped": return `${e.caster} casts ${spellName(e.cardId)} into the slime — it sticks, waiting.`;
case "slimeTrapSprung": return `The slime releases its ${spellName(e.cardId)} at ${e.victim}!`;
case "slimeWashed": return `The wave washes the slime away.`;
case "wallDamaged": { case "wallDamaged": {
const what = e.needed === 15 ? "door" : "wall"; const what = e.needed === 15 ? "door" : "wall";
return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name}${e.total}/${e.needed}.`; return `${e.player} batters the ${what} with ${e.source === "punch" ? "bare fists" : cardDef(e.source).name}${e.total}/${e.needed}.`;
} }
case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`; case "treasureDropped": return e.onHomeOf ? `${e.player} drops a treasure on ${e.onHomeOf}'s home base!` : `${e.player} drops a treasure.`;
case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null; case "playerEliminated": return e.reason === "treasuresLost" ? `${e.player} is eliminated — both treasures lost!` : null;
case "cardsDiscarded": return `${e.player} discards ${e.cards.length} card(s).`; case "cardsDiscarded":
return `${e.player} discards ${e.cards.map((c) => spellName(c.cardId)).join(", ")}.`;
case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null; case "cardsDrawn": return e.count > 0 ? `${e.player} draws ${e.count} card(s).` : null;
case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`; case "deckReshuffled": return `The discard pile is reshuffled (${e.size} cards).`;
case "turnEnded": return null; case "turnEnded": return null;
@@ -146,6 +171,12 @@ export function humanize(e: GameEvent): string | null {
const SEAT_KEY = "wizwar-seat"; const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats"; const SEATS_KEY = "wizwar-seats";
const CHAT_SEEN_KEY = "wizwar-chat-seen";
function loadChatSeen(): Record<string, number> {
try { return JSON.parse(localStorage.getItem(CHAT_SEEN_KEY) ?? "{}"); }
catch { return {}; }
}
const SEEN_KEY = "wizwar-seen"; const SEEN_KEY = "wizwar-seen";
function loadSeen(): Record<string, number> { function loadSeen(): Record<string, number> {
@@ -166,6 +197,7 @@ export interface GameSummary {
attention: "turn" | "counteract" | "discard" | "interrupt" | null; attention: "turn" | "counteract" | "discard" | "interrupt" | null;
round: number | null; round: number | null;
lastMoveAt: string | null; lastMoveAt: string | null;
chatCount: number;
} }
export function attentionLabel(a: GameSummary["attention"]): string { export function attentionLabel(a: GameSummary["attention"]): string {
@@ -193,6 +225,7 @@ class Net {
started = $state(false); started = $state(false);
/** Lobby standee choices, by player name. */ /** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({}); roomColors = $state<Record<string, number>>({});
roomBots = $state<Record<string, string>>({});
you = $state<string | null>(null); you = $state<string | null>(null);
view = $state<GameView | null>(null); view = $state<GameView | null>(null);
log = $state<string[]>([]); log = $state<string[]>([]);
@@ -201,6 +234,9 @@ class Net {
seats = $state<Seat[]>(loadSeats()); seats = $state<Seat[]>(loadSeats());
/** Lobby ledger: one summary per live seat. */ /** Lobby ledger: one summary per live seat. */
stats = $state<Record<string, number | string | null> | null>(null); stats = $state<Record<string, number | string | null> | null>(null);
chatSeen = $state<Record<string, number>>(loadChatSeen());
/** Messages in the current room this session (history + live). */
chatCount = 0;
games = $state<GameSummary[]>([]); games = $state<GameSummary[]>([]);
notificationsEnabled = $state( notificationsEnabled = $state(
typeof Notification !== "undefined" && Notification.permission === "granted", typeof Notification !== "undefined" && Notification.permission === "granted",
@@ -210,7 +246,7 @@ class Net {
/** Moves you haven't watched yet in the current room. */ /** Moves you haven't watched yet in the current room. */
missedMoves = $state(0); missedMoves = $state(0);
/** A catch-up reel delivered by the server. */ /** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView }[] | null>(null); catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
private seen: Record<string, number> = loadSeen(); private seen: Record<string, number> = loadSeen();
private currentSeq = 0; private currentSeq = 0;
private lastYourTurn = new Map<string, boolean>(); private lastYourTurn = new Map<string, boolean>();
@@ -260,6 +296,7 @@ class Net {
case "room": case "room":
this.roomId = msg.roomId; this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {}; this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? {};
if (this.you && this.token) { if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token }; const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat)); localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -286,12 +323,19 @@ class Net {
case "catchUp": case "catchUp":
this.catchUp = msg.steps; this.catchUp = msg.steps;
break; break;
case "events": case "events": {
let talk = 0;
for (const e of msg.events as GameEvent[]) { for (const e of msg.events as GameEvent[]) {
if (e.type === "tableTalk") talk++;
const line = humanize(e); const line = humanize(e);
if (line) this.log = [...this.log, line]; if (line) this.log = [...this.log, line];
} }
if (talk > 0) {
this.chatCount += talk;
if (this.roomId) this.markChatSeen();
}
break; break;
}
case "transferCode": case "transferCode":
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt }; this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
break; break;
@@ -302,6 +346,12 @@ class Net {
this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token }); this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
break; break;
} }
case "chat": {
this.log = [...this.log, `\u{1F4AC} ${msg.player}: ${msg.text}`];
this.chatCount += 1;
if (this.roomId) this.markChatSeen();
break;
}
case "stats": { case "stats": {
this.stats = msg.stats; this.stats = msg.stats;
break; break;
@@ -322,6 +372,8 @@ class Net {
this.roomIdPending = null; this.roomIdPending = null;
} }
this.error = msg.message; this.error = msg.message;
// The toast fades; the chronicle remembers why nothing happened.
this.log = [...this.log, `${msg.message}`];
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000); setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break; break;
} }
@@ -376,6 +428,32 @@ class Net {
} }
/** Ask the server how all our games are doing. */ /** Ask the server how all our games are doing. */
addBot(style?: string, tier?: string): void {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
}
rollTableDie(): void {
this.send({ type: "rollDie" });
}
sendChat(text: string): void {
this.send({ type: "chat", text });
}
/** Watching the table counts as reading the talk. */
markChatSeen(): void {
if (!this.roomId) return;
const games = this.games.find((g) => g.roomId === this.roomId);
const count = Math.max(this.chatCount, games?.chatCount ?? 0);
if ((this.chatSeen[this.roomId] ?? 0) >= count) return;
this.chatSeen = { ...this.chatSeen, [this.roomId]: count };
localStorage.setItem(CHAT_SEEN_KEY, JSON.stringify(this.chatSeen));
}
unreadChat(roomId: string, chatCount: number): number {
return Math.max(0, chatCount - (this.chatSeen[roomId] ?? 0));
}
requestStats(): void { requestStats(): void {
this.send({ type: "stats" }); this.send({ type: "stats" });
} }
@@ -444,6 +522,11 @@ class Net {
} }
/** Ask for the reel of everything since we last watched. */ /** Ask for the reel of everything since we last watched. */
/** The whole game from the deal, once it is finished. */
requestFullReplay(): void {
this.send({ type: "catchUp", sinceSeq: 0, full: true });
}
requestCatchUp(): void { requestCatchUp(): void {
if (!this.roomId) return; if (!this.roomId) return;
this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 }); this.send({ type: "catchUp", sinceSeq: this.seen[this.roomId] ?? 0 });