From f859d90af88add236a7ee0cfb5b6bfc9159af4fc Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Sat, 5 Sep 2026 23:30:17 -0400 Subject: [PATCH] Rev 19: a dust cloud blinds whoever stands in it (CPU7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/game.ts | 14 ++++++++++- packages/engine/src/view.ts | 19 +++++++++++++++ .../engine/test/expansion-terrain.test.ts | 23 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index af91a18..78d054c 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -238,7 +238,7 @@ export interface CastParams { } /** 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 * 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: 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: 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 { @@ -438,7 +439,18 @@ function sightThroughOneEdge( 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 { + if (dustAtEnd(state, from, to)) return false; const board = doorsAjar(state, viewerId, openedDoors(state, boardView(state))); const blockers = losBlockers(state); if (sightBetween(board, from, to, blockers)) return true; diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 05ff16d..e8c14dc 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -220,8 +220,14 @@ export function sightedCellsFor(view: GameView): Set { const me = view.players.find((p) => p.id === view.you); if (!me) return out; 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)) { 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); } // 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. */ export function traceSightFor(view: GameView, from: Cell, to: Cell): SightTrace | null { + if (dustAtEnd(view, from, to)) return null; const { board, blockers } = sightBasis(view); 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 * sees a middle square which sees the target — mirroring the engine's * bentLos so a client can predict whether the modifier will land. */ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean { + if (dustAtEnd(view, from, to)) return false; const { board, blockers } = sightBasis(view); if (sightBetween(board, from, to, blockers)) return true; for (const key of Object.keys(board.cells)) { diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index bcb0e27..c14b03d 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -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", () => { let { state } = newGame(); const me = activePlayer(state);