22 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5 42d872df6d Milestone two: the pane becomes the wand hand
Clicks in the first-person view now act. The renderer keeps what the
last frame knew — per-column depth, warp bends, every wall hit's edge,
every sprite's screen rect with its identity — and hitTest inverts the
projection: the nearest visible sprite answers as its wizard or
creature, a struck wall or door answers as its edge, and the ground
answers as the square under the pixel, warp-bent columns mapping their
virtual floor back to real cells through the warp's own rigid motion.
The pane hands each target to the very functions the top-down board's
clicks use, so pane and map can never disagree: click the floor ahead
and you walk there; click a door with a key selected and the lock
turns; click a wizard with an attack and the spell flies.

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

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

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

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

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

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

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

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

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

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

All 21 ledgers verified; 284 tests.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-25 14:28:12 -04:00
20 changed files with 1880 additions and 157 deletions
+140
View File
@@ -0,0 +1,140 @@
---
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 has won two games
with quietly-placed wormholes ending beside his home. The client now
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".
+236 -55
View File
@@ -9,6 +9,7 @@
import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game";
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
@@ -293,6 +294,17 @@ function pathDenial(view: GameView): Command | null {
if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! });
}
}
// Enemy gold banked at MY home: my score, snatchable by anyone. A
// raider closing on the bank gets the same road-lengthening treatment.
const me2 = me(view);
const banked = view.treasures.find(
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home));
if (banked) {
for (const e of livingEnemies(view)) {
const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y);
if (d <= 6) threats.push({ enemy: e.position, goal: me2.home });
}
}
if (threats.length === 0) return null;
const sighted = sightedCellsFor(view);
@@ -358,7 +370,12 @@ function pathDenial(view: GameView): Command | null {
);
}
// Never brick the square the treasure needs to stay reachable on.
if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
// TACKS are scattered at the caster's feet ("you must be adjacent"),
// not thrown — offering a distant square wedges the whole brain on
// an eternally refused cast.
const fillerReaches = !filler || filler.cardId !== "handful-of-tacks" ||
Math.abs(b.x - me(view).position.x) + Math.abs(b.y - me(view).position.y) <= 1;
if (filler && fillerReaches && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
consider(
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
{ avoidCell: cellKey(b) },
@@ -597,7 +614,7 @@ function pathToward(
const hazard = view.squareContents[k]?.kind;
if (!opts.throughHazards &&
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime")) continue;
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
next.push(to);
}
@@ -630,7 +647,16 @@ function treasureGoals(view: GameView): Set<string> {
}
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
if (t.owner === view.you) continue;
if (t.owner === view.you) {
// REPOSSESSION: my own gold banked at an enemy's home is a point on
// THEIR scoreboard. Marching to take it back is worth the detour
// only when that enemy is one delivery from winning — constant
// re-stealing farms nothing and stalls the whole table.
const banker = view.players.find(
(p) => p.id !== view.you && cellKey(p.home) === cellKey(t.position!));
if (banker && deliveryWins(view, banker)) goals.add(cellKey(t.position));
continue;
}
if (cellKey(t.position) === cellKey(self.home)) continue;
goals.add(cellKey(t.position));
}
@@ -645,6 +671,15 @@ function bushOrMist(view: GameView, id: PlayerId): boolean {
view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id);
}
/** Would this wizard's delivery, landed, win them the game right now? */
function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTreasureId: string | null }): boolean {
if (!p.carriedTreasureId) return false;
const carried = view.treasures.find((t) => t.id === p.carriedTreasureId);
if (!carried || carried.owner === p.id) return false;
return view.treasures.some(
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home));
}
/** The wizard making off with MY gold, if any. */
function thiefOfMine(view: GameView) {
const carriers = new Set(
@@ -722,7 +757,16 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
return { type: "pass" };
}
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
// The LAST counter in hand is a treasure of its own: at a healthy life
// total it waits for something big rather than answering every jab —
// patience the clockwork's best opponent taught it, three counters at
// a time. (Lethal blows override this below.)
const counterCount = view.yourHand.filter((c) => {
const t = cardDef(c.cardId).cardType;
return t === "counteraction" || t === "neutral/counteraction";
}).length;
const lastCounterHold = counterCount <= 1 && me(view).life >= 10 ? 2 : 0;
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift - lastCounterHold;
const attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
@@ -749,19 +793,24 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1;
// A blow that would be the last one outranks every thrift threshold:
// when the incoming points reach the clockwork's life, any counter
// that can save it is cheap at the price.
const lethal = !affliction && incoming >= me(view).life;
const weight = lethal ? Math.max(incoming, 9) : incoming;
if (stack.kind === "spell") {
// REVERSE eats points; a pure duration offers it nothing.
const reverse = find("reverse");
if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
if (reverse && !affliction && weight >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
// ABSORB SPELL steals the good ones for later.
const absorbSpell = find("absorb-spell");
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
if (absorbSpell && weight >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
const shield = find("full-shield");
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) {
return { type: "counteract", instanceId: shield.instanceId };
}
const reflect = find("full-reflection");
if (reflect && incoming >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
if (reflect && weight >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
// A curse in flight is best refused at the door.
if (affliction) {
const cleanse = find("remove-curse");
@@ -777,9 +826,11 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
const permanentCurse = affliction &&
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
const absorb = find("absorb");
if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
if (absorb && !affliction && weight >= 2 - flinch && (incoming <= 3 || (lethal && incoming - 3 < me(view).life))) {
return { type: "counteract", instanceId: absorb.instanceId };
}
const blunt = find("blunt");
if (blunt && !permanentCurse && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
if (blunt && !permanentCurse && weight >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
// The berserker shares its pain out of spite — but only pain: a
// damage-less curse gives EMPATHY nothing to mirror.
const empathy = find("empathy");
@@ -788,13 +839,13 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
}
// Nothing to block with: teleport clear of the big ones.
const tp = find("teleport");
if (tp && incoming >= 4 - flinch) {
if (tp && weight >= 4 - flinch) {
const out = escapeCell(view, me(view).position);
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
}
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
const vanish = find("invisible");
if (vanish && incoming >= 3 - flinch) {
if (vanish && weight >= 3 - flinch) {
const num = numbersInHand(view)[0];
return {
type: "counteract", instanceId: vanish.instanceId,
@@ -814,12 +865,44 @@ function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Comma
/** The best attack available against a visible target, numbers and amplify included. */
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
const numbers = numbersInHand(view);
// TURN THEFT: against a carrier one delivery from winning, a
// LIGHTNING BLAST's stolen turn outranks any point total — the number
// rides along so soaks cannot zero the damage and void the stun.
{
const carrier = view.players.find((p) => p.id === targetId);
const bolt = inHand(view, "lightning-blast");
const biggestNum = numbers[numbers.length - 1];
if (carrier && bolt && biggestNum && deliveryWins(view, carrier)) {
return {
damage: cardDef(biggestNum.cardId).value ?? 1,
cmd: {
type: "cast", instanceId: bolt.instanceId,
target: { kind: "player", playerId: targetId },
numberInstanceIds: [biggestNum.instanceId],
},
};
}
}
const biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
let best: { cmd: Command; damage: number } | null = null;
// A killing blow at minimal spend outranks maximum splash — but only
// when it clears the target's life with soak to spare: an exact-lethal
// blow dies to one ABSORB, so thrift begins three points past the kill.
type BestPick = { cmd: Command; damage: number; kill: boolean; cost: number };
let best: BestPick | null = null;
const offer = (damage: number, cost: number, cmd: Command) => {
const rank = (d: number) => (d >= target.life + 3 ? 2 : d >= target.life ? 1 : 0);
const r = rank(damage);
const better = !best ? true
: r !== rank(best.damage) ? r > rank(best.damage)
: r === 2 ? cost < best.cost
: damage !== best.damage ? damage > best.damage
: cost < best.cost;
if (better) best = { cmd, damage, kill: damage >= target.life, cost };
};
for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId];
if (!atk) continue;
@@ -831,25 +914,27 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if ((uncharged || atk.needsNumber) && !biggest) continue;
const withNumber = (atk.perNumber || uncharged) && biggest;
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
if (damage <= 0) continue;
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
const amplified = amplify && damage >= 4 && !isWand &&
c.cardId !== "dagger" && c.cardId !== "large-rock";
if (amplified) damage *= 2;
if (!best || damage > best.damage) {
best = {
damage,
cmd: {
const mustNumber = uncharged || atk.needsNumber === true;
const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
const numberChoices: (CardInstance | undefined)[] = atk.perNumber
? [undefined, ...numbers]
: [mustNumber ? biggest : undefined];
for (const num of numberChoices) {
if (mustNumber && !num) continue;
const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
const base = atk.base + (atk.perNumber && num ? value : 0);
if (base <= 0) continue;
for (const amp of canAmplify && base >= 4 ? [false, true] : [false]) {
const damage = amp ? base * 2 : base;
offer(damage, value + (amp ? 5 : 0), {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
...(amplified ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
},
};
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
});
}
}
}
// STONE DEAD: number times the stones the victim carries — the displayed
@@ -858,9 +943,10 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown;
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
const cur = best as BestPick | null;
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
best = {
damage: dmg,
damage: dmg, kill: dmg >= target.life, cost: biggestValue,
cmd: {
type: "cast", instanceId: sd.instanceId,
target: { kind: "player", playerId: targetId },
@@ -869,7 +955,8 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd
};
}
}
return best;
const picked = best as BestPick | null;
return picked ? { cmd: picked.cmd, damage: picked.damage } : null;
}
/** An affliction worth casting when no damage lands, mid number attached.
@@ -957,6 +1044,14 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
return { type: "cast", instanceId: c.instanceId };
}
}
// So does the MASTER KEY: displayed, every workable lock in the maze
// opens under its bearer's hand without another cast.
{
const mk = inHand(view, "master-key");
if (mk && !self.displayed.some((d) => d.instanceId === mk.instanceId)) {
return { type: "cast", instanceId: mk.instanceId };
}
}
if (!tier.buffs) return null; // the apprentice's book ends at the stones
// A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some(
@@ -1036,6 +1131,43 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
}
}
}
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
// delivery into a three-move stroll — the pattern that won its best
// opponent two games. Placed early, from home's doorstep, far mouth
// by the richest distant gold.
{
const dwarp = inHand(view, "dimensional-warp");
const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
if (dwarp && nearHome && view.dimWarps.length === 0 && view.turn.round <= 6) {
const legal = (c: Cell) =>
view.board.cells[cellKey(c)] !== undefined &&
!view.board.homes.some((h) => h.x === c.x && h.y === c.y) &&
!view.squareContents[cellKey(c)] &&
!view.treasures.some((t) => t.position && cellKey(t.position) === cellKey(c)) &&
(view.groundObjects[cellKey(c)] ?? []).length === 0;
const goals = treasureGoals(view);
if (legal(self.position) && goals.size > 0) {
const dHome = distancesFrom(view, [self.home], false);
const sighted = sightedCellsFor(view);
let far: { cell: Cell; worth: number } | null = null;
for (const g of goals) {
const [gx, gy] = g.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const c = neighbor({ x: gx, y: gy }, side);
if (!legal(c) || !sighted.has(cellKey(c))) continue;
const worth = dHome.get(cellKey(c)) ?? 0;
if (worth >= 8 && (far === null || worth > far.worth)) far = { cell: c, worth };
}
}
if (far) {
return {
type: "cast", instanceId: dwarp.instanceId,
params: { cell: self.position }, target: { kind: "cell", cell: far.cell },
};
}
}
}
}
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
const myFloorTreasure = tier.guardGold
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
@@ -1128,10 +1260,13 @@ export function automatonCommand(
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) {
const prize = view.treasures.find(
(t) => t.position && !t.carriedBy && t.owner !== you && cellKey(t.position) === here &&
here !== cellKey(self.home),
(t) => t.position && !t.carriedBy && cellKey(t.position) === here &&
here !== cellKey(self.home) &&
(t.owner !== you ||
// Repossessing my own gold off an enemy's home square.
view.players.some((p) => p.id !== you && cellKey(p.home) === here)),
);
if (prize) return { type: "pickUpTreasure" };
if (prize) return { type: "pickUpTreasure", treasureId: prize.id };
}
// Wounded clockwork teleports clear of visible hunters.
@@ -1200,13 +1335,16 @@ export function automatonCommand(
const target = thief && visible.some((p) => p.id === thief.id)
? thief
: visible.sort((a, b) => a.life - b.life)[0]!;
// DROP OBJECT shakes my treasure out of the thief's hands.
if (thief && target.id === thief.id) {
const dob = inHand(view, "drop-object");
if (dob) {
// DROP OBJECT shakes the treasure out of the hands that matter:
// the thief of my gold, or any carrier whose delivery wins.
{
const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
: visible.find((p) => deliveryWins(view, p));
const dob = mark ? inHand(view, "drop-object") : undefined;
if (mark && dob) {
return {
type: "cast", instanceId: dob.instanceId,
target: { kind: "player", playerId: thief.id }, params: { cardId: "treasure" },
target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" },
};
}
}
@@ -1216,6 +1354,7 @@ export function automatonCommand(
if (exile) {
const carrier = visible.find((p) => {
if (thief && p.id === thief.id) return true;
if (deliveryWins(view, p)) return true;
if (!p.carriedTreasureId) return false;
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
});
@@ -1365,6 +1504,24 @@ export function automatonCommand(
}
}
// TELEPORT DELIVERY: carrying with home four wall-ignoring spaces away
// but farther by boot — blink in and drop next command. The jump beats
// any ambush camped on the walking road.
if (self.carriedTreasureId && !view.turn.actionsEnded) {
const tpHome = inHand(view, "teleport");
if (tpHome) {
const blink = wallIgnoringDistance(view.board, self.position, self.home);
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
const dWalk = blink >= 1 && blink <= 4
? distancesFrom(view, [self.position], UNLOCKS.some((id) => inHand(view, id)))
.get(cellKey(self.home)) ?? Infinity
: 0;
if (blink <= 4 && blink >= 1 && dWalk > movesLeft) {
return { type: "cast", instanceId: tpHome.instanceId, target: { kind: "cell", cell: { ...self.home } } };
}
}
}
// March. The thief-chase outranks everything; the berserker hunts wizards
// over gold; the worrier keeps its distance; the hunter goes to the gold.
if (view.turn.movementUsed < view.turn.movementAllowance) {
@@ -1374,19 +1531,38 @@ export function automatonCommand(
const ownGoldCells = new Set(view.treasures
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
.map((t) => cellKey(t.position!)));
// A thief already underfoot is not a destination — with the chase
// moot, march on the real goals (deliver, grab) instead of standing
// on them forever.
const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null;
// The chase exists to deliver a blow, so it claims the march only
// while that blow is still live: an attack in hand, this turn's
// attack unspent, casting not yet closed. Those gates hold still as
// the clockwork walks — a goal keyed to the thief's nearness flips
// underfoot and shuttles the walker on and off their square. Caught
// with the strike still live, it stands its ground (a goal underfoot
// ends the march); otherwise the gold has its legs for the turn.
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
const chasing = thief !== null && strikeLive;
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
// anyone may snatch it off the floor. A raider nearer my bank than I
// am, with the bank stocked, outranks the next grab — run home.
const bankedCount = view.treasures.filter(
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
// The predicate reads only ENEMY positions — my own steps must not
// flip it mid-march or the walker shuttles (the thief-chase lesson).
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
livingEnemies(view).some((p) =>
!p.carriedTreasureId &&
Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2);
const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells
: thiefAfar
? new Set([cellKey(thiefAfar.position)])
: style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
: chasing
? new Set([cellKey(thief.position)])
: bankThreatened
? new Set([cellKey(self.home)])
: style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
const path =
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thiefAfar, canUnlock }) ??
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ??
pathToward(view, self.position, enemyCells, { canUnlock }) ??
// No clean road to anything: grit the teeth and wade the hazards,
@@ -1469,12 +1645,17 @@ export function automatonCommand(
// The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn.
const wantsNumber = view.yourHand.some((c) => {
const atk = ATTACKS[c.cardId];
if (!atk) return false;
return atk.perNumber || atk.needsNumber === true ||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
});
// — but a chest hoarded with no enemy in reach just slows the
// march, and no reserve outbids the delivery that wins the game.
const foesClose = livingEnemies(view).some((p) =>
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6);
const wantsNumber = foesClose && !deliveryWins(view, self) &&
view.yourHand.some((c) => {
const atk = ATTACKS[c.cardId];
if (!atk) return false;
return atk.perNumber || atk.needsNumber === true ||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
});
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
// A treasure within free walking ends this turn at the grab (the
// gold-underfoot rung outranks everything) — a number played first
+281 -45
View File
@@ -195,6 +195,9 @@ export interface CastStack {
/** FULL REFLECTION vs SWAP MEET: the reflector's chosen trade. */
cardId?: string }[];
waitingOn: PlayerId;
/** AROUND THE CORNER carried this attack: sight bent through a middle
* square, so the straight line the table would look for does not exist. */
bentCorner?: true;
/** CHAOS only: the defender's FULL SHIELD sat them out rather than stopping it. */
defenderShielded?: boolean;
/** Set when a creature, not a wizard, delivers the attack. */
@@ -225,6 +228,9 @@ export interface CastParams {
hold?: boolean;
}
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */
export const CURRENT_RULES_REV = 5;
export interface GameConfig {
playerIds: PlayerId[];
seed: number;
@@ -235,17 +241,18 @@ export interface GameConfig {
colors?: number[];
/**
* Rules revision, frozen per game so stored games replay unchanged.
* Absent = original. Rev 2: LIFESAVER leaves two-player decks ("Not
* applicable in a 2-player game."). Rev 3: WARD springs only when armed,
* and CHAOS honors FULL SHIELD sit-outs and refuses REFLECTIONS. Rev 4:
* creature blows open a counteraction window like any attack. Rev 5:
* BIG MAN pushes occupants ahead, steps over floor hazards for 2 points,
* and bars monsters from his square. Rev 6: ANTI-ANTI cannot pin a
* teleport escape ("does not work against escape" the card face).
* Rev 7: no behavioral change every board opening carries warp sight
* in every revision. DIMENSIONAL WARP's tokens never do ("There is no L.O.S. through the
* warp." the card face). Rev 8: nothing can be created on a warp token,
* and a SPEED bonus turn burns a turn of durations on the hastened wizard.
* Absent = rev 1, the baseline all earlier revisions collapsed into.
* Rev 2: a wizard beside a door they can open (lock removed, door
* unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
* the doorway; the hallway behind them still cannot.
* Rev 3: VISIONSTONE pierces its one wall for every sight the game
* asks of its bearer creations and utility spells included not
* only direct attacks.
* Rev 4: "the door will relock behind you" means it passing through
* an unlocked door shuts it at the walker's back unless a hand holds
* it; unpassed, it relocks at turn's end as before.
* Rev 5: MENTAL FORCE refuses a destination the victim cannot walk to
* in three spaces, instead of eating the card silently at resolution.
*/
deckRev?: number;
}
@@ -348,19 +355,71 @@ export function losBlockers(state: GameState): Record<string, true> {
return blockers;
}
/** A held-open door is an open doorway to the eye: REMOVE LOCK's "still
* considered to block L.O.S." speaks of a CLOSED door, and
* the table holds doors open precisely to cast back through them. */
function openHeldDoors(state: GameState, board: AssembledBoard): AssembledBoard {
/** A door HELD open is propped ajar for every eye in the hallway. Any
* other door hangs shut, and a shut door blocks L.O.S. see doorsAjar
* for the one wizard who may pull it open and peek. */
function openedDoors(state: GameState, board: AssembledBoard): AssembledBoard {
if (state.heldDoors.length === 0) return board;
const edges = { ...board.edges };
for (const h of state.heldDoors) delete edges[h.key];
return { ...board, edges };
}
/** A wizard at a door's threshold may pull it open and look through
* without stepping through: any wizard beside a door whose lock is
* REMOVED or that stands unlocked this turn, and a holder of PICK LOCK
* or MASTER KEY beside any workable lock. The door still hangs shut to
* everyone down the hallway. (Rules rev 2; earlier games replay with
* doors dark.) */
function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: AssembledBoard): AssembledBoard {
if ((state.config.deckRev ?? 1) < 2 || !viewerId) return board;
const viewer = state.players.find((p) => p.id === viewerId);
if (!viewer || !viewer.alive) return board;
const carriesKey = viewer.hand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
let edges: Record<string, EdgeState> | null = null;
for (const side of SIDES) {
const key = edgeKey(viewer.position, side);
if (board.edges[key] !== "door") continue;
const workable = carriesKey && state.doorStates[key] !== "jammed";
if (!workable && state.doorStates[key] !== "removed" && !state.openDoorEdges.includes(key)) continue;
if (!edges) edges = { ...board.edges };
delete edges[key];
}
return edges ? { ...board, edges } : board;
}
/** LOS including square-filling blockers. */
export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
return sightBetween(openHeldDoors(state, boardView(state)), from, to, losBlockers(state));
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;
for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[key];
if (sightBetween({ ...board, edges }, from, to, blockers)) return true;
}
return false;
}
/** LOS including square-filling blockers. VISIONSTONE is the bearer's
* sight wherever sight is asked of them creations, dispels, utility
* spells not just attacks: one wall or door, any type, falls away.
* Safe at every revision: widening what a cast may target never changes
* how a recorded command replays. */
export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean {
return losWith(state, from, to, viewerId, true);
}
/** The eye of an armed ambush. Springing is decided mid-move and lands
* in the ledger's flow, so this sight is frozen per game: before rev 3
* an ambush never looked through its owner's VISIONSTONE, and stored
* games must replay that blindness. */
function ambushLos(state: GameState, owner: PlayerState, from: Cell, to: Cell): boolean {
return losWith(state, from, to, owner.id, (state.config.deckRev ?? 1) >= 3);
}
/** Parse an edge key back into its north/west cell and side. */
@@ -409,7 +468,7 @@ function casterLos(
from: Cell,
to: Cell,
): boolean {
const board = openHeldDoors(state, perceivedBoard(state, caster.id));
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false;
@@ -525,6 +584,8 @@ export type GameEvent =
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
| { type: "creatureWarpStepped"; creatureId: string; from: Cell; to: Cell; by: PlayerId }
| { type: "mentalForceFizzled"; attacker: PlayerId; defender: PlayerId; cell: Cell }
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell;
/** Origins in FINAL coordinates (the maze may renormalize after the
* landing); from/to record the pre-shift request. */
@@ -641,6 +702,7 @@ export type Command =
| { type: "wardChoice"; play: boolean }
| { type: "warpStep" }
| { type: "moveCreature"; creatureId: string; direction: Side }
| { type: "creatureWarpStep"; creatureId: string }
| { type: "creatureAttack"; creatureId: string; targetId: string }
| {
type: "cast";
@@ -693,7 +755,7 @@ type AttackEffect = {
sustains?: boolean;
/** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */
keepInHand?: boolean;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>) => string | null;
validate?: (state: GameState, cmd: Extract<Command, { type: "cast" }>, caster?: PlayerState, target?: PlayerState) => string | null;
onResolved?: (ctx: ResolutionContext) => void;
};
@@ -1182,8 +1244,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
keepInHand: true,
// "Unlocks any door (door relocks behind you). Do not discard when used.
// Display Immediately. Must be adjacent. Does not work on a JAMmed LOCK."
// Cast bare, it simply goes on display, and a DISPLAYED key turns in
// every lock its bearer walks through (see doMove) — no further casts.
// Cast at a door, it works that one lock like PICK LOCK and may hold
// the door open for others.
resolve: (state, events, caster, cmd) =>
unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true }),
cmd.target
? unlockDoor(state, events, caster, cmd, "master-key", { requireAdjacent: true })
: null,
},
"remove-lock": {
kind: "neutral",
@@ -1306,7 +1374,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
// Effectively permanent: broken by the caster attacking the target.
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
return null;
@@ -1474,14 +1542,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = cellKey(cmd.target.cell);
const creature = creatureAt(state, cmd.target.cell);
if (creature) {
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
destroyCreature(state, events, creature, "dispel creation");
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
return null;
}
const content = state.squareContents[key];
if (!content) return "nothing created there";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
return null;
@@ -1498,7 +1566,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (target.id === caster.id) return "you cannot drag yourself";
if (!gameLos(state, caster.position, target.position)) return "no line of sight";
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
if (isLockedInPlace(state, target.id)) return "they are locked in place";
const from = target.position;
dragToward(state, target, caster.position);
@@ -1507,7 +1575,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
const objects = state.groundObjects[key];
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
if (objects && objects.length > 0) {
@@ -1584,7 +1652,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
resolve: (state, events, caster) => {
for (const opp of state.players) {
if (!opp.alive || opp.id === caster.id) continue;
if (!gameLos(state, caster.position, opp.position)) continue;
if (!gameLos(state, caster.position, opp.position, caster.id)) continue;
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
retreatFromSight(state, events, opp, caster.position);
}
@@ -1680,7 +1748,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return "no such monster";
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
if (!gameLos(state, caster.position, creature.position)) return "no line of sight";
if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight";
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
if (boost === "movement") creature.movesPerTurn *= 2;
else creature.maxDamage *= 2;
@@ -1882,7 +1950,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to glue down";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
state.gluedCells[key] = true;
// "a duration equal to twice the NUMBER card played"
const turns = magnitude.duration * 2;
@@ -1906,7 +1974,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to lock up";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
return null;
@@ -1924,7 +1992,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ka === kb) return "pick two different squares";
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
if (!gameLos(state, caster.position, a) || !gameLos(state, caster.position, b)) return "no line of sight";
if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight";
const itemsA = state.groundObjects[ka] ?? [];
const itemsB = state.groundObjects[kb] ?? [];
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
@@ -1987,7 +2055,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
if (!gameLos(state, caster.position, cmd.target.cell)) return "no line of sight";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
@@ -2103,11 +2171,19 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
"mental-force": {
kind: "attack",
baseDamage: () => 0, // no LOS printed
validate: (state, cmd) => {
validate: (state, cmd, caster, target) => {
const cell = cmd.params?.cell;
if (!cell) return "say where they go (within three moved spaces)";
if (!boardView(state).cells[cellKey(cell)]) return "off the board";
if (state.squareContents[cellKey(cell)]?.kind === "stone") return "that square is solid stone";
// The victim WALKS those three spaces — a destination the maze's
// walls put out of reach is refused up front, not swallowed at
// resolution with the card already spent. (Rev 5; the victim may
// still slip out of range before it resolves — see onResolved.)
if ((state.config.deckRev ?? 1) >= 5 && target &&
walkingDistance(state, target.position, cell) > 3) {
return "the walls put that square beyond three moved spaces";
}
return null;
},
onResolved: (ctx) => {
@@ -2115,7 +2191,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (isLockedInPlace(ctx.state, ctx.defender.id)) return;
const to = ctx.stack.params?.cell;
if (!to) return; // an ambush armed without a destination fizzles
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) return;
if (walkingDistance(ctx.state, ctx.defender.position, to) > 3) {
// The victim slipped beyond reach: the force strains and fails,
// and the table sees it fail rather than wondering.
ctx.events.push({ type: "mentalForceFizzled", attacker: ctx.attacker.id, defender: ctx.defender.id, cell: to });
return;
}
const from = ctx.defender.position;
ctx.defender.position = to;
ctx.events.push({ type: "teleported", player: ctx.defender.id, from, to, by: ctx.attacker.id, cardId: "mental-force" });
@@ -2536,7 +2617,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const other = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!other || !other.alive) return "no such living player";
if (other.id === caster.id) return "that is already your home";
if (!gameLos(state, caster.position, other.position)) return "no line of sight to them";
if (!gameLos(state, caster.position, other.position, caster.id)) return "no line of sight to them";
const onHome = (home: Cell) =>
state.treasures.filter((t) => t.position && cellKey(t.position) === cellKey(home)).length;
if (onHome(caster.home) !== onHome(other.home)) {
@@ -2727,7 +2808,7 @@ function emptySquareTarget(
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it";
}
if (!gameLos(state, caster.position, cell)) return "no line of sight";
if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight";
return cell;
}
@@ -2860,6 +2941,57 @@ function impCheck(state: GameState, events: GameEvent[], onlyPlayer?: PlayerId):
checkVictory(state, events);
}
/** 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 square");
}
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 }];
// Touch effects on arriving in a player's square, as any step has.
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;
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 { ok: true, state, events };
}
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 { ok: true, state, events };
}
}
return { ok: true, state, events };
}
function doMoveCreature(prev: GameState, creatureId: string, direction: Side): CommandResult {
const blocked = requireActionsAvailable(prev);
if (blocked) return err(blocked);
@@ -3361,7 +3493,7 @@ function walkingDistance(state: GameState, from: Cell, to: Cell): number {
}
/** BFS steps between cells ignoring walls (teleport distance). */
function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
export function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): number {
if (cellKey(from) === cellKey(to)) return 0;
const seen = new Map<string, number>([[cellKey(from), 0]]);
const queue: Cell[] = [from];
@@ -3370,8 +3502,17 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
const d = seen.get(cellKey(cur))!;
if (d >= 8) break; // teleport range is 4; stop early
for (const side of SIDES) {
const n = neighbor(cur, side);
if (!board.cells[cellKey(n)] || seen.has(cellKey(n))) continue;
let n = neighbor(cur, side);
if (!board.cells[cellKey(n)]) {
// The maze wraps for walkers; it wraps for teleporters too —
// a warp mouth is one step, same as any doorway.
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cur) && w.from.side === side,
);
if (!w) continue;
n = w.to.cell;
}
if (seen.has(cellKey(n))) continue;
seen.set(cellKey(n), d + 1);
if (cellKey(n) === cellKey(to)) return d + 1;
queue.push(n);
@@ -3384,6 +3525,7 @@ function wallIgnoringDistance(board: AssembledBoard, from: Cell, to: Cell): numb
// Setup
export function createGame(config: GameConfig): { state: GameState; events: GameEvent[] } {
config = { ...config, deckRev: config.deckRev ?? CURRENT_RULES_REV };
const n = config.playerIds.length;
let rng = createRng(config.seed);
const events: GameEvent[] = [];
@@ -3669,6 +3811,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman
case "wardChoice": return err("no grab is hanging on your Ward");
case "warpStep": return doWarpStep(state);
case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction);
case "creatureWarpStep": return doCreatureWarpStep(state, command.creatureId);
case "creatureAttack": return doCreatureAttack(state, command.creatureId, command.targetId);
case "cast": return doCast(state, command);
case "setAmbush": return doSetAmbush(state, command);
@@ -3747,11 +3890,60 @@ function fearRepels(state: GameState, moverId: PlayerId | null, from: Cell, to:
if (sustainedOn(state, other.id, "fear").length === 0) continue;
const d = dreadDistance(board, other.position, to);
const dBefore = dreadDistance(board, other.position, from);
if (d <= 3 && d < dBefore) return true;
if (d <= 3 && d < dBefore) {
// Approaching from outside the dread is never willing.
if (dBefore > 3) return true;
// Already caged inside it: the maze's corners may force a step
// that closes the crow-flies distance — permitted only when the
// step walks the shortest way OUT of the bubble.
const eFrom = escapeSteps(state, board, other.position, from);
const eTo = escapeSteps(state, board, other.position, to);
if (!(eTo < eFrom)) return true;
}
}
return false;
}
/**
* Walking steps (walls and shut doors respected, warp mouths crossed,
* fire braved) from `start` to the nearest cell beyond a dread bubble
* centered on `dreadFrom`. Infinity when no way out exists.
*/
function escapeSteps(state: GameState, board: AssembledBoard, dreadFrom: Cell, start: Cell): number {
if (dreadDistance(board, dreadFrom, start) > 3) return 0;
const passable = (cell: Cell, side: Side): Cell | null => {
const key = edgeKey(cell, side);
const e = board.edges[key] ?? "open";
if (e === "wall") return null;
if (e === "door" &&
!state.openDoorEdges.includes(key) &&
!state.heldDoors.some((h) => h.key === key)) return null;
let n = neighbor(cell, side);
if (!board.cells[cellKey(n)]) {
const w = board.warps.find(
(w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side,
);
if (!w) return null;
n = w.to.cell;
}
if (state.squareContents[cellKey(n)]?.kind === "stone") return null;
return n;
};
const seen = new Set<string>([cellKey(start)]);
const queue: [Cell, number][] = [[start, 0]];
while (queue.length > 0) {
const [cur, d] = queue.shift()!;
for (const side of SIDES) {
const n = passable(cur, side);
if (!n || seen.has(cellKey(n))) continue;
if (dreadDistance(board, dreadFrom, n) > 3) return d + 1;
seen.add(cellKey(n));
queue.push([n, d + 1]);
}
}
return Infinity;
}
/** A blind wizard's wasted lurch into a wall still costs a movement point. */
function blindBump(state: GameState, events: GameEvent[], p: PlayerState, direction: Side): CommandResult {
state.turn.movementUsed++;
@@ -3831,9 +4023,27 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult {
p.position = { ...warp.to.cell };
via = "warp";
crossedFirewall = true;
} else if (edge === "door" && doorIsOpen(state, key)) {
} else if (edge === "door" &&
(doorIsOpen(state, key) || (displays(p, "master-key") && state.doorStates[key] !== "jammed"))) {
// The displayed MASTER KEY turns in every lock it meets: the walker
// passes without another cast, and the door RELOCKS BEHIND THEM at
// once — "door relocks behind you" grants no lingering opening, so
// bystanders see only a shut door (the bearer's own threshold peek
// rides the key itself). A JAMmed LOCK still refuses it. To hold a
// door open, cast the key at it as ever.
if (!doorIsOpen(state, key)) {
events.push({ type: "doorUnlocked", player: p.id, edge: parseEdgeKey(key), withCardId: "master-key" });
}
p.position = dest;
via = "step";
// "The door will relock behind you": stepping through an unlocked
// door shuts it at the walker's back unless a hand holds it open.
// (Rev 4; earlier games kept their turn-long openings.)
if ((state.config.deckRev ?? 1) >= 4 &&
state.openDoorEdges.includes(key) && !state.heldDoors.some((h) => h.key === key)) {
state.openDoorEdges = state.openDoorEdges.filter((k) => k !== key);
events.push({ type: "doorsRelocked", count: 1 });
}
} else if (edge === "firewall") {
// "Passing through it does four points of magical damage." Firewalls
// burn even a MIST-BODY.
@@ -4316,7 +4526,7 @@ function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult
if (wall.createdBy === p.id) return err("you made it — you know exactly what it is");
if (wall.belief[p.id]) return err("your eyes have already ruled on that wall");
const events: GameEvent[] = [];
const board = openHeldDoors(state, perceivedBoard(state, p.id));
const board = doorsAjar(state, p.id, openedDoors(state, perceivedBoard(state, p.id)));
if (!isAdjacentToEdge(p.position, cell, side) &&
!losToEdge(board, p.position, cell, side)) {
return err("you cannot see that wall from here");
@@ -4627,6 +4837,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.kind === "neutral" && effect.displayOnce && caster.displayed.includes(inHand.instanceId)) {
return err(`${def.name} is already displayed`);
}
if (inHand.cardId === "master-key" && !cmd.target && caster.displayed.includes(inHand.instanceId)) {
return err("the key is already on display");
}
// Magic wands: charged on first use by the number card(s) played; one
// charge per use, one use per turn; discarded when the last charge goes.
@@ -4823,6 +5036,28 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
}
return { ok: true, state, events: events2 };
}
// CHAOS aims at no one: cast bare, it sweeps every other living
// wizard into the pile, each offered their FULL SHIELD sit-out in
// turn order. (Full shields only exclude; nothing in the set cancels
// a zero-point storm, so the queue is the whole defense.) The older
// player-targeted form is still accepted below, so stored games
// replay unchanged.
if (inHand.cardId === "chaos" && (!cmd.target || cmd.target.kind !== "player")) {
consumeCast(state, caster, inHand, mods, false);
if (state.turn.attackUsed) state.turn.secondAttackUsed = true;
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: null, targetCell: null,
}];
const queue = turnOrderFrom(state, caster.id).filter((id) =>
id !== caster.id && state.players.find((p) => p.id === id)!.alive);
state.chaosPending = { casterId: caster.id, excluded: [], queue };
finishChaosIfReady(state, events);
return { ok: true, state, events };
}
if (!cmd.target || cmd.target.kind !== "player") return err("attack spells target a player");
if (cmd.target.playerId === caster.id) return err("you cannot attack yourself");
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
@@ -4844,7 +5079,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
(s) => !(s.cardId === "buddy" && s.casterId === caster.id && s.targetId === target.id),
);
if (effect.validate) {
const problem = effect.validate(state, cmd);
const problem = effect.validate(state, cmd, caster, target);
if (problem) return err(problem);
}
if (inHand.cardId === "waterbolt") {
@@ -4932,6 +5167,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
kind: effect.physical ? "physical" : "spell",
counters: [],
waitingOn: target.id,
...(mods.aroundCorner ? { bentCorner: true as const } : {}),
};
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [...wandEvents, ...preEvents];
@@ -5089,8 +5325,8 @@ function checkAmbushes(
sprung = context.pickedUpTreasure === true;
} else if (context.movedFrom) {
if (ambush.trigger.kind === "los") {
const before = gameLos(state, owner.position, context.movedFrom);
const now = gameLos(state, owner.position, actor.position);
const before = ambushLos(state, owner, owner.position, context.movedFrom);
const now = ambushLos(state, owner, owner.position, actor.position);
sprung = now && !before;
} else if (ambush.trigger.kind === "near") {
const dist = (c: Cell) =>
@@ -5102,7 +5338,7 @@ function checkAmbushes(
// The committed spell must be legal right now, or the ambush stays armed.
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect;
if (fx.requiresLos && !gameLos(state, owner.position, actor.position)) continue;
if (fx.requiresLos && !ambushLos(state, owner, owner.position, actor.position)) continue;
state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
+59 -6
View File
@@ -2,7 +2,7 @@
// The server sends this after every state change; clients never see the
// deck order or other players' hands.
import { sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, type CardInstance } from "./cards";
import {
boardView,
@@ -62,6 +62,8 @@ export interface GameView {
/** Safes standing open (their combination entered this turn). */
openSafes: string[];
groundObjects: Record<string, CardInstance[]>;
/** The rules revision this game was dealt under. */
deckRev: number;
doorStates: Record<string, "jammed" | "removed">;
/** Accumulated attack damage per edge (public — cracks show). */
wallDamage: Record<string, number>;
@@ -153,6 +155,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
groundObjects: Object.fromEntries(
Object.entries(state.groundObjects).map(([k, v]) => [k, [...v]]),
),
deckRev: state.config.deckRev ?? 1,
doorStates: { ...state.doorStates },
wallDamage: { ...state.wallDamage },
openDoorEdges: [...state.openDoorEdges],
@@ -197,16 +200,50 @@ export function sightedCellsFor(view: GameView): Set<string> {
const [x, y] = key.split(",").map(Number) as [number, number];
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
}
// VISIONSTONE lets its bearer see through exactly one wall or door —
// any one — so a square is also sighted if removing a single edge
// reveals it, mirroring the engine's casterLos.
if (me.displayed.some((c) => c.cardId === "visionstone")) {
const unseen = Object.keys(board.cells).filter((k) => !out.has(k));
for (const edgeK of Object.keys(board.edges)) {
if (unseen.length === 0) break;
if ((board.edges[edgeK] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[edgeK];
const opened = { ...board, edges };
for (let i = unseen.length - 1; i >= 0; i--) {
const [x, y] = unseen[i]!.split(",").map(Number) as [number, number];
if (sightBetween(opened, me.position, { x, y }, blockers)) {
out.add(unseen[i]!);
unseen.splice(i, 1);
}
}
}
}
return out;
}
/** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// Held-open doors are open doorways to the eye.
// Held-open doors are open doorways to every eye. Beyond them, the
// viewer at a door's threshold may pull it open and peek (rules rev 2):
// lock removed, door unlocked this turn, or PICK LOCK / MASTER KEY in
// hand — mirroring the engine's doorsAjar.
let board = view.board;
if (view.heldDoorEdges.length > 0) {
const openKeys = new Set(view.heldDoorEdges);
const me = view.players.find((p) => p.id === view.you);
if (view.deckRev >= 2 && me?.alive) {
const carriesKey = view.yourHand.some((c) => c.cardId === "pick-lock" || c.cardId === "master-key");
for (const side of SIDES) {
const key = edgeKey(me.position, side);
if (board.edges[key] !== "door") continue;
const workable = carriesKey && view.doorStates[key] !== "jammed";
if (workable || view.doorStates[key] === "removed" || view.openDoorEdges.includes(key)) openKeys.add(key);
}
}
if (openKeys.size > 0) {
const edges = { ...board.edges };
for (const k of view.heldDoorEdges) delete edges[k];
for (const k of openKeys) delete edges[k];
board = { ...board, edges };
}
const blockers: Record<string, true> = {};
@@ -259,7 +296,7 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
*/
export function stackSightTrace(
view: GameView,
): { from: Cell; to: Cell; trace: SightTrace } | null {
): { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace } } | null {
const stack = view.stack;
if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
@@ -268,7 +305,23 @@ export function stackSightTrace(
if (!a || !d) return null;
if (a.position.x === d.position.x && a.position.y === d.position.y) return null;
const trace = traceSightFor(view, a.position, d.position);
return trace ? { from: a.position, to: d.position, trace } : null;
if (trace) return { from: a.position, to: d.position, trace };
// AROUND THE CORNER: no straight line exists — find a middle square both
// ends can see and draw the sight leg by leg, so the table can audit the
// needle instead of doubting it.
if (stack.bentCorner) {
for (const key of Object.keys(view.board.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if ((mid.x === a.position.x && mid.y === a.position.y) ||
(mid.x === d.position.x && mid.y === d.position.y)) continue;
const leg1 = traceSightFor(view, a.position, mid);
if (!leg1) continue;
const leg2 = traceSightFor(view, mid, d.position);
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } };
}
}
return null;
}
const CREATION_CARD_IDS = new Set([
+219
View File
@@ -14,6 +14,7 @@ import { pushSustained } from "./helpers";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
return (
state.wardPending?.ownerId ??
state.stack?.waitingOn ??
state.pendingDiscard ??
state.chaosPending?.queue[0] ??
@@ -735,3 +736,221 @@ describe("the clockwork flees the dread", () => {
}
});
});
describe("the thief-chase holds one goal per turn", () => {
it("a clockwork beside its thief never shuttles on and off their square", () => {
let { state } = createGame({ playerIds: ["thief", "bot"], seed: 42, sets: ["basic", "expansion1"] });
// Round 2, thief's turn burned; the bot acts with a full allowance.
while (state.turn.round < 2 || state.players[state.turn.activeIndex]!.id !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const thief = state.players.find((p) => p.id === "thief")!;
const bot = state.players.find((p) => p.id === "bot")!;
// The thief carries the bot's gold and stands one step away.
const mine = state.treasures.find((t) => t.owner === "bot")!;
mine.carriedBy = "thief";
mine.position = null;
thief.position = { x: 2, y: 4 };
bot.position = { x: 2, y: 5 };
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "open";
// A number card and no attacks: the blow the chase serves cannot land.
bot.hand = [];
bot.hand.push({ cardId: "number-2", instanceId: "N2" } as never);
const visited = [cellKey(bot.position)];
for (let guard = 0; guard < 40; guard++) {
const view = viewFor(state, "bot");
const cmd = automatonCommand(view, "hunter", "archmage") ?? automatonFallback(view, "archmage");
if (cmd.type === "endTurn") break;
const r = applyCommand(state, "bot", cmd);
if (!r.ok) break;
state = r.state;
const at = cellKey(state.players.find((p) => p.id === "bot")!.position);
if (cmd.type === "move" && at !== visited[visited.length - 1]) visited.push(at);
}
// No step may return to the square just departed: A-B-A is the shuttle.
for (let i = 2; i < visited.length; i++) {
expect(visited[i]).not.toBe(visited[i - 2]);
}
});
});
describe("the archmage's sharpened instincts", () => {
function botTurn(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" } as never);
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" } as never];
foe.hand.push({ cardId: "fireball", instanceId: "FB" } as never);
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
bot.hand = [
{ cardId: "fireball", instanceId: "FB" } as never,
{ cardId: "number-3", instanceId: "N3" } as never,
];
// Stand the bot a straight, open four squares from home.
const home = bot.home;
const column = [0, 1, 2, 3, 4].map((d) => ({ x: home.x, y: home.y + d }));
if (!column.every((c) => viewFor(state, "bot").board.cells[cellKey(c)])) return;
bot.position = { ...column[4]! };
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("lessons from the human duels", () => {
function rig(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 = [];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
// Whatever step it picks, the march must be TOWARD home, not the gold map.
expect(cmd?.type).toBe("move");
});
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" } as never];
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) return;
carried.carriedBy = "foe";
carried.position = null;
foe.carriedTreasureId = carried.id;
foe.position = { ...bot.position };
bot.hand = [
{ cardId: "fireball", instanceId: "FB" } as never,
{ cardId: "lightning-blast", instanceId: "LB" } as never,
{ cardId: "number-4", instanceId: "N4" } as never,
];
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 at range are never proposed — the RNRX coma", () => {
// Room RNRX froze Automaton II for three turns: pathDenial proposed
// scattering tacks four squares away, the engine refused ("you must
// be adjacent"), and the refusal-fallback loop read as a coma.
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" } as never];
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.
expect(r.ok, `refused: ${JSON.stringify(cmd)}${!r.ok ? r.error : ""}`).toBe(true);
if (!r.ok) break;
state = r.state;
if (cmd.type === "endTurn") break;
}
});
});
+162 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, type GameState } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Cell, type Side, SIDES, stepTarget } from "../src/board";
import type { CardInstance } from "../src/cards";
import { viewFor } from "../src/view";
import { sightedCellsFor, stackSightTrace, viewFor } from "../src/view";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */
@@ -402,6 +402,35 @@ describe("the ward window and chaos shields", () => {
expect(state.players.find((p) => p.id === owner.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("chaos casts bare — no target, every other wizard queued for a shield", () => {
let { state } = threeGame();
state = toRound2(state);
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
const caster = activePlayer(state);
const others = state.players.filter((p) => p.id !== caster.id).map((p) => p.id);
const chaos = giveCard(state, caster.id, "chaos", "C", 0);
const shielder = others[0]!;
giveCard(state, shielder, "full-shield", "S", 0);
const shielderHand = () => state.players.find((p) => p.id === shielder)!.hand.map((c) => c.instanceId).sort();
const kept = shielderHand().filter((id) => id !== "full-shield#S").sort();
state = must(state, caster.id, { type: "cast", instanceId: chaos.instanceId });
// No stack: the queue opens at once, in turn order from the caster.
expect(state.stack).toBeNull();
expect(state.chaosPending?.queue.length).toBe(2);
const first = state.chaosPending!.queue[0]!;
state = first === shielder
? must(state, first, { type: "counteract", instanceId: "full-shield#S" })
: must(state, first, { type: "pass" });
const second = state.chaosPending!.queue[0]!;
state = second === shielder
? must(state, second, { type: "counteract", instanceId: "full-shield#S" })
: must(state, second, { type: "pass" });
expect(state.chaosPending).toBeNull();
// The shielded hand rode out the storm untouched.
expect(shielderHand()).toEqual(kept);
});
it("chaos: bystanders may shield out, reflections are refused", () => {
let { state } = threeGame();
state = toRound2(state);
@@ -500,6 +529,83 @@ describe("zero-damage utility attacks", () => {
});
});
describe("the VISIONSTONE pierces one wall", () => {
function stoneRig() {
let { state } = newGame();
state = toRound2(state);
const a = activePlayer(state);
const b = state.players.find((p) => p.id !== a.id)!;
a.position = { x: 2, y: 4 };
b.position = { x: 2, y: 5 };
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "S")] = "wall";
return { state, a, b };
}
it("its bearer casts an attack straight through the wall", () => {
const { state, a, b } = stoneRig();
const fb = giveCard(state, a.id, "fireball", "FB", 1);
const refused = applyCommand(state, a.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
});
expect(refused.ok).toBe(false);
const stone = giveCard(state, a.id, "visionstone", "VS");
a.displayed.push(stone.instanceId);
const cast = applyCommand(state, a.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: b.id },
});
expect(cast.ok).toBe(true);
});
it("its bearer plants a THORNBUSH through the wall", () => {
const { state, a } = stoneRig();
// The square past the wall must be bare ground for a creation.
const cell = { x: 2, y: 5 };
for (const p of state.players) if (cellKey(p.position) === cellKey(cell)) p.position = { x: 0, y: 0 };
const bush = giveCard(state, a.id, "thornbush", "TB");
const refused = applyCommand(state, a.id, {
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toContain("line of sight");
const stone = giveCard(state, a.id, "visionstone", "VS", 1);
a.displayed.push(stone.instanceId);
const cast = applyCommand(state, a.id, {
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell },
});
expect(cast.ok).toBe(true);
});
it("the client's sighted squares agree with the stone", () => {
const { state, a, b } = stoneRig();
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false);
const stone = giveCard(state, a.id, "visionstone", "VS");
a.displayed.push(stone.instanceId);
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(true);
});
});
describe("teleport wraps the maze's rim", () => {
it("four spaces counted through a warp mouth reach the far side", () => {
let { state } = newGame();
state = toRound2(state);
// Stand the active wizard at a wraparound mouth; the paired cell is
// ONE teleport step away, exactly as it is one walking step.
const active = activePlayer(state);
const warp = boardView(state).warps[0]!;
active.position = { ...warp.from.cell };
const tp = giveCard(state, active.id, "teleport", "TP", 0);
const r = applyCommand(state, active.id, {
type: "cast", instanceId: tp.instanceId,
target: { kind: "cell", cell: { ...warp.to.cell } },
});
expect(r.ok).toBe(true);
if (r.ok) {
const after = r.state.players.find((p) => p.id === active.id)!;
expect(cellKey(after.position)).toBe(cellKey(warp.to.cell));
}
});
});
describe("teleport as a counteraction", () => {
it("the attack has no chance of hitting you — you are simply elsewhere", () => {
let { state } = newGame();
@@ -1034,3 +1140,58 @@ describe("stone dead counts only the stones in play", () => {
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
});
});
describe("MENTAL FORCE respects the victim's three walked spaces", () => {
it("a destination beyond the walls is refused up front, card kept", () => {
let { state } = newGame();
state = toRound2(state);
const caster = activePlayer(state);
const victim = state.players.find((p) => p.id !== caster.id)!;
caster.position = { x: 0, y: 0 };
victim.position = { x: 2, y: 4 };
// Seal the victim into their square: every walked space is out of
// reach, so ANY other destination is beyond three moved spaces.
for (const side of SIDES) {
state.edgeOverrides[edgeKey(victim.position, side)] = "wall";
}
const mf = giveCard(state, caster.id, "mental-force", "MF");
const far = { x: 4, y: 9 };
const refused = applyCommand(state, caster.id, {
type: "cast", instanceId: mf.instanceId,
target: { kind: "player", playerId: victim.id }, params: { cell: far },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toContain("three moved spaces");
// The card is still in hand — nothing was spent on the refusal.
expect(state.players.find((p) => p.id === caster.id)!.hand.some((c) => c.instanceId === mf.instanceId)).toBe(true);
});
});
describe("the bent sight-trace shows AROUND THE CORNER's legs", () => {
it("a bentCorner stack yields two auditable legs through a midpoint", () => {
let { state } = newGame();
state = toRound2(state);
const a = activePlayer(state);
const d = state.players.find((p) => p.id !== a.id)!;
// Diagonal neighbors: center-to-center sight grazes the shared corner
// (blocked, strict reading), but the legs through (3,2) are opened.
a.position = { x: 2, y: 2 };
d.position = { x: 3, y: 3 };
state.edgeOverrides[edgeKey({ x: 2, y: 2 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 3, y: 2 }, "S")] = "open";
state.stack = {
attackerId: a.id, defenderId: d.id,
attackCard: { instanceId: "fireball#T", cardId: "fireball" },
numberValue: null, amplifyFactor: 1, extendFactor: 1,
powerAttackPoints: 0, params: null, kind: "spell",
counters: [], waitingOn: d.id, bentCorner: true,
};
const traced = stackSightTrace(viewFor(state, d.id));
expect(traced).not.toBeNull();
if (traced) {
// Whether sight ran straight (free-angle found a gap) or bent, the
// overlay has something to draw; a bend names its middle square.
if (traced.bend) expect(traced.bend.mid).toBeDefined();
}
});
});
+66
View File
@@ -720,4 +720,70 @@ describe("fear holds off monsters and unwilling feet alike", () => {
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("dread");
});
it("a wizard caged inside the dread may round a corner to escape", () => {
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
const a = state.players.find((p) => p.id === "a")!;
const b = state.players.find((p) => p.id === "b")!;
pushSustained(state, {
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
remainingTurns: 5, data: {},
});
// b radiates dread from (2,5). a stands in a dead-end pocket at
// (2,3): walls on three sides, so the only way out steps SOUTH —
// closer to b — before the corridor east leads clear of the dread.
b.position = { x: 2, y: 5 };
a.position = { x: 2, y: 3 };
for (const side of ["N", "E", "W"] as const) {
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, side)] = "wall";
}
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "S")] = "open";
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 3, y: 4 }, "E")] = "open";
state.edgeOverrides[edgeKey({ x: 4, y: 4 }, "N")] = "open";
while (state.players[state.turn.activeIndex]!.id !== "a") {
const r0 = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
if (!r0.ok) throw new Error(r0.error);
state = r0.state;
}
// The closer step is the only way out: permitted.
const r = applyCommand(state, "a", { type: "move", direction: "S" });
if (!r.ok) throw new Error("escape step refused: " + r.error);
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 },
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0,
} as never);
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 },
life: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0,
} as never);
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain("stone");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, type Side } from "../src/board";
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
@@ -111,7 +111,7 @@ describe("doors", () => {
throw new Error("no door on this board");
}
it("pick lock opens an adjacent door until end of turn", () => {
it("pick lock opens an adjacent door — and it relocks behind the walker", () => {
let { state } = newGame();
const me = activePlayer(state);
const door = findDoor(state);
@@ -130,9 +130,8 @@ describe("doors", () => {
state = must(state, me.id, { type: "move", direction: dir });
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
// Relocks when the turn ends.
state = must(state, me.id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
// "It will relock behind you": shut at the walker's back at once —
// the way back is barred without another pick.
expect(state.openDoorEdges.length).toBe(0);
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
});
@@ -445,7 +444,7 @@ describe("a held door is an open doorway to the eye", () => {
expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore);
});
it("an unheld unlocked door still blocks sight", () => {
it("an unlocked door is an open doorway to the wizard at its threshold", () => {
let { state, cell, side, holder, pursuer } = sightRig();
const pick = giveCard(state, holder, "pick-lock");
state = must(state, holder, {
@@ -453,9 +452,116 @@ describe("a held door is an open doorway to the eye", () => {
target: { kind: "edge", cell, side },
});
const fb = giveCard(state, holder, "fireball", "FB", 1);
const refused = applyCommand(state, holder, {
const cast = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(cast.ok).toBe(true);
});
it("a lock REMOVED lets any wizard beside the door peek through", () => {
let { state, cell, side, holder, pursuer } = sightRig();
const rm = giveCard(state, holder, "remove-lock");
state = must(state, holder, {
type: "cast", instanceId: rm.instanceId,
target: { kind: "edge", cell, side },
});
const fb = giveCard(state, holder, "fireball", "FB", 1);
const cast = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(cast.ok).toBe(true);
});
it("PICK LOCK in hand is enough to ease a locked door open a crack", () => {
let { state, cell, side, holder, pursuer } = sightRig();
giveCard(state, holder, "pick-lock");
const fb = giveCard(state, holder, "fireball", "FB", 1);
const cast = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(cast.ok).toBe(true);
});
it("the wizard down the hallway sees no further than the shut door", () => {
let { state, cell, side, holder, pursuer } = sightRig();
// Pull the pursuer one square further down the hall, so the door is
// no longer on their threshold; clear their path to the doorway.
const near = neighbor(cell, side);
const far = neighbor(near, side);
if (!boardView(state).cells[cellKey(far)]) return; // the maze ends here; geometry unavailable
state.players.find((p) => p.id === pursuer)!.position = far;
state.edgeOverrides[edgeKey(near, side)] = "open";
const rm = giveCard(state, holder, "remove-lock");
state = must(state, holder, {
type: "cast", instanceId: rm.instanceId,
target: { kind: "edge", cell, side },
});
state = must(state, holder, { type: "endTurn", draw: 0 });
const fb = giveCard(state, pursuer, "fireball", "FB", 1);
const refused = applyCommand(state, pursuer, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: holder },
});
expect(refused.ok).toBe(false);
if (!refused.ok) expect(refused.error).toContain("line of sight");
});
});
describe("the displayed MASTER KEY", () => {
function keyRig() {
let { state } = createGame({ playerIds: ["keeper", "watcher"], seed: 42, sets: ["basic"] });
state = toRound2(state);
const view = boardView(state);
for (const [key, edge] of Object.entries(view.edges)) {
if (edge !== "door") continue;
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const cell = { x, y };
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
const keeper = activePlayer(state);
keeper.position = { ...cell };
return { state, cell, side, key, keeper };
}
throw new Error("setup: seed 42 grew a maze with no doors");
}
it("cast bare, the key goes on display — once", () => {
const { state, keeper } = keyRig();
const mk = giveCard(state, keeper.id, "master-key", "MK");
const cast = applyCommand(state, keeper.id, { type: "cast", instanceId: mk.instanceId });
expect(cast.ok).toBe(true);
if (cast.ok) {
const after = cast.state.players.find((p) => p.id === keeper.id)!;
expect(after.displayed).toContain(mk.instanceId);
expect(after.hand.some((c) => c.instanceId === mk.instanceId)).toBe(true);
const again = applyCommand(cast.state, keeper.id, { type: "cast", instanceId: mk.instanceId });
expect(again.ok).toBe(false);
}
});
it("its bearer walks through locked doors, which relock at their back", () => {
const { state, cell, side, key, keeper } = keyRig();
const mk = giveCard(state, keeper.id, "master-key", "MK");
keeper.displayed.push(mk.instanceId);
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
expect(r.ok).toBe(true);
if (r.ok) {
const after = r.state.players.find((p) => p.id === keeper.id)!;
expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side)));
// "Door relocks behind you": no lingering opening for bystanders.
expect(r.state.openDoorEdges).not.toContain(key);
expect(r.events.some((e) => e.type === "doorUnlocked")).toBe(true);
// The key turns again on the way back.
const back = applyCommand(r.state, keeper.id, { type: "move", direction: opposite(side) });
expect(back.ok).toBe(true);
}
});
it("a JAMmed LOCK refuses even the master key", () => {
const { state, side, key, keeper } = keyRig();
const mk = giveCard(state, keeper.id, "master-key", "MK");
keeper.displayed.push(mk.instanceId);
state.doorStates[key] = "jammed";
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
expect(r.ok).toBe(false);
});
});
+52
View File
@@ -57,6 +57,8 @@ import {
summarize,
viewForPlayer,
type Room,
kickSeat,
abandonRoom,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { getShare, loadShares, mintShare } from "./shares";
@@ -394,6 +396,16 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string
}
}
}
/** Spoken when a clockwork's chosen command was refused by the engine —
* the table sees a stumble instead of an unexplained idle turn. */
const HESITATION_LINES = [
"RECALCULATING.",
"TACTICAL PAUSE. INTENTIONAL. PROBABLY.",
"THE MAZE REFUSES MY GENIUS.",
"ERROR LOGGED. DIGNITY INTACT.",
"I MEANT TO DO THAT.",
];
const pumping = new Set<string>();
function runBots(room: Room): void {
if (pumping.has(room.id)) return;
@@ -408,6 +420,14 @@ function runBots(room: Room): void {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
// The game's end unmasks the mystery machines in the roster.
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
// A refused brain-choice becomes a visible stumble, in character —
// an idle bot turn should read as hesitation, never as nothing.
if (step.hesitated) {
const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at }));
}
}
botRemark(room, step.seat, step.events as { type: string }[]);
setTimeout(tick, BOT_STEP_MS);
};
@@ -526,6 +546,38 @@ wss.on("connection", (socket) => {
session.token = null;
break;
}
case "kickSeat": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const problem = kickSeat(room, session.playerId, String(msg.name ?? ""));
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 === msg.name) {
other.playerId = null;
other.roomId = null;
other.token = null;
send(other.socket, { type: "kicked", roomId: room.id });
}
}
broadcastRoomState(room);
break;
}
case "abandonRoom": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const problem = abandonRoom(room, session.playerId);
if (problem) return send(socket, { type: "error", message: problem });
for (const other of sessions) {
if (other.roomId === room.id) {
other.playerId = null;
other.roomId = null;
other.token = null;
send(other.socket, { type: "roomAbandoned", roomId: room.id });
}
}
break;
}
case "addBot": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
+51 -6
View File
@@ -20,8 +20,9 @@ import {
type GameState,
type GameView,
type PlayerId,
CURRENT_RULES_REV,
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats";
export interface LoggedCommand {
@@ -56,7 +57,7 @@ const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own).
* A rules change while games are live must bump this and gate the engine;
* local hotseat games ride the engine's default and follow in lockstep. */
const RULES_REV = 1;
const RULES_REV = CURRENT_RULES_REV;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -152,6 +153,33 @@ export function joinRoom(
return { token: fresh };
}
/** The host shows an unclaimed (or unwanted) seat the door — lobby only:
* once the deal happens, every seat is a player in the game's record. */
export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | null {
if (byId !== room.hostId) return "only the host may clear a seat";
if (room.state) return "the game has started — seats are settled";
if (name === room.hostId) return "the host cannot kick themselves — abandon the room instead";
if (!room.players.includes(name)) return "no such seat";
room.players = room.players.filter((p) => p !== name);
room.tokens.delete(name);
room.bots.delete(name);
room.colorChoices.delete(name);
appendLine(room.id, { kind: "kick", name });
recordRoom(room);
return null;
}
/** The host dissolves the room: a lobby that never dealt, or a finished
* game done being remembered. The ledger is archived, never deleted. */
export function abandonRoom(room: Room, byId: PlayerId): string | null {
if (byId !== room.hostId) return "only the host may abandon the room";
if (room.state && room.state.phase === "playing") return "the game is still being played";
appendLine(room.id, { kind: "abandon", by: byId, at: new Date().toISOString() });
archiveRoomFile(room.id);
rooms.delete(room.id);
return null;
}
/** Everyone gets their chosen standee; the undecided get the first free one. */
export function resolveColors(room: Room): number[] {
const taken = new Set<number>();
@@ -290,17 +318,28 @@ function actingSeat(room: Room): PlayerId | null {
* One automaton command, if the maze is waiting on clockwork. Null when a
* human holds the floor (or the game is over, or the clockwork is wedged).
*/
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
export function driveOneAutomaton(
room: Room,
): { seat: PlayerId; events: GameEvent[]; hesitated?: { refused: Command; error: string } } | null {
const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null;
const view = viewFor(room.state!, seat);
const bot = room.bots.get(seat);
const chosen = automatonCommand(view, bot?.style, bot?.tier);
let hesitated: { refused: Command; error: string } | undefined;
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
if ("error" in r) {
// A refused choice retries with the fallback — unless the fallback IS
// what just failed — then burns down the ladder to endTurn and pass.
if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier));
// The refusal is remembered: a brain whose choice the engine rejects
// is a bug in the brain, and the table deserves to see the stumble
// rather than an unexplained idle turn (the RNRX coma lesson).
if (chosen) {
hesitated = { refused: chosen, error: r.error };
console.warn(
`automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`);
r = runCommand(room, seat, automatonFallback(view, bot?.tier));
}
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) {
@@ -308,7 +347,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
return null;
}
}
return { seat, events: r.events };
return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
}
export interface GameSummary {
@@ -600,8 +639,14 @@ export function loadPersistedRooms(): void {
chat: [],
bots: new Map(),
};
if (lines.some((l) => l.kind === "abandon")) continue;
for (const line of lines.slice(1)) {
if (line.kind === "join") {
if (line.kind === "kick") {
room.players = room.players.filter((p) => p !== line.name);
room.tokens.delete(line.name);
room.bots.delete(line.name);
room.colorChoices.delete(line.name);
} else if (line.kind === "join") {
if (line.bot) {
room.players.push(line.name);
room.bots.set(line.name, {
+24 -2
View File
@@ -3,7 +3,7 @@
// command. Because the engine is deterministic, replaying a file rebuilds
// the exact game state — server restarts lose nothing.
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs";
import { join } from "node:path";
export interface RoomMetaLine {
@@ -58,7 +58,19 @@ export interface ChatLine {
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine;
export interface KickLine {
kind: "kick";
/** The seat the host removed from an unstarted room. */
name: string;
}
export interface AbandonLine {
kind: "abandon";
by: string;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
@@ -103,3 +115,13 @@ export function readAllRooms(): Map<string, RoomLine[]> {
}
return rooms;
}
/** Retire an abandoned room's ledger to the graveyard — never deleted,
* only moved out of the living rooms directory. */
export function archiveRoomFile(roomId: string): void {
const src = join(DATA_DIR, `${roomId}.jsonl`);
if (!existsSync(src)) return;
const graveyard = join(DATA_DIR, "..", "rooms-abandoned");
mkdirSync(graveyard, { recursive: true });
renameSync(src, join(graveyard, `${roomId}.${Date.now()}.jsonl`));
}
+44 -2
View File
@@ -54,6 +54,8 @@
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);
let fxCancels: (() => void)[] = [];
/** A trap drawn by YOU earns a modal; buried log lines get missed. */
let trapNotice = $state<string | null>(null);
@@ -342,7 +344,7 @@
"wall-of-fire": "click a corridor line for the fire",
"waterwall": "click a corridor line — the wave collapses at once",
"pick-lock": "click a locked door",
"master-key": "click a locked door",
"master-key": "click a locked door — or Display it once and walk through every lock",
"jam-lock": "click a door to jam its lock solid",
"remove-lock": "click a door to strip its lock for good",
"create-door": "click a wall for the new door",
@@ -720,6 +722,15 @@
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
return;
}
// Standing on a DIMENSIONAL WARP token: clicking the paired token
// steps it through.
const dw = view.dimWarps.find((d) =>
(cellKey(d.a) === cellKey(creature.position) && cellKey(d.b) === cellKey(cell)) ||
(cellKey(d.b) === cellKey(creature.position) && cellKey(d.a) === cellKey(cell)));
if (dw) {
dispatch({ type: "creatureWarpStep", creatureId: selectedCreature });
return;
}
}
selectedCreature = null;
return;
@@ -894,6 +905,16 @@
}
}
/** A click in the first-person pane, resolved by the renderer to the
* board's own vocabulary — then handled by the very same functions the
* top-down board uses, so the pane and the map can never disagree. */
function fpvTarget(t: import("./fpv/raycast").FpvTarget) {
if (t.kind === "player") clickPlayer(t.id);
else if (t.kind === "creature") clickCreature(t.id);
else if (t.kind === "edge") clickEdge(t.cell, t.side);
else clickCell(t.cell);
}
function clickPlayer(playerId: string) {
if (!view || !yourMoment) return;
if (selectedCreature) {
@@ -1256,6 +1277,7 @@
<header class="masthead">
<span class="mast-title">Wiz-War</span>
<span class="mast-sub">sixth edition</span>
<span class="mast-version" title="deployed">{__BUILD_STAMP__}</span>
<button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}> preferences</button>
{#if local.active}
<span class="mast-room">hotseat</span>
@@ -1811,6 +1833,10 @@
<span class="dot" style:background="#b3a687"></span>
{/if}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots[p] ? ` ⚙ ${net.roomBots[p]}` : chosen === undefined ? " — choosing…" : ""}
{#if net.you === net.hostId && p !== net.hostId}
<button class="stamp tiny kick" title="clear this seat"
onclick={() => net.kickSeat(p)}>✕</button>
{/if}
</li>
{/each}
</ul>
@@ -1834,6 +1860,8 @@
{/each}
</div>
{#if net.you === net.hostId}
<button class="stamp tiny abandon" title="dissolve this room for everyone"
onclick={() => net.abandonRoom()}>abandon room</button>
{#if net.players.length < 6}
<span class="bot-row">
<span class="bot-label">⚙ seat a</span>
@@ -1872,7 +1900,8 @@
<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} />
onstride={(side) => tryMove(side)} canStride={yourMoment}
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} />
{/if}
<Board
{view}
@@ -1894,6 +1923,7 @@
onEdgePeek={(tip) => (peekEdge = tip)}
{litCells}
{sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
/>
</div>
</section>
@@ -2219,6 +2249,10 @@
{#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"}
<label class="inline"><input type="checkbox" bind:checked={holdDoor} /> hold the door open</label>
{/if}
{#if selectedCard?.cardId === "master-key" && isYourTurn &&
!me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)}
<button class="stamp tiny" onclick={castSelfWithNumber}>Display the key</button>
{/if}
{#if selectedCard?.cardId === "rotate-sector"}
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
<span> click the sector</span>
@@ -2463,6 +2497,14 @@
text-transform: uppercase;
color: #8d8672;
}
.kick { margin-left: 0.4rem; color: #a04545; }
.abandon { margin: 0.4rem 0; color: #a04545; border-color: #a04545; }
.mast-version {
font-family: "Courier Prime", monospace;
font-size: 0.65rem;
letter-spacing: 0.06em;
color: #6b6454;
}
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
.mast-audience { font-size: 0.85rem; color: #a49c86; white-space: nowrap; }
+45 -4
View File
@@ -23,6 +23,7 @@
onCellClick,
onEdgeClick,
onPlayerClick,
povFacing = null,
onCreatureClick,
onWarpClick,
onCellPeek,
@@ -43,6 +44,9 @@
onCellClick?: (cell: { x: number; y: number }) => void;
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
onPlayerClick?: (playerId: string) => void;
/** Which way YOUR first-person camera faces: a wedge on your token
* keeps the keymap and the pane pointing the same way. */
povFacing?: "N" | "E" | "S" | "W" | null;
onCreatureClick?: (creatureId: string) => void;
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
/** Long-press on a square: read the card behind whatever occupies it. */
@@ -68,7 +72,7 @@
effects?: BoardFx[] | null;
/** The sight line an attack in progress traveled — proof against "how
* can he even see me?", drawn leg by leg through any warp mouth. */
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace } | null;
sightTrace?: { from: { x: number; y: number }; to: { x: number; y: number }; trace: SightTrace; bend?: { mid: { x: number; y: number }; trace: SightTrace } } | null;
} = $props();
@@ -390,14 +394,20 @@
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
width={CELL * 0.4} height={CELL * 0.4}
preserveAspectRatio="xMidYMid slice"
class="token-art small"
class="token-art small peekable"
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
onkeydown={() => {}}
>
<title>{o.cardId}</title>
</image>
{:else}
<rect
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
width={12} height={10} rx="2" class="ground-object"
width={12} height={10} rx="2" class="ground-object peekable"
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }}
onkeydown={() => {}}
>
<title>{o.cardId}</title>
</rect>
@@ -533,6 +543,14 @@
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)}
{@const cy = p.position.y * CELL + CELL * 0.36}
<g class="mover" class:snap={snapsTo(`w:${p.id}`, cx, cy)} style={`transform: translate(${cx}px, ${cy}px)`}>
{#if povFacing && p.id === view.you}
{@const fr = CELL * 0.34}
{@const fa = povFacing === "E" ? 0 : povFacing === "S" ? Math.PI / 2 : povFacing === "W" ? Math.PI : -Math.PI / 2}
<polygon
points={`${Math.cos(fa) * fr},${Math.sin(fa) * fr} ${Math.cos(fa + 2.6) * fr * 0.55},${Math.sin(fa + 2.6) * fr * 0.55} ${Math.cos(fa - 2.6) * fr * 0.55},${Math.sin(fa - 2.6) * fr * 0.55}`}
class="pov-wedge"
/>
{/if}
<g
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
@@ -661,7 +679,14 @@
<!-- the sight line an attack traveled, leg by leg through any warp mouth -->
{#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}
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
<g class="fx-layer" aria-hidden="true">
@@ -752,6 +777,22 @@
fill: #6a5c44;
pointer-events: none;
}
.pov-wedge {
fill: #c9a72a;
opacity: 0.85;
pointer-events: none;
}
.peekable { cursor: pointer; pointer-events: all; }
.sight-corner {
fill: none;
stroke: #c9a72a;
stroke-width: 2;
animation: sight-pulse 1.6s ease-in-out infinite;
}
@keyframes sight-pulse {
0%, 100% { opacity: 0.9; }
50% { opacity: 0.35; }
}
.dim-cell {
fill: rgba(12, 9, 5, 0.55);
pointer-events: none;
+18 -2
View File
@@ -8,13 +8,14 @@
// instant replay uses, so spells, doors, and conjurations perform
// here first.
import FirstPerson from "./fpv/FirstPerson.svelte";
import type { FpvTarget } from "./fpv/raycast";
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
import { castRay } from "./fpv/raycast";
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 }: {
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null }: {
view: GameView;
/** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */
@@ -23,6 +24,12 @@
/** 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;
} = $props();
const povId = $derived(view.you);
@@ -225,6 +232,14 @@
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
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} />
@@ -233,7 +248,8 @@
<div class="live-fp">
<FirstPerson {view} povId={cutawayShot ? "" : povId}
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos} />
fx={fpFx} posOverride={actorPos}
ontarget={ontarget ?? undefined} />
<button class="live-fp-hide" onclick={onhide} title="hide (re-enable in preferences)"></button>
<!-- The helm, tappable: edge strips turn and stride. -->
<button class="drive drive-left" onclick={() => manualTurn(-1)} aria-label="turn left"></button>
+110 -3
View File
@@ -3,7 +3,9 @@
// GameView. Columns of wall shaded by distance and facing; token art
// billboarded for whatever stands in the corridors, occluded per column
// by the same depth buffer the walls wrote.
import { castRay, billboards } from "./raycast";
import {
warpMotion,
type FpvTarget, castRay, billboards } from "./raycast";
import { materialTextures } from "./textures";
import { doorOpenness, fxFallback, growProgress, type FpFx } from "./fx3d";
import { terrainFallback, TERRAIN3D } from "./terrain3d";
@@ -22,6 +24,7 @@
fx = [],
posOverride,
rubble = [],
ontarget,
}: {
view: GameView;
povId: string;
@@ -38,11 +41,23 @@
posOverride?: Record<string, { x: number; y: number }>;
/** Where walls have died: a mound of stone marks each fallen edge. */
rubble?: { x: number; y: number }[];
/** Present = the pane is an instrument: clicks resolve to targets. */
ontarget?: (t: FpvTarget) => void;
} = $props();
const FOV = Math.PI / 2.9;
let canvas: HTMLCanvasElement;
/** Everything the LAST drawn frame knew, kept for click resolution:
* the pane is an instrument only because the renderer remembers what
* stood under every pixel. */
let hitFrame: {
W: number; H: number; ex: number; ey: number; facing: number;
zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array;
cols: ({ edge?: string; kind: string; top: number; h: number } | null)[];
sprites: Projected[];
} | null = null;
// Token art loads lazily; a sprite draws once its image has arrived.
const images = new Map<string, HTMLImageElement>();
function imageFor(src: string): HTMLImageElement | null {
@@ -237,6 +252,7 @@
// side must MATCH its column's, or bodies near a far mouth would
// ghost into real corridors the unrolled space happens to overlap.
const zbuf = new Float64Array(W);
const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null);
// Per column: which warp (if any) the ray bent through, and how far
// away that mouth stood. A REAL body paints a column only if it is
// nearer than the mouth (it stands in front of the window); a
@@ -281,6 +297,7 @@
}
const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05));
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!;
// 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
@@ -363,6 +380,7 @@
if (s) sprites.push({ ...s, alpha: f.kind === "impact" ? 1 - p : 1, fallback: f.art });
}
sprites.sort((a, b) => b.sort - a.sort);
hitFrame = { W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, sprites };
// The nearest sprite each column carries — depth AND vertical span —
// so the overlay passes (veils, ghosts, lintels, risings) can paint
// around a body standing in front of them instead of over it.
@@ -535,6 +553,8 @@
fallback?: string;
clampL?: number;
clampR?: number;
hit?: { kind: "player" | "creature"; id: string };
cell?: { x: number; y: number };
/** Sort key only: the near-bias orders sprites among THEMSELVES (a
* hedge over the wizard standing in it) but must never let one
* cheat past a wall — occlusion always uses the true depth. */
@@ -544,7 +564,9 @@
b: { x: number; y: number; src: string; scale: number; rise: number;
aspect?: number; alpha?: number; glow?: boolean; bias?: number;
fallback?: string; warped?: boolean; warpId?: number;
clip?: { x: number; y: number } },
clip?: { x: number; y: number };
hit?: { kind: "player" | "creature"; id: string };
cell?: { x: number; y: number } },
ex: number, ey: number,
): Projected | null {
const relX = b.x - ex, relY = b.y - ey;
@@ -597,11 +619,94 @@
return {
src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId,
alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR,
hit: b.hit, cell: b.cell,
left: screenX - wide / 2, right: screenX + wide / 2,
top: bottom - size, bottom,
};
}
/** Resolve a canvas-pixel click to what stood under it: the nearest
* visible sprite, else the struck wall or door face, else the floor
* (or vault) square the pixel lies on — warp-bent columns mapping
* their virtual ground back to real cells through the warp's own
* rigid motion. */
export function hitTest(px: number, py: number): FpvTarget | 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.
const byDepth = [...f.sprites].sort((a, b) => a.depth - b.depth);
for (const sp of byDepth) {
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 (sp.clampL !== undefined && (col < sp.clampL || col > sp.clampR!)) continue;
if (sp.depth >= f.zbuf[col]!) continue;
if (sp.warped) {
if (f.warpIdCol[col] !== sp.warpId || sp.depth <= f.warpDistCol[col]!) continue;
} else if (sp.depth >= f.warpDistCol[col]!) continue;
if (sp.hit) return sp.hit;
return { kind: "cell", cell: { x: sp.cell!.x, y: sp.cell!.y } };
}
// 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 { kind: "edge", cell: { x, y }, side: kind === "V" ? "E" : "S" };
}
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 ? { kind: "cell", 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
const pt = groundPoint(f, col, d);
return pt ? { kind: "cell", cell: pt } : null;
}
/** The real-world square at perpendicular depth d down column col —
* mapping through the column's warp when the ray bent. */
function groundPoint(
f: NonNullable<typeof hitFrame>, col: number, d: number,
): { x: number; y: number } | null {
const flen = (f.W / 2) / Math.tan(FOV / 2);
const side = (col - f.W / 2) * (d / flen);
const cosF = Math.cos(f.facing), sinF = Math.sin(f.facing);
let wx = f.ex + d * cosF - side * sinF;
let wy = f.ey + d * sinF + side * cosF;
if (f.warpIdCol[col]! >= 0 && d > f.warpDistCol[col]!) {
const w = view.board.warps[f.warpIdCol[col]!];
if (!w) return null;
const real = warpMotion(w).toReal({ x: wx, y: wy });
wx = real.x; wy = real.y;
}
const cell = { x: Math.floor(wx), y: Math.floor(wy) };
return view.board.cells[`${cell.x},${cell.y}`] ? cell : null;
}
function onCanvasClick(e: MouseEvent) {
if (!ontarget || !canvas) return;
const rect = canvas.getBoundingClientRect();
const px = (e.clientX - rect.left) * (width / rect.width);
const py = (e.clientY - rect.top) * (height / rect.height);
const t = hitTest(px, py);
if (t) ontarget(t);
}
// Redraw every frame: the fire flickers and the warps swirl even when
// the camera holds still.
$effect(() => {
@@ -612,9 +717,11 @@
});
</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}></canvas>
<style>
.targeting { cursor: crosshair; }
.fpv-canvas {
display: block;
width: 100%;
+23 -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 };
}
/** What a click in the first-person pane resolved to the pane speaks
* the board's own click vocabulary. */
export type FpvTarget =
| { kind: "player"; id: string }
| { kind: "creature"; id: string }
| { kind: "edge"; cell: Cell; side: Side }
| { kind: "cell"; cell: Cell };
export interface Billboard {
/** World position (cell-centered). */
x: number;
@@ -324,6 +332,13 @@ export interface Billboard {
* visible only through THAT warp's columns, beyond its mouth. */
warped?: boolean;
warpId?: number;
/** Click identity: the specific body this sprite is (a wizard, a
* creature) the pane's hit test hands it straight to the board's
* own click handlers. */
hit?: { kind: "player" | "creature"; id: string };
/** Click fallback: the REAL square this sprite stands in (a virtual
* warp copy keeps its source's square, not its drawn position). */
cell?: Cell;
}
/** The along=0 corner of a warp mouth the anchor castRay's lane
@@ -384,6 +399,7 @@ export function billboards(
out.push({
x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5,
src: art(`wizard-${p.colorIndex}`, "players"), scale: 0.85, rise: 0, label: p.id,
hit: { kind: "player", id: p.id }, cell: { ...p.position },
});
}
for (const c of view.creatures) {
@@ -391,6 +407,7 @@ export function billboards(
out.push({
x: o?.x ?? c.position.x + 0.5, y: o?.y ?? c.position.y + 0.5,
src: art(c.kind, "creatures"), scale: 0.75, rise: 0, label: c.kind, key: c.id,
hit: { kind: "creature", id: c.id }, cell: { ...c.position },
});
}
for (const t of view.treasures) {
@@ -405,13 +422,14 @@ export function billboards(
out.push({
x: o?.x ?? carrier.position.x + 0.5, y: o?.y ?? carrier.position.y + 0.5,
src, scale: 0.26, rise: 0.2, bias: 0.12, label: "treasure",
hit: { kind: "player", id: carrier.id }, cell: { ...carrier.position },
});
continue;
}
if (!t.position) continue;
out.push({
x: t.position.x + 0.5, y: t.position.y + 0.5,
src, 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
@@ -426,25 +444,25 @@ export function billboards(
out.push({
...at, src: `/terrain3d/${content.kind}.png`, fallback: content.kind,
scale: 0.5, aspect: 2.1, rise: 0, bias: 0.18, label: content.kind, key: k,
clip: { x: tx, y: ty },
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
});
} else if (content.kind === "ooze") {
out.push({
...at, src: "/terrain3d/jello.png", fallback: "jello",
scale: 0.96, aspect: 1.04, rise: 0, alpha: 0.6, bias: 0.18, label: "ooze", key: k,
clip: { x: tx, y: ty },
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
});
} else if (content.kind === "dust") {
out.push({
...at, src: "/terrain3d/dust.png", fallback: "dust",
scale: 0.85, aspect: 1.1, rise: 0, alpha: 0.55, bias: 0.18, label: "dust", key: k,
clip: { x: tx, y: ty },
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
});
} else if (content.kind === "safe") {
out.push({
...at, src: "/terrain3d/safe.png", fallback: "safe",
scale: 0.55, aspect: 1, rise: 0, bias: 0.18, label: "safe", key: k,
clip: { x: tx, y: ty },
clip: { x: tx, y: ty }, cell: { x: tx, y: ty },
});
}
}
+43 -19
View File
@@ -103,6 +103,16 @@ export function humanize(e: GameEvent): string | null {
case "doorHeld": return `${e.player} holds the door open.`;
case "doorReleased": return `The held door swings shut.`;
case "doorsRelocked": return `The door swings shut and relocks.`;
case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`;
case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`;
case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`;
case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`;
case "cardsStolenPrivate": return e.cards.length > 0
? `Stolen into your hand: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`
: null;
case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`;
case "creatureWarpStepped": return `The creature slips through the dimensional warp!`;
case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`;
case "doorJammed": return `${e.player} jams a door's lock solid.`;
case "lockRemoved": return `${e.player} removes a door's lock for good.`;
case "cardDisplayed": return `${e.player} displays ${cardDef(e.card.cardId).name}.`;
@@ -215,18 +225,6 @@ export interface LogLine {
* momentSteps, so a chronicle turn number addresses the same commands. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
/** Does this event make a turn worth reliving? Combat and spectacle. */
export function isNotableEvent(e: GameEvent): boolean {
if (e.type === "spellCast") return e.target !== null || e.targetCell !== null;
return NOTABLE_EVENTS.has(e.type);
}
const NOTABLE_EVENTS = new Set([
"punched", "attackResolved", "damaged", "died", "knockedBack", "shoved",
"washedBack", "retreatedInHorror", "creatureAttacked", "boobytrapSprung",
"firewallBurned", "objectThrown", "gameWon", "positionsSwapped",
"teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater",
"waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped",
]);
const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats";
@@ -321,7 +319,6 @@ class Net {
* counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1;
/** The turn that already carries an instant-replay eye (one per turn). */
private eyeTurn = -1;
/** The turn whose moment reel is open (share links point at it). */
private momentTurn: number | null = null;
private shareResolve: ((url: string) => void) | null = null;
@@ -444,12 +441,11 @@ class Net {
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
const line = humanize(e);
if (line) {
// One eye per turn — except the crown: the game-winning
// line always carries its own, whatever came before it.
// Every turn wears its eye, on the header line that opens
// it — the players judge what is share-worthy. The game-
// winning line carries its own besides.
const notable = this.turnCounter >= 0 &&
(e.type === "gameWon" ||
(this.turnCounter !== this.eyeTurn && isNotableEvent(e)));
if (notable) this.eyeTurn = this.turnCounter;
(e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
}
}
@@ -459,6 +455,14 @@ class Net {
}
break;
}
case "kicked":
this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId}`, turn: null, notable: false }];
this.leaveLocal();
break;
case "roomAbandoned":
this.log = [...this.log, { text: `— the host closed room ${msg.roomId}`, turn: null, notable: false }];
this.leaveLocal();
break;
case "transferCode":
this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
break;
@@ -577,6 +581,14 @@ class Net {
this.send({ type: "addBot", ...(style ? { style } : {}), ...(tier ? { tier } : {}) });
}
kickSeat(name: string): void {
this.send({ type: "kickSeat", name });
}
abandonRoom(): void {
this.send({ type: "abandonRoom" });
}
rollTableDie(): void {
this.send({ type: "rollDie" });
}
@@ -648,6 +660,19 @@ class Net {
}
/** Forget the remembered seat and return to the lobby. */
/** Client-side teardown when the SERVER detached us (kick, abandon). */
leaveLocal(): void {
localStorage.removeItem(SEAT_KEY);
this.roomId = null;
this.roomIdPending = null;
this.view = null;
this.started = false;
this.players = [];
this.token = null;
this.spectating = false;
this.audience = 0;
}
leave(): void {
this.send({ type: "leave" }); // detach server-side too (frees a gallery seat)
localStorage.removeItem(SEAT_KEY);
@@ -698,7 +723,6 @@ class Net {
private resetChronicle(): void {
this.log = [];
this.turnCounter = -1;
this.eyeTurn = -1;
}
/** Summon one turn's reel by its chronicle turn number. */
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
/// <reference types="svelte" />
/** Build moment, yyyy.mm.dd.hh.mm — injected by vite.config.ts. */
declare const __BUILD_STAMP__: string;
+14
View File
@@ -1,6 +1,20 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// Deploys build locally moments before rsync, so build time IS deploy time.
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const stamp = [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
pad(now.getHours()),
pad(now.getMinutes()),
].join(".");
export default defineConfig({
plugins: [svelte()],
define: {
__BUILD_STAMP__: JSON.stringify(stamp),
},
});
+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 = y === 0 ? (B.edges[`H:${x},${y - 1}`] ?? "open") : (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}`));