56 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 65e88df523 The step light is for a first game's first turn, then by choice
It lit every turn of the first game. Now a browser's first game lights
its first turn on its own, and the preference is an explicit switch
that keeps it on every turn for anyone who wants it. The old
preference key retires with it, so nobody is left lit by accident.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 22:21:22 -04:00
Eric WagonerandClaude Fable 5.1 53b6b01582 The workshops wear the masthead, with the door marked
The token, flourish, and eyes workshops opened as bare rooms with no
way back. The masthead now heads each, wordmark linking home, with
the table, the clips, and the other workshops a tap away.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 13:44:12 -04:00
Eric WagonerandClaude Fable 5.1 34b621b5cb The board's furniture and the reel's fists, painted as cels
The last of the inked look. Walls carry stone joints and highlights,
doors their grain, hinges, and a brass lock plate whose keyhole turns
to a cross when jammed; battle scars fracture and branch with the
damage; illusion walls drift as tapered wisps with breathing glints;
the wall of fire burns in flat three-tone flames on a stepped flicker;
warp mouths and destinations are painted curls. Through a wizard's
eyes the punch is a three-tone hand with an ink contour and a
hand-cut POW, the same on every platform where the emoji glyph was
not, and the incoming fist no longer reverses late in its travel. The
inked stand-ins for missing conjuration files match. Goldens
re-blessed: every board frame, and the web's third ring.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-15 12:42:02 -04:00
Eric WagonerandClaude Fable 5.1 8dfd0e0c29 The art refresh: textures, tokens, conjurations, and cel flourishes
Eric repainted the maze. The first-person materials are painterly
sandstone, flagstone, and iron; the hand-drawn token set is redrawn
with matching chests and hats; the standing conjurations — hedges,
jello, dust, safe, warp light — and the effect billboards are redone
in the same hand; and the board's flourishes trade their filters and
gradients for flat cel silhouettes cut from five shared shapes, which
read better against the counters and cost a phone nothing. Thirty
scene goldens re-blessed against the new walls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-14 20:28:52 -04:00
Eric WagonerandClaude Fable 5.1 3fc9df85ac The chronicle marks each line with its wizard's color; the turn's owner stands out
Two of Randy's flourishes. Every chronicle line now carries a small
square in the color of the wizard acting — whoever cast, struck,
walked, or countered, else the turn's owner — so one player's doings
can be picked out of the scroll at a glance. And the life-points row
of the wizard whose turn it is wears an accent bar and a tint, since
bold alone was lost in the handwriting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-14 19:23:00 -04:00
Eric WagonerandClaude Fable 5.1 19aec73b30 Screenplay: Dust and Light
The eleventh scene, written for the new conjurations. Wanderer fills
the corridor between them with a dust cloud — no sight, no spells —
so Rival lays a dimensional warp with one mouth under his own feet
and the other at her back, steps through the light, and lands on her
square fists first. She bolts into her own cloud, and the die picks
where she goes: into a wall. Three beats through the eyes — the cloud
billowing, the arrival, the blind stagger with the far pillar of light
at the corridor's end — and the board at the arrival.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-12 14:57:11 -05:00
Eric WagonerandClaude Fable 5.1 09fcaaf4e3 The house rulings say how a loaded slime goes off
One spell per wizard who pushes in, oldest first — the card says only
that each goes off once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-07 16:05:03 -05:00
Eric WagonerandClaude Fable 5.1 44c7ff8379 A slime shows how many spells wait in it, and a peek names them
Every cast into the gel was seen at the table, so the count is public:
a badge on the slime, and the peek lists each spell with its caster.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-07 16:00:32 -05:00
Eric WagonerandClaude Fable 5.1 16fac5e6ca A test walks a wizard into a slime with a spell waiting in it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-07 15:55:00 -05:00
Eric WagonerandClaude Fable 5.1 c09bcc481d An attack tapped onto a slime is cast into it
The engine has trapped spells in slime since the card arrived; the
board never offered the tap. LIGHTNING BLAST on a slime square now
lodges there for the next wizard in, as the card says.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-07 15:51:07 -05:00
Eric WagonerandClaude Fable 5.1 5c956c24bb Sentry ignores the in-app browser's own scripts
Facebook's Android browser injects a performance logger under iabjs://
and its bridge dies on unload; the error wore our name (WIZWAR-6).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-07 15:31:33 -05:00
Eric WagonerandClaude Fable 5.1 3f71b04c3b Rev 20: a wave names its victims before it pushes (LK97)
Kestrel stood against a wall Mad Hywel turned to water, was washed one
square back into stone, and was crushed twice. The sweep walked the
way it pushed: having shoved him onto the next square, its next probe
found him there and shoved him again with the force left. From rev 20
a sweep names every victim along its line first, then pushes each
once; older games replay the double shove. A test washes a wizard one
square into a wall under both readings.

With it, a client fix: a tap on a bare square while holding a card no
longer walks there. Kestrel tapped a slime with LIGHTNING BLAST in hand
and strode into it. The tap now says the card is aimed at a wizard or
creature and leaves the feet alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-07 14:38:48 -05:00
Eric WagonerandClaude Fable 5.1 e5d82a94af A first game's first turn: the step light, the goal, and a held opening
Three strangers this week pressed play, looked at the board for a
minute, and closed it. What they saw: a small maze under a roll-off
card, a column of prose, seven strange cards, and nothing that said
where they were or what to tap. Now, on a browser's first game, the
squares a stride can reach glow and the rest of the maze falls into
shadow, with one line hung over the wizard for the first stride. The
turn banner says what winning is for the first two rounds. And when
the clockwork rolls first, its opening turn waits behind the roll-off
card instead of playing out unwatched, then arrives a beat at a time.
The step light switches off once that first game is won, and lives in
preferences for anyone who wants it back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-07 12:50:28 -05:00
Eric WagonerandClaude Fable 5.1 96c0c18d97 Referrers count only on pages we served
A scanner forging google.com on /.env showed as forty-eight Google
readers before breakfast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-06 23:30:14 -04:00
Eric WagonerandClaude Fable 5.1 7af9de4a9a The rollup's check-in tells Sentry its schedule
A bare ping to a slug Sentry has never been told about is accepted
and dropped, so the rollup monitor never existed and a missed night
would have alarmed nobody. The check-in now carries the monitor's
shape and upserts it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-06 11:33:11 -04:00
Eric WagonerandClaude Fable 5.1 2bc5fba8d2 The rollup's path mix counts people only
Crawlers and the uptime monitor hit the front page hundreds of times a
day and were tallied beside the visitors who read it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-06 09:24:55 -04:00
Eric WagonerandClaude Fable 5.1 f859d90af8 Rev 19: a dust cloud blinds whoever stands in it (CPU7)
The automaton stood in Kestrel's DUST CLOUD and filled a square with
stone three rows away. The cloud blocked only sight lines passing
through it; the square a line began or ended in was never asked. The
FAQ is plain: no LOS spell may be cast by a wizard in a cloud, nor at
one, though a spell upon oneself still works, and VISIONSTONE does
not see through dust. From rev 19 the engine's sight and the view's —
the targeting aid the client and the automaton both read — refuse a
line with dust at either end. Older games keep their sight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 23:30:17 -04:00
Eric WagonerandClaude Fable 5.1 3d4bf1fa6d A sprung Ward says its bite is coming, not that it missed
The bite rides a counteraction stack, so the batch that springs the
Ward carries no damage; the notice read that as a bite with no
purchase and the three points landed later unannounced. It now says
the bite waits on the taker's answer, and the counteractions follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 23:25:05 -04:00
Eric WagonerandClaude Fable 5.1 34d76d9feb Rev 18: REFLECTION's returning half is the caster's to counteract (96WE)
Kestrel's blade came back halved off the automaton's REFLECTION and
landed before he could raise the ABSORB in his hand. FULL REFLECTION's
return was already a fresh attack with the caster's own window; the
half now goes the same way — its damage and any split duration ride
a returned stack the caster may meet with an ABSORB, a BLUNT, or
another REFLECTION. Trapped spells and creatures' touches keep the
instant half. Older games replay as they landed. A test soaks the
half and takes it, under rev 18 and rev 17.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 22:37:19 -04:00
Eric WagonerandClaude Fable 5.1 2fd324a475 POWER ATTACK burns any number of life points short of the last
The card sets no ceiling and the engine never had one; the picker
offered five. It now offers every point but the one that keeps you
alive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 22:25:43 -04:00
Eric WagonerandClaude Fable 5.1 2812b85016 The mouth on a pit's rim may be named in any game
Only a bare step is a matter of revision: from rev 17 a square beside a
mouth is a fork to name; before it the step lands on the square, as the
recorded games did. Naming the mouth was never recorded, so it opens
for every game.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 22:24:18 -04:00
Eric WagonerandClaude Fable 5.1 461c1a5c83 Rev 17: a warp mouth on a pit's rim is a way off it (M4Q7)
Kestrel stood beside a pit dug against the board's west edge, walls
north and south of it, and was told there was nothing to land on — the
rim check counted only neighbouring squares, and the warp mouth beyond
the pit is not one. From rev 17 a mouth on the rim is an exit like any
square, named at a fork like any other; every older game takes the
mouth when no square offers, so the refused crossing goes through as
it stands. The jump through a mouth carries `via: "warp"`: the board
shimmers both mouths instead of streaking across the maze, and the
reel cuts rather than gliding. The automaton leaves a pit only by its
exits. Three tests walk the rim under both readings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 22:20:18 -04:00
Eric WagonerandClaude Fable 5.1 dc4d2a1221 The stub path checks the room id, as the ledger path does
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 22:10:03 -04:00
Eric WagonerandClaude Fable 5.1 6293c36add Sleeping rooms stay asleep across a restart
Idle rooms already went back to their ledgers, but a boot replayed
every ledger before the first sweep could put them to bed again — most
of a restart's fifteen seconds spent rebuilding games nobody was at.
A room put to sleep now leaves a stub beside the ledgers, with the
ledger's size at that moment; at boot a stub whose size still matches
answers for the room and the ledger stays unreplayed. Replayed rooms
take their eviction clock from the ledger's last line, so the first
sweep after a boot writes the stubs for everything long idle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 22:05:43 -04:00
Eric WagonerandClaude Fable 5.1 c7edfa74ad The state is copied by hand, and the proxy waits out a restart
Two-thirds of a ledger replay was structuredClone; a recursive copy of
plain data does the same work in a fraction of the time, so a server
boot — every ledger replayed — is half as long, and the automaton's
lookahead is quicker with it. Caddy now holds a request for up to
thirty seconds while the game restarts, dialing every quarter second,
so a visitor who lands during a deploy waits instead of meeting a 502.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 20:41:02 -04:00
Eric WagonerandClaude Fable 5.1 0821b8f7c7 The house rulings name how a goat's ram is measured
The shortest walk on this turn's legs, no ceiling, MAD DASH doubling
the lot — the reading the engine has held since rev 10 and the
combo it only just began to allow at full length.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 19:49:04 -04:00
Eric WagonerandClaude Fable 5.1 3b52a92cdc Rev 16: BUTT-HEAD's charge is measured as far as its legs reach (K4T3)
Reuben, a goat with a 5 played and eight legs, aimed at a wizard eight
corridor steps away and was told he had no wings. The charge measures
the walk through real corridors, as rev 10 made it, but the walk was
searched only six steps deep and anything past that came back as
unreachable. From rev 16 the search runs as far as the legs do; older
games keep the six-step reading. A test charges seven or eight steps
under both revisions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-05 18:29:05 -04:00
Eric WagonerandClaude Fable 5.1 0911312f98 A page from before a deploy can still find its chunks, or reload once
Sentry WIZWAR-5: a table open through a deploy asked for the replay
module by its old hashed name, the deploy had deleted it, and the
server answered with the app shell as text/html — "not a valid
JavaScript MIME type". Three guards: deploys keep old chunks for a
week instead of deleting them; a missing asset is a plain 404, never
the shell; and the client reloads once when Vite reports a failed
import, so the live bundle takes over.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-04 20:30:39 -04:00
Eric WagonerandClaude Fable 5.1 fa5a5a07a7 MEGA-MONSTER asks which number to double
The two choices sat in the hint strip with life-points pre-selected,
so a tap on the monster cast before the choice was seen. Tapping the
monster now asks — life-points or movement, with the monster's
current numbers shown — and the cast waits on the answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-04 17:25:34 -04:00
Eric WagonerandClaude Fable 5.1 9f98a4fc59 The unasked council's clearing effect watches the turn
It checked its own plain flag first and returned, so the turn was
never read and never tracked; the effect ran once and slept. The turn
is read first now, and the flag is reactive.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-04 17:22:39 -04:00
Eric WagonerandClaude Fable 5.1 e724267b0a BIG MAN pushes around corners; at a fork the pushed wizard chooses
Eric, a giant on his first turn, could only shove a wizard in a
straight line: the engine shoved in the giant's own direction or
refused. The FAQ says otherwise — "If Big Man pushes someone to an
intersection, then the other player gets to decide which direction to
go." Now a pushed wizard leaves by any open side but the way the giant
came. One open side is taken. Several are theirs to choose: the stride
hangs in a pushPending window like the Ward's, the pushed wizard
answers with pushChoice (a dialog offers the ways; an automaton steps
out of the giant's line), and the stride resumes with the answer — a
blinded giant's roll is not rolled twice.

Rounding a corner was a refusal before, so every game may do it now.
Only the case that used to resolve — straight ahead open — keeps its
old reading in games dealt before rev 15; from rev 15 the pushed
wizard chooses there too. Tests pin the corner, the fork's window and
its refusals, and the older reading; 84 ledgers replay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-04 17:19:34 -04:00
Eric WagonerandClaude Fable 5.1 c6913ea1bc Rev 14: a pit's rim is a ledge, and the walker chooses the way off (GDCN)
The table's reading of CREATE PIT: a thin ledge runs around the rim,
and "jumping over" is edging along it, trying not to fall. So on a 2,
3, or 4 the walker lands on an open square beside the pit — the only
one if there is one, otherwise the one they name (a click on that
square sends `exit` with the move) — and a pit with no way off cannot
be entered from there at all, refused before any die is rolled. The
FAQ's diagonal crossing at an intersection is this rule.

Older games bounced the walker back and charged the stride; they keep
that under their frozen revision, so GDCN's seven bounces replay. The
clockwork's path search treats a pit as a road when any side of its
rim is open and names the exit its path leaves by. Tests pin the fork,
the single way off, the closed pit under both revisions, and the
brain's route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-04 16:18:42 -04:00
Eric WagonerandClaude Fable 5.1 0750fe3f21 The clockwork minds the pit, the curse, and the shadow (GDCN)
Kestrel's archmage berserker dug a pit at the far mouth of a warp,
then tried to walk through that mouth seven times: each stride landed
on its own pit with a thornbush beyond, the maze bounced it back and
charged the stride, and on the seventh it rolled the 1 and fell in.
Cursed with SLOW DEATH at seven life, it kept drawing two cards a
turn at a point apiece, raised a SHADOW that drinks a point a turn,
and bled to death in three turns.

The path search now refuses a pit it cannot leap beyond — the same
test the maze applies — whether the pit is a waypoint or the goal.
Under SLOW DEATH the end-of-turn draw keeps four life in hand and
draws nothing when it cannot; while bleeding, or at six life or less,
no SHADOW is raised. Three tests pin them.

The chronicle tells the bounce as what it was: "leaps the pit —
nothing to land on beyond it — and teeters back", not a blind blunder
into a wall; and the shadow's last drink no longer trails a zero-damage
line. Brain and chronicle changes only, ungated; 83 ledgers replay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-04 16:11:42 -04:00
Eric WagonerandClaude Fable 5.1 b92016db51 The unasked council leaves with the turn
Advice offered on a first turn stayed on the sidebar until dismissed,
as advice a player asked for should. Offered advice now clears when
the turn passes; asking again keeps it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-04 16:00:09 -04:00
Eric WagonerandClaude Fable 5.1 2d98393010 The recorder takes one poster per clip
Once written, the poster's countdown fell back to idle while the same
move still showed, re-armed, and overwrote the poster with the next
move — for two scenes, the change-of-eyes wipe. A done flag holds it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 19:39:13 -04:00
Eric WagonerandClaude Fable 5.1 53fba77a55 Eight quality-of-life turns for strangers, newcomers, and veterans
For visitors: the landing shows a clip card of the maze before asking
for a name; "Play now against a clockwork wizard" is one tap from the
landing to the first turn (the room, the seat, and the flip each
follow as the server confirms the last); pass-the-device and seat
transfer fold behind "More ways to play".

For a first game: automatons default to apprentice until this browser
has seen a game through, then step up to adept unless the tier was
picked by hand; the council speaks once unasked on a first turn; a
finished game offers "play again" (the same bots re-seated in a fresh
room) and "share the tale" (the whole replay as a link).

For players who know the box: a House rulings tab in help — what the
digital table does differently, every rulings revision (now exported
from the engine as RULES_REVISIONS, which the deckRev doc block points
at), and the 76 cards the FAQ has spoken on. And the arrow keys and
WASD walk on the board as they already did in the first-person pane.

The recorder's poster settle lengthens to outlast a change-of-eyes
wipe; two cards had caught the title card instead of the maze.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 19:24:46 -04:00
Eric WagonerandClaude Fable 5.1 2b5c2c4884 The visitors digest: who is here, who came today, how far they got
/usr/local/bin/wizwar-visitors.sh prints one screen — live sockets and
rooms touched in the hour, today's traffic row, the names seated today
with the first-time ones marked, every room opened today with its
state, move counts, span, and last human move, humans' chat verbatim,
and the desk's count — and the wizwar-visitors skill reads it aloud.
It replaces the four hand-written versions of the same report from
announcement day.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 18:58:53 -04:00
Eric WagonerandClaude Fable 5.1 9d2c66f0bd The lobby leads with your wizard, then fills the table
Pick your color first; then one section, "Fill the table", holds the
invite link, the roster, and the computer wizards, with a line saying
friends, automatons, or a mix all count toward the two to six. Abandon
sits below Start on its own line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 18:53:48 -04:00
Eric WagonerandClaude Fable 5.1 c2506dae69 Color, as the rest of the table spells it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 18:50:18 -04:00
Eric WagonerandClaude Fable 5.1 a7c55819ca The lobby says the plain words beside the flavor
Two evening arrivals created a room and left in the lobby: one never
seated anyone, one seated a bot and never pressed the button. The card
led with "share the link" as if company were required, the row of
temperaments had no word saying they were computer opponents, and the
start button said only "Flip the boards".

Now the card has three headings — Invite friends, Your colour, Or seat
a computer wizard — with one line on what a hunter, berserker, and
worrier do; the start button says Start the game with "flip the
boards · N wizards" beneath; and while it is disabled a line says what
would enable it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 18:48:05 -04:00
Eric WagonerandClaude Fable 5.1 6e1b565228 The first-turn slip promises what the board can do
Arrow keys stride only in the first-person pane; on the board a step
is a tap on the square beside you. The slip says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 15:11:35 -04:00
Eric WagonerandClaude Fable 5.1 6e3e998307 Three slips for a stranger's first game
Two newcomers on announcement day left within minutes at the same
moment: they picked up a treasure, the turn's actions ended as the
rules say, every walk after that was refused, and the status line's
"turn spent" told them nothing about why or what the treasure was for.
Both had stalled over a minute before their first move as well.

Now: on a first turn, a coaching slip says what a turn is — walk this
many squares, a NUMBER card walks farther, tap a card to cast, End
turn — dismissed once with "got it" and remembered in prefs. After a
pickup, the status line says the pickup ended the actions and the
treasure comes along, and to end the turn and carry it home. While
carrying on later turns, a slip names whose treasure it is, how far
home is, and that two scored wins; your own treasure scores nothing
in your arms.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 14:47:24 -04:00
Eric WagonerandClaude Fable 5.1 cf7920e159 The rollup counts the tags on posted links, and the pulse has a right-now
Reddit and BoardGameGeek send no referrer, so a visitor from either
looks direct. A ?ref=<name> on the posted link says where they came
from; the rollup counts those (one per address per day, Facebook's
fbclid as its own) beside the referrers browsers do send. The pulse
gains a right-now recipe: live sockets, rooms touched in the last
hour, and today's partial row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 12:06:12 -04:00
Eric WagonerandClaude Fable 5.1 ac2c3576a7 The rollup seats the host
A room's host takes the first seat by creating it; only the others
leave join lines. Counting joins alone reported rooms full of nobody.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 12:03:32 -04:00
Eric WagonerandClaude Fable 5.1 e1a740119c Credibility pass: the session seams sanded from the clips-and-camera batch
Two blind reviews of everything since the last pass (afa0e17), every
finding checked against the code, no behavior changed: all thirty
scene goldens match without a re-bless and the engine suite is
untouched.

Reel and renderer: the camera's look-down rule is stated once, beside
LOOK_DOWN, instead of twice in the effect; the empty aim branch that
stood where a cutaway used to be is gone (the guard it implied is now
explicit); the pit events and the punch are handled by their own
types, not through "in" casts; smoothstep is one export used by every
tween instead of eleven inline copies; the two floor rings share one
painter; project() takes a Billboard instead of a third hand-typed
copy of its fields; the strides-left figure and the web rim no longer
shadow the reel's steps and the pane's fx; the die card's verdict is
built from events, not by matching an emoji; the workshop asks for the
hover cue by name instead of passing an empty click handler.

Server and engine: one requestBase() for the origin, one slug pattern
in store.ts gating both the clip page and its files, one 404 for both;
LOOPBACK sits above its only caller; doCounteract names what a counter
is played against once; fearCells sits beside its own docblock rather
than between sightedCellsFor and its.

Deploy: chromiumExe, the private server, ffmpeg, and the reel rewind
live in deploy/lib/harness.mjs, shared by the gate, the recorder, and
the card cutter instead of pasted three times; the recorder drops its
duplicate frame counters and names its poster settle; the card uses the
gallery's exact gold; the one-time Sentry URL bootstrap leaves
deploy.sh; the backup comment states the rule rather than the incident.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 11:55:57 -04:00
Eric WagonerandClaude Fable 5.1 ddd2e347bd The address limits believe the proxy, not the client
X-Forwarded-For's first entry is whatever the client wrote; Caddy
appends the true peer last. The limiter now reads the last entry, and
only when the socket peer is loopback — the proxy — so a request that
somehow reaches the server directly is keyed by its own address.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 11:32:21 -04:00
Eric WagonerandClaude Fable 5.1 ca108f83a6 The nightly rollup keeps what the access log forgets; two doors get a limit
Three sysadmin items ahead of the public announcement's arrivals.

deploy/wizwar-rollup.sh writes one JSON line per UTC day to
/var/lib/wizwar/rollup.jsonl at 00:10: yesterday's traffic from Caddy's
access log (requests, human vs bot addresses as counts only, socket
connects, path mix, external referrers), the table's growth (new
rooms, human seats and names, lifetime game totals, reports and
replies), and the box's vitals (service memory and peak, disk, load,
protocol errors, service starts, ledger size). Re-rolling a day
replaces its line. It checks in to a Sentry cron monitor of its own,
whose URL deploy.sh derives from the backup monitor's on first
install, alongside the cron entry. The pulse gains a Trends section
that reads the last week of it. Caddy keeps 30 log files instead of 5
as the raw backing.

