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:
co-authored by
Claude Fable 5
parent
500d9a7154
commit
d5b2b92f2c
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, "untested" | "believes" | "seesThrough" | "mine">;
|
||||
creatures: CreatureState[];
|
||||
/** Charges left on displayed wands (public), by card instance id. */
|
||||
wandCharges: Record<string, number>;
|
||||
@@ -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<string, "untested" | "believes" | "seesThrough" | "mine"> = {};
|
||||
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 } })),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface Room {
|
||||
const rooms = new Map<string, Room>();
|
||||
|
||||
/** 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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user