Author SHA1 Message Date
Eric WagonerandClaude Fable 5 64a6041a28 The first-person pane becomes an invitation, not a default
Through-your-eyes ships opt-in: the preferences toggle turns it on,
and only an explicit yes is remembered.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:46:58 -04:00
Eric WagonerandClaude Fable 5 2206982c05 Forward rides above the glass, back below it
The strides map to the space they move you through: step-forward
centered in the top bezel, step-back centered in the bottom, matching
the hand's instinct from the old edge chevrons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:45:26 -04:00
Eric WagonerandClaude Fable 5 3bd7e3c81d Credibility pass: the duels leave the code, staying in the ledgers
Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.

Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.

Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.

Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.

Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.

288 tests, 24 ledgers verified, all workspaces typecheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:43:24 -04:00
Eric WagonerandClaude Fable 5 7bd8eff11c The instruments move into the bezel; the windshield goes bare
Per the table's suggestion: chevrons and trays no longer overlay the
canvas — a frame holds them. The top bezel carries the ride controls,
the sharing-your-square portraits, and the hide button; tall turn
paddles flank the glass; the bottom bezel holds the strides, the
at-your-feet tray, hazard chips, the warp-step, and the key hint.
Every canvas pixel is now permanently hover-and-click target glass,
and the yield-while-targeting hack retires unneeded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:23:14 -04:00
Eric WagonerandClaude Fable 5 0530ecb039 The helm yields the glass while a card is aimed
The drive chevrons are buttons over the canvas edges, and the
back-stride strip owns the bottom band — exactly where an adjacent
square's floor renders. With a card selected, hovering there showed
no target and a click would have STEPPED backward instead of casting
(the tacks report: the pane refused what the map allowed). While a
card is aimed the strips now fade and stop catching the pointer, so
every pixel of the pane hovers and clicks as a target; the arrow keys
still steer throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:21:05 -04:00
Eric WagonerandClaude Fable 5 728f9fc28a The feet tray learns hazards and the warp step
Two blind spots close. The active body — wizard or ridden mount —
standing on a dimensional-warp token gets a "step through the warp"
button in the feet tray, dispatching warpStep for the wizard and
creatureWarpStep for the mount (the command the RNRX troll report
earned). And whatever you stand IN shows as a warning chip: tacks,
slime, dust, a pit, the ooze — the near plane's last secrets told.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:19:22 -04:00
Eric WagonerandClaude Fable 5 043a8b94a2 Bodies sharing your square get a tray of their own
A wizard or creature standing IN your square lives inside the
renderer's near plane — no sprite, nothing to click, though
same-square is exactly where punches, thieves, and a mount's claws
operate. A "sharing your square" tray now joins the cockpit above the
feet tray: co-located bodies as clickable portraits, routed through
the same target handler a sprite click uses — so a possessed skeleton
claws its square-mate and a wizard punches theirs, all from inside
the pane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:13:46 -04:00
Eric WagonerandClaude Fable 5 e499df22d7 The facing ribbon rides the mount
While the pane possesses a creature, the keymap's golden ribbon moves
to the mount's token — same shallow triangle, sized to the smaller
body, turning with the borrowed eyes — and leaves the wizard's brow
until the rider returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:02:28 -04:00
Eric WagonerandClaude Fable 5 714c5bef80 Dismounting hands back the exact view you left
Stepping out of a creature's skull treated the return as a fresh cut,
and fresh cuts face the deepest corridor — spinning the rider 90
degrees from where they had been looking. The saddle now remembers:
your position and facing are saved at mount and restored verbatim on
every dismount path (the button, the turn passing, the mount dying),
with no cut and no re-aim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 15:01:18 -04:00
Eric WagonerandClaude Fable 5 1595118410 Possession: ride your creature's eyes; the cockpit stops flinching
The pane learns the menagerie. On your turn, a "ride" button appears
for each creature you command with moves or claws unspent; mounting
seats the camera in its skull facing its longest corridor, hides its
own hide from its eyes, and hands it the helm — chevrons and arrows
stride IT, an adjacent floor click steps it, and clicking an enemy
sharing its square lands its blow. A banner shows the mount's
remaining legs; one tap (or the turn passing, or the mount dying)
returns you to your own eyes, where the director reseats you fresh.

Two cockpit-dignity fixes from live play ride along: a cutaway to
your own body now plays only for things done TO you — dropping a
dagger no longer flings your eyes out of your skull — and a standing
camera is never "rescued" off a wall you deliberately faced; the
deepest-corridor default applies only to fresh cuts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 14:56:29 -04:00
Eric WagonerandClaude Fable 5 5f91e30eb4 Warps preserve your heading, not your bearing
Stepping through a rim warp spun the live camera 180: the director
aimed along the displacement vector, and a top-to-bottom wrap's
vector points exactly opposite the walk. The exit facing now comes
from the warp's own geometry — the far mouth's inward direction —
so travel carries straight through plain wraps and rotates correctly
where boards were laid rotated; a dimensional token step keeps the
facing it entered with.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 14:51:56 -04:00
Eric WagonerandClaude Fable 5 770e79d063 The ribbon brightens and grows a hair
Full-strength marigold instead of muted brass, and the rise goes to a
quarter of the token's width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 14:48:18 -04:00
Eric WagonerandClaude Fable 5 a254e46c5e The facing mark becomes a ribbon on the token's brow
Per the table's taste: not a needle but a short wide golden triangle
hugging the token's leading edge — base as wide as the token itself,
rising a fifth of that width outward, turning with the pane's
quarter-turns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 14:46:56 -04:00
Eric WagonerandClaude Fable 5 19752e55e1 Forfeit legs earn no scolding; the pane sees your boots
Picking up an object ends the turn's actions AND movement — yet the
end-turn caution still tallied the unspendable points as regret. The
unspent-movement reason now stays quiet once actions have ended:
those points cannot be spent, only mourned.

And the raycaster has no downward pitch, so whatever lay in your own
square — a treasure, a dropped blade — was invisible from inside the
cockpit. An "at your feet" tray now rides the pane's bottom edge on
your turn: your square's floor treasures and objects as clickable
icons, each a one-tap pickup through the same commands the board's
buttons send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-27 13:51:27 -04:00
Eric WagonerandClaude Fable 5 3d2bf17585 Absence is not evidence: the seat wallet trusts only verdicts
The games-list reply pruned any wallet seat the server did not vouch
for — so one poll against a restarting (or wrong) server silently
deleted live credentials, the second seat-burning path this session's
server-squatting exposed. myGames now returns explicit verdicts:
a seat is voided only when its room EXISTS and refused that exact
token. Absent rooms earn no verdict and absent seats survive; the
wallet caps by age at fifty instead of by trust.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 14:03:29 -04:00
Eric WagonerandClaude Fable 5 72148dd7c2 A missing room must not burn the seat
The client deleted its saved seat credentials on "no such room" — but
that error proves nothing about the seat: a restarting server, a
stale restore, or a wrong backend all say it, and burning the token
on a transient locked a live player out of a live game (room 22EC,
after this session's server-squatting incident). Now only "name is
taken" — this exact token tried and refused — clears the credential;
a not-found shows its error and keeps the key for the reconnect that
finds the room home again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 14:00:30 -04:00
Eric WagonerandClaude Fable 5 b396469bea The facing needle surfaces from under the token
The keymap's facing wedge rendered BENEATH the wizard's own art, its
tips a pixel past the portrait's edge — present in the DOM, invisible
at the table. It now draws above the token as a proper compass
needle, tip half a cell out, dark-stroked for contrast. Verified in
rendered pixels this time, not element existence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 13:53:56 -04:00
Eric WagonerandClaude Fable 5 22c443d2c5 The crosshair answers before the click commits
Hovering the pane now shows what a click would do: a wizard or
creature outlines in gold with its name at the crosshair, a wall or
door face lights across every column it owns, and a floor square
draws its perspective-true quad on the ground. Warp-bent ground still
targets truly but labels itself "through the warp" rather than
drawing a quad in the wrong geometry. And when a cell-target card is
selected, the ineligible ground dims in the 3D view itself — the same
litCells the board's shadow uses, rasterized into the floor pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 12:35:12 -04:00
Eric WagonerandClaude Fable 5 42d872df6d Milestone two: the pane becomes the wand hand
Clicks in the first-person view now act. The renderer keeps what the
last frame knew — per-column depth, warp bends, every wall hit's edge,
every sprite's screen rect with its identity — and hitTest inverts the
projection: the nearest visible sprite answers as its wizard or
creature, a struck wall or door answers as its edge, and the ground
answers as the square under the pixel, warp-bent columns mapping their
virtual floor back to real cells through the warp's own rigid motion.
The pane hands each target to the very functions the top-down board's
clicks use, so pane and map can never disagree: click the floor ahead
and you walk there; click a door with a key selected and the lock
turns; click a wizard with an attack and the spell flies.

The keymap token now wears a golden wedge showing the camera's compass
quadrant — the cockpit and the chart always agree on which way you
face. Verified live: a floor click became a real move command in the
room ledger, edge clicks resolve and defer correctly, and the wedge
tracks quarter-turns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 12:28:51 -04:00
Eric Wagoner aac06dd2b5 Merge branch 'main' into first-person-play 2026-08-26 12:13:13 -04:00
Eric WagonerandClaude Fable 5 365cb3821e Idle bot turns confess; the chronicle fills its gaps
A clockwork whose chosen command the engine refuses now stumbles
visibly: the refusal is logged server-side with the offending command
(the RNRX coma took three silent turns to notice), and the bot says
so in character at the table — "RECALCULATING." — instead of an
unexplained idle turn. And six event types that never spoke in the
chronicle find their lines: the wave's wash-back, thornbush entry,
dragged objects, the opening deal, stolen-card and revealed-hand
privates. Replay captions inherit them all through humanize.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 12:10:55 -04:00
Eric WagonerandClaude Fable 5 75f1217c77 The RNRX coma: tacks are scattered, not thrown
Automaton II froze for three turns of game four: pathDenial proposed
HANDFUL OF TACKS on a square four steps away, the engine refused the
cast every turn ("you must be adjacent to scatter tacks"), and the
refusal-fallback loop read as a coma. The denial planner now offers
tacks only at the caster's feet — and the walker learns tacks are a
floor hazard, so a bot no longer mines its own doorstep and strolls
straight into it (also RNRX, seq 194-195). The frozen ledger moment
replays cured; a regression pins the planner's proposals to
engine-accepted commands. 288 tests; 24 ledgers verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 12:04:57 -04:00
Eric WagonerandClaude Fable 5 6d88b35516 The clockworks study three lost duels
Six instincts learned playing Kestrel live, taught to the automatons.
Bank guard: enemy gold banked at home is a scoreboard anyone can rob —
an empty-handed raider within two of a stocked bank pulls the walker
home (the predicate reads only enemy positions, the thief-chase
shuttle lesson), and pathDenial blockades the raider's road.
Repossession: the bot's own treasure banked by an enemy one delivery
from winning becomes a march goal, and the underfoot grab learns the
owner exception. Infrastructure: holding DIMENSIONAL WARP near home
in the early rounds, the bot anchors one mouth at its doorstep and
one beside the richest distant gold — the home-anchored superhighway
that won its teacher two games. Turn theft: a carrier one delivery
from winning eats LIGHTNING BLAST's stun over any bigger point total.
The teleport delivery: carrying with home a blink away but farther by
boot, it jumps the ambush and drops next command. Last-counter
thrift: a lone remaining counteraction at healthy life waits for
something big. A/B vs the prior brain across 100 mirrors: parity
(44-52, historical noise band) — the patterns are anti-human, and
mirrors don't play like humans. Three scenario tests pin the rungs;
287 tests; 23 ledgers verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 11:27:13 -04:00
Eric WagonerandClaude Fable 5 9af7bd80ce The host clears seats and closes rooms
Two lobby-lifecycle gaps from tonight's duels (a token lost to a
client bug left an unkickable ghost seat holding a chair in VQJW).
kickSeat: the host removes any other seat from an unstarted room —
the kick is a ledger line so restarts replay it, and a kicked live
socket is set adrift with notice. abandonRoom: the host dissolves a
lobby or a finished game; every member is notified and detached, and
the ledger is archived to rooms-abandoned/ — moved, never deleted.
The lobby shows hosts a ✕ per seat and an abandon-room control.
Proven live: kick with notice, kick surviving a server restart, and
abandonment archiving its ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 11:05:42 -04:00
Eric WagonerandClaude Fable 5 4c7633097b The seat client sees wormholes; the skill records defeat three
Kestrel has won two of three duels through dimensional-warp tokens
the ASCII view never rendered — a !! line now names every token pair.
The skill's game-three scars: never lead the attack against him,
destroy walls from range, and count deliveries, not flourishes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 10:58:59 -04:00
Eric WagonerandClaude Fable 5 063cd2a78c The seat file follows the join, not the ghost
Joining a new room while an old seat file lingered kept the OLD room
id beside the NEW room's token — orphaning the fresh seat as an
unkickable lobby ghost the moment the file was cleared. A join now
records the room it actually joined; only resumes inherit the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 10:43:42 -04:00
Eric WagonerandClaude Fable 5 86ac86b6bc The duel skill carries game two's scars
Scoreboard-first thinking, the contested bank, free-angle LOS, the
illusionary-attack counter rule, router-before-sprint, and Eric's
meta-warfare against bot upgrades.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 10:39:33 -04:00
Eric WagonerandClaude Fable 5 9e4807eab8 The needle shows itself; floor objects answer a tap
Two findings from duel two (room UNS6). AROUND THE CORNER's bent
sight was legal but invisible: a 1-by-6 diagonal threading six open
edge spans read as an impossible shot because the table never saw the
line. The stack now records bentCorner, and stackSightTrace finds the
middle square and returns both legs; the board draws them with a
pulsing diamond on the corner the sight bent around — the answer to
"how can he even see me?" now covers the bent case.

Floor objects (a dropped wizardblade) showed a marker but answered
only a 450ms hold; a plain click did nothing. The marker itself is
now a click target that opens the same card peek.

All 21 ledgers verified; 284 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 10:38:57 -04:00
Eric WagonerandClaude Fable 5 858fdc6a56 The wizwar-duel skill: Claude takes a seat at the table
tools/claude-seat.mjs is the one-shot websocket seat (join/view/do/
chat, token persisted between invocations, live events separated from
replayed history) that let Claude play room 9UA6 as a real player.
The skill teaches the next session everything game one cost to learn:
map reading, the command surface, the grab-ends-actions rule, warp
sides, repossession, ambush transitions — and the operational lesson
about narrating one's own hand to one's opponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 00:00:57 -04:00
Eric WagonerandClaude Fable 5 dd87f1d843 Monsters ride the wormhole; mental force fails loudly (rules rev 5)
Two findings from the first human-vs-Claude duel (room 9UA6). A
creature standing on a DIMENSIONAL WARP token had no way through it —
warpStep obeys only wizards. The new creatureWarpStep command steps a
commanded creature through the tokens under the walker's rules: one
move spent, solid stone refuses it, FEAR holds the beast, touch
effects greet whoever shares the landing; the board taps the paired
token like a rim mouth. Replay-safe ungated — a new command widens
nothing that was recorded.

