The stone pierces walls for creations too (rules rev 3)

VISIONSTONE only ever lit the attack path: casterLos knew it, but
every sight check routed through gameLos — creations like THORNBUSH,
dispels, CREATE DOOR, STONE TO WATER — refused "no line of sight"
through the bearer's one wall. gameLos now honors the stone whenever
the viewer bears it, at every revision: widening what a cast may
target never changes how a recorded command replays, so games already
in flight get the fix at once. Only the ambush's mid-move eye is
frozen per game (rev 3) — springing lands in the ledger's flow, and
stored games must replay their blindness. All 18 production ledgers
verified; 272 engine tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-08-25 20:21:32 -04:00
co-authored by Claude Fable 5
parent 7adf4b6cd6
commit 71a3c81670
2 changed files with 56 additions and 5 deletions
+37 -5
View File
@@ -226,7 +226,7 @@ export interface CastParams {
} }
/** The revision new games are dealt under; GameConfig.deckRev pins it per game. */ /** 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 { export interface GameConfig {
playerIds: PlayerId[]; playerIds: PlayerId[];
@@ -242,6 +242,9 @@ export interface GameConfig {
* Rev 2: a wizard beside a door they can open (lock removed, door * 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 * unlocked this turn, or PICK LOCK / MASTER KEY in hand) sees through
* the doorway; the hallway behind them still cannot. * 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; deckRev?: number;
} }
@@ -378,8 +381,37 @@ function doorsAjar(state: GameState, viewerId: PlayerId | undefined, board: Asse
} }
/** LOS including square-filling blockers. */ /** 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 { 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. */ /** Parse an edge key back into its north/west cell and side. */
@@ -5167,8 +5199,8 @@ function checkAmbushes(
sprung = context.pickedUpTreasure === true; sprung = context.pickedUpTreasure === true;
} else if (context.movedFrom) { } else if (context.movedFrom) {
if (ambush.trigger.kind === "los") { if (ambush.trigger.kind === "los") {
const before = gameLos(state, owner.position, context.movedFrom, owner.id); const before = ambushLos(state, owner, owner.position, context.movedFrom);
const now = gameLos(state, owner.position, actor.position, owner.id); const now = ambushLos(state, owner, owner.position, actor.position);
sprung = now && !before; sprung = now && !before;
} else if (ambush.trigger.kind === "near") { } else if (ambush.trigger.kind === "near") {
const dist = (c: Cell) => const dist = (c: Cell) =>
@@ -5180,7 +5212,7 @@ function checkAmbushes(
// The committed spell must be legal right now, or the ambush stays armed. // The committed spell must be legal right now, or the ambush stays armed.
const fx = CARD_EFFECTS[ambush.spell.cardId] as AttackEffect; 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.ambushes = state.ambushes.filter((a) => a.id !== ambush.id);
state.discard.push(ambush.via, ambush.spell, ...ambush.numbers); state.discard.push(ambush.via, ambush.spell, ...ambush.numbers);
+19
View File
@@ -527,6 +527,25 @@ describe("the VISIONSTONE pierces one wall", () => {
expect(cast.ok).toBe(true); 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", () => { it("the client's sighted squares agree with the stone", () => {
const { state, a, b } = stoneRig(); const { state, a, b } = stoneRig();
expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false); expect(sightedCellsFor(viewFor(state, a.id)).has(cellKey(b.position))).toBe(false);