From d5b2b92f2c2129ad79882691a0f4ed678b0adc74 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 18 Aug 2026 01:54:53 -0400 Subject: [PATCH] Illusion walls shimmer until YOU test them (rules rev 29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the owner's design — and truer to a real table, where the cast is public: every illusion wall's location is open knowledge, rendered as a shimmering wall to any player whose eyes have not yet ruled on it. Testing is an explicit act: tap the shimmer (or bump into it) and a modal explains the 50/50 and offers 'Roll to test your eyes' — the new testIllusion command rolls once, then the wall either hardens or dissolves for you alone. Nobody's verdict is moved by watching someone else stroll through (the Q3WZ mystery: an automaton walking through its own round-1 fake). Untested walls block movement and sight without consuming RNG; the pre-29 lazy belief roll is preserved for stored games. Automatons doubt free shimmers on their best crossing before spending any card on that wall. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/automaton.ts | 15 +++++ packages/engine/src/game.ts | 47 +++++++++++++-- packages/engine/src/view.ts | 13 +++++ .../test/sight-illusions-sectors.test.ts | 58 +++++++++++++++++++ packages/server/src/rooms.ts | 2 +- packages/web/src/App.svelte | 48 ++++++++++++++- packages/web/src/Board.svelte | 38 ++++++++++++ packages/web/src/local.svelte.ts | 2 +- 8 files changed, 215 insertions(+), 8 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 90c8f36..1dd3a90 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -1308,6 +1308,21 @@ export function automatonCommand( pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ?? pathToward(view, self.position, enemyCells, { canUnlock }); + // A shimmering illusion on the best crossing costs nothing to doubt: + // roll the free test before spending any card on that wall (rev 29). + if (view.deckRev >= 29) { + const crossing = bestWallCrossing(view, self, objectives, canUnlock); + if (crossing && crossing.total < (path?.distance ?? Infinity)) { + const k = edgeKey(crossing.cell, crossing.side); + if (view.illusionEdges[k] === "untested") { + const sighted = sightedCellsFor(view); + const flanks = [crossing.cell, neighbor(crossing.cell, crossing.side)]; + if (flanks.some((c) => cellKey(c) === cellKey(self.position) || sighted.has(cellKey(c)))) { + return { type: "testIllusion", cell: crossing.cell, side: crossing.side }; + } + } + } + } // A wall between here and the gold may be cheaper to remove than to walk // around — and sometimes it is the only way through. const dw = inHand(view, "destroy-wall"); diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 2504fb2..945d2f9 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -360,8 +360,10 @@ function parseEdgeKey(key: string): { cell: Cell; side: Side } { } /** - * What does this player believe about an illusion wall? Rolls the 50% chance - * lazily the first time it matters ("when they gain L.O.S. to it"). + * What does this player believe about an illusion wall? Before rules rev 29 + * the 50% chance rolled lazily the first time it mattered; from rev 29 the + * test is an explicit act (the testIllusion command) and an untested wall + * simply IS a wall to this player until they choose to doubt it. */ function illusionBelief( state: GameState, @@ -373,6 +375,7 @@ function illusionBelief( if (wall.createdBy === playerId) return "seesThrough"; const known = wall.belief[playerId]; if (known) return known; + if ((state.config.deckRev ?? 1) >= 29) return "believes"; // untested blocks; no roll const [roll, rngNext] = rollDie(state.rng); state.rng = rngNext; const result = roll <= 2 ? "seesThrough" : "believes"; @@ -649,6 +652,7 @@ export type Command = | { type: "playNumberForMovement"; instanceId: string; addInstanceId?: string } | { type: "punch"; targetId: PlayerId } | { type: "punchWall"; cell: Cell; side: Side } + | { type: "testIllusion"; cell: Cell; side: Side } | { type: "armWard"; armed: boolean } | { type: "warpStep" } | { type: "moveCreature"; creatureId: string; direction: Side } @@ -3631,6 +3635,7 @@ function applyCommandInner(state: GameState, playerId: PlayerId, command: Comman case "playNumberForMovement": return doPlayNumberForMovement(state, command.instanceId, command.addInstanceId); case "punch": return doPunch(state, command.targetId); case "punchWall": return doPunchWall(state, command.cell, command.side); + case "testIllusion": return doTestIllusion(state, command.cell, command.side); case "armWard": return doArmWard(state, command.armed); case "warpStep": return doWarpStep(state); case "moveCreature": return doMoveCreature(state, command.creatureId, command.direction); @@ -3721,8 +3726,13 @@ function doMove(prev: GameState, direction: Side, over = false): CommandResult { // A believed (or untested, when bumped) ILLUSION WALL blocks the believer. { const key = edgeKey(p.position, direction); - if (state.illusionWalls[key] && - illusionBelief(state, events, p.id, key) === "believes") { + const shimmer = state.illusionWalls[key]; + if (shimmer && (state.config.deckRev ?? 1) >= 29 && + shimmer.createdBy !== p.id && !shimmer.belief[p.id]) { + if (isBlinded(state, p)) return blindBump(state, events, p, direction); + return err("the wall shimmers oddly — test your eyes before you can pass"); + } + if (shimmer && illusionBelief(state, events, p.id, key) === "believes") { if (isBlinded(state, p)) return blindBump(state, events, p, direction); return err("blocked by wall"); } @@ -4232,6 +4242,35 @@ function finishChaosIfReady(state: GameState, events: GameEvent[]): void { scrambleHands(state, events, pending.casterId, pending.excluded); } +/** + * TEST AN ILLUSION (rules rev 29): a free act on your turn. Standing beside + * the shimmer or seeing it, you roll your 50% doubt — the wall then either + * hardens (for you) or dissolves (for you). Nobody else's eyes are moved by + * your verdict, or by anyone strolling through it. + */ +function doTestIllusion(prev: GameState, cell: Cell, side: Side): CommandResult { + if ((prev.config.deckRev ?? 1) < 29) return err("illusions test themselves in this vintage of the rules"); + const state = clone(prev); + const p = activePlayer(state); + const key = edgeKey(cell, side); + const wall = state.illusionWalls[key]; + if (!wall) return err("there is no illusion there"); + 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, events, p.id)); + if (!isAdjacentToEdge(p.position, cell, side) && + !losToEdge(board, p.position, cell, side)) { + return err("you cannot see that wall from here"); + } + const [roll, rngNext] = rollDie(state.rng); + state.rng = rngNext; + const result = roll <= 2 ? "seesThrough" : "believes"; + state.illusionWalls[key]!.belief[p.id] = result; + events.push({ type: "illusionTested", player: p.id, edge: key, result }); + return { ok: true, state, events }; +} + function doPunchWall(prev: GameState, cell: Cell, side: Side): CommandResult { const pre = attackPreconditions(prev); if (pre) return err(pre); diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index c87b03e..9826304 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -70,6 +70,9 @@ export interface GameView { createdEdges: string[]; /** Illusion edges YOU know are fake (creator or saw through); others see walls. */ knownIllusionEdges: string[]; + /** Every illusion edge and YOUR verdict on it (rules rev 29+): untested + * shimmers, believes renders solid, mine/seesThrough are ghosts. */ + illusionEdges: Record; creatures: CreatureState[]; /** Charges left on displayed wands (public), by card instance id. */ wandCharges: Record; @@ -90,11 +93,20 @@ export interface GameView { export function viewFor(state: GameState, playerId: PlayerId): GameView { const you = state.players.find((p) => p.id === playerId); // Illusion walls render as real walls unless this viewer knows better. + // From rules rev 29 their locations are open knowledge (the cast is + // public at a table) — what stays personal is each player's verdict. const base = boardView(state); + const rev29 = (state.config.deckRev ?? 1) >= 29; const knownIllusionEdges: string[] = []; + const illusionEdges: Record = {}; let edges = base.edges; for (const [key, wall] of Object.entries(state.illusionWalls)) { const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough"; + if (rev29) { + illusionEdges[key] = + wall.createdBy === playerId ? "mine" + : wall.belief[playerId] ?? "untested"; + } if (knows) { knownIllusionEdges.push(key); } else { @@ -147,6 +159,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { heldDoorEdges: state.heldDoors.map((h) => h.key), createdEdges: Object.keys(state.createdEdges), knownIllusionEdges, + illusionEdges, creatures: state.creatures.map((c) => ({ ...c, scorchedThisTurn: [...c.scorchedThisTurn] })), wandCharges: { ...state.wandCharges }, dimWarps: state.dimWarps.map((w) => ({ a: { ...w.a }, b: { ...w.b } })), diff --git a/packages/engine/test/sight-illusions-sectors.test.ts b/packages/engine/test/sight-illusions-sectors.test.ts index 4a178da..b66bc97 100644 --- a/packages/engine/test/sight-illusions-sectors.test.ts +++ b/packages/engine/test/sight-illusions-sectors.test.ts @@ -500,3 +500,61 @@ describe("sight tracing", () => { expect(warped).toBeGreaterThan(0); }); }); + +describe("illusions are tested by choice (rules rev 29)", () => { + function shimmerRig() { + let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 29 }); + state = toRound2(state); + const caster = activePlayer(state); + const mark = state.players.find((p) => p.id !== caster.id)!; + // Conjure the illusion on an OPEN edge beside the victim (state surgery + // for a deterministic spot; the cast itself is tested elsewhere). + const spot = emptyNeighborCell(state, mark.position); + const key = edgeKey(mark.position, spot.side); + state.illusionWalls[key] = { createdBy: caster.id, belief: {} }; + state = must(state, caster.id, { type: "endTurn", draw: 0 }); + return { + state, caster: caster.id, victim: mark.id, side: spot.side, + cell: { ...state.players.find((p) => p.id === mark.id)!.position }, + }; + } + + it("an untested shimmer blocks the walker with its own message, no die rolled", () => { + const { state, victim, side } = shimmerRig(); + const rngBefore = JSON.stringify(state.rng); + const r = applyCommand(state, victim, { type: "move", direction: side }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toContain("shimmers"); + expect(JSON.stringify(state.rng)).toBe(rngBefore); + }); + + it("the belief test is an explicit roll; the verdict then governs the wall", () => { + let { state, victim, cell, side } = shimmerRig(); + state = must(state, victim, { type: "testIllusion", cell, side }); + const verdict = state.illusionWalls[edgeKey(cell, side)]!.belief[victim]; + expect(verdict === "believes" || verdict === "seesThrough").toBe(true); + const r = applyCommand(state, victim, { type: "move", direction: side }); + if (verdict === "seesThrough") { + expect(r.ok).toBe(true); + } else { + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toBe("blocked by wall"); + } + // A second test is refused: eyes rule once. + expect(applyCommand(state, victim, { type: "testIllusion", cell, side }).ok).toBe(false); + }); + + it("the creator strolls through their own fake without any test", () => { + let { state, caster } = shimmerRig(); + // Skip to the caster's turn; the wall never blocks its maker. + while (activePlayer(state).id !== caster) { + state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 }); + } + const me = state.players.find((p) => p.id === caster)!; + const other = state.players.find((p) => p.id !== caster)!; + me.position = { ...other.position }; + const r = applyCommand(state, caster, { type: "move", direction: "E" }); + // Blocked only if some REAL edge stands there; the illusion itself never objects. + if (!r.ok) expect(r.error).not.toContain("shimmers"); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 79d5fed..86d274d 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** Rules revision new games are dealt under (stored games keep their own). */ -const RULES_REV = 28; +const RULES_REV = 29; const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 8429e0e..a5f0c49 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -10,7 +10,7 @@ import { scheduleFx, type BoardFx } from "./fx"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { local } from "./local.svelte"; - import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; + import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; import type { CardInstance, Side } from "@wizwar/engine"; net.connect(); @@ -637,11 +637,17 @@ } } // Adjacent but blocked? Send the move anyway — unlocked doors, mist-body - // and pass-through-wall are resolved server-side. + // and pass-through-wall are resolved server-side. A shimmering illusion + // in the way asks for the belief test first instead. for (const side of SIDES) { const n = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0), y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) }; if (cellKey(n) === cellKey(cell)) { + const k = edgeKey(me.position, side); + if (view.illusionEdges[k] === "untested") { + illusionPrompt = illusionEdgeParts(k); + return; + } dispatch({ type: "move", direction: side }); return; } @@ -930,6 +936,19 @@ clearSelection(); } + // A shimmering illusion wall, tapped or bumped: offer the belief test. + let illusionPrompt = $state<{ cell: { x: number; y: number }; side: Side } | null>(null); + function illusionEdgeParts(key: string): { cell: { x: number; y: number }; side: Side } { + const [kind, coords] = key.split(":") as [string, string]; + const [x, y] = coords.split(",").map(Number) as [number, number]; + return { cell: { x, y }, side: kind === "V" ? "E" : "S" }; + } + function rollIllusion() { + if (!illusionPrompt) return; + dispatch({ type: "testIllusion", cell: illusionPrompt.cell, side: illusionPrompt.side }); + illusionPrompt = null; + } + let pickChoice = $state(false); function pickUp() { if (treasuresHere.length > 1) { pickChoice = true; return; } @@ -1119,6 +1138,30 @@ {/if} + {#if illusionPrompt} +
(illusionPrompt = null)} onkeydown={() => {}}> + +
+ {/if} + {#if faqCardId} (faqCardId = null)} /> {/if} @@ -1503,6 +1546,7 @@ ghostSlots={relocateGhosts} onGhostClick={clickGhostSlot} onCellPeek={(c) => peekAt(c)} + onIllusionClick={(cell, side) => (illusionPrompt = { cell, side })} {litCells} {sightTrace} /> diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 02a3ee4..808d739 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -21,6 +21,7 @@ onCreatureClick, onWarpClick, onCellPeek, + onIllusionClick, markedCell = null, markedCells = null, litCells = null, @@ -40,6 +41,8 @@ onWarpClick?: (cell: { x: number; y: number }, side: Side) => void; /** Long-press on a square: read the card behind whatever occupies it. */ onCellPeek?: (cell: { x: number; y: number }) => void; + /** Tap on a shimmering (untested) illusion wall: offer the belief test. */ + onIllusionClick?: (cell: { x: number; y: number }, side: Side) => void; /** First square of a two-square spell: marked so the click reads as taken. */ markedCell?: { x: number; y: number } | null; /** A multi-square placement in progress (boobytrap tokens), in click @@ -426,6 +429,24 @@ {/if} {/each} + + {#each Object.entries(view.illusionEdges) as [key, verdict] (key)} + {#if verdict === "untested"} + {@const kind = key.split(":")[0]} + {@const ix = Number(key.split(":")[1]?.split(",")[0])} + {@const iy = Number(key.split(":")[1]?.split(",")[1])} + {@const x1 = kind === "V" ? (ix + 1) * CELL : ix * CELL + 2} + {@const y1 = kind === "V" ? iy * CELL + 2 : (iy + 1) * CELL} + {@const x2 = kind === "V" ? (ix + 1) * CELL : (ix + 1) * CELL - 2} + {@const y2 = kind === "V" ? (iy + 1) * CELL - 2 : (iy + 1) * CELL} + + { ev.stopPropagation(); onIllusionClick?.({ x: ix, y: iy }, kind === "V" ? "E" : "S"); }} + onkeydown={() => {}} /> + {/if} + {/each} + {#each view.board.warps as w, i (i)} {@const wx = w.from.cell.x * CELL + CELL / 2 + @@ -721,6 +742,23 @@ .creature-ring.selected { stroke-width: 4; stroke-dasharray: 5 3; } .ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; } .illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; } + .illusion-shimmer { + stroke: #cfc3f0; + stroke-width: 3; + stroke-dasharray: 4 5; + stroke-linecap: round; + pointer-events: none; + animation: shimmer-drift 1.4s linear infinite; + } + .illusion-hit { stroke: transparent; stroke-width: 14; cursor: pointer; } + @keyframes shimmer-drift { + 0% { stroke-dashoffset: 0; opacity: 0.9; } + 50% { opacity: 0.4; } + 100% { stroke-dashoffset: -18; opacity: 0.9; } + } + @media (prefers-reduced-motion: reduce) { + .illusion-shimmer { animation: none; opacity: 0.7; } + } .creature { cursor: pointer; } .creature-body { fill: #3b3428; stroke-width: 2.5; } .creature-body.selected { fill: #6b5a34; } diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index ba97206..ab15745 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -136,7 +136,7 @@ class LocalGame { seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), - deckRev: 28, + deckRev: 29, }; const { state, events } = createGame(config); for (const e of events) {