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 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
21 changed files with 2484 additions and 293 deletions
+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".
+224 -48
View File
@@ -9,6 +9,7 @@
import { cardDef, type CardInstance } from "./cards"; import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board"; import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
import { bentSightFor, sightedCellsFor, type GameView } from "./view"; import { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game"; import type { AmbushTrigger, Command, PlayerId } from "./game";
export type AutomatonStyle = "hunter" | "berserker" | "worrier"; 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! }); 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; if (threats.length === 0) return null;
const sighted = sightedCellsFor(view); 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. // 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( consider(
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } }, { type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
{ avoidCell: cellKey(b) }, { avoidCell: cellKey(b) },
@@ -597,7 +614,7 @@ function pathToward(
const hazard = view.squareContents[k]?.kind; const hazard = view.squareContents[k]?.kind;
if (!opts.throughHazards && if (!opts.throughHazards &&
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" || (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime")) continue; hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
if (opts.avoidNearEnemies && nearEnemy(to)) continue; if (opts.avoidNearEnemies && nearEnemy(to)) continue;
next.push(to); next.push(to);
} }
@@ -630,7 +647,16 @@ function treasureGoals(view: GameView): Set<string> {
} }
for (const t of view.treasures) { for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue; 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; if (cellKey(t.position) === cellKey(self.home)) continue;
goals.add(cellKey(t.position)); 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); 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. */ /** The wizard making off with MY gold, if any. */
function thiefOfMine(view: GameView) { function thiefOfMine(view: GameView) {
const carriers = new Set( const carriers = new Set(
@@ -722,7 +757,15 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
return { type: "pass" }; 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 attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null; const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false; const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
@@ -749,19 +792,26 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
? Math.max(2, stack.numberValue ?? 2) ? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2) : (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1; : 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") { if (stack.kind === "spell") {
// REVERSE eats points; a pure duration offers it nothing. // REVERSE eats points; a pure duration offers it nothing.
const reverse = find("reverse"); 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. // ABSORB SPELL steals the good ones for later.
const absorbSpell = find("absorb-spell"); 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"); 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 }; return { type: "counteract", instanceId: shield.instanceId };
} }
const reflect = find("full-reflection"); 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. // A curse in flight is best refused at the door.
if (affliction) { if (affliction) {
const cleanse = find("remove-curse"); const cleanse = find("remove-curse");
@@ -777,9 +827,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
const permanentCurse = affliction && const permanentCurse = affliction &&
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot"); (attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
const absorb = find("absorb"); 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"); 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 // The berserker shares its pain out of spite — but only pain: a
// damage-less curse gives EMPATHY nothing to mirror. // damage-less curse gives EMPATHY nothing to mirror.
const empathy = find("empathy"); 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. // Nothing to block with: teleport clear of the big ones.
const tp = find("teleport"); const tp = find("teleport");
if (tp && incoming >= 4 - flinch) { if (tp && weight >= 4 - flinch) {
const out = escapeCell(view, me(view).position); const out = escapeCell(view, me(view).position);
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } }; if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
} }
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after. // Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
const vanish = find("invisible"); const vanish = find("invisible");
if (vanish && incoming >= 3 - flinch) { if (vanish && weight >= 3 - flinch) {
const num = numbersInHand(view)[0]; const num = numbersInHand(view)[0];
return { return {
type: "counteract", instanceId: vanish.instanceId, 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. */ /** 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 { function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
const numbers = numbersInHand(view); 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 biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0; const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
const target = view.players.find((p) => p.id === targetId)!; const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position); const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = tier.amplify ? inHand(view, "amplify") : undefined; 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) { for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId]; const atk = ATTACKS[c.cardId];
if (!atk) continue; if (!atk) continue;
@@ -831,25 +915,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
const isWand = c.cardId === "blaster-wand"; const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null; const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if ((uncharged || atk.needsNumber) && !biggest) continue; if ((uncharged || atk.needsNumber) && !biggest) continue;
const withNumber = (atk.perNumber || uncharged) && biggest; const mustNumber = uncharged || atk.needsNumber === true;
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0); const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
if (damage <= 0) continue; const numberChoices: (CardInstance | undefined)[] = atk.perNumber
// AMPLIFY doubles the heavy hitters (spells only, not thrown things). ? [undefined, ...numbers]
const amplified = amplify && damage >= 4 && !isWand && : [mustNumber ? biggest : undefined];
c.cardId !== "dagger" && c.cardId !== "large-rock"; for (const num of numberChoices) {
if (amplified) damage *= 2; if (mustNumber && !num) continue;
if (!best || damage > best.damage) { const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
best = { const base = atk.base + (atk.perNumber && num ? value : 0);
damage, if (base <= 0) continue;
cmd: { 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, type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId }, target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best. // An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}), ...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}), ...(num ? { numberInstanceIds: [num.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}), ...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
}, });
}; }
} }
} }
// STONE DEAD: number times the stones the victim carries — the displayed // 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) { if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length; const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown; 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 = { best = {
damage: dmg, damage: dmg, kill: dmg >= target.life, cost: biggestValue,
cmd: { cmd: {
type: "cast", instanceId: sd.instanceId, type: "cast", instanceId: sd.instanceId,
target: { kind: "player", playerId: targetId }, 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. /** 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 }; 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 if (!tier.buffs) return null; // the apprentice's book ends at the stones
// A curse on the clockwork gets scrubbed off. // A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some( 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. // Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
const myFloorTreasure = tier.guardGold const myFloorTreasure = tier.guardGold
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy) ? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
@@ -1128,10 +1261,13 @@ export function automatonCommand(
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" }; if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) { if (!self.carriedTreasureId) {
const prize = view.treasures.find( const prize = view.treasures.find(
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here && (t) => t.position && !t.carriedBy && cellKey(t.position) === here &&
here !== cellKey(self.home), 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. // Wounded clockwork teleports clear of visible hunters.
@@ -1200,13 +1336,16 @@ export function automatonCommand(
const target = thief && visible.some((p) => p.id === thief.id) const target = thief && visible.some((p) => p.id === thief.id)
? thief ? thief
: visible.sort((a, b) => a.life - b.life)[0]!; : visible.sort((a, b) => a.life - b.life)[0]!;
// DROP OBJECT shakes my treasure out of the thief's hands. // DROP OBJECT shakes the treasure out of the hands that matter:
if (thief && target.id === thief.id) { // the thief of my gold, or any carrier whose delivery wins.
const dob = inHand(view, "drop-object"); {
if (dob) { 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 { return {
type: "cast", instanceId: dob.instanceId, type: "cast", instanceId: dob.instanceId,
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" }, target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" },
}; };
} }
} }
@@ -1216,6 +1355,7 @@ export function automatonCommand(
if (exile) { if (exile) {
const carrier = visible.find((p) => { const carrier = visible.find((p) => {
if (thief && p.id === thief.id) return true; if (thief && p.id === thief.id) return true;
if (deliveryWins(view, p)) return true;
if (!p.carriedTreasureId) return false; if (!p.carriedTreasureId) return false;
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6; return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
}); });
@@ -1365,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 // March. The thief-chase outranks everything; the berserker hunts wizards
// over gold; the worrier keeps its distance; the hunter goes to the gold. // over gold; the worrier keeps its distance; the hunter goes to the gold.
if (view.turn.movementUsed < view.turn.movementAllowance) { if (view.turn.movementUsed < view.turn.movementAllowance) {
@@ -1384,13 +1542,26 @@ export function automatonCommand(
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden && const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null); !view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
const chasing = thief !== null && strikeLive; 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 const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells ? ownGoldCells
: chasing : chasing
? new Set([cellKey(thief.position)]) ? new Set([cellKey(thief.position)])
: style === "berserker" && !self.carriedTreasureId : bankThreatened
? (enemyCells.size > 0 ? enemyCells : gold) ? new Set([cellKey(self.home)])
: gold; : style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
const path = const path =
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ?? pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ??
@@ -1475,12 +1646,17 @@ export function automatonCommand(
// The war chest: when an attack in hand wants a number, the // The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer // biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn. // 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
const atk = ATTACKS[c.cardId]; // march, and no reserve outbids the delivery that wins the game.
if (!atk) return false; const foesClose = livingEnemies(view).some((p) =>
return atk.perNumber || atk.needsNumber === true || Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6);
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null); 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 ||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
});
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers; const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
// A treasure within free walking ends this turn at the grab (the // A treasure within free walking ends this turn at the grab (the
// gold-underfoot rung outranks everything) — a number played first // gold-underfoot rung outranks everything) — a number played first
+140 -44
View File
@@ -195,6 +195,9 @@ export interface CastStack {
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */ /** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
cardId?: string }[]; cardId?: string }[];
waitingOn: PlayerId; 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. */ /** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean; defenderShielded?: boolean;
/** Set when a creature, not a wizard, delivers the attack. */ /** Set when a creature, not a wizard, delivers the attack. */
@@ -226,7 +229,7 @@ export interface CastParams {
} }
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 3; export const CURRENT_RULES_REV = 5;
export interface GameConfig { export interface GameConfig {
playerIds: PlayerId[]; playerIds: PlayerId[];
@@ -245,6 +248,11 @@ export interface GameConfig {
* Rev 3: VISIONSTONE pierces its one wall for every sight the game * Rev 3: VISIONSTONE pierces its one wall for every sight the game
* asks of its bearer creations and utility spells included not * asks of its bearer creations and utility spells included not
* only direct attacks. * 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; deckRev?: number;
} }
@@ -381,13 +389,11 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
} }
/** LOS including square-filling blockers. */ /** LOS including square-filling blockers. */
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { /** Sight granted by removing any ONE closed edge the VISIONSTONE's
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); * whole power, shared by every path that honors it. */
const blockers = losBlockers(state); function sightThroughOneEdge(
if (sightBetween(board, from, to, blockers)) return true; board: AssembledBoard, from: Cell, to: Cell, blockers: Record<string, true>,
if (!stone) return false; ): boolean {
const viewer = viewerId ? state.players.find((p) => p.id === viewerId) : undefined;
if (!viewer || !viewer.alive || !displays(viewer, "visionstone")) return false;
for (const key of Object.keys(board.edges)) { for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue; if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges }; const edges = { ...board.edges };
@@ -397,11 +403,21 @@ function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | un
return false; 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 /** LOS including square-filling blockers. VISIONSTONE is the bearer's
* sight wherever sight is asked of them creations, dispels, utility * sight wherever sight is asked of them creations, dispels, utility
* spells not just attacks: one wall or door, any type, falls away. * spells not just attacks: one wall or door, any type, falls away.
* Safe at every revision: widening what a cast may target never changes * Ungated by design: validation may widen without a rev bump, because
* how a recorded command replays. */ * refused commands never entered any ledger. */
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean { export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
return losWith(state, from, to, viewerId, true); return losWith(state, from, to, viewerId, true);
} }
@@ -464,13 +480,7 @@ function casterLos(
const blockers = losBlockers(state); const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true; if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false; if (!displays(caster, "visionstone")) return false;
for (const key of Object.keys(board.edges)) { return sightThroughOneEdge(board, from, to, blockers);
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;
} }
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */ /** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
@@ -576,6 +586,8 @@ export type GameEvent =
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } } | { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" } | { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean } | { 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; | { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell;
/** Origins in FINAL coordinates (the maze may renormalize after the /** Origins in FINAL coordinates (the maze may renormalize after the
* landing); from/to record the pre-shift request. */ * landing); from/to record the pre-shift request. */
@@ -692,6 +704,7 @@ export type Command =
| { type: "wardChoice"; play: boolean } | { type: "wardChoice"; play: boolean }
| { type: "warpStep" } | { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side } | { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureWarpStep"; creatureId: string }
| { type: "creatureAttack"; creatureId: string; targetId: string } | { type: "creatureAttack"; creatureId: string; targetId: string }
| { | {
type: "cast"; type: "cast";
@@ -744,7 +757,7 @@ type AttackEffect = {
sustains?: boolean; sustains?: boolean;
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */ /** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
keepInHand?: boolean; keepInHand?: boolean;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>) => string | null; validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
onResolved?: (ctx: ResolutionContext) => void; onResolved?: (ctx: ResolutionContext) => void;
}; };
@@ -1233,8 +1246,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
keepInHand: true, keepInHand: true,
// "Unlocks any door (door relocks behind you). Do not discard when used. // "Unlocks any door (door relocks behind you). Do not discard when used.
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK." // 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) => 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": { "remove-lock": {
kind: "neutral", kind: "neutral",
@@ -2154,11 +2173,19 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
"mental-force": { "mental-force": {
kind: "attack", kind: "attack",
baseDamage: () => 0, // no LOS printed baseDamage: () => 0, // no LOS printed
validate: (state, cmd) => { validate: (state, cmd, caster, target) => {
const cell = cmd.params?.cell; const cell = cmd.params?.cell;
if (!cell) return "say where they go (within three moved spaces)"; if (!cell) return "say where they go (within three moved spaces)";
if (!boardView(state).cells[cellKey(cell)]) return "off the board"; if (!boardView(state).cells[cellKey(cell)]) return "off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone"; 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; return null;
}, },
onResolved: (ctx) => { onResolved: (ctx) => {
@@ -2166,7 +2193,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (isLockedInPlace(ctx.state, ctx.defender.id)) return; if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell; const to = ctx.stack.params?.cell;
if (!to) return; // an ambush armed without a destination fizzles 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; const from = ctx.defender.position;
ctx.defender.position = to; ctx.defender.position = to;
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" }); ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" });
@@ -2911,6 +2943,66 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
checkVictory(state, events); 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 { function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
const blocked = requireActionsAvailable(prev); const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked); if (blocked) return err(blocked);
@@ -2965,26 +3057,7 @@ function doMoveCreature(prev: GameState, creatureId: string, direction: Side): C
} }
creature.movementUsed++; creature.movementUsed++;
events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id }); events.push({ type: "creatureMoved", creatureId: creature.id, from, to: creature.position, direction, by: active.id });
if (creatureTouchOnArrival(state, events, creature)) return { ok: true, state, events };
// 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 };
}
}
checkVictory(state, events); checkVictory(state, events);
return { ok: true, state, events }; return { ok: true, state, events };
} }
@@ -3412,7 +3485,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number {
} }
/** BFS steps between cells ignoring walls (teleport distance). */ /** 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; if (cellKey(from) === cellKey(to)) return 0;
const seen = new Map<string, number>([[cellKey(from), 0]]); const seen = new Map<string, number>([[cellKey(from), 0]]);
const queue: Cell[] = [from]; const queue: Cell[] = [from];
@@ -3730,6 +3803,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
case "wardChoice": return err("no grab is hanging on your Ward"); case "wardChoice": return err("no grab is hanging on your Ward");
case "warpStep": return doWarpStep(state); case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId); case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
case "cast": return doCast(state, command); case "cast": return doCast(state, command);
case "setAmbush": return doSetAmbush(state, command); case "setAmbush": return doSetAmbush(state, command);
@@ -3941,9 +4015,27 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
p.position = { ...warp.to.cell }; p.position = { ...warp.to.cell };
via = "warp"; via = "warp";
crossedFirewall = true; 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; p.position = dest;
via = "step"; 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") { } else if (edge === "firewall") {
// "Passing through it does four points of magical damage." Firewalls // "Passing through it does four points of magical damage." Firewalls
// burn even a MIST-BODY. // burn even a MIST-BODY.
@@ -4737,6 +4829,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) { if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`); 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 // 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. // charge per use, one use per turn; discarded when the last charge goes.
@@ -4976,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), (s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
); );
if (effect.validate) { if (effect.validate) {
const problem = effect.validate(state, cmd); const problem = effect.validate(state, cmd, caster, target);
if (problem) return err(problem); if (problem) return err(problem);
} }
if (inHand.cardId === "waterbolt") { if (inHand.cardId === "waterbolt") {
@@ -5064,6 +5159,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
kind: effect.physical ? "physical" : "spell", kind: effect.physical ? "physical" : "spell",
counters: [], counters: [],
waitingOn: target.id, waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
}; };
state.lastSpellUsed[caster.id] = inHand.cardId; state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents]; const events: GameEvent[] = [...wandEvents, ...preEvents];
+18 -2
View File
@@ -296,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
*/ */
export function stackSightTrace( export function stackSightTrace(
view: GameView, view: GameView,
): { from: Cell; to: Cell; trace: SightTrace } | null { ): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null {
const stack = view.stack; const stack = view.stack;
if (!stack || stack.creatureId) return null; if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null; if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
@@ -305,7 +305,23 @@ export function stackSightTrace(
if (!a || !d) return null; if (!a || !d) return null;
if (a.position.x === d.position.x && a.position.y === d.position.y) return null; if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
const trace = traceSightFor(view, a.position, d.position); const trace = traceSightFor(view, a.position, d.position);
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([ const CREATION_CARD_IDS = new Set([
+205 -1
View File
@@ -757,7 +757,7 @@ describe("the thief-chase holds one goal per turn", () => {
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open"; state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
// A number card and no attacks: the blow the chase serves cannot land. // A number card and no attacks: the blow the chase serves cannot land.
bot.hand = []; bot.hand = [];
bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never); bot.hand.push({ cardId: "number-2", instanceId: "N2" });
const visited = [cellKey(bot.position)]; const visited = [cellKey(bot.position)];
for (let guard = 0; guard < 40; guard++) { for (let guard = 0; guard < 40; guard++) {
const view = viewFor(state, "bot"); const view = viewFor(state, "bot");
@@ -775,3 +775,207 @@ describe("the thief-chase holds one goal per turn", () => {
} }
}); });
}); });
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;
}
});
});
+57 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game"; 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 { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { sightedCellsFor, viewFor } from "../src/view"; import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers"; import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */ /** Test surgery: put a specific card into a player's hand (swapping one out). */
@@ -1140,3 +1140,59 @@ describe("stone dead counts only the stones in play", () => {
expect(state.players.find((p) => p.id === defender)!.life).toBe(12); 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();
});
});
+38 -3
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; import {
type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards"; import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; import { newExpansionGame as newGame, must, 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, damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
scorchedThisTurn: [], scorchedThisTurn: [],
} as never); });
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open"; for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
while (state.players[state.turn.activeIndex]!.id !== "a") { while (state.players[state.turn.activeIndex]!.id !== "a") {
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 }); const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
@@ -749,6 +750,40 @@ describe("fear holds off monsters and unwilling feet alike", () => {
// The closer step is the only way out: permitted. // The closer step is the only way out: permitted.
const r = applyCommand(state, "a", { type: "move", direction: "S" }); const r = applyCommand(state, "a", { type: "move", direction: "S" });
if (!r.ok) throw new Error("escape step refused: " + r.error); if (!r.ok) throw new Error("escape step refused: " + r.error);
expect(r.ok).toBe(true); });
});
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 { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game"; 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 type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers"; import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -111,7 +111,7 @@ describe("doors", () => {
throw new Error("no door on this board"); 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(); let { state } = newGame();
const me = activePlayer(state); const me = activePlayer(state);
const door = findDoor(state); const door = findDoor(state);
@@ -130,9 +130,8 @@ describe("doors", () => {
state = must(state, me.id, { type: "move", direction: dir }); state = must(state, me.id, { type: "move", direction: dir });
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other)); expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
// Relocks when the turn ends. // "It will relock behind you": shut at the walker's back at once —
state = must(state, me.id, { type: "endTurn", draw: 0 }); // the way back is barred without another pick.
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(state.openDoorEdges.length).toBe(0); expect(state.openDoorEdges.length).toBe(0);
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false); expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
}); });
@@ -489,7 +488,9 @@ describe("a held door is an open doorway to the eye", () => {
// no longer on their threshold; clear their path to the doorway. // no longer on their threshold; clear their path to the doorway.
const near = neighbor(cell, side); const near = neighbor(cell, side);
const far = neighbor(near, side); const far = neighbor(near, side);
if (!boardView(state).cells[cellKey(far)]) return; // the maze ends here; geometry unavailable 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.players.find((p) => p.id === pursuer)!.position = far;
state.edgeOverrides[edgeKey(near, side)] = "open"; state.edgeOverrides[edgeKey(near, side)] = "open";
const rm = giveCard(state, holder, "remove-lock"); const rm = giveCard(state, holder, "remove-lock");
@@ -506,3 +507,63 @@ describe("a held door is an open doorway to the eye", () => {
if (!refused.ok) expect(refused.error).toContain("line of sight"); 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);
});
});
+65 -3
View File
@@ -57,6 +57,8 @@ import {
summarize, summarize,
viewForPlayer, viewForPlayer,
type Room, type Room,
kickSeat,
abandonRoom,
} from "./rooms"; } from "./rooms";
import { engagementStats, recordHotseat } from "./stats"; import { engagementStats, recordHotseat } from "./stats";
import { getShare, loadShares, mintShare } from "./shares"; import { getShare, loadShares, mintShare } from "./shares";
@@ -394,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>(); const pumping = new Set<string>();
function runBots(room: Room): void { function runBots(room: Room): void {
if (pumping.has(room.id)) return; if (pumping.has(room.id)) return;
@@ -408,6 +421,14 @@ function runBots(room: Room): void {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
// The game's end unmasks the mystery machines in the roster. // The game's end unmasks the mystery machines in the roster.
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room)); 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 }[]); botRemark(room, step.seat, step.events as { type: string }[]);
setTimeout(tick, BOT_STEP_MS); setTimeout(tick, BOT_STEP_MS);
}; };
@@ -526,6 +547,39 @@ wss.on("connection", (socket) => {
session.token = null; session.token = null;
break; 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": { case "addBot": {
const room = session.roomId ? getRoom(session.roomId) : undefined; const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" }); if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
@@ -680,18 +734,26 @@ wss.on("connection", (socket) => {
break; break;
} }
case "myGames": { 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 seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
const games = []; const games = [];
const voided: string[] = [];
for (const seat of seats) { for (const seat of seats) {
if (typeof seat !== "object" || seat === null) continue; if (typeof seat !== "object" || seat === null) continue;
const room = getRoom(String(seat.roomId ?? "")); const room = getRoom(String(seat.roomId ?? ""));
if (!room) continue; if (!room) continue;
const name = String(seat.name ?? ""); 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)); games.push(summarize(room, name));
} }
send(socket, { type: "games", games }); send(socket, { type: "games", games, voided });
break; break;
} }
default: default:
+51 -12
View File
@@ -22,7 +22,7 @@ import {
type PlayerId, type PlayerId,
CURRENT_RULES_REV, CURRENT_RULES_REV,
} from "@wizwar/engine"; } 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"; import { recordRoom } from "./stats";
export interface LoggedCommand { export interface LoggedCommand {
@@ -54,11 +54,6 @@ export interface Room {
const rooms = new Map<string, Room>(); const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own).
* 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 = CURRENT_RULES_REV;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** Tokens live hashed at rest (memory and disk); clients hold the raw form. */ /** Tokens live hashed at rest (memory and disk); clients hold the raw form. */
@@ -153,6 +148,33 @@ export function joinRoom(
return { token: fresh }; 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. */ /** Everyone gets their chosen standee; the undecided get the first free one. */
export function resolveColors(room: Room): number[] { export function resolveColors(room: Room): number[] {
const taken = new Set<number>(); const taken = new Set<number>();
@@ -201,9 +223,9 @@ function startInMemory(room: Room, expansion: boolean, colors?: number[], deckRe
export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } { export function startGame(room: Room, expansion: boolean): { events: GameEvent[] } | { error: string } {
if (room.state) return { error: "already started" }; if (room.state) return { error: "already started" };
const colors = resolveColors(room); const colors = resolveColors(room);
const result = startInMemory(room, expansion, colors, RULES_REV); const result = startInMemory(room, expansion, colors, CURRENT_RULES_REV);
if ("error" in result) return result; 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); recordRoom(room);
return result; return result;
} }
@@ -291,17 +313,28 @@ function actingSeat(room: Room): PlayerId | null {
* One automaton command, if the maze is waiting on clockwork. Null when a * 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). * 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); const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null; if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat); const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat); const bot = room.bots.get(seat);
const chosen = automatonCommand(view, bot?.style, bot?.tier); const chosen = automatonCommand(view, bot?.style, bot?.tier);
let hesitated: true | undefined;
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier)); let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) { if ("error" in r) {
// A refused choice retries with the fallback — unless the fallback IS // A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass. // 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: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" }); if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) { if ("error" in r) {
@@ -309,7 +342,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
return null; return null;
} }
} }
return { seat, events: r.events }; return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
} }
export interface GameSummary { export interface GameSummary {
@@ -601,8 +634,14 @@ export function loadPersistedRooms(): void {
chat: [], chat: [],
bots: new Map(), bots: new Map(),
}; };
if (lines.some((l) => l.kind === "abandon")) continue;
for (const line of lines.slice(1)) { 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) { if (line.bot) {
room.players.push(line.name); room.players.push(line.name);
room.bots.set(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 // command. Because the engine is deterministic, replaying a file rebuilds
// the exact game state — server restarts lose nothing. // the exact game state — server restarts lose nothing.
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
export interface RoomMetaLine { export interface RoomMetaLine {
@@ -58,7 +58,20 @@ export interface ChatLine {
at: string; 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"); 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; 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`));
}
+140 -11
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { attentionLabel, net, spellName } from "./net.svelte"; import { attentionLabel, net, spellName } from "./net.svelte";
import Board from "./Board.svelte"; import Board from "./Board.svelte";
import LiveFirstPerson from "./LiveFirstPerson.svelte";
import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors"; import { colorIndexOf, PLAYER_COLORS, wizardColor } from "./colors";
import Faq from "./Faq.svelte"; import Faq from "./Faq.svelte";
import Card from "./Card.svelte"; import Card from "./Card.svelte";
@@ -14,7 +15,7 @@
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte"; import { local } from "./local.svelte";
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, 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.connect();
net.startGamePolling(); net.startGamePolling();
@@ -51,6 +52,12 @@
}); });
/** Live spell flourishes on the board (cosmetic, self-expiring). */ /** Live spell flourishes on the board (cosmetic, self-expiring). */
let boardFx = $state<BoardFx[]>([]); 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)[] = []; let fxCancels: (() => void)[] = [];
/** A trap drawn by YOU earns a modal; buried log lines get missed. */ /** A trap drawn by YOU earns a modal; buried log lines get missed. */
let trapNotice = $state<string | null>(null); let trapNotice = $state<string | null>(null);
@@ -126,6 +133,7 @@
wardBite = { owner: e.owner, amount: dmg?.type === "damaged" ? dmg.amount : 0 }; 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; if (!prefs.flourishes) return;
fxCancels.push(scheduleFx( fxCancels.push(scheduleFx(
events, view, events, view,
@@ -338,7 +346,7 @@
"wall-of-fire": "click a corridor line for the fire", "wall-of-fire": "click a corridor line for the fire",
"waterwall": "click a corridor line — the wave collapses at once", "waterwall": "click a corridor line — the wave collapses at once",
"pick-lock": "click a locked door", "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", "jam-lock": "click a door to jam its lock solid",
"remove-lock": "click a door to strip its lock for good", "remove-lock": "click a door to strip its lock for good",
"create-door": "click a wall for the new door", "create-door": "click a wall for the new door",
@@ -716,6 +724,15 @@
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side }); dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
return; 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; selectedCreature = null;
return; 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) { function clickPlayer(playerId: string) {
if (!view || !yourMoment) return; if (!view || !yourMoment) return;
if (selectedCreature) { if (selectedCreature) {
@@ -946,8 +973,10 @@
if (view && me) { if (view && me) {
const left = view.turn.movementAllowance - view.turn.movementUsed; const left = view.turn.movementAllowance - view.turn.movementUsed;
// Unspent legs are no regret when the walk ended ON someone's gold — // Unspent legs are no regret when the walk ended ON someone's gold —
// stopping there was the point. // stopping there was the point — nor when a pickup or drop already
if (left > 0 && !treasuresHere.some((t) => t.owner !== view.you)) { // 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`); reasons.push(`${left} movement point${left === 1 ? "" : "s"} unspent`);
} }
const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7; const limit = me.displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
@@ -1372,24 +1401,29 @@
<button class="stamp tiny" class:lit={prefs.art === "photo"} <button class="stamp tiny" class:lit={prefs.art === "photo"}
onclick={() => setPref("art", "photo")}>cardboard</button> onclick={() => setPref("art", "photo")}>cardboard</button>
<button class="stamp tiny" class:lit={prefs.art === "drawn"} <button class="stamp tiny" class:lit={prefs.art === "drawn"}
onclick={() => setPref("art", "drawn")}>hand-drawn</button> onclick={() => setPref("art", "drawn")}>alternate</button>
</div> </div>
<label class="pref-row"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.autoGrab} <input type="checkbox" checked={prefs.autoGrab}
onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} /> onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} />
<span>Ending my turn on a lone treasure picks it up (never on my own home)</span> <span>Ending my turn on a lone treasure picks it up (never on my own home)</span>
</label> </label>
<label class="pref-row"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.flourishes} <input type="checkbox" checked={prefs.flourishes}
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} /> onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects)</span> <span>Spell flourishes (the animated effects)</span>
</label> </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} <input type="checkbox" checked={prefs.instantReplay}
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} /> onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span> <span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
</label> </label>
<label class="pref-row"> <label class="pref-row pref-check">
<input type="checkbox" checked={prefs.cautions} <input type="checkbox" checked={prefs.cautions}
onchange={(e) => setPref("cautions", e.currentTarget.checked)} /> onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
<span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span> <span>Ask "are you sure?" before easy-to-regret plays (unspent movement, capped draws, undropped deliveries)</span>
@@ -1803,6 +1837,10 @@
<span class="dot" style:background="#b3a687"></span> <span class="dot" style:background="#b3a687"></span>
{/if} {/if}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""} {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> </li>
{/each} {/each}
</ul> </ul>
@@ -1826,6 +1864,8 @@
{/each} {/each}
</div> </div>
{#if net.you === net.hostId} {#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} {#if net.players.length < 6}
<span class="bot-row"> <span class="bot-row">
<span class="bot-label">⚙ seat a</span> <span class="bot-label">⚙ seat a</span>
@@ -1861,6 +1901,21 @@
{:else if view} {:else if view}
<div class="game"> <div class="game">
<section class="board-zone"> <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 <Board
{view} {view}
edgeSelectMode={edgeSelectMode && yourMoment} edgeSelectMode={edgeSelectMode && yourMoment}
@@ -1881,7 +1936,10 @@
onEdgePeek={(tip) => (peekEdge = tip)} onEdgePeek={(tip) => (peekEdge = tip)}
{litCells} {litCells}
{sightTrace} {sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
/> />
</div>
</section> </section>
<aside class="paper-rail"> <aside class="paper-rail">
@@ -2205,6 +2263,10 @@
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label> <label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
{/if} {/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"} {#if selectedCard?.cardId === "rotate-sector"}
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label> <label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
<span>— click the sector</span> <span>— click the sector</span>
@@ -2449,6 +2511,8 @@
text-transform: uppercase; text-transform: uppercase;
color: #8d8672; color: #8d8672;
} }
.kick { margin-left: 0.4rem; color: #a04545; }
.abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; }
.mast-version { .mast-version {
font-family: "Courier Prime", monospace; font-family: "Courier Prime", monospace;
font-size: 0.65rem; font-size: 0.65rem;
@@ -2772,11 +2836,52 @@
.pref-row { .pref-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.6rem;
margin: 0.55rem 0; width: 100%;
box-sizing: border-box;
margin: 0;
padding: 0.55rem 0.1rem;
font-size: 0.85rem; font-size: 0.85rem;
color: #43331f; 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-note { font-size: 0.72rem; color: #8a7a5e; margin-top: 0.6rem; }
.pref-name { max-width: 11rem; } .pref-name { max-width: 11rem; }
.pref-swatch { .pref-swatch {
@@ -2809,6 +2914,30 @@
min-height: 0; min-height: 0;
} }
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; } .board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
/* 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) { .board-zone :global(svg.board) {
max-height: calc(100dvh - 21.5rem); max-height: calc(100dvh - 21.5rem);
width: auto; width: auto;
+60 -4
View File
@@ -23,6 +23,8 @@
onCellClick, onCellClick,
onEdgeClick, onEdgeClick,
onPlayerClick, onPlayerClick,
povFacing = null,
povCreatureId = null,
onCreatureClick, onCreatureClick,
onWarpClick, onWarpClick,
onCellPeek, onCellPeek,
@@ -43,6 +45,11 @@
onCellClick?: (cell: { x: number; y: number }) => void; onCellClick?: (cell: { x: number; y: number }) => void;
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void; onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
onPlayerClick?: (playerId: string) => 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; onCreatureClick?: (creatureId: string) => void;
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void; onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
/** Long-press on a square: read the card behind whatever occupies it. */ /** Long-press on a square: read the card behind whatever occupies it. */
@@ -68,7 +75,7 @@
effects?: BoardFx[] | null; effects?: BoardFx[] | null;
/** The sight line an attack in progress traveled — proof against "how /** The sight line an attack in progress traveled — proof against "how
* can he even see me?", drawn leg by leg through any warp mouth. */ * can he even see me?", drawn leg by leg through any warp mouth. */
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null; sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null;
} = $props(); } = $props();
@@ -113,6 +120,18 @@
} }
// BIG MAN towers; SHRINK dwindles. The token says so at a glance. // 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 { function wizardScale(id: string): number {
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5; if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62; 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} x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
width={CELL * 0.4} height={CELL * 0.4} width={CELL * 0.4} height={CELL * 0.4}
preserveAspectRatio="xMidYMid slice" 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> <title>{o.cardId}</title>
</image> </image>
{:else} {:else}
<rect <rect
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16} 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> <title>{o.cardId}</title>
</rect> </rect>
@@ -582,6 +607,9 @@
{/if} {/if}
{/if} {/if}
</g> </g>
{#if povFacing && !povCreatureId && p.id === view.you}
<polygon points={facingWedge(povFacing, CELL * 0.3 * wizardScale(p.id))} class="pov-wedge" />
{/if}
</g> </g>
{/each} {/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> <text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if} {/if}
</g> </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> </g>
{/each} {/each}
@@ -661,7 +693,14 @@
<!-- the sight line an attack traveled, leg by leg through any warp mouth --> <!-- the sight line an attack traveled, leg by leg through any warp mouth -->
{#if sightTrace} {#if sightTrace}
<SightTraceOverlay from={sightTrace.from} to={sightTrace.to} trace={sightTrace.trace} /> {#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} {/if}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) --> <!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true"> <g class="fx-layer" aria-hidden="true">
@@ -752,6 +791,23 @@
fill: #6a5c44; fill: #6a5c44;
pointer-events: none; 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 { .dim-cell {
fill: rgba(12, 9, 5, 0.55); fill: rgba(12, 9, 5, 0.55);
pointer-events: none; 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>
+10 -100
View File
@@ -7,6 +7,7 @@
import { scheduleFx, type BoardFx } from "./fx"; import { scheduleFx, type BoardFx } from "./fx";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d"; import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast"; import { castRay, edgeMid } from "./fpv/raycast";
import { deepestFacing, aimOfEvents, gatherGlides, hurledIn, shortestArc } from "./fpv/director";
import { prefs } from "./prefs.svelte"; import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine"; import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine";
@@ -187,28 +188,7 @@
const before = prevView; const before = prevView;
prevView = v; prevView = v;
if (!before || before === v) return; if (!before || before === v) return;
const moves: { id: string; fx: number; fy: number; tx: number; ty: number }[] = []; const moves = gatherGlides(before, v, povId);
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);
}
if (moves.length === 0) { actorPos = {}; return; } if (moves.length === 0) { actorPos = {}; return; }
const t0 = performance.now(); const t0 = performance.now();
const dur = 400 / speed; const dur = 400 / speed;
@@ -234,12 +214,6 @@
// still, the eye turns toward whoever acted. // still, the eye turns toward whoever acted.
const cam = $state({ x: 0, y: 0, facing: 0 }); const cam = $state({ x: 0, y: 0, facing: 0 });
let camReady = false; 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(() => { $effect(() => {
if (!fp) { camReady = false; return; } if (!fp) { camReady = false; return; }
const v = step.view; const v = step.view;
@@ -261,72 +235,11 @@
// actor, when someone else moved the world; wherever it was, otherwise. // actor, when someone else moved the world; wherever it was, otherwise.
// A blow that hurls the body glides fast and straight, however far, // A blow that hurls the body glides fast and straight, however far,
// eyes still where they were; only unexplained leaps (teleports) cut. // eyes still where they were; only unexplained leaps (teleports) cut.
const hurled = step.events.some((e) => const hurled = hurledIn(step.events, povId);
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
e.type === "retreatedInHorror") && e.player === povId);
const actor = v.players.find((p) => p.id === step.actor); const actor = v.players.find((p) => p.id === step.actor);
// Where the eyes' own magic went this step: a wizard looks where // Where the eyes' own magic went this step: a wizard looks where
// they aim, so the camera turns to watch its own spells land. // they aim, so the camera turns to watch its own spells land.
const aim = (() => { const aim = aimOfEvents(step.events, v, povId, me.position, step.actor);
for (const e of step.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 ?? 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;
} 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 - (me.position.x + 0.5)) > 0.3 || Math.abs(m.y - (me.position.y + 0.5)) > 0.3) {
return { x: m.x - 0.5, y: m.y - 0.5 };
}
continue;
}
if (cell && (cell.x !== me.position.x || cell.y !== me.position.y)) return cell;
// The deed lands on the wizard's OWN square — a summon at their
// feet, invisible from inside their own eyes. Mark it for the
// cutaway: step outside yourself and watch.
if (cell) return { ...cell, self: true };
}
// Last resort, only for the reel's own wizard's step: wherever the
// fx layer is about to play something visible — an impact, a
// projectile's landing — the eyes should already be.
if (step.actor === povId) {
for (const e of step.events) {
if (e.type === "creatureMoved" && "by" in e && e.by === povId) {
const to = (e as { to: { x: number; y: number } }).to;
if (to.x !== me.position.x || to.y !== me.position.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 === me.position.x && at.y === me.position.y };
}
}
for (const { fx } of fpFxForEvents(step.events, v, 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 === me.position.x && cy === me.position.y) return { x: cx, y: cy, self: true };
return { x: cx, y: cy };
}
}
return null;
})() as ({ x: number; y: number; self?: boolean } | null);
// A leap the legs cannot explain (first frame, teleport, the return // 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 // from a cutaway) is a CUT, and a cut is not a stride: the direction
// of travel means nothing at the far end. // of travel means nothing at the far end.
@@ -336,15 +249,12 @@
* corridor, facing it, holding a beat of the world-before so the * corridor, facing it, holding a beat of the world-before so the
* deed happens ON screen — with the reel's own wizard visible. */ * deed happens ON screen — with the reel's own wizard visible. */
const takeCutaway = (ax: number, ay: number) => { const takeCutaway = (ax: number, ay: number) => {
let best = { d: -1, a: 0 }; const a = deepestFacing(v, ax, ay);
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { const d = castRay(v, ax, ay, a).dist;
const h = castRay(v, ax, ay, a); const back = Math.min(1.6, Math.max(0.35, d - 0.4));
if (h.dist > best.d) best = { d: h.dist, a }; cam.x = ax + Math.cos(a) * back;
} cam.y = ay + Math.sin(a) * back;
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4)); cam.facing = a + Math.PI;
cam.x = ax + Math.cos(best.a) * back;
cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI;
camReady = true; camReady = true;
cutawayShot = true; cutawayShot = true;
const i2 = Math.min(idx, steps.length - 1); const i2 = Math.min(idx, steps.length - 1);
+256 -11
View File
@@ -3,7 +3,7 @@
// GameView. Columns of wall shaded by distance and facing; token art // GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column // billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote. // 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 { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d"; import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d"; import { terrainFallback, TERRAIN3D } from "./terrain3d";
@@ -22,6 +22,8 @@
fx = [], fx = [],
posOverride, posOverride,
rubble = [], rubble = [],
ontarget,
litCells = null,
}: { }: {
view: GameView; view: GameView;
povId: string; povId: string;
@@ -38,11 +40,44 @@
posOverride?: Record<string, { x: number; y: number }>; posOverride?: Record<string, { x: number; y: number }>;
/** Where walls have died: a mound of stone marks each fallen edge. */ /** Where walls have died: a mound of stone marks each fallen edge. */
rubble?: { x: number; y: number }[]; 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(); } = $props();
const FOV = Math.PI / 2.9; const FOV = Math.PI / 2.9;
let canvas: HTMLCanvasElement; 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. // Token art loads lazily; a sprite draws once its image has arrived.
const images = new Map<string, HTMLImageElement>(); const images = new Map<string, HTMLImageElement>();
function imageFor(src: string): HTMLImageElement | null { function imageFor(src: string): HTMLImageElement | null {
@@ -168,6 +203,17 @@
const fl = pixelsOf(textures.floor!); const fl = pixelsOf(textures.floor!);
const ce = pixelsOf(textures.ceiling!); const ce = pixelsOf(textures.ceiling!);
const FOG = 13; 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++) { for (let row = 0; row < H; row++) {
const below = row > half; const below = row > half;
const dz = below ? row - half : half - row; const dz = below ? row - half : half - row;
@@ -201,6 +247,11 @@
let v = wy % 1; if (v < 0) v += 1; let v = wy % 1; if (v < 0) v += 1;
const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4; const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4;
let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!; let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!;
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) { if (dec) {
const hx = (wx - u) | 0, hy = (wy - v) | 0; const hx = (wx - u) | 0, hy = (wy - v) | 0;
if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) { if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) {
@@ -221,9 +272,9 @@
} }
} }
} }
buf[o] = r * shade; buf[o] = r * sh;
buf[o + 1] = g * shade; buf[o + 1] = g * sh;
buf[o + 2] = b * shade; buf[o + 2] = b * sh;
} }
buf[o + 3] = 255; buf[o + 3] = 255;
wx += stepX; wx += stepX;
@@ -237,6 +288,7 @@
// side must MATCH its column's, or bodies near a far mouth would // side must MATCH its column's, or bodies near a far mouth would
// ghost into real corridors the unrolled space happens to overlap. // ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W); const zbuf = new Float64Array(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 // 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 // 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 // nearer than the mouth (it stands in front of the window); a
@@ -281,6 +333,7 @@
} }
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
const top = half - wallH / 2; const top = half - wallH / 2;
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!; const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!;
// Sample by the texture's own size: painted files may be any scale. // Sample by the texture's own size: painted files may be any scale.
// Fire shifts its slice per world cell, so a blaze spanning edges // Fire shifts its slice per world cell, so a blaze spanning edges
@@ -363,6 +416,10 @@
if (s) sprites.push({ ...s, 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.sort - a.sort); 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 — // The nearest sprite each column carries — depth AND vertical span —
// so the overlay passes (veils, ghosts, lintels, risings) can paint // so the overlay passes (veils, ghosts, lintels, risings) can paint
// around a body standing in front of them instead of over it. // around a body standing in front of them instead of over it.
@@ -392,11 +449,7 @@
if (s.glow) ctx.globalCompositeOperation = "lighter"; if (s.glow) ctx.globalCompositeOperation = "lighter";
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1; 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++) { for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) {
if (s.depth >= zbuf[col]!) continue; if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) continue;
if (s.warped) {
if (warpIdCol[col] !== s.warpId || s.depth <= warpDistCol[col]!) continue;
} else if (s.depth >= warpDistCol[col]!) continue;
if (s.clampL !== undefined && (col < s.clampL || col > s.clampR!)) continue;
if (s.depth < spriteZ[col]!) { if (s.depth < spriteZ[col]!) {
spriteZ[col] = s.depth; spriteZ[col] = s.depth;
sprTop[col] = s.top; sprTop[col] = s.top;
@@ -519,6 +572,71 @@
ctx.fillRect(0, 0, W, H); ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1; 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 { interface Projected {
@@ -535,6 +653,8 @@
fallback?: string; fallback?: string;
clampL?: number; clampL?: number;
clampR?: 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 /** Sort key only: the near-bias orders sprites among THEMSELVES (a
* hedge over the wizard standing in it) but must never let one * hedge over the wizard standing in it) but must never let one
* cheat past a wall — occlusion always uses the true depth. */ * cheat past a wall — occlusion always uses the true depth. */
@@ -544,7 +664,9 @@
b: { x: number; y: number; src: string; scale: number; rise: number; b: { x: number; y: number; src: string; scale: number; rise: number;
aspect?: number; alpha?: number; glow?: boolean; bias?: number; aspect?: number; alpha?: number; glow?: boolean; bias?: number;
fallback?: string; warped?: boolean; warpId?: number; fallback?: string; warped?: boolean; warpId?: number;
clip?: { x: number; y: number } }, clip?: { x: number; y: number };
hit?: { kind: "player" | "creature"; id: string };
cell?: { x: number; y: number } },
ex: number, ey: number, ex: number, ey: number,
): Projected | null { ): Projected | null {
const relX = b.x - ex, relY = b.y - ey; const relX = b.x - ex, relY = b.y - ey;
@@ -597,11 +719,126 @@
return { return {
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId, 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, 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, left: screenX - wide / 2, right: screenX + wide / 2,
top: bottom - size, bottom, 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 // Redraw every frame: the fire flickers and the warps swirl even when
// the camera holds still. // the camera holds still.
$effect(() => { $effect(() => {
@@ -612,9 +849,17 @@
}); });
</script> </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> <style>
.targeting { cursor: crosshair; }
.fpv-canvas { .fpv-canvas {
display: block; display: block;
width: 100%; width: 100%;
+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;
}
+24 -5
View File
@@ -292,6 +292,14 @@ export function edgeMid(cell: Cell, side: Side): { x: number; y: number } {
return { x: cell.x + 0.5, y: cell.y }; 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 { export interface Billboard {
/** World position (cell-centered). */ /** World position (cell-centered). */
x: number; x: number;
@@ -324,6 +332,13 @@ export interface Billboard {
* visible only through THAT warp's columns, beyond its mouth. */ * visible only through THAT warp's columns, beyond its mouth. */
warped?: boolean; warped?: boolean;
warpId?: number; 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 /** The along=0 corner of a warp mouth the anchor castRay's lane
@@ -384,13 +399,16 @@ export function billboards(
out.push({ out.push({
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5, 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, 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) { for (const c of view.creatures) {
if (c.id === povId) continue; // possessed: you do not see your own hide
const o = posOverride?.[c.id]; const o = posOverride?.[c.id];
out.push({ out.push({
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5, 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, key: c.id, 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) { for (const t of view.treasures) {
@@ -405,13 +423,14 @@ export function billboards(
out.push({ out.push({
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5, 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", src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
hit: { kind: "player", id: carrier.id }, cell: { ...carrier.position },
}); });
continue; continue;
} }
if (!t.position) continue; if (!t.position) continue;
out.push({ out.push({
x: t.position.x + 0.5, y: t.position.y + 0.5, x: t.position.x + 0.5, y: t.position.y + 0.5,
src, scale: 0.4, rise: 0, label: "treasure", src, scale: 0.4, rise: 0, label: "treasure", cell: { ...t.position },
}); });
} }
// The floor's furniture, standing in the room. Bushes fill the square // The floor's furniture, standing in the room. Bushes fill the square
@@ -426,25 +445,25 @@ export function billboards(
out.push({ out.push({
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind, ...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, scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
clip: { x: tx, y: ty }, clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
}); });
} else if (content.kind === "ooze") { } else if (content.kind === "ooze") {
out.push({ out.push({
...at, src: "/terrain3d/jello.png", fallback: "jello", ...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, scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
clip: { x: tx, y: ty }, clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
}); });
} else if (content.kind === "dust") { } else if (content.kind === "dust") {
out.push({ out.push({
...at, src: "/terrain3d/dust.png", fallback: "dust", ...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, scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
clip: { x: tx, y: ty }, clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
}); });
} else if (content.kind === "safe") { } else if (content.kind === "safe") {
out.push({ out.push({
...at, src: "/terrain3d/safe.png", fallback: "safe", ...at, src: "/terrain3d/safe.png", fallback: "safe",
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k, scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
clip: { x: tx, y: ty }, clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
}); });
} }
} }
+61 -39
View File
@@ -103,6 +103,16 @@ export function humanize(e: GameEvent): string | null {
case "doorHeld": return `${e.player} holds the door open.`; case "doorHeld": return `${e.player} holds the door open.`;
case "doorReleased": return `The held door swings shut.`; case "doorReleased": return `The held door swings shut.`;
case "doorsRelocked": return `The door swings shut and relocks.`; 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 "doorJammed": return `${e.player} jams a door's lock solid.`;
case "lockRemoved": return `${e.player} removes a door's lock for good.`; case "lockRemoved": return `${e.player} removes a door's lock for good.`;
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`; case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
@@ -215,20 +225,6 @@ export interface LogLine {
* momentSteps, so a chronicle turn number addresses the same commands. */ * momentSteps, so a chronicle turn number addresses the same commands. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]); 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",
"handsScrambled",
]);
const SEAT_KEY = "wizwar-seat"; const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats"; const SEATS_KEY = "wizwar-seats";
const CHAT_SEEN_KEY = "wizwar-chat-seen"; const CHAT_SEEN_KEY = "wizwar-chat-seen";
@@ -321,8 +317,6 @@ class Net {
/** Turns witnessed so far: counts the same boundary events the server /** Turns witnessed so far: counts the same boundary events the server
* counts, so a chronicle line can name the turn it belongs to. */ * counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1; 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). */ /** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null; private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null; private shareResolve: ((url: string) => void) | null = null;
@@ -445,12 +439,11 @@ class Net {
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++; if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
const line = humanize(e); const line = humanize(e);
if (line) { if (line) {
// One eye per turn — except the crown: the game-winning // Every turn wears its eye, on the header line that opens
// line always carries its own, whatever came before it. // it — the players judge what is share-worthy. The game-
// winning line carries its own besides.
const notable = this.turnCounter >= 0 && const notable = this.turnCounter >= 0 &&
(e.type === "gameWon" || (e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
(this.turnCounter !== this.eyeTurn && isNotableEvent(e)));
if (notable) this.eyeTurn = this.turnCounter;
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }]; this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
} }
} }
@@ -460,6 +453,14 @@ class Net {
} }
break; 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": case "transferCode":
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt }; this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
break; break;
@@ -488,24 +489,31 @@ class Net {
if (g.yourTurn && !was) this.notifyTurn(g); if (g.yourTurn && !was) this.notifyTurn(g);
this.lastYourTurn.set(key, g.yourTurn); this.lastYourTurn.set(key, g.yourTurn);
} }
// A seat the server answered for but did not list is gone — the // Prune ONLY seats the server proved dead: the room exists and
// room was deleted (or the seat's token voided). Quietly drop it // refused this exact token. A seat merely ABSENT from the reply
// from the ledger rather than showing "unreachable" forever. // stays — a restarting server, a stale restore, or the wrong
// (The server checks at most MAX_MYGAMES_SEATS = 50 per ask; // backend all answer with ignorance, and ignorance is not
// never prune blind past that cap.) // deletion. The wallet is capped by age instead of by trust.
if (this.seats.length <= 50) { const voided = new Set((msg.voided as string[] | undefined) ?? []);
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`)); let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`)); if (kept.length > 50) kept = kept.slice(kept.length - 50);
if (kept.length !== this.seats.length) { if (kept.length !== this.seats.length) {
this.seats = kept; this.seats = kept;
saveSeats(this.seats); saveSeats(this.seats);
}
} }
break; break;
} }
case "error": 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); localStorage.removeItem(SEAT_KEY);
}
if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
this.roomIdPending = null; this.roomIdPending = null;
} }
this.error = msg.message; this.error = msg.message;
@@ -578,6 +586,14 @@ class Net {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) }); 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 { rollTableDie(): void {
this.send({ type: "rollDie" }); this.send({ type: "rollDie" });
} }
@@ -648,21 +664,28 @@ class Net {
} catch { /* blocked at the OS level; the tab title still shows it */ } } catch { /* blocked at the OS level; the tab title still shows it */ }
} }
/** Forget the remembered seat and return to the lobby. */ /** Client-side teardown when the SERVER detached us (kick, abandon).
leave(): void { * The chronicle survives: the detachment appended its own explanatory
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat) * line, and wiping it would erase the only notice of why. */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY); localStorage.removeItem(SEAT_KEY);
this.roomId = null; this.roomId = null;
this.roomIdPending = null; this.roomIdPending = null;
this.view = null; this.view = null;
this.started = false; this.started = false;
this.players = []; this.players = [];
this.resetChronicle();
this.token = null; this.token = null;
this.spectating = false; this.spectating = false;
this.audience = 0; 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 { start(expansion: boolean): void {
this.send({ type: "start", expansion }); this.send({ type: "start", expansion });
} }
@@ -699,7 +722,6 @@ class Net {
private resetChronicle(): void { private resetChronicle(): void {
this.log = []; this.log = [];
this.turnCounter = -1; this.turnCounter = -1;
this.eyeTurn = -1;
} }
/** Summon one turn's reel by its chronicle turn number. */ /** Summon one turn's reel by its chronicle turn number. */
+4 -1
View File
@@ -20,6 +20,8 @@ export interface Prefs {
botTier: "apprentice" | "adept" | "archmage"; botTier: "apprentice" | "adept" | "archmage";
/** Instant replay: notable chronicle lines offer a first-person reel. */ /** Instant replay: notable chronicle lines offer a first-person reel. */
instantReplay: boolean; instantReplay: boolean;
/** The live first-person pane above the board during play. */
liveFp: boolean;
} }
const KEY = "wizwar-prefs"; const KEY = "wizwar-prefs";
@@ -27,7 +29,7 @@ const KEY = "wizwar-prefs";
function load(): Prefs { function load(): Prefs {
const fallback: Prefs = { const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null, art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "adept", instantReplay: true, cautions: true, botTier: "adept", instantReplay: true, liveFp: false,
}; };
try { try {
const raw = localStorage.getItem(KEY); const raw = localStorage.getItem(KEY);
@@ -42,6 +44,7 @@ function load(): Prefs {
cautions: p.cautions !== false, cautions: p.cautions !== false,
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept", botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
instantReplay: p.instantReplay !== false, instantReplay: p.instantReplay !== false,
liveFp: p.liveFp === true,
}; };
} catch { } catch {
return fallback; return fallback;
+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}`));