Around the Corner bends every spell, hairpins included

Two holes, found when a SAFE cast at a treasure 'around the 180 degree
corner' refused with no line of sight (room LZ5R). First: the bend was
honored only in the attack path — every neutral resolve (creations,
summons, utilities, edge targets) checked straight sight and ignored
the attached card, though the card says 'any line of sight spell'.
castSight/castSightEdge now thread the bend through all of them, and
the chronicle narrates the neutral bend too.

Second: the bend itself pivoted only at cell centers, so the card's
flagship shot — doubling back up to 180 degrees around a wall's end —
never resolved: both legs graze the corner and die. The pivot may now
sit in the wall's open gap itself (bentSightThroughGap), which is what
rounding a corner physically is. Closed doors still block; two corners
still refuse.

The client lights bent-reachable squares for real now instead of the
old one-step-adjacency guess, creations included. Pure widening, so no
rev bump: every one of these casts was refused before, so no ledger
holds one. All 26 production ledgers replay clean; the LZ5R moment
itself replayed and the exact refused cast now lands.

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-27 17:34:48 -04:00
co-authored by Claude Fable 5
parent f53f26ace9
commit 3bf3c33fdc
5 changed files with 263 additions and 49 deletions
+29 -3
View File
@@ -2,7 +2,7 @@
// The server sends this after every state change; clients never see the
// deck order or other players' hands.
import { SIDES, edgeKey, sightBetween, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { SIDES, edgeKey, sightBetween, bentSightThroughGap, traceSight, type AssembledBoard, type Cell, type SightTrace } from "./board";
import { cardDef, type CardInstance } from "./cards";
import {
boardView,
@@ -223,6 +223,32 @@ export function sightedCellsFor(view: GameView): Set<string> {
return out;
}
/**
* Sight with an AROUND THE CORNER attached: every square straightly sighted,
* plus every square visible from one of those — the caster looks to a middle
* cell and the spell turns there, mirroring the engine's bentLos.
*/
export function bentSightedCellsFor(view: GameView): Set<string> {
const out = sightedCellsFor(view);
const me = view.players.find((p) => p.id === view.you);
if (!me) return out;
const mids = [...out].map((k) => {
const [x, y] = k.split(",").map(Number) as [number, number];
return { x, y };
});
const { board, blockers } = sightBasis(view);
for (const key of Object.keys(board.cells)) {
if (out.has(key)) continue;
const [x, y] = key.split(",").map(Number) as [number, number];
const cell = { x, y };
if (mids.some((mid) => sightBetween(board, mid, cell, blockers)) ||
bentSightThroughGap(board, me.position, cell, blockers)) {
out.add(key);
}
}
return out;
}
/** The board-as-seen and sight blockers this view's sight rules run against. */
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
// Held-open doors are open doorways to every eye. Beyond them, the
@@ -337,12 +363,12 @@ const SUMMON_CARD_IDS = new Set([
* aid — mirroring the engine's own validation from the viewer's knowledge.
* Null = this card's eligibility is not modeled; light everything.
*/
export function eligibleCellsFor(view: GameView, cardId: string): Set<string> | null {
export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set<string> | null {
const me = view.players.find((p) => p.id === view.you);
if (!me) return null;
const cells = Object.keys(view.board.cells);
const key = (x: number, y: number) => `${x},${y}`;
const sighted = sightedCellsFor(view);
const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view);
if (CREATION_CARD_IDS.has(cardId)) {
// emptySquareTarget: on the board, unoccupied by content, home, wizard,