From 7d0c1c68a3df5313820e330e8f00c50721a066ef Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 16 Sep 2026 23:05:32 -0400 Subject: [PATCH] A cast that resolved at once draws the sight line it was accepted on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attack on the stack has always shown its line; a creation or a curse resolved in the same breath showed nothing, so a legal aim through a Visionstone and out a lettered opening (PPL7, move 309) looked like a cheat. The line a cast was accepted on — plain, through the one wall a VISIONSTONE dissolves, or AROUND THE CORNER's two legs — now draws on the live board for a few seconds and on that move of any reel, traced after the fact so the cast's own dust cloud cannot blind it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- packages/engine/src/view.ts | 119 ++++++++++++++++++++++----------- packages/web/src/App.svelte | 19 +++++- packages/web/src/Replay.svelte | 16 ++++- 3 files changed, 112 insertions(+), 42 deletions(-) diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index f0f621a..c2fc9c9 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -9,6 +9,7 @@ import { LOS_BLOCKING_CONTENT, type AmbushState, type CastStack, + type GameEvent, type PushPending, type CreatureState, type GameState, @@ -328,8 +329,8 @@ function sightBasis(view: GameView): { board: GameView["board"]; blockers: Recor * (believed illusion walls block; held doors admit). Null when no sight * 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; +export function traceSightFor(view: GameView, from: Cell, to: Cell, ignoreDust = false): SightTrace | null { + if (!ignoreDust && dustAtEnd(view, from, to)) return null; const { board, blockers } = sightBasis(view); return traceSight(board, from, to, blockers); } @@ -372,54 +373,96 @@ export function bentSightFor(view: GameView, from: Cell, to: Cell): boolean { * defender sharing a square, or no sight under this viewer's knowledge * (a believed illusion wall can honestly hide the line). */ -export function stackSightTrace( - view: GameView, -): { +/** A sight line as the board draws it: the legs, the corner bend, and the + * one wall a VISIONSTONE dissolved. */ +export interface SightLine { from: Cell; to: Cell; trace: SightTrace; bend?: { mid: Cell; trace: SightTrace }; /** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */ pierced?: string; -} | null { +} + +/** The sight line from one square to another under the sight law the + * engine applies to a cast: a plain line (warps included), else the + * VISIONSTONE's look through one wall, else AROUND THE CORNER's two legs. + * `afterTheFact` traces a cast already resolved, whose own dust cloud + * would otherwise blind the line to it. */ +export function sightTraceBetween( + view: GameView, + from: Cell, + to: Cell, + opts: { visionstone?: boolean; bentCorner?: boolean; afterTheFact?: boolean } = {}, +): SightLine | null { + if (from.x === to.x && from.y === to.y) return null; + const trace = traceSightFor(view, from, to, opts.afterTheFact); + if (trace) return { from, to, trace }; + // VISIONSTONE: the bearer sees through exactly one wall or door. When no + // plain line exists, find the single edge whose removal opens one and draw + // the ray straight through it, marking the pierced wall for the table. + if (opts.visionstone) { + const { board, blockers } = sightBasis(view); + for (const [key, edge] of Object.entries(board.edges)) { + if (edge === "open") continue; + const edges = { ...board.edges }; + delete edges[key]; + const t = traceSight({ ...board, edges }, from, to, blockers); + if (t) return { from, to, trace: t, pierced: key }; + } + } + // AROUND THE CORNER: no straight line exists — find a middle square both + // ends can see and draw the sight leg by leg, so the table can audit the + // needle instead of doubting it. + if (opts.bentCorner) { + for (const key of Object.keys(view.board.cells)) { + const [mx, my] = key.split(",").map(Number) as [number, number]; + const mid = { x: mx, y: my }; + if ((mid.x === from.x && mid.y === from.y) || (mid.x === to.x && mid.y === to.y)) continue; + const leg1 = traceSightFor(view, from, mid, opts.afterTheFact); + if (!leg1) continue; + const leg2 = traceSightFor(view, mid, to, opts.afterTheFact); + if (leg2) return { from, to, trace: leg1, bend: { mid, trace: leg2 } }; + } + } + return null; +} + +/** The sight line of the attack on the stack, for the board to draw. */ +export function stackSightTrace(view: GameView): SightLine | null { const stack = view.stack; if (!stack || stack.creatureId) return null; if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null; const a = view.players.find((p) => p.id === stack.attackerId); const d = view.players.find((p) => p.id === stack.defenderId); if (!a || !d) return null; - if (a.position.x === d.position.x && a.position.y === d.position.y) return null; - const trace = traceSightFor(view, a.position, d.position); - if (trace) return { from: a.position, to: d.position, trace }; - // VISIONSTONE: the bearer sees through exactly one wall or door. When no - // plain line exists, find the single edge whose removal opens one and draw - // the ray straight through it, marking the pierced wall for the table. - if (a.displayed.some((c) => c.cardId === "visionstone")) { - const { board, blockers } = sightBasis(view); - for (const [key, edge] of Object.entries(board.edges)) { - if (edge === "open") continue; - const edges = { ...board.edges }; - delete edges[key]; - const t = traceSight({ ...board, edges }, a.position, d.position, blockers); - if (t) return { from: a.position, to: d.position, trace: t, pierced: key }; - } - } - // AROUND THE CORNER: no straight line exists — find a middle square both - // ends can see and draw the sight leg by leg, so the table can audit the - // needle instead of doubting it. - if (stack.bentCorner) { - for (const key of Object.keys(view.board.cells)) { - const [mx, my] = key.split(",").map(Number) as [number, number]; - const mid = { x: mx, y: my }; - if ((mid.x === a.position.x && mid.y === a.position.y) || - (mid.x === d.position.x && mid.y === d.position.y)) continue; - const leg1 = traceSightFor(view, a.position, mid); - if (!leg1) continue; - const leg2 = traceSightFor(view, mid, d.position); - if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } }; - } - } - return null; + return sightTraceBetween(view, a.position, d.position, { + visionstone: a.displayed.some((c) => c.cardId === "visionstone"), + bentCorner: stack.bentCorner === true, + }); +} + +/** The sight line a resolved cast was accepted on — a creation, a curse, + * anything aimed by line of sight that never waited on the stack — so the + * table can see how the aim was legal. `events` is the cast's own batch, + * which names an AROUND THE CORNER cast. */ +export function castSightTrace( + view: GameView, + cast: Extract, + events: readonly GameEvent[], + afterTheFact = false, +): SightLine | null { + let def; + try { def = cardDef(cast.cardId); } catch { return null; } + if (def.los !== true) return null; + const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null; + if (!to) return null; + const caster = view.players.find((p) => p.id === cast.caster); + return sightTraceBetween(view, cast.from, to, { + visionstone: caster?.displayed.some((c) => c.cardId === "visionstone") ?? false, + bentCorner: events.some((e) => e.type === "castAroundCorner" && e.caster === cast.caster), + afterTheFact, + }); } const CREATION_CARD_IDS = new Set([ diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index c18e071..391316a 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -18,7 +18,7 @@ import { prefs, savePrefs } from "./prefs.svelte"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { local } from "./local.svelte"; - import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine"; + import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, opposite, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, castSightTrace, type GameView, type SightLine, eligibleCellsFor } from "@wizwar/engine"; import type { CardInstance, GameEvent, Side } from "@wizwar/engine"; net.connect(); @@ -176,6 +176,16 @@ } function playFx(events: Parameters[0]) { if (!view) return; + // The events arrive before the state that follows them: the view here + // is the board as the caster saw it, dust and all. + for (const e of events) { + if (e.type !== "spellCast") continue; + const line = castSightTrace(view, e, events); + if (!line) continue; + castTrace = line; + if (castTraceTimer) clearTimeout(castTraceTimer); + castTraceTimer = setTimeout(() => { if (castTrace === line) castTrace = null; }, 4500); + } for (const e of events) { if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId; if (e.type === "handRevealedPrivate" && e.visibleTo === view.you) { @@ -1640,7 +1650,12 @@ /** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */ // While an LOS attack sits on the stack, draw the line it traveled — the // answer to "how can he even see me?" when sight ran through a warp mouth. - const sightTrace = $derived(view ? stackSightTrace(view) : null); + /** The attack on the stack draws its sight line; a cast that resolved + * at once (a creation, a curse) draws the line it was accepted on for a + * few seconds, so the table can see how the aim was legal. */ + let castTrace = $state(null); + let castTraceTimer: ReturnType | null = null; + const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace); /** A first game's first turn lights itself; after that only by choice. */ const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1); diff --git a/packages/web/src/Replay.svelte b/packages/web/src/Replay.svelte index 19f70ff..fdbd21b 100644 --- a/packages/web/src/Replay.svelte +++ b/packages/web/src/Replay.svelte @@ -9,7 +9,7 @@ import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d"; import { castRay, edgeMid } from "./fpv/raycast"; import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director"; - import { cardDef, isPermanentDuration, stackSightTrace } from "@wizwar/engine"; + import { cardDef, isPermanentDuration, stackSightTrace, castSightTrace } from "@wizwar/engine"; import type { GameEvent, GameView } from "@wizwar/engine"; let { @@ -187,7 +187,19 @@ // The reel draws the same sight line the live table shows for an LOS // attack in progress, so a replay-watcher can see how a spell reached them. - const sightTrace = $derived(stackSightTrace(step.view)); + /** The attack's line, or the line a cast in this step was accepted on — + * traced after the fact, so the cast's own dust cloud does not blind it. */ + const sightTrace = $derived.by(() => { + const onStack = stackSightTrace(step.view); + if (onStack) return onStack; + for (let i = step.events.length - 1; i >= 0; i--) { + const e = step.events[i]!; + if (e.type !== "spellCast") continue; + const line = castSightTrace(step.view, e, step.events, true); + if (line) return line; + } + return null; + }); /** Every wall destroyed so far in the reel leaves a mound of rubble on * the first-person floor for the rest of it. */