Per-address limits on the two doors anyone may use unseated: 12 new
rooms and 6 reports per address per hour, in a sliding window keyed by
the address Caddy forwards. The per-connection cap stays; it reset on
reconnect, which is what a script would do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 11:30:44 -04:00
Eric WagonerandClaude Fable 5.1 3f909b485b A sprung Ward is named as one, not as a punch
Eric grabbed a warded treasure and took the Ward's three points — but
the attack modal showed a bare-knuckled punch, the hint line said the
owner "attacks with a punch", and the cast panel called it a physical
blow with no card behind it. The engine knew better: the stack has
carried sourceName ("warded treasure") since rev 9, and the damage
line used it. The client never read it.

Now every cardless stack with a name shows that name — and, for the
Ward, the Ward card itself — in the modal, the hint line, and the
cast panel, with the damage inbound. In the chronicle, a counteraction
played against it records "warded treasure" rather than "punch", and
an EMPATHY echo of it names the same. Chronicle-only, ungated; all 58
production ledgers replay clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 09:41:12 -04:00
Eric WagonerandClaude Fable 5.1 3723b8f211 The clip gallery joins the site
The box lid links to the clips beside the about line, and the gallery
pages wear the site's own dress — its masthead with a way back to the
table, Oswald headings, Archivo Narrow body, the parchment-and-gold
buttons — so a shared clip and the game it came from read as one
place. The clip page's subtitle is the blurb alone, the title being
the heading.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 21:39:53 -04:00
Eric WagonerandClaude Fable 5.1 86070b42a3 The eyes turn to meet magic thrown at them, even after a stride
Springing the Trap never showed its fireball: Rival stepped into the
watched corridor facing the way he walked, and the ambush's fireball
flew in from the mouth behind him while only the caption told of it.
The director now aims at incoming magic and punches — the caster's or
attacker's square, straight or through a mouth — and a stride that
ends with something to watch turns once more after the walk, the
slate holding the outcome until the eyes face it. The fireball comes
through the wormhole at the camera, then the blow lands.

The trap's first beat re-blessed for it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 21:28:52 -04:00
Eric WagonerandClaude Fable 5.1 86856781df The eyes watch through a warp mouth; a cutaway is for what no sight reaches
The Warp Sniper cut to another angle the moment the fireball landed,
though the caster could see the whole thing: the director counted a
warp mouth before the target as blocked sight. Sight threads warps as
the rules say, and now the camera does too. director.sightline finds
the heading that actually sees a point — straight, or through a mouth
by aiming at the target's position in the far room and asking whether
the bent ray reaches it — and both the aim and the turn-toward-the-
actor rules use it. Only a point no ray reaches earns the cutaway.

Warp Sniper's second beat re-blessed: Wanderer's eyes now find Rival
through the mouth as the turn passes to him.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 20:16:47 -04:00
Eric WagonerandClaude Fable 5.1 7c0a761c59 The eyes look down, the die stops the reel, and a punch is a fist
A day of Eric watching the clips as a newcomer would.

The camera can pitch: FirstPerson takes a `pitch` that shears the
horizon up the pane (walls center on it, the eye stays half a wall
high), so the ground at the pane's foot comes within half a cell. The
director uses it for deeds at your own feet — a conjuration on your
square, a treasure taken up, the pit you fell down — instead of the
cutaway that stepped outside your body.

Everything shows through a warp. The floor pass now runs between the
cast and the wall draw, so a column that bent through a mouth maps its
ground through the warp's rigid motion and wears the far room's decals
— the pit past the wormhole included — under the same violet haze as
its walls.

A wizard down a pit is public (inPit on the player view), drawn sunk to
the floor line in first person and dimmed on the board. A body sharing
your square, which had no depth to draw at, stands right in front of
you. A pit leap glides like a shove instead of cutting. And the
director's blocked-sight test no longer counts a warp BEYOND the
target as a block, nor stands a cutaway camera inside another wizard's
square — the pair that put Wanderer's face across the whole pane.

The reel stops for a die: a card over the held world names who rolls
and for what, tumbles the faces on the reel's own clock, lands the
roll, and only then plays the outcome; the beat, the fx, the glides
and the camera all wait it out. A punch by you drives a fist into the
scene; one at you comes at the camera; both land with a comic POW.

film-clips rolls until the reel's last move rather than a fixed count,
and the gate settles longer on a step whose caption rolls a die. All
thirty goldens re-blessed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 19:20:03 -04:00
Eric WagonerandClaude Fable 5.1 7223f8eb0d The reel slates a change of eyes and captions the wizard's state
Two things for a viewer who has never seen the game. When the reel's
pov passes to another wizard, the cut used to be instant — one wizard
apparently teleporting. Now a slate wipes across the pane: a dark band
sweeps in over the old sight, holds with the new wizard's standee and
THROUGH THE EYES OF <name>, and sweeps off to reveal theirs. It is an
FpFx ("slate") drawn by the renderer on the reel's own clock, so the
harness and the film see the same frames.

The caption grows a right-hand column: strides left to spend this turn
as a countdown, ♥ life, and chips for what the wizard carries,
displays, or suffers — a treasure, displayed cards, spells on them
with turns remaining, lost turns, banked Pass Wall, attack spent. The
save-video composite carries the same column.

All thirty scene goldens re-blessed: the caption is inside every
frame. The 3D panes are pixel-identical to the previous goldens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 13:21:04 -04:00
Eric WagonerandClaude Fable 5.1 6a933f030c A warp crossing is just a step; only a wormhole opening surges
Eric's direction: passing through an ordinary warp mouth is a normal
stride. The veil already marks the doorway, and the wizard walks through
it as through any door — no shimmer, no surge, and the eyes carry on
the way they were going instead of turning back to show the mouth. The
surge stays for the moment a wormhole is cast, staged straight from the
warpOpened event through both its new mouths.

The heist's pinned frames re-blessed for it: at the crossing the reel
now looks down the corridor at the enemy's home rather than back
through the mouth.

deploy/film-clips.mjs films the catalog for the gallery — every scene
in both takes, the reel playing under the paused clock at one frame per
40ms, muxed to 25fps mp4s with the first pinned beat as the poster. The
recorder used for the first clip set lived in a session scratchpad and
was lost; this one lives here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 12:56:53 -04:00
Eric WagonerandClaude Fable 5.1 a7f77367a6 A warp crossing surges the veil itself, not a billboard over the wizard
Every board effect the cockpit had no art for landed as the same purple
lightning-ring sprite, so a body stepping through a warp mouth wore the
ring over its own portrait. Eric wanted the purple wall to do the
animating. The portal effect now becomes a "surge" keyed by the mouth's
index in board.warps: the veil hung in that opening floods with its
texture, the swirl quickens and pales, and a ripple runs out to the
jambs before it settles back — nothing drawn over whoever stands in it.

The director used to steer the eyes toward that ring's cell on the pov
wizard's own crossing; it now aims at a surging mouth the same way, so
the heist's pinned frames render as before. Illusion and Pass Wall
crossings keep the shimmer sprite — stone has no veil to surge.