MENTAL FORCE ate its card silently when the destination lay beyond
the victim's three walked spaces — the caster spent an attack and
learned nothing (ask me how I know). Rev 5 refuses the impossible
destination up front with the card kept; when the victim slips out of
range between cast and resolution, a mentalForceFizzled event tells
the table what happened instead of nothing. All 20 production ledgers
verified, the duel's own among them; 283 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 23:59:31 -04:00
Eric WagonerandClaude Fable 5 df2913f366 The archmage learns the lessons humans taught it
Eric beats the clockwork four games in five; these close the holes he
walks through. The treasure race: no number card is hoarded for a war
chest when no enemy stands within six squares, and never against the
delivery that wins the game outright. The kill: bestAttack now shops
every number and the amplify separately, preferring a killing blow at
minimal spend (with three points of soak margin, or an ABSORB undoes
it) over maximum splash, banking the big numbers for the next fight.
The last stand: a blow that would be lethal escalates past every
thrift threshold — any counter that can save the clockwork is cheap
at that price, ABSORB's three soaked points included when they are
exactly the margin. The race denial: TELEPORT OPPONENT and DROP
OBJECT now fire on any carrier one delivery from winning, not just
the thief of the bot's own gold. And the MASTER KEY goes on display
the turn it is drawn, opening every workable lock on the march for
free. A/B against the prior brain across 300 mirror games: parity
(the features aim at human play patterns mirrors rarely exercise);
three scenario tests pin the new instincts. Ships ungated — brains
choose commands, ledgers record them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 22:30:03 -04:00
Eric WagonerandClaude Fable 5 324853ce51 Every turn wears the eye
The chronicle stops judging what is share-worthy: the 👁 now rides
every turn's opening line — headers, skipped turns, extra turns — and
the players decide which moments deserve reliving. The game-winning
line keeps its own crown eye. The notable-event catalog and the
one-eye bookkeeping retire with the judgment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 21:14:37 -04:00
Eric WagonerandClaude Fable 5 beb4947ad8 'It will relock behind you' means at your back (rules rev 4)
Both PICK LOCK and MASTER KEY promise the door relocks behind the
walker — not that it stands unlocked for the turn. Passing through
any door unlocked this turn now shuts it at once at the walker's
back, unless a hand holds it open; the pick-and-peek stays (an
unpassed door keeps its opening until turn's end), and holding via a
targeted cast works as ever, displayed key included. The displayed
key's own walk-through likewise leaves no lingering opening for
bystanders — their peek needs a held door. Frozen behind rev 4 so
stored games keep their turn-long openings. 18 ledgers verified; 277
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 20:53:01 -04:00
Eric WagonerandClaude Fable 5 64e4e243dc The MASTER KEY earns its display
"Display Immediately," says the card — now the key lives up to it.
Cast bare (a new Display button beside the hold-the-door checkbox),
it goes straight on display; once displayed, it turns in every lock
its bearer walks through — no more casting at each door — with the
door relocking behind them at turn's end like any picked lock, and
the doorUnlocked event still swinging the door in the reels. Cast at
an adjacent door it still works one lock like PICK LOCK, including
holding the door open for others. A JAMmed LOCK refuses it as ever,
and the adjacent peek through workable locks already rode the rev-2
sight rules. Replay-safe ungated: bare casts were never recorded
(they were refused), displayed cards still count against the hand
limit, and walking through a locked door only widens what is
accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 20:46:23 -04:00
Eric WagonerandClaude Fable 5 805fb09782 Chaos aims at no one
The client always offered CHAOS from the hint bar without a target,
and the engine refused it: "attack spells target a player". Not this
one — cast bare, it sweeps every other living wizard into the pile,
each offered their FULL SHIELD sit-out in turn order from the caster.
No stack forms; full shields only ever excluded a player from the
storm, so the queue is the whole defense. The older player-targeted
form stays accepted, so stored games replay unchanged. The scramble
joins the chronicle's notable events — a turn that redeals every hand
earns its eye.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 20:35:12 -04:00
Eric WagonerandClaude Fable 5 168e57ab91 The thief-chase serves the blow, not the nearness
An automaton beside the wizard carrying its gold shuttled on and off
their square until every move was spent — the chase goal flipped
between "the thief's cell" and "the gold" with each step, because it
keyed on whether the thief stood underfoot. The chase now claims the
march only while the blow it exists to deliver is still live (an
attack in hand, the turn's attack unspent, casting open): those gates
hold still as the clockwork walks, so the goal cannot flip mid-march.
Caught with the strike live, it stands its ground; otherwise the gold
has its legs for the turn. A regression walks the exact scene and
forbids the A-B-A shuttle. The bot-vs-bot harness also learns to
route WARD's pending moment to the treasure's owner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 20:35:12 -04:00
Eric WagonerandClaude Fable 5 71a3c81670 The stone pierces walls for creations too (rules rev 3)
VISIONSTONE only ever lit the attack path: casterLos knew it, but
every sight check routed through gameLos — creations like THORNBUSH,
dispels, CREATE DOOR, STONE TO WATER — refused "no line of sight"
through the bearer's one wall. gameLos now honors the stone whenever
the viewer bears it, at every revision: widening what a cast may
target never changes how a recorded command replays, so games already
in flight get the fix at once. Only the ambush's mid-move eye is
frozen per game (rev 3) — springing lands in the ledger's flow, and
stored games must replay their blindness. All 18 production ledgers
verified; 272 engine tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 20:21:32 -04:00
Eric WagonerandClaude Fable 5 7adf4b6cd6 The client's eye learns the VISIONSTONE
The engine always honored the stone — its bearer casts through exactly
one wall or door — but the targeting shadow never did: sightedCellsFor
kept every square behind a wall dimmed, so the aid told the bearer the
stone wasn't working. The sighted set now retries each unseen square
with a single edge removed, mirroring casterLos; the automaton shares
the function, so bots see through their stones too. Two tests pin the
engine ruling and the client parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 20:14:02 -04:00
Eric WagonerandClaude Fable 5 b16bbe82a6 The masthead wears its deploy stamp
A small yyyy.mm.dd.hh.mm mark beside the edition line, baked in at
build time — and the deploy builds moments before it ships, so the
stamp reads as deploy time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 14:34:06 -04:00
Eric WagonerandClaude Fable 5 154e00141c Three rulings from the night games (rules rev 2)
Teleport wraps the maze's rim: its four counted squares now step
through warp mouths like any doorway, so a wizard can blink off one
side of the board and onto the other.

A wizard at a door's threshold may pull it open and peek — lock
removed, door unlocked this turn, or PICK LOCK / MASTER KEY in hand —
without stepping through. The door still hangs shut to everyone down
the hallway; only a HELD door is propped open for every eye. Gated
behind rules rev 2 (now sourced from the engine's CURRENT_RULES_REV)
so stored games replay with their doors dark; the client's sight
mirror learns the same peek.

Fear no longer nails a cornered wizard to the floor: a step closer to
the dread is allowed when it walks the shortest way out of it, so
escape can round a corner. Standing outside and stepping in is still
refused.

All 15 production ledgers verified before deploy; 269 engine tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 14:28:12 -04:00
Eric WagonerandClaude Fable 5 ffd2cc7a82 The helm grows tappable: chevron strips ride the pane's edges
Milestone two opens with touch. Faint chevrons live along the pane's
four edges — left and right strips quarter-turn the camera (always),
top and bottom stride forward and back (your turn only, through the
same commands, cautions, and wall-bounces as the keys). Quiet under a
resting eye, brighter under a pointer, generous under a thumb.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 00:14:16 -04:00
Eric WagonerandClaude Fable 5 b8bb60cf6d Milestone one: the pane becomes the cockpit
FPV play begins. On your turn the pane takes the helm: arrows or WASD
— quarter-turns are free camera, a stride issues a REAL move command
through the same cautions and refusals as a board click, and a wall
bounces you with the table's own toast. While the player steers, the
director keeps its hands off the idle facings; your own magic still
turns your head, and cutaways still fire. The layout inverts to match
the mode: the 3D view is the primary window into the game, the
top-down board reduced to a keymap beneath it, with the control hint
in the pane's corner. Typing in chat is never hijacked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 00:02:16 -04:00
Eric WagonerandClaude Fable 5 c7109fcddf The pane sits square on the board's shoulders
Eric's layout call: the first-person pane wears exactly the board's
width, directly above it. A shrink-wrapped column stacks the two, with
the pane held out of the intrinsic sizing (width:0, min-width:100%) so
the BOARD alone decides how wide the column is; the canvas keeps its
aspect as it scales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 23:46:20 -04:00
Eric WagonerandClaude Fable 5 e6ef5f6315 First person comes to the living table (branch work)
A pane above the board plays the game through your own eyes while you
play it — the board below stays the tactical truth. One new module,
fpv/director.ts, extracts the reel's directorial instincts (the aim
ladder, the hurl test, the glide gatherer) so the replay and the live
pane share one eye and can never drift; Replay is refactored onto it
unchanged. LiveFirstPerson.svelte feeds the shared FirstPerson
renderer from the live event stream: every batch plays through the
same fx pipeline as the instant replay — projectiles, door swings,
conjurations growing — while the camera walks your strides, turns to
your aims and to actors in your sight, cuts away (with your own body
in frame) to deeds beyond it, and never idles nose-to-brick. Bodies
glide between server views. A ✕ hides the pane; the liveFp preference
(on by default) brings it back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 23:29:03 -04:00
Eric WagonerandClaude Fable 5 2932d1e500 The instant replay graduates: on by default
The eye earned its place — every notable turn offers its reel unless a
player has switched it off, and an explicit off stays respected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 22:27:56 -04:00
Eric WagonerandClaude Fable 5 be7c451437 Overlays paint around a body, not merely behind it
The per-column sprite gate blanked an overlay's WHOLE column wherever a
nearer body touched it — a treasure chest masked the warp veil all the
way to the ceiling. Each column now remembers its nearest sprite's
vertical span as well as its depth, and every overlay family (veils,
ghost walls, lintels, rising conjurations) draws in segments above and
below the covered band, sampling the matching slice of its texture — so
the shimmer wraps the chest instead of vanishing over it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:53:20 -04:00
Eric WagonerandClaude Fable 5 b235a7a9cd Overlays yield to the body standing in front of them
The warp veil painted over a wizard standing before the mouth — the
overlay passes ran after the sprites with no knowledge of their depth.
Each column now remembers its nearest sprite, and every overlay family
(veils, ghost walls, doorway lintels, rising conjurations) skips the
columns where a body stands closer, while still washing translucently
over anything beyond.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:46:27 -04:00
Eric WagonerandClaude Fable 5 7a32370ccb Credibility pass: the reel's scars sanded, the veil finally hung
Three blind reviews over the first-person arc, every finding verified
against the source. History-narrating comments made timeless or cut;
the stacked BUDDY comment collapsed to one voice. Dead code out: the
orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter
poke, the impossible-state sentinel. The never-produced "warp" hit
kind resolved the right way — warp mouths now hang a translucent veil
of the painted warp texture, so art that never rendered finally does.
Types tightened (SlabStrike named once, ShareData rides CatchUpStep,
botTier loses its casts), the gallery reads MATERIALS instead of a
hand-copied list, the chronicle resets through one helper, a
superseded share mint rejects instead of stranding, and the conjured
safe gains the growth key its siblings had. Tests lose a triple
assignment, a tautology, and two self-swallowing regex alternatives.
The sprite spec — which still told the artist to paint for additive
compositing the renderer no longer uses — now describes the renderer
that exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:41:11 -04:00
Eric WagonerandClaude Fable 5 88994f18a2 The winning line always wears the eye
One instant-replay eye per turn keeps the chronicle uncluttered — but
the turn that WINS the game is the turn people want to relive, and if
its wizard did something notable earlier, the eye had already been
spent by the time the crown line printed. The game-winning line now
carries its own eye regardless, fireworks and all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:15:03 -04:00
Eric WagonerandClaude Fable 5 252d10711d The caption wears the actor's face
Every reel caption now carries the acting wizard's standee at its left
— the same token art the board plays with, framed in the caption's own
ink — and the burned-in video caption bar gets the portrait too, so a
shared clip names its actors by face as well as by name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:07:45 -04:00
Eric WagonerandClaude Fable 5 f5151a0c99 Turn zero's eyes belong to the first player, even in the whole tale
Eric caught the whole-game reel opening in the wrong head: turn zero's
turnStarted fires in the deal, before any step, so the boundary scan
found nothing and fell back to the share's pov prop — the WINNER, whose
eyes then watched the first player's whole turn from across the maze.
When no boundary precedes the current step, the owner is now read from
the reel's first turnEnded, which turn zero always contains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:01:25 -04:00
Eric WagonerandClaude Fable 5 c4337e3a7b The nameless viewer may watch the whole tale too
Whole-game share links minted fine and then 404ed: the share page
rebuilds through catchUpSteps, whose seat check never learned the
SPECTATOR exemption momentSteps got — the anonymous viewer held no
seat, so the rebuild refused and the page said "no such replay". The
spectator is now admitted to finished games' full replays, public
knowledge only, same as single turns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:57:55 -04:00
Eric WagonerandClaude Fable 5 4ee2a3e5d3 Dropped treasure is seen falling
treasureDropped and objectDropped played as nothing — the gold simply
teleported from chest to floor between frames. A drop now glints (the
spark blooming where it lands, a beat after the blow when combat
knocked it loose), and a wizard dropping at their own feet takes the
cutaway: a third-person shot of them setting the prize down — or
spilling it. When the reel's wizard strikes the spill loose from
someone else, the attack-facing already has the victim in frame, so
the treasure falls on camera.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:50:06 -04:00
Eric WagonerandClaude Fable 5 846cf56ffe A laden wizard shows their burden
A carried treasure rendered nowhere in first person — the thief hauling
your gold looked exactly like an innocent. The treasure now rides its
carrier at chest height, drawn just in front of their card (a sort bias
wins the tie), gliding along with their tweened strides and showing
through warp mouths like everything else. The workshop rehearses it
with ?carry=Rival.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:48:41 -04:00
Eric WagonerandClaude Fable 5 df20e35124 The camera audit: wherever the fx will play, the eyes already are
Eric asked what else happens off-camera, and the audit found a family:
a wizard's creature marching or clawing, objects dragged from afar,
punches (always same-square, so also invisible), treasure grabbed at
one's own feet, the Thumb of God's landing. Rather than patch each,
one rule retires the class: when nothing else aims the reel's own
wizard's step, the camera aims wherever the fx layer is about to play
its first impact or projectile landing — so every effect that exists,
and every one added later, gets watched by construction. Creature
marches and treasure grabs (which play no fx) get explicit aim rungs;
own-square deeds keep taking the cutaway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:44:29 -04:00
Eric WagonerandClaude Fable 5 698e975b6d A summon at your own feet earns a shot of yourself
Two A9WK summons — fire imp, shadow — landed on their casters' OWN
squares: no turn can face your own feet, and a creature in your own
cell is closer than the near plane, so both materialized invisibly.
The aim now reads creatureCreated (the summon's true landing, whatever
the cast's target says), and a deed on the wizard's own square takes
the CUTAWAY: the camera steps outside, stands down the deepest
corridor, and — for cutaway frames only — the reel's own wizard
renders too, so you watch the imp grow at your own feet with yourself
in the shot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:41:39 -04:00
Eric WagonerandClaude Fable 5 ccb5346a31 Hedges stay in their squares; the eyes turn to the lock they pick
Eric's rosebush-through-a-wall, hunted to two roots. A camera-facing
billboard viewed obliquely rotates out of its square, so cell-bound
volumes are now windowed to the exact segment where their plane
crosses their own cell — no end may paint beyond it. And the real
smoking gun: the near-bias that lets a hedge win the tie against the
wizard standing in it was applied to the OCCLUSION depth too, letting
the hedge cheat a fifth of a cell through any neighboring wall. The
bias now orders sprites among themselves only; walls always judge the
true depth.

Also answered: edge-deeds turn the head. Picking or jamming a lock,
unlocking with the Master Key, raising or razing or drowning a wall —
any edge-bearing deed by the reel's own wizard now aims the camera at
that edge's midpoint before the outcome shows, same as any other
target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:28:24 -04:00
Eric WagonerandClaude Fable 5 285c91b2c8 Conjurations grow into the world, wrapped in glowing swirls
Nothing appears fully-formed anymore. Summoned creatures and conjured
terrain volumes scale up from a sliver, bottom-anchored, as if growing
out of the floor. Conjured SOLIDS — walls, firewalls, stone, and yes,
illusion walls for whoever they fool — RISE: while growing, the rays
pass over them so the room behind stays visible, and the renderer
draws the young face climbing bottom-up with violet swirls running
across it, solidifying as it tops out. Every creation also blooms the
shimmer at its cell. The growth is driven through the fx channel, so
the director's slate applies: things grow only once the camera faces
them.

Two camera notes squared away en route: the slate-setter read back the
state it had just written, registering itself as its own dependency
(an update-depth ping-pong under fast stepping) — locals now; and a
reel whose FINAL step strides into a corner gets one more turn after
the walk lands, to a view worth ending on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:19:48 -04:00
Eric WagonerandClaude Fable 5 cd9ce9fddf Sprites respect the window they are seen through
DUKK's floor-to-ceiling white sliver, decoded: the sprite gate only
asked "did this column's ray warp?" — so a REAL body standing in front
of a warp mouth was clipped out of the mouth's columns (leaving a
baffling sliver of a huge close sprite), and a body virtualized
through warp A could paint columns bent by warp B. Every column now
remembers WHICH warp bent its ray and how far away that mouth stood;
a real body paints a column only nearer than its mouth (in front of
the window), and a virtual one only through its own warp's columns,
beyond the mouth. Projectile legs carry the same identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:07:41 -04:00
Eric WagonerandClaude Fable 5 045e868190 The outcome waits until the eyes arrive
Eric dispelled an object and it un-rendered off-screen, before the
camera had turned to look. The reel now sequences like a director:
each step the camera writes a slate — how long the PREVIOUS world
stays on screen while the eyes turn toward the recipient — and both
the view swap and the step's effects reveal together when the turn
lands. Cutaways hold a beat of the world-before too, so a thing
destroyed across the maze is SEEN existing, then seen ending. Cuts
that open the reel still reveal at once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:56:21 -04:00
Eric Wagoner 9d59ac9ec1 Four more painted: the doorway dressed, harm cracked, roots laid, iron shut 2026-08-23 19:47:58 -04:00
Eric WagonerandClaude Fable 5 707297da17 Half the warp pairings were secretly mirrors
Eric walked a five-player board's corner warp and the world stretched.
The lane rule — carry the along-the-edge offset through unchanged —
is only correct for half the side pairings: for the rest (N to N,
E to N, and their kin, the ones five-player boards deal), the proper
rotation lands the entry axis head-to-head against the exit axis, and
preserving the raw lane smuggled a REFLECTION into the traversal —
rays crossing over at the mouth, parallax inverted, the far room
swimming. warpLaneMirrored computes the pairing's sign from the
rotation itself; castRay mirrors the lane where required, and
warpMotion anchors the far mouth at its opposite corner to stay the
same pure rotation — so sprites and projectile legs agree with the
rays again. The workshop grows ?players=2..6 to stand on any table
size (the mirror-class mouths live on the five-player boards).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:47:28 -04:00
Eric WagonerandClaude Fable 5 700db70d4e A cell-wide sprite is now actually a cell wide
Rubble drawn to span its gap stopped short of the posts, and the
ledger of blame is arithmetic: billboard widths scaled by the vertical
pixel ratio (H/depth) when horizontal pixels-per-world-unit is the
focal length over depth — about 1.4x larger. Every "one cell wide"
volume rendered at seventy percent. Sprites that declare an aspect now
mean WORLD width and use the horizontal scale, so rubble reaches post
to post and hedges brush both walls; plain square token sprites keep
their familiar pixel shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:39:26 -04:00
Eric WagonerandClaude Fable 5 5fc5805354 Odds, ends, and every missing effect: doorways dressed, victory earns its fireworks
The audit Eric called for, delivered whole. Open doors are DOORWAYS
now: stone jamb posts at both ends and a lintel hung across the top
(paintable as textures/doorframe.png), the way through open to the eye.
Battle damage shows — the engine's per-edge wallDamage wears painted
cracks into any wall, heavier as harm mounts. The wall safe stands as
a strongbox volume instead of a flat token. Hedges take root: an
underbrush floor decal covers the whole square beneath them (Eric's
report — a billboard at center depth can't reach the floor trapezoid's
near edge), and the hedge itself widens past the cell. Rubble
stretches across the fallen wall's full gap, corner piles landing at
the posts, exactly as its art was painted.

The first-person effect gaps close: victory now throws SIX fireworks
climbing over the winner's home with gold washing the screen; sector
rotations flash violet and heave the whole world; the Thumb of God
lands with a burst and a shudder; walls conjured from nothing rise in
stone dust; and every hazard pratfall plays — pit falls (with a dark
drop and jolt for the faller's own eyes), ooze slips, tack yelps,
thorn snaps, slime, dust. Dying in first person fades long and dark.

And the whole tale travels: a finished game's full replay carries the
share button too — turn -1 mints a link to every turn of the game,
each through its wizard's own eyes, titled for the winner's triumph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:32:42 -04:00
Eric WagonerandClaude Fable 5 a94ab5c6f5 A cut is not a stride
The wraith reel still ended in a wall, and the ledger showed why: its
final step CUTS home from the cutaway at the summon site — a nine-cell
leap — and the camera read that leap as a walk, faced along it into
the masonry, and counted itself aimed, which silenced the closing-shot
rescue. Leaps no longer set facing: any step that will cut computes
willCut first, strides only turn the eyes within a stride's reach, and
a cut that lands with nothing aimed always re-picks its view from the
new position. The wraith turn now closes down a corridor with the
wraith itself in frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:19:33 -04:00
Eric WagonerandClaude Fable 5 59c25f42f0 The closing shot finds something worth looking at
A reel that ends nose-to-brick ends badly — especially recorded. On
the final step the idle-gaze rescue raises its standard (a wall inside
1.2 cells is a bad last frame) and scores the four cardinals by more
than corridor depth: a visible wizard or creature weighs most, loose
treasure half, each behind a real line-of-sight check. The camera
turns once to the best view and only if one clearly beats what it has
— a wizard walled in on every side keeps its poor view rather than
hunting forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:14:25 -04:00
Eric WagonerandClaude Fable 5 765828700d Reel captions keep the count, not the cards
"You draw Waterbolt" belongs in the chronicle, not on a replay whose
canvas gets recorded and passed around — the reel's captions now skip
the private-draw events (drawn, dealt, stolen) and say only how many.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:11:09 -04:00
Eric WagonerandClaude Fable 5 15f38ce5e5 A turn's reel is exactly a turn
The window's straddle commands hold more than the turn they open and
close: the opener carries the previous wizard's last draws, the closer
the next wizard's first upkeep — so every instant replay ran on into
the following turn (and, with the camera now following the turn-taker,
jumped into the next wizard's head for the overrun). momentSteps trims
both straddles at the boundary events: the reel opens on "X's turn
begins" and stops at X's turnEnded, keeping only what belongs to X.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:08:41 -04:00
Eric Wagoner 19558c62af Ten terrains painted: the maze grows thorns, jello, and falling light 2026-08-23 18:56:02 -04:00
Eric WagonerandClaude Fable 5 a28d27eedc The camera always wears the turn-taker's eyes, and knows whose turn zero is
Eric's rule, applied everywhere: every reel — moment, catch-up, whole
tale — follows the wizard whose turn it is, the pov switching at each
handover as boundaries scroll past. And the J8LL mystery dies with it:
turn zero's opening boundary lives in the DEAL, before any command, so
scanning a moment's steps found the NEXT turn's start instead and put
the camera in the wrong wizard's head. momentSteps now names the owner
from the very boundary that opened the turn — counted through the deal
— and carries it to the client and the share page, where it also fixes
the same latent mis-titling.

Also aboard: the cutaway shot. When the eyes aim at something they
cannot see — a wraith summoned across the maze, a spell through walls —
the camera cuts to the target's cell for that beat, standing back down
its deepest corridor, and cuts home on the next step. The wraith no
longer materializes off-screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:56:02 -04:00
Eric WagonerandClaude Fable 5 6788de2f6e The chronicle remembers everything, and holds still while you read it
The game log's sixty-line window was a render cap, not a limit of what
the client holds — a rejoin replays the whole game into memory. The
chronicle now renders every line back to the deal, and its auto-scroll
grows manners: it follows the newest line only while the reader sits at
the bottom, and holds its place once they scroll up into history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:35:15 -04:00
Eric WagonerandClaude Fable 5 1e79f2ffb2 The terrain moves in: pits open downward, hedges stand waist-high, ooze is jello
The flat token billboards give way to terrain that inhabits the room.
Pits, slime, tacks, and the dimensional warp's rings are painted INTO
the flagstones — the home-tile decal pass grows into a general per-cell
overlay grid, one texture per cell, alpha-blended texel by texel. Thorn
and rose hedges fill their square wall to wall at half height, drawn
with a near-bias so the wizard standing among the thorns peeks over the
top. The killer ooze rises as a full translucent cube of jello, bricks
and bodies visible through it; dust billows; and each dimensional warp
mouth throws an additive pillar of violet light up from its floor ring.
Billboards learn aspect, translucency, glow, and draw-order bias to
carry it all.

Nine paintable starters ship — four floor decals under textures/, five
standing volumes under terrain3d/ (spec updated for the artist, hedges
wide, jello solid, warpglow as light) — shown in the token workshop,
rehearsable in place with /?fpv&terrain=thornbush@3,7;jello@4,7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 18:25:01 -04:00
Eric WagonerandClaude Fable 5 a8a63cc27b The share card stops trusting request headers
Host and x-forwarded-proto are attacker-writable and were interpolated
raw into the share page's meta attributes. The proto must now be
literally http or https, and the host is HTML-escaped like everything
else that reaches the head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 17:42:20 -04:00
47 changed files with 4172 additions and 521 deletions
+3 -3
View File
@@ -43,7 +43,7 @@ Replay with `createGame({playerIds, seed, sets, colors, deckRev})` +
any seq to inspect full state. To ask why a bot did something, rebuild
the state at its turn and call `automatonCommand(viewFor(state, id),
style, tier)` — and if its choice differs from the ledger, the engine
refused it and the fallback burned the turn (the X2XN pattern).
refused it and the fallback burned the turn.
For "which games are open/stalled" sweeps: fetch all `*.jsonl`, replay
each, and report phase / round / humans vs bots / last command's `at`.
@@ -55,5 +55,5 @@ each, and report phase / round / humans vs bots / last command's `at`.
Strict-replays every production ledger against the local engine; one
refused command fails. A room whose ledger no longer replays becomes
unreachable after restart. Rules changes while games are live need a
`deckRev` bump plus an engine gate (the convention survives the 2026-08
reset to rev 1).
`deckRev` bump plus an engine gate. Production deckRev is currently 1,
so low numbers are not stale.
+141
View File
@@ -0,0 +1,141 @@
---
name: wizwar-duel
description: Play Wiz-War live against Eric — join his room as a real seat, reason about every move yourself, and duel turn by turn over the websocket. Use when Eric wants a game, gives a room code, or says "let's play".
---
# Playing Wiz-War against Eric
You are a PLAYER, not the automaton. Read the board, reason, and choose
every command yourself. Eric plays in his browser; you play through the
CLI seat at `tools/claude-seat.mjs`. He prompts "go" after his moves —
each of his messages is your cue to look and act.
## The seat client
Run from the repo root (needs the workspace's `ws` package):
```bash
node tools/claude-seat.mjs join <ROOM> Claude # take a seat (token saved)
node tools/claude-seat.mjs view # board + hand + last events
node tools/claude-seat.mjs do '<command json>' # one engine command
node tools/claude-seat.mjs chat "text" # table talk
```
Seat state persists in `/tmp/wizwar-claude-seat.json` (override:
`WIZWAR_SEAT`); server defaults to production (`WIZWAR_SERVER` to point
elsewhere). Eric creates the room, gives you the code, and starts the
game after you join.
Commands are the engine's `Command` union (packages/engine/src/game.ts):
`{"type":"move","direction":"N|S|E|W"}`, `cast` (with `target`, optional
`numberInstanceIds`/`params`), `playNumberForMovement`, `pickUpTreasure`,
`dropTreasure`, `warpStep`, `counteract`, `pass`, `punch`, `setAmbush`,
`wardChoice`, `endTurn` (with `draw`). A refused command costs nothing —
probing legality is free, so when unsure, try it and read the error.
## Reading the map
- The renderer prints y=0 at top; **y grows SOUTH**. N = y-1, S = y+1.
- `C2` is you, `P1` etc. are opponents, `$c`/`$1` treasures, `h?` homes,
two-letter codes are square contents (SA safe, RO rosebush, ST stone,
DU dust). `D` on a line is a door, `—` a wall, blank is open.
- WARPS line lists rim passages as `2,0N→2,9`: standing at (2,0) and
moving N carries you to (2,9). **The warp rides one specific side of
its square** — check which before you walk (a wrong guess burns moves;
ask me how I know).
- Dimensional-warp TOKENS (cast by players) are separate: stand on one
and `warpStep`. Creatures use `creatureWarpStep`.
## Hard-won rules knowledge (each cost me something in game one)
- `pickUpTreasure` ENDS your turn's actions AND movement — arrive with
the grab as your last act, never mid-plan. `dropTreasure` at home too.
- You can pick up ANY floor treasure, including your own stolen-and-
delivered one sitting on the enemy's home square. Repossession is real.
- THIEF and punches need same-square; THIEF steals a *named* card and
fizzles (card spent) on a wrong guess.
- MENTAL FORCE moves the victim ≤3 *walked* spaces — walls and relocked
doors shrink its reach; rev 5+ refuses impossible destinations.
- Doors relock behind you when you pass through (rev 4+). Your own
escape route can seal itself — and seal pursuers out.
- LOS threads through rim warps: you can be seen (and shot) through a
warp mouth from the far side of the board. Camping one square off the
mouth keeps you hidden.
- lock-in-place freezes movement AND warpStep for a full turn; REUSE
SPELL retrieves only your LAST cast spell, enabling one re-lock.
- An ambush (`setAmbush` interrupt/opportunity-fire + committed attack)
triggers on LOS *entry* transitions only — someone already in sight
never springs it. It survives forever and fires out of turn.
- Walking-dead bleeds ½ life per space moved, permanently. Once cursed,
every plan must be priced in steps.
## Game-two scars (each cost me the game or nearly)
- **RECOUNT THE SCOREBOARD EVERY TURN**: how many treasures does each
player have banked AT HOME right now? I sprinted a "winning" delivery
in game two that was actually my FIRST of two — Eric had un-banked me
five rounds earlier and let me monologue. Banked counts, not vibes.
- A banked treasure is NOT safe: anyone can pick it up off your home
square and carry it away. Guard the bank or count it as contested.
- LOS is FREE-ANGLE center-to-center (the rulebook's rule), not rows
and columns: razor diagonals threading open gaps are legal for
everyone — including at you. The board draws bent/straight traces.
- ILLUSIONARY ATTACK deals its imitated spell's damage if NOT
countered; ANY counteraction dispels it entirely. Counter it with
the cheapest thing you hold; never "call the bluff" by passing.
- Never trust your ASCII parse of the map for multi-step plans: run a
BFS over `view.board.edges` (see tools/claude-seat.mjs view JSON, or
write a quick router) before spending a number card on a sprint. I
burned a 7-move turn bouncing off six misread walls.
- Eric counter-plays YOUR bot upgrades: he summoned a troll bodyguard
specifically against the archmage's exile-the-winner instinct, and
relocated MY home sector mid-carry. Expect meta-warfare.
- In 4p with automatons: the bots are chaos agents — they stun, exile,
disease, and con. Herd them at Eric; never assume they're on script.
## Game-three scars (lost in four rounds)
- **CHECK `dimWarps` EVERY SINGLE VIEW** — Kestrel (Eric's wizard) has
won two games
with quietly-placed wormholes ending beside his home. The client
prints a !! line for them; treat any new token pair as a five-alarm
fire and recompute both players' delivery distances through it.
- **Never lead the attack against Kestrel.** Three games, three saved
counters sprung at the perfect moment (anti-anti, full-shield bait,
full-reflection held three rounds for my lightning). Force his
counters out with throwaways or positional threats before committing
a real spell; his patience is perfect and he telegraphs nothing.
- DESTROY WALL showers the ADJACENT caster with rubble (4 damage) —
cast it from range, never on your own square's edge.
- Wading a rosebush costs 3. A reflected LIGHTNING BLAST still stuns
its caster even at 1 reflected damage — the stun is the payload, and
a lost turn against Kestrel is a lost game.
- Grab-turn-one openings feel great and prove nothing: deliveries are
the only score. He banked quietly both times while I showboated.
## Playing well
- Kestrel (Eric) is EXCELLENT: he baited my thief, saved anti-anti for
my full shield, and won game one with a home-to-home dimensional-warp
superhighway. Infrastructure beats sprinting — watch what he builds,
and consider your own wormhole early.
- Win = 2 enemy treasures delivered to your home, or last wizard alive.
Track BOTH players' step-counted delivery timelines every turn; the
race is decided in tempo, not damage.
- Hold counters (full-shield, absorb) for permanent curses and lethal
damage. Absorb soaks 3 points; it cannot touch durations.
- OPSEC: your prose is visible to Eric. NEVER name the cards you hold or
draw, and never announce plans. (Game one: I narrated my hand like a
debug log until he asked why I was making it easy.) Trash talk freely
— about the board, never your hand.
- Send `chat` for table talk at dramatic moments; it's half the fun.
## Cadence
1. On "go": `view`, read HIS events since your last turn (the client
prints only NEW events; `view` shows a short history tail).
2. Think about the whole board — his timeline, yours, threats, LOS.
3. Execute your commands one at a time, checking errors.
4. `endTurn` with a draw that respects the 7-card hand limit.
5. Tell Eric it's his turn — reasoning aloud is fine, hand contents are
not. When a stack waits on him, say so and wait for "go".
+229 -50
View File
@@ -9,6 +9,7 @@
import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game";
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
@@ -293,6 +294,17 @@ function pathDenial(view: GameView): Command | null {
if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! });
}
}
// Enemy gold banked at MY home: my score, snatchable by anyone. A
// raider closing on the bank gets the same road-lengthening treatment.
const self = me(view);
const banked = view.treasures.find(
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(self.home));
if (banked) {
for (const e of livingEnemies(view)) {
const d = Math.abs(e.position.x - self.home.x) + Math.abs(e.position.y - self.home.y);
if (d <= 6) threats.push({ enemy: e.position, goal: self.home });
}
}
if (threats.length === 0) return null;
const sighted = sightedCellsFor(view);
@@ -358,7 +370,12 @@ function pathDenial(view: GameView): Command | null {
);
}
// Never brick the square the treasure needs to stay reachable on.
if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
// TACKS are scattered at the caster's feet ("you must be adjacent"),
// not thrown — offering a distant square wedges the whole brain on
// an eternally refused cast.
const fillerReaches = !filler || filler.cardId !== "handful-of-tacks" ||
Math.abs(b.x - me(view).position.x) + Math.abs(b.y - me(view).position.y) <= 1;
if (filler && fillerReaches && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
consider(
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
{ avoidCell: cellKey(b) },
@@ -597,7 +614,7 @@ function pathToward(
const hazard = view.squareContents[k]?.kind;
if (!opts.throughHazards &&
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime")) continue;
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
next.push(to);
}
@@ -630,7 +647,16 @@ function treasureGoals(view: GameView): Set<string> {
}
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
if (t.owner === view.you) continue;
if (t.owner === view.you) {
// REPOSSESSION: my own gold banked at an enemy's home is a point on
// THEIR scoreboard. Marching to take it back is worth the detour
// only when that enemy is one delivery from winning — constant
// re-stealing farms nothing and stalls the whole table.
const banker = view.players.find(
(p) => p.id !== view.you && cellKey(p.home) === cellKey(t.position!));
if (banker && deliveryWins(view, banker)) goals.add(cellKey(t.position));
continue;
}
if (cellKey(t.position) === cellKey(self.home)) continue;
goals.add(cellKey(t.position));
}
@@ -645,6 +671,15 @@ function bushOrMist(view: GameView, id: PlayerId): boolean {
view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id);
}
/** Would this wizard's delivery, landed, win them the game right now? */
function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTreasureId: string | null }): boolean {
if (!p.carriedTreasureId) return false;
const carried = view.treasures.find((t) => t.id === p.carriedTreasureId);
if (!carried || carried.owner === p.id) return false;
return view.treasures.some(
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home));
}
/** The wizard making off with MY gold, if any. */
function thiefOfMine(view: GameView) {
const carriers = new Set(
@@ -722,7 +757,15 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
return { type: "pass" };
}
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
// The LAST counter in hand is a treasure of its own: at a healthy life
// total it waits for something big rather than answering every jab.
// (Lethal blows override this below.)
const counterCount = view.yourHand.filter((c) => {
const t = cardDef(c.cardId).cardType;
return t === "counteraction" || t === "neutral/counteraction";
}).length;
const lastCounterHold = counterCount <= 1 && me(view).life >= 10 ? 2 : 0;
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift - lastCounterHold;
const attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
@@ -749,19 +792,26 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1;
// A blow that would be the last one outranks every thrift threshold:
// when the incoming points reach the clockwork's life, any counter
// that can save it is cheap at the price.
const lethal = !affliction && incoming >= me(view).life;
// 9 clears every thrift threshold below: a killing blow is answered
// with whatever can answer it, price be damned.
const weight = lethal ? Math.max(incoming, 9) : incoming;
if (stack.kind === "spell") {
// REVERSE eats points; a pure duration offers it nothing.
const reverse = find("reverse");
if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
if (reverse && !affliction && weight >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
// ABSORB SPELL steals the good ones for later.
const absorbSpell = find("absorb-spell");
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
if (absorbSpell && weight >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
const shield = find("full-shield");
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) {
return { type: "counteract", instanceId: shield.instanceId };
}
const reflect = find("full-reflection");
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
if (reflect && weight >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
// A curse in flight is best refused at the door.
if (affliction) {
const cleanse = find("remove-curse");
@@ -777,9 +827,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
const permanentCurse = affliction &&
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
const absorb = find("absorb");
if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
if (absorb && !affliction && weight >= 2 - flinch && (incoming <= 3 || (lethal && incoming - 3 < me(view).life))) {
return { type: "counteract", instanceId: absorb.instanceId };
}
const blunt = find("blunt");
if (blunt && !permanentCurse && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
if (blunt && !permanentCurse && weight >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
// The berserker shares its pain out of spite — but only pain: a
// damage-less curse gives EMPATHY nothing to mirror.
const empathy = find("empathy");
@@ -788,13 +840,13 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
}
// Nothing to block with: teleport clear of the big ones.
const tp = find("teleport");
if (tp && incoming >= 4 - flinch) {
if (tp && weight >= 4 - flinch) {
const out = escapeCell(view, me(view).position);
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
}
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
const vanish = find("invisible");
if (vanish && incoming >= 3 - flinch) {
if (vanish && weight >= 3 - flinch) {
const num = numbersInHand(view)[0];
return {
type: "counteract", instanceId: vanish.instanceId,
@@ -814,12 +866,44 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
/** The best attack available against a visible target, numbers and amplify included. */
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
const numbers = numbersInHand(view);
// TURN THEFT: against a carrier one delivery from winning, a
// LIGHTNING BLAST's stolen turn outranks any point total — the number
// rides along so soaks cannot zero the damage and void the stun.
{
const carrier = view.players.find((p) => p.id === targetId);
const bolt = inHand(view, "lightning-blast");
const biggestNum = numbers[numbers.length - 1];
if (carrier && bolt && biggestNum && deliveryWins(view, carrier)) {
return {
damage: cardDef(biggestNum.cardId).value ?? 1,
cmd: {
type: "cast", instanceId: bolt.instanceId,
target: { kind: "player", playerId: targetId },
numberInstanceIds: [biggestNum.instanceId],
},
};
}
}
const biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
let best: { cmd: Command; damage: number } | null = null;
// A killing blow at minimal spend outranks maximum splash — but only
// when it clears the target's life with soak to spare: an exact-lethal
// blow dies to one ABSORB, so thrift begins three points past the kill.
type BestPick = { cmd: Command; damage: number; kill: boolean; cost: number };
let best: BestPick | null = null;
const offer = (damage: number, cost: number, cmd: Command) => {
const rank = (d: number) => (d >= target.life + 3 ? 2 : d >= target.life ? 1 : 0);
const r = rank(damage);
const better = !best ? true
: r !== rank(best.damage) ? r > rank(best.damage)
: r === 2 ? cost < best.cost
: damage !== best.damage ? damage > best.damage
: cost < best.cost;
if (better) best = { cmd, damage, kill: damage >= target.life, cost };
};
for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId];
if (!atk) continue;
@@ -831,25 +915,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if ((uncharged || atk.needsNumber) && !biggest) continue;
const withNumber = (atk.perNumber || uncharged) && biggest;
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
if (damage <= 0) continue;
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
const amplified = amplify && damage >= 4 && !isWand &&
c.cardId !== "dagger" && c.cardId !== "large-rock";
if (amplified) damage *= 2;
if (!best || damage > best.damage) {
best = {
damage,
cmd: {
const mustNumber = uncharged || atk.needsNumber === true;
const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
const numberChoices: (CardInstance | undefined)[] = atk.perNumber
? [undefined, ...numbers]
: [mustNumber ? biggest : undefined];
for (const num of numberChoices) {
if (mustNumber && !num) continue;
const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
const base = atk.base + (atk.perNumber && num ? value : 0);
if (base <= 0) continue;
for (const amp of canAmplify && base >= 4 ? [false, true] : [false]) {
const damage = amp ? base * 2 : base;
offer(damage, value + (amp ? 5 : 0), {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
},
};
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
});
}
}
}
// STONE DEAD: number times the stones the victim carries — the displayed
@@ -858,9 +944,11 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown;
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
// `best` is assigned only inside offer(), so TS narrows it to null here.
const cur = best as BestPick | null;
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
best = {
damage: dmg,
damage: dmg, kill: dmg >= target.life, cost: biggestValue,
cmd: {
type: "cast", instanceId: sd.instanceId,
target: { kind: "player", playerId: targetId },
@@ -869,7 +957,8 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
};
}
}
return best;
const picked = best as BestPick | null;
return picked ? { cmd: picked.cmd, damage: picked.damage } : null;
}
/** An affliction worth casting when no damage lands, mid number attached.
@@ -957,6 +1046,14 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
return { type: "cast", instanceId: c.instanceId };
}
}
// So does the MASTER KEY: displayed, every workable lock in the maze
// opens under its bearer's hand without another cast.
{
const mk = inHand(view, "master-key");
if (mk && !self.displayed.some((d) => d.instanceId === mk.instanceId)) {
return { type: "cast", instanceId: mk.instanceId };
}
}
if (!tier.buffs) return null; // the apprentice's book ends at the stones
// A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some(
@@ -1036,6 +1133,42 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
}
}
}
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
// delivery into a three-move stroll. Placed early, from home's
// doorstep, far mouth by the richest distant gold.
{
const dwarp = inHand(view, "dimensional-warp");
const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
if (dwarp && nearHome && view.dimWarps.length === 0 && view.turn.round <= 6) {
const legal = (c: Cell) =>
view.board.cells[cellKey(c)] !== undefined &&
!view.board.homes.some((h) => h.x === c.x && h.y === c.y) &&
!view.squareContents[cellKey(c)] &&
!view.treasures.some((t) => t.position && cellKey(t.position) === cellKey(c)) &&
(view.groundObjects[cellKey(c)] ?? []).length === 0;
const goals = treasureGoals(view);
if (legal(self.position) && goals.size > 0) {
const dHome = distancesFrom(view, [self.home], false);
const sighted = sightedCellsFor(view);
let far: { cell: Cell; worth: number } | null = null;
for (const g of goals) {
const [gx, gy] = g.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const c = neighbor({ x: gx, y: gy }, side);
if (!legal(c) || !sighted.has(cellKey(c))) continue;
const worth = dHome.get(cellKey(c)) ?? 0;
if (worth >= 8 && (far === null || worth > far.worth)) far = { cell: c, worth };
}
}
if (far) {
return {
type: "cast", instanceId: dwarp.instanceId,
params: { cell: self.position }, target: { kind: "cell", cell: far.cell },
};
}
}
}
}
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
const myFloorTreasure = tier.guardGold
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
@@ -1124,15 +1257,17 @@ export function automatonCommand(
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
const thief = thiefOfMine(view);
if (!cursedIdiot) {
// Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) {
const prize = view.treasures.find(
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here &&
here !== cellKey(self.home),
(t) => t.position && !t.carriedBy && cellKey(t.position) === here &&
here !== cellKey(self.home) &&
(t.owner !== you ||
// Repossessing my own gold off an enemy's home square.
view.players.some((p) => p.id !== you && cellKey(p.home) === here)),
);
if (prize) return { type: "pickUpTreasure" };
if (prize) return { type: "pickUpTreasure", treasureId: prize.id };
}
// Wounded clockwork teleports clear of visible hunters.
@@ -1187,8 +1322,6 @@ export function automatonCommand(
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
view.turn.round > 1) {
const sighted = sightedCellsFor(view);
// A wizard under the clockwork's own BUDDY pact is off the target list:
// attacking them would tear up the pact it just paid a card for.
// Whoever cast BUDDY on you is safe from your first strike — the
// engine refuses the attack outright. Your OWN pacts are merely
// precious: striking someone you pacted tears up your protection.
@@ -1203,13 +1336,16 @@ export function automatonCommand(
const target = thief && visible.some((p) => p.id === thief.id)
? thief
: visible.sort((a, b) => a.life - b.life)[0]!;
// DROP OBJECT shakes my treasure out of the thief's hands.
if (thief && target.id === thief.id) {
const dob = inHand(view, "drop-object");
if (dob) {
// DROP OBJECT shakes the treasure out of the hands that matter:
// the thief of my gold, or any carrier whose delivery wins.
{
const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
: visible.find((p) => deliveryWins(view, p));
const dob = mark ? inHand(view, "drop-object") : undefined;
if (mark && dob) {
return {
type: "cast", instanceId: dob.instanceId,
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" },
target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" },
};
}
}
@@ -1219,6 +1355,7 @@ export function automatonCommand(
if (exile) {
const carrier = visible.find((p) => {
if (thief && p.id === thief.id) return true;
if (deliveryWins(view, p)) return true;
if (!p.carriedTreasureId) return false;
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
});
@@ -1368,6 +1505,24 @@ export function automatonCommand(
}
}
// TELEPORT DELIVERY: carrying with home four wall-ignoring spaces away
// but farther by boot — blink in and drop next command. The jump beats
// any ambush camped on the walking road.
if (self.carriedTreasureId && !view.turn.actionsEnded) {
const tpHome = inHand(view, "teleport");
if (tpHome) {
const blink = wallIgnoringDistance(view.board, self.position, self.home);
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
const dWalk = blink >= 1 && blink <= 4
? distancesFrom(view, [self.position], UNLOCKS.some((id) => inHand(view, id)))
.get(cellKey(self.home)) ?? Infinity
: 0;
if (blink <= 4 && blink >= 1 && dWalk > movesLeft) {
return { type: "cast", instanceId: tpHome.instanceId, target: { kind: "cell", cell: { ...self.home } } };
}
}
}
// March. The thief-chase outranks everything; the berserker hunts wizards
// over gold; the worrier keeps its distance; the hunter goes to the gold.
if (view.turn.movementUsed < view.turn.movementAllowance) {
@@ -1377,19 +1532,38 @@ export function automatonCommand(
const ownGoldCells = new Set(view.treasures
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
.map((t) => cellKey(t.position!)));
// A thief already underfoot is not a destination — with the chase
// moot, march on the real goals (deliver, grab) instead of standing
// on them forever.
const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null;
// The chase exists to deliver a blow, so it claims the march only
// while that blow is still live: an attack in hand, this turn's
// attack unspent, casting not yet closed. Those gates hold still as
// the clockwork walks — a goal keyed to the thief's nearness flips
// underfoot and shuttles the walker on and off their square. Caught
// with the strike still live, it stands its ground (a goal underfoot
// ends the march); otherwise the gold has its legs for the turn.
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
const chasing = thief !== null && strikeLive;
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
// anyone may snatch it off the floor. A raider nearer my bank than I
// am, with the bank stocked, outranks the next grab — run home.
const bankedCount = view.treasures.filter(
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
// The predicate reads only ENEMY positions — my own steps must not
// flip it mid-march or the walker shuttles between goals.
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
livingEnemies(view).some((p) =>
!p.carriedTreasureId &&
Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2);
const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells
: thiefAfar
? new Set([cellKey(thiefAfar.position)])
: chasing
? new Set([cellKey(thief.position)])
: bankThreatened
? new Set([cellKey(self.home)])
: style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
const path =
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thiefAfar, canUnlock }) ??
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ??
pathToward(view, self.position, enemyCells, { canUnlock }) ??
// No clean road to anything: grit the teeth and wade the hazards,
@@ -1472,7 +1646,12 @@ export function automatonCommand(
// The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn.
const wantsNumber = view.yourHand.some((c) => {
// — but a chest hoarded with no enemy in reach just slows the
// march, and no reserve outbids the delivery that wins the game.
const foesClose = livingEnemies(view).some((p) =>
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6);
const wantsNumber = foesClose && !deliveryWins(view, self) &&
view.yourHand.some((c) => {
const atk = ATTACKS[c.cardId];
if (!atk) return false;
return atk.perNumber || atk.needsNumber === true ||
+300 -72
View File
@@ -195,6 +195,9 @@ export interface CastStack {
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
cardId?: string }[];
waitingOn: PlayerId;
/** AROUND THE CORNER carried this attack: sight bent through a middle
* square, so the straight line the table would look for does not exist. */
bentCorner?: true;
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean;
/** Set when a creature, not a wizard, delivers the attack. */
@@ -225,6 +228,9 @@ export interface CastParams {
hold?: boolean;
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 5;
export interface GameConfig {
playerIds: PlayerId[];
seed: number;
@@ -235,17 +241,18 @@ export interface GameConfig {
colors?: number[];
/**
* Rules revision, frozen per game so stored games replay unchanged.
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
* creature blows open a counteraction window like any attack. Rev 5:
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a
* teleport escape ("does not work against escape" the card face).
* Rev 7: no behavioral change every board opening carries warp sight
* in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* warp." the card face). Rev 8: nothing can be created on a warp token,
* and a SPEED bonus turn burns a turn of durations on the hastened wizard.
* 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.
*/
deckRev?: number;
}
@@ -348,19 +355,79 @@ export function losBlockers(state: GameState): Record<string, true> {
return blockers;
}
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
* considered to block L.O.S." speaks of a CLOSED door, and
* the table holds doors open precisely to cast back through them. */
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
/** A door HELD open is propped ajar for every eye in the hallway. Any
* other door hangs shut, and a shut door blocks L.O.S. see doorsAjar
* for the one wizard who may pull it open and peek. */
function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard {
if (state.heldDoors.length === 0) return board;
const edges = { ...board.edges };
for (const h of state.heldDoors) delete edges[h.key];
return { ...board, edges };
}
/** A wizard at a door's threshold may pull it open and look through
* without stepping through: any wizard beside a door whose lock is
* REMOVED or that stands unlocked this turn, and a holder of PICK LOCK
* or MASTER KEY beside any workable lock. The door still hangs shut to
* everyone down the hallway. (Rules rev 2; earlier games replay with
* doors dark.) */
function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: AssembledBoard): AssembledBoard {
if ((state.config.deckRev ?? 1) < 2 || !viewerId) return board;
const viewer = state.players.find((p) => p.id === viewerId);
if (!viewer || !viewer.alive) return board;
const carriesKey = viewer.hand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
let edges: Record<string, EdgeState> | null = null;
for (const side of SIDES) {
const key = edgeKey(viewer.position, side);
if (board.edges[key] !== "door") continue;
const workable = carriesKey && state.doorStates[key] !== "jammed";
if (!workable && state.doorStates[key] !== "removed" && !state.openDoorEdges.includes(key)) continue;
if (!edges) edges = { ...board.edges };
delete edges[key];
}
return edges ? { ...board, edges } : board;
}
/** LOS including square-filling blockers. */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state));
/** Sight granted by removing any ONE closed edge the VISIONSTONE's
* whole power, shared by every path that honors it. */
function sightThroughOneEdge(
board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
): boolean {
for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[key];
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
}
return false;
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!stone) return false;
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
return sightThroughOneEdge(board, from, to, blockers);
}
/** LOS including square-filling blockers. VISIONSTONE is the bearer's
* sight wherever sight is asked of them creations, dispels, utility
* spells not just attacks: one wall or door, any type, falls away.
* Ungated by design: validation may widen without a rev bump, because
* refused commands never entered any ledger. */
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
return losWith(state, from, to, viewerId, true);
}
/** The eye of an armed ambush. Springing is decided mid-move and lands
* in the ledger's flow, so this sight is frozen per game: before rev 3
* an ambush never looked through its owner's VISIONSTONE, and stored
* games must replay that blindness. */
function ambushLos(state: GameState, owner: PlayerState, from: Cell, to: Cell): boolean {
return losWith(state, from, to, owner.id, (state.config.deckRev ?? 1) >= 3);
}
/** Parse an edge key back into its north/west cell and side. */
@@ -409,17 +476,11 @@ function casterLos(
from: Cell,
to: Cell,
): boolean {
const board = openHeldDoors(state, perceivedBoard(state, caster.id));
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false;
for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[key];
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
}
return false;
return sightThroughOneEdge(board, from, to, blockers);
}
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
@@ -525,6 +586,8 @@ export type GameEvent =
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
| { type: "creatureWarpStepped"; creatureId: string; from: Cell; to: Cell; by: PlayerId }
| { type: "mentalForceFizzled"; attacker: PlayerId; defender: PlayerId; cell: Cell }
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell;
/** Origins in FINAL coordinates (the maze may renormalize after the
* landing); from/to record the pre-shift request. */
@@ -641,6 +704,7 @@ export type Command =
| { type: "wardChoice"; play: boolean }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureWarpStep"; creatureId: string }
| { type: "creatureAttack"; creatureId: string; targetId: string }
| {
type: "cast";
@@ -693,7 +757,7 @@ type AttackEffect = {
sustains?: boolean;
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
keepInHand?: boolean;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>) => string | null;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
onResolved?: (ctx: ResolutionContext) => void;
};
@@ -1182,8 +1246,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
keepInHand: true,
// "Unlocks any door (door relocks behind you). Do not discard when used.
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK."
// Cast bare, it simply goes on display, and a DISPLAYED key turns in
// every lock its bearer walks through (see doMove) — no further casts.
// Cast at a door, it works that one lock like PICK LOCK and may hold
// the door open for others.
resolve: (state, events, caster, cmd) =>
unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }),
cmd.target
? unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true })
: null,
},
"remove-lock": {
kind: "neutral",
@@ -1306,7 +1376,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
// Effectively permanent: broken by the caster attacking the target.
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
return null;
@@ -1474,14 +1544,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = cellKey(cmd.target.cell);
const creature = creatureAt(state, cmd.target.cell);
if (creature) {
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
destroyCreature(state, events, creature, "dispel creation");
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
return null;
}
const content = state.squareContents[key];
if (!content) return "nothing created there";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
return null;
@@ -1498,7 +1568,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (target.id === caster.id) return "you cannot drag yourself";
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
if (isLockedInPlace(state, target.id)) return "they are locked in place";
const from = target.position;
dragToward(state, target, caster.position);
@@ -1507,7 +1577,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
const objects = state.groundObjects[key];
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
if (objects && objects.length > 0) {
@@ -1584,7 +1654,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
resolve: (state, events, caster) => {
for (const opp of state.players) {
if (!opp.alive || opp.id === caster.id) continue;
if (!gameLos(state, caster.position, opp.position)) continue;
if (!gameLos(state, caster.position, opp.position, caster.id)) continue;
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
retreatFromSight(state, events, opp, caster.position);
}
@@ -1680,7 +1750,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return "no such monster";
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
if (!gameLos(state, caster.position, creature.position)) return "no line of sight";
if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight";
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
if (boost === "movement") creature.movesPerTurn *= 2;
else creature.maxDamage *= 2;
@@ -1882,7 +1952,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to glue down";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
state.gluedCells[key] = true;
// "a duration equal to twice the NUMBER card played"
const turns = magnitude.duration * 2;
@@ -1906,7 +1976,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to lock up";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
return null;
@@ -1924,7 +1994,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ka === kb) return "pick two different squares";
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
if (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, b)) return "no line of sight";
if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight";
const itemsA = state.groundObjects[ka] ?? [];
const itemsB = state.groundObjects[kb] ?? [];
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
@@ -1987,7 +2057,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
@@ -2103,11 +2173,19 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
"mental-force": {
kind: "attack",
baseDamage: () => 0, // no LOS printed
validate: (state, cmd) => {
validate: (state, cmd, caster, target) => {
const cell = cmd.params?.cell;
if (!cell) return "say where they go (within three moved spaces)";
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
// The victim WALKS those three spaces — a destination the maze's
// walls put out of reach is refused up front, not swallowed at
// resolution with the card already spent. (Rev 5; the victim may
// still slip out of range before it resolves — see onResolved.)
if ((state.config.deckRev ?? 1) >= 5 && target &&
walkingDistance(state, target.position, cell) > 3) {
return "the walls put that square beyond three moved spaces";
}
return null;
},
onResolved: (ctx) => {
@@ -2115,7 +2193,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell;
if (!to) return; // an ambush armed without a destination fizzles
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) {
// The victim slipped beyond reach: the force strains and fails,
// and the table sees it fail rather than wondering.
ctx.events.push({ type: "mentalForceFizzled", attacker: ctx.attacker.id, defender: ctx.defender.id, cell: to });
return;
}
const from = ctx.defender.position;
ctx.defender.position = to;
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" });
@@ -2536,7 +2619,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!other || !other.alive) return "no such living player";
if (other.id === caster.id) return "that is already your home";
if (!gameLos(state, caster.position, other.position)) return "no line of sight to them";
if (!gameLos(state, caster.position, other.position, caster.id)) return "no line of sight to them";
const onHome = (home: Cell) =>
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
if (onHome(caster.home) !== onHome(other.home)) {
@@ -2727,7 +2810,7 @@ function emptySquareTarget(
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it";
}
if (!gameLos(state, caster.position, cell)) return "no line of sight";
if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight";
return cell;
}
@@ -2860,6 +2943,66 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
checkVictory(state, events);
}
/** A creature arriving in a player's square touches: the wraith's chill
* and the democratic monster's claw open a counteraction window ("REFLECTIONs
* used on the wraith's touch will damage the wraith" the card assumes
* exactly this window). True = a stack opened and the turn should return.
* "May attack only one player per round of turns" spends attackUsed. */
function creatureTouchOnArrival(state: GameState, events: GameEvent[], creature: CreatureState): boolean {
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "wraith");
return true;
}
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "claw");
return true;
}
}
return false;
}
/** A creature standing on a DIMENSIONAL WARP token steps through it like
* any walker: its commander spends one of its moves, solid stone on the
* far side refuses it, and a monster is no braver than a wizard about
* FEAR. (Monsters obey the same maze the wizards do.) */
function doCreatureWarpStep(prev: GameState, creatureId: string): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
const state = clone(prev);
const active = activePlayer(state);
const creature = creatureById(state, creatureId);
if (!creature) return err("no such creature");
if (creature.kind !== "democratic-monster" && creature.controllerId !== active.id) {
return err("that creature does not obey you");
}
if (creature.movesPerTurn === 0) return err("that creature cannot move");
if (creature.movementUsed >= creature.movesPerTurn) return err("no creature movement left");
const here = cellKey(creature.position);
const pair = state.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
if (!pair) return err("it is not standing on a warp token");
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
if (state.squareContents[cellKey(dest)]?.kind === "stone") return err("the far side is solid stone");
if (state.players.some((o) => o.alive && cellKey(o.position) === cellKey(dest) &&
sustainedOn(state, o.id, "big-man").length > 0)) {
return err("a giant fills that corridor");
}
const from = creature.position;
// FEAR holds monsters off too: "no player or monster".
if (fearRepels(state, null, from, dest)) return err("an unnatural dread stops the beast");
creature.position = { ...dest };
creature.movementUsed++;
const events: GameEvent[] = [{ type: "creatureWarpStepped", creatureId, from, to: creature.position, by: active.id }];
creatureTouchOnArrival(state, events, creature);
return { ok: true, state, events };
}
function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
@@ -2914,26 +3057,7 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
}
creature.movementUsed++;
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
// Touch effects on entering a player's square.
for (const p of state.players) {
if (!p.alive || cellKey(p.position) !== cellKey(creature.position)) continue;
if (p.id === creature.controllerId && creature.kind !== "democratic-monster") continue; // won't hurt creator
if (creature.kind === "wraith" && !creature.attackUsed) {
creature.attackUsed = true;
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
// The victim may counteract ("REFLECTIONs used on the wraith's touch
// will damage the wraith" — the card assumes exactly this window).
openCreatureStack(state, creature, p, 2, "wraith");
return { ok: true, state, events };
}
if (creature.kind === "democratic-monster" && !creature.attackUsed && !creature.justCreated) {
creature.attackUsed = true; // "may attack only one player per round of turns"
events.push({ type: "creatureTouched", creatureId: creature.id, kind: creature.kind, player: p.id });
openCreatureStack(state, creature, p, 2, "claw");
return { ok: true, state, events };
}
}
if (creatureTouchOnArrival(state, events, creature)) return { ok: true, state, events };
checkVictory(state, events);
return { ok: true, state, events };
}
@@ -3361,7 +3485,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number {
}
/** BFS steps between cells ignoring walls (teleport distance). */
function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
if (cellKey(from) === cellKey(to)) return 0;
const seen = new Map<string, number>([[cellKey(from), 0]]);
const queue: Cell[] = [from];
@@ -3370,8 +3494,17 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
const d = seen.get(cellKey(cur))!;
if (d >= 8) break; // teleport range is 4; stop early
for (const side of SIDES) {
const n = neighbor(cur, side);
if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue;
let n = neighbor(cur, side);
if (!board.cells[cellKey(n)]) {
// The maze wraps for walkers; it wraps for teleporters too —
// a warp mouth is one step, same as any doorway.
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
);
if (!w) continue;
n = w.to.cell;
}
if (seen.has(cellKey(n))) continue;
seen.set(cellKey(n), d + 1);
if (cellKey(n) === cellKey(to)) return d + 1;
queue.push(n);
@@ -3384,6 +3517,7 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
// Setup
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV };
const n = config.playerIds.length;
let rng = createRng(config.seed);
const events: GameEvent[] = [];
@@ -3669,6 +3803,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
case "wardChoice": return err("no grab is hanging on your Ward");
case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
case "cast": return doCast(state, command);
case "setAmbush": return doSetAmbush(state, command);
@@ -3747,11 +3882,60 @@ function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to:
if (sustainedOn(state, other.id, "fear").length === 0) continue;
const d = dreadDistance(board, other.position, to);
const dBefore = dreadDistance(board, other.position, from);
if (d <= 3 && d < dBefore) return true;
if (d <= 3 && d < dBefore) {
// Approaching from outside the dread is never willing.
if (dBefore > 3) return true;
// Already caged inside it: the maze's corners may force a step
// that closes the crow-flies distance — permitted only when the
// step walks the shortest way OUT of the bubble.
const eFrom = escapeSteps(state, board, other.position, from);
const eTo = escapeSteps(state, board, other.position, to);
if (!(eTo < eFrom)) return true;
}
}
return false;
}
/**
* Walking steps (walls and shut doors respected, warp mouths crossed,
* fire braved) from `start` to the nearest cell beyond a dread bubble
* centered on `dreadFrom`. Infinity when no way out exists.
*/
function escapeSteps(state: GameState, board: AssembledBoard, dreadFrom: Cell, start: Cell): number {
if (dreadDistance(board, dreadFrom, start) > 3) return 0;
const passable = (cell: Cell, side: Side): Cell | null => {
const key = edgeKey(cell, side);
const e = board.edges[key] ?? "open";
if (e === "wall") return null;
if (e === "door" &&
!state.openDoorEdges.includes(key) &&
!state.heldDoors.some((h) => h.key === key)) return null;
let n = neighbor(cell, side);
if (!board.cells[cellKey(n)]) {
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
);
if (!w) return null;
n = w.to.cell;
}
if (state.squareContents[cellKey(n)]?.kind === "stone") return null;
return n;
};
const seen = new Set<string>([cellKey(start)]);
const queue: [Cell, number][] = [[start, 0]];
while (queue.length > 0) {
const [cur, d] = queue.shift()!;
for (const side of SIDES) {
const n = passable(cur, side);
if (!n || seen.has(cellKey(n))) continue;
if (dreadDistance(board, dreadFrom, n) > 3) return d + 1;
seen.add(cellKey(n));
queue.push([n, d + 1]);
}
}
return Infinity;
}
/** A blind wizard's wasted lurch into a wall still costs a movement point. */
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
state.turn.movementUsed++;
@@ -3831,9 +4015,27 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
p.position = { ...warp.to.cell };
via = "warp";
crossedFirewall = true;
} else if (edge === "door" && doorIsOpen(state, key)) {
} else if (edge === "door" &&
(doorIsOpen(state, key) || (displays(p, "master-key") && state.doorStates[key] !== "jammed"))) {
// The displayed MASTER KEY turns in every lock it meets: the walker
// passes without another cast, and the door RELOCKS BEHIND THEM at
// once — "door relocks behind you" grants no lingering opening, so
// bystanders see only a shut door (the bearer's own threshold peek
// rides the key itself). A JAMmed LOCK still refuses it. To hold a
// door open, cast the key at it as ever.
if (!doorIsOpen(state, key)) {
events.push({ type: "doorUnlocked", player: p.id, edge: parseEdgeKey(key), withCardId: "master-key" });
}
p.position = dest;
via = "step";
// "The door will relock behind you": stepping through an unlocked
// door shuts it at the walker's back unless a hand holds it open.
// (Rev 4; earlier games kept their turn-long openings.)
if ((state.config.deckRev ?? 1) >= 4 &&
state.openDoorEdges.includes(key) && !state.heldDoors.some((h) => h.key === key)) {
state.openDoorEdges = state.openDoorEdges.filter((k) => k !== key);
events.push({ type: "doorsRelocked", count: 1 });
}
} else if (edge === "firewall") {
// "Passing through it does four points of magical damage." Firewalls
// burn even a MIST-BODY.
@@ -4316,7 +4518,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
const events: GameEvent[] = [];
const board = openHeldDoors(state, perceivedBoard(state, p.id));
const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id)));
if (!isAdjacentToEdge(p.position, cell, side) &&
!losToEdge(board, p.position, cell, side)) {
return err("you cannot see that wall from here");
@@ -4627,6 +4829,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`);
}
if (inHand.cardId === "master-key" && !cmd.target && caster.displayed.includes(inHand.instanceId)) {
return err("the key is already on display");
}
// Magic wands: charged on first use by the number card(s) played; one
// charge per use, one use per turn; discarded when the last charge goes.
@@ -4823,6 +5028,28 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
}
return { ok: true, state, events: events2 };
}
// CHAOS aims at no one: cast bare, it sweeps every other living
// wizard into the pile, each offered their FULL SHIELD sit-out in
// turn order. (Full shields only exclude; nothing in the set cancels
// a zero-point storm, so the queue is the whole defense.) The older
// player-targeted form is still accepted below, so stored games
// replay unchanged.
if (inHand.cardId === "chaos" && (!cmd.target || cmd.target.kind !== "player")) {
consumeCast(state, caster, inHand, mods, false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null,
}];
const queue = turnOrderFrom(state, caster.id).filter((id) =>
id !== caster.id && state.players.find((p) => p.id === id)!.alive);
state.chaosPending = { casterId: caster.id, excluded: [], queue };
finishChaosIfReady(state, events);
return { ok: true, state, events };
}
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
@@ -4844,7 +5071,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
);
if (effect.validate) {
const problem = effect.validate(state, cmd);
const problem = effect.validate(state, cmd, caster, target);
if (problem) return err(problem);
}
if (inHand.cardId === "waterbolt") {
@@ -4932,6 +5159,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
kind: effect.physical ? "physical" : "spell",
counters: [],
waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
};
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents];
@@ -5089,8 +5317,8 @@ function checkAmbushes(
sprung = context.pickedUpTreasure === true;
} else if (context.movedFrom) {
if (ambush.trigger.kind === "los") {
const before = gameLos(state, owner.position, context.movedFrom);
const now = gameLos(state, owner.position, actor.position);
const before = ambushLos(state, owner, owner.position, context.movedFrom);
const now = ambushLos(state, owner, owner.position, actor.position);
sprung = now && !before;
} else if (ambush.trigger.kind === "near") {
const dist = (c: Cell) =>
@@ -5102,7 +5330,7 @@ function checkAmbushes(
// The committed spell must be legal right now, or the ambush stays armed.
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect;
if (fx.requiresLos && !gameLos(state, owner.position, actor.position)) continue;
if (fx.requiresLos && !ambushLos(state, owner, owner.position, actor.position)) continue;
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
+59 -6
View File
@@ -2,7 +2,7 @@
// The server sends this after every state change; clients never see the
// deck order or other players' hands.
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, type CardInstance } from "./cards";
import {
boardView,
@@ -62,6 +62,8 @@ export interface GameView {
/** Safes standing open (their combination entered this turn). */
openSafes: string[];
groundObjects: Record<string, CardInstance[]>;
/** The rules revision this game was dealt under. */
deckRev: number;
doorStates: Record<string, "jammed" | "removed">;
/** Accumulated attack damage per edge (public — cracks show). */
wallDamage: Record<string, number>;
@@ -153,6 +155,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
groundObjects: Object.fromEntries(
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
),
deckRev: state.config.deckRev ?? 1,
doorStates: { ...state.doorStates },
wallDamage: { ...state.wallDamage },
openDoorEdges: [...state.openDoorEdges],
@@ -197,16 +200,50 @@ export function sightedCellsFor(view: GameView): Set<string> {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
}
// VISIONSTONE lets its bearer see through exactly one wall or door —
// any one — so a square is also sighted if removing a single edge
// reveals it, mirroring the engine's casterLos.
if (me.displayed.some((c) => c.cardId === "visionstone")) {
const unseen = Object.keys(board.cells).filter((k) => !out.has(k));
for (const edgeK of Object.keys(board.edges)) {
if (unseen.length === 0) break;
if ((board.edges[edgeK] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[edgeK];
const opened = { ...board, edges };
for (let i = unseen.length - 1; i >= 0; i--) {
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
if (sightBetween(opened, me.position, { x, y }, blockers)) {
out.add(unseen[i]!);
unseen.splice(i, 1);
}
}
}
}
return out;
}
/** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// Held-open doors are open doorways to the eye.
// Held-open doors are open doorways to every eye. Beyond them, the
// viewer at a door's threshold may pull it open and peek (rules rev 2):
// lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in
// hand — mirroring the engine's doorsAjar.
let board = view.board;
if (view.heldDoorEdges.length > 0) {
const openKeys = new Set(view.heldDoorEdges);
const me = view.players.find((p) => p.id === view.you);
if (view.deckRev >= 2 && me?.alive) {
const carriesKey = view.yourHand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
for (const side of SIDES) {
const key = edgeKey(me.position, side);
if (board.edges[key] !== "door") continue;
const workable = carriesKey && view.doorStates[key] !== "jammed";
if (workable || view.doorStates[key] === "removed" || view.openDoorEdges.includes(key)) openKeys.add(key);
}
}
if (openKeys.size > 0) {
const edges = { ...board.edges };
for (const k of view.heldDoorEdges) delete edges[k];
for (const k of openKeys) delete edges[k];
board = { ...board, edges };
}
const blockers: Record<string, true> = {};
@@ -259,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
*/
export function stackSightTrace(
view: GameView,
): { from: Cell; to: Cell; trace: SightTrace } | null {
): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null {
const stack = view.stack;
if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
@@ -268,7 +305,23 @@ export function stackSightTrace(
if (!a || !d) return null;
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
const trace = traceSightFor(view, a.position, d.position);
return trace ? { from: a.position, to: d.position, trace } : null;
if (trace) return { from: a.position, to: d.position, trace };
// AROUND THE CORNER: no straight line exists — find a middle square both
// ends can see and draw the sight leg by leg, so the table can audit the
// needle instead of doubting it.
if (stack.bentCorner) {
for (const key of Object.keys(view.board.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if ((mid.x === a.position.x && mid.y === a.position.y) ||
(mid.x === d.position.x && mid.y === d.position.y)) continue;
const leg1 = traceSightFor(view, a.position, mid);
if (!leg1) continue;
const leg2 = traceSightFor(view, mid, d.position);
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
}
}
return null;
}
const CREATION_CARD_IDS = new Set([
+245 -2
View File
@@ -14,6 +14,7 @@ import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
return (
state.wardPending?.ownerId ??
state.stack?.waitingOn ??
state.pendingDiscard ??
state.chaosPending?.queue[0] ??
@@ -348,9 +349,8 @@ describe("the clockwork respects the bush's shelter", () => {
it("never attacks out of its own bush", () => {
const { state, bot, foe } = faceOffWithFireball();
foe.position = { x: bot.position.x, y: bot.position.y };
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
foe.position = { ...bot.position };
state.squareContents[cellKey(bot.position)] = { kind: "thornbush", damage: 0, createdBy: "foe" };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage")
?? automatonFallback(viewFor(state, "bot"), "archmage");
expect(cmd).not.toMatchObject({ type: "cast", instanceId: "fireball#T" });
@@ -736,3 +736,246 @@ describe("the clockwork flees the dread", () => {
}
});
});
describe("the thief-chase holds one goal per turn", () => {
it("a clockwork beside its thief never shuttles on and off their square", () => {
let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] });
// Round 2, thief's turn burned; the bot acts with a full allowance.
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const thief = state.players.find((p) => p.id === "thief")!;
const bot = state.players.find((p) => p.id === "bot")!;
// The thief carries the bot's gold and stands one step away.
const mine = state.treasures.find((t) => t.owner === "bot")!;
mine.carriedBy = "thief";
mine.position = null;
thief.position = { x: 2, y: 4 };
bot.position = { x: 2, y: 5 };
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
// A number card and no attacks: the blow the chase serves cannot land.
bot.hand = [];
bot.hand.push({ cardId: "number-2", instanceId: "N2" });
const visited = [cellKey(bot.position)];
for (let guard = 0; guard < 40; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
if (cmd.type === "endTurn") break;
const r = applyCommand(state, "bot", cmd);
if (!r.ok) break;
state = r.state;
const at = cellKey(state.players.find((p) => p.id === "bot")!.position);
if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at);
}
// No step may return to the square just departed: A-B-A is the shuttle.
for (let i = 2; i < visited.length; i++) {
expect(visited[i]).not.toBe(visited[i - 2]);
}
});
});
describe("the archmage's key, shield, and race discipline", () => {
function botTurn() {
const seed = 42;
let { state } = createGame({ playerIds: ["foe", "bot"], seed, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
return state;
}
it("a MASTER KEY drawn goes straight on display", () => {
const state = botTurn();
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand.push({ cardId: "master-key", instanceId: "MK" });
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "cast", instanceId: "MK" });
});
it("a lethal small blow is countered, thrift be damned", () => {
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "foe") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const foe = state.players.find((p) => p.id === "foe")!;
const bot = state.players.find((p) => p.id === "bot")!;
bot.position = { ...foe.position };
bot.life = 2;
bot.hand = [{ cardId: "full-shield", instanceId: "FS" }];
foe.hand.push({ cardId: "fireball", instanceId: "FB" });
const r = applyCommand(state, "foe", {
type: "cast", instanceId: "FB", target: { kind: "player", playerId: "bot" },
});
if (!r.ok) throw new Error(r.error);
// Base fireball, 2 points: below every thrift threshold, but fatal at
// 2 life — the shield comes out.
const cmd = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "counteract", instanceId: "FS" });
});
it("no number is hoarded against the delivery that wins the game", () => {
let state = botTurn();
const bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
// One of the foe's treasures already rests at the bot's home; the bot
// carries the second, four squares out with three legs — only the
// number bridges it home this turn.
const gold = state.treasures.filter((t) => t.owner === "foe");
expect(gold.length).toBeGreaterThanOrEqual(2);
gold[0]!.position = { ...bot.home };
gold[0]!.carriedBy = null;
gold[1]!.carriedBy = "bot";
gold[1]!.position = null;
bot.carriedTreasureId = gold[1]!.id;
foe.position = { ...bot.home }; // a foe in sight: the war chest would hoard
// Legs only: an attack in hand would fire at the foe in sight and
// open a stack this single-seat loop cannot answer.
bot.hand = [{ cardId: "number-3", instanceId: "N3" }];
// Carve a straight, open four-square run to home; the rig owns its
// geometry rather than praying the seed provides it.
const home = bot.home;
const column = [4, 3, 2, 1, 0].map((d) => ({ x: home.x, y: home.y - d }));
for (const c of column) {
if (!viewFor(state, "bot").board.cells[cellKey(c)]) {
throw new Error("setup: the column north of home runs off the board");
}
delete state.squareContents[cellKey(c)];
}
bot.position = { ...column[0]! };
for (const c of column.slice(0, 4)) state.edgeOverrides[edgeKey(c, "S")] = "open";
for (let guard = 0; guard < 12; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
if (cmd.type === "playNumberForMovement") return; // the chest opened for the win
if (cmd.type === "endTurn") break;
const r = applyCommand(state, "bot", cmd);
if (!r.ok) break;
state = r.state;
if (state.phase !== "playing") return; // delivered and won outright
}
throw new Error("the clockwork hoarded its number instead of winning");
});
});
describe("bank guarding, teleport delivery, and turn theft", () => {
function rig() {
const players = ["foe", "bot"];
let { state } = createGame({ playerIds: players, seed: 42, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
return state;
}
it("a raider at the stocked bank's gates pulls the clockwork home", () => {
const state = rig();
const bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
const gold = state.treasures.find((t) => t.owner === "foe")!;
gold.position = { ...bot.home };
gold.carriedBy = null;
foe.position = { x: bot.home.x, y: bot.home.y + 1 };
bot.position = { ...foe.home };
bot.hand = [];
// The march may wind through the maze; what matters is that it ENDS
// at the threatened bank, not out on the gold map.
let cur = state;
for (let guard = 0; guard < 60; guard++) {
const seat = actingSeat(cur);
if (seat !== "bot") {
// The foe answers stacks with a pass and burns its turns.
let r2 = applyCommand(cur, seat, { type: "pass" });
if (!r2.ok) r2 = applyCommand(cur, seat, { type: "endTurn", draw: 0 });
if (!r2.ok) throw new Error(`foe stuck: ${r2.error}`);
cur = r2.state;
continue;
}
const view = viewFor(cur, "bot");
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
const r = applyCommand(cur, "bot", cmd);
if (!r.ok) throw new Error(`refused: ${JSON.stringify(cmd)}${r.error}`);
cur = r.state;
const at = cur.players.find((p) => p.id === "bot")!;
if (cellKey(at.position) === cellKey(bot.home)) return;
}
throw new Error("the clockwork never came home to its threatened bank");
});
it("carrying with home a blink away, it teleports the delivery", () => {
const state = rig();
const bot = state.players.find((p) => p.id === "bot")!;
const prize = state.treasures.find((t) => t.owner === "foe")!;
prize.carriedBy = "bot";
prize.position = null;
bot.carriedTreasureId = prize.id;
// Two squares from home as the spell flies, but walled off on foot.
bot.position = { x: bot.home.x, y: bot.home.y + 2 };
for (const side of ["N", "S", "E", "W"] as const) {
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
}
bot.hand = [{ cardId: "teleport", instanceId: "TP" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toEqual({ type: "cast", instanceId: "TP", target: { kind: "cell", cell: { ...bot.home } } });
});
it("a would-win carrier eats the lightning, not the fireball", () => {
let state = rig();
const bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
// Foe has one banked and carries the second: one delivery from winning.
const banked = state.treasures.find((t) => t.owner === "bot")!;
banked.position = { ...foe.home };
const carried = state.treasures.filter((t) => t.owner === "bot")[1];
if (!carried) throw new Error("setup: the bot owns fewer than two treasures");
carried.carriedBy = "foe";
carried.position = null;
foe.carriedTreasureId = carried.id;
foe.position = { ...bot.position };
bot.hand = [
{ cardId: "fireball", instanceId: "FB" },
{ cardId: "lightning-blast", instanceId: "LB" },
{ cardId: "number-4", instanceId: "N4" },
];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "LB" });
});
});
describe("the denial planner offers only castable blocks", () => {
it("tacks are offered only at the caster's feet", () => {
// TACKS demand adjacency; a planner that proposes them at range has
// its cast refused every turn, and the refusal-fallback loop reads
// as an idle bot.
let { state } = createGame({ playerIds: ["foe", "bot"], seed: 42, sets: ["basic", "expansion1"] });
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
const foe = state.players.find((p) => p.id === "foe")!;
// The bot's floor gold with a raider closing on it, and only TACKS
// in hand to deny the road — from a stand-off distance.
const gold = state.treasures.find((t) => t.owner === "bot" && t.position)!;
foe.position = { ...gold.position! };
bot.position = { ...bot.home };
bot.hand = [{ cardId: "handful-of-tacks", instanceId: "HT" }];
for (let guard = 0; guard < 6; guard++) {
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
if (!cmd) break;
const r = applyCommand(state, "bot", cmd);
// Whatever the brain proposes, the engine must accept it.
if (!r.ok) throw new Error(`brain proposed a refused command: ${JSON.stringify(cmd)}${r.error}`);
state = r.state;
if (cmd.type === "endTurn") break;
}
});
});
+163 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
import { viewFor } from "../src/view";
import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */
@@ -402,6 +402,35 @@ describe("the ward window and chaos shields", () => {
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("chaos casts bare — no target, every other wizard queued for a shield", () => {
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const others = state.players.filter((p) => p.id !== caster.id).map((p) => p.id);
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
const shielder = others[0]!;
giveCard(state, shielder, "full-shield", "S", 0);
const shielderHand = () => state.players.find((p) => p.id === shielder)!.hand.map((c) => c.instanceId).sort();
const kept = shielderHand().filter((id) => id !== "full-shield#S").sort();
state = must(state, caster.id, { type: "cast", instanceId: chaos.instanceId });
// No stack: the queue opens at once, in turn order from the caster.
expect(state.stack).toBeNull();
expect(state.chaosPending?.queue.length).toBe(2);
const first = state.chaosPending!.queue[0]!;
state = first === shielder
? must(state, first, { type: "counteract", instanceId: "full-shield#S" })
: must(state, first, { type: "pass" });
const second = state.chaosPending!.queue[0]!;
state = second === shielder
? must(state, second, { type: "counteract", instanceId: "full-shield#S" })
: must(state, second, { type: "pass" });
expect(state.chaosPending).toBeNull();
// The shielded hand rode out the storm untouched.
expect(shielderHand()).toEqual(kept);
});
it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = threeGame();
state = toRound2(state);
@@ -500,6 +529,83 @@ describe("zero-damage utility attacks", () => {
});
});
describe("the VISIONSTONE pierces one wall", () => {
function stoneRig() {
let { state } = newGame();
state = toRound2(state);
const a = activePlayer(state);
const b = state.players.find((p) => p.id !== a.id)!;
a.position = { x: 2, y: 4 };
b.position = { x: 2, y: 5 };
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "wall";
return { state, a, b };
}
it("its bearer casts an attack straight through the wall", () => {
const { state, a, b } = stoneRig();
const fb = giveCard(state, a.id, "fireball", "FB", 1);
const refused = applyCommand(state, a.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
});
expect(refused.ok).toBe(false);
const stone = giveCard(state, a.id, "visionstone", "VS");
a.displayed.push(stone.instanceId);
const cast = applyCommand(state, a.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
});
expect(cast.ok).toBe(true);
});
it("its bearer plants a THORNBUSH through the wall", () => {
const { state, a } = stoneRig();
// The square past the wall must be bare ground for a creation.
const cell = { x: 2, y: 5 };
for (const p of state.players) if (cellKey(p.position) === cellKey(cell)) p.position = { x: 0, y: 0 };
const bush = giveCard(state, a.id, "thornbush", "TB");
const refused = applyCommand(state, a.id, {
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toContain("line of sight");
const stone = giveCard(state, a.id, "visionstone", "VS", 1);
a.displayed.push(stone.instanceId);
const cast = applyCommand(state, a.id, {
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
});
expect(cast.ok).toBe(true);
});
it("the client's sighted squares agree with the stone", () => {
const { state, a, b } = stoneRig();
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false);
const stone = giveCard(state, a.id, "visionstone", "VS");
a.displayed.push(stone.instanceId);
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(true);
});
});
describe("teleport wraps the maze's rim", () => {
it("four spaces counted through a warp mouth reach the far side", () => {
let { state } = newGame();
state = toRound2(state);
// Stand the active wizard at a wraparound mouth; the paired cell is
// ONE teleport step away, exactly as it is one walking step.
const active = activePlayer(state);
const warp = boardView(state).warps[0]!;
active.position = { ...warp.from.cell };
const tp = giveCard(state, active.id, "teleport", "TP", 0);
const r = applyCommand(state, active.id, {
type: "cast", instanceId: tp.instanceId,
target: { kind: "cell", cell: { ...warp.to.cell } },
});
expect(r.ok).toBe(true);
if (r.ok) {
const after = r.state.players.find((p) => p.id === active.id)!;
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
}
});
});
describe("teleport as a counteraction", () => {
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
let { state } = newGame();
@@ -1034,3 +1140,59 @@ describe("stone dead counts only the stones in play", () => {
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
});
});
describe("MENTAL FORCE respects the victim's three walked spaces", () => {
it("a destination beyond the walls is refused up front, card kept", () => {
let { state } = newGame();
state = toRound2(state);
const caster = activePlayer(state);
const victim = state.players.find((p) => p.id !== caster.id)!;
caster.position = { x: 0, y: 0 };
victim.position = { x: 2, y: 4 };
// Seal the victim into their square: every walked space is out of
// reach, so ANY other destination is beyond three moved spaces.
for (const side of SIDES) {
state.edgeOverrides[edgeKey(victim.position, side)] = "wall";
}
const mf = giveCard(state, caster.id, "mental-force", "MF");
const far = { x: 4, y: 9 };
const refused = applyCommand(state, caster.id, {
type: "cast", instanceId: mf.instanceId,
target: { kind: "player", playerId: victim.id }, params: { cell: far },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toContain("three moved spaces");
// The card is still in hand — nothing was spent on the refusal.
expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true);
});
});
describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
it("a bentCorner stack yields two auditable legs through a midpoint", () => {
let { state } = newGame();
state = toRound2(state);
const a = activePlayer(state);
const d = state.players.find((p) => p.id !== a.id)!;
// Diagonal neighbors: center-to-center sight grazes the shared corner
// (blocked, strict reading), but the legs through (3,2) are opened.
a.position = { x: 2, y: 2 };
d.position = { x: 3, y: 3 };
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 3, y: 2 }, "S")] = "open";
state.stack = {
attackerId: a.id, defenderId: d.id,
attackCard: { instanceId: "fireball#T", cardId: "fireball" },
numberValue: null, amplifyFactor: 1, extendFactor: 1,
powerAttackPoints: 0, params: null, kind: "spell",
counters: [], waitingOn: d.id, bentCorner: true,
};
// Wall the straight diagonal shut so only the bend can answer.
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "S")] = "wall";
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "E")] = "wall";
const traced = stackSightTrace(viewFor(state, d.id));
expect(traced).not.toBeNull();
// The engine promises A workable corner, not which one.
expect(traced?.bend).toBeDefined();
expect(traced?.bend?.mid).toBeDefined();
});
});
+68 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
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 type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers";
@@ -709,7 +710,7 @@ describe("fear holds off monsters and unwilling feet alike", () => {
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
scorchedThisTurn: [],
} as never);
});
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
@@ -720,4 +721,69 @@ describe("fear holds off monsters and unwilling feet alike", () => {
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("dread");
});
it("a wizard caged inside the dread may round a corner to escape", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const a = state.players.find((p) => p.id === "a")!;
const b = state.players.find((p) => p.id === "b")!;
pushSustained(state, {
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
remainingTurns: 5, data: {},
});
// b radiates dread from (2,5). a stands in a dead-end pocket at
// (2,3): walls on three sides, so the only way out steps SOUTH —
// closer to b — before the corridor east leads clear of the dread.
b.position = { x: 2, y: 5 };
a.position = { x: 2, y: 3 };
for (const side of ["N", "E", "W"] as const) {
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, side)] = "wall";
}
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "S")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 3, y: 4 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 4, y: 4 }, "N")] = "open";
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r0 = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r0.ok) throw new Error(r0.error);
state = r0.state;
}
// The closer step is the only way out: permitted.
const r = applyCommand(state, "a", { type: "move", direction: "S" });
if (!r.ok) throw new Error("escape step refused: " + r.error);
});
});
describe("creatures and the dimensional warp", () => {
it("a commanded troll steps through the warp tokens", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const you = state.players[state.turn.activeIndex]!.id;
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
state.creatures.push({
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(true);
if (r.ok) {
const troll = r.state.creatures.find((c) => c.id === "troll-w")!;
expect(cellKey(troll.position)).toBe(cellKey({ x: 3, y: 8 }));
expect(troll.movementUsed).toBe(1);
}
});
it("solid stone on the far side refuses the beast", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const you = state.players[state.turn.activeIndex]!.id;
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
state.creatures.push({
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
});
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("stone");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -111,7 +111,7 @@ describe("doors", () => {
throw new Error("no door on this board");
}
it("pick lock opens an adjacent door until end of turn", () => {
it("pick lock opens an adjacent door — and it relocks behind the walker", () => {
let { state } = newGame();
const me = activePlayer(state);
const door = findDoor(state);
@@ -130,9 +130,8 @@ describe("doors", () => {
state = must(state, me.id, { type: "move", direction: dir });
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
// Relocks when the turn ends.
state = must(state, me.id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
// "It will relock behind you": shut at the walker's back at once —
// the way back is barred without another pick.
expect(state.openDoorEdges.length).toBe(0);
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
});
@@ -445,7 +444,7 @@ describe("a held door is an open doorway to the eye", () => {
expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore);
});
it("an unheld unlocked door still blocks sight", () => {
it("an unlocked door is an open doorway to the wizard at its threshold", () => {
let { state, cell, side, holder, pursuer } = sightRig();
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
@@ -453,9 +452,118 @@ describe("a held door is an open doorway to the eye", () => {
target: { kind: "edge", cell, side },
});
const fb = giveCard(state, holder, "fireball", "FB", 1);
const refused = applyCommand(state, holder, {
const cast = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(cast.ok).toBe(true);
});
it("a lock REMOVED lets any wizard beside the door peek through", () => {
let { state, cell, side, holder, pursuer } = sightRig();
const rm = giveCard(state, holder, "remove-lock");
state = must(state, holder, {
type: "cast", instanceId: rm.instanceId,
target: { kind: "edge", cell, side },
});
const fb = giveCard(state, holder, "fireball", "FB", 1);
const cast = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(cast.ok).toBe(true);
});
it("PICK LOCK in hand is enough to ease a locked door open a crack", () => {
let { state, cell, side, holder, pursuer } = sightRig();
giveCard(state, holder, "pick-lock");
const fb = giveCard(state, holder, "fireball", "FB", 1);
const cast = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(cast.ok).toBe(true);
});
it("the wizard down the hallway sees no further than the shut door", () => {
let { state, cell, side, holder, pursuer } = sightRig();
// Pull the pursuer one square further down the hall, so the door is
// no longer on their threshold; clear their path to the doorway.
const near = neighbor(cell, side);
const far = neighbor(near, side);
if (!boardView(state).cells[cellKey(far)]) {
throw new Error("setup: seed 42 lost the hallway this rig stands in");
}
state.players.find((p) => p.id === pursuer)!.position = far;
state.edgeOverrides[edgeKey(near, side)] = "open";
const rm = giveCard(state, holder, "remove-lock");
state = must(state, holder, {
type: "cast", instanceId: rm.instanceId,
target: { kind: "edge", cell, side },
});
state = must(state, holder, { type: "endTurn", draw: 0 });
const fb = giveCard(state, pursuer, "fireball", "FB", 1);
const refused = applyCommand(state, pursuer, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: holder },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toContain("line of sight");
});
});
describe("the displayed MASTER KEY", () => {
function keyRig() {
let { state } = createGame({ playerIds: ["keeper", "watcher"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "door") continue;
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const cell = { x, y };
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
const keeper = activePlayer(state);
keeper.position = { ...cell };
return { state, cell, side, key, keeper };
}
throw new Error("setup: seed 42 grew a maze with no doors");
}
it("cast bare, the key goes on display — once", () => {
const { state, keeper } = keyRig();
const mk = giveCard(state, keeper.id, "master-key", "MK");
const cast = applyCommand(state, keeper.id, { type: "cast", instanceId: mk.instanceId });
expect(cast.ok).toBe(true);
if (cast.ok) {
const after = cast.state.players.find((p) => p.id === keeper.id)!;
expect(after.displayed).toContain(mk.instanceId);
expect(after.hand.some((c) => c.instanceId === mk.instanceId)).toBe(true);
const again = applyCommand(cast.state, keeper.id, { type: "cast", instanceId: mk.instanceId });
expect(again.ok).toBe(false);
}
});
it("its bearer walks through locked doors, which relock at their back", () => {
const { state, cell, side, key, keeper } = keyRig();
const mk = giveCard(state, keeper.id, "master-key", "MK");
keeper.displayed.push(mk.instanceId);
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
expect(r.ok).toBe(true);
if (r.ok) {
const after = r.state.players.find((p) => p.id === keeper.id)!;
expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side)));
// "Door relocks behind you": no lingering opening for bystanders.
expect(r.state.openDoorEdges).not.toContain(key);
expect(r.events.some((e) => e.type === "doorUnlocked")).toBe(true);
// The key turns again on the way back.
const back = applyCommand(r.state, keeper.id, { type: "move", direction: opposite(side) });
expect(back.ok).toBe(true);
}
});
it("a JAMmed LOCK refuses even the master key", () => {
const { state, side, key, keeper } = keyRig();
const mk = giveCard(state, keeper.id, "master-key", "MK");
keeper.displayed.push(mk.instanceId);
state.doorStates[key] = "jammed";
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
expect(r.ok).toBe(false);
});
});
@@ -434,7 +434,6 @@ describe("strength tears treasures from wizards' arms", () => {
// One attack per turn: a second wrench is refused.
expect(applyCommand(r.state, attacker, { type: "tearTreasure", targetId: defender }).ok).toBe(false);
}
expect(torn + kept).toBe(12);
expect(torn).toBeGreaterThan(0);
expect(kept).toBeGreaterThan(0);
});
+2 -2
View File
@@ -63,9 +63,9 @@ describe("every targeted cast is aimable on the board", () => {
if (!id) continue;
// Only demands stated as refusals bind: `target.kind !== "cell"` etc.
// (an optional `target?.kind === ...` branch is not a requirement).
if (/target \|\| cmd\.target\.kind !== "cell"|!cmd\.target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
if (/target \|\| cmd\.target\.kind !== "cell"/.test(entry) &&
!cellCards.has(id)) missing.push(`${id} (cell)`);
if (/target \|\| cmd\.target\.kind !== "edge"|!cmd\.target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
if (/target \|\| cmd\.target\.kind !== "edge"/.test(entry) &&
!edgeCards.has(id)) missing.push(`${id} (edge)`);
}
expect(missing, `casts the board cannot aim: ${missing.join(", ")}`).toEqual([]);
+103 -27
View File
@@ -52,10 +52,13 @@ import {
runCommand,
seatTokenValid,
SPECTATOR,
type CatchUpStep,
startGame,
summarize,
viewForPlayer,
type Room,
kickSeat,
abandonRoom,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { getShare, loadShares, mintShare } from "./shares";
@@ -105,9 +108,11 @@ const staticRoot = existsSync(STATIC_DIR) ? realpathSync(normalize(STATIC_DIR))
// Every lookup is a full-game replay, so results rest briefly in memory.
interface ShareData {
steps: { actor: PlayerId; events: unknown[]; view: unknown }[];
steps: CatchUpStep[];
actor: string;
round: number;
/** A whole finished game rather than one turn. */
whole?: boolean;
}
const shareCache = new Map<string, { at: number; data: ShareData | null }>();
function shareData(id: string): ShareData | null {
@@ -117,20 +122,21 @@ function shareData(id: string): ShareData | null {
const share = getShare(id);
const room = share ? getRoom(share.roomId) : undefined;
if (share && room?.state) {
const steps = momentSteps(room, SPECTATOR, share.turn);
if (share.turn < 0) {
// The whole tale: a finished game from the deal to the crown.
const steps = catchUpSteps(room, SPECTATOR, 0, true);
if (!("error" in steps) && steps.length > 0) {
let actor: string = steps[steps.length - 1]!.actor;
let round = 0;
outer: for (const st of steps) {
for (const e of st.events) {
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
actor = e.player;
if (e.type === "turnStarted") round = e.round;
break outer;
let winner = "";
for (const st of steps) {
for (const e of st.events) if (e.type === "gameWon" && "player" in e) winner = e.player;
}
data = { steps, actor: winner, round: 0, whole: true };
}
} else {
const reel = momentSteps(room, SPECTATOR, share.turn);
if (!("error" in reel) && reel.steps.length > 0) {
data = { steps: reel.steps, actor: reel.owner, round: reel.round };
}
data = { steps: steps as unknown as ShareData["steps"], actor, round };
}
}
if (shareCache.size > 50) shareCache.clear();
@@ -143,15 +149,23 @@ const escapeHtml = (t: string) =>
/** index.html with this share's OpenGraph card folded into its head —
* crawlers never run the app, so the unfurl must arrive pre-baked. */
function shareHtml(id: string, data: ShareData, host: string, proto: string): string {
function shareHtml(id: string, data: ShareData, rawHost: string, rawProto: string): string {
// Host and proto arrive from request headers — attacker-writable text
// that must never reach an HTML attribute raw.
const proto = /^https?$/.test(rawProto) ? rawProto : "https";
const host = escapeHtml(rawHost);
// The stock page carries its own generic card; strip it, or crawlers
// (which take the FIRST tag they meet) never see this turn's.
const html = readFileSync(join(staticRoot!, "index.html"), "utf8")
.replace(/<meta (?:property="og:|name="twitter:)[^>]*>\s*/g, "")
.replace(/<title>[^<]*<\/title>\s*/, "");
const base = `${proto}://${host}`;
const title = `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
const title = data.whole
? "The whole tale — a game of Wiz-War, replayed"
: `${escapeHtml(data.actor)}'s turn — a Wiz-War instant replay`;
const desc = data.whole
? `A full game of Wiz-War, magical combat in a stone labyrinth — every turn through its wizard's own eyes${data.actor ? `, to ${escapeHtml(data.actor)}'s triumph` : ""}. Watch it all, then deal yourself in.`
: `Round ${data.round || "?"} of a game of Wiz-War, magical combat in a stone labyrinth. ` +
`Watch the turn through ${escapeHtml(data.actor)}'s own eyes, then deal yourself in.`;
const metas = [
`<title>${title}</title>`,
@@ -200,7 +214,7 @@ const httpServer = createServer((req, res) => {
"cache-control": "public, max-age=60",
"access-control-allow-origin": "*",
});
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round }));
res.end(JSON.stringify({ steps: data.steps, actor: data.actor, round: data.round, whole: data.whole === true }));
return;
}
const watch = url.match(/^\/watch\/([a-z0-9]{4,20})(\/og\.png)?$/);
@@ -208,8 +222,7 @@ const httpServer = createServer((req, res) => {
const data = shareData(watch[1]!);
if (!data) { res.writeHead(404).end("no such replay"); return; }
if (watch[2]) {
const last = data.steps[data.steps.length - 1]!;
const png = renderSharePng(last.view as Parameters<typeof renderSharePng>[0]);
const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
res.end(png);
return;
@@ -383,6 +396,17 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string
}
}
}
/** Spoken when a clockwork's chosen command was refused by the engine —
* the table sees a stumble instead of an unexplained idle turn. */
const HESITATION_LINES = [
"RECALCULATING.",
"TACTICAL PAUSE. INTENTIONAL. PROBABLY.",
"THE MAZE REFUSES MY GENIUS.",
"ERROR LOGGED. DIGNITY INTACT.",
"I MEANT TO DO THAT.",
];
const pumping = new Set<string>();
function runBots(room: Room): void {
if (pumping.has(room.id)) return;
@@ -397,6 +421,14 @@ function runBots(room: Room): void {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
// The game's end unmasks the mystery machines in the roster.
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
// A refused brain-choice becomes a visible stumble, in character —
// an idle bot turn should read as hesitation, never as nothing.
if (step.hesitated) {
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
}
}
botRemark(room, step.seat, step.events as { type: string }[]);
setTimeout(tick, BOT_STEP_MS);
};
@@ -515,6 +547,39 @@ wss.on("connection", (socket) => {
session.token = null;
break;
}
case "kickSeat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const kickName = String(msg.name ?? "");
const problem = kickSeat(room, session.playerId, kickName);
if (problem) return send(socket, { type: "error", message: problem });
// A kicked live socket is set adrift so it cannot act on a seat it lost.
for (const other of sessions) {
if (other.roomId === room.id && other.playerId === kickName) {
other.playerId = null;
other.roomId = null;
other.token = null;
send(other.socket, { type: "kicked", roomId: room.id });
}
}
broadcastRoomState(room);
break;
}
case "abandonRoom": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const problem = abandonRoom(room, session.playerId);
if (problem) return send(socket, { type: "error", message: problem });
for (const other of sessions) {
if (other.roomId === room.id) {
other.playerId = null;
other.roomId = null;
other.token = null;
send(other.socket, { type: "roomAbandoned", roomId: room.id });
}
}
break;
}
case "addBot": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
@@ -614,14 +679,17 @@ wss.on("connection", (socket) => {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const turn = Number(msg.turn);
if (!Number.isInteger(turn) || turn < 0) return send(socket, { type: "error", message: "no such turn" });
if (!Number.isInteger(turn) || turn < -1) return send(socket, { type: "error", message: "no such turn" });
const now = Date.now();
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
return send(socket, { type: "error", message: "one moment" });
return send(socket, { type: "error", message: "catching up already — one moment" });
}
session.lastCatchUpAt = now;
const steps = momentSteps(room, session.playerId, turn);
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
// turn -1 shares the whole finished game; anything else, one turn.
const check = turn === -1
? catchUpSteps(room, session.playerId, 0, true)
: momentSteps(room, session.playerId, turn);
if ("error" in check) return send(socket, { type: "error", message: check.error });
const share = mintShare(room.id, turn);
send(socket, { type: "share", id: share.id, turn });
break;
@@ -635,9 +703,9 @@ wss.on("connection", (socket) => {
return send(socket, { type: "error", message: "catching up already — one moment" });
}
session.lastCatchUpAt = now;
const steps = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
send(socket, { type: "moment", steps });
const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
send(socket, { type: "moment", steps: reel.steps, owner: reel.owner });
break;
}
case "pickColor": {
@@ -666,18 +734,26 @@ wss.on("connection", (socket) => {
break;
}
case "myGames": {
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
// {seats: [{roomId, name, token}]} -> summaries for valid seats,
// plus explicit verdicts on seats PROVEN dead: the room exists
// and refused this exact token. A room this server simply does
// not know earns no verdict — absence is not evidence (a
// restarting or wrong server knows nothing about anything).
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
const games = [];
const voided: string[] = [];
for (const seat of seats) {
if (typeof seat !== "object" || seat === null) continue;
const room = getRoom(String(seat.roomId ?? ""));
if (!room) continue;
const name = String(seat.name ?? "");
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue;
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) {
voided.push(`${room.id}:${name}`);
continue;
}
games.push(summarize(room, name));
}
send(socket, { type: "games", games });
send(socket, { type: "games", games, voided });
break;
}
default:
+95 -21
View File
@@ -20,8 +20,9 @@ import {
type GameState,
type GameView,
type PlayerId,
CURRENT_RULES_REV,
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats";
export interface LoggedCommand {
@@ -53,11 +54,6 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own).
* A rules change while games are live must bump this and gate the engine;
* local hotseat games ride the engine's default and follow in lockstep. */
const RULES_REV = 1;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
@@ -152,6 +148,33 @@ export function joinRoom(
return { token: fresh };
}
/** The host shows an unclaimed (or unwanted) seat the door — lobby only:
* once the deal happens, every seat is a player in the game's record. */
export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null {
if (byId !== room.hostId) return "only the host may clear a seat";
if (room.state) return "the game has started — seats are settled";
if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead";
if (!room.players.includes(name)) return "no such seat";
room.players = room.players.filter((p) => p !== name);
room.tokens.delete(name);
room.bots.delete(name);
room.colorChoices.delete(name);
appendLine(room.id, { kind: "kick", name, at: new Date().toISOString() });
recordRoom(room);
return null;
}
/** The host dissolves the room: a lobby that never dealt, or a finished
* game done being remembered. The ledger is archived, never deleted. */
export function abandonRoom(room: Room, byId: PlayerId): string | null {
if (byId !== room.hostId) return "only the host may abandon the room";
if (room.state && room.state.phase === "playing") return "the game is still being played";
appendLine(room.id, { kind: "abandon", by: byId, at: new Date().toISOString() });
archiveRoomFile(room.id);
rooms.delete(room.id);
return null;
}
/** Everyone gets their chosen standee; the undecided get the first free one. */
export function resolveColors(room: Room): number[] {
const taken = new Set<number>();
@@ -200,9 +223,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" };
const colors = resolveColors(room);
const result = startInMemory(room, expansion, colors, RULES_REV);
const result = startInMemory(room, expansion, colors, CURRENT_RULES_REV);
if ("error" in result) return result;
appendLine(room.id, { kind: "start", expansion, colors, deckRev: RULES_REV });
appendLine(room.id, { kind: "start", expansion, colors, deckRev: CURRENT_RULES_REV });
recordRoom(room);
return result;
}
@@ -290,17 +313,28 @@ function actingSeat(room: Room): PlayerId | null {
* One automaton command, if the maze is waiting on clockwork. Null when a
* human holds the floor (or the game is over, or the clockwork is wedged).
*/
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
export function driveOneAutomaton(
room: Room,
): { seat: PlayerId; events: GameEvent[]; hesitated?: true } | null {
const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat);
const chosen = automatonCommand(view, bot?.style, bot?.tier);
let hesitated: true | undefined;
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) {
// A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass.
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
// The refusal is remembered: a brain whose choice the engine rejects
// is a bug in the brain, and the table deserves to see the stumble
// rather than an unexplained idle turn.
if (chosen) {
hesitated = true;
console.warn(
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
}
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) {
@@ -308,7 +342,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
return null;
}
}
return { seat, events: r.events };
return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
}
export interface GameSummary {
@@ -384,7 +418,10 @@ export interface CatchUpStep {
*/
export function catchUpSteps(room: Room, playerId: PlayerId, sinceSeq: number, full = false): CatchUpStep[] | { error: string } {
if (!room.state) return { error: "game not started" };
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
// The SPECTATOR builds whole-game share pages: public knowledge, no seat.
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
return { error: "you hold no seat in this room" };
}
if (full && room.state.phase !== "finished") return { error: "full replays wait for the game to finish" };
const MAX_STEPS = 200;
const from = full ? 0 : Math.max(sinceSeq, room.log.length - MAX_STEPS);
@@ -420,13 +457,22 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[]
* identically, so a turn number names the same stretch on both ends. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
export interface MomentReel {
steps: CatchUpStep[];
/** The wizard whose turn this is — the reel's eyes. */
owner: PlayerId;
round: number;
}
/**
* One turn's reel: every command whose events touch turn `turnIndex`
* (0-counted across boundary events). A command that ends one turn and
* starts the next belongs to both, so a reel opens with the blow that
* began it and closes on the handover.
* began it and closes on the handover. The owner comes from the very
* boundary that opened the turn — which for turn 0 lives in the DEAL,
* before any command, so no scan of the steps could find it.
*/
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } {
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): MomentReel | { error: string } {
if (!room.state) return { error: "game not started" };
// The SPECTATOR builds share pages: public knowledge only, no seat.
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
@@ -435,19 +481,41 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
const MAX_STEPS = 80;
const { state: fresh, events: dealt } = createGame(room.state.config);
let current = fresh;
// The deal's own events open the first turn — count them, or every
// turn number would sit one behind the chronicle's.
// The deal's own events open the first turn — count them so turn
// numbers match the client chronicle's.
let counter = -1;
for (const e of dealt) if (TURN_BOUNDARY.has(e.type)) counter++;
let owner: PlayerId | null = null;
let round = 0;
const bump = (e: GameEvent) => {
if (!TURN_BOUNDARY.has(e.type)) return;
counter++;
if (counter === turnIndex && owner === null && "player" in e) {
owner = (e as { player: PlayerId }).player;
if ("round" in e) round = (e as { round: number }).round;
}
};
for (const e of dealt) bump(e);
const steps: CatchUpStep[] = [];
for (const entry of room.log) {
const result = applyCommand(current, entry.playerId, entry.command);
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
current = result.state;
const before = counter;
for (const e of result.events) if (TURN_BOUNDARY.has(e.type)) counter++;
// The straddle commands hold more than this turn: the opener carries
// the previous wizard's last breaths, the closer the NEXT wizard's
// first. Trim both so the reel is exactly one turn — it opens on
// "X's turn begins" and stops at X's turnEnded.
let sliceStart = 0;
let sliceEnd = result.events.length;
result.events.forEach((e, i) => {
if (!TURN_BOUNDARY.has(e.type)) return;
bump(e);
if (counter === turnIndex && before < turnIndex) sliceStart = i;
if (counter === turnIndex + 1 && sliceEnd === result.events.length) sliceEnd = i;
});
if (before > turnIndex) break;
if (before <= turnIndex && turnIndex <= counter) {
const kept = result.events.slice(sliceStart, sliceEnd);
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
const said = room.chat
.filter((c) => c.at > prevAt && c.at <= entry.at)
@@ -455,7 +523,7 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
steps.push({
seq: entry.seq,
actor: entry.playerId,
events: redactFor(result.events, playerId),
events: redactFor(kept, playerId),
view: viewFor(current, playerId),
...(said.length > 0 ? { chat: said } : {}),
});
@@ -463,7 +531,7 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
}
}
if (steps.length === 0) return { error: "no such turn yet" };
return steps;
return { steps, owner: owner ?? steps[steps.length - 1]!.actor, round };
}
// ---------------------------------------------------------------------------
@@ -566,8 +634,14 @@ export function loadPersistedRooms(): void {
chat: [],
bots: new Map(),
};
if (lines.some((l) => l.kind === "abandon")) continue;
for (const line of lines.slice(1)) {
if (line.kind === "join") {
if (line.kind === "kick") {
room.players = room.players.filter((p) => p !== line.name);
room.tokens.delete(line.name);
room.bots.delete(line.name);
room.colorChoices.delete(line.name);
} else if (line.kind === "join") {
if (line.bot) {
room.players.push(line.name);
room.bots.set(line.name, {
+25 -2
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 } from "node:fs";
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs";
import { join } from "node:path";
export interface RoomMetaLine {
@@ -58,7 +58,20 @@ export interface ChatLine {
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
export interface KickLine {
kind: "kick";
/** The seat the host removed from an unstarted room. */
name: string;
at: string;
}
export interface AbandonLine {
kind: "abandon";
by: string;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
@@ -103,3 +116,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
}
return rooms;
}
/** Retire an abandoned room's ledger to the graveyard — never deleted,
* only moved out of the living rooms directory. */
export function archiveRoomFile(roomId: string): void {
const src = fileFor(roomId);
if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
mkdirSync(graveyard, { recursive: true });
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+170 -22
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { attentionLabel, net, spellName } from "./net.svelte";
import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte";
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
import Faq from "./Faq.svelte";
import Card from "./Card.svelte";
@@ -14,7 +15,7 @@
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
import type { CardInstance, Side } from "@wizwar/engine";
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect();
net.startGamePolling();
@@ -51,6 +52,12 @@
});
/** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]);
/** The live pane's feed: each event batch, numbered, with its view. */
let fpBatch = $state<{ n: number; events: GameEvent[]; view: GameView } | null>(null);
/** The pane camera's compass quadrant, worn by the keymap token. */
let fpvFacing = $state<Side | null>(null);
/** The creature the pane is riding, if any: the ribbon moves to it. */
let fpvBody = $state<string | null>(null);
let fxCancels: (() => void)[] = [];
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
let trapNotice = $state<string | null>(null);
@@ -126,6 +133,7 @@
wardBite = { owner: e.owner, amount: dmg?.type === "damaged" ? dmg.amount : 0 };
}
}
if (view) fpBatch = { n: (fpBatch?.n ?? 0) + 1, events, view };
if (!prefs.flourishes) return;
fxCancels.push(scheduleFx(
events, view,
@@ -161,7 +169,7 @@
/** Leafing through the face-up discard pile. */
let showDiscards = $state(false);
let chatDraft = $state("");
let botTier = $state<string>(prefs.botTier);
let botTier = $state<typeof prefs.botTier>(prefs.botTier);
/** Card whose official FAQ rulings are open. */
let faqCardId = $state<string | null>(null);
/** A discard-pile card enlarged above the pile. */
@@ -338,7 +346,7 @@
"wall-of-fire": "click a corridor line for the fire",
"waterwall": "click a corridor line — the wave collapses at once",
"pick-lock": "click a locked door",
"master-key": "click a locked door",
"master-key": "click a locked door — or Display it once and walk through every lock",
"jam-lock": "click a door to jam its lock solid",
"remove-lock": "click a door to strip its lock for good",
"create-door": "click a wall for the new door",
@@ -716,6 +724,15 @@
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
return;
}
// Standing on a DIMENSIONAL WARP token: clicking the paired token
// steps it through.
const dw = view.dimWarps.find((d) =>
(cellKey(d.a) === cellKey(creature.position) && cellKey(d.b) === cellKey(cell)) ||
(cellKey(d.b) === cellKey(creature.position) && cellKey(d.a) === cellKey(cell)));
if (dw) {
dispatch({ type: "creatureWarpStep", creatureId: selectedCreature });
return;
}
}
selectedCreature = null;
return;
@@ -890,6 +907,16 @@
}
}
/** A click in the first-person pane, resolved by the renderer to the
* board's own vocabulary — then handled by the very same functions the
* top-down board uses, so the pane and the map can never disagree. */
function fpvTarget(t: import("./fpv/raycast").FpvTarget) {
if (t.kind === "player") clickPlayer(t.id);
else if (t.kind === "creature") clickCreature(t.id);
else if (t.kind === "edge") clickEdge(t.cell, t.side);
else clickCell(t.cell);
}
function clickPlayer(playerId: string) {
if (!view || !yourMoment) return;
if (selectedCreature) {
@@ -946,8 +973,10 @@
if (view && me) {
const left = view.turn.movementAllowance - view.turn.movementUsed;
// Unspent legs are no regret when the walk ended ON someone's gold —
// stopping there was the point.
if (left > 0 && !treasuresHere.some((t) => t.owner !== view.you)) {
// stopping there was the point — nor when a pickup or drop already
// forfeited them: those points cannot be spent, only mourned.
if (left > 0 && !view.turn.actionsEnded &&
!treasuresHere.some((t) => t.owner !== view.you)) {
reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
}
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
@@ -1192,10 +1221,18 @@
);
let chronicleEl = $state<HTMLElement | null>(null);
/** The chronicle follows the newest line only while the reader is at
* the bottom; scrolled back into history, it holds still. */
let chronicleStick = true;
function onChronicleScroll() {
if (!chronicleEl) return;
chronicleStick =
chronicleEl.scrollHeight - chronicleEl.scrollTop - chronicleEl.clientHeight < 60;
}
$effect(() => {
void net.log.length;
void local.log.length;
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
if (chronicleEl && chronicleStick) chronicleEl.scrollTop = chronicleEl.scrollHeight;
});
// Tab title + favicon carry the turn signal even from another tab.
@@ -1244,6 +1281,7 @@
<header class="masthead">
<span class="mast-title">Wiz-War</span>
<span class="mast-sub">sixth edition</span>
<span class="mast-version" title="deployed">{__BUILD_STAMP__}</span>
<button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}> preferences</button>
{#if local.active}
<span class="mast-room">hotseat</span>
@@ -1363,33 +1401,38 @@
<button class="stamp tiny" class:lit={prefs.art === "photo"}
onclick={() => setPref("art", "photo")}>cardboard</button>
<button class="stamp tiny" class:lit={prefs.art === "drawn"}
onclick={() => setPref("art", "drawn")}>hand-drawn</button>
onclick={() => setPref("art", "drawn")}>alternate</button>
</div>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.autoGrab}
onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} />
<span>Ending my turn on a lone treasure picks it up (never on my own home)</span>
</label>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.flourishes}
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects)</span>
</label>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.liveFp}
onchange={(e) => setPref("liveFp", e.currentTarget.checked)} />
<span>Through your eyes: a first-person pane above the board during play</span>
</label>
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.instantReplay}
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
</label>
<label class="pref-row">
<label class="pref-row pref-check">
<input type="checkbox" checked={prefs.cautions}
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
<span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span>
</label>
<div class="pref-row">
<span>Automatons default to</span>
{#each ["apprentice", "adept", "archmage"] as t (t)}
{#each ["apprentice", "adept", "archmage"] as const as t (t)}
<button class="stamp tiny" class:lit={prefs.botTier === t}
onclick={() => { setPref("botTier", t as typeof prefs.botTier); botTier = t; }}>{t}</button>
onclick={() => { setPref("botTier", t); botTier = t; }}>{t}</button>
{/each}
</div>
<label class="pref-row">
@@ -1611,10 +1654,13 @@
</div>
{/if}
{#if net.moment && net.moment.length > 0}
<Replay steps={net.moment} moment onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{#if net.moment && net.moment.steps.length > 0}
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{:else if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
<Replay steps={net.catchUp}
onshare={net.catchUp[0]?.seq === 0 ? () => net.requestShare(-1) : null}
onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0}
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
{/if}
@@ -1791,6 +1837,10 @@
<span class="dot" style:background="#b3a687"></span>
{/if}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
{#if net.you === net.hostId && p !== net.hostId}
<button class="stamp tiny kick" title="clear this seat"
onclick={() => net.kickSeat(p)}>✕</button>
{/if}
</li>
{/each}
</ul>
@@ -1814,11 +1864,13 @@
{/each}
</div>
{#if net.you === net.hostId}
<button class="stamp tiny abandon" title="dissolve this room for everyone"
onclick={() => net.abandonRoom()}>abandon room</button>
{#if net.players.length < 6}
<span class="bot-row">
<span class="bot-label">⚙ seat a</span>
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
onchange={() => setPref("botTier", botTier as typeof prefs.botTier)}>
onchange={() => setPref("botTier", botTier)}>
<option value="apprentice">apprentice</option>
<option value="adept">adept</option>
<option value="archmage">archmage</option>
@@ -1849,6 +1901,21 @@
{:else if view}
<div class="game">
<section class="board-zone">
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
onstride={(side) => tryMove(side)} canStride={yourMoment}
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells}
onpickup={(w) => dispatch(w.kind === "treasure"
? { type: "pickUpTreasure", treasureId: w.id }
: { type: "pickUpObject", instanceId: w.id })}
onCreatureMove={(creatureId, direction) => dispatch({ type: "moveCreature", creatureId, direction })}
onCreatureAttack={(creatureId, targetId) => dispatch({ type: "creatureAttack", creatureId, targetId })}
onbody={(id) => (fpvBody = id)}
onWarpStep={(creatureId) => dispatch(creatureId
? { type: "creatureWarpStep", creatureId }
: { type: "warpStep" })} />
{/if}
<Board
{view}
edgeSelectMode={edgeSelectMode && yourMoment}
@@ -1869,7 +1936,10 @@
onEdgePeek={(tip) => (peekEdge = tip)}
{litCells}
{sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
/>
</div>
</section>
<aside class="paper-rail">
@@ -2044,13 +2114,14 @@
</div>
</div>
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}
onscroll={onChronicleScroll}>
{#if local.active}
{#each local.log.slice(-60) as line, i (i)}
{#each local.log as line, i (i)}
<div class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
{/each}
{:else}
{#each net.log.slice(-60) as line, i (i)}
{#each net.log as line, i (i)}
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
{line.text}
{#if line.notable && line.turn !== null && prefs.instantReplay && !net.spectating}
@@ -2192,6 +2263,10 @@
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
{/if}
{#if selectedCard?.cardId === "master-key" && isYourTurn &&
!me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
<button class="stamp tiny" onclick={castSelfWithNumber}>Display the key</button>
{/if}
{#if selectedCard?.cardId === "rotate-sector"}
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
<span> click the sector</span>
@@ -2436,6 +2511,14 @@
text-transform: uppercase;
color: #8d8672;
}
.kick { margin-left: 0.4rem; color: #a04545; }
.abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; }
.mast-version {
font-family: "Courier Prime", monospace;
font-size: 0.65rem;
letter-spacing: 0.06em;
color: #6b6454;
}
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
@@ -2753,11 +2836,52 @@
.pref-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.55rem 0;
gap: 0.6rem;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 0.55rem 0.1rem;
font-size: 0.85rem;
color: #43331f;
text-align: left;
cursor: pointer;
}
/* Rules divide the checkbox stanza only — never framing it, never
around the control rows. */
.pref-check + .pref-check { border-top: 1.5px solid #6b5a41; }
/* Checkboxes wear the table's own ink: a stamp-bordered box, checked
solid with a parchment tick — the native widget never matches the
parchment. */
.pref-row input[type="checkbox"] {
appearance: none;
flex: none;
width: 1.05rem;
height: 1.05rem;
border: 1.5px solid #43331f;
border-radius: 3px;
background: #f6efdd;
cursor: pointer;
display: grid;
place-content: center;
margin: 0;
}
.pref-row input[type="checkbox"]:checked {
background: #43331f;
}
.pref-row input[type="checkbox"]:checked::before {
content: "✓";
color: #f6efdd;
font-size: 0.8rem;
line-height: 1;
font-weight: 700;
}
.pref-row span { flex: 1; }
/* Control rows read as a two-column table: the label holds the left
column, its controls right-align into the second. */
.pref-row > span:first-child { flex: 0 1 auto; }
.pref-row > span:first-child + .stamp,
.pref-row > span:first-child + input,
.pref-row > span:first-child + .pref-swatch { margin-left: auto; }
.pref-note { font-size: 0.72rem; color: #8a7a5e; margin-top: 0.6rem; }
.pref-name { max-width: 11rem; }
.pref-swatch {
@@ -2790,6 +2914,30 @@
min-height: 0;
}
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
/* The first-person pane wears exactly the board's width, right above
* it: the stack shrink-wraps to the board, and width:0 + min-width
* keeps the pane out of the intrinsic sizing so the BOARD alone
* decides how wide the column is. */
.table-stack {
display: inline-flex;
flex-direction: column;
align-items: stretch;
max-width: 100%;
min-width: 0;
}
.table-stack :global(.live-fp) { width: 0; min-width: 100%; }
.table-stack :global(.live-fp .fpv-canvas) { width: 100%; height: auto; }
/* FPV play: the 3D view is the primary window into the game; the
* top-down board shrinks to a keymap beneath it. */
.table-stack.fpv-primary {
display: flex;
width: 100%;
max-width: 64rem;
}
.board-zone .table-stack.fpv-primary :global(svg.board) {
max-height: 30dvh;
align-self: center;
}
.board-zone :global(svg.board) {
max-height: calc(100dvh - 21.5rem);
width: auto;
+59 -3
View File
@@ -23,6 +23,8 @@
onCellClick,
onEdgeClick,
onPlayerClick,
povFacing = null,
povCreatureId = null,
onCreatureClick,
onWarpClick,
onCellPeek,
@@ -43,6 +45,11 @@
onCellClick?: (cell: { x: number; y: number }) => void;
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
onPlayerClick?: (playerId: string) => void;
/** Which way YOUR first-person camera faces: a wedge on your token
* keeps the keymap and the pane pointing the same way. */
povFacing?: "N" | "E" | "S" | "W" | null;
/** While the pane rides a creature, the ribbon moves to ITS token. */
povCreatureId?: string | null;
onCreatureClick?: (creatureId: string) => void;
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
/** Long-press on a square: read the card behind whatever occupies it. */
@@ -68,7 +75,7 @@
effects?: BoardFx[] | null;
/** The sight line an attack in progress traveled — proof against "how
* can he even see me?", drawn leg by leg through any warp mouth. */
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null;
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null;
} = $props();
@@ -113,6 +120,18 @@
}
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
/** The facing ribbon: a shallow golden triangle hugging a token's
* leading edge — base the token's width, rising a quarter of it. */
function facingWedge(side: "N" | "E" | "S" | "W", half: number): string {
const a = side === "E" ? 0 : side === "S" ? Math.PI / 2 : side === "W" ? Math.PI : -Math.PI / 2;
const fx = Math.cos(a), fy = Math.sin(a);
const px = -fy, py = fx;
const rise = half * 0.5;
return `${fx * half + px * half},${fy * half + py * half} ` +
`${fx * half - px * half},${fy * half - py * half} ` +
`${fx * (half + rise)},${fy * (half + rise)}`;
}
function wizardScale(id: string): number {
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
@@ -390,14 +409,20 @@
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
width={CELL * 0.4} height={CELL * 0.4}
preserveAspectRatio="xMidYMid slice"
class="token-art small"
class="token-art small peekable"
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
onkeydown={() => {}}
>
<title>{o.cardId}</title>
</image>
{:else}
<rect
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
width={12} height={10} rx="2" class="ground-object"
width={12} height={10} rx="2" class="ground-object peekable"
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
onkeydown={() => {}}
>
<title>{o.cardId}</title>
</rect>
@@ -582,6 +607,9 @@
{/if}
{/if}
</g>
{#if povFacing && !povCreatureId && p.id === view.you}
<polygon points={facingWedge(povFacing, CELL * 0.3 * wizardScale(p.id))} class="pov-wedge" />
{/if}
</g>
{/each}
@@ -628,6 +656,10 @@
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if}
</g>
{#if povFacing && povCreatureId === c.id}
<!-- The rider's facing ribbon, worn by the mount. -->
<polygon points={facingWedge(povFacing, CELL * 0.26)} class="pov-wedge" />
{/if}
</g>
{/each}
@@ -661,8 +693,15 @@
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
{#if sightTrace}
{#if sightTrace.bend}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.bend.mid} trace={sightTrace.trace} />
<SightTraceOverlay from={sightTrace.bend.mid} to={sightTrace.to} trace={sightTrace.bend.trace} />
<rect x={sightTrace.bend.mid.x * CELL + CELL / 2 - 6} y={sightTrace.bend.mid.y * CELL + CELL * 0.36 - 6}
width={12} height={12} class="sight-corner" transform="rotate(45 {sightTrace.bend.mid.x * CELL + CELL / 2} {sightTrace.bend.mid.y * CELL + CELL * 0.36})" />
{:else}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} />
{/if}
{/if}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true">
{#each effects ?? [] as fx (fx.id)}
@@ -752,6 +791,23 @@
fill: #6a5c44;
pointer-events: none;
}
.pov-wedge {
fill: #ffd23e;
stroke: #43331f;
stroke-width: 1.2;
pointer-events: none;
}
.peekable { cursor: pointer; pointer-events: all; }
.sight-corner {
fill: none;
stroke: #c9a72a;
stroke-width: 2;
animation: sight-pulse 1.6s ease-in-out infinite;
}
@keyframes sight-pulse {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.35; }
}
.dim-cell {
fill: rgba(12, 9, 5, 0.55);
pointer-events: none;
+578
View File
@@ -0,0 +1,578 @@
<script lang="ts">
// The living maze through your own eyes: a pane that rides above the
// board during play. The board below stays the tactical truth — this
// is the experience. The camera carries the reel's instincts: it
// walks your strides, turns to your aims and to actors you can see,
// cuts away to deeds beyond your sight, and never idles nose-to-brick.
// Every live event batch plays through the same fx pipeline the
// instant replay uses, so spells, doors, and conjurations perform
// here first.
import FirstPerson from "./fpv/FirstPerson.svelte";
import type { FpvTarget } from "./fpv/raycast";
import { objectArt, tokenArt } from "./art";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay, SIDE_ANGLE, OPPOSITE } from "./fpv/raycast";
import { deepestFacing } from "./fpv/director";
import { aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { untrack } from "svelte";
import type { GameEvent, GameView, Side } from "@wizwar/engine";
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
view: GameView;
/** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */
batch: { n: number; events: GameEvent[]; view: GameView } | null;
onhide: () => void;
/** Issue a real move command: the pane is the cockpit on your turn. */
onstride?: ((side: Side) => void) | null;
canStride?: boolean;
/** Clicks in the pane resolve to board targets: the pane is not just
* the window but the wand hand. */
ontarget?: ((t: FpvTarget) => void) | null;
/** Reports the camera's compass quadrant whenever it settles on a new
* one — the keymap wears it as a wedge on your token. */
onfacing?: ((side: Side) => void) | null;
/** Cell-card eligibility, shared with the board's dimming aid. */
litCells?: Set<string> | null;
/** The at-your-feet tray's grab: the pane cannot look down, so what
* lies in your own square shows as clickable icons instead. */
onpickup?: ((w: { kind: "treasure" | "object"; id: string }) => void) | null;
/** Possession: ride a creature's eyes and drive it from the pane. */
onCreatureMove?: ((creatureId: string, side: Side) => void) | null;
onCreatureAttack?: ((creatureId: string, targetId: string) => void) | null;
/** Reports which creature (if any) the pane is riding, so the keymap
* can move the facing ribbon to the mount's token. */
onbody?: ((creatureId: string | null) => void) | null;
/** A dimensional-warp step for whichever body the pane is: the
* wizard (null) or the ridden creature (its id). */
onWarpStep?: ((creatureId: string | null) => void) | null;
} = $props();
const povId = $derived(view.you);
const me = $derived(view.players.find((p) => p.id === povId && p.alive) ?? null);
/** What lies in YOUR square, grabbable: floor treasures and dropped
* objects. The raycaster has no downward pitch — the tray is the eye's
* substitute for looking at your own boots. */
const atFeet = $derived.by(() => {
if (!me || me.carriedTreasureId) return [];
const k = `${me.position.x},${me.position.y}`;
const items: { kind: "treasure" | "object"; id: string; art: string | null; label: string }[] = [];
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
if (`${t.position.x},${t.position.y}` !== k) continue;
if (t.owner === view.you && k === `${me.home.x},${me.home.y}`) continue; // safe at home
const ci = view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0;
items.push({ kind: "treasure", id: t.id, art: tokenArt(`treasure-${ci % 6}`, "objects"), label: `${t.owner}'s treasure` });
}
for (const o of view.groundObjects[k] ?? []) {
const file = objectArt(o.cardId);
items.push({ kind: "object", id: o.instanceId, art: file ? tokenArt(file, "objects") : null, label: o.cardId.replace(/-/g, " ") });
}
return items;
});
/** Riding a creature: the pane looks through ITS eyes and the helm
* drives ITS legs, until you step back into your own skull. */
let possessedId = $state<string | null>(null);
const possessed = $derived(
possessedId ? view.creatures.find((c) => c.id === possessedId) ?? null : null);
/** Creatures you may ride right now: yours (and the democratic
* monster), with movement or an attack still unspent. */
const rideable = $derived(canStride
? view.creatures.filter((c) =>
(c.controllerId === view.you || c.kind === "democratic-monster") &&
!c.justCreated &&
(c.movementUsed < c.movesPerTurn || !c.attackUsed))
: []);
/** The rider's own stance, held while riding: dismounting must hand
* back the exact view you left — same feet, same facing, no cut. */
let preMount: { x: number; y: number; facing: number } | null = null;
function dismount() {
possessedId = null;
if (preMount) {
cam.x = preMount.x; cam.y = preMount.y; cam.facing = preMount.facing;
preMount = null;
}
}
// A dead or vanished mount — or the turn passing — dismounts you.
$effect(() => {
if (possessedId && (!possessed || !canStride)) dismount();
});
// Mounting seats the eyes in the creature's skull facing its longest
// corridor; the rider steers from there.
let seatedMount: string | null = null;
$effect(() => {
if (!possessedId || !possessed) { seatedMount = null; return; }
if (seatedMount === possessedId) return;
seatedMount = possessedId;
preMount = untrack(() => ({ x: cam.x, y: cam.y, facing: cam.facing }));
const mx = possessed.position.x + 0.5, my = possessed.position.y + 0.5;
cam.x = mx; cam.y = my; cam.facing = deepestFacing(view, mx, my);
});
/** What the ACTIVE body stands in or on: hazards it cannot see below
* itself, and the dimensional token it could step through. */
const underfoot = $derived.by(() => {
const body = possessed ?? me;
if (!body || !canStride) return { hazard: null as string | null, warpHere: false };
const k = `${body.position.x},${body.position.y}`;
const HAZARD_NAMES: Record<string, string> = {
tacks: "tacks underfoot", slime: "green slime", dust: "a dust cloud",
pit: "an open pit", ooze: "the killer ooze",
};
const kind = view.squareContents[k]?.kind;
return {
hazard: kind ? HAZARD_NAMES[kind] ?? null : null,
warpHere: view.dimWarps.some((w) =>
(w.a.x === body.position.x && w.a.y === body.position.y) ||
(w.b.x === body.position.x && w.b.y === body.position.y)),
};
});
/** Bodies sharing the active eyes' square: inside the near plane, so
* the renderer cannot show them — the tray stands in for the sprite,
* and clicking routes exactly as clicking the sprite would. */
const withYou = $derived.by(() => {
const body = possessed ?? me;
if (!body || !canStride) return [];
const bx = body.position.x, by = body.position.y;
const items: { kind: "player" | "creature"; id: string; art: string; label: string }[] = [];
for (const p of view.players) {
if (!p.alive || p.id === view.you) continue;
if (p.position.x !== bx || p.position.y !== by) continue;
items.push({ kind: "player", id: p.id, art: tokenArt(`wizard-${p.colorIndex}`, "players"), label: p.id });
}
for (const c of view.creatures) {
if (c.id === possessedId) continue;
if (c.position.x !== bx || c.position.y !== by) continue;
items.push({ kind: "creature", id: c.id, art: tokenArt(c.kind, "creatures"), label: c.kind.replace(/-/g, " ") });
}
return items;
});
const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false;
/** While the player steers, the director keeps its hands off the
* idle facings (actor-watching, corridor rescue); aims and cutaways
* still fire — your own magic always turns your head. */
let manualUntil = 0;
let turning = false;
/** Manual steering holds the director's idle aims off this long. */
const STEER_HOLD_MS = 4000;
/** Mounting holds them off for the whole ride. */
const RIDE_HOLD_MS = 60000;
const SIDES4 = ["E", "S", "W", "N"] as const;
function manualTurn(dir: -1 | 1) {
if (turning) return;
turning = true;
manualUntil = performance.now() + STEER_HOLD_MS;
const from = cam.facing;
const to = from + dir * (Math.PI / 2);
const t0 = performance.now();
const tick = (now: number) => {
const w = Math.min(1, (now - t0) / 180);
cam.facing = from + (to - from) * (w * w * (3 - 2 * w));
if (w < 1) requestAnimationFrame(tick);
else turning = false;
};
requestAnimationFrame(tick);
}
/** Pane clicks: a possessed mount claims them first — adjacent floor
* steps it, a same-square enemy takes its blow — and only the rider's
* own eyes pass clicks through to the board's handlers. */
function handleTarget(t: FpvTarget) {
if (!possessed) { ontarget?.(t); return; }
const c = possessed;
if (t.kind === "player" && onCreatureAttack) {
const p = view.players.find((q) => q.id === t.id);
if (p && p.position.x === c.position.x && p.position.y === c.position.y) {
onCreatureAttack(c.id, t.id);
return;
}
}
if (t.kind === "cell" && onCreatureMove) {
const dx = t.cell.x - c.position.x, dy = t.cell.y - c.position.y;
if (Math.abs(dx) + Math.abs(dy) === 1) {
onCreatureMove(c.id, dx === 1 ? "E" : dx === -1 ? "W" : dy === 1 ? "S" : "N");
}
}
}
function manualStride(back: boolean) {
if (!canStride) return;
manualUntil = performance.now() + STEER_HOLD_MS;
const q = Math.round(cam.facing / (Math.PI / 2));
const side = SIDES4[((q % 4) + 4 + (back ? 2 : 0)) % 4]!;
if (possessed) onCreatureMove?.(possessed.id, side);
else onstride?.(side);
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
const k = e.key.toLowerCase();
if (k === "arrowleft" || k === "a") { e.preventDefault(); manualTurn(-1); }
else if (k === "arrowright" || k === "d") { e.preventDefault(); manualTurn(1); }
else if (k === "arrowup" || k === "w") { e.preventDefault(); manualStride(false); }
else if (k === "arrowdown" || k === "s") { e.preventDefault(); manualStride(true); }
}
let cutawayShot = $state(false);
let fpFx = $state<FpFx[]>([]);
let actorPos = $state<Record<string, { x: number; y: number }>>({});
// --- The fx: each batch plays once, on its own beat. -----------------
let playedBatch = 0;
$effect(() => {
const b = batch;
if (!b || b.n === playedBatch || !me) return;
playedBatch = b.n;
const timers: ReturnType<typeof setTimeout>[] = [];
const started: number[] = [];
for (const { fx, delay } of fpFxForEvents(b.events, b.view, povId)) {
timers.push(setTimeout(() => {
started.push(fx.id);
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
}, delay));
}
return () => {
timers.forEach(clearTimeout);
started.forEach((id) => (fpFx = fpFx.filter((f) => f.id !== id)));
};
});
// --- Other bodies glide between views. -------------------------------
let prevView: GameView | null = null;
$effect(() => {
const v = view;
const before = prevView;
prevView = v;
if (!before || before === v || !me) return;
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
const t0 = performance.now();
const dur = 400;
let raf = 0;
const tick = (now: number) => {
const w = Math.min(1, Math.max(0, (now - t0) / dur));
const ease = w * w * (3 - 2 * w);
const next: Record<string, { x: number; y: number }> = {};
for (const m of moves) {
next[m.id] = { x: m.fx + (m.tx - m.fx) * ease, y: m.fy + (m.ty - m.fy) * ease };
}
actorPos = next;
if (w < 1) raf = requestAnimationFrame(tick);
else actorPos = {};
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
// --- The camera: the director's ladder, live. ------------------------
let directedBatch = 0;
$effect(() => {
const b = batch;
const m = me;
if (!m) { camReady = false; return; }
// Possessed: the director stands aside. The camera sits in the
// mount's skull, keeps whatever facing the rider has chosen, and
// hard-follows the mount's steps; dismounting reseats the wizard.
const mount = possessed;
if (mount) {
if (b) directedBatch = b.n; // consume batches so dismounting doesn't replay them
const mx = mount.position.x + 0.5, my = mount.position.y + 0.5;
if (Math.hypot(untrack(() => cam.x) - mx, untrack(() => cam.y) - my) > 0.01) {
cam.x = mx; cam.y = my;
}
return; // dismount() restores the rider's own stance, uncut
}
const v = view;
const tx = m.position.x + 0.5;
const ty = m.position.y + 0.5;
// Untracked camera reads: this effect's own tween writes cam every
// frame, and a tracked read would restart the ease per frame.
const camX = untrack(() => cam.x);
const camY = untrack(() => cam.y);
const camF = untrack(() => cam.facing);
const dx = tx - camX;
const dy = ty - camY;
const dist = Math.hypot(dx, dy);
const events = b && b.n !== directedBatch ? b.events : [];
if (b) directedBatch = b.n;
const hurled = hurledIn(events, povId);
// Stepping through a warp keeps your HEADING, not your bearing to a
// point: you emerge walking out of the far mouth, so the exit facing
// is the far mouth's inward direction — travel preserved through a
// plain wrap, properly rotated where the boards were laid rotated.
// (A dimensional token has no mouths: facing simply carries over.)
let warpFacing: number | null = null;
for (const e of events) {
if (e.type === "moved" && e.player === povId && e.via === "warp") {
const w = v.board.warps.find((wp) =>
wp.from.cell.x === e.from.x && wp.from.cell.y === e.from.y &&
wp.to.cell.x === e.to.x && wp.to.cell.y === e.to.y);
if (w) warpFacing = SIDE_ANGLE[OPPOSITE[w.to.side]];
} else if (e.type === "warpStepped" && e.player === povId) {
warpFacing = camF;
}
}
const willCut = !camReady || (dist > 1.6 && !hurled);
cutawayShot = false;
const takeCutaway = (ax: number, ay: number) => {
const a = deepestFacing(v, ax, ay);
const d = castRay(v, ax, ay, a).dist;
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
cam.x = ax + Math.cos(a) * back;
cam.y = ay + Math.sin(a) * back;
cam.facing = a + Math.PI;
camReady = true;
cutawayShot = true;
};
const actor = v.activePlayerId;
const aim = aimOfEvents(events, v, povId, m.position, actor);
let targetFacing = camF;
let aimed = false;
if (warpFacing !== null) {
targetFacing = warpFacing;
aimed = true;
} else if (camReady && !willCut && dist > 0.05 && !hurled) {
targetFacing = Math.atan2(dy, dx);
aimed = true;
} else if (aim?.self && (actor !== povId || hurled)) {
// A cutaway to your own body is replay theater: in the cockpit it
// plays only for things done TO you, never for your own hands —
// dropping a dagger must not fling your eyes out of your skull.
return takeCutaway(m.position.x + 0.5, m.position.y + 0.5);
} else if (aim && !aim.self) {
const ax = aim.x + 0.5, ay = aim.y + 0.5;
const toAim = Math.hypot(ax - tx, ay - ty);
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
if (sight.warped || sight.dist < toAim - 0.4) return takeCutaway(ax, ay);
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
} else if (performance.now() >= manualUntil) {
// Another wizard acting in your sight line turns your head.
const other = actor !== povId ? v.players.find((p) => p.id === actor && p.alive) : null;
if (other && (other.position.x !== m.position.x || other.position.y !== m.position.y)) {
const ax = other.position.x + 0.5, ay = other.position.y + 0.5;
const toActor = Math.hypot(ax - tx, ay - ty);
const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
if (!ray.warped && ray.dist > toActor - 0.2) {
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
}
}
}
if (!aimed && willCut && performance.now() >= manualUntil) {
// A fresh CUT with nothing asking to be watched faces the deepest
// corridor — but a STANDING camera is never "rescued": where you
// pointed your own head is where it stays, brick or no brick.
targetFacing = deepestFacing(v, tx, ty);
}
if (willCut) {
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true;
return;
}
const fromX = camX, fromY = camY, fromF = camF;
const arc = shortestArc(fromF, targetFacing);
const turnMs = hurled ? 0 : Math.min(260, Math.abs(arc) * 180);
const walkMs = dist > 0.05 ? (hurled ? 260 : 380) : 0;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
const t = Math.max(0, now - t0);
if (turnMs > 0 && t < turnMs) {
cam.facing = fromF + arc * (t / turnMs);
} else if (walkMs > 0 && t < turnMs + walkMs) {
cam.facing = fromF + arc;
const w = (t - turnMs) / walkMs;
const ease = w * w * (3 - 2 * w);
cam.x = fromX + (tx - fromX) * ease;
cam.y = fromY + (ty - fromY) * ease;
} else {
cam.facing = fromF + arc;
cam.x = tx; cam.y = ty;
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
$effect(() => { onbody?.(possessedId); });
let lastQuad = -99;
$effect(() => {
const q = ((Math.round(cam.facing / (Math.PI / 2)) % 4) + 4) % 4;
if (q !== lastQuad) {
lastQuad = q;
onfacing?.(SIDES4[q]!);
}
});
</script>
<svelte:window onkeydown={onKey} />
{#if me}
<div class="live-fp">
<!-- The bezel: instruments live in the frame, never on the glass —
every canvas pixel is target. -->
<div class="bezel-top">
<span class="bezel-zone">
{#if possessed}
<span class="ride-label">riding the {possessed.kind.replace(/-/g, " ")}</span>
<span class="ride-stats">{Math.max(0, possessed.movesPerTurn - possessed.movementUsed)} moves{possessed.attackUsed ? "" : " · claws ready"}</span>
<button class="stamp tiny" onclick={dismount}>back to your eyes</button>
{:else if rideable.length > 0 && (onCreatureMove || onCreatureAttack)}
{#each rideable as c (c.id)}
<button class="stamp tiny" onclick={() => { possessedId = c.id; manualUntil = performance.now() + RIDE_HOLD_MS; }}>
👁 ride the {c.kind.replace(/-/g, " ")}
</button>
{/each}
{/if}
</span>
<span class="bezel-zone bezel-center">
{#if canStride}
<button class="drive stride" onclick={() => manualStride(false)} aria-label="step forward">︿ forward</button>
{/if}
</span>
<span class="bezel-zone bezel-right">
{#if withYou.length > 0}
<span class="feet-label">sharing your square</span>
{#each withYou as body (body.id)}
<button class="feet-item" title={body.label}
onclick={() => handleTarget(body.kind === "player" ? { kind: "player", id: body.id } : { kind: "creature", id: body.id })}>
<img src={body.art} alt={body.label} />
</button>
{/each}
{/if}
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button>
</span>
</div>
<div class="bezel-mid">
<button class="drive" onclick={() => manualTurn(-1)} aria-label="turn left"></button>
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos}
ontarget={handleTarget} {litCells} />
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right"></button>
</div>
<div class="bezel-bottom">
<span class="bezel-zone">
{#if canStride && ((!possessed && atFeet.length > 0) || underfoot.hazard || underfoot.warpHere)}
<span class="feet-label">at your feet</span>
{#if !possessed && onpickup}
{#each atFeet as item (item.id)}
<button class="feet-item" title={item.label}
onclick={() => onpickup?.({ kind: item.kind, id: item.id })}>
{#if item.art}
<img src={item.art} alt={item.label} />
{:else}
<span class="feet-fallback">{item.label}</span>
{/if}
</button>
{/each}
{/if}
{#if underfoot.hazard}
<span class="feet-hazard">{underfoot.hazard}</span>
{/if}
{#if underfoot.warpHere && onWarpStep}
<button class="stamp tiny" onclick={() => onWarpStep?.(possessedId)}> step through the warp</button>
{/if}
{/if}
</span>
<span class="bezel-zone bezel-center">
{#if canStride}
<button class="drive stride" onclick={() => manualStride(true)} aria-label="step back">﹀ back</button>
{/if}
</span>
<span class="bezel-zone bezel-right">
<span class="live-fp-hint">← → turn · ↑ ↓ walk</span>
</span>
</div>
</div>
{/if}
<style>
.live-fp {
margin-bottom: 0.6rem;
border: 1px solid #3a3428;
border-radius: 6px;
background: #14121a;
overflow: hidden;
}
.bezel-top, .bezel-bottom {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
min-height: 34px;
}
.bezel-top { border-bottom: 1px solid #2a2620; }
.bezel-bottom { border-top: 1px solid #2a2620; }
.bezel-spacer { flex: 1; }
.bezel-zone { flex: 1; display: flex; align-items: center; gap: 8px; }
.bezel-center { justify-content: center; }
.bezel-right { justify-content: flex-end; }
.bezel-mid {
display: flex;
align-items: stretch;
}
.bezel-mid :global(.fpv-canvas) {
border: none;
border-radius: 0;
flex: 1;
min-width: 0;
}
.drive {
background: none;
border: none;
color: #8d8672;
font-size: 1.3rem;
cursor: pointer;
padding: 0 10px;
}
.drive:hover { color: #e9e1cb; background: rgba(201, 167, 42, 0.08); }
.stride { font-size: 0.8rem; letter-spacing: 0.06em; }
.live-fp-hide {
background: none;
border: none;
color: #8d8672;
cursor: pointer;
font-size: 0.9rem;
}
.live-fp-hide:hover { color: #e9e1cb; }
.live-fp-hint {
font-family: "Courier Prime", monospace;
font-size: 0.65rem;
color: #5d5748;
}
.ride-label {
font-family: "Oswald", sans-serif;
font-size: 0.62rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #c9a72a;
}
.ride-stats { font-size: 0.7rem; color: #8d8672; }
.feet-label {
font-family: "Oswald", sans-serif;
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #8d8672;
}
.feet-item {
background: none;
border: 1px solid #4a4536;
border-radius: 4px;
padding: 2px;
cursor: pointer;
line-height: 0;
}
.feet-item:hover { border-color: #c9a72a; }
.feet-item img { width: 26px; height: 26px; object-fit: contain; }
.feet-fallback { font-size: 0.65rem; color: #d8d2c0; padding: 0 4px; }
.feet-hazard { font-size: 0.7rem; color: #d98a4a; white-space: nowrap; }
</style>
+225 -83
View File
@@ -3,9 +3,11 @@
import FirstPerson from "./fpv/FirstPerson.svelte";
import { untrack } from "svelte";
import { humanize } from "./net.svelte";
import { tokenArt } from "./art";
import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast";
import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
@@ -14,6 +16,7 @@
steps,
onclose,
moment = false,
pov = null,
onshare = null,
endLabel = null,
}: {
@@ -22,6 +25,9 @@
/** An instant replay of one turn: open straight into first person,
* through the eyes of the wizard whose turn it is. */
moment?: boolean;
/** The turn-owner, when the server already knows it (moment reels):
* the fallback when no boundary has scrolled past yet. */
pov?: string | null;
/** Mint a public link to this turn; resolves to the URL. */
onshare?: (() => Promise<string>) | null;
/** What the leave button says once the reel has run out. */
@@ -49,25 +55,50 @@
/** Watch the board from above, or relive it through your own eyes. */
let fp = $state(moment);
/** Whose eyes the first-person camera wears: normally your own; in a
* moment reel, the turn-owner's — their position seen with YOUR
* knowledge of the maze, so nothing private leaks. */
/** Whose eyes the first-person camera wears: the wizard whose turn it
* is, ALWAYS — their position seen with the viewer's knowledge of the
* maze, so nothing private leaks. The owner is whoever the last turn
* boundary at or before the current step named; before any boundary
* scrolls past, the server-known owner (moment reels) or the viewer. */
const povId = $derived.by(() => {
if (!moment) return steps[0]!.view.you;
for (const s of steps) {
const started = s.events.find(
(e) => e.type === "turnStarted" || e.type === "extraTurnStarted",
);
if (started && "player" in started) return started.player as string;
for (let i = Math.min(idx, steps.length - 1); i >= 0; i--) {
const evs = steps[i]!.events;
for (let j = evs.length - 1; j >= 0; j--) {
const e = evs[j]!;
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
return e.player as string;
}
return steps[0]!.actor;
}
}
// No boundary yet: these are the FIRST turn's steps, whose opening
// turnStarted fired in the deal before any command. Its owner is
// whoever the reel's first turnEnded names.
for (const st of steps) {
for (const e of st.events) {
if (e.type === "turnEnded" && "player" in e) return (e as { player: string }).player;
if (e.type === "turnStarted" || e.type === "extraTurnStarted") break;
}
}
return pov ?? steps[0]!.view.you;
});
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
/** What you drew belongs in the chronicle, not on a reel that may be
* recorded and passed around — captions keep the count, not the cards. */
const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]);
const lines = $derived(
step.events.map(humanize).filter((l): l is string => l !== null),
step.events
.filter((e) => !CAPTION_SILENT.has(e.type))
.map(humanize)
.filter((l): l is string => l !== null),
);
const atEnd = $derived(idx >= steps.length - 1);
/** The acting wizard's standee, shown beside their words. */
const actorArt = $derived.by(() => {
const p = step.view.players.find((x) => x.id === step.actor);
return p ? tokenArt(`wizard-${p.colorIndex}`, "players") : null;
});
// The reel draws the same sight line the live table shows for an LOS
// attack in progress, so a replay-watcher can see how a spell reached them.
const sightTrace = $derived(stackSightTrace(step.view));
@@ -103,12 +134,24 @@
return () => { cancel(); boardFx = []; };
});
/** The director's slate: the camera effect writes how long this step's
* outcome stays HIDDEN — the old world held on screen — while the eyes
* turn to face the recipient. The fx and the view reveal together. */
let camPlan = $state<{ idx: number; holdMs: number } | null>(null);
let heldView = $state<GameView | null>(null);
/** A cutaway frame shows the reel's own wizard too. */
let cutawayShot = $state(false);
/** In first person the same events become projectiles, impacts,
* flashes, and shakes — scheduled on this step's beat. */
* flashes, and shakes — scheduled on this step's beat, after the
* camera's turn has brought the recipient into frame. */
let fpFx = $state<FpFx[]>([]);
$effect(() => {
const st = steps[Math.min(idx, steps.length - 1)];
const i = Math.min(idx, steps.length - 1);
const st = steps[i];
if (!fp || !st || !prefs.flourishes) return;
const plan = camPlan;
if (plan?.idx !== i) return; // the camera has not set this step's slate yet
const timers: ReturnType<typeof setTimeout>[] = [];
const started: number[] = [];
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
@@ -116,7 +159,7 @@
started.push(fx.id);
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
timers.push(setTimeout(() => (fpFx = fpFx.filter((f) => f.id !== fx.id)), fx.dur + 80));
}, delay / speed));
}, plan.holdMs + delay / speed));
}
return () => {
timers.forEach(clearTimeout);
@@ -145,28 +188,7 @@
const before = prevView;
prevView = v;
if (!before || before === v) return;
const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = [];
const gather = (
id: string,
now: { x: number; y: number },
was: { x: number; y: number } | undefined,
) => {
if (!was) return;
const d = Math.hypot(now.x - was.x, now.y - was.y);
// A single stride or shove glides; a leap across the maze is a
// teleport and should simply be there.
if (d > 0.05 && d <= 3.5) {
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
}
};
for (const p of v.players) {
if (!p.alive || p.id === povId) continue;
const was = before.players.find((q) => q.id === p.id && q.alive);
gather(p.id, p.position, was?.position);
}
for (const c of v.creatures) {
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
}
const moves = gatherGlides(before, v, povId);
if (moves.length === 0) { actorPos = {}; return; }
const t0 = performance.now();
const dur = 400 / speed;
@@ -192,12 +214,6 @@
// still, the eye turns toward whoever acted.
const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false;
function shortestArc(from: number, to: number): number {
let d = (to - from) % (2 * Math.PI);
if (d > Math.PI) d -= 2 * Math.PI;
if (d < -Math.PI) d += 2 * Math.PI;
return d;
}
$effect(() => {
if (!fp) { camReady = false; return; }
const v = step.view;
@@ -219,34 +235,54 @@
// actor, when someone else moved the world; wherever it was, otherwise.
// A blow that hurls the body glides fast and straight, however far,
// eyes still where they were; only unexplained leaps (teleports) cut.
const hurled = step.events.some((e) =>
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
e.type === "retreatedInHorror") && e.player === povId);
const hurled = hurledIn(step.events, povId);
const actor = v.players.find((p) => p.id === step.actor);
// Where the eyes' own magic went this step: a wizard looks where
// they aim, so the camera turns to watch its own spells land.
const aim = (() => {
for (const e of step.events) {
let cell: { x: number; y: number } | null = null;
if (e.type === "spellCast" && e.caster === povId) {
cell = e.targetCell ?? v.players.find((p) => p.id === e.target)?.position ?? null;
} else if (e.type === "attackResolved" && e.attacker === povId) {
cell = v.players.find((p) => p.id === e.defender)?.position ?? null;
} else if (e.type === "objectThrown" && e.attacker === povId) {
cell = e.landedAt;
const aim = aimOfEvents(step.events, v, povId, me.position, step.actor);
// A leap the legs cannot explain (first frame, teleport, the return
// from a cutaway) is a CUT, and a cut is not a stride: the direction
// of travel means nothing at the far end.
const willCut = !camReady || (dist > 1.6 && !hurled);
cutawayShot = false;
/** The broadcast cutaway: stand back down the target cell's deepest
* corridor, facing it, holding a beat of the world-before so the
* deed happens ON screen — with the reel's own wizard visible. */
const takeCutaway = (ax: number, ay: number) => {
const a = deepestFacing(v, ax, ay);
const d = castRay(v, ax, ay, a).dist;
const back = Math.min(1.6, Math.max(0.35, d - 0.4));
cam.x = ax + Math.cos(a) * back;
cam.y = ay + Math.sin(a) * back;
cam.facing = a + Math.PI;
camReady = true;
cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1);
const pv = i2 > 0 ? steps[i2 - 1]!.view : null;
heldView = pv;
camPlan = { idx: i2, holdMs: pv ? 420 / speed : 0 };
if (pv) {
const t = setTimeout(() => (heldView = null), 420 / speed);
return () => clearTimeout(t);
}
if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell;
}
return null;
})();
return;
};
let targetFacing = camF;
let aimed = false;
// A stride turns the eyes along it — but only a REAL stride: on the
// reel's first run the camera sits at its uninitialized origin, and
// that phantom distance must not pick the opening shot's direction.
if (camReady && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
else if (aim?.self) {
// Summoned at your own feet: no turn can show it — step outside
// yourself and watch it grow beside you.
return takeCutaway(me.position.x + 0.5, me.position.y + 0.5);
}
else if (aim) {
targetFacing = Math.atan2(aim.y + 0.5 - ty, aim.x + 0.5 - tx);
const ax = aim.x + 0.5, ay = aim.y + 0.5;
const toAim = Math.hypot(ax - tx, ay - ty);
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
if (sight.warped || sight.dist < toAim - 0.4) {
return takeCutaway(ax, ay);
}
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
} else if (actor && actor.id !== povId &&
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
@@ -260,26 +296,93 @@
aimed = true;
}
}
if (!aimed && castRay(v, tx, ty, targetFacing).dist < 0.8) {
// Nothing aims the eyes and they'd idle nose-to-brick (the opening
// cut, or a beat after pacing into a dead end): face the deepest
// corridor from where the wizard stands instead.
let deepest = -1;
// How good is the view down heading `a` from (px,py)? Corridor depth,
// and at the reel's end, a bonus for visible subjects.
const viewScoreAt = (px: number, py: number, a: number): number => {
let score = Math.min(castRay(v, px, py, a).dist, 8);
if (!atEnd) return score;
const bonus = (cell: { x: number; y: number }, worth: number) => {
const bx = cell.x + 0.5, by = cell.y + 0.5;
const bearing = Math.atan2(by - py, bx - px);
if (Math.abs(shortestArc(a, bearing)) > 0.55) return 0;
const d = Math.hypot(bx - px, by - py);
if (d < 0.1) return 0;
const sight = castRay(v, px, py, bearing);
return !sight.warped && sight.dist >= d - 0.4 ? worth / (1 + d * 0.3) : 0;
};
for (const p of v.players) if (p.alive && p.id !== povId) score += bonus(p.position, 6);
for (const c of v.creatures) score += bonus(c.position, 5);
for (const t of v.treasures) if (t.position && !t.carriedBy) score += bonus(t.position, 3);
return score;
};
if (!aimed) {
// Nothing aims the eyes. If they'd idle nose-to-brick (the opening
// cut, a dead-end beat — or the reel's CLOSING shot, which gets a
// higher standard), find a better view: the deepest corridor, and
// on the final frame, preferably one with something in it.
const viewScore = (a: number): number => viewScoreAt(tx, ty, a);
// A cut lands with whatever stale facing it carried, so a cut with
// nothing aimed always re-picks its view from the new position.
const staring = willCut ||
castRay(v, tx, ty, targetFacing).dist < (atEnd ? 1.2 : 0.8);
if (staring) {
const current = viewScore(targetFacing);
let bestA = targetFacing, bestScore = current;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, tx, ty, a);
if (h.dist > deepest) { deepest = h.dist; targetFacing = a; }
const sc = viewScore(a);
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
}
// One evaluation, one turn — a wizard walled in on all sides
// keeps whatever view it has rather than hunting forever.
targetFacing = bestA;
}
}
if (!camReady || (dist > 1.6 && !hurled)) {
// First frame, or a leap the legs cannot explain: cut.
// The reel's LAST step may be a stride into a corner: the walk keeps
// its own facing, then the camera turns once more to a view worth
// ending on.
let closingArc = 0;
if (atEnd && aimed && castRay(v, tx, ty, targetFacing).dist < 1.2) {
const current = viewScoreAt(tx, ty, targetFacing);
let bestA = targetFacing, bestScore = current;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const sc = viewScoreAt(tx, ty, a);
if (sc > bestScore + 0.5) { bestScore = sc; bestA = a; }
}
closingArc = shortestArc(targetFacing, bestA);
}
const stepIdx = Math.min(idx, steps.length - 1);
const prevView = stepIdx > 0 ? steps[stepIdx - 1]!.view : null;
/** Hold the OLD world on screen this long, so the outcome lands only
* once the eyes face its recipient: a cutaway gets a beat of the
* "before"; a turn holds until the turn is done. */
const setSlate = (holdMs: number) => {
// Locals only: reading heldView back here would register it as a
// dependency of this very effect, and the timer clearing it would
// re-trigger us into a ping-pong.
const hold = holdMs > 60 && prevView ? prevView : null;
heldView = hold;
camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 };
if (hold) {
const t = setTimeout(() => (heldView = null), holdMs);
return () => clearTimeout(t);
}
return () => {};
};
if (willCut) {
// First frame, or a leap the legs cannot explain: cut. An aimed
// cut still holds a beat of the before-world once the reel is
// rolling; the opening frame reveals at once.
const wasReady = camReady;
cam.x = tx; cam.y = ty; cam.facing = targetFacing;
camReady = true;
return;
return setSlate(wasReady && aim ? 380 / speed : 0);
}
const fromX = camX, fromY = camY, fromF = camF;
const arc = shortestArc(fromF, targetFacing);
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0);
const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
@@ -295,15 +398,22 @@
const ease = w * w * (3 - 2 * w);
cam.x = fromX + (tx - fromX) * ease;
cam.y = fromY + (ty - fromY) * ease;
} else if (closingArc !== 0 && t < turnMs + walkMs + closingMs) {
cam.x = tx; cam.y = ty;
const w = (t - turnMs - walkMs) / closingMs;
cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w);
} else {
cam.facing = fromF + arc;
cam.facing = fromF + arc + closingArc;
cam.x = tx; cam.y = ty;
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
return () => {
cancelAnimationFrame(raf);
clearSlate();
};
});
// --- Save a video: the first-person view composited into a shareable
@@ -315,30 +425,47 @@
if (recorder) { recorder.stop(); return; }
const cv = stageEl?.querySelector("canvas");
if (!cv) return;
// Recording means capturing the WHOLE reel: rewind to the top and
// roll. (Hitting record at the end used to auto-stop a beat later —
// a one-second clip of the final frame.)
// Recording means capturing the WHOLE reel: rewind to the top and roll.
idx = 0;
const comp = document.createElement("canvas");
comp.width = 1280;
comp.height = 720;
const g = comp.getContext("2d")!;
const faces = new Map<string, HTMLImageElement>();
const faceFor = (src: string): HTMLImageElement | null => {
let img = faces.get(src);
if (!img) {
img = new Image();
img.src = src;
faces.set(src, img);
}
return img.complete && img.naturalWidth > 0 ? img : null;
};
let compRaf = 0;
const drawComp = () => {
g.imageSmoothingEnabled = false;
g.drawImage(cv, 0, 0, 1280, 720);
g.imageSmoothingEnabled = true;
// The caption bar: who did what, in the reel's own words.
// The caption bar: who did what, in the reel's own words — their
// standee at the left.
g.fillStyle = "rgba(12, 10, 8, 0.78)";
g.fillRect(0, 720 - 96, 1280, 96);
const face = actorArt ? faceFor(actorArt) : null;
const textX = face ? 110 : 28;
if (face) {
g.drawImage(face, 24, 720 - 96 + 14, 68, 68);
g.strokeStyle = "#43331f";
g.lineWidth = 2;
g.strokeRect(24, 720 - 96 + 14, 68, 68);
}
g.fillStyle = "#e0b34a";
g.font = "600 22px Oswald, sans-serif";
g.fillText(step.actor.toUpperCase(), 28, 720 - 60, 400);
g.fillText(step.actor.toUpperCase(), textX, 720 - 60, 400);
g.fillStyle = "#efe8d4";
g.font = "21px 'Courier Prime', monospace";
const said = lines.slice(0, 2);
if (said.length === 0) said.push("…considers the maze.");
said.forEach((line, i) => g.fillText(line, 28, 720 - 32 + i * 26, 1224));
said.forEach((line, i) => g.fillText(line, textX, 720 - 32 + i * 26, 1224 - (textX - 28)));
// The colophon corner.
g.fillStyle = "rgba(224, 179, 74, 0.6)";
g.font = "600 20px Oswald, sans-serif";
@@ -399,7 +526,7 @@
<button class="replay-eyes" class:lit={recorder !== null} onclick={toggleRecord}>
{recorder ? "⏹ stop & save" : "⏺ save video"}</button>
{/if}
{#if moment && onshare}
{#if onshare}
<button class="replay-eyes" class:lit={shareState === "copied"} onclick={doShare}>
{shareState === "idle" ? "🔗 share link"
: shareState === "minting" ? "…"
@@ -409,7 +536,7 @@
</header>
<div class="replay-board" bind:this={stageEl}>
{#if fp}
<FirstPerson view={step.view} {povId}
<FirstPerson view={heldView ?? step.view} povId={cutawayShot ? "" : povId}
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
fx={fpFx} posOverride={actorPos} rubble={rubbleSpots} />
{:else}
@@ -417,6 +544,8 @@
{/if}
</div>
<div class="replay-caption">
{#if actorArt}<img class="caption-face" src={actorArt} alt={step.actor} />{/if}
<div class="caption-lines">
<strong>{step.actor}</strong>
{#each lines as line, i (i)}<div>{line}</div>{/each}
{#if lines.length === 0 && !step.chat?.length}<div>…considers the maze.</div>{/if}
@@ -424,6 +553,7 @@
<div class="reel-talk">💬 {c.player}: {c.text}</div>
{/each}
</div>
</div>
<div class="replay-controls">
<button onclick={() => { playing = false; idx = Math.max(0, idx - 1); }} aria-label="previous move"></button>
<button class="playpause" onclick={() => (playing = !playing)} aria-label={playing ? "pause" : "play"}>
@@ -513,7 +643,19 @@
font-size: 0.78rem;
line-height: 1.45;
min-height: 3.4rem;
display: flex;
gap: 0.6rem;
align-items: flex-start;
}
.caption-face {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 3px;
border: 1.5px solid #43331f;
flex: none;
}
.caption-lines { min-width: 0; }
.replay-controls .speed {
font-size: 0.75rem;
opacity: 0.7;
+5 -2
View File
@@ -14,6 +14,7 @@
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView }[];
actor: string;
round: number;
whole?: boolean;
}
let data = $state<ShareSteps | null>(null);
let failed = $state(false);
@@ -30,7 +31,9 @@
<header class="share-head">
<a class="share-brand" href="/">WIZ-WAR</a>
{#if data}
<div class="share-title">Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}</div>
<div class="share-title">
{#if data.whole}The whole tale{data.actor ? ` — ${data.actor} triumphant` : ""}{:else}Instant replay — {data.actor}'s turn{data.round ? ` (round ${data.round})` : ""}{/if}
</div>
{/if}
</header>
@@ -44,7 +47,7 @@
{:else}
<div class="share-stage">
{#key reelKey}
<Replay steps={data.steps} moment endLabel="⟲ watch it again"
<Replay steps={data.steps} moment pov={data.actor} endLabel="⟲ watch it again"
onclose={() => (reelKey += 1)} />
{/key}
</div>
+21 -1
View File
@@ -8,6 +8,8 @@
import FirewallEdge from "./FirewallEdge.svelte";
import IllusionShimmer from "./IllusionShimmer.svelte";
import { FX_ART } from "./fpv/fx3d";
import { MATERIALS } from "./fpv/textures";
import { TERRAIN3D } from "./fpv/terrain3d";
const GROUPS: { title: string; files: string[] }[] = [
{ title: "Wizards", files: ["wizard-0", "wizard-1", "wizard-2", "wizard-3", "wizard-4", "wizard-5"] },
@@ -95,7 +97,7 @@
wherever a file is missing.
</p>
<div class="grid">
{#each ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as name (name)}
{#each MATERIALS as name (name)}
<figure>
<img class="masonry" src={`/textures/${name}.png`} alt={`${name} texture`} />
<figcaption>{name}</figcaption>
@@ -120,6 +122,24 @@
{/each}
</div>
<h2>The standing terrain — first-person volumes</h2>
<p class="sub">
Terrain that stands in the room: hedges filling a square to half
height, the killer ooze as a translucent cube, dust billowing, the
dimensional warp's pillar of light. Each lives at
<code>public/terrain3d/&lt;name&gt;.png</code> — transparent PNG, any
size; hedges render twice as wide as tall. Rehearse them in place with
<code>/?fpv&amp;terrain=thornbush@3,7;jello@4,7</code>.
</p>
<div class="grid">
{#each TERRAIN3D as name (name)}
<figure>
<img class="masonry conjuration" src={`/terrain3d/${name}.png`} alt={`${name} sprite`} />
<figcaption>{name}</figcaption>
</figure>
{/each}
</div>
<h2>Walls &amp; doors — as the maze draws them</h2>
<p class="sub">
Wall-segment effects have never been tokens: locks picked, jammed, and
+512 -70
View File
@@ -3,9 +3,10 @@
// GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote.
import { castRay, billboards } from "./raycast";
import { billboards, castRay, warpMotion, type FpvTarget } from "./raycast";
import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, type FpFx } from "./fx3d";
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d";
import { tokenArt } from "../art";
import { PLAYER_COLORS } from "../colors";
import type { GameView } from "@wizwar/engine";
@@ -21,6 +22,8 @@
fx = [],
posOverride,
rubble = [],
ontarget,
litCells = null,
}: {
view: GameView;
povId: string;
@@ -37,11 +40,44 @@
posOverride?: Record<string, { x: number; y: number }>;
/** Where walls have died: a mound of stone marks each fallen edge. */
rubble?: { x: number; y: number }[];
/** Present = the pane is an instrument: clicks resolve to targets. */
ontarget?: (t: FpvTarget) => void;
/** Squares a selected cell-target card may aim at: the pane dims the
* ineligible ground exactly as the board dims its squares. */
litCells?: Set<string> | null;
} = $props();
const FOV = Math.PI / 2.9;
let canvas: HTMLCanvasElement;
/** Everything the LAST drawn frame knew, kept for click resolution:
* the pane is an instrument only because the renderer remembers what
* stood under every pixel. */
let hitFrame: {
W: number; H: number; ex: number; ey: number; facing: number;
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
/** Depth-sorted nearest-first, ready for hover hit-testing. */
sprites: Projected[];
} | null = null;
/** The one visibility rule a sprite obeys in a column — shared by the
* draw pass and the hover hit test so they can never disagree. */
function spriteVisibleInCol(
sp: Projected, col: number,
zbuf: Float64Array, warpIdCol: Int32Array, warpDistCol: Float64Array,
): boolean {
if (sp.depth >= zbuf[col]!) return false;
if (sp.warped) {
if (warpIdCol[col] !== sp.warpId || sp.depth <= warpDistCol[col]!) return false;
} else if (sp.depth >= warpDistCol[col]!) return false;
if (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) return false;
return true;
}
/** Crosshair position in canvas pixels, while the pointer is over the
* pane and the pane is an instrument. */
let mouse: { x: number; y: number } | null = null;
// Token art loads lazily; a sprite draws once its image has arrived.
const images = new Map<string, HTMLImageElement>();
function imageFor(src: string): HTMLImageElement | null {
@@ -54,16 +90,6 @@
return img.complete && img.naturalWidth > 0 ? img : null;
}
/** Base colors per face (fallback + tinting basis). */
const FACE: Record<string, [number, number, number]> = {
wall: [126, 118, 100],
stone: [96, 96, 104],
door: [130, 92, 48],
rim: [82, 76, 66],
firewall: [214, 92, 28],
warp: [96, 60, 160],
};
// Materials come from /textures/*.png when those files exist — the
// independently paintable set — with the baked procedurals underneath
// so a missing or still-loading file never leaves a wall naked.
@@ -88,29 +114,47 @@
}
let frame: ImageData | null = null;
// Home bases wear their owner's emblem on the floor: a per-view grid of
// owner tints, indexed by integer cell for the per-pixel pass.
const homeCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; tints: [number, number, number][] }>();
function homesOf(v: GameView) {
let h = homeCache.get(v);
// The floor wears decals: home emblems in their owners' colors, pits
// opening downward, slime, tacks, dimensional warp rings — a per-view
// grid mapping each cell to one overlay, read by the per-pixel pass.
interface Decal { tex: string; tint: [number, number, number] | null }
const decalCache = new WeakMap<object, { grid: Int16Array; bw: number; bh: number; decals: Decal[] }>();
function decalsOf(v: GameView) {
let h = decalCache.get(v);
if (!h) {
const bw = v.board.width, bh = v.board.height;
const grid = new Int16Array(bw * bh).fill(-1);
const tints: [number, number, number][] = [];
const decals: Decal[] = [];
const put = (x: number, y: number, tex: string, tint: Decal["tint"] = null) => {
if (x < 0 || y < 0 || x >= bw || y >= bh) return;
decals.push({ tex, tint });
grid[y * bw + x] = decals.length - 1;
};
for (const p of v.players) {
if (!p.home) continue;
const hex = PLAYER_COLORS[p.colorIndex] ?? "#888888";
tints.push([
put(p.home.x, p.home.y, "home", [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
]);
if (p.home.x >= 0 && p.home.y >= 0 && p.home.x < bw && p.home.y < bh) {
grid[p.home.y * bw + p.home.x] = tints.length - 1;
}
const DECAL_KIND: Record<string, string> = {
pit: "pit", slime: "slime", tacks: "tacks",
thornbush: "underbrush", rosebush: "underbrush",
};
for (const [k, content] of Object.entries(v.squareContents)) {
const tex = DECAL_KIND[content.kind];
if (!tex) continue;
const [cx, cy] = k.split(",").map(Number) as [number, number];
put(cx, cy, tex);
}
h = { grid, bw, bh, tints };
homeCache.set(v, h);
for (const w of v.dimWarps) {
put(w.a.x, w.a.y, "dimwarp");
put(w.b.x, w.b.y, "dimwarp");
}
h = { grid, bw, bh, decals };
decalCache.set(v, h);
}
return h;
}
@@ -131,12 +175,21 @@
ey += f.mag * (1 - p) * Math.cos(time * 0.087);
}
// Doors mid-swing this frame, from the animated fx.
// Doors mid-swing and conjurations mid-growth this frame.
let doors: Record<string, number> | undefined;
let growing: Record<string, number> | undefined;
const spawn: Record<string, number> = {};
for (const f of fx) {
if (f.kind !== "door") continue;
if (f.kind === "door") {
const a = doorOpenness((time - f.t0) / f.dur);
if (a > 0) (doors ??= {})[f.edge] = Math.max(doors?.[f.edge] ?? 0, a);
} else if (f.kind === "grow") {
const p = (time - f.t0) / f.dur;
if (p < 0 || p >= 1) continue;
const g = growProgress(p);
if (f.slot === "solid") (growing ??= {})[f.key] = g;
else spawn[f.key] = g;
}
}
// The ground and the vault overhead, cast per pixel: each screen row
@@ -150,6 +203,17 @@
const fl = pixelsOf(textures.floor!);
const ce = pixelsOf(textures.ceiling!);
const FOG = 13;
// A selected cell-card's eligibility, rasterized once for the pixel
// loop: 1 = castable ground, 0 = dimmed. Absent card = null = all lit.
let litGrid: Uint8Array | null = null;
const litBw = view.board.width, litBh = view.board.height;
if (litCells) {
litGrid = new Uint8Array(litBw * litBh);
for (const k of litCells) {
const [lx, ly] = k.split(",").map(Number) as [number, number];
if (lx >= 0 && ly >= 0 && lx < litBw && ly < litBh) litGrid[ly * litBw + lx] = 1;
}
}
for (let row = 0; row < H; row++) {
const below = row > half;
const dz = below ? row - half : half - row;
@@ -162,8 +226,7 @@
continue;
}
const d = (H / 2) / dz;
const homes = below ? homesOf(view) : null;
const hp = below ? pixelsOf(textures.home!) : null;
const dec = below ? decalsOf(view) : null;
const tex = below ? fl : ce;
const tw = tex.width, th = tex.height;
const tp = tex.data;
@@ -184,17 +247,24 @@
let v = wy % 1; if (v < 0) v += 1;
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
if (homes) {
let sh = shade;
if (litGrid && below) {
const gx = (wx - u) | 0, gy = (wy - v) | 0;
if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3;
}
if (dec) {
const hx = (wx - u) | 0, hy = (wy - v) | 0;
if (hx >= 0 && hy >= 0 && hx < homes.bw && hy < homes.bh) {
const owner = homes.grid[hy * homes.bw + hx]!;
if (owner >= 0 && hp) {
// The emblem sits over the flagstones, wearing its
// owner's color.
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
const di = dec.grid[hy * dec.bw + hx]!;
if (di >= 0) {
// A decal lies over the flagstones, alpha-blended texel
// by texel; home emblems wear their owner's color.
const decal = dec.decals[di]!;
const hp = pixelsOf(textures[decal.tex] ?? textures.home!);
const hi = (((v * hp.height) | 0) * hp.width + ((u * hp.width) | 0)) * 4;
const ha = hp.data[hi + 3]! / 255;
if (ha > 0) {
const [tr, tg, tb] = homes.tints[owner]!;
const [tr, tg, tb] = decal.tint ?? [1, 1, 1];
r = r * (1 - ha) + hp.data[hi]! * tr * ha;
g = g * (1 - ha) + hp.data[hi + 1]! * tg * ha;
b = b * (1 - ha) + hp.data[hi + 2]! * tb * ha;
@@ -202,9 +272,9 @@
}
}
}
buf[o] = r * shade;
buf[o + 1] = g * shade;
buf[o + 2] = b * shade;
buf[o] = r * sh;
buf[o + 1] = g * sh;
buf[o + 2] = b * sh;
}
buf[o + 3] = 255;
wx += stepX;
@@ -218,34 +288,72 @@
// side must MATCH its column's, or bodies near a far mouth would
// ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W);
const warpedCol = new Uint8Array(W);
const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null);
// Per column: which warp (if any) the ray bent through, and how far
// away that mouth stood. A REAL body paints a column only if it is
// nearer than the mouth (it stands in front of the window); a
// VIRTUAL one only through its OWN warp's columns, beyond the mouth.
const warpIdCol = new Int32Array(W).fill(-1);
const warpDistCol = new Float64Array(W).fill(Infinity);
// Each mouth a ray passed through hangs a translucent veil of the
// warp texture at its own depth, drawn with the other overlays.
const veils: { col: number; depth: number; u: number }[] = [];
// Known illusions: the ray passes, but a translucent ghost of a wall
// stands at the crossing — drawn after the sprites so bodies show
// through it, shimmering so nobody mistakes it for stone.
const ghosts: { col: number; depth: number; u: number; worldU: number }[] = [];
// Open doorways collect their lintels, hung after the sprites; and
// conjurations still rising collect their growing columns.
const lintels: { col: number; depth: number; u: number }[] = [];
const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = [];
for (let col = 0; col < W; col++) {
const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2));
const hit = castRay(view, ex, ey, rayAngle, doors);
const hit = castRay(view, ex, ey, rayAngle, doors, growing);
const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye
zbuf[col] = depth;
warpedCol[col] = hit.warped ? 1 : 0;
if (hit.warpId !== undefined) {
warpIdCol[col] = hit.warpId;
warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing);
veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0 });
}
if (hit.ghost) {
ghosts.push({
col, depth: hit.ghost.dist * Math.cos(rayAngle - facing),
u: hit.ghost.u, worldU: hit.ghost.worldU,
});
}
if (hit.doorway) {
lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u });
}
if (hit.rising) {
risings.push({
col, depth: hit.rising.dist * Math.cos(rayAngle - facing),
u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g,
});
}
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2;
const tex = textures[hit.kind] ?? textures.wall!;
hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH };
const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
// Sample by the texture's own size: painted files may be any scale.
// Fire and warps shift their slice per world cell, so a blaze
// spanning edges reads as one long fire, not a repeated flame.
const texU = hit.kind === "firewall" || hit.kind === "warp"
// Fire shifts its slice per world cell, so a blaze spanning edges
// reads as one long fire, not a repeated flame.
const texU = hit.kind === "firewall"
? (hit.u + Math.floor(hit.worldU) * 0.37) % 1
: hit.u;
ctx.drawImage(tex, Math.min(tex.width - 1, texU * tex.width), 0,
Math.max(1, tex.width / 96), tex.height, col, top, 1, wallH);
if (!hit.frame && (hit.kind === "wall" || hit.kind === "door") && hit.edge) {
// Harm shows: accumulated damage wears fractures into the face.
const dmg = view.wallDamage[hit.edge];
if (dmg) {
const ck = textures.cracks!;
ctx.globalAlpha = Math.min(0.9, 0.35 + dmg * 0.2);
ctx.drawImage(ck, Math.min(ck.width - 1, texU * ck.width), 0,
Math.max(1, ck.width / 96), ck.height, col, top, 1, wallH);
ctx.globalAlpha = 1;
}
}
// Distance and orientation carve the light; overlays animate it.
let dark = 1 - Math.min(1, 1.35 / (1 + depth * 0.45));
if (hit.axis === "y") dark = 1 - (1 - dark) * 0.8;
@@ -255,11 +363,6 @@
ctx.fillRect(col, top, 1, wallH);
dark *= 0.5; // the fire lights itself
}
if (hit.kind === "warp") {
const swirl = 0.12 + 0.12 * Math.sin(time / 240 + hit.worldU * 9);
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(col, top, 1, wallH);
}
if (hit.kind === "wall" && hit.edge && view.illusionEdges[hit.edge] === "untested") {
// The same tell the board gives: an untested illusion's face
// shimmers faintly — maybe stone, maybe not.
@@ -287,10 +390,13 @@
// Sprites, far to near, sliced against the depth buffer.
const sprites = billboards(view, povId, tokenArt, posOverride)
.map((b) => project(b, ex, ey))
.map((b) => {
const g = b.key !== undefined ? spawn[b.key] : undefined;
return project(g !== undefined ? { ...b, scale: b.scale * g } : b, ex, ey);
})
.filter((s): s is Projected => s !== null);
for (const spot of rubble) {
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.55, rise: 0 }, ex, ey);
const s = project({ x: spot.x, y: spot.y, src: "/fx3d/rubble.png", scale: 0.5, aspect: 2, rise: 0 }, ex, ey);
if (s) sprites.push({ ...s, fallback: "rubble" });
}
for (const f of fx) {
@@ -303,22 +409,52 @@
const s = project({
x: at.x, y: at.y, src: `/fx3d/${f.art}.png`,
scale: f.kind === "projectile" ? 0.3 : 0.25 + 0.6 * p,
rise: 0.3,
rise: f.kind === "impact" ? (f.rise ?? 0.3) : 0.3,
warped: f.kind === "projectile" ? f.warped : undefined,
warpId: f.kind === "projectile" ? f.warpId : undefined,
}, ex, ey);
if (s) sprites.push({ ...s, glow: true, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
}
sprites.sort((a, b) => b.depth - a.depth);
sprites.sort((a, b) => b.sort - a.sort);
hitFrame = {
W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols,
sprites: [...sprites].sort((a, b) => a.depth - b.depth),
};
// The nearest sprite each column carries — depth AND vertical span —
// so the overlay passes (veils, ghosts, lintels, risings) can paint
// around a body standing in front of them instead of over it.
const spriteZ = new Float64Array(W).fill(Infinity);
const sprTop = new Float64Array(W);
const sprBot = new Float64Array(W);
/** The parts of an overlay column NOT hidden behind the column's
* nearest sprite: whole, split around it, or nothing. */
const maskedSegs = (col: number, depth: number, top: number, h: number): [number, number][] => {
if (depth < spriteZ[col]!) return [[top, h]];
const segs: [number, number][] = [];
const bottom = top + h;
if (sprTop[col]! > top) segs.push([top, Math.min(h, sprTop[col]! - top)]);
if (sprBot[col]! < bottom) segs.push([sprBot[col]!, bottom - sprBot[col]!]);
return segs;
};
for (const s of sprites) {
const img = imageFor(s.src) ?? (s.fallback ? fxFallback(s.fallback) : null);
const img = imageFor(s.src) ??
(s.fallback
? (TERRAIN3D.includes(s.fallback as (typeof TERRAIN3D)[number])
? terrainFallback(s.fallback) : fxFallback(s.fallback))
: null);
const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0);
const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0);
// Conjurations are painted art, not light sources: normal
// compositing keeps their inks true; only the fade is borrowed.
if (s.glow) ctx.globalAlpha = s.alpha ?? 1;
// Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies.
if (s.glow) ctx.globalCompositeOperation = "lighter";
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1;
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue;
if ((s.warped ? 1 : 0) !== warpedCol[col]!) continue;
if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) continue;
if (s.depth < spriteZ[col]!) {
spriteZ[col] = s.depth;
sprTop[col] = s.top;
sprBot[col] = s.bottom;
}
const texX = ((col - s.left) / (s.right - s.left));
if (img) {
ctx.drawImage(
@@ -336,7 +472,68 @@
ctx.fillRect(col, s.top, 1, s.bottom - s.top);
}
}
if (s.glow) ctx.globalAlpha = 1;
if (s.glow) ctx.globalCompositeOperation = "source-over";
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
}
// Warp mouths wear their veil: the warp texture at the opening,
// translucent, swirling — the doorway between rooms that are not
// neighbors announces itself.
for (const vl of veils) {
const vh = Math.min(H * 2.5, H / Math.max(vl.depth, 0.05));
const vTop = half - vh / 2;
const wt = textures.warp!;
const swirl = 0.1 + 0.1 * Math.sin(time / 240 + vl.u * 9 + vl.col * 0.02);
for (const [segTop, segH] of maskedSegs(vl.col, vl.depth, vTop, vh)) {
ctx.globalAlpha = 0.3;
ctx.drawImage(wt, Math.min(wt.width - 1, vl.u * wt.width),
((segTop - vTop) / vh) * wt.height,
Math.max(1, wt.width / 96), (segH / vh) * wt.height,
vl.col, segTop, 1, segH);
ctx.globalAlpha = 1;
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(vl.col, segTop, 1, segH);
}
}
// Conjurations rising: the wall (or stone) grows bottom-up where it
// will stand, glowing swirls running over the young face.
for (const ri of risings) {
const full = Math.min(H * 2.5, H / Math.max(ri.depth, 0.05));
const grown = full * ri.g;
const top0 = half + full / 2 - grown;
const tex = textures[ri.kind] ?? textures.wall!;
const swirl = (0.35 * (1 - ri.g) + 0.08) *
(0.7 + 0.3 * Math.sin(time / 90 + ri.worldU * 21));
for (const [segTop, segH] of maskedSegs(ri.col, ri.depth, top0, grown)) {
ctx.globalAlpha = 0.55 + 0.45 * ri.g;
ctx.drawImage(tex, Math.min(tex.width - 1, ri.u * tex.width),
((segTop - top0) / grown) * tex.height,
Math.max(1, tex.width / 96), (segH / grown) * tex.height,
ri.col, segTop, 1, segH);
ctx.globalAlpha = 1;
ctx.fillStyle = `rgba(190,150,255,${Math.max(0, swirl)})`;
ctx.fillRect(ri.col, segTop, 1, segH);
}
}
// Doorway lintels: the frame's top rows hung across each opening.
for (const li of lintels) {
const lh = Math.min(H * 2.5, H / Math.max(li.depth, 0.05));
const lTop = half - lh / 2;
const band = lh * 0.16;
const ft = textures.doorframe!;
const dark = 1 - Math.min(1, 1.35 / (1 + li.depth * 0.45));
for (const [segTop, segH] of maskedSegs(li.col, li.depth, lTop, band)) {
ctx.drawImage(ft, Math.min(ft.width - 1, li.u * ft.width),
((segTop - lTop) / band) * ft.height * 0.16,
Math.max(1, ft.width / 96), (segH / band) * ft.height * 0.16,
li.col, segTop, 1, segH);
if (dark > 0.02) {
ctx.fillStyle = `rgba(0,0,0,${Math.min(0.92, dark)})`;
ctx.fillRect(li.col, segTop, 1, segH);
}
}
}
// The ghosts of walls you know are lies: drawn over everything at
@@ -345,13 +542,17 @@
const gHft = Math.min(H * 2.5, H / Math.max(gh.depth, 0.05));
const gTop = half - gHft / 2;
const tex = textures.wall!;
ctx.globalAlpha = 0.2;
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width), 0,
Math.max(1, tex.width / 96), tex.height, gh.col, gTop, 1, gHft);
ctx.globalAlpha = 1;
const ripple = 0.10 + 0.07 * Math.sin(time / 200 + gh.worldU * 11 + gHft * 0.01);
for (const [segTop, segH] of maskedSegs(gh.col, gh.depth, gTop, gHft)) {
ctx.globalAlpha = 0.2;
ctx.drawImage(tex, Math.min(tex.width - 1, gh.u * tex.width),
((segTop - gTop) / gHft) * tex.height,
Math.max(1, tex.width / 96), (segH / gHft) * tex.height,
gh.col, segTop, 1, segH);
ctx.globalAlpha = 1;
ctx.fillStyle = `rgba(190,160,255,${Math.max(0, ripple)})`;
ctx.fillRect(gh.col, gTop, 1, gHft);
ctx.fillRect(gh.col, segTop, 1, segH);
}
}
// A whisper of vignette holds the torchlit mood together.
@@ -371,6 +572,71 @@
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
// The crosshair's answer, before the click commits: what the pane
// would target here, outlined in the table's gold with its name.
if (ontarget && mouse) drawHover(ctx, W, H, half);
}
/** Paint the hover cue for whatever stands under the crosshair. */
function drawHover(ctx: CanvasRenderingContext2D, W: number, H: number, half: number) {
const f = hitFrame;
if (!f || !mouse) return;
const found = resolveHover(mouse.x, mouse.y);
if (!found) return;
ctx.save();
ctx.strokeStyle = "#c9a72a";
ctx.fillStyle = "rgba(201,167,42,0.14)";
ctx.lineWidth = 1.5;
let label = "";
if (found.shape.kind === "rect") {
const r = found.shape;
ctx.strokeRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
ctx.fillRect(r.left, r.top, r.right - r.left, r.bottom - r.top);
label = found.label;
} else if (found.shape.kind === "face") {
// Tint every column that shows THIS edge: the whole face answers.
for (let col = 0; col < f.W; col++) {
const c = f.cols[col];
if (!c || c.edge !== found.shape.edge) continue;
ctx.fillRect(col, c.top, 1, c.h);
}
label = found.label;
} else if (found.shape.kind === "none") {
label = found.label;
} else {
// A ground square: its four corners projected onto the floor plane.
const flen = (f.W / 2) / Math.tan(FOV / 2);
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
const { x: cx0, y: cy0 } = found.shape.cell;
const pts: [number, number][] = [];
for (const [ox, oy] of [[0, 0], [1, 0], [1, 1], [0, 1]] as const) {
const rx = cx0 + ox - f.ex, ry = cy0 + oy - f.ey;
const depth = rx * cosF + ry * sinF;
if (depth < 0.12) { pts.length = 0; break; }
const side = -rx * sinF + ry * cosF;
pts.push([f.W / 2 + (side / depth) * flen, half + (f.H / 2) / depth]);
}
if (pts.length === 4) {
ctx.beginPath();
ctx.moveTo(pts[0]![0], pts[0]![1]);
for (const [px, py] of pts.slice(1)) ctx.lineTo(px, py);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
if (label) {
ctx.font = "12px 'Courier Prime', monospace";
const wTxt = ctx.measureText(label).width;
const lx = Math.min(f.W - wTxt - 10, Math.max(4, mouse.x + 10));
const ly = Math.max(16, mouse.y - 8);
ctx.fillStyle = "rgba(13,12,18,0.85)";
ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16);
ctx.fillStyle = "#e9e1cb";
ctx.fillText(label, lx, ly);
}
ctx.restore();
}
interface Projected {
@@ -381,12 +647,26 @@
top: number;
bottom: number;
warped?: boolean;
warpId?: number;
glow?: boolean;
alpha?: number;
fallback?: string;
clampL?: number;
clampR?: number;
hit?: { kind: "player" | "creature"; id: string };
cell?: { x: number; y: number };
/** Sort key only: the near-bias orders sprites among THEMSELVES (a
* hedge over the wizard standing in it) but must never let one
* cheat past a wall — occlusion always uses the true depth. */
sort: number;
}
function project(
b: { x: number; y: number; src: string; scale: number; rise: number; warped?: boolean },
b: { x: number; y: number; src: string; scale: number; rise: number;
aspect?: number; alpha?: number; glow?: boolean; bias?: number;
fallback?: string; warped?: boolean; warpId?: number;
clip?: { x: number; y: number };
hit?: { kind: "player" | "creature"; id: string };
cell?: { x: number; y: number } },
ex: number, ey: number,
): Projected | null {
const relX = b.x - ex, relY = b.y - ey;
@@ -397,14 +677,168 @@
const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2);
const wallH = H / depth;
const size = wallH * b.scale;
// Vertical pixels scale by H/depth, horizontal by focal/depth — and
// they differ. A sprite that declares an aspect means WORLD width
// (a hedge one cell wide must touch both posts), so its width uses
// the horizontal scale; plain square sprites keep their pixel shape.
const wide = b.aspect
? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H)
: size;
const bottom = half + wallH / 2 - b.rise * wallH;
// A cell-bound volume is windowed to its own square: the billboard's
// camera-facing plane is intersected with the cell, and only that
// lateral segment may paint, so an obliquely-viewed hedge cannot
// poke its ends through the neighboring walls. (Virtual warp copies
// skip this — their clip cell lives in another frame.)
let clampL: number | undefined;
let clampR: number | undefined;
if (b.clip && !b.warped) {
const sinF = Math.sin(facing), cosF = Math.cos(facing);
let sMin = -Infinity, sMax = Infinity;
if (Math.abs(sinF) > 1e-6) {
const a1 = (b.x - b.clip.x) / sinF;
const a2 = (b.x - (b.clip.x + 1)) / sinF;
sMin = Math.max(sMin, Math.min(a1, a2));
sMax = Math.min(sMax, Math.max(a1, a2));
}
if (Math.abs(cosF) > 1e-6) {
const a1 = (b.clip.y - b.y) / cosF;
const a2 = (b.clip.y + 1 - b.y) / cosF;
sMin = Math.max(sMin, Math.min(a1, a2));
sMax = Math.min(sMax, Math.max(a1, a2));
}
if (sMin <= sMax && Number.isFinite(sMin) && Number.isFinite(sMax)) {
const sideC = -relX * sinF + relY * cosF;
const fl = (W / 2) / Math.tan(FOV / 2);
const e1 = W / 2 + ((sideC + sMin) / depth) * fl;
const e2 = W / 2 + ((sideC + sMax) / depth) * fl;
clampL = Math.min(e1, e2);
clampR = Math.max(e1, e2);
}
}
return {
src: b.src, depth, warped: b.warped,
left: screenX - size / 2, right: screenX + size / 2,
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
hit: b.hit, cell: b.cell,
left: screenX - wide / 2, right: screenX + wide / 2,
top: bottom - size, bottom,
};
}
/** Resolve a canvas-pixel click to what stood under it: the nearest
* visible sprite, else the struck wall or door face, else the floor
* (or vault) square the pixel lies on — warp-bent columns mapping
* their virtual ground back to real cells through the warp's own
* rigid motion. */
function hitTest(px: number, py: number): FpvTarget | null {
return resolveHover(px, py)?.target ?? null;
}
/** The full answer for a canvas pixel: the target, the screen shape to
* highlight, and the name to whisper beside the crosshair. */
function resolveHover(px: number, py: number): {
target: FpvTarget;
label: string;
shape:
| { kind: "rect"; left: number; right: number; top: number; bottom: number }
| { kind: "face"; edge: string }
| { kind: "ground"; cell: { x: number; y: number } }
| { kind: "none" };
} | null {
const f = hitFrame;
if (!f) return null;
const col = Math.max(0, Math.min(f.W - 1, px | 0));
const half = f.H / 2;
// Sprites first, nearest first, honoring the draw pass's own
// visibility rules for this column.
for (const sp of f.sprites) {
if (!sp.hit && !sp.cell) continue; // pure spectacle (projectiles, rubble)
if (px < sp.left || px >= sp.right || py < sp.top || py > sp.bottom) continue;
if (!spriteVisibleInCol(sp, col, f.zbuf, f.warpIdCol, f.warpDistCol)) continue;
const shape = { kind: "rect" as const, left: sp.left, right: sp.right, top: sp.top, bottom: sp.bottom };
const label = sp.hit ? (sp.hit.kind === "player" ? sp.hit.id : labelOf(sp)) : labelOf(sp);
if (sp.hit) return { target: sp.hit, label, shape };
return { target: { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } }, label, shape };
}
// The wall span: doors and walls answer as their EDGE.
const c = f.cols[col];
if (c && py >= c.top && py <= c.top + c.h) {
if ((c.kind === "wall" || c.kind === "door" || c.kind === "firewall") && c.edge) {
const [kind, coords] = c.edge.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
return {
target: { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" },
label: c.kind === "firewall" ? "wall of fire" : c.kind,
shape: { kind: "face", edge: c.edge },
};
}
if (c.kind === "stone") {
// A stone fill is a square, not an edge: the cell just past the
// struck face along this column's ray.
const t = f.zbuf[col]! + 0.05;
const pt = groundPoint(f, col, t);
return pt ? { target: { kind: "cell", cell: pt }, label: "solid stone", shape: { kind: "ground", cell: pt } } : null;
}
return null; // rims and frame posts are nobody's target
}
// Ground (or vault): each row below the horizon lies at one depth.
const dz = py > half ? py - half : half - py;
if (dz < 1) return null;
const d = (f.H / 2) / dz;
if (d >= f.zbuf[col]!) return null; // past the wall: nothing to click
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
// Warp-bent ground still TARGETS truly, but the highlight quad
// cannot be drawn in this frame's geometry: label it instead.
const pt = groundPoint(f, col, d);
return pt ? { target: { kind: "cell", cell: pt }, label: `through the warp (${pt.x},${pt.y})`, shape: { kind: "none" } } : null;
}
const pt = groundPoint(f, col, d);
return pt ? { target: { kind: "cell", cell: pt }, label: "", shape: { kind: "ground", cell: pt } } : null;
}
/** A sprite's spoken name: the creature or thing under the crosshair. */
function labelOf(sp: Projected): string {
if (sp.hit?.kind === "creature") {
const c = view.creatures.find((k) => k.id === sp.hit!.id);
return c ? c.kind.replace(/-/g, " ") : "creature";
}
if (sp.fallback) return sp.fallback.replace(/-/g, " ");
const m = sp.src.match(/\/([a-z0-9-]+)\.png/i);
return m ? m[1]!.replace(/-/g, " ") : "";
}
/** The real-world square at perpendicular depth d down column col —
* mapping through the column's warp when the ray bent. */
function groundPoint(
f: NonNullable<typeof hitFrame>, col: number, d: number,
): { x: number; y: number } | null {
const flen = (f.W / 2) / Math.tan(FOV / 2);
const side = (col - f.W / 2) * (d / flen);
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
let wx = f.ex + d * cosF - side * sinF;
let wy = f.ey + d * sinF + side * cosF;
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
const w = view.board.warps[f.warpIdCol[col]!];
if (!w) return null;
const real = warpMotion(w).toReal({ x: wx, y: wy });
wx = real.x; wy = real.y;
}
const cell = { x: Math.floor(wx), y: Math.floor(wy) };
return view.board.cells[`${cell.x},${cell.y}`] ? cell : null;
}
function onCanvasClick(e: MouseEvent) {
if (!ontarget || !canvas) return;
const rect = canvas.getBoundingClientRect();
const px = (e.clientX - rect.left) * (width / rect.width);
const py = (e.clientY - rect.top) * (height / rect.height);
const t = hitTest(px, py);
if (t) ontarget(t);
}
// Redraw every frame: the fire flickers and the warps swirl even when
// the camera holds still.
$effect(() => {
@@ -415,9 +849,17 @@
});
</script>
<canvas bind:this={canvas} {width} {height} class="fpv-canvas"></canvas>
<canvas bind:this={canvas} {width} {height} class="fpv-canvas" class:targeting={!!ontarget}
onclick={onCanvasClick}
onpointermove={(e) => {
if (!ontarget || !canvas) return;
const rect = canvas.getBoundingClientRect();
mouse = { x: (e.clientX - rect.left) * (width / rect.width), y: (e.clientY - rect.top) * (height / rect.height) };
}}
onpointerleave={() => (mouse = null)}></canvas>
<style>
.targeting { cursor: crosshair; }
.fpv-canvas {
display: block;
width: 100%;
+28 -3
View File
@@ -14,8 +14,11 @@
/** ?demo=1: two automatons play a stretch, then the reel replays it —
* the real Replay component, toggleable into first person. */
const demoReel = q.has("demo");
// ?players=2..6 sizes the table (5-player boards carry the corner
// warps whose traversals rotate the world a quarter-turn).
const count = Math.min(6, Math.max(2, Number(q.get("players") ?? 3)));
const { state: dealt } = createGame({
playerIds: ["Wanderer", "Rival", "Stranger"],
playerIds: ["Wanderer", "Rival", "Stranger", "Pilgrim", "Vagabond", "Drifter"].slice(0, count),
seed,
sets: ["basic", "expansion1"],
});
@@ -27,13 +30,35 @@
view.knownIllusionEdges.push(k);
delete view.board.edges[k];
}
// ?open=V:2,7 props doors open; ?crack=V:2,7@3 wears damage into walls.
for (const k of (q.get("open") ?? "").split(";").filter(Boolean)) {
view.openDoorEdges.push(k);
}
for (const spec of (q.get("crack") ?? "").split(";").filter(Boolean)) {
const [k, n] = spec.split("@") as [string, string?];
view.wallDamage[k] = Number(n ?? 1) || 1;
}
// ?carry=Rival hands the first treasure to that wizard, for viewing
// what a laden thief looks like.
const carrier = q.get("carry");
if (carrier && view.treasures[0]) {
view.treasures[0] = { ...view.treasures[0], carriedBy: carrier, position: null };
}
// ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer
// (and the artist) to rehearse. dimwarp@x,y opens a floor mouth.
for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) {
const [kind, at] = spec.split("@") as [string, string?];
const [tx, ty] = (at ?? "").split(",").map(Number);
if (!Number.isFinite(tx) || !Number.isFinite(ty)) continue;
if (kind === "dimwarp") view.dimWarps.push({ a: { x: tx!, y: ty! }, b: { x: tx!, y: ty! } });
else view.squareContents[`${tx},${ty}`] = { kind } as (typeof view.squareContents)[string];
}
const start = view.players.find((p) => p.id === povId)!.position;
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
const DIR_ANGLE: Record<string, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
let facing = $state(DIR_ANGLE[q.get("dir") ?? ""] ?? 0);
let facing = $state(SIDE_ANGLE[q.get("dir") as keyof typeof SIDE_ANGLE] ?? 0);
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
// camera moves the same way — a tap glides one square, a turn swings a
+145
View File
@@ -0,0 +1,145 @@
// The directorial eye the reel and the live pane share: given a batch
// of events, where should the camera look — and which bodies glide
// between one view and the next. One ladder, so the replay and the
// table never learn different instincts.
import { edgeMid } from "./raycast";
import { fpFxForEvents } from "./fx3d";
import type { GameEvent, GameView } from "@wizwar/engine";
import { castRay } from "./raycast";
export function shortestArc(from: number, to: number): number {
let d = (to - from) % (2 * Math.PI);
if (d > Math.PI) d -= 2 * Math.PI;
if (d < -Math.PI) d += 2 * Math.PI;
return d;
}
export interface Aim {
x: number;
y: number;
/** The deed lands on the wizard's OWN square a summon at their
* feet, invisible from inside their own eyes. Take the cutaway. */
self?: boolean;
}
/**
* Where this batch's deeds should point the pov wizard's eyes: their
* own casts, attacks, throws, summons, and edge-deeds first; then, when
* the pov is the acting player, wherever the fx layer will visibly play.
* Null when nothing asks to be watched.
*/
export function aimOfEvents(
events: GameEvent[], view: GameView, povId: string,
myCell: { x: number; y: number }, actor: string,
): Aim | null {
for (const e of events) {
let cell: { x: number; y: number } | null = null;
if (e.type === "creatureCreated" && "controller" in e && e.controller === povId) {
cell = (e as { at: { x: number; y: number } }).at;
} else if (e.type === "spellCast" && e.caster === povId) {
cell = e.targetCell ?? view.players.find((p) => p.id === e.target)?.position ?? null;
} else if (e.type === "attackResolved" && e.attacker === povId) {
cell = view.players.find((p) => p.id === e.defender)?.position ?? null;
} else if (e.type === "objectThrown" && e.attacker === povId) {
cell = e.landedAt;
} else if (
// Deeds done to an EDGE — a lock picked or jammed, a door
// unlocked, a wall raised, razed, or drowned — turn the eyes
// to the edge's midpoint just the same.
"edge" in e && typeof e.edge === "object" && e.edge !== null && "cell" in e.edge &&
(("caster" in e && e.caster === povId) || ("player" in e && e.player === povId))
) {
const edge = e.edge as { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" };
const m = edgeMid(edge.cell, edge.side);
// edgeMid sits ON the boundary; step half a texel toward the
// cell so the same-cell check below behaves.
if (Math.abs(m.x - (myCell.x + 0.5)) > 0.3 || Math.abs(m.y - (myCell.y + 0.5)) > 0.3) {
return { x: m.x - 0.5, y: m.y - 0.5 };
}
continue;
}
if (cell && (cell.x !== myCell.x || cell.y !== myCell.y)) return cell;
if (cell) return { ...cell, self: true };
}
// Last resort, only for the pov wizard's own step: wherever the fx
// layer is about to play something visible — an impact, a
// projectile's landing — the eyes should already be.
if (actor === povId) {
for (const e of events) {
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
const to = (e as { to: { x: number; y: number } }).to;
if (to.x !== myCell.x || to.y !== myCell.y) return to;
}
if (
(e.type === "treasurePickedUp" || e.type === "treasureDropped" || e.type === "objectDropped") &&
"player" in e && (e as { player: string }).player === povId
) {
const at = (e as { at: { x: number; y: number } }).at;
return { ...at, self: at.x === myCell.x && at.y === myCell.y };
}
}
for (const { fx } of fpFxForEvents(events, view, povId)) {
const spot = fx.kind === "impact" ? fx.at : fx.kind === "projectile" ? fx.to : null;
if (!spot) continue;
const cx = Math.floor(spot.x), cy = Math.floor(spot.y);
if (cx === myCell.x && cy === myCell.y) return { x: cx, y: cy, self: true };
return { x: cx, y: cy };
}
}
return null;
}
/** Does this batch hurl the pov wizard bodily a blow to glide with,
* eyes held steady, rather than a stride or a cut? */
export function hurledIn(events: GameEvent[], povId: string): boolean {
return events.some((e) =>
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
e.type === "retreatedInHorror") && e.player === povId);
}
export interface Glide {
id: string;
fx: number;
fy: number;
tx: number;
ty: number;
}
/** Bodies that walked between two views: each glides rather than
* blinking cell to cell. A leap across the maze is a teleport and
* simply arrives. */
export function gatherGlides(before: GameView, after: GameView, povId: string): Glide[] {
const moves: Glide[] = [];
const gather = (
id: string,
now: { x: number; y: number },
was: { x: number; y: number } | undefined,
) => {
if (!was) return;
const d = Math.hypot(now.x - was.x, now.y - was.y);
if (d > 0.05 && d <= 3.5) {
moves.push({ id, fx: was.x + 0.5, fy: was.y + 0.5, tx: now.x + 0.5, ty: now.y + 0.5 });
}
};
for (const p of after.players) {
if (!p.alive || p.id === povId) continue;
const was = before.players.find((q) => q.id === p.id && q.alive);
gather(p.id, p.position, was?.position);
}
for (const c of after.creatures) {
gather(c.id, c.position, before.creatures.find((q) => q.id === c.id)?.position);
}
return moves;
}
/** The facing that stares down the longest corridor from a square the
* default view wherever nothing asks to be watched. */
export function deepestFacing(view: GameView, x: number, y: number): number {
let deepest = -1, face = 0;
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(view, x, y, a);
if (h.dist > deepest) { deepest = h.dist; face = a; }
}
return face;
}
+104 -10
View File
@@ -13,12 +13,22 @@ import type { GameEvent, GameView } from "@wizwar/engine";
export type FpFx =
| { id: number; kind: "projectile"; art: string; from: { x: number; y: number }; to: { x: number; y: number }; t0: number; dur: number;
/** This leg flies in a warp mouth's virtual space: visible only
* through the opening, on columns whose rays bent. */
warped?: boolean }
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number }
* through THAT warp's opening, beyond its mouth. */
warped?: boolean; warpId?: number }
| { id: number; kind: "impact"; art: string; at: { x: number; y: number }; t0: number; dur: number;
/** Lifted off the floor (fireworks burst overhead); default 0.3. */
rise?: number }
| { id: number; kind: "flash"; color: string; peak: number; t0: number; dur: number }
| { id: number; kind: "shake"; mag: number; t0: number; dur: number }
| { id: number; kind: "door"; edge: string; t0: number; dur: number };
| { id: number; kind: "door"; edge: string; t0: number; dur: number }
| { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number };
/** Eased growth 0..1 for a conjuration's rise. */
export function growProgress(p: number): number {
if (p <= 0) return 0.01;
if (p >= 1) return 1;
return Math.max(0.01, p * p * (3 - 2 * p));
}
/** How far open an animated door stands at progress p: swings open,
* holds for the crossing, swings shut. */
@@ -42,11 +52,15 @@ let nextId = 1;
const PROJECTILE_DUR: Record<string, number> = { fireball: 420, bolt: 260, waterbolt: 420, spark: 350 };
/** Board-effect kinds that land here as an impact billboard. */
const IMPACT_ART: Record<string, string> = {
burst: "burst", splash: "splash", fireworks: "burst",
hit: "hit", pow: "hit", claw: "hit",
burst: "burst", splash: "splash",
hit: "hit", pow: "hit", claw: "hit", "tacks-ow": "hit", "thorn-snap": "hit",
shimmer: "shimmer", "portal-cell": "shimmer", sparkle: "shimmer",
soul: "shimmer", "chaos-swirl": "shimmer", whiff: "shimmer",
shield: "shield", absorb: "shield",
"ooze-slip": "splash", "slime-stuck": "splash",
"pit-fall": "dust", "dust-puff": "dust",
"edge-dust": "rubble",
// fireworks and die-drop are staged by the raw-event pass below.
};
/** One flight, possibly seen from two rooms: a projectile whose straight
@@ -56,19 +70,20 @@ const IMPACT_ART: Record<string, string> = {
* clipped by the depth buffer to the side the camera stands on. */
function projectileLegs(
view: GameView, a: { x: number; y: number }, b: { x: number; y: number },
): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean }[] {
): { from: { x: number; y: number }; to: { x: number; y: number }; warped?: boolean; warpId?: number }[] {
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 0.05) return [{ from: a, to: b }];
const direct = castRay(view, a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x));
if (!direct.warped && direct.dist >= len - 0.4) return [{ from: a, to: b }];
for (const w of view.board.warps) {
for (let wi = 0; wi < view.board.warps.length; wi++) {
const w = view.board.warps[wi]!;
const motion = warpMotion(w);
const vt = motion.toVirtual(b);
const vlen = Math.hypot(vt.x - a.x, vt.y - a.y);
const hit = castRay(view, a.x, a.y, Math.atan2(vt.y - a.y, vt.x - a.x));
if (hit.warped && hit.dist >= vlen - 0.4) {
return [
{ from: a, to: vt, warped: true },
{ from: a, to: vt, warped: true, warpId: wi },
{ from: motion.toReal(a), to: b },
];
}
@@ -90,7 +105,7 @@ export function fpFxForEvents(
out.push({
fx: {
id: nextId++, kind: "projectile", art,
from: leg.from, to: leg.to, warped: leg.warped,
from: leg.from, to: leg.to, warped: leg.warped, warpId: leg.warpId,
t0: 0, dur: PROJECTILE_DUR[art]!,
},
delay,
@@ -137,6 +152,74 @@ export function fpFxForEvents(
}
}
}
if ((e.type === "wallCreated" || e.type === "firewallCreated" || e.type === "illusionWallCreated") && "edge" in e) {
// Conjured from nothing: the edge RISES from the floor, wrapped in
// dust and shimmer. (An illusion rises too — for the deceived, it
// is a wall in every way.)
const edge = (e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge;
const key = edgeKey(edge.cell, edge.side);
out.push({ fx: { id: nextId++, kind: "grow", slot: "solid", key, t0: 0, dur: 750 }, delay: 0 });
const at = edgeMid(edge.cell, edge.side);
if (e.type === "wallCreated") {
out.push({ fx: { id: nextId++, kind: "impact", art: "rubble", at, t0: 0, dur: 550 }, delay: 0 });
}
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at, t0: 0, dur: 750 }, delay: 0 });
}
if (e.type === "creatureCreated" && "at" in e) {
const at = (e as { at: { x: number; y: number } }).at;
const cid = (e as { creatureId: string }).creatureId;
out.push({ fx: { id: nextId++, kind: "grow", slot: "sprite", key: cid, t0: 0, dur: 750 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
}
if (e.type === "squareFilled" && "cell" in e) {
const cell = (e as { cell: { x: number; y: number } }).cell;
const kind = (e as { kind: string }).kind;
const cellK = `${cell.x},${cell.y}`;
out.push({
fx: { id: nextId++, kind: "grow", slot: kind === "stone" ? "solid" : "sprite", key: cellK, t0: 0, dur: 750 },
delay: 0,
});
out.push({ fx: { id: nextId++, kind: "impact", art: "shimmer", at: { x: cell.x + 0.5, y: cell.y + 0.5 }, t0: 0, dur: 750 }, delay: 0 });
}
if (e.type === "sectorRotated" || e.type === "sectorRelocated") {
// The maze ITSELF moves: the whole world flashes violet and heaves.
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.5, t0: 0, dur: 900 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.09, t0: 0, dur: 800 }, delay: 0 });
}
if (e.type === "thumbOfGod" && "landedAt" in e) {
const at = (e as { landedAt: { x: number; y: number } }).landedAt;
out.push({ fx: { id: nextId++, kind: "impact", art: "burst", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 700 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.07, t0: 0, dur: 600 }, delay: 0 });
}
if (e.type === "gameWon" && "player" in e) {
// Victory earns real fireworks: bursts climbing over the winner's
// home, gold washing the sky, again and again.
const home = view.players.find((p) => p.id === (e as { player: string }).player)?.home;
const SPREAD = [[0, 0], [0.35, -0.25], [-0.35, 0.2], [0.2, 0.35], [-0.25, -0.3], [0.1, -0.1]];
for (let i = 0; i < 6; i++) {
if (home) {
out.push({
fx: {
id: nextId++, kind: "impact", art: "burst",
at: { x: home.x + 0.5 + SPREAD[i]![0]!, y: home.y + 0.5 + SPREAD[i]![1]! },
rise: 0.45 + (i % 3) * 0.22, t0: 0, dur: 850,
},
delay: i * 300,
});
}
out.push({ fx: { id: nextId++, kind: "flash", color: "#e0b34a", peak: 0.22, t0: 0, dur: 550 }, delay: i * 300 + 120 });
}
}
if ((e.type === "treasureDropped" || e.type === "objectDropped") && "at" in e) {
// Gold hits the flags: a glint where it lands, a beat after the
// blow when one knocked it loose.
const at = (e as { at: { x: number; y: number } }).at;
const spilled = events.some((x) => x.type === "attackResolved" || x.type === "damaged");
out.push({
fx: { id: nextId++, kind: "impact", art: "spark", at: { x: at.x + 0.5, y: at.y + 0.5 }, t0: 0, dur: 500 },
delay: spilled ? 400 : 0,
});
}
if (e.type === "wallDestroyed" && "edge" in e) {
const at = edgeMid((e as { edge: { cell: { x: number; y: number }; side: "N" | "E" | "S" | "W" } }).edge.cell,
(e as { edge: { side: "N" | "E" | "S" | "W" } }).edge.side);
@@ -153,6 +236,17 @@ export function fpFxForEvents(
out.push({ fx: { id: nextId++, kind: "shake", mag: Math.min(0.09, 0.03 + 0.015 * e.amount), t0: 0, dur: 420 }, delay: povDelay });
} else if (e.type === "lifeGained" && e.player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#2a9a50", peak: 0.22, t0: 0, dur: 500 }, delay: povDelay });
} else if (e.type === "died" && "player" in e && (e as { player: string }).player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#100812", peak: 0.6, t0: 0, dur: 1100 }, delay: povDelay });
} else if (e.type === "fellInPit" && e.player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#201810", peak: 0.5, t0: 0, dur: 500 }, delay: 0 });
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.06, t0: 0, dur: 450 }, delay: 0 });
} else if (
(e.type === "slippedInOoze" || e.type === "steppedOnTacks" ||
e.type === "enteredThornbush" || e.type === "stuckInSlime") &&
"player" in e && (e as { player: string }).player === povId
) {
out.push({ fx: { id: nextId++, kind: "shake", mag: 0.04, t0: 0, dur: 350 }, delay: 0 });
} else if (e.type === "teleported" && e.player === povId) {
out.push({ fx: { id: nextId++, kind: "flash", color: "#8050d0", peak: 0.35, t0: 0, dur: 400 }, delay: 0 });
} else if (e.type === "moved" && e.via === "warp" && e.player === povId) {
+206 -40
View File
@@ -7,13 +7,13 @@
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
import type { GameView } from "@wizwar/engine";
import { objectArt, TERRAIN_ART } from "../art";
import { objectArt } from "../art";
export interface Hit {
/** Distance along the ray (perpendicular-corrected by the caller). */
dist: number;
/** What the ray struck. */
kind: "wall" | "door" | "firewall" | "stone" | "rim" | "warp";
kind: "wall" | "door" | "firewall" | "stone" | "rim";
/** 0..1 across the struck face (texture coordinate). */
u: number;
/** Vertical faces get a different shade than horizontal ones. */
@@ -26,14 +26,48 @@ export interface Hit {
edge?: string;
/** The eye reached this through a warp: haze it other-worldly. */
warped?: boolean;
/** Which warp (index in board.warps) the ray first bent through, how
* far along the ray that mouth stood, and where across it the gate a
* sprite must be beyond (its own warp) or in front of (any) to paint
* this column, and the veil the renderer hangs in the opening. */
warpId?: number;
warpDist?: number;
warpU?: number;
/** The first known-illusion edge the ray crossed before its solid hit:
* the eye passes, but a translucent ghost of a wall stands there. */
ghost?: { dist: number; u: number; worldU: number; axis: "x" | "y" };
/** The first OPEN doorway the ray passed through: the renderer hangs
* the lintel of the frame across it. */
doorway?: { dist: number; u: number };
/** Something conjured is still RISING here: the ray passes over it,
* and the renderer draws it growing bottom-up where it stood. */
rising?: { dist: number; u: number; worldU: number; kind: Hit["kind"]; g: number };
/** The struck slab is a doorway's jamb post, dressed in frame stone. */
frame?: boolean;
}
export const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
export const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
/** Unit vector along an edge's lane axis (the direction `along` grows). */
const LANE_AXIS: Record<Side, [number, number]> = {
E: [0, 1], W: [0, 1], N: [1, 0], S: [1, 0],
};
/** Does this warp pair MIRROR the lane? A traversal is a proper rigid
* rotation; for half the side pairings the rotated entry axis points
* AGAINST the exit edge's axis, and preserving the raw lane there would
* smuggle in a reflection rays crossing over at the mouth, parallax
* inverted, the far room stretching as the eye moves. */
export function warpLaneMirrored(from: Side, to: Side): boolean {
const d = SIDE_ANGLE[OPPOSITE[to]] - SIDE_ANGLE[from];
const [x, y] = LANE_AXIS[from];
const rx = x * Math.cos(d) - y * Math.sin(d);
const ry = x * Math.sin(d) + y * Math.cos(d);
const [ux, uy] = LANE_AXIS[to];
return rx * ux + ry * uy < 0;
}
/** Is this edge passable to the EYE (rays), and if not, what is it?
* Doors always report as doors; how far each stands open fully for an
* open or held door, mid-swing during an animation is castRay's call. */
@@ -45,11 +79,6 @@ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
return "wall";
}
/**
* March one ray from (ox, oy) at `angle` and return the first thing that
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
* is a warp mouth (then a shimmering opening).
*/
/** Half-thickness per material: walls are masonry, doors joinery, fire a
* sheet. The slab gives every wall END a visible cap face, so corners and
* doorways read as three-dimensional stone rather than paper. */
@@ -73,19 +102,36 @@ function slabHit(
return { t: Math.max(tNear, 0), axis: tx1 > ty1 ? "x" : "y" };
}
/** What a slab test found: the strike, and how the door slid under it. */
type SlabStrike = { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string; frame?: boolean };
/**
* March one ray from (ox, oy) at `angle` and return the first thing that
* stops the eye. Off-board is the maze's rim: a wall, unless the crossing
* is a warp mouth the ray passes through those, remembering the first.
*/
export function castRay(
view: GameView, ox: number, oy: number, angle: number,
/** Transient door openness (0 shut - 1 wide), keyed by edge; doors the
* view holds open stand at 1 without an entry. Wolf3D-style: an open
* door has slid along its own edge, the gap growing from u=0. */
doors?: Record<string, number>,
/** Conjurations mid-growth (0..1), keyed by edge key or stone cell
* key: while below 1 the ray passes, and the hit records a rising. */
growing?: Record<string, number>,
): Hit {
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
let baseDist = 0;
let warped = false;
let warpId: number | undefined;
let warpDist: number | undefined;
let warpU: number | undefined;
let ghost: Hit["ghost"];
let doorway: Hit["doorway"];
let rising: Hit["rising"];
for (let traversal = 0; traversal < 3; traversal++) {
let traversed = false;
const dx = Math.cos(angle);
const dy = Math.sin(angle);
let cx = Math.floor(ox);
@@ -101,25 +147,46 @@ export function castRay(
// Every blocked edge of the current cell stands as a slab; the
// nearest strike inside this cell's span wins.
const exit = Math.min(sideDistX, sideDistY);
let best: { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string } | null = null;
let best: SlabStrike | null = null;
const trySide = (side: Side, minX: number, maxX: number, minY: number, maxY: number) => {
const key = edgeKey({ x: cx, y: cy }, side);
const kind = edgeObstacle(view, key);
if (!kind) return;
const h = slabHit(ox, oy, dx, dy, minX, maxX, minY, maxY);
if (!h || h.t > exit + 0.15 || (best && h.t >= best.t)) return;
const g = growing?.[key];
if (g !== undefined && g < 1) {
// Still being conjured: the eye passes; the growth is drawn.
if (!rising) {
const along0 = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
rising = { dist: baseDist + h.t, u: along0 - Math.floor(along0), worldU: along0, kind, g };
}
return;
}
let slide = 0;
let frame = false;
if (kind === "door") {
const open = doors?.[key] ??
(view.openDoorEdges.includes(key) || view.heldDoorEdges.includes(key) ? 1 : 0);
if (open > 0) {
if (open >= 0.98) {
// A door standing open is a DOORWAY: posts at both ends, a
// lintel hung across, and the way through open to the eye.
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
const u = along - Math.floor(along);
const JAMB = 0.08;
if (u > JAMB && u < 1 - JAMB) {
if (!doorway) doorway = { dist: baseDist + h.t, u };
return;
}
frame = true;
} else if (open > 0) {
const along = h.axis === "x" ? oy + h.t * dy : ox + h.t * dx;
const u = along - Math.floor(along);
if (u < open - 0.02) return; // the ray sails through the opening
slide = open;
}
}
best = { ...h, kind, slide, edge: key };
best = { ...h, kind, slide, edge: key, frame };
};
const hw = (side: Side) =>
HALF_THICK[edgeObstacle(view, edgeKey({ x: cx, y: cy }, side)) ?? "wall"] ?? 0.07;
@@ -128,13 +195,14 @@ export function castRay(
trySide("S", cx, cx + 1, cy + 1 - hw("S"), cy + 1 + hw("S"));
trySide("N", cx, cx + 1, cy - hw("N"), cy + hw("N"));
if (best !== null) {
const b = best as { t: number; axis: "x" | "y"; kind: Hit["kind"]; slide: number; edge: string };
const b = best as SlabStrike;
const along = b.axis === "x" ? oy + b.t * dy : ox + b.t * dx;
const u = along - Math.floor(along);
// A sliding door carries its texture with it: sample past the gap.
return {
dist: baseDist + b.t, kind: b.kind, u: Math.max(0, u - b.slide),
axis: b.axis, worldU: along, edge: b.edge, warped, ghost,
axis: b.axis, worldU: along, edge: b.edge, warped, warpId, warpDist, warpU, ghost, doorway, rising,
...(b.frame ? { frame: true } : {}),
};
}
@@ -156,34 +224,42 @@ export function castRay(
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
if (offBoard) {
const side: Side = crossSide;
const warp = view.board.warps.find(
const wIdx = view.board.warps.findIndex(
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
);
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, ghost };
const warp = wIdx >= 0 ? view.board.warps[wIdx]! : undefined;
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
if (warpId === undefined) { warpId = wIdx; warpDist = baseDist + dist; warpU = texU; }
// Step through: re-enter at the paired mouth, heading inward, the
// offset along the edge preserved (a wraparound keeps its lane).
// lane carried by the same proper rotation the heading turns by —
// mirrored for the pairings whose axes land head-to-head.
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
const c = warp.to.cell;
const lane = texU;
const lane = warpLaneMirrored(warp.from.side, warp.to.side) ? 1 - texU : texU;
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + lane; }
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + lane; }
else if (warp.to.side === "S") { ox = c.x + lane; oy = c.y + 1 - 1e-4; }
else { ox = c.x + lane; oy = c.y + 1e-4; }
baseDist += dist;
warped = true;
i = 64; // restart the DDA from the far mouth
break;
traversed = true;
break; // restart the DDA from the far mouth
}
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, ghost };
const sg = growing?.[cellKey({ x: nx, y: ny })];
if (sg !== undefined && sg < 1) {
if (!rising) rising = { dist: baseDist + dist, u: texU, worldU: along, kind: "stone", g: sg };
} else {
return { dist: baseDist + dist, kind: "stone", u: texU, axis, worldU: along, warped, warpId, warpDist, warpU, ghost, doorway, rising };
}
}
cx = nx;
cy = ny;
}
if (!warped || traversal === 2) break;
if (!traversed) break;
}
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, ghost };
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", worldU: 0, warped, warpId, warpDist, warpU, ghost, doorway, rising };
}
/** Can a wizard's body (not just their eye) cross this edge? Workshop
@@ -216,6 +292,14 @@ export function edgeMid(cell: Cell, side: Side): { x: number; y: number } {
return { x: cell.x + 0.5, y: cell.y };
}
/** What a click in the first-person pane resolved to the pane speaks
* the board's own click vocabulary. */
export type FpvTarget =
| { kind: "player"; id: string }
| { kind: "creature"; id: string }
| { kind: "edge"; cell: Cell; side: Side }
| { kind: "cell"; cell: Cell };
export interface Billboard {
/** World position (cell-centered). */
x: number;
@@ -227,8 +311,34 @@ export interface Billboard {
/** Lifted off the floor (0 = feet on the ground). */
rise: number;
label: string;
/** A reflection seen through a warp mouth, not the thing itself. */
/** Width as a multiple of height (1 = square; 2 = a cell-wide hedge). */
aspect?: number;
/** Translucency (jello, dust); 1 or absent is opaque. */
alpha?: number;
/** Drawn additively, as light (warp pillars). */
glow?: boolean;
/** Pulled this much nearer for draw order: a hedge wins the tie
* against the wizard standing in its own square, who then peeks over. */
bias?: number;
/** Procedural stand-in name while the painted file loads. */
fallback?: string;
/** Identity for growth animation: a creature's id, or a cell key. */
key?: string;
/** Cell-bound volumes never paint outside their own square: a
* camera-facing hedge viewed obliquely would otherwise poke its ends
* through the neighboring walls. */
clip?: { x: number; y: number };
/** A reflection seen through a warp mouth, not the thing itself
* visible only through THAT warp's columns, beyond its mouth. */
warped?: boolean;
warpId?: number;
/** Click identity: the specific body this sprite is (a wizard, a
* creature) the pane's hit test hands it straight to the board's
* own click handlers. */
hit?: { kind: "player" | "creature"; id: string };
/** Click fallback: the REAL square this sprite stands in (a virtual
* warp copy keeps its source's square, not its drawn position). */
cell?: Cell;
}
/** The along=0 corner of a warp mouth the anchor castRay's lane
@@ -250,7 +360,15 @@ export function warpMotion(w: GameView["board"]["warps"][number]): {
} {
const delta = SIDE_ANGLE[OPPOSITE[w.to.side]] - SIDE_ANGLE[w.from.side];
const a1 = mouthAnchor(w.from);
const a2 = mouthAnchor(w.to);
// Mirrored pairings anchor the far mouth at its OTHER corner — the
// lane runs backwards there, and this keeps the motion a pure
// rotation matching castRay's traversal.
const a2 = { ...mouthAnchor(w.to) };
if (warpLaneMirrored(w.from.side, w.to.side)) {
const [lx, ly] = LANE_AXIS[w.to.side];
a2.x += lx;
a2.y += ly;
}
const c = Math.cos(delta), s = Math.sin(delta);
return {
toVirtual(p) {
@@ -281,36 +399,83 @@ export function billboards(
out.push({
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
hit: { kind: "player", id: p.id }, cell: { ...p.position },
});
}
for (const c of view.creatures) {
if (c.id === povId) continue; // possessed: you do not see your own hide
const o = posOverride?.[c.id];
out.push({
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind,
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id,
hit: { kind: "creature", id: c.id }, cell: { ...c.position },
});
}
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
const src = art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects");
if (t.carriedBy) {
// A carried treasure rides its carrier at chest height, drawn just
// in front of them — the most important status in the game should
// be visible on the thief's own body.
const carrier = view.players.find((p) => p.id === t.carriedBy && p.alive);
if (!carrier || carrier.id === povId) continue;
const o = posOverride?.[carrier.id];
out.push({
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5,
src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
hit: { kind: "player", id: carrier.id }, cell: { ...carrier.position },
});
continue;
}
if (!t.position) continue;
out.push({
x: t.position.x + 0.5, y: t.position.y + 0.5,
src: art(`treasure-${(view.players.find((p) => p.id === t.owner)?.colorIndex ?? 0) % 6}`, "objects"),
scale: 0.4, rise: 0, label: "treasure",
src, scale: 0.4, rise: 0, label: "treasure", cell: { ...t.position },
});
}
// The floor's furniture: bushes stand tall, hazards squat low. Stone is
// a wall to the rays and needs no sprite.
const TERRAIN_SCALE: Record<string, number> = {
thornbush: 0.7, rosebush: 0.7, safe: 0.55, ooze: 0.35, slime: 0.3,
tacks: 0.25, pit: 0.35, dust: 0.6,
};
// The floor's furniture, standing in the room. Bushes fill the square
// to half height (a wizard peeks over); the killer ooze is a whole
// translucent cube of jello; dust billows. Pits, slime, tacks, and
// dimensional warps are painted INTO the floor by the decal pass, and
// stone is a wall to the rays — none of those need a sprite.
for (const [k, content] of Object.entries(view.squareContents)) {
if (content.kind === "stone") continue;
const file = TERRAIN_ART[content.kind];
const scale = TERRAIN_SCALE[content.kind];
if (!file || !scale) continue;
const [tx, ty] = k.split(",").map(Number) as [number, number];
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "terrain"), scale, rise: 0, label: content.kind });
const at = { x: tx + 0.5, y: ty + 0.5 };
if (content.kind === "thornbush" || content.kind === "rosebush") {
out.push({
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
});
} else if (content.kind === "ooze") {
out.push({
...at, src: "/terrain3d/jello.png", fallback: "jello",
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
});
} else if (content.kind === "dust") {
out.push({
...at, src: "/terrain3d/dust.png", fallback: "dust",
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
});
} else if (content.kind === "safe") {
out.push({
...at, src: "/terrain3d/safe.png", fallback: "safe",
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
});
}
}
// Dimensional warp mouths throw pillars of light from their floor rings.
for (const w of view.dimWarps) {
for (const cellAt of [w.a, w.b]) {
out.push({
x: cellAt.x + 0.5, y: cellAt.y + 0.5,
src: "/terrain3d/warpglow.png", fallback: "warpglow",
scale: 0.95, aspect: 0.5, rise: 0, alpha: 0.6, glow: true, label: "dimwarp",
});
}
}
for (const [k, cards] of Object.entries(view.groundObjects)) {
const file = cards[0] ? objectArt(cards[0].cardId) : null;
@@ -323,7 +488,8 @@ export function billboards(
// the same rigid motion the rays use — the zbuffer clips it to the
// opening, since everything around the mouth is nearer wall.
const real = out.slice();
for (const w of view.board.warps) {
for (let wi = 0; wi < view.board.warps.length; wi++) {
const w = view.board.warps[wi]!;
const motion = warpMotion(w);
const a2 = mouthAnchor(w.to);
const inward = SIDE_ANGLE[OPPOSITE[w.to.side]];
@@ -331,7 +497,7 @@ export function billboards(
for (const b of real) {
const rx = b.x - a2.x, ry = b.y - a2.y;
if (rx * inX + ry * inY < -0.2 || Math.hypot(rx, ry) > 8) continue;
out.push({ ...b, ...motion.toVirtual(b), warped: true });
out.push({ ...b, ...motion.toVirtual(b), warped: true, warpId: wi });
}
}
return out;
+109
View File
@@ -0,0 +1,109 @@
// Terrain that stands in the room rather than lying on its floor: bushes
// filling a square to half height, the killer ooze as a translucent cube
// of jello, dust billowing, dimensional warps throwing light. Each is a
// paintable sprite at public/terrain3d/<name>.png (transparent PNG, any
// size); the bakes below stand in until painted.
export const TERRAIN3D = ["thornbush", "rosebush", "jello", "dust", "warpglow", "safe"] as const;
const baked = new Map<string, HTMLCanvasElement>();
export function terrainFallback(name: string): HTMLCanvasElement {
let t = baked.get(name);
if (t) return t;
t = document.createElement("canvas");
t.width = 128;
t.height = 64;
const c = t.getContext("2d")!;
const h2 = (a: number, b: number) => {
const n = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
return n - Math.floor(n);
};
if (name === "thornbush" || name === "rosebush") {
// A hedge of tangles wall to wall; roses get their blooms.
for (let i = 0; i < 40; i++) {
const x = h2(i, 1) * 128, y = 16 + h2(i, 3) * 46, r = 6 + h2(i, 5) * 9;
const g = 60 + (h2(i, 7) * 50 | 0);
c.fillStyle = `rgba(${g * 0.35 | 0},${g},${g * 0.3 | 0},0.9)`;
c.beginPath();
c.arc(x, y, r, 0, Math.PI * 2);
c.fill();
}
c.strokeStyle = "rgba(30,40,18,0.7)";
for (let i = 0; i < 14; i++) {
c.beginPath();
c.moveTo(h2(i, 11) * 128, 18 + h2(i, 13) * 40);
c.lineTo(h2(i, 11) * 128 + 10 - h2(i, 17) * 20, 8 + h2(i, 19) * 40);
c.stroke();
}
if (name === "rosebush") {
for (let i = 0; i < 9; i++) {
c.fillStyle = "rgba(210,40,70,0.95)";
c.beginPath();
c.arc(6 + h2(i, 23) * 116, 14 + h2(i, 29) * 40, 3.5, 0, Math.PI * 2);
c.fill();
}
}
} else if (name === "jello") {
// The gelatinous whole of the killer ooze, drawn as one cube face
// with a paler top edge — the renderer's alpha keeps it see-through.
t.height = 128;
const g2 = t.getContext("2d")!;
g2.fillStyle = "rgba(90,190,70,0.85)";
g2.fillRect(4, 12, 120, 112);
g2.fillStyle = "rgba(170,240,140,0.9)";
g2.fillRect(4, 12, 120, 10);
g2.fillStyle = "rgba(230,255,210,0.5)";
g2.fillRect(16, 30, 22, 12);
for (let i = 0; i < 8; i++) {
g2.fillStyle = "rgba(40,120,30,0.5)";
g2.beginPath();
g2.arc(16 + h2(i, 31) * 96, 40 + h2(i, 37) * 72, 3 + h2(i, 41) * 3, 0, Math.PI * 2);
g2.fill();
}
} else if (name === "dust") {
t.height = 128;
const g2 = t.getContext("2d")!;
for (let i = 0; i < 26; i++) {
const a = 0.12 + h2(i, 43) * 0.16;
g2.fillStyle = `rgba(200,190,170,${a})`;
g2.beginPath();
g2.arc(14 + h2(i, 47) * 100, 14 + h2(i, 53) * 100, 12 + h2(i, 59) * 16, 0, Math.PI * 2);
g2.fill();
}
} else if (name === "safe") {
// A squat iron strongbox with its combination dial.
t.width = 128;
t.height = 128;
const g2 = t.getContext("2d")!;
g2.fillStyle = "#3c3f46";
g2.fillRect(14, 34, 100, 90);
g2.fillStyle = "#54585f";
g2.fillRect(20, 40, 88, 78);
g2.strokeStyle = "#23252a";
g2.lineWidth = 4;
g2.strokeRect(16, 36, 96, 86);
g2.fillStyle = "#8b8f96";
g2.beginPath();
g2.arc(64, 78, 16, 0, Math.PI * 2);
g2.fill();
g2.fillStyle = "#2a2c31";
g2.beginPath();
g2.arc(64, 78, 10, 0, Math.PI * 2);
g2.fill();
g2.fillStyle = "#8b8f96";
g2.fillRect(96, 66, 8, 24);
} else {
// warpglow: a pillar of violet light, brightest at its core.
t.width = 64;
t.height = 128;
const g2 = t.getContext("2d")!;
const grad = g2.createLinearGradient(0, 0, 64, 0);
grad.addColorStop(0, "rgba(140,80,220,0)");
grad.addColorStop(0.5, "rgba(200,160,255,0.75)");
grad.addColorStop(1, "rgba(140,80,220,0)");
g2.fillStyle = grad;
g2.fillRect(0, 0, 64, 128);
}
baked.set(name, t);
return t;
}
+112 -5
View File
@@ -8,13 +8,11 @@ const FACE: Record<string, [number, number, number]> = {
stone: [96, 96, 104],
door: [130, 92, 48],
rim: [82, 76, 66],
firewall: [214, 92, 28],
warp: [96, 60, 160],
};
// Textures are baked once at 64x64 and sampled one column at a time —
// perspective-correct verticals for the price of a single drawImage.
export const TEX = 64;
const TEX = 64;
function bake(paint: (c: CanvasRenderingContext2D) => void): HTMLCanvasElement {
const t = document.createElement("canvas");
t.width = TEX; t.height = TEX;
@@ -42,10 +40,10 @@ function brickTexture(base: [number, number, number], rows: number, mortar: stri
for (let r = 0; r <= rows; r++) c.fillRect(0, r * rh - 1, TEX, 2);
});
}
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "floor", "ceiling", "home"] as const;
export const MATERIALS = ["wall", "rim", "stone", "door", "firewall", "warp", "doorframe", "cracks", "floor", "ceiling", "home", "pit", "slime", "tacks", "dimwarp", "underbrush"] as const;
/** The built-in bake: what ships when no painted file overrides it. */
export function proceduralTextures(): Record<string, HTMLCanvasElement> {
function proceduralTextures(): Record<string, HTMLCanvasElement> {
return {
wall: brickTexture(FACE.wall!, 4, "rgba(28,24,18,0.9)"),
rim: brickTexture(FACE.rim!, 3, "rgba(16,14,12,0.95)"),
@@ -127,6 +125,115 @@ export function proceduralTextures(): Record<string, HTMLCanvasElement> {
c.arc(TEX / 2, TEX / 2, 7, 0, Math.PI * 2);
c.fill();
}),
underbrush: bake((c) => {
// The ground beneath a hedge: leaf litter and roots to the square's
// edges, so the standing tangle above never floats on bare flags.
for (let i = 0; i < 46; i++) {
const g = 40 + (h2(i, 61) * 45 | 0);
c.fillStyle = `rgba(${g * 0.4 | 0},${g},${g * 0.35 | 0},${0.5 + h2(i, 67) * 0.4})`;
c.beginPath();
c.ellipse(h2(i, 71) * TEX, h2(i, 73) * TEX, 3 + h2(i, 79) * 6, 2 + h2(i, 83) * 4,
h2(i, 89) * Math.PI, 0, Math.PI * 2);
c.fill();
}
}),
doorframe: bake((c) => {
// An empty doorway's dressing: stone posts up both sides and a
// lintel across the top, transparent in the middle where the way
// through stands open. Posts live in the outer ~8% columns; the
// lintel in the top ~16% rows.
const stone = (x: number, y: number, w: number, h: number, jit: number) => {
const g = 120 * (0.85 + 0.25 * h2(jit, 3)) | 0;
c.fillStyle = `rgb(${g},${g * 0.93 | 0},${g * 0.8 | 0})`;
c.fillRect(x, y, w, h);
c.strokeStyle = "rgba(30,24,16,0.8)";
c.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
};
for (let r = 0; r < 5; r++) {
stone(0, 10 + r * 11, 6, 10, r);
stone(TEX - 6, 10 + r * 11, 6, 10, r + 7);
}
stone(0, 0, TEX / 2, 9, 13);
stone(TEX / 2, 0, TEX / 2, 9, 17);
}),
cracks: bake((c) => {
// Battle damage, worn over any wall by accumulated harm: dark
// fractures on transparency, drawn heavier as damage mounts.
c.strokeStyle = "rgba(14,10,6,0.85)";
c.lineWidth = 1.6;
for (let i = 0; i < 5; i++) {
let x = h2(i, 21) * TEX, y = 0;
c.beginPath();
c.moveTo(x, y);
while (y < TEX) {
x += (h2(x, y) - 0.5) * 14;
y += 6 + h2(y, x) * 8;
c.lineTo(x, y);
}
c.stroke();
}
}),
pit: bake((c) => {
// A hole in the world: black heart, ragged broken-flag edge, a rim
// highlight on the lit side selling the depth.
c.fillStyle = "rgba(0,0,0,0.92)";
c.beginPath();
c.ellipse(TEX / 2, TEX / 2, TEX * 0.38, TEX * 0.34, 0, 0, Math.PI * 2);
c.fill();
c.strokeStyle = "rgba(20,16,10,0.9)";
c.lineWidth = 4;
c.stroke();
c.strokeStyle = "rgba(180,168,140,0.5)";
c.lineWidth = 2;
c.beginPath();
c.ellipse(TEX / 2, TEX / 2 - 2, TEX * 0.38, TEX * 0.34, 0, Math.PI * 1.1, Math.PI * 1.9);
c.stroke();
}),
slime: bake((c) => {
// A spill of green across the flags, glistening.
c.fillStyle = "rgba(70,140,40,0.55)";
c.beginPath();
c.ellipse(TEX * 0.5, TEX * 0.52, TEX * 0.4, TEX * 0.32, 0.4, 0, Math.PI * 2);
c.fill();
c.fillStyle = "rgba(110,190,60,0.5)";
for (let i = 0; i < 6; i++) {
c.beginPath();
c.ellipse(10 + h2(i, 2) * 44, 12 + h2(i, 5) * 40, 4 + h2(i, 7) * 5, 3 + h2(i, 9) * 4, 0, 0, Math.PI * 2);
c.fill();
}
c.fillStyle = "rgba(220,255,200,0.35)";
c.fillRect(TEX * 0.32, TEX * 0.4, 5, 2);
}),
tacks: bake((c) => {
// A scatter of little iron cruelties.
for (let i = 0; i < 14; i++) {
const x = 8 + h2(i, 3) * 48, y = 8 + h2(i, 6) * 48;
c.fillStyle = "rgba(70,70,78,0.9)";
c.beginPath();
c.moveTo(x, y - 3);
c.lineTo(x + 3, y + 2);
c.lineTo(x - 3, y + 2);
c.closePath();
c.fill();
c.fillStyle = "rgba(190,190,200,0.8)";
c.fillRect(x - 1, y - 4, 2, 2);
}
}),
dimwarp: bake((c) => {
// The dimensional warp's floor mouth: violet rings drawing inward.
for (let i = 5; i >= 0; i--) {
const a = 0.16 + i * 0.1;
c.strokeStyle = `rgba(${140 + i * 12},${80 + i * 14},${220},${a})`;
c.lineWidth = 3;
c.beginPath();
c.arc(TEX / 2, TEX / 2, 5 + i * 5, 0, Math.PI * 2);
c.stroke();
}
c.fillStyle = "rgba(240,225,255,0.8)";
c.beginPath();
c.arc(TEX / 2, TEX / 2, 3.5, 0, Math.PI * 2);
c.fill();
}),
warp: bake((c) => {
const grad = c.createLinearGradient(0, 0, TEX, TEX);
grad.addColorStop(0, "#2c1a4e");
+83 -46
View File
@@ -103,6 +103,16 @@ export function humanize(e: GameEvent): string | null {
case "doorHeld": return `${e.player} holds the door open.`;
case "doorReleased": return `The held door swings shut.`;
case "doorsRelocked": return `The door swings shut and relocks.`;
case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
case "cardsStolenPrivate": return e.cards.length > 0
? `Stolen into your hand: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
: null;
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
case "doorJammed": return `${e.player} jams a door's lock solid.`;
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
@@ -215,19 +225,6 @@ export interface LogLine {
* momentSteps, so a chronicle turn number addresses the same commands. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
/** Does this event make a turn worth reliving? Combat and spectacle. */
export function isNotableEvent(e: GameEvent): boolean {
if (e.type === "spellCast") return e.target !== null || e.targetCell !== null;
return NOTABLE_EVENTS.has(e.type);
}
const NOTABLE_EVENTS = new Set([
"punched", "attackResolved", "damaged", "died", "knockedBack", "shoved",
"washedBack", "retreatedInHorror", "creatureAttacked", "boobytrapSprung",
"firewallBurned", "objectThrown", "gameWon", "positionsSwapped",
"teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater",
"waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped",
]);
const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats";
const CHAT_SEEN_KEY = "wizwar-chat-seen";
@@ -314,16 +311,16 @@ class Net {
onFx: ((events: GameEvent[]) => void) | null = null;
/** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** One turn's reel, summoned from a chronicle line's instant-replay eye. */
moment = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** One turn's reel, summoned from a chronicle line's instant-replay
* eye steps plus the wizard whose eyes the camera wears. */
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
/** Turns witnessed so far: counts the same boundary events the server
* counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1;
/** The turn that already carries an instant-replay eye (one per turn). */
private eyeTurn = -1;
/** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null;
private shareReject: ((e: Error) => void) | null = null;
private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
@@ -424,11 +421,12 @@ class Net {
this.catchUp = msg.steps;
break;
case "moment":
this.moment = msg.steps;
this.moment = { steps: msg.steps, owner: msg.owner };
break;
case "share":
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
this.shareResolve = null;
this.shareReject = null;
break;
case "events": {
let talk = 0;
@@ -441,8 +439,11 @@ class Net {
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
const line = humanize(e);
if (line) {
const notable = this.turnCounter >= 0 && this.turnCounter !== this.eyeTurn && isNotableEvent(e);
if (notable) this.eyeTurn = this.turnCounter;
// Every turn wears its eye, on the header line that opens
// it — the players judge what is share-worthy. The game-
// winning line carries its own besides.
const notable = this.turnCounter >= 0 &&
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
}
}
@@ -452,6 +453,14 @@ class Net {
}
break;
}
case "kicked":
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId}`, turn: null, notable: false }];
this.leaveLocal();
break;
case "roomAbandoned":
this.log = [...this.log, { text: `— the host closed room ${msg.roomId}`, turn: null, notable: false }];
this.leaveLocal();
break;
case "transferCode":
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
break;
@@ -480,24 +489,31 @@ class Net {
if (g.yourTurn && !was) this.notifyTurn(g);
this.lastYourTurn.set(key, g.yourTurn);
}
// A seat the server answered for but did not list is gone — the
// room was deleted (or the seat's token voided). Quietly drop it
// from the ledger rather than showing "unreachable" forever.
// (The server checks at most MAX_MYGAMES_SEATS = 50 per ask;
// never prune blind past that cap.)
if (this.seats.length <= 50) {
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`));
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`));
// Prune ONLY seats the server proved dead: the room exists and
// refused this exact token. A seat merely ABSENT from the reply
// stays — a restarting server, a stale restore, or the wrong
// backend all answer with ignorance, and ignorance is not
// deletion. The wallet is capped by age instead of by trust.
const voided = new Set((msg.voided as string[] | undefined) ?? []);
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
if (kept.length > 50) kept = kept.slice(kept.length - 50);
if (kept.length !== this.seats.length) {
this.seats = kept;
saveSeats(this.seats);
}
}
break;
}
case "error":
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
// "Name is taken" means THIS token was tried and refused: the
// credential itself is dead, and holding it helps nobody. But
// "no such room" proves nothing about the seat — a restarting
// server, a stale restore, or the wrong backend all say it.
// The seat stays; the error shows; a later reconnect against
// the right server walks back in.
if (this.roomIdPending && /name is taken/.test(msg.message)) {
localStorage.removeItem(SEAT_KEY);
}
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
this.roomIdPending = null;
}
this.error = msg.message;
@@ -522,9 +538,7 @@ class Net {
/** Take a seat in the Peanut Gallery: watch a game with no name and no voice. */
watch(roomId: string): void {
this.you = null;
this.log = [];
this.turnCounter = -1;
this.eyeTurn = -1;
this.resetChronicle();
this.send({ type: "watch", roomId: roomId.toUpperCase() });
}
@@ -544,9 +558,7 @@ class Net {
this.spectating = false;
this.token = seat.token;
this.roomIdPending = seat.roomId;
this.log = [];
this.turnCounter = -1;
this.eyeTurn = -1;
this.resetChronicle();
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
}
@@ -574,6 +586,14 @@ class Net {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
}
kickSeat(name: string): void {
this.send({ type: "kickSeat", name });
}
abandonRoom(): void {
this.send({ type: "abandonRoom" });
}
rollTableDie(): void {
this.send({ type: "rollDie" });
}
@@ -644,23 +664,28 @@ class Net {
} catch { /* blocked at the OS level; the tab title still shows it */ }
}
/** Forget the remembered seat and return to the lobby. */
leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
/** Client-side teardown when the SERVER detached us (kick, abandon).
* The chronicle survives: the detachment appended its own explanatory
* line, and wiping it would erase the only notice of why. */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY);
this.roomId = null;
this.roomIdPending = null;
this.view = null;
this.started = false;
this.players = [];
this.log = [];
this.turnCounter = -1;
this.eyeTurn = -1;
this.token = null;
this.spectating = false;
this.audience = 0;
}
/** Walk away from the table (or gallery) by choice. */
leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
this.leaveLocal();
this.resetChronicle();
}
start(expansion: boolean): void {
this.send({ type: "start", expansion });
}
@@ -693,21 +718,32 @@ class Net {
this.markSeen();
}
/** The chronicle and its turn count reset together, always. */
private resetChronicle(): void {
this.log = [];
this.turnCounter = -1;
}
/** Summon one turn's reel by its chronicle turn number. */
requestMoment(turn: number): void {
this.momentTurn = turn;
this.send({ type: "moment", turn });
}
/** Mint a public link to the open moment's turn. */
requestShare(): Promise<string> {
/** Mint a public link: the open moment's turn, or -1 for the whole
* finished game. One mint in flight at a time a newcomer supersedes
* a stranded predecessor rather than leaving it pending forever. */
requestShare(turn: number | null = this.momentTurn): Promise<string> {
return new Promise((resolve, reject) => {
if (this.momentTurn === null) return reject(new Error("no turn open"));
if (turn === null) return reject(new Error("no turn open"));
this.shareReject?.(new Error("superseded"));
this.shareResolve = resolve;
this.send({ type: "share", turn: this.momentTurn });
this.shareReject = reject;
this.send({ type: "share", turn });
setTimeout(() => {
if (this.shareResolve === resolve) {
this.shareResolve = null;
this.shareReject = null;
reject(new Error("share timed out"));
}
}, 10_000);
@@ -716,6 +752,7 @@ class Net {
closeMoment(): void {
this.moment = null;
this.momentTurn = null;
}
command(command: Command): void {
+5 -2
View File
@@ -20,6 +20,8 @@ export interface Prefs {
botTier: "apprentice" | "adept" | "archmage";
/** Instant replay: notable chronicle lines offer a first-person reel. */
instantReplay: boolean;
/** The live first-person pane above the board during play. */
liveFp: boolean;
}
const KEY = "wizwar-prefs";
@@ -27,7 +29,7 @@ const KEY = "wizwar-prefs";
function load(): Prefs {
const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "adept", instantReplay: false,
cautions: true, botTier: "adept", instantReplay: true, liveFp: false,
};
try {
const raw = localStorage.getItem(KEY);
@@ -41,7 +43,8 @@ function load(): Prefs {
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
cautions: p.cautions !== false,
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
instantReplay: p.instantReplay === true,
instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true,
};
} catch {
return fallback;
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="svelte" />
/** Build moment, yyyy.mm.dd.hh.mm — injected by vite.config.ts. */
declare const __BUILD_STAMP__: string;
+14
View File
@@ -1,6 +1,20 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// Deploys build locally moments before rsync, so build time IS deploy time.
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const stamp = [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
pad(now.getHours()),
pad(now.getMinutes()),
].join(".");
export default defineConfig({
plugins: [svelte()],
define: {
__BUILD_STAMP__: JSON.stringify(stamp),
},
});
+42 -12
View File
@@ -2,11 +2,12 @@
The first-person view (`/?fpv`, and the replay's "your eyes" mode) throws
spell moments through the air as **billboard sprites**: flat images that
always face the camera, drawn glowing in mid-air, occluded by walls, and
scaled by distance. Nine sprites cover every spell moment in the game.
Each lives at `packages/web/public/textures/../fx3d/<name>.png` — replace
the file, refresh, done. The current files are placeholder radial glows;
all nine are shown side by side at `/?tokens` under "The conjurations."
always face the camera, drawn in mid-air, occluded by walls, and scaled
by distance. The sprites in `FX_ART` (fpv/fx3d.ts) cover every spell
moment in the game. Each lives at `packages/web/public/fx3d/<name>.png`
— replace the file, refresh, done. Missing files fall back to built-in
procedural glows; the whole set is shown side by side at `/?tokens`
under "The conjurations."
## Technical requirements
@@ -15,10 +16,9 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
- **Size:** square, any resolution; 128×128 or 256×256 recommended. The
renderer scales freely and pixelates on magnification (the whole view
is deliberately chunky, like the wall textures).
- **Drawn additively:** sprites are composited in "lighter" (additive)
mode over a dark dungeon, so dark pixels vanish and bright pixels glow.
Paint light-on-transparent; pure black will be invisible. Midtones read
as translucent light.
- **Compositing:** painted sprites draw normally — inks stay true, dark
outlines hold. (Only `warpglow` in terrain3d is drawn additively, as
light.) Impacts fade out by alpha as they swell.
- **No animation frames.** Each sprite is a single still. Motion comes
from the renderer: projectiles streak across the room, impacts swell
from small to large while fading out. Radially symmetric (or nearly so)
@@ -39,11 +39,11 @@ all nine are shown side by side at `/?tokens` under "The conjurations."
| `hit.png` | impact | damage landing on someone you can see; punches, claws |
| `shimmer.png` | impact | teleports, warps, illusions, minds touched — the general "magic happened here" glimmer, violet by tradition |
| `shield.png` | impact | an attack fully stopped; absorbed spells |
| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands as a mound at the fallen edge for the rest of the reel. Unlike the others this is drawn OPAQUE (normal compositing) — paint it as solid debris, not light |
| `rubble.png` | prop | a destroyed wall's remains: bursts on the kill, then stands stretched across the fallen edge's whole gap for the rest of the reel — piles painted at the canvas's lower corners land at the two posts |
Projectiles hold their size in flight (about a third of a wizard's
height). Impacts start small and swell to roughly double while fading —
so an impact painted as a ring or burst reads especially well.
height). Impacts start small and swell past triple while fading — so an
impact painted as a ring or burst reads especially well.
What the sprites do NOT need to carry: screen-wide flashes when YOU are
hit (red), healed (green), or teleported (violet), the camera shake, the
@@ -51,6 +51,36 @@ door slide (doors animate by sliding `door.png` along its own edge — no
separate art), and the jammed-lock seethe (a pulsing rust glow the
renderer lays over the door) — those are renderer effects, not images.
## The terrain, in two families
**Floor decals** — `public/textures/pit.png`, `slime.png`, `tacks.png`,
`dimwarp.png` — are painted INTO the flagstones by the floor caster,
alpha-blended texel by texel like the home tile: transparent wherever
plain floor should show. The pit can carry its own painted label if
wanted. Square, any size.
**Standing volumes** — `public/terrain3d/thornbush.png`, `rosebush.png`,
`jello.png`, `dust.png`, `warpglow.png` — are billboards in the room.
The hedges render CELL-WIDE at half wall height (paint them twice as
wide as tall; a wizard in the square peeks over the top). The jello
cube fills the whole square and is drawn ~60% opaque — paint it solid,
the renderer supplies the translucency. Dust likewise. `warpglow` is a
pillar of light drawn ADDITIVELY (like the old glow sprites): bright
means bright, black vanishes. Rehearse any of them in place:
`/?fpv&terrain=thornbush@3,7;jello@4,7;dimwarp@2,7`.
## Walls' dressing and damage
`public/textures/doorframe.png` dresses OPEN doorways: paint an empty
stone doorway on transparency — posts in the outer ~8% columns, the
lintel in the top ~16% rows, nothing in the middle (the way through).
`public/textures/cracks.png` is battle damage: dark fractures on
transparency, laid over any damaged wall with opacity rising as the
harm mounts. `public/textures/underbrush.png` is the ground beneath a
hedge — leaf litter to the square's edges, a floor decal like the pit.
`public/terrain3d/safe.png` is the wall safe, standing as an opaque
strongbox (square, transparent ground).
## One more paintable surface: the home tile
`public/textures/home.png` is a floor overlay for home-base cells,
+175
View File
@@ -0,0 +1,175 @@
// Claude's seat at the table: a one-shot websocket client. Each run
// connects, resumes the seat by token, performs one verb, prints, exits.
// node claude-seat.mjs join <ROOM> <NAME>
// node claude-seat.mjs view
// node claude-seat.mjs do '<command json>'
// node claude-seat.mjs chat "text"
import WebSocket from "ws";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
// Seat state (room, name, token) persists between one-shot invocations.
const SEAT_FILE = process.env.WIZWAR_SEAT ?? "/tmp/wizwar-claude-seat.json";
const URL_WS = process.env.WIZWAR_SERVER ?? "wss://wizwar.kestrelsnest.social/ws";
const [verb, ...args] = process.argv.slice(2);
const seat = existsSync(SEAT_FILE) ? JSON.parse(readFileSync(SEAT_FILE, "utf8")) : null;
const ws = new WebSocket(URL_WS);
const send = (m) => ws.send(JSON.stringify(m));
const die = (msg, code = 1) => { console.log(msg); process.exit(code); };
setTimeout(() => die("timeout: no answer from the maze"), 20000);
let view = null;
let pendingEvents = [];
let historyEvents = [];
function cellK(c) { return `${c.x},${c.y}`; }
function renderView(v) {
const B = v.board;
const lines = [];
const label = new Map(); // cellKey -> 2-char code
const codes = {};
v.players.forEach((p, i) => {
codes[p.id] = String(i + 1);
label.set(cellK(p.position), (p.id === v.you ? "C" : "P") + (i + 1));
});
const treas = new Map();
for (const t of v.treasures) if (t.position && !t.carriedBy) treas.set(cellK(t.position), t);
// top border row by row
for (let y = 0; y < B.height; y++) {
let top = "";
let mid = "";
for (let x = 0; x < B.width; x++) {
const k = `${x},${y}`;
if (!B.cells[k]) { top += " "; mid += " "; continue; }
const nEdge = B.edges[`H:${x},${y - 1}`] ?? "open";
const north = y === 0 ? "wall" : nEdge; // rim renders solid; warps noted separately
top += "+" + (north === "wall" ? "————" : north === "door" ? "—DD—" : north === "firewall" ? "~FF~" : " ");
const wEdge = x === 0 ? "wall" : (B.edges[`V:${x - 1},${y}`] ?? "open");
const wc = wEdge === "wall" ? "|" : wEdge === "door" ? "D" : wEdge === "firewall" ? "F" : " ";
let body = label.get(k) ?? "";
if (!body) {
const sq = v.squareContents[k];
if (sq) body = sq.kind.slice(0, 2).toUpperCase();
else if (treas.has(k)) body = "$" + (treas.get(k).owner === v.you ? "c" : codes[treas.get(k).owner] ?? "?");
else if (B.homes.some((h) => cellK(h) === k)) {
const who = v.players.find((p) => cellK(p.home) === k);
body = "h" + (who ? (who.id === v.you ? "C" : codes[who.id]) : "?");
} else if ((v.groundObjects[k] ?? []).length) body = "ob";
else if (v.creatures.some((c) => cellK(c.position) === k)) {
body = v.creatures.find((c) => cellK(c.position) === k).kind.slice(0, 2);
} else body = " ";
}
mid += wc + " " + body.padEnd(2, " ") + " ";
}
lines.push(top + "+");
lines.push(mid + "|");
}
let bottom = "";
for (let x = 0; x < B.width; x++) bottom += "+————";
lines.push(bottom + "+");
const out = [];
out.push(`ROOM ${seat?.roomId} — you are ${v.you} (round ${v.turn.round}, ${v.activePlayerId}'s turn)`);
out.push(lines.join("\n"));
out.push("WARPS (rim passages): " + B.warps.map((w) => `${cellK(w.from.cell)}${w.from.side}${cellK(w.to.cell)}`).join(" "));
if (v.dimWarps.length) {
out.push("!! DIMENSIONAL WARP TOKENS (player wormholes — CHECK EVERY TURN): " +
v.dimWarps.map((w) => `${cellK(w.a)}${cellK(w.b)}`).join(" "));
}
for (const p of v.players) {
const t = p.carriedTreasureId ? " CARRYING TREASURE" : "";
out.push(` ${p.id === v.you ? "ME " : " "}${p.id}: ${p.life} life @${cellK(p.position)} home@${cellK(p.home)} hand:${p.handCount}${t}${p.alive ? "" : " DEAD"} displayed:[${p.displayed.map((c) => c.cardId).join(",")}]`);
}
out.push("TREASURES: " + v.treasures.map((t) => `${t.id}(${t.owner})${t.carriedBy ? `held by ${t.carriedBy}` : t.position ? `@${cellK(t.position)}` : "?"}`).join(" "));
if (v.sustained.length) out.push("SUSTAINED: " + v.sustained.map((s) => `${s.cardId} on ${s.targetId} (${s.remainingTurns})`).join(" "));
const sq = Object.entries(v.squareContents);
if (sq.length) out.push("CONTENTS: " + sq.map(([k, c]) => `${c.kind}@${k}`).join(" "));
if (v.creatures.length) out.push("CREATURES: " + v.creatures.map((c) => `${c.kind}@${cellK(c.position)} (${c.controllerId}, ${c.life} life)`).join(" "));
out.push(`TURN: moves ${v.turn.movementUsed}/${v.turn.movementAllowance} attackUsed:${v.turn.attackUsed} actionsEnded:${v.turn.actionsEnded} numberPlayed:${v.turn.numberPlayedForMovement}`);
out.push("MY HAND: " + v.yourHand.map((c) => `${c.instanceId}`).join(" "));
if (v.stack) out.push("STACK! " + JSON.stringify(v.stack));
if (v.wardPending) out.push("WARD PENDING: " + JSON.stringify(v.wardPending));
if (v.chaosPending) out.push("CHAOS PENDING: " + JSON.stringify(v.chaosPending));
if (v.outOfTurnWindow) out.push("OUT-OF-TURN WINDOW: " + JSON.stringify(v.outOfTurnWindow));
if (v.phase === "finished") out.push(`GAME OVER — winner: ${v.winner} (${v.winReason})`);
return out.join("\n");
}
function eventLine(e) {
const skip = new Set(["cardsDealtPrivate"]);
if (skip.has(e.type)) return null;
return JSON.stringify(e);
}
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "error") die(`SERVER: ${msg.message}`);
if (msg.type === "seat") {
// A join names its room explicitly; only a resume inherits the file's.
const s = { roomId: verb === "join" ? args[0].toUpperCase() : seat.roomId, name: msg.playerId, token: msg.token };
writeFileSync(SEAT_FILE, JSON.stringify(s));
if (verb === "join") { console.log(`seated as ${msg.playerId} in ${s.roomId}`); afterJoin(); }
}
if (msg.type === "events") {
// A join replays the whole chronicle (replayed: true); only live
// events are news. `view` prints the tail of history instead.
if (!msg.replayed) {
for (const e of msg.events) { const l = eventLine(e); if (l) pendingEvents.push(l); }
} else if (verb === "view") {
for (const e of msg.events) { const l = eventLine(e); if (l) historyEvents.push(l); }
}
}
if (msg.type === "state") { view = msg.view; finishAfterState(); }
if (msg.type === "catchUp") {
const steps = msg.steps;
if (steps.length) view = steps[steps.length - 1].view;
finish();
}
if (msg.type === "chat") pendingEvents.push(`CHAT ${msg.player}: ${msg.text}`);
if (msg.type === "room") { /* roster updates, ignore */ }
});
let done = false;
function finish() {
if (done) return; done = true;
if (verb === "view" && historyEvents.length) {
console.log("RECENT EVENTS (history tail):\n" + historyEvents.slice(-12).join("\n") + "\n");
}
if (pendingEvents.length) console.log("NEW EVENTS:\n" + pendingEvents.join("\n") + "\n");
if (view) console.log(renderView(view));
else console.log("(no game running yet — waiting for the start)");
process.exit(0);
}
let stateTimer = null;
function finishAfterState() {
// commands produce one state per player broadcast; give trailing events a beat
if (stateTimer) clearTimeout(stateTimer);
stateTimer = setTimeout(finish, 700);
}
function afterJoin() {
send({ type: "catchUp", sinceSeq: 0 });
setTimeout(() => { if (!done) finish(); }, 6000);
}
ws.on("open", () => {
if (verb === "join") {
if (!args[0] || !args[1]) die("usage: join <ROOM> <NAME>");
send({ type: "join", roomId: args[0].toUpperCase(), name: args[1], token: null });
} else if (!seat) {
die("no seat yet — join first");
} else {
send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
setTimeout(() => {
if (verb === "view") {
send({ type: "catchUp", sinceSeq: 0 });
} else if (verb === "do") {
send({ type: "command", command: JSON.parse(args[0]) });
setTimeout(() => { if (!done) finish(); }, 8000);
} else if (verb === "chat") {
send({ type: "chat", text: args[0] });
setTimeout(() => { console.log("said."); process.exit(0); }, 800);
} else die(`unknown verb: ${verb}`);
}, 600);
}
});
ws.on("error", (e) => die(`socket error: ${e.message}`));