Rev 19: a dust cloud blinds whoever stands in it (CPU7)

The automaton stood in Kestrel's DUST CLOUD and filled a square with
stone three rows away. The cloud blocked only sight lines passing
through it; the square a line began or ended in was never asked. The
FAQ is plain: no LOS spell may be cast by a wizard in a cloud, nor at
one, though a spell upon oneself still works, and VISIONSTONE does
not see through dust. From rev 19 the engine's sight and the view's —
the targeting aid the client and the automaton both read — refuse a
line with dust at either end. Older games keep their sight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-05 23:30:17 -04:00
co-authored by Claude Fable 5.1
parent 3d4bf1fa6d
commit f859d90af8
3 changed files with 55 additions and 1 deletions
+13 -1
View File
@@ -238,7 +238,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 = 18; export const CURRENT_RULES_REV = 19;
/** Every rulings revision since the baseline, newest last the entries a /** Every rulings revision since the baseline, newest last the entries a
* game's deckRev freezes it before or after. Shown to players as the house * game's deckRev freezes it before or after. Shown to players as the house
@@ -261,6 +261,7 @@ export const RULES_REVISIONS: { rev: number; note: string }[] = [
{ rev: 16, note: "BUTT-HEAD's charge is measured as far as the goat's legs reach. Older games searched only six steps of corridor and refused a longer ram as unreachable, even with the legs to make it." }, { rev: 16, note: "BUTT-HEAD's charge is measured as far as the goat's legs reach. Older games searched only six steps of corridor and refused a longer ram as unreachable, even with the legs to make it." },
{ rev: 17, note: "A warp mouth on a pit's rim is a way off it like any square: a bare step beside one square and one mouth is a fork the walker names. In every game the mouth may be named, and is taken when no square offers; only an older game's bare step still lands on the square unasked." }, { rev: 17, note: "A warp mouth on a pit's rim is a way off it like any square: a bare step beside one square and one mouth is a fork the walker names. In every game the mouth may be named, and is taken when no square offers; only an older game's bare step still lands on the square unasked." },
{ rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." }, { rev: 18, note: "REFLECTION's returning half is an attack on the caster in its own right, with the caster's own counteraction window — an ABSORB or a BLUNT meets it as it would any blow. Before, the half landed the instant the spell resolved." },
{ rev: 19, note: "DUST CLOUD blinds whoever stands in it: no LOS spell may be cast from inside a cloud, nor at anyone standing in one, and VISIONSTONE does not see through it. Spells cast on oneself still work. Before, the cloud blocked only sight lines passing through it." },
]; ];
export interface GameConfig { export interface GameConfig {
@@ -438,7 +439,18 @@ function sightThroughOneEdge(
return throughOneEdge(board, (b) => sightBetween(b, from, to, blockers)); return throughOneEdge(board, (b) => sightBetween(b, from, to, blockers));
} }
/** Rev 19: DUST CLOUD blinds whoever stands in it and hides them "an
* LOS spell cannot be cast upon a player in DUST CLOUD, or by a player
* who is in a DUST CLOUD" (official FAQ); a spell cast upon oneself
* still works, and VISIONSTONE does not see through dust either. Before
* it the cloud blocked only sight lines passing through. */
function dustAtEnd(state: GameState, from: Cell, to: Cell): boolean {
if ((state.config.deckRev ?? 1) < 19 || cellKey(from) === cellKey(to)) return false;
return state.squareContents[cellKey(from)]?.kind === "dust" || state.squareContents[cellKey(to)]?.kind === "dust";
}
function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean { function losWith(state: GameState, from: Cell, to: Cell, viewerId: PlayerId | undefined, stone: boolean): boolean {
if (dustAtEnd(state, from, to)) return false;
const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state)));
const blockers = losBlockers(state); const blockers = losBlockers(state);
if (sightBetween(board, from, to, blockers)) return true; if (sightBetween(board, from, to, blockers)) return true;
+19
View File
@@ -220,8 +220,14 @@ export function sightedCellsFor(view: GameView): Set<string> {
const me = view.players.find((p) => p.id === view.you); const me = view.players.find((p) => p.id === view.you);
if (!me) return out; if (!me) return out;
const { board, blockers } = sightBasis(view); const { board, blockers } = sightBasis(view);
// In a dust cloud a wizard sees only their own square (rev 19).
if (dustAtEnd(view, me.position, me.position, true)) {
out.add(`${me.position.x},${me.position.y}`);
return out;
}
for (const key of Object.keys(board.cells)) { for (const key of Object.keys(board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number]; const [x, y] = key.split(",").map(Number) as [number, number];
if (dustAtEnd(view, me.position, { x, y })) continue;
if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key);
} }
// VISIONSTONE lets its bearer see through exactly one wall or door — // VISIONSTONE lets its bearer see through exactly one wall or door —
@@ -314,16 +320,29 @@ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Recor
* exists — the renderer's material for drawing the line an attack traveled. * exists — the renderer's material for drawing the line an attack traveled.
*/ */
export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null { export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null {
if (dustAtEnd(view, from, to)) return null;
const { board, blockers } = sightBasis(view); const { board, blockers } = sightBasis(view);
return traceSight(board, from, to, blockers); return traceSight(board, from, to, blockers);
} }
/** Rev 19: DUST CLOUD blinds its occupant and hides them from LOS spells;
* a square sees itself still. `standing` asks only whether the viewer's
* own square is dust. */
function dustAtEnd(view: GameView, from: Cell, to: Cell, standing = false): boolean {
if (view.deckRev < 19) return false;
const dust = (c: Cell) => view.squareContents[`${c.x},${c.y}`]?.kind === "dust";
if (standing) return dust(from);
if (from.x === to.x && from.y === to.y) return false;
return dust(from) || dust(to);
}
/** /**
* AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster * AROUND THE CORNER's bent sight, from this viewer's knowledge: the caster
* sees a middle square which sees the target — mirroring the engine's * sees a middle square which sees the target — mirroring the engine's
* bentLos so a client can predict whether the modifier will land. * bentLos so a client can predict whether the modifier will land.
*/ */
export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean { export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean {
if (dustAtEnd(view, from, to)) return false;
const { board, blockers } = sightBasis(view); const { board, blockers } = sightBasis(view);
if (sightBetween(board, from, to, blockers)) return true; if (sightBetween(board, from, to, blockers)) return true;
for (const key of Object.keys(board.cells)) { for (const key of Object.keys(board.cells)) {
@@ -145,6 +145,29 @@ describe("expansion terrain", () => {
} }
}); });
it("a wizard in a dust cloud casts no LOS spell out, and none reaches them (rev 19)", () => {
for (const deckRev of [19, 18]) {
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
const spot = emptyNeighborCell(state, me.position);
state.squareContents[cellKey(spot.cell)] = { kind: "dust", damage: 0, createdBy: me.id };
me.position = { ...spot.cell };
// An empty square beside the cloud, in plain sight but for the dust.
const outside = emptyNeighborCell(state, spot.cell).cell;
const blind = deckRev >= 19;
expect(gameLos(state, me.position, outside, me.id)).toBe(!blind);
expect(gameLos(state, outside, me.position)).toBe(!blind);
expect(gameLos(state, me.position, me.position, me.id)).toBe(true);
const sighted = sightedCellsFor(viewFor(state, me.id));
expect(sighted.has(cellKey(outside))).toBe(!blind);
expect(sighted.has(cellKey(me.position))).toBe(true);
// FILL SQUARE WITH STONE from inside the cloud: refused when blind.
const stone = giveCard(state, me.id, "fill-square-with-stone");
const r = applyCommand(state, me.id, { type: "cast", instanceId: stone.instanceId, target: { kind: "cell", cell: outside } });
expect(r.ok).toBe(!blind);
}
});
it("glue pins a treasure to the floor until it wears off", () => { it("glue pins a treasure to the floor until it wears off", () => {
let { state } = newGame(); let { state } = newGame();
const me = activePlayer(state); const me = activePlayer(state);