Scene gate: 29 of 30 frames unchanged. stone-gaze-s10-board drifts
0.21% with or without this change (the dashed sight line's phase), a
pre-existing flake left unblessed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 12:43:28 -04:00
Eric WagonerandClaude Fable 5.1 8488e9a8d1 Clips unfurl: share cards, faststart video, a share button
Each clip page already carried its own title, blurb, poster and
og:video — but the assets behind the tags let the unfurl down. The mp4s
were tail-indexed, so previewers in iMessage, Discord, Slack and
Facebook gave up before finding a player; the tags carried no video
dimensions, without which those same sites refuse to embed one; and the
poster was a 736x578 screenshot of the workshop chrome, cropped to
nothing by any share sheet.

deploy/clips-prep.mjs fixes the set before clips-publish.sh ships it:
a lossless faststart remux of every take, a 1200x630 card per clip cut
from the poster's first-person viewport with the title set over it (an
HTML card screenshot through the harness's chromium — ffmpeg here has
no drawtext), and the fpv frame size stamped into clips.json. The page
emits the card with its dimensions, og:video:width/height, and a Share
button that opens the phone's share sheet or copies the link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-02 11:26:27 -04:00
175 changed files with 4064 additions and 1794 deletions
+28 -4
View File
@@ -30,7 +30,26 @@ quiet, say so in one line before the details.
connector reads fine but 403s on create operations
3. **Backups**: `tail -3 /var/log/wizwar/backup.log` (or its rotation)
for the last run, plus the cron monitor above for missed runs
4. **The table itself**:
4. **Trends** (`/var/lib/wizwar/rollup.jsonl`, one JSON line per UTC day
written at 00:10 by /usr/local/bin/wizwar-rollup.sh; counts only, no
addresses): the last 7 rows give requests, human vs bot addresses,
socket connects, path mix (/, /clips, /watch, /ws), external
referrers (reddit.com, boardgamegeek.com after the 2026-09-03
announcement), new rooms, human seats and names, reports/replies,
and vitals (memMB, memPeakMB, diskPct, load1, protocolErrors,
serviceStarts, ledgerMB). Report day-over-day direction, not raw
rows. Its Sentry cron monitor is slug `wizwar-rollup` (a missed
check-in there means the rollup itself is broken). Re-roll a day by
hand: `wizwar-rollup.sh YYYY-MM-DD`. `campaigns` counts visitors
(one per address per day) whose link carried `?ref=<name>` — the tags
on Eric's posts (reddit, bgg, …) — or a Facebook fbclid; referrers
alone can't attribute Reddit or BGG, which send none.
**Right now** (on request, or when the announcement is fresh): live
sockets `ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l`,
rooms touched in the last hour `find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l`,
and today so far `wizwar-rollup.sh $(date -u +%F)` (a partial row,
replaced again at 00:10; the vitals in it are the moment's).
5. **The table itself**:
- Room count and growth: `ls /var/lib/wizwar/rooms/*.jsonl | wc -l`
- New human names this week: room ledgers' join lines minus bots
(Automaton*) and known names, on rooms created in the last 7 days
@@ -42,8 +61,9 @@ quiet, say so in one line before the details.
## Report
Five sections, tight: **Verdict** (one line), **Box**, **Errors &
monitors**, **Backups**, **The table** (games, new faces, feedback).
Six sections, tight: **Verdict** (one line), **Box**, **Errors &
monitors**, **Backups**, **Trends** (the week's direction from the
rollup), **The table** (games, new faces, feedback).
Numbers with their thresholds, not raw dumps. End with any recommended
action, or "nothing needs you."
@@ -57,7 +77,11 @@ action, or "nothing needs you."
- Unattended-upgrades reboots the box at 09:30 UTC when a kernel patch
requires it; a reboot there is maintenance, not an outage.
- Caddy access logs live at /var/lib/caddy/access.log (self-rotating,
10MiB × 5); the systemd sandbox denies /var/log/caddy.
10MiB × 30); the systemd sandbox denies /var/log/caddy.
- Per-address limits (2026-09-03): 12 new rooms and 6 reports per
address per hour, in packages/server/src/ratelimit.ts. A player who
hits one sees "try again in an hour"; a pulse showing many refused
creates from one address is a script, not a friend.
- Restore drill (2026-09-01): a Spaces snapshot booted 56/56 rooms
clean. The path: `rclone copy spaces:kestrel-wizwar-backups/wizwar/
snapshots/<date> ...` on the droplet, then run a local server with
+56
View File
@@ -0,0 +1,56 @@
---
name: wizwar-visitors
description: The real-time Wiz-War visitors report — who is at the table right now, who came today and how far each got, what they said, what they filed, and today's traffic so far. Use when Eric asks about new players, visitors, "anyone playing?", or wants a traffic report for today.
---
# Wiz-War visitors
One command, one screen. The droplet prints the digest; you read it for
Eric like a host glancing over the tavern.
## Gather
ssh root@104.236.96.198 /usr/local/bin/wizwar-visitors.sh [YYYY-MM-DD]
Default is today, UTC. The script rolls today's partial rollup row
first, so the traffic line is current to the minute. Its output:
- **as of / live sockets / rooms touched in the last hour** — the
"right now" line. Two sockets at a quiet hour is Eric plus one.
- **traffic** — today's row of `/var/lib/wizwar/rollup.jsonl`:
addresses of people (bots split out), front-page vs gallery vs
replay views, campaigns (`?ref=` tags on posted links, Facebook's
fbclid) and referrers. Reddit and BGG send no referrer.
- **players today / NEW today** — names seated in rooms created today;
NEW means the name's first room ever is today. Known regulars:
Kestrel, Reuben, Jason, Yohon, and "Claude" (the duel seat).
- **rooms today** — one line each: time, code, humans vs bots, state
(lobby only / started, no moves / in play), move counts, span, and
the last human move with its time. Human chat lines follow the room,
verbatim. Automatons' chat is omitted; humans' is the signal.
- **REPORT lines and the feedback count** — reports filed today, plus
the unanswered total. Unanswered > 0 means run /wizwar-reports.
## Read it
Lead with new names and whether anyone is at the table now. Then, for
each stranger, say how far they got and where they stopped — the last
human move and the span tell it: a room that is "lobby only" stopped
before the start button; a game of under five minutes ending on
pickUpTreasure is the treasure wall the slips now explain; a long span
with many moves is a real session, resumable from their lobby. Quote
human chat verbatim. Then the traffic line as a single sentence with
the day-over-day direction if a previous row exists.
Two reads to resist: a ledger has no "finished" mark (the winner comes
from replaying it), and refused commands never enter ledgers — what a
player TRIED and was refused is invisible; to learn it, fetch the
ledger, replay to its end with the engine, and apply the commands they
plausibly sent next (see [[wizwar-fetch-game-files]]).
## Numbers with their meaning
- Announcement day (2026-09-03): 117 addresses, 9 rooms, 5 strangers;
before it, a good day was 27 addresses and a room or two.
- A new arrival who never presses start, or quits at a pickup, is a
first-impression signal, not a rules dispute — those file reports.
+97
View File
@@ -0,0 +1,97 @@
// Make a clips directory unfurl well before clips-publish.sh ships it:
// - remux every mp4 with its index up front (faststart). Previewers in
// iMessage, Discord, Slack and Facebook read the head of the file to
// decide whether to show a player, and give up on a tail-indexed one.
// - cut a 1200x630 share card per clip — the poster's first-person
// viewport, full-bleed, with the title set over it — for og:image.
// Share sheets crop anything that isn't 1.91:1; X crops to 2:1.
// - stamp each clip's video width/height into clips.json so the page
// can emit og:video:width/height, without which Facebook, LinkedIn
// and Discord refuse to embed a player.
//
// npx tsx deploy/clips-prep.mjs <clips-dir>
//
// Needs ffmpeg + ffprobe and the Playwright chromium cache (see
// verify-scenes.mjs). Idempotent: run it again after re-recording.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { chromium } from "playwright-core";
import { chromiumExe, ff } from "./lib/harness.mjs";
const dir = process.argv[2];
if (!dir || !existsSync(join(dir, "clips.json"))) {
console.error("usage: clips-prep.mjs <clips-dir> (a directory holding clips.json)");
process.exit(1);
}
// The workshop poster is a screenshot of the replay stage; the wizard's
// viewport sits at this rectangle within it — a 16:9 window.
const VIEWPORT = { x: 18, y: 51, w: 700, h: 393 };
const CARD = { width: 1200, height: 630 };
function faststart(file) {
const tmp = `${file}.tmp.mp4`;
ff("-i", file, "-c", "copy", "-movflags", "+faststart", tmp);
renameSync(tmp, file);
}
function videoSize(file) {
const [width, height] = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0", file]).toString().trim().split(",").map(Number);
return { width, height };
}
const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
/** The share card: the viewport scaled to the card's width (a 16:9 crop
* runs 674 tall at 1200 wide, so 22px shave top and bottom), a shade
* rising from the bottom, the title over it in the gallery's gold — set
* in Georgia, since the card is rendered offline with no web fonts. */
function cardHtml(title, viewportPngDataUrl) {
return `<!doctype html><html><head><meta charset="utf-8"><style>
html, body { margin: 0; width: ${CARD.width}px; height: ${CARD.height}px; overflow: hidden; background: #171a20; }
.card { position: relative; width: ${CARD.width}px; height: ${CARD.height}px;
font-family: Georgia, "Times New Roman", serif; color: #d6d2c4; }
.card img { position: absolute; left: 0; top: -22px; width: ${CARD.width}px; height: 674px;
image-rendering: auto; }
.shade { position: absolute; inset: 0;
background: linear-gradient(to bottom, rgba(23,26,33,0) 45%, rgba(23,26,33,0.92) 100%); }
.title { position: absolute; left: 56px; right: 56px; bottom: 94px;
font-size: 66px; line-height: 1.1; color: #e0b34a; letter-spacing: 0.02em;
text-shadow: 0 2px 12px rgba(0,0,0,0.6); }
.mark { position: absolute; left: 58px; bottom: 52px; font-size: 26px; letter-spacing: 0.08em;
color: #d6d2c4; opacity: 0.9; text-shadow: 0 1px 8px rgba(0,0,0,0.6); }
.mark b { font-weight: normal; color: #e0b34a; }
</style></head><body><div class="card">
<img src="${viewportPngDataUrl}">
<div class="shade"></div>
<div class="title">${esc(title)}</div>
<div class="mark"><b>WIZ-WAR</b> &nbsp;·&nbsp; a clip from the labyrinth</div>
</div></body></html>`;
}
const clipsFile = join(dir, "clips.json");
const clips = JSON.parse(readFileSync(clipsFile, "utf8"));
const browser = await chromium.launch({ executablePath: chromiumExe() });
const page = await browser.newPage({ viewport: CARD, deviceScaleFactor: 1 });
try {
for (const clip of clips) {
for (const take of ["fpv", "board"]) faststart(join(dir, `${clip.name}-${take}.mp4`));
Object.assign(clip, videoSize(join(dir, `${clip.name}-fpv.mp4`)));
const crop = join(dir, `.${clip.name}-viewport.png`);
ff("-i", join(dir, `${clip.name}.jpg`),
"-vf", `crop=${VIEWPORT.w}:${VIEWPORT.h}:${VIEWPORT.x}:${VIEWPORT.y}`, crop);
const dataUrl = `data:image/png;base64,${readFileSync(crop).toString("base64")}`;
unlinkSync(crop);
await page.setContent(cardHtml(clip.title, dataUrl));
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: join(dir, `${clip.name}-card.jpg`), type: "jpeg", quality: 88 });
console.log(`${clip.name}: ${clip.width}x${clip.height}, faststart, card`);
}
} finally {
await browser.close();
}
writeFileSync(clipsFile, JSON.stringify(clips, null, 2) + "\n");
console.log(`prepped ${clips.length} clips in ${dir}`);
Regular → Executable
+3 -1
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env bash
# Publish showcase clips to the droplet's clip vault (/var/lib/wizwar/clips).
# Run deploy/clips-prep.mjs on the directory first: it remuxes the video
# for streaming, cuts the share cards, and stamps frame sizes into clips.json.
# The gallery at /clips serves whatever this ships: <slug>-fpv.mp4,
# <slug>-board.mp4, <slug>.jpg per clip, described by clips.json.
# <slug>-board.mp4, <slug>.jpg, <slug>-card.jpg per clip, described by clips.json.
set -euo pipefail
host=${1:?usage: clips-publish.sh <host> <clips-dir>}
dir=${2:?usage: clips-publish.sh <host> <clips-dir>}
+9 -4
View File
@@ -4,17 +4,22 @@ set -euo pipefail
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
npm run build --workspace=@wizwar/web
rsync -az --delete \
# Old hashed chunks stay a week: a page loaded before this deploy can still
# fetch the module it was built against instead of failing mid-game.
rsync -az --delete --filter='P packages/web/dist/assets/*' \
--exclude node_modules --exclude data/ --exclude .git --exclude research \
./ "root@$HOST:/opt/wizwar/"
ssh "root@$HOST" '
cd /opt/wizwar && npm install --no-audit --no-fund
find /opt/wizwar/packages/web/dist/assets -type f -mtime +7 -delete
chown -R wizwar:wizwar /opt/wizwar
cp /opt/wizwar/deploy/wizwar.service /etc/systemd/system/wizwar.service
# The backup script is cron-run from /usr/local/bin install it every
# deploy, or the repo copy and the live copy drift apart (they did:
# the Sentry check-ins shipped in the repo and never reached the box).
# Cron runs the backup and the rollup from /usr/local/bin: install
# them on every deploy so the live copies are always the repo copies.
install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh
install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh
install -m 755 /opt/wizwar/deploy/wizwar-visitors.sh /usr/local/bin/wizwar-visitors.sh
install -m 644 /opt/wizwar/deploy/wizwar-rollup.cron /etc/cron.d/wizwar-rollup
systemctl daemon-reload
systemctl enable --now wizwar
systemctl restart wizwar
+99
View File
@@ -0,0 +1,99 @@
// Film the screenplay catalog for the public gallery: every scene in two
// takes — through the pov wizard's eyes and from the board — as the reel
// plays it under a paused Playwright clock, one screenshot per 40ms of
// simulated time, muxed by ffmpeg into 25fps mp4s. The poster is the
// first pinned beat, settled. Writes <slug>-fpv.mp4, <slug>-board.mp4,
// <slug>.jpg and clips.json into the output dir — then run
// clips-prep.mjs and clips-publish.sh on it.
//
// npx tsx deploy/film-clips.mjs <out-dir> [scene ...]
//
// Run from the repo root AFTER `npx vite build` in packages/web. Needs
// ffmpeg and the Playwright chromium cache (see verify-scenes.mjs).
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium } from "playwright-core";
import { chromiumExe, ff, rewindReel, startServer } from "./lib/harness.mjs";
import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts";
const [outDir, ...only] = process.argv.slice(2);
if (!outDir) { console.error("usage: film-clips.mjs <out-dir> [scene ...]"); process.exit(1); }
const PORT = 8809;
const FPS = 25;
const TAIL_MS = 2500; // hold on the last move
const POSTER_SETTLE_MS = 1100; // the poster beat's tween and fx done, still inside its 1.5s beat
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
const server = startServer(PORT, "wizwar-film");
try {
await server.up();
mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch({
executablePath: chromiumExe(),
args: ["--disable-gpu", "--force-color-profile=srgb", "--font-render-hinting=none"],
});
const scenes = SCREENPLAYS.filter((sp) => !only.length || only.includes(sp.name));
if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`);
const catalog = [];
for (const sp of scenes) {
let seconds = 0;
for (const view of ["fpv", "board"]) {
const frames = mkdtempSync(join(tmpdir(), `wizwar-frames-${sp.name}-${view}-`));
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
await page.clock.install({ time: EPOCH });
await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" });
const count = await rewindReel(page);
if (view === "fpv") await page.getByText("your eyes").click();
await page.clock.pauseAt(EPOCH + 60_000);
await page.clock.runFor(400); // let the first frame settle before rolling
const box = await page.locator(".replay").boundingBox();
const clip = { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) };
// Roll: the reel's play button is the ▶ before the step button.
await page.locator("button", { hasText: "▶" }).first().click();
// Roll until the reel reaches its last move (steps a die decides
// run longer than the beat), then hold the tail.
const posterBeat = sp.beats?.[0] ?? 1;
const MAX_FRAMES = FPS * 240; // four minutes: a reel that never reaches its last move
let f = 0, tail = -1, posterIn = -1, posterDone = false;
while (tail !== 0 && f < MAX_FRAMES) {
const png = await page.screenshot({ clip });
writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png);
const at = Number((await page.locator(".replay-count").textContent()).match(/move (\d+)/)[1]);
if (view === "fpv" && !posterDone && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(POSTER_SETTLE_MS / (1000 / FPS));
if (posterIn === 0) { writeFileSync(join(frames, "poster.png"), png); posterDone = true; }
if (posterIn >= 0) posterIn--;
if (tail < 0 && at === count) tail = Math.round(TAIL_MS / (1000 / FPS));
if (tail > 0) tail--;
await page.clock.runFor(1000 / FPS);
f++;
}
await ctx.close();
ff("-framerate", String(FPS), "-i", join(frames, "%05d.png"),
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "22",
"-movflags", "+faststart", join(outDir, `${sp.name}-${view}.mp4`));
if (view === "fpv") {
const poster = existsSync(join(frames, "poster.png")) ? join(frames, "poster.png") : join(frames, "00000.png");
ff("-i", poster, "-q:v", "3", join(outDir, `${sp.name}.jpg`));
seconds = Math.round(f / FPS);
}
rmSync(frames, { recursive: true, force: true });
console.log(`${sp.name} ${view}: ${f} frames`);
}
catalog.push({ name: sp.name, title: sp.title, blurb: sp.blurb, seconds });
}
await browser.close();
// A partial run keeps the rest of an existing catalog.
const file = join(outDir, "clips.json");
const prior = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : [];
const merged = SCREENPLAYS.map((sp) =>
catalog.find((c) => c.name === sp.name) ?? prior.find((c) => c.name === sp.name)).filter(Boolean);
writeFileSync(file, JSON.stringify(merged, null, 2) + "\n");
console.log(`filmed ${catalog.length} scenes into ${outDir}`);
} finally {
server.stop();
}
+56
View File
@@ -0,0 +1,56 @@
// Shared by the scene gate, the clip recorder, and the card cutter: the
// Playwright chromium they all drive, a private game server on a
// throwaway data dir, ffmpeg with the flags they all want, and the
// reel's rewind. Run from the repo root after `npx vite build` in
// packages/web.
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** The Playwright chromium executable: $WIZWAR_CHROME, else the newest
* build in the ms-playwright cache. */
export function chromiumExe() {
if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME;
const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright");
const builds = existsSync(cache)
? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort()
: [];
for (const build of builds.reverse()) {
const exe = join(cache, build, "chrome-mac-arm64",
"Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
if (existsSync(exe)) return exe;
}
throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)");
}
/** A game server on `port` with its own empty data dir. `up()` resolves
* once it answers; `stop()` kills it and removes the dir. */
export function startServer(port, label = "wizwar-harness") {
const dataDir = mkdtempSync(join(tmpdir(), `${label}-`));
const server = spawn("npx", ["tsx", "src/index.ts"], {
cwd: join(import.meta.dirname, "..", "..", "packages", "server"),
env: { ...process.env, PORT: String(port), WIZWAR_DATA_DIR: join(dataDir, "rooms") },
stdio: "ignore",
});
const up = async () => {
for (let i = 0; i < 60; i++) {
try { if ((await fetch(`http://127.0.0.1:${port}/`)).ok) return; } catch { /* not yet */ }
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("server never came up");
};
const stop = () => { server.kill(); rmSync(dataDir, { recursive: true, force: true }); };
return { up, stop };
}
/** ffmpeg, quiet and overwriting. */
export const ff = (...args) => execFileSync("ffmpeg", ["-loglevel", "error", "-y", ...args]);
/** Pause the reel and step it back to its first move. */
export async function rewindReel(page) {
await page.locator("button", { hasText: "❚❚" }).click();
const count = Number((await page.locator(".replay-count").textContent()).match(/of (\d+)/)[1]);
for (let i = 0; i < count; i++) await page.locator("button", { hasText: "◀" }).click();
return count;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 376 KiB

+5 -1
View File
@@ -25,7 +25,11 @@ mkdir -p /opt/wizwar /var/lib/wizwar/rooms
chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar
# Caddy vhost: auto-TLS, security headers, proxy to the game.
printf '%s\n\nheader {\n\tStrict-Transport-Security "max-age=31536000"\n\tX-Content-Type-Options "nosniff"\n\tX-Frame-Options "DENY"\n\tReferrer-Policy "no-referrer"\n}\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile
# Access log kept to 30 rolls of 10MiB: the nightly rollup keeps the
# counts; the raw lines back it for a while. A deploy restarts the game
# for a few seconds; the proxy holds requests that land in that gap and
# keeps dialing, so a visitor waits instead of meeting a 502.
printf '%s\n\nheader {\n\tStrict-Transport-Security "max-age=31536000"\n\tX-Content-Type-Options "nosniff"\n\tX-Frame-Options "DENY"\n\tReferrer-Policy "no-referrer"\n}\nlog {\n\toutput file /var/lib/caddy/access.log {\n\t\troll_size 10MiB\n\t\troll_keep 30\n\t}\n}\nreverse_proxy localhost:8787 {\n\tlb_try_duration 30s\n\tlb_try_interval 250ms\n}\n' "$HOST" > /etc/caddy/Caddyfile
systemctl reload caddy
# Firewall: ssh + web only. The game server binds loopback and is reached
+16 -46
View File
@@ -11,11 +11,12 @@
// Run from the repo root AFTER `npx vite build` in packages/web (the
// wrapper deploy/verify-scenes.sh does both). Needs the Playwright
// chromium cache (`npx playwright-core install chromium` if missing).
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium } from "playwright-core";
import { chromiumExe, rewindReel, startServer } from "./lib/harness.mjs";
import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts";
const args = process.argv.slice(2);
@@ -31,20 +32,6 @@ const CHANNEL_TOL = 6;
const FAIL_FRAC = 0.002;
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
function chromiumExe() {
if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME;
const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright");
const builds = existsSync(cache)
? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort()
: [];
for (const build of builds.reverse()) {
const exe = join(cache, build, "chrome-mac-arm64",
"Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
if (existsSync(exe)) return exe;
}
throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)");
}
/** Decode a PNG to raw RGBA via ffmpeg — the harness's only image dep. */
function rgba(png) {
return execFileSync("ffmpeg", ["-loglevel", "error", "-i", png,
@@ -64,26 +51,11 @@ function compare(goldenPng, actualPng) {
}
// --- A private server on a throwaway data dir. ---------------------------
const dataDir = mkdtempSync(join(tmpdir(), "wizwar-scenes-"));
const server = spawn("npx", ["tsx", "src/index.ts"], {
cwd: join(import.meta.dirname, "..", "packages", "server"),
env: { ...process.env, PORT: String(PORT), WIZWAR_DATA_DIR: join(dataDir, "rooms") },
stdio: "ignore",
});
const up = async () => {
for (let i = 0; i < 60; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/`);
if (r.ok) return;
} catch { /* not yet */ }
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("server never came up");
};
const server = startServer(PORT, "wizwar-scenes");
let failures = 0;
try {
await up();
await server.up();
mkdirSync(GOLDENS, { recursive: true });
rmSync(DIFFS, { recursive: true, force: true });
mkdirSync(DIFFS, { recursive: true });
@@ -95,10 +67,8 @@ try {
if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`);
for (const sp of scenes) {
// One page per view: the reel steps forward beat to beat under the
// paused clock, so a scene's later beats cost only their marginal
// steps — and every beat's simulated capture time is still exactly
// (steps so far) × 2.6s, identical every run.
// One page per view: the reel steps forward beat to beat, so a
// scene's later beats cost only their marginal steps.
const takes = [
{ view: "fpv", beats: [...(sp.beats ?? [])].sort((a, b) => a - b) },
{ view: "board", beats: sp.boardBeat === undefined ? [] : [sp.boardBeat] },
@@ -109,19 +79,20 @@ try {
const page = await ctx.newPage();
await page.clock.install({ time: EPOCH });
await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" });
await page.locator("button", { hasText: "❚❚" }).click();
for (let i = 0; i < 4; i++) await page.locator("button", { hasText: "◀" }).click();
await rewindReel(page);
if (view === "fpv") await page.getByText("your eyes").click();
// Freeze the world at a fixed instant, then advance it by hand.
// Steps on the way to a beat get 0.6 simulated seconds; the beat
// itself gets 2.6 to settle its tween, hold, and fx. The schedule
// is fixed, so every run renders the same bytes.
// Freeze the world at a fixed instant, then advance it by hand:
// steps on the way to a beat get 0.6 simulated seconds, the beat
// itself 2.6 to settle its tween, hold, and fx.
await page.clock.pauseAt(EPOCH + 60_000);
let at = 0;
for (const beat of beats) {
for (; at < beat; at++) {
await page.locator("button", { hasText: "▶" }).last().click();
await page.clock.runFor(at === beat - 1 ? 2600 : 600);
// A step the die decides holds the world for the roll's
// interlude before anything moves: wait it out on top.
const die = (await page.locator(".replay-caption").textContent()).includes("\u{1F3B2}");
await page.clock.runFor((at === beat - 1 ? 2600 : 600) + (die ? 2400 : 0));
}
const box = await page.locator(".replay").boundingBox();
const name = `${sp.name}-s${beat}-${view}.png`;
@@ -151,8 +122,7 @@ try {
}
await browser.close();
} finally {
server.kill();
rmSync(dataDir, { recursive: true, force: true });
server.stop();
}
if (!update) console.log(`--- ${failures ? `${failures} beat(s) drifted` : "every pinned beat matches its golden"}`);
process.exit(failures ? 1 : 0);
+2
View File
@@ -0,0 +1,2 @@
# Nightly rollup of yesterday's traffic, table, and vitals (UTC). Installed by deploy.sh.
10 0 * * * root /usr/local/bin/wizwar-rollup.sh
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# Nightly rollup: one JSON line per day in /var/lib/wizwar/rollup.jsonl —
# yesterday's traffic (from Caddy's access log), the table's growth (from
# the room ledgers, stats.json and feedback.jsonl), and the box's vitals.
# Counts only: no addresses are kept. The pulse reads it for trends the
# self-rotating access log cannot keep. Referrers are what browsers send
# (Reddit and BGG send none); campaigns are the ?ref=<name> tags on links
# Eric posts, one count per address per day, plus Facebook's fbclid.
# Cron: 10 0 * * * (UTC), see /etc/cron.d/wizwar-rollup (deploy.sh installs both).
# Sentry Crons check-in: /root/.wizwar-sentry-cron-rollup holds the URL
# (absent = no check-ins).
set -u
OUT="/var/lib/wizwar/rollup.jsonl"
LOG="/var/log/wizwar/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
CRON_URL=$(cat /root/.wizwar-sentry-cron-rollup 2>/dev/null || true)
# The check-in carries the schedule: Sentry only keeps a monitor it has
# been told the shape of, and a bare ping to an unknown slug is accepted
# and dropped without a word.
checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"10 0 * * *\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true
}
checkin in_progress
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
import json, sys, os, glob, gzip, datetime, collections, subprocess, re
day, out = sys.argv[1], sys.argv[2]
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
BOT = ("bot", "crawl", "spider", "facebookexternalhit", "slack", "discord", "twitter", "preview", "curl", "python", "go-http", "wget", "headless")
# --- traffic: every access log file touched since the day began ---
req = 0; human = 0; bot = 0; hips = set(); bips = set()
paths = collections.Counter(); status = collections.Counter(); refs = collections.Counter(); ws = 0
# Where a visit came from, when the link itself says so: ?ref=<name> on a
# link Eric posted, a Facebook click's fbclid, or a utm_source.
campaigns = collections.Counter(); seen_campaign = set()
for f in sorted(glob.glob("/var/lib/caddy/access*.log*")):
if os.path.getmtime(f) < t0: continue
opener = gzip.open if f.endswith(".gz") else open
with opener(f, "rt", errors="ignore") as fh:
for line in fh:
try: r = json.loads(line)
except ValueError: continue
ts = r.get("ts", 0)
if not (t0 <= ts < t1): continue
q = r.get("request", {}); h = q.get("headers", {})
ua = " ".join(h.get("User-Agent", [""])).lower()
ip = q.get("client_ip") or q.get("remote_ip") or "?"
isbot = any(k in ua for k in BOT)
req += 1
if isbot: bot += 1; bips.add(ip)
else: human += 1; hips.add(ip)
full = q.get("uri", ""); uri = full.split("?")[0]
if uri == "/ws": ws += 1
if not isbot and "?" in full and ip not in seen_campaign:
qs = dict(kv.split("=", 1) if "=" in kv else (kv, "") for kv in full.split("?", 1)[1].split("&"))
tag = qs.get("ref") or qs.get("utm_source") or ("facebook" if "fbclid" in qs else None)
if tag and re.fullmatch(r"[a-z0-9_-]{1,24}", tag):
campaigns[tag] += 1; seen_campaign.add(ip)
key = ("/clips" if uri.startswith("/clips") else "/watch" if uri.startswith("/watch")
else "(assets)" if uri.startswith("/assets") or re.search(r"\.(png|jpg|svg|ico|css|js|webmanifest|mp4)$", uri)
else uri if uri in ("/", "/ws", "/robots.txt") else "(other)")
if not isbot: paths[key] += 1 # the path mix is what people looked at
status[str(r.get("status", 0))[0] + "xx"] += 1
ref = " ".join(h.get("Referer", [""]))
m = re.match(r"https?://([^/]+)", ref)
host = m.group(1) if m else ""
ours = host.endswith("kestrelsnest.social") or "104.236.96.198" in host
# A referrer on /.env is a scanner's costume (a forged google.com),
# not a reader — and the app answers unknown paths with the page,
# so the status cannot tell them apart. Only the pages count.
if host and not ours and not isbot and key in ("/", "/clips", "/watch"): refs[host] += 1
# --- the table ---
new_rooms = 0; seats = collections.Counter()
for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
try:
with open(f) as fh:
meta = json.loads(fh.readline())
ca = meta.get("createdAt")
if not ca: continue
ct = datetime.datetime.fromisoformat(ca.replace("Z", "+00:00")).timestamp()
if not (t0 <= ct < t1): continue
new_rooms += 1
# The host takes the first seat by creating; only others join.
if meta.get("hostId"): seats[meta["hostId"]] += 1
for line in fh:
try: r = json.loads(line)
except ValueError: continue
if r.get("kind") == "join" and not r.get("bot"): seats[r["name"]] += 1
except OSError: continue
stats = {}
try: stats = json.load(open("/var/lib/wizwar/stats.json"))
except Exception: pass
reports = 0; replies = 0
try:
for line in open("/var/lib/wizwar/feedback.jsonl"):
try: r = json.loads(line)
except ValueError: continue
at = datetime.datetime.fromisoformat(r["at"].replace("Z", "+00:00")).timestamp()
if t0 <= at < t1: reports += ("happened" in r); replies += ("reportId" in r)
except OSError: pass
# --- vitals (now, at rollup time) ---
def sh(cmd):
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip()
except Exception: return ""
mem = sh("systemctl show wizwar -p MemoryCurrent --value"); peak = sh("systemctl show wizwar -p MemoryPeak --value")
disk = sh("df --output=pcent / | tail -1").strip().rstrip("%")
load = sh("cut -d' ' -f1 /proc/loadavg")
journal = sh("journalctl -u wizwar --since '%s' --until '%s' --no-pager 2>/dev/null" % (day, (d0 + datetime.timedelta(days=1)).date()))
errs = str(len(re.findall(r"(?i)unhandled protocol error", journal)))
starts = str(journal.count("Started wizwar"))
ledger = sum(os.path.getsize(f) for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"))
row = {
"day": day, "requests": req, "human": human, "bot": bot,
"humanAddresses": len(hips), "botAddresses": len(bips), "socketConnects": ws,
"paths": dict(paths.most_common()), "status": dict(status), "referrers": dict(refs.most_common(10)),
"campaigns": dict(campaigns.most_common(10)),
"newRooms": new_rooms, "humanSeats": sum(seats.values()), "humanNames": sorted(seats),
"gamesFinishedTotal": stats.get("gamesFinished"), "gamesCreatedTotal": stats.get("gamesCreated"),
"reports": reports, "replies": replies,
"vitals": {"memMB": round(int(mem) / 1048576) if mem.isdigit() else None,
"memPeakMB": round(int(peak) / 1048576) if peak.isdigit() else None,
"diskPct": int(disk) if disk.isdigit() else None, "load1": float(load) if load else None,
"protocolErrors": int(errs) if errs.isdigit() else None, "serviceStarts": int(starts) if starts.isdigit() else None,
"ledgerMB": round(ledger / 1048576, 1)},
}
# One line per day: a rerun for the same day replaces its line.
rows = []
if os.path.exists(out):
rows = [l for l in open(out).read().splitlines() if l.strip() and json.loads(l).get("day") != day]
rows.append(json.dumps(row, separators=(",", ":")))
tmp = out + ".tmp"
open(tmp, "w").write("\n".join(rows) + "\n"); os.replace(tmp, out)
print("%s rollup %s: %d requests, %d human addresses, %d new rooms, %d human seats" % (datetime.datetime.now(datetime.timezone.utc).isoformat(), day, req, len(hips), new_rooms, sum(seats.values())))
PY
then checkin ok; else checkin error; fi
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# The visitors digest: who is here right now and who came today — the
# rooms opened, how far each got, what humans said, what they filed —
# with today's traffic so far. Read by the wizwar-visitors skill.
# wizwar-visitors.sh [YYYY-MM-DD] (default: today, UTC)
set -u
DAY="${1:-$(date -u +%F)}"
/usr/local/bin/wizwar-rollup.sh "$DAY" >/dev/null 2>&1
python3 - "$DAY" <<'PY'
import json, sys, glob, os, datetime, subprocess
day = sys.argv[1]
now = datetime.datetime.now(datetime.timezone.utc)
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
def sh(cmd):
try: return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20).stdout.strip()
except Exception: return "?"
print("live sockets: %s | rooms touched in the last hour: %s" % (
sh("ss -tn state established '( sport = :8787 )' | tail -n +2 | wc -l"),
sh("find /var/lib/wizwar/rooms -name '*.jsonl' -mmin -60 | wc -l")))
# --- traffic so far, from the rollup ---
try:
rows = [json.loads(l) for l in open("/var/lib/wizwar/rollup.jsonl")]
r = [x for x in rows if x["day"] == day][-1]
p = r["paths"]
print("traffic: %d addresses, %d requests from people (%d bot); front %d, gallery %d, replays %d; campaigns %s; referrers %s" % (
r["humanAddresses"], r["human"], r["bot"], p.get("/", 0), p.get("/clips", 0), p.get("/watch", 0),
json.dumps(r["campaigns"]), json.dumps(r["referrers"])))
except Exception as e:
print("traffic: (no rollup row: %s)" % e)
# --- every room, for first sightings; today's rooms in detail ---
first_seen = {}
rooms = []
for f in glob.glob("/var/lib/wizwar/rooms/*.jsonl"):
try:
lines = open(f).read().splitlines()
meta = json.loads(lines[0])
except Exception: continue
ca = meta.get("createdAt", "")
if not ca: continue
humans = [meta["hostId"]]; bots = []; started = False; cmds = 0; chat = []; last = ca; steps = []
for l in lines[1:]:
try: x = json.loads(l)
except Exception: continue
k = x.get("kind")
if k == "join":
(bots if x.get("bot") else humans).append(x["name"])
elif k == "start": started = True
elif k == "command":
cmds += 1; last = x.get("at", last)
steps.append((x.get("playerId"), x["command"].get("type"), x.get("at", "")))
elif k == "chat":
last = x.get("at", last)
if x["player"] in humans: chat.append((x.get("at", "")[11:16], x["player"], x["text"]))
for h in humans:
if h not in first_seen or ca < first_seen[h]: first_seen[h] = ca
rooms.append(dict(id=meta["id"], created=ca, humans=humans, bots=bots, started=started, cmds=cmds, last=last, chat=chat, steps=steps))
today = sorted([r for r in rooms if r["created"].startswith(day)], key=lambda r: r["created"])
names = sorted({h for r in today for h in r["humans"]})
new = [n for n in names if first_seen[n].startswith(day)]
print("players today: %s | NEW today: %s" % (", ".join(names) or "none", ", ".join(new) or "none"))
print("rooms today: %d" % len(today))
for r in today:
span = ""
if r["cmds"]:
t0 = datetime.datetime.fromisoformat(r["created"].replace("Z", "+00:00")); t1 = datetime.datetime.fromisoformat(r["last"].replace("Z", "+00:00"))
span = "%d min" % round((t1 - t0).total_seconds() / 60)
human_cmds = [s for s in r["steps"] if s[0] in r["humans"]]
last_human = ("last human move: %s %s at %s" % (human_cmds[-1][0], human_cmds[-1][1], human_cmds[-1][2][11:16])) if human_cmds else "no human move"
state = "lobby only" if not r["started"] else ("started, no moves" if not r["cmds"] else "in play")
print(" %s %s %s%s %s %d moves (%d human) %s %s" % (
r["created"][11:16], r["id"], "+".join(r["humans"]), (" vs %d bot%s" % (len(r["bots"]), "" if len(r["bots"]) == 1 else "s")) if r["bots"] else "",
state, r["cmds"], len(human_cmds), span, last_human))
for at, who, text in r["chat"]: print(" %s %s: %s" % (at, who, text))
# --- the desk ---
reports = 0; unanswered = 0; answered = set()
try:
rows = [json.loads(l) for l in open("/var/lib/wizwar/feedback.jsonl") if l.strip()]
answered = {x["reportId"] for x in rows if "reportId" in x}
for x in rows:
if "happened" not in x: continue
if (x.get("id") or x["at"]) not in answered: unanswered += 1
if x["at"].startswith(day):
reports += 1
print(" REPORT %s %s (%s): %s / expected: %s" % (x["at"][11:16], x["player"], x["roomId"], x["happened"], x["expected"]))
except OSError: pass
print("feedback: %d filed today, %d unanswered overall" % (reports, unanswered))
PY
+72 -8
View File
@@ -155,6 +155,13 @@ interface PathResult {
distance: number;
/** A locked door stands on the first step of this path. */
doorAhead?: { cell: Cell; side: Side };
/** The first step lands on a pit: the side of its rim the path leaves by. */
exit?: Side;
}
/** The move that takes a path's first step, naming the pit exit when there is one. */
function stepAlong(path: PathResult): Command {
return { type: "move", direction: path.dir, ...(path.exit ? { exit: path.exit } : {}) };
}
/** One walkable step for the clockwork's pathfinding; null = impassable.
@@ -582,7 +589,28 @@ function wallBlastTarget(
* when the clockwork can unlock them; the first such door is reported so the
* key gets used before the boot.
*/
function pathToward(
/** Can a wizard entering the pit at `pit` heading `dir` get off its rim
* — the maze's own test: some side other than the way back with a cell
* there, no wall between, and no stone on it. */
/** The sides a walker may leave a pit by, having entered it heading
* `dir`: neighbouring squares first; a warp mouth on the rim only when
* no square offers, the reading every revision accepts. */
function pitExits(view: GameView, pit: Cell, dir: Side): Side[] {
const footing = (d: Side) => {
if (d === opposite(dir)) return null;
const t = stepTarget(view.board, pit, d);
if (t.kind === "blocked" || view.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t.kind;
};
const floor = SIDES.filter((d) => footing(d) === "step");
return floor.length > 0 ? floor : SIDES.filter((d) => footing(d) === "warp");
}
function pitLandable(view: GameView, pit: Cell, dir: Side): boolean {
return pitExits(view, pit, dir).length > 0;
}
export function pathToward(
view: GameView,
from: Cell,
goals: Set<string>,
@@ -607,6 +635,16 @@ function pathToward(
const k = cellKey(to);
if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
const hazard = view.squareContents[k]?.kind;
// A pit is entered by leaping to the square beyond it in the same
// direction; with nothing to land on, the maze bounces the leaper
// back and charges the stride. Goal or waypoint, that is no road.
if (hazard === "pit" && !pitLandable(view, to, dir)) continue;
// Out of a pit only by one of its exits.
if (view.squareContents[cellKey(c)]?.kind === "pit") {
const entry = cameBy.get(cellKey(c))?.dir;
if (entry && !pitExits(view, c, entry).includes(dir)) continue;
}
seen.add(k);
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
if (goals.has(k)) { found = k; break; }
@@ -615,7 +653,6 @@ function pathToward(
// turn, and stings — a jail with foliage. Only a wizard with no
// road at all (throughJails) dives in. "Few will dive into one
// unless they are desperate."
const hazard = view.squareContents[k]?.kind;
if (hazard === "thornbush" && !opts.throughJails) continue;
if (!opts.throughHazards &&
(hazard === "pit" || hazard === "ooze" ||
@@ -629,15 +666,19 @@ function pathToward(
}
if (!found) return null;
let cursor = found;
let second: Side | undefined;
for (;;) {
const hop = cameBy.get(cursor)!;
if (hop.prev === cellKey(from)) {
const firstIsPit = view.squareContents[cursor]?.kind === "pit";
return {
dir: hop.dir,
distance: depth,
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
...(firstIsPit && second ? { exit: second } : {}),
};
}
second = hop.dir;
cursor = hop.prev;
}
}
@@ -725,7 +766,8 @@ function overLimit(view: GameView): number {
* clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length);
const draw = drawUnderCurses(view, tier.draw);
const deficit = draw - (handLimitOf(view) - view.yourHand.length);
if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand]
.filter((c) => discardValue(c, view, style) <= 3)
@@ -734,7 +776,16 @@ function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle
.map((c) => c.instanceId);
if (shed.length > 0) return { type: "discard", instanceIds: shed };
}
return { type: "endTurn", draw: tier.draw };
return { type: "endTurn", draw };
}
/** SLOW DEATH bleeds a point per card drawn: the clockwork draws only what
* it can afford, keeping four life in hand, and nothing at all when it
* cannot — a bare hand beats a bare grave. */
export function drawUnderCurses(view: GameView, draw: number): number {
const slow = view.sustained.some((e) => e.cardId === "slow-death" && e.targetId === view.you && !e.data?.reversed);
if (!slow) return draw;
return Math.max(0, Math.min(draw, me(view).life - 4));
}
function worstCards(view: GameView, n: number, style?: AutomatonStyle): string[] {
@@ -1299,6 +1350,13 @@ export function automatonCommand(
// A Ward is free damage on a thief of OUR gold: always spring it.
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
}
if (view.pushPending) {
if (view.pushPending.pusheeId !== you) return null;
// Shoved to a fork: step out of the giant's line so he cannot keep
// pushing; failing that, the first way open.
const { direction, exits } = view.pushPending;
return { type: "pushChoice", direction: exits.find((d) => d !== direction) ?? exits[0]! };
}
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) };
}
@@ -1522,8 +1580,13 @@ export function automatonCommand(
}
}
}
// No shot at a wizard: raise a creature to do the walking.
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
// No shot at a wizard: raise a creature to do the walking. A SHADOW
// drinks a life a turn from its master — no pet for a wizard already
// bleeding, or with little blood to spare.
const bleeding = view.sustained.some((e) =>
(e.cardId === "slow-death" || e.cardId === "walking-dead") && e.targetId === you && !e.data?.reversed);
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId) &&
!(c.cardId === "shadow" && (bleeding || self.life <= 6)));
if (summon && livingEnemies(view).length > 0) {
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
const spot = summonSpot(view, near);
@@ -1700,7 +1763,7 @@ export function automatonCommand(
return { type: "move", direction: dir };
}
const approach = pathToward(view, self.position, new Set([cellKey(crossing.near)]), { canUnlock });
if (approach) return { type: "move", direction: approach.dir };
if (approach) return stepAlong(approach);
}
}
// Or bank one now, when a crossing beats the walk by enough to spend a card.
@@ -1811,7 +1874,7 @@ export function automatonCommand(
if (run) return { type: "cast", instanceId: run.instanceId, params: { points } };
}
}
return { type: "move", direction: path.dir };
return stepAlong(path);
}
}
}
@@ -1823,6 +1886,7 @@ export function automatonCommand(
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
const you = view.you;
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
if (view.pushPending?.pusheeId === you) return { type: "pushChoice", direction: view.pushPending.exits[0]! };
if (view.slowDeathPending?.playerId === you) return { type: "slowDeathChoice" };
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
+276 -113
View File
@@ -238,7 +238,32 @@ export interface CastParams {
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 13;
export const CURRENT_RULES_REV = 20;
/** Every rulings revision since the baseline, newest last the entries a
* game's deckRev freezes it before or after. Shown to players as the house
* rulings; new entries go here alongside the CURRENT_RULES_REV bump. */
export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 2, note: "a wizard beside a door they can open (lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through the doorway; the hallway behind them still cannot." },
{ rev: 3, note: "VISIONSTONE pierces its one wall for every sight the game asks of its bearer — creations and utility spells included — not only direct attacks." },
{ rev: 4, note: "\"the door will relock behind you\" means it — passing through an unlocked door shuts it at the walker's back unless a hand holds it; unpassed, it relocks at turn's end as before." },
{ rev: 5, note: "MENTAL FORCE refuses a destination the victim cannot walk to in three spaces, instead of eating the card silently at resolution." },
{ rev: 6, note: "STRENGTH's treasure-tear opens a counteraction window (\"this would be an attack\") instead of resolving instantly." },
{ rev: 7, note: "DISEASE is a self-cast plague (\"You're the carrier!\") — the caster carries it, sharing a square bites both directions, no counteraction." },
{ rev: 8, note: "the fire imp's scorch is a SPELL (FAQ) — counteractable, magical — and SOULSTONE's floor holds even at 3 life or below." },
{ rev: 9, note: "the WARD's bite is counteractable (\"COUNTERACTIONs ... otherwise work as written\"), though nothing a counter does touches its caster." },
{ rev: 10, note: "BUTT-HEAD's ram is MOVEMENT — the charge walks real corridors on the turn's legal legs (three plus numbers) and spends them." },
{ rev: 11, note: "a REVERSE against SLOW DEATH or WALKING DEAD turns the whole curse (FAQ) — a point gained per card drawn, half a point per space walked, permanently — and a FULL REFLECTION returns a permanent curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of letting it evaporate." },
{ rev: 12, note: "MAD DASH doubles \"NUMBER cards and other add-ons\" too — a number riding the cast fuels it, and numbers or POWER RUN points played under the dash double; older games doubled only the base allowance and consumed the rider without effect." },
{ rev: 13, note: "SLOW DEATH's per-draw bites land as ONE blow that pauses for a victim holding ABSORB or BLUNT — countered against the total (absorb soaks up to 3, blunt halves rounding up); older games bit instantly." },
{ rev: 14, note: "Crossing a pit on a 2, 3, or 4 means edging around its rim: the walker lands on an open square beside the pit — the only one if there is one, otherwise the one they name by clicking it — and a pit with no way off cannot be entered. Older games bounced the walker back and charged the stride." },
{ rev: 15, note: "BIG MAN at a fork: a pushed wizard leaves by any open side but the way the giant came — the only one if there is one, otherwise the side they choose, the giant's stride hanging until they do (FAQ: at an intersection the other player decides). Older games shove straight ahead whenever that way is open; rounding a corner was never possible before and is allowed in every game." },
{ rev: 16, note: "BUTT-HEAD's charge is measured as far as the goat's legs reach. Older games searched only six steps of corridor and refused a longer ram as unreachable, even with the legs to make it." },
{ rev: 17, note: "A warp mouth on a pit's rim is a way off it like any square: a bare step beside one square and one mouth is a fork the walker names. In every game the mouth may be named, and is taken when no square offers; only an older game's bare step still lands on the square unasked." },
{ rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." },
{ rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." },
{ rev: 20, note: "A waterwall's wave names its victims before it pushes any of them. Before, a wave walking the way it pushed could catch a wizard it had just shoved and shove them again with the force left — one square into a wall cost two points instead of one." },
];
export interface GameConfig {
playerIds: PlayerId[];
@@ -251,43 +276,7 @@ export interface GameConfig {
/**
* Rules revision, frozen per game so stored games replay unchanged.
* Absent = rev 1, the baseline all earlier revisions collapsed into.
* Rev 2: a wizard beside a door they can open (lock removed, door
* unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
* the doorway; the hallway behind them still cannot.
* Rev 3: VISIONSTONE pierces its one wall for every sight the game
* asks of its bearer creations and utility spells included not
* only direct attacks.
* Rev 4: "the door will relock behind you" means it passing through
* an unlocked door shuts it at the walker's back unless a hand holds
* it; unpassed, it relocks at turn's end as before.
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
* in three spaces, instead of eating the card silently at resolution.
* Rev 6: STRENGTH's treasure-tear opens a counteraction window ("this
* would be an attack") instead of resolving instantly.
* Rev 7: DISEASE is a self-cast plague ("You're the carrier!") the
* caster carries it, sharing a square bites both directions, no
* counteraction.
* Rev 8: the fire imp's scorch is a SPELL (FAQ) counteractable,
* magical and SOULSTONE's floor holds even at 3 life or below.
* Rev 9: the WARD's bite is counteractable ("COUNTERACTIONs ...
* otherwise work as written"), though nothing a counter does touches
* its caster.
* Rev 10: BUTT-HEAD's ram is MOVEMENT the charge walks real
* corridors on the turn's legal legs (three plus numbers) and spends
* them.
* Rev 11: a REVERSE against SLOW DEATH or WALKING DEAD turns the whole
* curse (FAQ) a point gained per card drawn, half a point per space
* walked, permanently and a FULL REFLECTION returns a permanent
* curse (SLOW DEATH, WALKING DEAD, IDIOT) onto its caster instead of
* letting it evaporate.
* Rev 12: MAD DASH doubles "NUMBER cards and other add-ons" too a
* number riding the cast fuels it, and numbers or POWER RUN points
* played under the dash double; older games doubled only the base
* allowance and consumed the rider without effect.
* Rev 13: SLOW DEATH's per-draw bites land as ONE blow that pauses
* for a victim holding ABSORB or BLUNT countered against the total
* (absorb soaks up to 3, blunt halves rounding up); older games bit
* instantly.
* What each revision changed is RULES_REVISIONS, above.
*/
deckRev?: number;
}
@@ -305,6 +294,11 @@ export interface GameState {
/** A treasure was just grabbed and its owner holds WARD:
* the table waits while they choose to play it "at that time" or not. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
/** BIG MAN has pushed a wizard to a fork: the giant's stride hangs while
* the pushed one picks which way to go (rev 15). */
pushPending: {
giantId: PlayerId; pusheeId: PlayerId; direction: Side; over: boolean; exit?: Side; exits: Side[];
} | null;
/** SLOW DEATH's bites hang while the victim weighs counteractions:
* the draw's total is one blow ABSORB soaks up to three of it,
* BLUNT halves it rounding up (table ruling). */
@@ -446,7 +440,18 @@ function sightThroughOneEdge(
return throughOneEdge(board, (b) => sightBetween(b, from, to, blockers));
}
/** Rev 19: DUST CLOUD blinds whoever stands in it and hides them "an
* LOS spell cannot be cast upon a player in DUST CLOUD, or by a player
* who is in a DUST CLOUD" (official FAQ); a spell cast upon oneself
* still works, and VISIONSTONE does not see through dust either. Before
* it the cloud blocked only sight lines passing through. */
function dustAtEnd(state: GameState, from: Cell, to: Cell): boolean {
if ((state.config.deckRev ?? 1) < 19 || cellKey(from) === cellKey(to)) return false;
return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust";
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
if (dustAtEnd(state, from, to)) return false;
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
@@ -666,7 +671,9 @@ export type GameEvent =
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
| { type: "spellReused"; player: PlayerId; card: CardInstance }
| { type: "castAroundCorner"; caster: PlayerId }
| { type: "moveBumped"; player: PlayerId; direction: Side }
/** A stride that went nowhere: into a wall while blind, or into a pit
* with nothing to land on beyond it. */
| { type: "moveBumped"; player: PlayerId; direction: Side; why?: "pit" }
| { 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 }
@@ -701,7 +708,7 @@ export type GameEvent =
| { type: "slippedInOoze"; player: PlayerId; at: Cell }
| { type: "struggledInOoze"; player: PlayerId; stood: boolean }
| { type: "steppedOnTacks"; player: PlayerId; at: Cell }
| { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell }
| { type: "jumpedPit"; player: PlayerId; from: Cell; to: Cell; via?: "warp" }
| { type: "fellInPit"; player: PlayerId; at: Cell }
| { type: "climbedFromPit"; player: PlayerId; success: boolean }
| { type: "stuckInSlime"; player: PlayerId; at: Cell }
@@ -723,6 +730,8 @@ export type GameEvent =
| { type: "swapFizzled"; player: PlayerId }
| { type: "wardSprung"; owner: PlayerId; victim: PlayerId }
| { type: "wardWindow"; owner: PlayerId; victim: PlayerId }
| { type: "pushWindow"; giant: PlayerId; pushee: PlayerId; exits: Side[] }
| { type: "pushChosen"; pushee: PlayerId; direction: Side }
| { type: "chaosShielded"; player: PlayerId }
| { type: "spellTrapped"; caster: PlayerId; cell: Cell; cardId: string }
| { type: "slimeTrapSprung"; cell: Cell; cardId: string; victim: PlayerId }
@@ -790,7 +799,9 @@ export type CastTarget =
| { kind: "cell"; cell: Cell };
export type Command =
| { type: "move"; direction: Side; over?: boolean }
/** `exit` names the side of a pit to leave by when the way straight
* ahead is closed the ledge runs around the whole rim (rev 14). */
| { type: "move"; direction: Side; over?: boolean; exit?: Side }
| { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string }
| { type: "punch"; targetId: PlayerId }
| { type: "tearTreasure"; targetId: PlayerId }
@@ -798,6 +809,8 @@ export type Command =
| { type: "testIllusion"; cell: Cell; side: Side }
| { type: "armWard" }
| { type: "wardChoice"; play: boolean }
/** The pushed wizard's answer at a fork: the side they go. */
| { type: "pushChoice"; direction: Side }
| { type: "slowDeathChoice"; counterInstanceId?: string }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
@@ -2364,7 +2377,11 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((state.config.deckRev ?? 1) < 10 || !target) return null;
const caster = activePlayer(state);
const legs = state.turn.movementAllowance - state.turn.movementUsed;
const d = walkingDistance(state, caster.position, target.position);
// Rev 16: the charge is measured as far as the legs reach. Before it,
// the search stopped at six steps and called anything past that
// unreachable — a goat with eight legs refused a seven-step ram.
const reach = (state.config.deckRev ?? 1) >= 16 ? legs : 6;
const d = walkingDistance(state, caster.position, target.position, reach);
if (d > legs) return "the goat charges on legs, not wings — you cannot walk that far this turn";
return null;
},
@@ -2372,7 +2389,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ctx.fullyStopped || !ctx.defender.alive || !ctx.attacker.alive) return;
if ((ctx.state.config.deckRev ?? 1) >= 10) {
const legs = ctx.state.turn.movementAllowance - ctx.state.turn.movementUsed;
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position);
const reach = (ctx.state.config.deckRev ?? 1) >= 16 ? legs : 6;
const d = walkingDistance(ctx.state, ctx.attacker.position, ctx.defender.position, reach);
if (d === 0) return;
if (d > legs) {
// The target slipped beyond the charge (a counter-teleport, say):
@@ -2859,6 +2877,48 @@ function waveForce(range: number, dist: number): number {
return range - dist;
}
/** One line of a wave's sweep: the squares it reaches, nearest first, with
* the force left at each. The victims are named before any is pushed
* (rev 20). Before it the sweep, walking the way it pushed, could find a
* victim it had just shoved and shove them again with the force left: a
* wizard washed one square into a wall was crushed twice. Older games
* replay that sweep. */
function sweepWave(
state: GameState, events: GameEvent[], line: { cell: Cell; force: number }[], dir: Side, reason: string,
): void {
const gather = ({ cell, force }: { cell: Cell; force: number }) => ({
force,
players: state.players.filter((p) => p.alive && cellKey(p.position) === cellKey(cell)),
creatures: state.creatures.filter((c) => cellKey(c.position) === cellKey(cell)),
slime: state.squareContents[cellKey(cell)]?.kind === "slime" ? cell : null,
});
const apply = (hit: ReturnType<typeof gather>) => {
for (const p of hit.players) washBackN(state, events, p, dir, hit.force);
for (const c of hit.creatures) {
if (!state.creatures.includes(c)) continue;
if (c.kind === "fire-imp") destroyCreature(state, events, c, reason);
else washBackCreature(state, events, c, dir, hit.force);
}
if (hit.slime) {
delete state.squareContents[cellKey(hit.slime)];
delete state.slimeTraps[cellKey(hit.slime)];
events.push({ type: "slimeWashed", cell: hit.slime });
}
};
if ((state.config.deckRev ?? 1) >= 20) line.map(gather).forEach(apply);
else for (const step of line) apply(gather(step));
}
function waveLine(start: Cell, dir: Side, range: number): { cell: Cell; force: number }[] {
const line: { cell: Cell; force: number }[] = [];
let probe = start;
for (let dist = 0; dist < range; dist++) {
line.push({ cell: probe, force: waveForce(range, dist) });
probe = neighbor(probe, dir);
}
return line;
}
/** A collapsing waterwall wave from an edge: wash players back `range`. */
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
// A warp mouth's "far side" is the opposite rim: the collapse washes both
@@ -2873,51 +2933,12 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
{ start: cell, dir: opposite(side) },
{ start: neighbor(cell, side), dir: side },
];
for (const { start, dir } of pushes) {
let probe = start;
for (let dist = 0; dist < range; dist++) {
const force = waveForce(range, dist);
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
}
for (const c of [...state.creatures]) {
if (cellKey(c.position) !== cellKey(probe)) continue;
if (c.kind === "fire-imp") {
destroyCreature(state, events, c, reason);
} else {
washBackCreature(state, events, c, dir, force);
}
}
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);
}
}
for (const { start, dir } of pushes) sweepWave(state, events, waveLine(start, dir, range), dir, reason);
}
/** A wave bursting outward from a cell in all four directions. */
function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range: number): void {
for (const dir of SIDES) {
let probe = center;
for (let dist = 0; dist < range; dist++) {
probe = neighbor(probe, dir);
const force = waveForce(range, dist);
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
}
for (const c of [...state.creatures]) {
if (cellKey(c.position) !== cellKey(probe)) continue;
if (c.kind === "fire-imp") {
destroyCreature(state, events, c, "rushing water");
} else {
washBackCreature(state, events, c, dir, force);
}
}
}
}
for (const dir of SIDES) sweepWave(state, events, waveLine(neighbor(center, dir), dir, range), dir, "rushing water");
}
/** A monster summon: ATTACK-typed, uses your attack, appears in your LOS. */
@@ -3680,7 +3701,9 @@ function attachSustained(
}
/** BFS steps between cells respecting walls (MENTAL FORCE's 3 moved spaces). */
function walkingDistance(state: GameState, from: Cell, to: Cell): number {
/** Steps along real corridors from one cell to another, searched out to
* `limit` steps; beyond that the answer is Infinity. */
export function walkingDistance(state: GameState, from: Cell, to: Cell, limit = 6): number {
if (cellKey(from) === cellKey(to)) return 0;
const view = boardView(state);
const seen = new Map<string, number>([[cellKey(from), 0]]);
@@ -3688,7 +3711,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number {
while (queue.length > 0) {
const cur = queue.shift()!;
const d = seen.get(cellKey(cur))!;
if (d >= 6) break;
if (d >= limit) break;
for (const side of SIDES) {
const step = stepTarget(view, cur, side);
if (step.kind === "blocked") continue;
@@ -3820,6 +3843,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
wallDamage: {},
slimeTraps: {},
wardPending: null,
pushPending: null,
slowDeathPending: null,
chaosPending: null,
doorStates: {},
@@ -3900,6 +3924,13 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
return doWardChoice(state, command.play);
}
// BIG MAN's fork: the stride hangs while the pushed wizard picks a way.
if (state.pushPending) {
if (playerId !== state.pushPending.pusheeId) return err("waiting on the pushed wizard to choose a way");
if (command.type !== "pushChoice") return err("choose which way you are pushed");
return doPushChoice(state, command.direction);
}
// SLOW DEATH's window: the bites hang while the victim weighs an absorb.
if (state.slowDeathPending) {
if (playerId !== state.slowDeathPending.playerId) return err("waiting on the slow death's victim");
@@ -4019,7 +4050,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
}
switch (command.type) {
case "move": return doMove(state, command.direction, command.over === true);
case "move": return doMove(state, command.direction, command.over === true, command.exit);
case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId);
case "punch": return doPunch(state, command.targetId);
case "tearTreasure": return doTearTreasure(state, command.targetId);
@@ -4027,6 +4058,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
case "testIllusion": return doTestIllusion(state, command.cell, command.side);
case "armWard": return doArmWard();
case "wardChoice": return err("no grab is hanging on your Ward");
case "pushChoice": return err("nobody is pushing you");
case "slowDeathChoice": return err("no slow death bites are hanging");
case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
@@ -4054,8 +4086,22 @@ export function activePlayer(state: GameState): PlayerState {
return state.players[state.turn.activeIndex]!;
}
/** The state is plain data literals, arrays, numbers (Infinity
* included), strings, booleans, and the odd undefined so a recursive
* copy serves, and runs several times faster than structuredClone. Every
* command clones once, and a server boot replays every ledger. */
function clone(state: GameState): GameState {
return structuredClone(state);
return deepCopy(state);
}
function deepCopy<T>(value: T): T {
if (Array.isArray(value)) return value.map(deepCopy) as T;
if (value !== null && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const key in value) out[key] = deepCopy((value as Record<string, unknown>)[key]);
return out as T;
}
return value;
}
/** Everything an eliminated wizard leaves behind goes with them.
@@ -4170,7 +4216,7 @@ function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direct
return { ok: true, state, events };
}
function doMove(prev: GameState, direction: Side, over = false): CommandResult {
function doMove(prev: GameState, direction: Side, over = false, exit?: Side, shove?: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
if (prev.turn.movementUsed >= prev.turn.movementAllowance) return err("no movement left");
@@ -4184,7 +4230,7 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
// BLIND (or a DUST CLOUD): "must roll direction on D4 if attempting to
// move ... bumping into a wall counts as one space of movement."
if (isBlinded(state, p)) {
if (isBlinded(state, p) && shove === undefined) {
direction = SIDES[rollD4(state, events, p.id, "a blind stagger picks the direction") - 1]!;
}
@@ -4314,24 +4360,59 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
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) {
const canShoveTo = (d: Side) => {
const t = stepTarget(view, p.position, d);
return t.kind === "step" &&
state.squareContents[cellKey(t.to)]?.kind !== "stone" &&
!state.players.some((o) => o.alive && cellKey(o.position) === cellKey(t.to) &&
sustainedOn(state, o.id, "big-man").length > 0)
? t.to : null;
};
// Rev 15 (FAQ): "If Big Man pushes someone to an intersection, then
// the other player gets to decide which direction to go." The
// pushed one leaves by any open side but the way the giant came;
// one open side is taken, several are theirs to choose — the stride
// hangs until they do. Older games shoved straight ahead only.
// A game dealt before rev 15 shoves straight ahead when it can, as it
// always did; the corner and the fork were refusals before, so every
// game may round a corner now.
const fork = (state.config.deckRev ?? 1) >= 15;
let landing: Cell | null = null;
const exits = SIDES.filter((d) => d !== opposite(direction) && canShoveTo(d) !== null);
if (exits.length === 0) {
p.position = from;
return err("no room to push them onward");
}
if (fork || !exits.includes(direction)) {
const chosen = shove ?? (exits.length === 1 ? exits[0]! : null);
if (chosen === null) {
// Hang the stride: nothing has changed but the question.
const pushee = pushPlayers[0];
if (pushee) {
const held = clone(prev);
held.pushPending = { giantId: p.id, pusheeId: pushee.id, direction, over, ...(exit ? { exit } : {}), exits };
return { ok: true, state: held, events: [{ type: "pushWindow", giant: p.id, pushee: pushee.id, exits }] };
}
landing = canShoveTo(exits.includes(direction) ? direction : exits[0]!);
} else {
if (!exits.includes(chosen)) { p.position = from; return err(`no way to push them ${chosen} — the corridor leads ${exits.join(" or ")}`); }
landing = canShoveTo(chosen);
}
} else {
landing = canShoveTo(direction);
}
if (!landing) {
p.position = from;
return err("no room to push them onward");
}
for (const o of pushPlayers) {
const oFrom = o.position;
o.position = { ...shove.to };
o.position = { ...landing };
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 };
c.position = { ...landing };
events.push({ type: "pushed", by: p.id, creatureId: c.id, from: cFrom, to: c.position });
}
}
@@ -4346,6 +4427,47 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
// fall in (2 damage, movement over); otherwise you sail across to the far
// side (if there is open floor there).
if (content?.kind === "pit" && !misted && !p.inPit) {
// Rev 14: the rim is a ledge that runs the whole way round. Before any
// roll, the walker needs somewhere off it — straight ahead, or the
// named side — or the pit cannot be entered from here at all.
const ledge = (state.config.deckRev ?? 1) >= 14;
let landing: Side | null = null;
let landingCell: Cell | null = null;
let landingWarp = false;
if (ledge) {
const pitCell = p.position;
const footing = (d: Side): { kind: "step" | "warp"; to: Cell } | null => {
const t = stepTarget(view, pitCell, d);
if (t.kind === "blocked" || state.squareContents[cellKey(t.to)]?.kind === "stone") return null;
return t;
};
const ways = SIDES.filter((d) => d !== opposite(direction));
const floor = ways.filter((d) => footing(d)?.kind === "step");
const mouths = ways.filter((d) => footing(d)?.kind === "warp");
// A warp mouth on the rim is a way off it like any square, and may
// always be named. Only what a bare step does is a matter of
// revision: from rev 17 a square beside a mouth is a fork to name;
// before it the step took the square, and takes it still, so the
// recorded games replay as they were played.
const exits = [...floor, ...mouths];
const bare = (state.config.deckRev ?? 1) >= 17 || floor.length === 0 ? exits : floor;
if (exits.length === 0) {
p.position = from;
return err("the pit cannot be crossed from here — nothing to land on beside it");
}
if (exit !== undefined && !exits.includes(exit)) {
p.position = from;
return err(`no footing that way — the pit's rim leads ${exits.join(" or ")}`);
}
landing = exit ?? (bare.length === 1 ? bare[0]! : null);
if (landing === null) {
p.position = from;
return err(`the rim leads more than one way — click the square to land on: ${exits.join(" or ")}`);
}
const foot = footing(landing)!;
landingCell = foot.to;
landingWarp = foot.kind === "warp";
}
const roll = rollD4(state, events, p.id, "leaping the pit — a 1 falls in");
if (roll === 1) {
p.inPit = true;
@@ -4356,21 +4478,21 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
events.unshift({ type: "moved", player: p.id, from, to: p.position, direction, via });
return { ok: true, state, events };
}
const beyond = neighbor(p.position, direction);
const beyondOk =
const beyond = landingCell ?? neighbor(p.position, direction);
const beyondOk = landingCell !== null || (
view.cells[cellKey(beyond)] &&
(view.edges[edgeKey(p.position, direction)] ?? "open") === "open" &&
state.squareContents[cellKey(beyond)]?.kind !== "stone";
state.squareContents[cellKey(beyond)]?.kind !== "stone");
if (beyondOk) {
const pitCell = p.position;
p.position = beyond;
events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond });
events.push({ type: "jumpedPit", player: p.id, from: pitCell, to: beyond, ...(landingWarp ? { via: "warp" as const } : {}) });
content = state.squareContents[cellKey(p.position)];
} else {
// Nowhere to land: teeter back where you started.
p.position = from;
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
events.push({ type: "moveBumped", player: p.id, direction, why: "pit" });
return { ok: true, state, events };
}
}
@@ -4703,6 +4825,18 @@ function touchesEdge(position: Cell, cell: Cell, side: Side): boolean {
return cellKey(position) === cellKey(cell) || cellKey(position) === cellKey(neighbor(cell, side));
}
/** The pushed wizard's answer at a fork: the giant's hanging stride
* resumes, shoving them the way they chose. */
function doPushChoice(prev: GameState, direction: Side): CommandResult {
const pp = prev.pushPending!;
if (!pp.exits.includes(direction)) return err(`the corridor leads ${pp.exits.join(" or ")}`);
const state = clone(prev);
state.pushPending = null;
const r = doMove(state, pp.direction, pp.over, pp.exit, direction);
if (!r.ok) return r;
return { ok: true, state: r.state, events: [{ type: "pushChosen", pushee: pp.pusheeId, direction }, ...r.events] };
}
/** The owner's answer to a hanging grab: spring the WARD, or let them go. */
function doWardChoice(prev: GameState, play: boolean): CommandResult {
const state = clone(prev);
@@ -5743,6 +5877,8 @@ function doCounteract(
): CommandResult {
const state = clone(prev);
const stack = state.stack!;
/** What the counter is played against, as the chronicle names it. */
const against = stack.attackCard?.cardId ?? stack.sourceName ?? "punch";
const player = state.players.find((p) => p.id === playerId)!;
const card = player.hand.find((c) => c.instanceId === instanceId);
if (!card) return err("card not in hand");
@@ -5779,7 +5915,7 @@ function doCounteract(
}
player.hand.push(attackCard);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against },
{ type: "attackAbsorbedIntoHand", player: playerId, attackCard },
{ type: "attackResolved", attacker: stack.attackerId, defender: stack.defenderId, attackCardId: attackCard.cardId, damageDealt: 0, reflectedDamage: 0, fullyStopped: true, redirected: false },
];
@@ -5809,7 +5945,7 @@ function doCounteract(
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
};
}
@@ -5821,7 +5957,7 @@ function doCounteract(
takeFromHand(player, instanceId);
state.discard.push(card);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against },
];
attachSustained(state, events, "invisible", playerId, playerId, duration);
stack.counters.push({ player: playerId, card, nullified: false });
@@ -5838,7 +5974,7 @@ function doCounteract(
takeFromHand(player, instanceId);
state.discard.push(card);
const events: GameEvent[] = [
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" },
{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against },
];
attachSustained(state, events, "empathy", playerId, playerId, duration);
stack.counters.push({ player: playerId, card, nullified: false });
@@ -5877,7 +6013,7 @@ function doCounteract(
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
};
}
@@ -5897,7 +6033,7 @@ function doCounteract(
return {
ok: true,
state,
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against: stack.attackCard?.cardId ?? "punch" }],
events: [{ type: "counteractionPlayed", player: playerId, card, cardId: card.cardId, against }],
};
}
@@ -5999,6 +6135,8 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
const attackId = stack.attackCard?.cardId ?? null;
const effect = attackId ? (CARD_EFFECTS[attackId] as AttackEffect) : null;
/** REFLECTION's half, when it goes back as an attack of its own. */
let returned: { damage: number; duration: number } | null = null;
// Hit rolls against INVISIBLE (attacker guesses a direction: 1-in-4) and
// SHRINK ("Reduces opponent's chance to hit you by 50% in any attack").
@@ -6236,17 +6374,26 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
state.discard.push(card!);
events.push({ type: "cardsDiscarded", player: defender.id, cards: [card!] });
}
if (pipe.reflectedDamage > 0 && !stack.trapped) {
// Rev 18: REFLECTION's returning half is a blow on the caster like
// FULL REFLECTION's whole — a fresh attack with the caster's own
// counteraction window, so an ABSORB or a BLUNT can meet it. Older
// games landed the half at once, and replay so.
const halfReturns = (state.config.deckRev ?? 1) >= 18 && !stack.trapped && !attackingCreature &&
attacker.alive && (pipe.reflectedDamage > 0 || (pipe.splitDuration && pipe.duration > 0));
if (pipe.reflectedDamage > 0 && !stack.trapped && !halfReturns) {
if (attackingCreature) {
damageCreature(state, events, attackingCreature, pipe.reflectedDamage, "reflected touch");
} else {
applyDamage(state, events, attacker, pipe.reflectedDamage, `${attackId} (reflection)`, defender.id);
}
}
if (halfReturns) {
returned = { damage: pipe.reflectedDamage, duration: pipe.splitDuration ? pipe.duration : 0 };
}
// EMPATHY: "Any attack done in any form against you acts against both
// you and the caster of the spell."
if (damageDealt > 0 && sustainedOn(state, defender.id, "empathy").length > 0 && attacker.alive) {
applyDamage(state, events, attacker, damageDealt, `${attackId ?? "punch"} (empathy)`, defender.id, pipe.kind);
applyDamage(state, events, attacker, damageDealt, `${attackId ?? stack.sourceName ?? "punch"} (empathy)`, defender.id, pipe.kind);
}
// SHADOWSTONE: physical damage you deal feeds your life total.
if (damageDealt > 0 && pipe.kind === "physical" && displays(attacker, "shadowstone") && attacker.alive) {
@@ -6255,7 +6402,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
}
if (effect?.sustains && pipe.duration > 0 && !pipe.fullyStopped) {
attachSustained(state, events, attackId!, attacker.id, defender.id, pipe.duration);
if (pipe.splitDuration) {
if (pipe.splitDuration && !returned) {
attachSustained(state, events, attackId!, defender.id, attacker.id, pipe.duration);
}
}
@@ -6294,6 +6441,22 @@ function resolveStack(state: GameState, events: GameEvent[]): void {
stack,
});
}
if (returned && attacker.alive) {
state.stack = {
attackerId: defender.id,
defenderId: attacker.id,
attackCard: stack.attackCard,
numberValue: null,
amplifyFactor: 1,
extendFactor: 1,
powerAttackPoints: 0,
params: stack.params,
kind: "spell",
counters: [],
waitingOn: attacker.id,
reflectedBase: returned,
};
}
}
/**
+35 -6
View File
@@ -31,6 +31,8 @@ export interface PlayerPublicView {
extraTurns: number;
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
passWallCharges: number;
/** Down a pit, until a roll climbs them out — everyone saw the fall. */
inPit: boolean;
displayed: CardInstance[];
/** Which of the six physical wizard colors this player plays. */
colorIndex: number;
@@ -58,6 +60,8 @@ export interface GameView {
/** Duration spells in play (public knowledge). */
sustained: SustainedEffect[];
squareContents: Record<string, SquareContent>;
/** Spells lodged in slime, oldest first (public: each cast was seen). */
slimeTraps: Record<string, { cardId: string; casterId: PlayerId }[]>;
/** Cells whose contents are glued down (public: the cast was seen). */
gluedCells: Record<string, true>;
/** Safes standing open (their combination entered this turn). */
@@ -89,6 +93,7 @@ export interface GameView {
chaosPending: { casterId: PlayerId; queue: PlayerId[] } | null;
/** A grab hangs while the treasure's owner decides their Ward. */
wardPending: { ownerId: PlayerId; takerId: PlayerId } | null;
pushPending: GameState["pushPending"];
slowDeathPending: { playerId: PlayerId; points: number } | null;
/** YOUR armed ambushes. Other players' ambushes are invisible. */
yourAmbushes: AmbushState[];
@@ -132,6 +137,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
position: p.position,
home: p.home,
life: p.life,
inPit: p.inPit,
alive: p.alive,
handCount: p.hand.length,
carriedTreasureId: p.carriedTreasureId,
@@ -152,6 +158,9 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
pendingDiscard: state.pendingDiscard,
sustained: state.sustained.map((s) => ({ ...s })),
squareContents: { ...state.squareContents },
slimeTraps: Object.fromEntries(
Object.entries(state.slimeTraps).map(([k, traps]) => [k, traps.map((t) => ({ cardId: t.card.cardId, casterId: t.casterId }))]),
),
gluedCells: { ...state.gluedCells },
openSafes: [...state.openSafes],
groundObjects: Object.fromEntries(
@@ -173,6 +182,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
? { casterId: state.chaosPending.casterId, queue: [...state.chaosPending.queue] }
: null,
wardPending: state.wardPending ? { ...state.wardPending } : null,
pushPending: state.pushPending ? { ...state.pushPending, exits: [...state.pushPending.exits] } : null,
slowDeathPending: state.slowDeathPending ? { ...state.slowDeathPending } : null,
yourAmbushes: state.ambushes
.filter((a) => a.ownerId === playerId)
@@ -188,12 +198,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
};
}
/**
* Every cell the viewing player can see from where they stand, by the same
* line-of-sight rules the engine enforces — computed from the VIEW, so it
* reflects what this player knows (illusion walls they believe in block it).
* The basis for the client's "dim the ineligible squares" targeting aid.
*/
/** Every square inside a living FEAR-bearer's aura — dreadDistance walks
* through warps, the engine's own yardstick, so the painted aura and the
* movement refusal can never disagree. */
@@ -210,13 +214,25 @@ export function fearCells(view: GameView): Set<string> {
return out;
}
/**
* Every cell the viewing player can see from where they stand, by the same
* line-of-sight rules the engine enforces — computed from the VIEW, so it
* reflects what this player knows (illusion walls they believe in block it).
* The basis for the client's "dim the ineligible squares" targeting aid.
*/
export function sightedCellsFor(view: GameView): Set<string> {
const out = new Set<string>();
const me = view.players.find((p) => p.id === view.you);
if (!me) return out;
const { board, blockers } = sightBasis(view);
// In a dust cloud a wizard sees only their own square (rev 19).
if (dustAtEnd(view, me.position, me.position, true)) {
out.add(`${me.position.x},${me.position.y}`);
return out;
}
for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (dustAtEnd(view, me.position, { x, y })) continue;
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
}
// VISIONSTONE lets its bearer see through exactly one wall or door —
@@ -309,16 +325,29 @@ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Recor
* exists — the renderer's material for drawing the line an attack traveled.
*/
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
if (dustAtEnd(view, from, to)) return null;
const { board, blockers } = sightBasis(view);
return traceSight(board, from, to, blockers);
}
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells;
* a square sees itself still. `standing` asks only whether the viewer's
* own square is dust. */
function dustAtEnd(view: GameView, from: Cell, to: Cell, standing = false): boolean {
if (view.deckRev < 19) return false;
const dust = (c: Cell) => view.squareContents[`${c.x},${c.y}`]?.kind === "dust";
if (standing) return dust(from);
if (from.x === to.x && from.y === to.y) return false;
return dust(from) || dust(to);
}
/**
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
* sees a middle square which sees the target — mirroring the engine's
* bentLos so a client can predict whether the modifier will land.
*/
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
if (dustAtEnd(view, from, to)) return false;
const { board, blockers } = sightBasis(view);
if (sightBetween(board, from, to, blockers)) return true;
for (const key of Object.keys(board.cells)) {
+82 -1
View File
@@ -8,13 +8,14 @@ import {
} from "../src/game";
import { cellKey, edgeKey } from "../src/board";
import { sightedCellsFor, viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { automatonCommand, automatonFallback, drawUnderCurses, pathToward, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
return (
state.wardPending?.ownerId ??
state.pushPending?.pusheeId ??
state.slowDeathPending?.playerId ??
state.stack?.waitingOn ??
state.pendingDiscard ??
@@ -1110,3 +1111,83 @@ describe("interception, escape, and hazard sense", () => {
expect(applyCommand(state, "bot", cmd!).ok).toBe(true);
});
});
describe("the clockwork under a curse, and before a pit", () => {
/** A two-seat game advanced to the bot's own turn in round 2. */
function botsTurn() {
let { state } = createGame({ playerIds: ["human", "bot"], seed: 7, sets: ["basic", "expansion1"] });
for (let guard = 0; guard < 8; guard++) {
if (state.turn.round >= 2 && actingSeat(state) === "bot") break;
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(`setup: ${r.error}`);
state = r.state;
}
return state;
}
it("draws only what SLOW DEATH lets it afford", () => {
const state = botsTurn();
const bot = state.players.find((p) => p.id === "bot")!;
const plain = viewFor(state, "bot");
expect(drawUnderCurses(plain, 2)).toBe(2);
pushSustained(state, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 });
bot.life = 7;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(2);
bot.life = 5;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(1);
bot.life = 4;
expect(drawUnderCurses(viewFor(state, "bot"), 2)).toBe(0);
});
it("raises no SHADOW while bleeding, nor with little blood to spare", () => {
for (const rig of [
(s: GameState) => pushSustained(s, { id: "sd", cardId: "slow-death", casterId: "human", targetId: "bot", remainingTurns: 1e9 }),
(s: GameState) => { s.players.find((p) => p.id === "bot")!.life = 5; },
]) {
let state = botsTurn();
rig(state);
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand = [{ instanceId: "shadow#T", cardId: "shadow" }];
// Play the bot's whole turn: nothing it does may be the shadow.
for (let guard = 0; guard < 30 && actingSeat(state) === "bot"; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "berserker", "archmage") ?? automatonFallback(view, "archmage");
expect(JSON.stringify(cmd)).not.toContain("shadow#T");
const r = applyCommand(state, "bot", cmd);
if (!r.ok) break;
state = r.state;
}
}
});
it("never plans a stride into a pit it cannot leap beyond", () => {
const state = botsTurn();
const bot = state.players.find((p) => p.id === "bot")!;
const view0 = viewFor(state, "bot");
// Stand the bot at the head of a straight three-square run, dig a pit
// in the middle square, and wall the far one with stone: the route in
// from here is no route. With the stone gone, the leap is a road again.
const delta = { N: { x: 0, y: -1 }, E: { x: 1, y: 0 }, S: { x: 0, y: 1 }, W: { x: -1, y: 0 } } as const;
for (const k of Object.keys(view0.board.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const dir of ["N", "E", "S", "W"] as const) {
const pit = { x: x + delta[dir].x, y: y + delta[dir].y };
const beyond = { x: pit.x + delta[dir].x, y: pit.y + delta[dir].y };
if (!view0.board.cells[cellKey(pit)] || !view0.board.cells[cellKey(beyond)]) continue;
if ((view0.board.edges[edgeKey({ x, y }, dir)] ?? "open") !== "open") continue;
if ((view0.board.edges[edgeKey(pit, dir)] ?? "open") !== "open") continue;
if (state.squareContents[k] || state.squareContents[cellKey(pit)] || state.squareContents[cellKey(beyond)]) continue;
bot.position = { x, y };
state.squareContents[cellKey(pit)] = { kind: "pit", damage: 0, createdBy: "human" };
state.squareContents[cellKey(beyond)] = { kind: "stone", damage: 0, createdBy: "human" };
const blocked = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true });
expect(blocked === null || blocked.dir !== dir).toBe(true);
delete state.squareContents[cellKey(beyond)];
const open = pathToward(viewFor(state, "bot"), bot.position, new Set([cellKey(pit)]), { throughHazards: true });
expect(open?.dir).toBe(dir);
return;
}
}
throw new Error("no straight three-square run on this board");
});
});
+42
View File
@@ -117,6 +117,48 @@ describe("attack spells", () => {
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
it("reflection's returning half is the caster's to counteract (rev 18)", () => {
const setup = (deckRev: number) => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev });
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "reflection#T", cardId: "reflection" };
const a = state.players.find((p) => p.id === attacker)!;
a.hand.push({ instanceId: "absorb#T", cardId: "absorb" });
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "reflection#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
return { state, attacker, defender };
};
const life = (s: GameState, id: string) => s.players.find((p) => p.id === id)!.life;
// Rev 18: the defender takes their half; the half coming back waits on the caster.
{
const { state, attacker, defender } = setup(18);
expect(life(state, defender)).toBe(12);
expect(life(state, attacker)).toBe(15);
expect(state.stack?.waitingOn).toBe(attacker);
expect(state.stack?.reflectedBase?.damage).toBe(3);
// A counter hands the word to the reflector, who may answer; both pass to resolve.
const soaked = must(state, attacker, { type: "counteract", instanceId: "absorb#T" });
const done = must(must(soaked, defender, { type: "pass" }), attacker, { type: "pass" });
expect(life(done, attacker)).toBe(15);
expect(done.stack).toBeNull();
const taken = must(state, attacker, { type: "pass" });
expect(life(taken, attacker)).toBe(12);
expect(taken.stack).toBeNull();
}
// Rev 17: the half landed at once.
{
const { state, attacker, defender } = setup(17);
expect(life(state, defender)).toBe(12);
expect(life(state, attacker)).toBe(12);
expect(state.stack).toBeNull();
}
});
it("absorb spell nullifies the attack and takes the card into hand", () => {
let { state } = newGame();
state = toRound2(state);
+95 -6
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
@@ -392,23 +392,112 @@ describe("big man", () => {
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
});
/** From a giant's square: a step into a square whose exits (all sides but
* the way back) number exactly `want` a corner (1, not straight) or a
* fork (2+). Returns the giant's start, the pushee's square, and the exits. */
function pushSite(state: GameState, want: "corner" | "fork") {
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
if (view.homes.some((h) => cellKey(h) === key)) continue;
for (const side of SIDES) {
const t1 = stepTarget(view, { x, y }, side);
if (t1.kind !== "step") continue;
const exits = SIDES.filter((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step");
if (want === "corner" && exits.length === 1 && exits[0] !== side) return { at: { x, y }, side, mid: t1.to, exits };
if (want === "fork" && exits.length >= 2) return { at: { x, y }, side, mid: t1.to, exits };
}
}
throw new Error(`no ${want} on this board`);
}
it("pushes a wizard around a corner when that is the only way (rev 15)", () => {
const rig = bigRig();
let state = rig.state;
const site = pushSite(state, "corner");
const giant = state.players.find((p) => p.id === rig.giant.id)!;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
giant.position = { ...site.at };
victim.position = { ...site.mid };
state = must(state, rig.giant.id, { type: "move", direction: site.side });
const after = state.players.find((p) => p.id !== rig.giant.id)!;
expect(cellKey(after.position)).toBe(cellKey(neighbor(site.mid, site.exits[0]!)));
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.mid));
});
it("at a fork the pushed wizard chooses, and the stride hangs until they do", () => {
const rig = bigRig();
let state = rig.state;
const site = pushSite(state, "fork");
const giant = state.players.find((p) => p.id === rig.giant.id)!;
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
giant.position = { ...site.at };
victim.position = { ...site.mid };
const used = state.turn.movementUsed;
const r = applyCommand(state, rig.giant.id, { type: "move", direction: site.side });
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.pushPending?.pusheeId).toBe(victim.id);
expect(state.turn.movementUsed).toBe(used);
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.at));
// Only the pushed wizard may answer, and only with pushChoice, and only an open way.
expect(applyCommand(state, rig.giant.id, { type: "move", direction: site.side }).ok).toBe(false);
expect(applyCommand(state, victim.id, { type: "pass" }).ok).toBe(false);
expect(applyCommand(state, victim.id, { type: "pushChoice", direction: opposite(site.side) }).ok).toBe(false);
const pick = site.exits[site.exits.length - 1]!;
state = must(state, victim.id, { type: "pushChoice", direction: pick });
expect(state.pushPending).toBeNull();
expect(cellKey(state.players.find((p) => p.id !== rig.giant.id)!.position)).toBe(cellKey(neighbor(site.mid, pick)));
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(site.mid));
expect(state.turn.movementUsed).toBe(used + 1);
});
it("an older game shoves straight ahead when it can, and now rounds a corner when it cannot", () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
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: {} });
const victim = state.players.find((p) => p.id !== giant.id)!;
// A fork where straight ahead is open: no window, straight it is.
const fork = pushSite(state, "fork");
if (fork.exits.includes(fork.side)) {
giant.position = { ...fork.at };
victim.position = { ...fork.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: fork.side });
if (!r.ok) throw new Error(r.error);
expect(r.state.pushPending).toBeNull();
expect(cellKey(r.state.players.find((p) => p.id !== giant.id)!.position)).toBe(cellKey(neighbor(fork.mid, fork.side)));
}
// A corner: refused before, rounded now.
const corner = pushSite(state, "corner");
giant.position = { ...corner.at };
victim.position = { ...corner.mid };
const r = applyCommand(state, giant.id, { type: "move", direction: corner.side });
if (!r.ok) throw new Error(r.error);
expect(cellKey(r.state.players.find((p) => p.id !== giant.id)!.position)).toBe(cellKey(neighbor(corner.mid, corner.exits[0]!)));
});
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.
// A dead-end pocket beside some square: a victim there cannot be pushed anywhere.
const giant = state.players.find((p) => p.id === rig.giant.id)!;
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const t1 = stepTarget(view, rig.giant.position, side);
const t1 = stepTarget(view, { x, y }, side);
if (t1.kind !== "step") continue;
const t2 = stepTarget(view, t1.to, side);
if (t2.kind === "step") continue;
if (SIDES.some((d) => d !== opposite(side) && stepTarget(view, t1.to, d).kind === "step")) continue;
giant.position = { x, y };
victim.position = { ...t1.to };
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
expect(r.ok).toBe(false);
return;
}
throw new Error("setup: seed offered no walled pocket beside the home");
}
throw new Error("setup: no dead-end pocket on this board");
});
it("a shrunk wizard slips between the giant's boots", () => {
+33 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn } from "../src/game";
import { cellKey, edgeKey, stepTarget } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, walkingDistance, type GameState } from "../src/game";
import { cellKey, edgeKey, stepTarget, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, drain, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -812,3 +812,34 @@ describe("the goat charges on legs, not wings (rev 10)", () => {
expect(rams).toBeGreaterThan(0);
});
});
describe("butt-head's charge reaches as far as its legs (K4T3)", () => {
/** Two empty cells seven or eight corridor steps apart on the seed-42 board. */
function longRun(state: GameState): { from: Cell; to: Cell; steps: number } {
const view = boardView(state);
const cells = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; })
.filter((c) => !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c)));
for (const from of cells) for (const to of cells) {
const d = walkingDistance(state, from, to, 12);
if (d === 7 || d === 8) return { from, to, steps: d };
}
throw new Error("no seven-step corridor on this board");
}
for (const [deckRev, ok] of [[16, true], [15, false]] as const) {
it(`a goat with eight legs and a target seven or eight corridor steps off: rev ${deckRev} ${ok ? "charges" : "refuses"}`, () => {
let { state } = createGame({ playerIds: ["goat", "mark"], seed: 42, sets: ["basic", "expansion1"], deckRev });
state = toRound2(state);
const goat = activePlayer(state);
const mark = state.players.find((p) => p.id !== goat.id)!;
const run = longRun(state);
goat.position = { ...run.from };
mark.position = { ...run.to };
state.turn.movementAllowance = 8;
state.turn.movementUsed = 0;
const card = giveCard(state, goat.id, "butt-head");
const r = applyCommand(state, goat.id, { type: "cast", instanceId: card.instanceId, target: { kind: "player", playerId: mark.id } });
expect(r.ok).toBe(ok);
if (!r.ok) expect(r.error).toMatch(/legs, not wings/);
});
}
});
+256 -5
View File
@@ -1,9 +1,43 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, gameLos, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall } from "./helpers";
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell, plainRimWall, toRound2 } from "./helpers";
/** The sides a walker may leave a pit by, having entered it heading `entry`. */
function rimExits(state: GameState, pit: Cell, entry: Side): Side[] {
const view = boardView(state);
return SIDES.filter((d) => {
if (d === opposite(entry)) return false;
const b = neighbor(pit, d);
return !!view.cells[cellKey(b)] && (view.edges[edgeKey(pit, d)] ?? "open") === "open" &&
state.squareContents[cellKey(b)]?.kind !== "stone";
});
}
/** Two pit sites on a board: a fork (several ways off) and a corridor (one
* way off, straight ahead), each with the square a walker enters from. */
function pitSpots(state: GameState) {
const view = boardView(state);
let fork: { from: Cell; pit: Cell; side: Side; exits: Side[] } | null = null;
let corridor: { from: Cell; pit: Cell; side: Side; beyond: Cell } | null = null;
for (const k of Object.keys(view.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const t = stepTarget(view, { x, y }, side);
if (t.kind !== "step") continue;
if (state.squareContents[k] || state.squareContents[cellKey(t.to)]) continue;
if (view.homes.some((h) => cellKey(h) === k || cellKey(h) === cellKey(t.to))) continue;
const exits = rimExits(state, t.to, side);
if (!fork && exits.length >= 2) fork = { from: { x, y }, pit: t.to, side, exits };
if (!corridor && exits.length === 1 && exits[0] === side) corridor = { from: { x, y }, pit: t.to, side, beyond: neighbor(t.to, side) };
if (fork && corridor) return { fork, corridor };
}
}
throw new Error("board lacks a fork or a corridor pit site");
}
describe("expansion terrain", () => {
it("killer ooze burns on entry and can drop you on your face", () => {
@@ -37,14 +71,62 @@ describe("expansion terrain", () => {
state = must(state, me.id, {
type: "cast", instanceId: pit.instanceId, target: { kind: "cell", cell: spot.cell },
});
state = must(state, me.id, { type: "move", direction: spot.side });
// Rev 14: the rim's exits are the walker's to name; take the first.
const exits = rimExits(state, spot.cell, spot.side);
if (exits.length === 0) continue;
state = must(state, me.id, { type: "move", direction: spot.side, exit: exits[0] });
const p = state.players.find((p) => p.id === me.id)!;
if (p.inPit) { falls++; expect(p.life).toBe(13); }
else if (cellKey(p.position) === cellKey(me.position)) teeters++;
else jumps++;
}
expect(falls + jumps + teeters).toBe(12);
expect(teeters).toBe(0);
expect(falls).toBeGreaterThan(0);
expect(jumps).toBeGreaterThan(0);
});
it("the rim leads off a pit: one way is taken, several must be named, none cannot be entered", () => {
const found = pitSpots(newGame(42).state);
// Several ways off: a bare step is refused with the choices; a named exit lands there or falls in.
{
let { state } = newGame(42);
const me = activePlayer(state);
me.position = { ...found.fork.from };
state.squareContents[cellKey(found.fork.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
const bare = applyCommand(state, me.id, { type: "move", direction: found.fork.side });
expect(bare.ok).toBe(false);
if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/);
const chosen = found.fork.exits[1]!;
const named = applyCommand(state, me.id, { type: "move", direction: found.fork.side, exit: chosen });
expect(named.ok).toBe(true);
if (named.ok) {
const p = named.state.players.find((p) => p.id === me.id)!;
const landing = neighbor(found.fork.pit, chosen);
expect(p.inPit || cellKey(p.position) === cellKey(landing)).toBe(true);
}
}
// No way off: refused before any die is rolled (rev 14); bounced and charged in an older game.
for (const deckRev of [14, 13]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
me.position = { ...found.corridor.from };
state.squareContents[cellKey(found.corridor.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
state.squareContents[cellKey(found.corridor.beyond)] = { kind: "stone", damage: 0, createdBy: me.id };
const r = applyCommand(state, me.id, { type: "move", direction: found.corridor.side });
if (deckRev >= 14) {
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/cannot be crossed/);
} else {
expect(r.ok).toBe(true);
if (r.ok) {
const p = r.state.players.find((p) => p.id === me.id)!;
if (!p.inPit) {
expect(cellKey(p.position)).toBe(cellKey(found.corridor.from));
expect(r.events.some((e) => e.type === "moveBumped")).toBe(true);
}
}
}
}
});
it("dust cloud blinds anyone standing inside it", () => {
@@ -63,6 +145,29 @@ describe("expansion terrain", () => {
}
});
it("a wizard in a dust cloud casts no LOS spell out, and none reaches them (rev 19)", () => {
for (const deckRev of [19, 18]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
state.squareContents[cellKey(spot.cell)] = { kind: "dust", damage: 0, createdBy: me.id };
me.position = { ...spot.cell };
// An empty square beside the cloud, in plain sight but for the dust.
const outside = emptyNeighborCell(state, spot.cell).cell;
const blind = deckRev >= 19;
expect(gameLos(state, me.position, outside, me.id)).toBe(!blind);
expect(gameLos(state, outside, me.position)).toBe(!blind);
expect(gameLos(state, me.position, me.position, me.id)).toBe(true);
const sighted = sightedCellsFor(viewFor(state, me.id));
expect(sighted.has(cellKey(outside))).toBe(!blind);
expect(sighted.has(cellKey(me.position))).toBe(true);
// FILL SQUARE WITH STONE from inside the cloud: refused when blind.
const stone = giveCard(state, me.id, "fill-square-with-stone");
const r = applyCommand(state, me.id, { type: "cast", instanceId: stone.instanceId, target: { kind: "cell", cell: outside } });
expect(r.ok).toBe(!blind);
}
});
it("glue pins a treasure to the floor until it wears off", () => {
let { state } = newGame();
const me = activePlayer(state);
@@ -455,3 +560,149 @@ describe("the boobytrap keeps its secret", () => {
}
});
});
describe("a pit on the board's rim (M4Q7)", () => {
/** A square on the outer rim with a warp mouth on one side, entered from
* the square opposite the mouth; its other neighbouring squares listed. */
function rimPitSite(state: GameState) {
const view = boardView(state);
for (const k of Object.keys(view.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
const pit = { x, y };
if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue;
for (const mouth of SIDES) {
if (stepTarget(view, pit, mouth).kind !== "warp") continue;
const entry = stepTarget(view, pit, opposite(mouth));
if (entry.kind !== "step" || state.squareContents[cellKey(entry.to)]) continue;
const floor = SIDES.filter((d) => d !== mouth && d !== opposite(mouth) && stepTarget(view, pit, d).kind === "step");
if (floor.length === 0) continue;
return { pit, mouth, from: entry.to, floor, far: stepTarget(view, pit, mouth) as { kind: "warp"; to: Cell } };
}
}
throw new Error("no rim pit site on this board");
}
const stoneUp = (state: GameState, pit: Cell, sides: Side[], by: string) => {
for (const d of sides) state.squareContents[cellKey(neighbor(pit, d))] = { kind: "stone", damage: 0, createdBy: by };
};
for (const deckRev of [17, 16]) {
it(`walls either side, the warp mouth is the way off (rev ${deckRev})`, () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const site = rimPitSite(state);
const me = activePlayer(state);
me.position = { ...site.from };
state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
stoneUp(state, site.pit, site.floor, me.id);
const r = applyCommand(state, me.id, { type: "move", direction: site.mouth });
expect(r.ok).toBe(true);
if (!r.ok) return;
const p = r.state.players.find((p) => p.id === me.id)!;
if (p.inPit) return;
expect(cellKey(p.position)).toBe(cellKey(site.far.to));
expect(r.events.some((e) => e.type === "jumpedPit" && e.via === "warp")).toBe(true);
});
}
it("a square beside the mouth: rev 17 asks which, rev 16 steps to the square but may name the mouth", () => {
for (const deckRev of [17, 16]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const site = rimPitSite(state);
const me = activePlayer(state);
me.position = { ...site.from };
state.squareContents[cellKey(site.pit)] = { kind: "pit", damage: 0, createdBy: me.id };
stoneUp(state, site.pit, site.floor.slice(1), me.id);
const bare = applyCommand(state, me.id, { type: "move", direction: site.mouth });
if (deckRev >= 17) {
expect(bare.ok).toBe(false);
if (!bare.ok) expect(bare.error).toMatch(/click the square to land on/);
const named = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth });
expect(named.ok).toBe(true);
if (named.ok) {
const p = named.state.players.find((p) => p.id === me.id)!;
expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true);
}
} else {
expect(bare.ok).toBe(true);
if (bare.ok) {
const p = bare.state.players.find((p) => p.id === me.id)!;
expect(p.inPit || cellKey(p.position) === cellKey(neighbor(site.pit, site.floor[0]!))).toBe(true);
}
// Named, the mouth is taken in any revision.
const mouth = applyCommand(state, me.id, { type: "move", direction: site.mouth, exit: site.mouth });
expect(mouth.ok).toBe(true);
if (mouth.ok) {
const p = mouth.state.players.find((p) => p.id === me.id)!;
expect(p.inPit || cellKey(p.position) === cellKey(site.far.to)).toBe(true);
}
}
}
});
});
describe("a wave names its victims before it pushes (LK97)", () => {
/** A wall between two squares, a wizard on its near side with one open
* square behind them and a wall beyond it, and a caster's square beside. */
function site(state: GameState) {
const view = boardView(state);
for (const k of Object.keys(view.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
const a = { x, y };
if (state.squareContents[k] || view.homes.some((h) => cellKey(h) === k)) continue;
if ((view.edges[edgeKey(a, "S")] ?? "open") !== "wall" || !view.cells[cellKey(neighbor(a, "S"))]) continue;
const back = stepTarget(view, a, "N");
if (back.kind !== "step" || state.squareContents[cellKey(back.to)] || view.homes.some((h) => cellKey(h) === cellKey(back.to))) continue;
if (stepTarget(view, back.to, "N").kind !== "blocked") continue;
const beside = stepTarget(view, a, "E");
if (beside.kind !== "step" || state.squareContents[cellKey(beside.to)]) continue;
return { a, back: back.to, beside: beside.to };
}
throw new Error("no such wall on this board");
}
for (const [deckRev, crush] of [[20, 1], [19, 2]] as const) {
it(`washed one square into a wall: rev ${deckRev} costs ${crush}`, () => {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const caster = activePlayer(state);
const victim = state.players.find((p) => p.id !== caster.id)!;
const s = site(state);
caster.position = { ...s.beside };
victim.position = { ...s.a };
const card = giveCard(state, caster.id, "stone-to-water");
const r = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId, target: { kind: "edge", cell: s.a, side: "S" } });
expect(r.ok).toBe(true);
if (!r.ok) return;
const v = r.state.players.find((p) => p.id === victim.id)!;
expect(cellKey(v.position)).toBe(cellKey(s.back));
expect(v.life).toBe(15 - crush);
expect(r.events.filter((e) => e.type === "washedBack" && e.player === victim.id).length).toBe(crush === 1 ? 1 : 2);
});
}
});
describe("spells cast at a slime wait in the gel", () => {
it("a lightning blast lodges in the slime and goes off on the next wizard in, once", () => {
let { state } = newGame(42);
state = toRound2(state);
const caster = activePlayer(state);
const victim = state.players.find((p) => p.id !== caster.id)!;
const spot = emptyNeighborCell(state, caster.position);
state.squareContents[cellKey(spot.cell)] = { kind: "slime", damage: 0, createdBy: caster.id };
const bolt = giveCard(state, caster.id, "lightning-blast");
const cast = applyCommand(state, caster.id, { type: "cast", instanceId: bolt.instanceId, target: { kind: "cell", cell: spot.cell } });
expect(cast.ok).toBe(true);
if (!cast.ok) return;
expect(cast.events.some((e) => e.type === "spellTrapped")).toBe(true);
expect(cast.state.slimeTraps[cellKey(spot.cell)]?.length).toBe(1);
// Nobody is hurt yet; the victim walks in on their own turn.
state = must(cast.state, caster.id, { type: "endTurn", draw: 2 });
victim.position = { ...caster.position };
const v = state.players.find((p) => p.id === victim.id)!;
v.position = { ...caster.position };
const walk = applyCommand(state, victim.id, { type: "move", direction: spot.side });
expect(walk.ok).toBe(true);
if (!walk.ok) return;
expect(walk.events.some((e) => e.type === "slimeTrapSprung")).toBe(true);
expect(walk.state.stack?.defenderId).toBe(victim.id);
expect(walk.state.stack?.trapped).toBe(true);
const hit = must(walk.state, victim.id, { type: "pass" });
expect(hit.players.find((p) => p.id === victim.id)!.life).toBeLessThan(15);
expect(hit.slimeTraps[cellKey(spot.cell)]).toBeUndefined();
});
});
+73 -20
View File
@@ -9,26 +9,53 @@ const esc = (s: string) =>
const PAGE_CSS = `
* { box-sizing: border-box; }
body { margin: 0; background: #171a21; color: #d6d2c4;
font-family: Georgia, "Times New Roman", serif; }
a { color: #e8c87a; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1.25rem 3rem; }
h1 { font-size: 1.6rem; letter-spacing: 0.04em; margin: 0 0 0.3rem; }
body { margin: 0; background: #171a20; color: #d8d2c0; font-family: "Archivo Narrow", sans-serif; }
a { color: #e0b34a; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 1.4rem 1.25rem 3rem; }
.mast { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 1.6rem;
border-bottom: 1px solid rgba(233, 225, 203, 0.18); padding-bottom: 0.7rem; }
.mast a { font-family: "Oswald", sans-serif; text-transform: uppercase; letter-spacing: 0.18em;
font-size: 0.95rem; color: #e9e1cb; text-decoration: none; }
.mast .crumb { color: #8d8672; font-size: 0.85rem; letter-spacing: 0.06em; }
.mast .crumb a { font: inherit; letter-spacing: inherit; text-transform: none; color: inherit; }
.mast .deal { margin-left: auto; font-size: 0.8rem; letter-spacing: 0.12em; color: #e0b34a; }
h1 { font-family: "Oswald", sans-serif; font-weight: 500; font-size: 1.7rem; letter-spacing: 0.06em;
text-transform: uppercase; margin: 0 0 0.3rem; color: #e9e1cb; }
h1 a { color: inherit; text-decoration: none; }
p.sub { color: #8b8778; margin: 0 0 1.6rem; }
p.sub { color: #a49c86; margin: 0 0 1.6rem; font-size: 1.05rem; max-width: 46rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.4rem; }
.card { background: #1f232d; border: 1px solid #2c3140; border-radius: 8px;
.card { background: #1f232d; border: 1px solid rgba(233, 225, 203, 0.18); border-radius: 8px;
overflow: hidden; text-decoration: none; color: inherit; display: block; }
.card:hover { border-color: #e0b34a; }
.card img { width: 100%; display: block; aspect-ratio: 736 / 577; object-fit: cover; }
.card .meta { padding: 0.7rem 0.9rem 0.9rem; }
.card .t { color: #e8c87a; letter-spacing: 0.03em; }
.card .b { font-size: 0.9rem; line-height: 1.45; margin-top: 0.3rem; color: #b5b0a1; }
.card .s { float: right; color: #8b8778; font-size: 0.85rem; }
.card .t { font-family: "Oswald", sans-serif; color: #e0b34a; letter-spacing: 0.06em; text-transform: uppercase; font-size: 0.95rem; }
.card .b { font-size: 0.95rem; line-height: 1.4; margin-top: 0.3rem; color: #b5b0a1; }
.card .s { float: right; color: #8d8672; font-size: 0.85rem; font-family: "Courier Prime", monospace; }
video { width: 100%; border-radius: 6px; display: block; background: #000; }
.takes { display: grid; gap: 1.5rem; margin-top: 1.4rem; }
.take h2 { font-size: 1.05rem; color: #e8c87a; font-weight: normal;
letter-spacing: 0.04em; margin: 0 0 0.5rem; }
.foot { margin-top: 2.2rem; color: #8b8778; font-size: 0.95rem; }
.take h2 { font-family: "Oswald", sans-serif; font-size: 0.9rem; color: #e0b34a; font-weight: 500;
letter-spacing: 0.14em; text-transform: uppercase; margin: 0 0 0.5rem; }
.foot { margin-top: 2.2rem; color: #8d8672; font-size: 0.95rem; }
.share { font: inherit; font-size: 0.85rem; letter-spacing: 0.12em; text-transform: uppercase; cursor: pointer;
font-family: "Oswald", sans-serif; color: #43331f; background: #e9e1cb; border: 1.5px solid #43331f;
border-radius: 3px; padding: 0.4rem 1rem; margin: -0.6rem 0 0.4rem; }
.share:hover { background: #f2d27a; }
.share.done { background: #d8d2c0; }
`;
// The share button: the phone's own share sheet where there is one,
// the clipboard everywhere else. The page URL is the canonical link.
const SHARE_JS = `
var b = document.querySelector(".share");
b.addEventListener("click", function () {
var data = { title: b.dataset.title, text: b.dataset.text, url: location.origin + location.pathname };
if (navigator.share) { navigator.share(data).catch(function () {}); return; }
navigator.clipboard.writeText(data.url).then(function () {
b.textContent = "Link copied"; b.classList.add("done");
setTimeout(function () { b.textContent = "Share this clip"; b.classList.remove("done"); }, 1800);
});
});
`;
const shell = (metas: string[], body: string) => `<!doctype html>
@@ -36,12 +63,31 @@ const shell = (metas: string[], body: string) => `<!doctype html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;600&family=Archivo+Narrow:wght@400;600&family=Courier+Prime&display=swap" rel="stylesheet">
${metas.join("\n")}
<style>${PAGE_CSS}</style>
</head>
<body><div class="wrap">${body}</div></body>
</html>`;
/** The 1200x630 share card cut by deploy/clips-prep.mjs, with the
* dimensions that let Facebook show it on the very first share. Falls
* back to the poster on a set that was never prepped. */
function cardImageMetas(clip: ClipMeta, base: string): string[] {
const prepped = clip.width !== undefined;
const image = `${base}/clips/${clip.name}${prepped ? "-card" : ""}.jpg`;
return [
`<meta property="og:image" content="${image}"/>`,
...(prepped ? [
`<meta property="og:image:width" content="1200"/>`,
`<meta property="og:image:height" content="630"/>`,
] : []),
`<meta name="twitter:image" content="${image}"/>`,
];
}
export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
const metas = [
`<title>Wiz-War — clips from the labyrinth</title>`,
@@ -50,9 +96,9 @@ export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
`<meta property="og:title" content="Wiz-War — clips from the labyrinth"/>`,
`<meta property="og:description" content="Short scenes of magical combat in a stone maze: wormhole heists, warp snipers, ambushes, and the walls coming down — each seen through the wizard's own eyes and from the board above."/>`,
`<meta property="og:url" content="${base}/clips"/>`,
clips[0] ? `<meta property="og:image" content="${base}/clips/${clips[0].name}.jpg"/>` : "",
...(clips[0] ? cardImageMetas(clips[0], base) : []),
`<meta name="twitter:card" content="summary_large_image"/>`,
].filter(Boolean);
];
const cards = clips.map((c) => `
<a class="card" href="/clips/${c.name}">
<img src="/clips/${c.name}.jpg" alt="${esc(c.title)}" loading="lazy">
@@ -63,6 +109,7 @@ export function clipsIndexHtml(clips: ClipMeta[], base: string): string {
</div>
</a>`).join("\n");
const body = `
<nav class="mast"><a href="/">Wiz-War</a><span class="crumb">clips</span><a class="deal" href="/">deal yourself in </a></nav>
<h1>Clips from the labyrinth</h1>
<p class="sub">Scenes of Wiz-War, played by the rules and filmed through the wizards' own eyes.
Each clip has a first-person take and a board take of the same moves.</p>
@@ -80,16 +127,21 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
`<meta property="og:title" content="${esc(clip.title)}"/>`,
`<meta property="og:description" content="${esc(clip.blurb)}"/>`,
`<meta property="og:url" content="${base}/clips/${clip.name}"/>`,
`<meta property="og:image" content="${base}/clips/${clip.name}.jpg"/>`,
...cardImageMetas(clip, base),
`<meta property="og:video" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
`<meta property="og:video:secure_url" content="${base}/clips/${clip.name}-fpv.mp4"/>`,
`<meta property="og:video:type" content="video/mp4"/>`,
...(clip.width && clip.height ? [
`<meta property="og:video:width" content="${clip.width}"/>`,
`<meta property="og:video:height" content="${clip.height}"/>`,
] : []),
`<meta name="twitter:card" content="summary_large_image"/>`,
`<meta name="twitter:image" content="${base}/clips/${clip.name}.jpg"/>`,
];
const body = `
<h1><a href="/clips">Clips from the labyrinth</a></h1>
<p class="sub">${esc(clip.title)} ${esc(clip.blurb)}</p>
<nav class="mast"><a href="/">Wiz-War</a><span class="crumb"><a href="/clips">clips</a> / ${esc(clip.title)}</span><a class="deal" href="/">deal yourself in </a></nav>
<h1><a href="/clips">${esc(clip.title)}</a></h1>
<p class="sub">${esc(clip.blurb)}</p>
<button class="share" type="button" data-title="${esc(clip.title)}" data-text="${esc(clip.blurb)}">Share this clip</button>
<div class="takes">
<div class="take">
<h2>Through the wizard's eyes</h2>
@@ -101,6 +153,7 @@ export function clipPageHtml(clip: ClipMeta, base: string): string {
</div>
</div>
<p class="foot">Played by the real rules engine every move on screen is a legal move.
<a href="/">Deal yourself in</a>.</p>`;
<a href="/">Deal yourself in</a>.</p>
<script>${SHARE_JS}</script>`;
return shell(metas, body);
}
+41 -13
View File
@@ -31,7 +31,7 @@
// {type:"error", message}
import * as Sentry from "@sentry/node";
import { createServer } from "node:http";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomBytes } from "node:crypto";
import { readFileSync, existsSync, realpathSync, statSync, createReadStream } from "node:fs";
import { extname, join, normalize, sep } from "node:path";
@@ -65,8 +65,9 @@ import {
abandonRoom,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { appendFeedback, readFeedback, readClips, clipAssetPath } from "./store";
import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG } from "./store";
import { clipsIndexHtml, clipPageHtml } from "./clips";
import { SlidingLimit, clientAddress } from "./ratelimit";
import { getShare, loadShares, mintShare } from "./shares";
import { renderSharePng } from "./ogimage";
import { BOT_LINES, type BanterTrigger } from "./banter";
@@ -81,6 +82,10 @@ if (process.env.SENTRY_DSN) {
const MAX_SOCKETS = 300; // concurrent connections
const MAX_ROOMS = 5000; // total rooms on the server
const MAX_ROOMS_PER_CONN = 10; // rooms one connection may create
// Per-address limits, held across reconnects: a table of friends never
// nears them; a script filling the vault or the reports desk does.
const roomsPerAddress = new SlidingLimit(12, 60 * 60 * 1000);
const reportsPerAddress = new SlidingLimit(6, 60 * 60 * 1000);
const MAX_COMMAND_BYTES = 16384; // serialized game command
const MAX_MYGAMES_SEATS = 50; // seats checked per myGames request
const CATCHUP_COOLDOWN_MS = 3000; // full-game replays are CPU-heavy
@@ -179,6 +184,15 @@ function ogPage(metas: string[]): string {
/** Host and proto arrive from request headers attacker-writable text
* that must never reach an HTML attribute raw. */
const noSuchClip = (res: ServerResponse) =>
res.writeHead(404, { "content-type": "text/plain" }).end("no such clip — see /clips");
/** The absolute origin a request came in on, as the proxy saw it. */
function requestBase(req: IncomingMessage): string {
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
return safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
}
function safeBase(rawHost: string, rawProto: string): string {
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
return `${proto}://${escapeHtml(rawHost)}`;
@@ -307,16 +321,15 @@ const httpServer = createServer((req, res) => {
// with Range support — Safari refuses an mp4 whose server can't
// serve bytes 0-1 on demand.
if (url === "/clips" || url === "/clips/") {
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
const base = requestBase(req);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(clipsIndexHtml(readClips(), base));
return;
}
const clipAsset = url.match(/^\/clips\/([a-z0-9-]{1,70}\.(?:mp4|jpg))$/);
const clipAsset = url.match(/^\/clips\/([^/]+\.(?:mp4|jpg))$/);
if (clipAsset) {
const path = clipAssetPath(clipAsset[1]!);
if (!path) { res.writeHead(404).end("no such clip"); return; }
if (!path) { noSuchClip(res); return; }
const size = statSync(path).size;
const type = path.endsWith(".mp4") ? "video/mp4" : "image/jpeg";
const range = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range ?? ""));
@@ -344,17 +357,16 @@ const httpServer = createServer((req, res) => {
createReadStream(path).pipe(res);
return;
}
const clipPage = url.match(/^\/clips\/([a-z0-9-]{1,60})$/);
if (clipPage) {
const clipPage = url.match(/^\/clips\/([^/]+)$/);
if (clipPage && CLIP_SLUG.test(clipPage[1]!)) {
const clip = readClips().find((c) => c.name === clipPage[1]);
if (clip) {
const proto = String(req.headers["x-forwarded-proto"] ?? "http").split(",")[0]!.trim();
const base = safeBase(String(req.headers.host ?? `localhost:${port}`), proto);
const base = requestBase(req);
res.writeHead(200, { "content-type": "text/html", "cache-control": "no-cache" });
res.end(clipPageHtml(clip, base));
return;
}
res.writeHead(404, { "content-type": "text/html" }).end("no such clip — see /clips");
noSuchClip(res);
return;
}
// Room invitations: a living room gets its recruiting card; a dead
@@ -375,7 +387,14 @@ const httpServer = createServer((req, res) => {
res.writeHead(403).end();
return;
}
if (!existsSync(file)) file = join(staticRoot, "index.html"); // SPA fallback
if (!existsSync(file)) {
// A hashed asset that is gone is gone: a page built before the last
// deploy asking for its old chunk gets a plain 404 it can act on,
// never the app shell as text/html. Every other unknown path is the
// app's to route.
if (url.startsWith("/assets/")) { res.writeHead(404, { "content-type": "text/plain" }).end("gone"); return; }
file = join(staticRoot, "index.html");
}
// Resolve symlinks and re-verify the real location stays inside the root.
const real = realpathSync(file);
if (real !== staticRoot && !real.startsWith(staticRoot + sep)) {
@@ -409,6 +428,8 @@ interface Session {
spectator: boolean;
/** The raw seat token this connection authenticated with (memory only). */
token: string | null;
/** Where the connection came from, for the per-address limits. */
address: string;
claimFails: number;
// Token bucket: refills at 15 msg/s up to a burst of 30.
bucket: number;
@@ -579,7 +600,7 @@ function broadcastRoomState(room: Room): void {
}
}
wss.on("connection", (socket) => {
wss.on("connection", (socket, req) => {
if (sessions.size >= MAX_SOCKETS) {
send(socket, { type: "error", message: "the tavern is packed — try again shortly" });
socket.close();
@@ -587,6 +608,7 @@ wss.on("connection", (socket) => {
}
const session: Session = {
socket, playerId: null, roomId: null, spectator: false, token: null, claimFails: 0,
address: clientAddress(req.headers, req.socket.remoteAddress),
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
};
@@ -626,6 +648,9 @@ wss.on("connection", (socket) => {
if (session.roomsCreated >= MAX_ROOMS_PER_CONN || roomCount() >= MAX_ROOMS) {
return send(socket, { type: "error", message: "no new rooms right now — try again later" });
}
if (!roomsPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "that's a lot of new tables from one place — try again in an hour" });
}
session.roomsCreated++;
leaveGallery(session);
const { room, token } = createRoom(name);
@@ -868,6 +893,9 @@ wss.on("connection", (socket) => {
String(raw ?? "").replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").trim().slice(0, 2000);
const happened = clean(msg.happened);
if (!happened) return send(socket, { type: "error", message: "say what happened" });
if (!reportsPerAddress.allow(session.address)) {
return send(socket, { type: "error", message: "the desk has plenty from you for now — more in an hour" });
}
appendFeedback({
id: randomBytes(4).toString("hex"),
at: new Date().toISOString(),
+47
View File
@@ -0,0 +1,47 @@
// A per-key sliding-window limiter for the two doors anyone may walk
// through unseated: creating rooms and filing reports. Per-connection
// caps reset on reconnect; these are keyed by client address and hold
// for the window. Memory is bounded by pruning keys whose windows have
// emptied.
export class SlidingLimit {
private hits = new Map<string, number[]>();
private lastSweep = 0;
constructor(private readonly max: number, private readonly windowMs: number) {}
/** Record a hit for `key` if it is under the limit; false if it is not. */
allow(key: string, now = Date.now()): boolean {
this.sweep(now);
const cutoff = now - this.windowMs;
const times = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
if (times.length >= this.max) { this.hits.set(key, times); return false; }
times.push(now);
this.hits.set(key, times);
return true;
}
private sweep(now: number): void {
if (now - this.lastSweep < this.windowMs) return;
this.lastSweep = now;
const cutoff = now - this.windowMs;
for (const [k, times] of this.hits) {
if (!times.some((t) => t > cutoff)) this.hits.delete(k);
}
}
}
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
/** The client's address as Caddy reports it. The proxy APPENDS the true
* peer to X-Forwarded-For, so the last entry is the trustworthy one; a
* client can write anything into the first. And the header is believed
* only when the socket peer is the proxy itself (loopback) reached
* any other way, the peer address is the client. */
export function clientAddress(headers: Record<string, string | string[] | undefined>, remote: string | undefined): string {
const peer = remote || "unknown";
if (!LOOPBACK.has(peer)) return peer;
const fwd = headers["x-forwarded-for"];
const parts = (Array.isArray(fwd) ? fwd.join(",") : fwd ?? "").split(",").map((s) => s.trim()).filter(Boolean);
return parts[parts.length - 1] || peer;
}
+32 -6
View File
@@ -22,7 +22,7 @@ import {
type PlayerId,
CURRENT_RULES_REV,
} from "@wizwar/engine";
import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, readAllRooms, readRoom, roomFileExists, type RoomLine } from "./store";
import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, ledgerStat, listRoomIds, readRoom, readStubFile, roomFileExists, writeStubFile, type RoomLine } from "./store";
import { recordRoom } from "./stats";
export interface LoggedCommand {
@@ -159,9 +159,16 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number
const idle = now - (room.touchedAt ?? now);
const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS;
if (idle < allowance) continue;
sleepingStubs.set(id, stubOf(room));
const stub = stubOf(room);
sleepingStubs.set(id, stub);
rooms.delete(id);
evicted++;
try {
const stat = ledgerStat(id);
if (stat) writeStubFile(id, stat.bytes, { ...stub, tokens: Object.fromEntries(stub.tokens) });
} catch (e) {
console.error(`could not write stub for ${id}:`, e);
}
}
return evicted;
}
@@ -176,6 +183,8 @@ interface RoomStub {
base: Omit<GameSummary, "name" | "yourTurn" | "attention">;
}
const sleepingStubs = new Map<string, RoomStub>();
/** A stub as it rests on disk — the token map spelled as an object. */
type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> };
function stubOf(room: Room): RoomStub {
const { active, turnHolder, waitKind } = turnFacts(room.state);
@@ -395,6 +404,7 @@ function actingSeat(room: Room): PlayerId | null {
if (!s || s.phase !== "playing") return null;
return (
s.wardPending?.ownerId ??
s.pushPending?.pusheeId ??
s.slowDeathPending?.playerId ??
s.stack?.waitingOn ??
s.pendingDiscard ??
@@ -466,11 +476,12 @@ function turnFacts(s: GameState | null): {
waitKind: "counteract" | "discard" | "interrupt" | null;
} {
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.wardPending?.ownerId ?? s?.slowDeathPending?.playerId ??
const waitingOn = s?.wardPending?.ownerId ?? s?.pushPending?.pusheeId ?? s?.slowDeathPending?.playerId ??
s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ??
s?.outOfTurnWindow?.playerId ?? null;
const waitKind = s == null ? null
: s.wardPending != null ? "counteract" as const
: s.pushPending != null ? "counteract" as const
: s.slowDeathPending != null ? "counteract" as const
: s.stack?.waitingOn != null ? "counteract" as const
: s.pendingDiscard != null ? "discard" as const
@@ -773,14 +784,29 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
return room;
}
/** Rebuild every persisted room by replaying its file. */
/** Bring the rooms back after a restart. A room whose stub still matches
* its ledger sleeps on, answering the games ledger from the stub; the
* rest are replayed, with the eviction clock set to their last line so
* the first sweep puts the long-idle ones back to bed. */
export function loadPersistedRooms(): void {
ensureDataDir();
let restored = 0;
for (const [id, lines] of readAllRooms()) {
let asleep = 0;
for (const id of listRoomIds()) {
const stat = ledgerStat(id);
if (!stat) continue;
const stored = readStubFile<StoredStub>(id);
if (stored && stored.bytes === stat.bytes) {
sleepingStubs.set(id, { ...stored.stub, tokens: new Map(Object.entries(stored.stub.tokens)) });
asleep++;
continue;
}
const lines = readRoom(id);
if (!lines) continue;
try {
const room = rebuildRoom(id, lines);
if (!room) continue;
room.touchedAt = stat.mtimeMs;
rooms.set(id, room);
recordRoom(room);
restored++;
@@ -788,5 +814,5 @@ export function loadPersistedRooms(): void {
console.error(`could not restore room ${id}:`, e);
}
}
if (restored > 0) console.log(`restored ${restored} room(s) from disk`);
if (restored + asleep > 0) console.log(`restored ${restored} room(s) from disk, ${asleep} left sleeping`);
}
+56 -21
View File
@@ -3,7 +3,7 @@
// command. Because the engine is deterministic, replaying a file rebuilds
// the exact game state — server restarts lose nothing.
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs";
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export interface RoomMetaLine {
@@ -128,24 +128,51 @@ export function countRoomFiles(): number {
return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).length;
}
/** Read every persisted room's lines, keyed by room id. */
export function readAllRooms(): Map<string, RoomLine[]> {
/** Every room with a ledger on disk. */
export function listRoomIds(): string[] {
ensureDataDir();
const rooms = new Map<string, RoomLine[]>();
for (const file of readdirSync(DATA_DIR)) {
if (!file.endsWith(".jsonl")) continue;
const id = file.slice(0, -".jsonl".length);
return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -".jsonl".length));
}
/** A ledger's size and the moment of its last line. */
export function ledgerStat(roomId: string): { bytes: number; mtimeMs: number } | null {
if (!safeRoomId(roomId)) return null;
const file = fileFor(roomId);
if (!existsSync(file)) return null;
const st = statSync(file);
return { bytes: st.size, mtimeMs: st.mtimeMs };
}
// --- Stubs of sleeping rooms. ---------------------------------------------
// A room put to sleep leaves a stub beside the ledgers: what the games
// ledger needs to answer for it, and the ledger's size at that moment. A
// ledger only ever grows, so at boot a stub whose size still matches is
// the room's whole truth and the room stays asleep, unreplayed.
const stubDir = () => join(DATA_DIR, "..", "stubs");
function stubFor(roomId: string): string {
if (!safeRoomId(roomId)) throw new Error(`unsafe room id: ${JSON.stringify(roomId)}`);
return join(stubDir(), `${roomId}.json`);
}
export function readStubFile<T>(roomId: string): { bytes: number; stub: T } | null {
if (!safeRoomId(roomId)) return null;
const file = stubFor(roomId);
if (!existsSync(file)) return null;
try {
const lines = readFileSync(join(DATA_DIR, file), "utf8")
.split("\n")
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l) as RoomLine);
if (lines.length > 0 && lines[0]!.kind === "meta") rooms.set(id, lines);
const parsed = JSON.parse(readFileSync(file, "utf8")) as { bytes: number; stub: T };
return typeof parsed.bytes === "number" && parsed.stub ? parsed : null;
} catch (e) {
console.error(`skipping unreadable room file ${file}:`, e);
console.error(`unreadable stub file ${roomId}:`, e);
return null;
}
}
return rooms;
}
export function writeStubFile(roomId: string, bytes: number, stub: unknown): void {
const file = stubFor(roomId);
mkdirSync(stubDir(), { recursive: true });
writeFileSync(file + ".tmp", JSON.stringify({ bytes, stub }), "utf8");
renameSync(file + ".tmp", file);
}
/** Player surprise reports and the wizards' replies share one JSONL file
@@ -227,28 +254,36 @@ export interface ClipMeta {
title: string;
blurb: string;
seconds: number;
/** Frame size of the first-person take, stamped by deploy/clips-prep.mjs;
* unfurlers won't embed a player without it. Absent on an unprepped set. */
width?: number;
height?: number;
}
const clipsDir = () => join(DATA_DIR, "..", "clips");
/** A clip's name, which is also its file stem: no separators, no dots,
* so a name can never name a path. */
const SLUG = "[a-z0-9-]{1,60}";
export const CLIP_SLUG = new RegExp(`^${SLUG}$`);
const CLIP_FILE = new RegExp(`^${SLUG}(?:(?:-fpv|-board)\\.mp4|(?:-card)?\\.jpg)$`);
export function readClips(): ClipMeta[] {
const file = join(clipsDir(), "clips.json");
if (!existsSync(file)) return [];
try {
const parsed = JSON.parse(readFileSync(file, "utf8")) as ClipMeta[];
return parsed.filter((c) => /^[a-z0-9-]{1,60}$/.test(c.name));
return parsed.filter((c) => CLIP_SLUG.test(c.name));
} catch {
return [];
}
}
/** Resolve a clip asset request to its path or null for any name that
* is not exactly a published clip file shape. The gate IS the security:
* nothing outside <slug>(-fpv|-board).mp4 / <slug>.jpg can be named. */
* is not exactly a published clip file: <slug>-fpv.mp4, <slug>-board.mp4,
* <slug>.jpg, <slug>-card.jpg. The pattern is the only path guard. */
export function clipAssetPath(file: string): string | null {
if (!/^[a-z0-9-]{1,60}(-fpv|-board)\.mp4$/.test(file) && !/^[a-z0-9-]{1,60}\.jpg$/.test(file)) {
return null;
}
if (!CLIP_FILE.test(file)) return null;
const path = join(clipsDir(), file);
return existsSync(path) ? path : null;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 157 KiB

+9 -17
View File
@@ -1,19 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Alter Ego token">
<defs>
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="31" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Alter Ego</text>
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M72 34q-30 3-35 36 2 35 35 57Z" fill="#23764b"/>
<path d="M72 34q30 3 35 36-2 35-35 57Z" fill="#6c3eae"/>
<path d="M72 39v83" fill="none" stroke="#29251e" stroke-width="4"/>
<path d="M43 65q12-15 25-1-9 13-25 1Zm33-1q14-14 25 1-16 12-25-1Z" fill="#eee5ca"/>
<circle cx="58" cy="64" r="3"/><circle cx="86" cy="64" r="3"/>
<path d="M47 91q12 15 21-1m9 0q10 16 21 0" fill="none"/>
<path d="M34 50l-12-10m88 10l12-10M35 112l-14 9m88-9l14 9" fill="none" stroke="#8e794d" stroke-width="4"/></g>
<title>Alter Ego</title><desc>Alter Ego illustrated in shaded ink on parchment.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#716291" stop-opacity=".2"/><stop offset="1" stop-color="#716291" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M34 113L45 91 51 80 37 67 40 49 54 38Q72 27 89 42L111 61 99 67 99 80 87 86 100 114Z" fill="#66628e" stroke="#302a29" stroke-width="2.0" /><path d="M72 35Q47 25 38 47L34 60 23 66 35 73 36 87 51 92 48 115H73Z" fill="#428c80" stroke="#302a29" stroke-width="2.0" /><path d="M72 35V115" fill="none" stroke="#f8edcf" stroke-width="2" /><path d="M43 58Q51 50 60 58M84 57Q93 50 100 60" fill="none" stroke="#f8edcf" stroke-width="3" /><circle cx="51" cy="57" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><circle cx="92" cy="57" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><path d="M45 77Q53 83 61 77M83 78Q92 83 98 76" fill="none" stroke="#302a29" stroke-width="2" /><path d="M23 36L32 29M110 31L120 40M15 83H24M117 85H128" fill="none" stroke="#d6ad57" stroke-width="3" /></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#716291" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Alter Ego</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+9 -16
View File
@@ -1,18 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Boobytrap token">
<defs>
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="32" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Boobytrap</text>
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M30 91h84v35H30Z" fill="#81562f"/>
<path d="M36 76h72l6 15H30Z" fill="#a77a42"/>
<path d="M40 96h64M53 92v34m38-34v34" fill="none" stroke="#d1a35c" stroke-width="4"/>
<path d="M43 78l-5-27 13 21 10-31 10 31 12-33 8 34 16-22-7 28" fill="#9b9a88"/>
<path d="M42 78l-4-24m23 19V44m22 30l8-31m9 36l8-25" fill="none" stroke="#555a59" stroke-width="5"/>
<circle cx="72" cy="108" r="8" fill="#29251e"/><circle cx="72" cy="108" r="3" fill="#d54835"/></g>
<title>Boobytrap</title><desc>Top view of a wooden spring mousetrap: rectangular steel striker, coiled spring, retaining arm, and cheese bait.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#ab784d" stop-opacity=".2"/><stop offset="1" stop-color="#ab784d" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M35 19H109V120H35Z" fill="#88603f" stroke="#302a29" stroke-width="2.0" /><path d="M31 15H105V115H31Z" fill="#caa16a" stroke="#302a29" stroke-width="2.0" /><path d="M37 23V108M99 24V107M42 39L43 55M94 82L94 104" fill="none" stroke="#ae804f" stroke-width="1.3" /><path d="M42 70V29H94V70" fill="none" stroke="#302a29" stroke-width="7" /><path d="M42 70V29H94V70" fill="none" stroke="#ccd0bf" stroke-width="3.5" /><path d="M39 73H97" fill="none" stroke="#302a29" stroke-width="6" /><path d="M39 73H97" fill="none" stroke="#aab4ad" stroke-width="3" /><ellipse cx="49" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M48 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="55" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M54 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="61" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M60 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="67" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M66 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="73" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M72 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="79" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M78 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><ellipse cx="85" cy="73" rx="3" ry="7" fill="#9ba69f" stroke="#302a29" stroke-width="1.2"/><path d="M84 68V76" fill="none" stroke="#e7e6cd" stroke-width="1.3" /><path d="M57 50H80L84 61H54Z" fill="#967e50" stroke="#302a29" stroke-width="2.0" /><path d="M55 48L70 37 84 45V57L55 60Z" fill="#e6bd58" stroke="#302a29" stroke-width="2.0" /><path d="M55 48L70 37 84 45 68 49Z" fill="#f7d778" stroke="#302a29" stroke-width="2.0" /><circle cx="62" cy="53" r="2.5" fill="#b48b36" stroke="none" stroke-width="2"/><circle cx="77" cy="49" r="2" fill="#b48b36" stroke="none" stroke-width="2"/><circle cx="74" cy="55" r="1.6" fill="#b48b36" stroke="none" stroke-width="2"/><path d="M69 99L70 62 74 58" fill="none" stroke="#302a29" stroke-width="4" /><path d="M69 99L70 62 74 58" fill="none" stroke="#dadcc9" stroke-width="1.8" /><circle cx="69" cy="101" r="3" fill="#727b72" stroke="#302a29" stroke-width="2"/></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#ab784d" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Boobytrap</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

+9 -16
View File
@@ -1,18 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dagger token">
<defs>
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="33" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Dagger</text>
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M46 116l12-25 34-45 10-9-4 14-33 46Z" fill="#b9c0b8"/>
<path d="M58 91l34-45-23 52Z" fill="#ece6d2"/>
<path d="M43 89l27 20" fill="none" stroke="#8c6a35" stroke-width="8"/>
<path d="M46 104l-13 17 8 7 15-18Z" fill="#d54835"/>
<circle cx="37" cy="125" r="5" fill="#d9b554"/>
<path d="M90 48l-7 18" fill="none" stroke="#ffffff" stroke-width="2" opacity=".7"/></g>
<title>Dagger</title><desc>Dagger illustrated in shaded ink on parchment.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#5e9298" stop-opacity=".2"/><stop offset="1" stop-color="#5e9298" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M27 111L49 82 99 24 123 14 118 38 63 91 37 119Z" fill="#aebcb7" stroke="#302a29" stroke-width="2.0" /><path d="M49 82L123 14 63 91Z" fill="#f7f0d7" stroke="#302a29" stroke-width="2.0" /><path d="M53 85L118 22" fill="none" stroke="#718d8b" stroke-width="2" /><path d="M39 78L72 104 76 98 45 71Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M28 96L42 85 58 98 47 112Z" fill="#9a4736" stroke="#302a29" stroke-width="2.0" /><path d="M30 97L46 109M36 92L51 103" fill="none" stroke="#d6ad57" stroke-width="2" /><path d="M23 108L30 101 44 112 36 121Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M106.0,39.0 107.3,43.7 112.0,45.0 107.3,46.3 106.0,51.0 104.7,46.3 100.0,45.0 104.7,43.7Z" fill="#f8edcf" stroke="none" stroke-width="2.0" /></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#5e9298" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Dagger</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,20 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Democratic Monster token">
<defs>
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="34" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="10" letter-spacing=".3" fill="#29251e">Democratic Monster</text>
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M38 129q-2-39 15-57h39q17 18 14 57Z" fill="#a94b3f"/>
<circle cx="51" cy="60" r="15" fill="#c45c48"/><circle cx="73" cy="52" r="17" fill="#a63f3b"/><circle cx="95" cy="60" r="15" fill="#d27852"/>
<path d="M40 50l-7-12 13 5m19-5l8-13 8 13m13 5l15-5-8 13" fill="#b54d3e"/>
<circle cx="47" cy="60" r="2"/><circle cx="56" cy="60" r="2"/><circle cx="68" cy="52" r="2"/><circle cx="78" cy="52" r="2"/><circle cx="91" cy="60" r="2"/><circle cx="100" cy="60" r="2"/>
<path d="M45 68q7 6 13 0m8-7q7 6 14 0m8 7q7 6 14 0" fill="none"/>
<path d="M43 87l-22 14m82-14l20 14" fill="none" stroke="#a94b3f" stroke-width="9"/>
<path d="M18 90l9-5 6 9-6 9Z" fill="#eee5ca"/><path d="M112 85l11 5-2 13-11-5Z" fill="#eee5ca"/>
<path d="M53 85q19 13 39 0" fill="none" stroke="#e1a071" stroke-width="4"/></g>
<title>Democratic Monster</title><desc>Red many-tentacled creature with lobster claws and eyes on stalks.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#c74f42" stop-opacity=".2"/><stop offset="1" stop-color="#c74f42" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M58 88Q20 73 20 101Q20 115 39 108" fill="none" stroke="#302a29" stroke-width="13" /><path d="M58 88Q20 73 20 101Q20 115 39 108" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M68 92Q38 125 19 117" fill="none" stroke="#302a29" stroke-width="13" /><path d="M68 92Q38 125 19 117" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M83 93Q119 75 123 96Q129 113 110 111" fill="none" stroke="#302a29" stroke-width="13" /><path d="M83 93Q119 75 123 96Q129 113 110 111" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M79 97Q86 123 110 121" fill="none" stroke="#302a29" stroke-width="13" /><path d="M79 97Q86 123 110 121" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M72 91Q63 117 48 116" fill="none" stroke="#302a29" stroke-width="13" /><path d="M72 91Q63 117 48 116" fill="none" stroke="#bd493d" stroke-width="8" /><path d="M47 73Q38 50 33 49L23 37 18 54 29 66 44 65M97 71Q110 55 112 49L122 34 129 53 117 66 104 64Z" fill="#cc5844" stroke="#302a29" stroke-width="2.0" /><path d="M27 45L28 59M122 44L118 58" fill="none" stroke="#f49868" stroke-width="2" /><path d="M47 76Q43 51 71 48Q101 48 100 77L91 94 53 95Z" fill="#c74f42" stroke="#302a29" stroke-width="2.0" /><path d="M59 51L53 34M84 51L91 33" fill="none" stroke="#302a29" stroke-width="7" /><circle cx="52" cy="31" r="7" fill="#ed9970" stroke="#302a29" stroke-width="2"/><circle cx="92" cy="30" r="7" fill="#ed9970" stroke="#302a29" stroke-width="2"/><circle cx="53" cy="30" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><circle cx="91" cy="29" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><path d="M53 72Q74 87 94 68L88 87 59 89Z" fill="#302a29" stroke="#302a29" stroke-width="2.0" /><path d="M59 76L63 83 67 78M79 78L82 84 86 76Z" fill="#f8edcf" stroke="#302a29" stroke-width="2.0" /><path d="M55 58Q64 52 75 56" fill="none" stroke="#f49868" stroke-width="3" /><path d="M32 99L39 96M98 106L103 114M62 104L68 108" fill="none" stroke="#302a29" stroke-width="1.1" opacity=".45"/></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#c74f42" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="9" font-weight="700" fill="#39302a">Democratic Monster</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -1,18 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dimensional Warp token">
<defs>
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="35" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="11.5" letter-spacing=".3" fill="#29251e">Dimensional Warp</text>
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="72" cy="79" rx="45" ry="43" fill="#3f3158"/>
<ellipse cx="72" cy="79" rx="35" ry="34" fill="#6c3eae"/>
<ellipse cx="72" cy="79" rx="23" ry="24" fill="#b196df"/>
<ellipse cx="72" cy="79" rx="10" ry="12" fill="#28223b"/>
<path d="M72 30v17m0 64v18M22 79h18m64 0h18M37 44l13 13m44 44l13 13m0-70L94 57m-44 44-13 13" fill="none" stroke="#8a5ed0" stroke-width="5"/>
<path d="M72 62l5 10 11 2-8 7 2 11-10-6-10 6 2-11-8-7 11-2Z" fill="#eee5ff" stroke-width="1.5"/></g>
<title>Dimensional Warp</title><desc>Tilted spiral vortex with a dark center and radiating magical sparks.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#7e6a9f" stop-opacity=".2"/><stop offset="1" stop-color="#7e6a9f" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="78" rx="53" ry="43" fill="#484562" stroke="#302a29" stroke-width="3"/><ellipse cx="72" cy="78" rx="44" ry="34" fill="#78749e" stroke="#302a29" stroke-width="2"/><ellipse cx="72" cy="78" rx="33" ry="25" fill="#bbb0d4" stroke="none" stroke-width="2"/><ellipse cx="72" cy="78" rx="22" ry="16" fill="#363650" stroke="none" stroke-width="2"/><path d="M29 76Q37 41 82 47Q127 55 107 86Q90 108 59 95Q36 84 59 69Q76 59 87 75Q89 85 76 84" fill="none" stroke="#ece0e9" stroke-width="4" /><path d="M20 60L14 48M42 35L36 24M70 31L72 16M100 38L110 27M124 64L135 60M118 103L130 109M91 118L96 129M57 119L51 130M26 101L14 108" fill="none" stroke="#6e608d" stroke-width="3" /><path d="M30.0,33.0 31.5,38.5 37.0,40.0 31.5,41.5 30.0,47.0 28.5,41.5 23.0,40.0 28.5,38.5Z" fill="#d6ad57" stroke="none" stroke-width="2.0" /><path d="M115.0,109.0 116.5,114.5 122.0,116.0 116.5,117.5 115.0,123.0 113.5,117.5 108.0,116.0 113.5,114.5Z" fill="#d6ad57" stroke="none" stroke-width="2.0" /><path d="M42 60Q58 49 75 51M91 100L104 95" fill="none" stroke="#d7bddb" stroke-width="2" /></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#7e6a9f" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Dimensional Warp</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

+9 -14
View File
@@ -1,16 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Dustcloud token">
<defs>
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="36" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Dustcloud</text>
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M32 111q-17-17 1-31-11-25 16-27 5-23 27-12 20-18 32 4 25 3 18 28 17 18-2 34 3 20-24 20H48q-23 2-16-16Z" fill="#c8a56a"/>
<path d="M44 91q-9-15 7-23m12 44q-14-13 1-27m20-23q12-13 25 0m-18 48q18-5 20-21" fill="none" stroke="#806b48" stroke-width="4"/>
<path d="M28 55l-11-8m101-6l11-10M25 119l-13 5m105 1l14 4" fill="none" stroke="#b28b54" stroke-width="5"/>
<circle cx="49" cy="57" r="3" fill="#745f3e"/><circle cx="103" cy="78" r="4" fill="#745f3e"/></g>
<title>Dustcloud</title><desc>Dustcloud illustrated in shaded ink on parchment.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#696b64" stop-opacity=".2"/><stop offset="1" stop-color="#696b64" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M30 112Q8 100 22 80Q10 60 33 54Q29 34 53 36Q66 17 84 36Q111 28 115 52Q138 66 123 84Q137 107 113 116Z" fill="#62615a" stroke="#302a29" stroke-width="2.0" /><path d="M25 91Q21 76 36 73Q24 54 48 51Q48 35 68 42Q91 30 98 48Q118 44 121 63" fill="none" stroke="#959080" stroke-width="5" /><path d="M46 69Q64 54 77 69Q82 83 70 88M99 66Q113 78 103 91M40 91Q34 105 52 108" fill="none" stroke="#3c403e" stroke-width="4" /><path d="M9 104L19 109M124 40L132 34M13 44L23 50M91 17L93 25" fill="none" stroke="#837c6c" stroke-width="2" /></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#696b64" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Dustcloud</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+9 -16
View File
@@ -1,18 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Fire Imp token">
<defs>
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="37" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Fire Imp</text>
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M44 128q-7-29 8-47-17-17 3-27-1-19 17-17 18-3 18 17 21 10 3 28 14 19 6 46Z" fill="#d54835"/>
<path d="M53 52L39 34l24 12m27 5l17-18-7 25" fill="#d54835"/>
<path d="M47 74L24 58l8 25m66-9l23-16-8 25" fill="#ef8a3c"/>
<circle cx="63" cy="67" r="5" fill="#f6d34e"/><circle cx="84" cy="67" r="5" fill="#f6d34e"/>
<path d="M59 91q14 12 28-2M48 115l-24 12m74-12l23 12" fill="none"/>
<path d="M93 106q21-7 18-25 15 16-1 29" fill="none" stroke="#d54835" stroke-width="6"/></g>
<title>Fire Imp</title><desc>Fire Imp illustrated in shaded ink on parchment.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#d8523b" stop-opacity=".2"/><stop offset="1" stop-color="#d8523b" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M30 121Q7 105 28 74Q27 93 42 94Q29 56 56 26Q48 56 67 58Q76 43 81 15Q110 45 99 78Q116 64 118 45Q141 96 114 121Z" fill="#d65c35" stroke="#302a29" stroke-width="2.0" /><path d="M38 118Q24 98 40 92Q48 108 59 90L71 57Q87 87 81 101Q106 80 116 79Q126 108 106 121Z" fill="#f4c25f" stroke="none" stroke-width="2.0" /><path d="M47 77L27 57 25 80 47 96M91 78L115 57 119 81 96 97Z" fill="#b33335" stroke="#302a29" stroke-width="2.0" /><path d="M52 89L45 116 59 122 73 111 86 123 101 115 91 86Z" fill="#c84035" stroke="#302a29" stroke-width="2.0" /><path d="M48 53L44 25 61 42Q72 37 84 43L105 25 98 57 95 78Q71 99 50 77Z" fill="#d8523b" stroke="#302a29" stroke-width="2.0" /><path d="M45 28L60 44 49 48M101 30L96 48 86 45Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M53 59L67 64 57 68ZM78 63L93 56 88 68Z" fill="#f8edcf" stroke="#302a29" stroke-width="2.0" /><path d="M62 77L72 81 85 75M69 78L72 85 76 79" fill="none" stroke="#302a29" stroke-width="2" /><path d="M97 109Q120 123 122 99" fill="none" stroke="#302a29" stroke-width="6" /><path d="M97 109Q120 123 122 99" fill="none" stroke="#b33335" stroke-width="3" /></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#d8523b" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Fire Imp</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+9 -19
View File
@@ -1,21 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Killer Ooze token">
<defs>
<filter id="rough" x="-8%" y="-8%" width="116%" height="116%"><feTurbulence baseFrequency=".045" numOctaves="2" seed="38" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale=".55"/></filter>
<filter id="soft"><feGaussianBlur stdDeviation="1.4"/></filter>
<pattern id="paper" width="18" height="18" patternUnits="userSpaceOnUse"><rect width="18" height="18" fill="#eee5ca"/><circle cx="3" cy="5" r=".45" fill="#b9aa86" opacity=".34"/><circle cx="14" cy="13" r=".3" fill="#75694f" opacity=".2"/><path d="M1 15q7-2 14 0M6 1l9 2" fill="none" stroke="#b9aa86" stroke-width=".35" opacity=".3"/></pattern>
</defs>
<rect x="2" y="2" width="140" height="146" rx="4" fill="url(#paper)" stroke="#29251e" stroke-width="2.4"/>
<path d="M8 25q30 2 61 0t67 0" fill="none" stroke="#b9aa86" stroke-width="1" opacity=".65"/>
<text x="72" y="18" text-anchor="middle" font-family="Georgia,serif" font-weight="700" font-size="13" letter-spacing=".3" fill="#29251e">Killer Ooze</text>
<ellipse cx="72" cy="131" rx="42" ry="6" fill="#342f26" opacity=".22" filter="url(#soft)"/>
<g filter="url(#rough)" stroke="#29251e" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M29 125q15-15 16-42-3-28 20-40 12-8 23 3 27-1 28 22-3 18-16 22 8 22 18 35-21 7-42-2-25 12-47 2Z" fill="#21824d"/>
<path d="M47 87q8-35 30-40 25 0 28 33-11 18-29 13-17 5-29-6Z" fill="#154f36"/>
<path d="M55 69q20-18 42 1-2 22-21 23-18-2-21-24Z" fill="#252923"/>
<path d="M57 69l8 18 8-20 8 20 8-19 7 15" fill="#f4efd9" stroke="#252923" stroke-width="2"/>
<path d="M55 55q7-11 15-1m14-3q8-8 14 2" fill="none" stroke="#b8d875" stroke-width="4"/>
<circle cx="64" cy="54" r="3" fill="#f0d664"/><circle cx="91" cy="52" r="3" fill="#f0d664"/>
<path d="M45 94q-18 2-23 18m84-24q17 3 20 16" fill="none" stroke="#21824d" stroke-width="9"/>
<circle cx="36" cy="66" r="4" fill="#3d9b5f"/><circle cx="25" cy="83" r="3" fill="#3d9b5f"/><circle cx="119" cy="75" r="4" fill="#3d9b5f"/>
<path d="M43 113q28 11 59-2" fill="none" stroke="#4fa66b" stroke-width="5"/></g>
<title>Killer Ooze</title><desc>Killer Ooze illustrated in shaded ink on parchment.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#4e995b" stop-opacity=".2"/><stop offset="1" stop-color="#4e995b" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M15 111Q16 97 32 97Q20 76 35 59Q43 27 70 34Q102 23 115 58Q129 73 117 99Q135 104 127 116L91 122 43 119Z" fill="#3c895e" stroke="#302a29" stroke-width="2.0" /><path d="M24 108Q43 102 43 67Q49 38 72 43Q103 32 106 61Q118 90 110 105Q99 114 86 103L72 112 54 104 43 112Z" fill="#74b875" stroke="none" stroke-width="2.0" /><path d="M43 74Q72 57 106 73L103 93Q77 113 46 92Z" fill="#233f36" stroke="#302a29" stroke-width="2.0" /><path d="M48 73L54 88 60 71M65 69L73 86 81 68M89 70L96 86 102 72M56 96L61 86 67 100M83 100L91 85 94 96Z" fill="#f1e6c4" stroke="#302a29" stroke-width="2.0" /><ellipse cx="57" cy="57" rx="5" ry="4" fill="#d6ad57" stroke="none" stroke-width="2"/><ellipse cx="89" cy="54" rx="5" ry="4" fill="#d6ad57" stroke="none" stroke-width="2"/><circle cx="58" cy="57" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><circle cx="88" cy="54" r="2" fill="#302a29" stroke="#302a29" stroke-width="2"/><path d="M40 54Q44 43 52 43M99 92L96 99" fill="none" stroke="#c9dc9a" stroke-width="3" /><circle cx="31" cy="91" r="4" fill="#90c185" stroke="#302a29" stroke-width="2"/><circle cx="109" cy="43" r="4" fill="#6aa773" stroke="#302a29" stroke-width="2"/><circle cx="24" cy="72" r="3" fill="#6aa773" stroke="#302a29" stroke-width="2"/></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#4e995b" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Killer Ooze</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

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