Illusion walls shimmer until YOU test them (rules rev 29)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-18 01:54:53 -04:00
co-authored by Claude Fable 5
parent 500d9a7154
commit d5b2b92f2c
8 changed files with 215 additions and 8 deletions
+46 -2
View File
@@ -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 @@
</div>
{/if}
{#if illusionPrompt}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (illusionPrompt = null)} onkeydown={() => {}}>
<div class="attack-notice" role="dialog" aria-label="a shimmering wall" tabindex="-1"
onclick={(ev) => ev.stopPropagation()} onkeydown={() => {}}>
<div class="attack-title">That wall shimmers strangely…</div>
<div class="attack-power">
Someone conjured an ILLUSION WALL here. Whether YOUR eyes are fooled
is a 50/50 roll — once. Believe it, and it is stone to you until
dispelled; see through it, and you may walk it like open floor.
Other wizards' eyes rule for themselves.
</div>
<div class="attack-actions">
{#if isYourTurn}
<button class="stamp primary" onclick={rollIllusion}>Roll to test your eyes</button>
{:else}
<span class="attack-power">— you can test it on your turn —</span>
{/if}
<button class="hint-cancel" onclick={() => (illusionPrompt = null)}>leave it be</button>
</div>
</div>
</div>
{/if}
{#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if}
@@ -1503,6 +1546,7 @@
ghostSlots={relocateGhosts}
onGhostClick={clickGhostSlot}
onCellPeek={(c) => peekAt(c)}
onIllusionClick={(cell, side) => (illusionPrompt = { cell, side })}
{litCells}
{sightTrace}
/>
+38
View File
@@ -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}
<!-- untested illusions: the wall stands, but it shimmers — click to doubt it -->
{#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}
<line {x1} {y1} {x2} {y2} class="illusion-shimmer" />
<line {x1} {y1} {x2} {y2} class="illusion-hit"
role="button" tabindex="-1" aria-label="a shimmering wall — test your eyes"
onclick={(ev) => { ev.stopPropagation(); onIllusionClick?.({ x: ix, y: iy }, kind === "V" ? "E" : "S"); }}
onkeydown={() => {}} />
{/if}
{/each}
<!-- warp openings: click the arrow (while standing on it) to step through -->
{#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; }
+1 -1
View File
@@ -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) {