A cast that resolved at once draws the sight line it was accepted on

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-16 23:05:32 -04:00
co-authored by Claude Fable 5.1
parent 12a655868b
commit 7d0c1c68a3
3 changed files with 112 additions and 42 deletions
+80 -37
View File
@@ -9,6 +9,7 @@ import {
LOS_BLOCKING_CONTENT, LOS_BLOCKING_CONTENT,
type AmbushState, type AmbushState,
type CastStack, type CastStack,
type GameEvent,
type PushPending, type PushPending,
type CreatureState, type CreatureState,
type GameState, 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 * (believed illusion walls block; held doors admit). Null when no sight
* 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, ignoreDust = false): SightTrace | null {
if (dustAtEnd(view, from, to)) return null; if (!ignoreDust && 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);
} }
@@ -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 * defender sharing a square, or no sight under this viewer's knowledge
* (a believed illusion wall can honestly hide the line). * (a believed illusion wall can honestly hide the line).
*/ */
export function stackSightTrace( /** A sight line as the board draws it: the legs, the corner bend, and the
view: GameView, * one wall a VISIONSTONE dissolved. */
): { export interface SightLine {
from: Cell; from: Cell;
to: Cell; to: Cell;
trace: SightTrace; trace: SightTrace;
bend?: { mid: Cell; trace: SightTrace }; bend?: { mid: Cell; trace: SightTrace };
/** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */ /** VISIONSTONE: the one wall or door the bearer's sight dissolved (edge key). */
pierced?: string; 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; const stack = view.stack;
if (!stack || stack.creatureId) return null; if (!stack || stack.creatureId) return null;
if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null; if (!stack.attackCard || cardDef(stack.attackCard.cardId).los !== true) return null;
const a = view.players.find((p) => p.id === stack.attackerId); const a = view.players.find((p) => p.id === stack.attackerId);
const d = view.players.find((p) => p.id === stack.defenderId); const d = view.players.find((p) => p.id === stack.defenderId);
if (!a || !d) return null; if (!a || !d) return null;
if (a.position.x === d.position.x && a.position.y === d.position.y) return null; return sightTraceBetween(view, a.position, d.position, {
const trace = traceSightFor(view, a.position, d.position); visionstone: a.displayed.some((c) => c.cardId === "visionstone"),
if (trace) return { from: a.position, to: d.position, trace }; bentCorner: stack.bentCorner === true,
// 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 /** The sight line a resolved cast was accepted on — a creation, a curse,
// ends can see and draw the sight leg by leg, so the table can audit the * anything aimed by line of sight that never waited on the stack — so the
// needle instead of doubting it. * table can see how the aim was legal. `events` is the cast's own batch,
if (stack.bentCorner) { * which names an AROUND THE CORNER cast. */
for (const key of Object.keys(view.board.cells)) { export function castSightTrace(
const [mx, my] = key.split(",").map(Number) as [number, number]; view: GameView,
const mid = { x: mx, y: my }; cast: Extract<GameEvent, { type: "spellCast" }>,
if ((mid.x === a.position.x && mid.y === a.position.y) || events: readonly GameEvent[],
(mid.x === d.position.x && mid.y === d.position.y)) continue; afterTheFact = false,
const leg1 = traceSightFor(view, a.position, mid); ): SightLine | null {
if (!leg1) continue; let def;
const leg2 = traceSightFor(view, mid, d.position); try { def = cardDef(cast.cardId); } catch { return null; }
if (leg2) return { from: a.position, to: d.position, trace: leg1, bend: { mid, trace: leg2 } }; if (def.los !== true) return null;
} const to = cast.targetCell ?? view.players.find((p) => p.id === cast.target)?.position ?? null;
} if (!to) return null;
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([ const CREATION_CARD_IDS = new Set([
+17 -2
View File
@@ -18,7 +18,7 @@
import { prefs, savePrefs } from "./prefs.svelte"; import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art"; import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte"; 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"; import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
net.connect(); net.connect();
@@ -176,6 +176,16 @@
} }
function playFx(events: Parameters<typeof scheduleFx>[0]) { function playFx(events: Parameters<typeof scheduleFx>[0]) {
if (!view) return; 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) { for (const e of events) {
if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId; if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId;
if (e.type === "handRevealedPrivate" && e.visibleTo === view.you) { 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. */ /** 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 // 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. // 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<SightLine | null>(null);
let castTraceTimer: ReturnType<typeof setTimeout> | null = null;
const sightTrace = $derived((view ? stackSightTrace(view) : null) ?? castTrace);
/** A first game's first turn lights itself; after that only by choice. */ /** A first game's first turn lights itself; after that only by choice. */
const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1); const firstTurnEver = $derived(!!view && prefs.finishedGames === 0 && view.turn.round === 1);
+14 -2
View File
@@ -9,7 +9,7 @@
import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d"; import { fpFxForEvents, smoothstep, type FpFx } from "./fpv/fx3d";
import { castRay, edgeMid } from "./fpv/raycast"; import { castRay, edgeMid } from "./fpv/raycast";
import { cutawayStand, aimOfEvents, gatherGlides, hurledIn, shortestArc, sightline } from "./fpv/director"; 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"; import type { GameEvent, GameView } from "@wizwar/engine";
let { let {
@@ -187,7 +187,19 @@
// The reel draws the same sight line the live table shows for an LOS // 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. // 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 /** Every wall destroyed so far in the reel leaves a mound of rubble on
* the first-person floor for the rest of it. */ * the first-person floor for the rest of it. */