From 154e00141c05d00507d48ee9f2ab880c3b668551 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 14:28:12 -0400 Subject: [PATCH 01/20] Three rulings from the night games (rules rev 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 154 +++++++++++++----- packages/engine/src/view.ts | 25 ++- packages/engine/test/casting.test.ts | 22 +++ packages/engine/test/creatures.test.ts | 31 ++++ .../engine/test/durations-doors-cards.test.ts | 51 +++++- packages/server/src/rooms.ts | 3 +- 6 files changed, 241 insertions(+), 45 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index e450195..2a8816a 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -225,6 +225,9 @@ export interface CastParams { hold?: boolean; } +/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ +export const CURRENT_RULES_REV = 2; + export interface GameConfig { playerIds: PlayerId[]; seed: number; @@ -235,17 +238,10 @@ 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. */ deckRev?: number; } @@ -348,19 +344,42 @@ export function losBlockers(state: GameState): Record { 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 | 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)); +export function gameLos(state: GameState, from: Cell, to: Cell, viewerId?: PlayerId): boolean { + return sightBetween(doorsAjar(state, viewerId, openedDoors(state, boardView(state))), from, to, losBlockers(state)); } /** Parse an edge key back into its north/west cell and side. */ @@ -409,7 +428,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; @@ -1306,7 +1325,7 @@ const CARD_EFFECTS: Record 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 +1493,14 @@ const CARD_EFFECTS: Record 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 +1517,7 @@ const CARD_EFFECTS: Record 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 +1526,7 @@ const CARD_EFFECTS: Record } 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 +1603,7 @@ const CARD_EFFECTS: Record 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 +1699,7 @@ const CARD_EFFECTS: Record 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 +1901,7 @@ const CARD_EFFECTS: Record (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 +1925,7 @@ const CARD_EFFECTS: Record (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 +1943,7 @@ const CARD_EFFECTS: Record 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 +2006,7 @@ const CARD_EFFECTS: Record 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." @@ -2536,7 +2555,7 @@ const CARD_EFFECTS: Record 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 +2746,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; } @@ -3370,8 +3389,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 +3412,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[] = []; @@ -3747,11 +3776,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([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++; @@ -4316,7 +4394,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"); @@ -5089,8 +5167,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 = gameLos(state, owner.position, context.movedFrom, owner.id); + const now = gameLos(state, owner.position, actor.position, owner.id); sprung = now && !before; } else if (ambush.trigger.kind === "near") { const dist = (c: Cell) => @@ -5102,7 +5180,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 && !gameLos(state, owner.position, actor.position, owner.id)) continue; state.ambushes = state.ambushes.filter((a) => a.id !== ambush.id); state.discard.push(ambush.via, ambush.spell, ...ambush.numbers); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 5203394..99860b4 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -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; + /** The rules revision this game was dealt under. */ + deckRev: number; doorStates: Record; /** Accumulated attack damage per edge (public — cracks show). */ wallDamage: Record; @@ -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], @@ -202,11 +205,25 @@ export function sightedCellsFor(view: GameView): Set { /** The board-as-seen and sight blockers this view's sight rules run against. */ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record } { - // 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 = {}; diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index eed75a3..35937a5 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -500,6 +500,28 @@ describe("zero-damage utility attacks", () => { }); }); +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(); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index 424e6c8..dd0656e 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -720,4 +720,35 @@ 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); + }); }); diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index 5dc421a..ad91402 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -445,7 +445,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 +453,56 @@ 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"); }); }); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 76974a5..8279f29 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -20,6 +20,7 @@ import { type GameState, type GameView, type PlayerId, + CURRENT_RULES_REV, } from "@wizwar/engine"; import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; import { recordRoom } from "./stats"; @@ -56,7 +57,7 @@ const rooms = new Map(); /** 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"; From b16bbe82a646c540a3d82adaabe9987049f42daf Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 14:34:06 -0400 Subject: [PATCH 02/20] The masthead wears its deploy stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/web/src/App.svelte | 7 +++++++ packages/web/src/vite-env.d.ts | 5 +++++ packages/web/vite.config.ts | 14 ++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 packages/web/src/vite-env.d.ts diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 75b78a7..22ab213 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1252,6 +1252,7 @@
Wiz-War sixth edition + {__BUILD_STAMP__} {#if local.active} hotseat @@ -2448,6 +2449,12 @@ text-transform: uppercase; color: #8d8672; } + .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; } diff --git a/packages/web/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts new file mode 100644 index 0000000..a737a71 --- /dev/null +++ b/packages/web/src/vite-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +/** Build moment, yyyy.mm.dd.hh.mm — injected by vite.config.ts. */ +declare const __BUILD_STAMP__: string; diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 8a6f4b5..40c6bde 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -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), + }, }); From 7adf4b6cd665f2408d43805f94d201ebfc268869 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 20:14:02 -0400 Subject: [PATCH 03/20] The client's eye learns the VISIONSTONE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/view.ts | 20 +++++++++++++++ packages/engine/test/casting.test.ts | 38 +++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 99860b4..0e72153 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -200,6 +200,26 @@ export function sightedCellsFor(view: GameView): Set { 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; } diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 35937a5..7d61eb1 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -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, 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). */ @@ -500,6 +500,42 @@ 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("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(); From 71a3c81670194fc49aeb1d3fece1528ce6933f19 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 20:21:32 -0400 Subject: [PATCH 04/20] The stone pierces walls for creations too (rules rev 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 42 ++++++++++++++++++++++++---- packages/engine/test/casting.test.ts | 19 +++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 2a8816a..fe60b8c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -226,7 +226,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 2; +export const CURRENT_RULES_REV = 3; export interface GameConfig { playerIds: PlayerId[]; @@ -242,6 +242,9 @@ export interface GameConfig { * 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. */ deckRev?: number; } @@ -378,8 +381,37 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse } /** LOS including square-filling blockers. */ +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 sightBetween(doorsAjar(state, viewerId, openedDoors(state, boardView(state))), from, to, losBlockers(state)); + 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. */ @@ -5167,8 +5199,8 @@ function checkAmbushes( sprung = context.pickedUpTreasure === true; } else if (context.movedFrom) { if (ambush.trigger.kind === "los") { - const before = gameLos(state, owner.position, context.movedFrom, owner.id); - const now = gameLos(state, owner.position, actor.position, owner.id); + 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) => @@ -5180,7 +5212,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, owner.id)) 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); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 7d61eb1..6edee4b 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -527,6 +527,25 @@ describe("the VISIONSTONE pierces one wall", () => { 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); From 168e57ab91386342594594febbe85fd279d2c244 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 20:35:12 -0400 Subject: [PATCH 05/20] The thief-chase serves the blow, not the nearness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/automaton.ts | 20 ++++++++----- packages/engine/test/automaton.test.ts | 40 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 5dc5de8..72264d4 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1374,19 +1374,25 @@ 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; const objectives = cursedIdiot && ownGoldCells.size > 0 ? ownGoldCells - : thiefAfar - ? new Set([cellKey(thiefAfar.position)]) + : chasing + ? new Set([cellKey(thief.position)]) : style === "berserker" && !self.carriedTreasureId ? (enemyCells.size > 0 ? enemyCells : gold) : gold; const path = - pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !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, diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 938381b..2448449 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -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,42 @@ 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]); + } + }); +}); From 805fb0978268c395a6fafa8440ed9bae6cb1f271 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 20:35:12 -0400 Subject: [PATCH 06/20] Chaos aims at no one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 22 +++++++++++++++++++++ packages/engine/test/casting.test.ts | 29 ++++++++++++++++++++++++++++ packages/web/src/net.svelte.ts | 1 + 3 files changed, 52 insertions(+) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index fe60b8c..b302ee7 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -4933,6 +4933,28 @@ function doCast(prev: GameState, cmd: Extract): 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); diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 6edee4b..ecc9f38 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -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); diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index a214ccf..04f4d07 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -226,6 +226,7 @@ const NOTABLE_EVENTS = new Set([ "firewallBurned", "objectThrown", "gameWon", "positionsSwapped", "teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater", "waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped", + "handsScrambled", ]); const SEAT_KEY = "wizwar-seat"; From 64e4e243dc6d61004de9d2925a5c462e663b77b8 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 20:46:23 -0400 Subject: [PATCH 07/20] The MASTER KEY earns its display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 21 ++++++- .../engine/test/durations-doors-cards.test.ts | 57 +++++++++++++++++++ packages/web/src/App.svelte | 6 +- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index b302ee7..67baafb 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -1233,8 +1233,14 @@ const CARD_EFFECTS: Record 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", @@ -3941,7 +3947,15 @@ 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 + // turn's end like any picked lock. A JAMmed LOCK still refuses it. + if (!doorIsOpen(state, key)) { + state.openDoorEdges.push(key); + events.push({ type: "doorUnlocked", player: p.id, edge: parseEdgeKey(key), withCardId: "master-key" }); + } p.position = dest; via = "step"; } else if (edge === "firewall") { @@ -4737,6 +4751,9 @@ function doCast(prev: GameState, cmd: Extract): 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. diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index ad91402..641f032 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -506,3 +506,60 @@ describe("a held door is an open doorway to the eye", () => { 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 behind them", () => { + 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))); + // Unlocked for the turn — the relock rides the usual end-of-turn sweep. + expect(r.state.openDoorEdges).toContain(key); + expect(r.events.some((e) => e.type === "doorUnlocked")).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); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 22ab213..5e7eb15 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -338,7 +338,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", @@ -2205,6 +2205,10 @@ {#if selectedCard?.cardId === "pick-lock" || selectedCard?.cardId === "master-key"} {/if} + {#if selectedCard?.cardId === "master-key" && isYourTurn && + !me?.displayed.some((c) => c.instanceId === selectedCard!.instanceId)} + + {/if} {#if selectedCard?.cardId === "rotate-sector"} — click the sector From beb4947ad81f43d61119e5f8f2fec10a116ae890 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 20:53:01 -0400 Subject: [PATCH 08/20] 'It will relock behind you' means at your back (rules rev 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 21 +++++++++++++++---- .../engine/test/durations-doors-cards.test.ts | 18 +++++++++------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 67baafb..231a63c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -226,7 +226,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 3; +export const CURRENT_RULES_REV = 4; export interface GameConfig { playerIds: PlayerId[]; @@ -245,6 +245,9 @@ export interface GameConfig { * 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. */ deckRev?: number; } @@ -3950,14 +3953,24 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { } 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 - // turn's end like any picked lock. A JAMmed LOCK still refuses it. + // 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)) { - state.openDoorEdges.push(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. diff --git a/packages/engine/test/durations-doors-cards.test.ts b/packages/engine/test/durations-doors-cards.test.ts index 641f032..0157608 100644 --- a/packages/engine/test/durations-doors-cards.test.ts +++ b/packages/engine/test/durations-doors-cards.test.ts @@ -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); }); @@ -539,7 +538,7 @@ describe("the displayed MASTER KEY", () => { } }); - it("its bearer walks through locked doors, which relock behind them", () => { + 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); @@ -548,9 +547,12 @@ describe("the displayed MASTER KEY", () => { if (r.ok) { const after = r.state.players.find((p) => p.id === keeper.id)!; expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side))); - // Unlocked for the turn — the relock rides the usual end-of-turn sweep. - expect(r.state.openDoorEdges).toContain(key); + // "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); } }); From 324853ce51f31095eeb7916e1e34fe00a11377c1 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 21:14:37 -0400 Subject: [PATCH 09/20] Every turn wears the eye MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/web/src/net.svelte.ts | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 04f4d07..a8d3861 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -215,19 +215,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", - "handsScrambled", -]); const SEAT_KEY = "wizwar-seat"; const SEATS_KEY = "wizwar-seats"; @@ -322,7 +309,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; @@ -445,12 +431,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 }]; } } @@ -699,7 +684,6 @@ class Net { private resetChronicle(): void { this.log = []; this.turnCounter = -1; - this.eyeTurn = -1; } /** Summon one turn's reel by its chronicle turn number. */ From df2913f3660d4ca77773007a3be4519b9546d057 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 22:30:03 -0400 Subject: [PATCH 10/20] The archmage learns the lessons humans taught it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/automaton.ts | 127 +++++++++++++++++-------- packages/engine/test/automaton.test.ts | 81 ++++++++++++++++ 2 files changed, 170 insertions(+), 38 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 72264d4..d626baa 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -645,6 +645,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( @@ -749,19 +758,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 +791,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 +804,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, @@ -819,7 +835,21 @@ function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd 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 +861,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 +890,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 +902,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 +991,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( @@ -1200,13 +1242,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 +1261,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; }); @@ -1475,12 +1521,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 diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 2448449..3554d10 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -775,3 +775,84 @@ describe("the thief-chase holds one goal per turn", () => { } }); }); + +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"); + }); +}); From dd87f1d843ad6f539cf13bdf414e8e4f18066b01 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 25 Aug 2026 23:59:31 -0400 Subject: [PATCH 11/20] Monsters ride the wormhole; mental force fails loudly (rules rev 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 80 ++++++++++++++++++++++++-- packages/engine/test/casting.test.ts | 26 +++++++++ packages/engine/test/creatures.test.ts | 35 +++++++++++ packages/web/src/App.svelte | 9 +++ packages/web/src/net.svelte.ts | 2 + 5 files changed, 147 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 231a63c..6ed9ab5 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -226,7 +226,7 @@ export interface CastParams { } /** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ -export const CURRENT_RULES_REV = 4; +export const CURRENT_RULES_REV = 5; export interface GameConfig { playerIds: PlayerId[]; @@ -248,6 +248,8 @@ export interface GameConfig { * 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; } @@ -579,6 +581,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. */ @@ -695,6 +699,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"; @@ -747,7 +752,7 @@ type AttackEffect = { sustains?: boolean; /** Card stays in hand and is displayed rather than discarded (WIZARDBLADE). */ keepInHand?: boolean; - validate?: (state: GameState, cmd: Extract) => string | null; + validate?: (state: GameState, cmd: Extract, caster?: PlayerState, target?: PlayerState) => string | null; onResolved?: (ctx: ResolutionContext) => void; }; @@ -2163,11 +2168,19 @@ const CARD_EFFECTS: Record "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) => { @@ -2175,7 +2188,12 @@ const CARD_EFFECTS: Record 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" }); @@ -2920,6 +2938,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); @@ -3739,6 +3808,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); @@ -5006,7 +5076,7 @@ function doCast(prev: GameState, cmd: Extract): 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") { diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index ecc9f38..5f728bc 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -1140,3 +1140,29 @@ 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); + }); +}); diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index dd0656e..2b17ece 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -752,3 +752,38 @@ describe("fear holds off monsters and unwilling feet alike", () => { 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"); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 5e7eb15..126a735 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -716,6 +716,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; diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index a8d3861..2f07bf7 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -103,6 +103,8 @@ 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 "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}.`; From 858fdc6a56392efb925aec7c9ef90668a218488e Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 26 Aug 2026 00:00:57 -0400 Subject: [PATCH 12/20] The wizwar-duel skill: Claude takes a seat at the table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- .claude/skills/wizwar-duel/SKILL.md | 97 ++++++++++++++++ tools/claude-seat.mjs | 170 ++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 .claude/skills/wizwar-duel/SKILL.md create mode 100644 tools/claude-seat.mjs diff --git a/.claude/skills/wizwar-duel/SKILL.md b/.claude/skills/wizwar-duel/SKILL.md new file mode 100644 index 0000000..147b002 --- /dev/null +++ b/.claude/skills/wizwar-duel/SKILL.md @@ -0,0 +1,97 @@ +--- +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 Claude # take a seat (token saved) +node tools/claude-seat.mjs view # board + hand + last events +node tools/claude-seat.mjs do '' # 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. + +## 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". diff --git a/tools/claude-seat.mjs b/tools/claude-seat.mjs new file mode 100644 index 0000000..e5a74e2 --- /dev/null +++ b/tools/claude-seat.mjs @@ -0,0 +1,170 @@ +// 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 +// node claude-seat.mjs view +// node claude-seat.mjs do '' +// 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(" ")); + 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") { + const s = { roomId: seat?.roomId ?? args[0]?.toUpperCase(), 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 "); + 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}`)); From 9e4807eab8e823462ad71805b012d9a137795e7d Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 26 Aug 2026 10:38:57 -0400 Subject: [PATCH 13/20] The needle shows itself; floor objects answer a tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/engine/src/game.ts | 4 ++++ packages/engine/src/view.ts | 20 +++++++++++++++-- packages/engine/test/casting.test.ts | 31 ++++++++++++++++++++++++++- packages/web/src/Board.svelte | 32 ++++++++++++++++++++++++---- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 6ed9ab5..a43b78d 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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. */ @@ -5164,6 +5167,7 @@ function doCast(prev: GameState, cmd: Extract): 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]; diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 0e72153..05b7a02 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -296,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; @@ -305,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([ diff --git a/packages/engine/test/casting.test.ts b/packages/engine/test/casting.test.ts index 5f728bc..fcf1be4 100644 --- a/packages/engine/test/casting.test.ts +++ b/packages/engine/test/casting.test.ts @@ -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 { sightedCellsFor, 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). */ @@ -1166,3 +1166,32 @@ describe("MENTAL FORCE respects the victim's three walked spaces", () => { 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(); + } + }); +}); diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index cccd20e..3f606b1 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -68,7 +68,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 +390,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={() => {}} > {o.cardId} {:else} { ev.stopPropagation(); onCellPeek?.({ x: gx, y: gy }); }} + onkeydown={() => {}} > {o.cardId} @@ -661,7 +667,14 @@ {#if sightTrace} - + {#if sightTrace.bend} + + + + {:else} + + {/if} {/if}