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
+74 -30
View File
@@ -24,6 +24,7 @@ import {
type Warp,
hasLineOfSight,
sightBetween,
bentSightThroughGap,
neighbor,
stepTarget,
opposite,
@@ -483,8 +484,10 @@ function casterLos(
return sightThroughOneEdge(board, from, to, blockers);
}
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean {
/** Bent LOS for AROUND THE CORNER: the caster sees a middle cell which sees
* the target or, for the full 180-degree hairpin, the sight line doubles
* back around a wall's end, pivoting in the open gap itself. */
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean {
if (casterLos(state, caster, from, to)) return true;
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
@@ -494,6 +497,41 @@ function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, ev
return true;
}
}
const board = doorsAjar(state, caster.id, openedDoors(state, perceivedBoard(state, caster.id)));
return bentSightThroughGap(board, from, to, losBlockers(state));
}
/** Sight for a square-aimed cast. "You may cast any line of sight spell
* around one corner (up to 180 degrees)" the attachment bends every
* L.O.S. spell, creations and utilities included, not just attacks. */
function castSight(
state: GameState,
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
to: Cell,
): boolean {
if (gameLos(state, caster.position, to, caster.id)) return true;
return !!cmd.aroundCornerInstanceId && bentLos(state, caster, caster.position, to);
}
/** Sight for an edge-aimed cast (walls, doors), bend-aware like castSight. */
function castSightEdge(
state: GameState,
caster: PlayerState,
cmd: Extract<Command, { type: "cast" }>,
board: AssembledBoard,
cell: Cell,
side: Side,
): boolean {
if (losToEdge(board, caster.position, cell, side)) return true;
if (!cmd.aroundCornerInstanceId) return false;
for (const key of Object.keys(board.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if (casterLos(state, caster, caster.position, mid) && losToEdge(board, mid, cell, side)) {
return true;
}
}
return false;
}
@@ -1376,7 +1414,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target.playerId === caster.id) return "you are already your own buddy";
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, target.position)) return "no line of sight";
// Effectively permanent: broken by the caster attacking the target.
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
return null;
@@ -1475,7 +1513,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (farKey && (view.edges[farKey] ?? "open") !== "open") {
return "the far mouth of that warp is sealed";
}
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
const ignite = (k: string, at: { cell: Cell; side: Side }) => {
state.edgeOverrides[k] = "firewall";
state.createdEdges[k] = true;
@@ -1511,7 +1549,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
return "the wave must span a corridor between two spaces";
}
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
// The two sides of the edge, and the push directions away from it.
waveFromEdge(state, events, cell, side, 2, "waterwall");
@@ -1526,13 +1564,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target?.kind === "edge") {
const key = edgeKey(cmd.target.cell, cmd.target.side);
if (state.illusionWalls[key]) {
if (!losToEdge(boardView(state), caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
if (!castSightEdge(state, caster, cmd, boardView(state), cmd.target.cell, cmd.target.side)) return "no line of sight";
delete state.illusionWalls[key];
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
return null;
}
if (!state.createdEdges[key]) return "that is not a created thing";
if (!losToEdge(view, caster.position, cmd.target.cell, cmd.target.side)) return "no line of sight";
if (!castSightEdge(state, caster, cmd, view, cmd.target.cell, cmd.target.side)) return "no line of sight";
const was = view.edges[key];
delete state.edgeOverrides[key];
delete state.createdEdges[key];
@@ -1544,14 +1582,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = cellKey(cmd.target.cell);
const creature = creatureAt(state, cmd.target.cell);
if (creature) {
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
destroyCreature(state, events, creature, "dispel creation");
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
return null;
}
const content = state.squareContents[key];
if (!content) return "nothing created there";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
return null;
@@ -1568,7 +1606,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId);
if (!target || !target.alive) return "no such living player";
if (target.id === caster.id) return "you cannot drag yourself";
if (!gameLos(state, caster.position, target.position, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, target.position)) return "no line of sight";
if (isLockedInPlace(state, target.id)) return "they are locked in place";
const from = target.position;
dragToward(state, target, caster.position);
@@ -1577,7 +1615,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
}
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
const objects = state.groundObjects[key];
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
if (objects && objects.length > 0) {
@@ -1644,8 +1682,8 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
"around-the-corner": {
kind: "neutral",
// Never cast alone — attached to an attack via aroundCornerInstanceId.
resolve: () => "attach Around The Corner to an attack instead of casting it alone",
// Never cast alone — attached to any L.O.S. cast via aroundCornerInstanceId.
resolve: () => "attach Around The Corner to a line-of-sight spell instead of casting it alone",
},
ugly: {
kind: "neutral",
@@ -1678,7 +1716,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = edgeKey(cell, side);
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
if (state.illusionWalls[key]) return "an illusion already shimmers there";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
return null;
@@ -1750,7 +1788,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
if (!creature) return "no such monster";
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no monster";
if (!gameLos(state, caster.position, creature.position, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, creature.position)) return "no line of sight";
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
if (boost === "movement") creature.movesPerTurn *= 2;
else creature.maxDamage *= 2;
@@ -1841,7 +1879,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const key = edgeKey(cell, side);
const view = boardView(state);
if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
state.edgeOverrides[key] = "open";
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
@@ -1917,7 +1955,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
return "a door goes into a stone wall or an open corridor";
}
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "door";
state.createdEdges[key] = true;
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
@@ -1952,7 +1990,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to glue down";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
state.gluedCells[key] = true;
// "a duration equal to twice the NUMBER card played"
const turns = magnitude.duration * 2;
@@ -1976,7 +2014,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
(state.groundObjects[key] ?? []).length > 0 ||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
if (!hasObject) return "there is nothing there to lock up";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
return null;
@@ -1994,7 +2032,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (ka === kb) return "pick two different squares";
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
if (state.squareContents[ka]?.kind === "safe" || state.squareContents[kb]?.kind === "safe") return "it is locked in a safe";
if (!gameLos(state, caster.position, a, caster.id) || !gameLos(state, caster.position, b, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, a) || !castSight(state, caster, cmd, b)) return "no line of sight";
const itemsA = state.groundObjects[ka] ?? [];
const itemsB = state.groundObjects[kb] ?? [];
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
@@ -2026,7 +2064,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const current = view.edges[key];
const meltable = current === "wall" || current === "door";
if (!meltable) return "that is not a stone wall";
if (!losToEdge(view, caster.position, cell, side)) return "no line of sight";
if (!castSightEdge(state, caster, cmd, view, cell, side)) return "no line of sight";
state.edgeOverrides[key] = "open";
delete state.doorStates[key];
delete state.createdEdges[key];
@@ -2057,7 +2095,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
if (cmd.target?.kind === "cell") {
const key = cellKey(cmd.target.cell);
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
if (!gameLos(state, caster.position, cmd.target.cell, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, cmd.target.cell)) return "no line of sight";
delete state.squareContents[key];
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
// "Solid stone block turns into a WATERWALL with a range and damage of 4."
@@ -2526,7 +2564,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const aim = cmd.target.cell;
const view = boardView(state);
if (!view.cells[cellKey(aim)]) return "off the board";
if (!casterLos(state, caster, caster.position, aim)) return "no line of sight";
if (!castSight(state, caster, cmd, aim)) return "no line of sight";
state.turn.attackUsed = true;
const clampToBoard = (c: Cell): Cell => {
@@ -2758,7 +2796,7 @@ function summonEffect(kind: CreatureState["kind"]): NeutralEffect {
if (!view.cells[cellKey(at)]) return "off the board";
if (state.squareContents[cellKey(at)]) return "that square is blocked";
if (creatureAt(state, at)) return "a creature is already there";
if (!casterLos(state, caster, caster.position, at)) return "no line of sight";
if (!castSight(state, caster, cmd, at)) return "no line of sight";
state.turn.attackUsed = true;
spawnCreature(state, events, kind, caster.id, at);
return null;
@@ -2810,7 +2848,7 @@ function emptySquareTarget(
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
return "the warp shimmers there — nothing can form on it";
}
if (!gameLos(state, caster.position, cell, caster.id)) return "no line of sight";
if (!castSight(state, caster, cmd, cell)) return "no line of sight";
return cell;
}
@@ -4907,7 +4945,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
return err("you must be in the same square");
}
if (effect.requiresLos && !casterLos(state, caster, caster.position, creature.position)) {
if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, creature.position)
: casterLos(state, caster, caster.position, creature.position))) {
return err("no line of sight to the creature");
}
const wandEvents: GameEvent[] = [];
@@ -4943,7 +4983,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
if (cmd.target?.kind === "cell" &&
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
const cell = cmd.target.cell;
if (effect.requiresLos && !casterLos(state, caster, caster.position, cell)) {
if (effect.requiresLos && !(mods.aroundCorner
? bentLos(state, caster, caster.position, cell)
: casterLos(state, caster, caster.position, cell))) {
return err("no line of sight to the slime");
}
const wandEvents: GameEvent[] = [];
@@ -5062,7 +5104,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
const preEvents: GameEvent[] = [];
if (effect.requiresLos) {
const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position, preEvents)
? bentLos(state, caster, caster.position, target.position)
: casterLos(state, caster, caster.position, target.position);
if (!sighted) return err("no line of sight to the target");
}
@@ -5183,7 +5225,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
}
// Neutral: validate on a preview clone before consuming any cards.
const events: GameEvent[] = [{
const events: GameEvent[] = [];
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
events.push({
type: "spellCast",
caster: caster.id,
card: inHand,
@@ -5193,7 +5237,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
from: caster.position,
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
}];
});
const preview = clone(state);
const problem = (effect as NeutralEffect).resolve(
preview, [], activePlayer(preview), cmd, mods.magnitude,