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:
co-authored by
Claude Fable 5
parent
f53f26ace9
commit
3bf3c33fdc
@@ -480,3 +480,34 @@ export function sightBetween(
|
|||||||
return hasLineOfSight(board, from, to, blockedCells) ||
|
return hasLineOfSight(board, from, to, blockedCells) ||
|
||||||
hasWarpLineOfSight(board, from, to, blockedCells);
|
hasWarpLineOfSight(board, from, to, blockedCells);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AROUND THE CORNER's full reach: "around one corner (up to 180 degrees)".
|
||||||
|
* A hairpin around the end of a wall pivots IN the wall's gap, not at any
|
||||||
|
* cell's center — so try the midpoint of every open edge as the bend point,
|
||||||
|
* with a clear straight segment from caster to pivot and pivot to target.
|
||||||
|
*/
|
||||||
|
export function bentSightThroughGap(
|
||||||
|
board: AssembledBoard,
|
||||||
|
from: Cell,
|
||||||
|
to: Cell,
|
||||||
|
blockedCells?: Record<string, true>,
|
||||||
|
): boolean {
|
||||||
|
const fx = from.x + 0.5, fy = from.y + 0.5;
|
||||||
|
const tx = to.x + 0.5, ty = to.y + 0.5;
|
||||||
|
const skip = [cellKey(from), cellKey(to)];
|
||||||
|
for (const key of Object.keys(board.cells)) {
|
||||||
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
|
const c = { x, y };
|
||||||
|
for (const side of ["E", "S"] as Side[]) {
|
||||||
|
if (!board.cells[cellKey(neighbor(c, side))]) continue;
|
||||||
|
if (edgeState(board, c, side) !== "open") continue;
|
||||||
|
const [px, py] = side === "E" ? [x + 1, y + 0.5] : [x + 0.5, y + 1];
|
||||||
|
if (segmentClear(board, fx, fy, px, py, blockedCells, skip) &&
|
||||||
|
segmentClear(board, px, py, tx, ty, blockedCells, skip)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
+74
-30
@@ -24,6 +24,7 @@ import {
|
|||||||
type Warp,
|
type Warp,
|
||||||
hasLineOfSight,
|
hasLineOfSight,
|
||||||
sightBetween,
|
sightBetween,
|
||||||
|
bentSightThroughGap,
|
||||||
neighbor,
|
neighbor,
|
||||||
stepTarget,
|
stepTarget,
|
||||||
opposite,
|
opposite,
|
||||||
@@ -483,8 +484,10 @@ function casterLos(
|
|||||||
return sightThroughOneEdge(board, from, to, blockers);
|
return sightThroughOneEdge(board, from, to, blockers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bent LOS for AROUND THE CORNER: caster sees a middle cell, which sees the target. */
|
/** Bent LOS for AROUND THE CORNER: the caster sees a middle cell which sees
|
||||||
function bentLos(state: GameState, caster: PlayerState, from: Cell, to: Cell, events: GameEvent[]): boolean {
|
* 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;
|
if (casterLos(state, caster, from, to)) return true;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
for (const key of Object.keys(view.cells)) {
|
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;
|
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;
|
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";
|
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);
|
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 || !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.
|
// Effectively permanent: broken by the caster attacking the target.
|
||||||
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
attachSustained(state, events, "buddy", caster.id, target.id, PERMANENT_TURNS);
|
||||||
return null;
|
return null;
|
||||||
@@ -1475,7 +1513,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (farKey && (view.edges[farKey] ?? "open") !== "open") {
|
if (farKey && (view.edges[farKey] ?? "open") !== "open") {
|
||||||
return "the far mouth of that warp is sealed";
|
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 }) => {
|
const ignite = (k: string, at: { cell: Cell; side: Side }) => {
|
||||||
state.edgeOverrides[k] = "firewall";
|
state.edgeOverrides[k] = "firewall";
|
||||||
state.createdEdges[k] = true;
|
state.createdEdges[k] = true;
|
||||||
@@ -1511,7 +1549,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
if (!view.cells[cellKey(cell)] || (offBoard && !warpMouth)) {
|
||||||
return "the wave must span a corridor between two spaces";
|
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 } });
|
events.push({ type: "waterwallCrashes", caster: caster.id, edge: { cell, side } });
|
||||||
// The two sides of the edge, and the push directions away from it.
|
// The two sides of the edge, and the push directions away from it.
|
||||||
waveFromEdge(state, events, cell, side, 2, "waterwall");
|
waveFromEdge(state, events, cell, side, 2, "waterwall");
|
||||||
@@ -1526,13 +1564,13 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (cmd.target?.kind === "edge") {
|
if (cmd.target?.kind === "edge") {
|
||||||
const key = edgeKey(cmd.target.cell, cmd.target.side);
|
const key = edgeKey(cmd.target.cell, cmd.target.side);
|
||||||
if (state.illusionWalls[key]) {
|
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];
|
delete state.illusionWalls[key];
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
|
events.push({ type: "creationDispelled", caster: caster.id, what: "illusion wall" });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!state.createdEdges[key]) return "that is not a created thing";
|
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];
|
const was = view.edges[key];
|
||||||
delete state.edgeOverrides[key];
|
delete state.edgeOverrides[key];
|
||||||
delete state.createdEdges[key];
|
delete state.createdEdges[key];
|
||||||
@@ -1544,14 +1582,14 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
const creature = creatureAt(state, cmd.target.cell);
|
const creature = creatureAt(state, cmd.target.cell);
|
||||||
if (creature) {
|
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");
|
destroyCreature(state, events, creature, "dispel creation");
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
events.push({ type: "creationDispelled", caster: caster.id, what: creature.kind });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const content = state.squareContents[key];
|
const content = state.squareContents[key];
|
||||||
if (!content) return "nothing created there";
|
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];
|
delete state.squareContents[key];
|
||||||
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
events.push({ type: "creationDispelled", caster: caster.id, what: content.kind });
|
||||||
return null;
|
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);
|
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 || !target.alive) return "no such living player";
|
||||||
if (target.id === caster.id) return "you cannot drag yourself";
|
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";
|
if (isLockedInPlace(state, target.id)) return "they are locked in place";
|
||||||
const from = target.position;
|
const from = target.position;
|
||||||
dragToward(state, target, caster.position);
|
dragToward(state, target, caster.position);
|
||||||
@@ -1577,7 +1615,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
}
|
}
|
||||||
if (cmd.target?.kind === "cell") {
|
if (cmd.target?.kind === "cell") {
|
||||||
const key = cellKey(cmd.target.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 objects = state.groundObjects[key];
|
||||||
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
const treasure = state.treasures.find((t) => t.position && cellKey(t.position) === key);
|
||||||
if (objects && objects.length > 0) {
|
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 },
|
blind: { kind: "attack", requiresLos: true, baseDamage: () => 0, sustains: true },
|
||||||
"around-the-corner": {
|
"around-the-corner": {
|
||||||
kind: "neutral",
|
kind: "neutral",
|
||||||
// Never cast alone — attached to an attack via aroundCornerInstanceId.
|
// Never cast alone — attached to any L.O.S. cast via aroundCornerInstanceId.
|
||||||
resolve: () => "attach Around The Corner to an attack instead of casting it alone",
|
resolve: () => "attach Around The Corner to a line-of-sight spell instead of casting it alone",
|
||||||
},
|
},
|
||||||
ugly: {
|
ugly: {
|
||||||
kind: "neutral",
|
kind: "neutral",
|
||||||
@@ -1678,7 +1716,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
if ((view.edges[key] ?? "open") !== "open") return "there is already something in that wall line";
|
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 (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: {} };
|
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
|
||||||
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
|
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
|
||||||
return null;
|
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);
|
const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId);
|
||||||
if (!creature) return "no such monster";
|
if (!creature) return "no such monster";
|
||||||
if (creature.kind === "shadow" || creature.kind === "alter-ego") return "that is no 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";
|
const boost = cmd.params?.boost === "movement" ? "movement" : "life";
|
||||||
if (boost === "movement") creature.movesPerTurn *= 2;
|
if (boost === "movement") creature.movesPerTurn *= 2;
|
||||||
else creature.maxDamage *= 2;
|
else creature.maxDamage *= 2;
|
||||||
@@ -1841,7 +1879,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
const key = edgeKey(cell, side);
|
const key = edgeKey(cell, side);
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
if ((view.edges[key] ?? "open") !== "wall") return "that is not a wall";
|
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.tempWarpEdges.push({ key, prior: state.edgeOverrides[key] ?? null });
|
||||||
state.edgeOverrides[key] = "open";
|
state.edgeOverrides[key] = "open";
|
||||||
events.push({ type: "wallWarpedOpen", player: caster.id, edge: { cell, side } });
|
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") {
|
if ((view.edges[key] ?? "open") !== "open" && view.edges[key] !== "wall") {
|
||||||
return "a door goes into a stone wall or an open corridor";
|
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.edgeOverrides[key] = "door";
|
||||||
state.createdEdges[key] = true;
|
state.createdEdges[key] = true;
|
||||||
events.push({ type: "wallCreated", caster: caster.id, edge: { cell, side } });
|
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.groundObjects[key] ?? []).length > 0 ||
|
||||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
if (!hasObject) return "there is nothing there to glue down";
|
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;
|
state.gluedCells[key] = true;
|
||||||
// "a duration equal to twice the NUMBER card played"
|
// "a duration equal to twice the NUMBER card played"
|
||||||
const turns = magnitude.duration * 2;
|
const turns = magnitude.duration * 2;
|
||||||
@@ -1976,7 +2014,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
(state.groundObjects[key] ?? []).length > 0 ||
|
(state.groundObjects[key] ?? []).length > 0 ||
|
||||||
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
state.treasures.some((t) => t.position && cellKey(t.position) === key);
|
||||||
if (!hasObject) return "there is nothing there to lock up";
|
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 };
|
state.squareContents[key] = { kind: "safe", damage: 0, createdBy: caster.id };
|
||||||
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
events.push({ type: "safeCreated", caster: caster.id, at: cmd.target.cell });
|
||||||
return null;
|
return null;
|
||||||
@@ -1994,7 +2032,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (ka === kb) return "pick two different squares";
|
if (ka === kb) return "pick two different squares";
|
||||||
if (state.gluedCells[ka] || state.gluedCells[kb]) return "glue holds it fast";
|
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 (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 itemsA = state.groundObjects[ka] ?? [];
|
||||||
const itemsB = state.groundObjects[kb] ?? [];
|
const itemsB = state.groundObjects[kb] ?? [];
|
||||||
const treasureA = state.treasures.find((t) => t.position && cellKey(t.position) === ka);
|
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 current = view.edges[key];
|
||||||
const meltable = current === "wall" || current === "door";
|
const meltable = current === "wall" || current === "door";
|
||||||
if (!meltable) return "that is not a stone wall";
|
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";
|
state.edgeOverrides[key] = "open";
|
||||||
delete state.doorStates[key];
|
delete state.doorStates[key];
|
||||||
delete state.createdEdges[key];
|
delete state.createdEdges[key];
|
||||||
@@ -2057,7 +2095,7 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
|
|||||||
if (cmd.target?.kind === "cell") {
|
if (cmd.target?.kind === "cell") {
|
||||||
const key = cellKey(cmd.target.cell);
|
const key = cellKey(cmd.target.cell);
|
||||||
if (state.squareContents[key]?.kind !== "stone") return "that is not a solid stone block";
|
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];
|
delete state.squareContents[key];
|
||||||
events.push({ type: "stoneTurnedToWater", caster: caster.id, at: cmd.target.cell });
|
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."
|
// "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 aim = cmd.target.cell;
|
||||||
const view = boardView(state);
|
const view = boardView(state);
|
||||||
if (!view.cells[cellKey(aim)]) return "off the board";
|
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;
|
state.turn.attackUsed = true;
|
||||||
|
|
||||||
const clampToBoard = (c: Cell): Cell => {
|
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 (!view.cells[cellKey(at)]) return "off the board";
|
||||||
if (state.squareContents[cellKey(at)]) return "that square is blocked";
|
if (state.squareContents[cellKey(at)]) return "that square is blocked";
|
||||||
if (creatureAt(state, at)) return "a creature is already there";
|
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;
|
state.turn.attackUsed = true;
|
||||||
spawnCreature(state, events, kind, caster.id, at);
|
spawnCreature(state, events, kind, caster.id, at);
|
||||||
return null;
|
return null;
|
||||||
@@ -2810,7 +2848,7 @@ function emptySquareTarget(
|
|||||||
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
if (state.dimWarps.some((w) => cellKey(w.a) === key || cellKey(w.b) === key)) {
|
||||||
return "the warp shimmers there — nothing can form on it";
|
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;
|
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)) {
|
if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) {
|
||||||
return err("you must be in the same square");
|
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");
|
return err("no line of sight to the creature");
|
||||||
}
|
}
|
||||||
const wandEvents: GameEvent[] = [];
|
const wandEvents: GameEvent[] = [];
|
||||||
@@ -4943,7 +4983,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
if (cmd.target?.kind === "cell" &&
|
if (cmd.target?.kind === "cell" &&
|
||||||
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
|
state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") {
|
||||||
const cell = cmd.target.cell;
|
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");
|
return err("no line of sight to the slime");
|
||||||
}
|
}
|
||||||
const wandEvents: GameEvent[] = [];
|
const wandEvents: GameEvent[] = [];
|
||||||
@@ -5062,7 +5104,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
const preEvents: GameEvent[] = [];
|
const preEvents: GameEvent[] = [];
|
||||||
if (effect.requiresLos) {
|
if (effect.requiresLos) {
|
||||||
const sighted = mods.aroundCorner
|
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);
|
: casterLos(state, caster, caster.position, target.position);
|
||||||
if (!sighted) return err("no line of sight to the target");
|
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.
|
// 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",
|
type: "spellCast",
|
||||||
caster: caster.id,
|
caster: caster.id,
|
||||||
card: inHand,
|
card: inHand,
|
||||||
@@ -5193,7 +5237,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
|
|||||||
from: caster.position,
|
from: caster.position,
|
||||||
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
|
target: cmd.target?.kind === "player" ? cmd.target.playerId : null,
|
||||||
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
|
targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null,
|
||||||
}];
|
});
|
||||||
const preview = clone(state);
|
const preview = clone(state);
|
||||||
const problem = (effect as NeutralEffect).resolve(
|
const problem = (effect as NeutralEffect).resolve(
|
||||||
preview, [], activePlayer(preview), cmd, mods.magnitude,
|
preview, [], activePlayer(preview), cmd, mods.magnitude,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// The server sends this after every state change; clients never see the
|
// The server sends this after every state change; clients never see the
|
||||||
// deck order or other players' hands.
|
// 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 { cardDef, type CardInstance } from "./cards";
|
||||||
import {
|
import {
|
||||||
boardView,
|
boardView,
|
||||||
@@ -223,6 +223,32 @@ export function sightedCellsFor(view: GameView): Set<string> {
|
|||||||
return out;
|
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. */
|
/** 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> } {
|
function sightBasis(view: GameView): { board: GameView["board"]; blockers: Record<string, true> } {
|
||||||
// Held-open doors are open doorways to every eye. Beyond them, the
|
// 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.
|
* aid — mirroring the engine's own validation from the viewer's knowledge.
|
||||||
* Null = this card's eligibility is not modeled; light everything.
|
* 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);
|
const me = view.players.find((p) => p.id === view.you);
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
const cells = Object.keys(view.board.cells);
|
const cells = Object.keys(view.board.cells);
|
||||||
const key = (x: number, y: number) => `${x},${y}`;
|
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)) {
|
if (CREATION_CARD_IDS.has(cardId)) {
|
||||||
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
// emptySquareTarget: on the board, unoccupied by content, home, wizard,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
||||||
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
||||||
import type { CardInstance } from "../src/cards";
|
import type { CardInstance } from "../src/cards";
|
||||||
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
import { newGame, newExpansionGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
||||||
|
|
||||||
describe("around the corner", () => {
|
describe("around the corner", () => {
|
||||||
it("bends line of sight past a wall that blocks a straight cast", () => {
|
it("bends line of sight past a wall that blocks a straight cast", () => {
|
||||||
@@ -44,6 +44,130 @@ describe("around the corner", () => {
|
|||||||
state = must(state, defender.id, { type: "pass" });
|
state = must(state, defender.id, { type: "pass" });
|
||||||
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10);
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hairpins 180 degrees around a wall's end — the pivot sits in the gap", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const attacker = activePlayer(state);
|
||||||
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
||||||
|
const view0 = boardView(state);
|
||||||
|
// Carve a 2x2 block: attacker at A, defender at C directly north behind a
|
||||||
|
// wall that ends beside the B/D gap. C is sealed on every other side, so
|
||||||
|
// no cell-centered bend reaches it — only the pivot in the gap itself.
|
||||||
|
// +---+---+ C = target D = gap's far cell
|
||||||
|
// # C D # A = caster B = gap's near cell
|
||||||
|
// +###+ +
|
||||||
|
// A B
|
||||||
|
let A: Cell | null = null;
|
||||||
|
for (const k of Object.keys(view0.cells)) {
|
||||||
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
||||||
|
if (view0.cells[`${x + 1},${y}`] && view0.cells[`${x},${y - 1}`] && view0.cells[`${x + 1},${y - 1}`]) {
|
||||||
|
A = { x, y };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(A).not.toBeNull();
|
||||||
|
const B = { x: A!.x + 1, y: A!.y };
|
||||||
|
const C = { x: A!.x, y: A!.y - 1 };
|
||||||
|
const D = { x: A!.x + 1, y: A!.y - 1 };
|
||||||
|
state.edgeOverrides[edgeKey(A!, "N")] = "wall"; // the wall to double back around
|
||||||
|
state.edgeOverrides[edgeKey(A!, "E")] = "open";
|
||||||
|
state.edgeOverrides[edgeKey(B, "N")] = "open"; // the gap past the wall's end
|
||||||
|
state.edgeOverrides[edgeKey(C, "E")] = "open";
|
||||||
|
state.edgeOverrides[edgeKey(C, "N")] = "wall";
|
||||||
|
state.edgeOverrides[edgeKey(C, "W")] = "wall";
|
||||||
|
state.edgeOverrides[edgeKey(D, "E")] = "wall";
|
||||||
|
attacker.position = { ...A! };
|
||||||
|
defender.position = { ...C };
|
||||||
|
|
||||||
|
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
|
||||||
|
giveCard(state, attacker.id, "around-the-corner", "ATC", 1);
|
||||||
|
const straight = applyCommand(state, attacker.id, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
|
||||||
|
});
|
||||||
|
expect(straight.ok).toBe(false);
|
||||||
|
|
||||||
|
const lifeBefore = defender.life;
|
||||||
|
state = must(state, attacker.id, {
|
||||||
|
type: "cast", instanceId: fb.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||||
|
target: { kind: "player", playerId: defender.id },
|
||||||
|
});
|
||||||
|
state = must(state, defender.id, { type: "pass" });
|
||||||
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBeLessThan(lifeBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A cell out of straight sight but reachable with one bend, clear enough
|
||||||
|
// to create on: no contents, home, wizard, treasure, object, or warp.
|
||||||
|
function hiddenEmptyCell(state: ReturnType<typeof newGame>["state"]): Cell {
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
const view = boardView(state);
|
||||||
|
for (const key of Object.keys(view.cells)) {
|
||||||
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
||||||
|
const cell = { x, y };
|
||||||
|
if (gameLos(state, caster.position, cell, caster.id)) continue;
|
||||||
|
if (state.squareContents[key]) continue;
|
||||||
|
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
||||||
|
if (state.players.some((p) => p.alive && cellKey(p.position) === key)) continue;
|
||||||
|
if (state.treasures.some((t) => t.position && cellKey(t.position) === key)) continue;
|
||||||
|
if ((state.groundObjects[key] ?? []).length > 0) continue;
|
||||||
|
for (const midKey of Object.keys(view.cells)) {
|
||||||
|
const [mx, my] = midKey.split(",").map(Number) as [number, number];
|
||||||
|
const mid = { x: mx, y: my };
|
||||||
|
if (gameLos(state, caster.position, mid, caster.id) && gameLos(state, mid, cell, caster.id)) {
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("no hidden-but-bendable cell on this board");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("bends a neutral spell too — SAFE locks up a treasure around the corner", () => {
|
||||||
|
let { state } = newExpansionGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
const hidden = hiddenEmptyCell(state);
|
||||||
|
const gold = state.treasures.find((t) => t.position)!;
|
||||||
|
gold.position = { ...hidden };
|
||||||
|
|
||||||
|
const safe = giveCard(state, caster.id, "safe", "S", 0);
|
||||||
|
giveCard(state, caster.id, "around-the-corner", "ATC", 1);
|
||||||
|
const straight = applyCommand(state, caster.id, {
|
||||||
|
type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: hidden },
|
||||||
|
});
|
||||||
|
expect(straight.ok).toBe(false);
|
||||||
|
if (!straight.ok) expect(straight.error).toBe("no line of sight");
|
||||||
|
|
||||||
|
const bent = applyCommand(state, caster.id, {
|
||||||
|
type: "cast", instanceId: safe.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||||
|
target: { kind: "cell", cell: hidden },
|
||||||
|
});
|
||||||
|
if (!bent.ok) throw new Error(`bent SAFE refused: ${bent.error}`);
|
||||||
|
expect(bent.state.squareContents[cellKey(hidden)]?.kind).toBe("safe");
|
||||||
|
expect(bent.events.some((e) => e.type === "castAroundCorner")).toBe(true);
|
||||||
|
// Both cards spent: the bend is not free.
|
||||||
|
const after = bent.state.players.find((p) => p.id === caster.id)!;
|
||||||
|
expect(after.hand.some((c) => c?.cardId === "around-the-corner")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bends a creation — THORNBUSH grows on a square out of straight sight", () => {
|
||||||
|
let { state } = newGame();
|
||||||
|
state = toRound2(state);
|
||||||
|
const caster = activePlayer(state);
|
||||||
|
const hidden = hiddenEmptyCell(state);
|
||||||
|
|
||||||
|
const bush = giveCard(state, caster.id, "thornbush", "TB", 0);
|
||||||
|
giveCard(state, caster.id, "around-the-corner", "ATC", 1);
|
||||||
|
const straight = applyCommand(state, caster.id, {
|
||||||
|
type: "cast", instanceId: bush.instanceId, target: { kind: "cell", cell: hidden },
|
||||||
|
});
|
||||||
|
expect(straight.ok).toBe(false);
|
||||||
|
|
||||||
|
state = must(state, caster.id, {
|
||||||
|
type: "cast", instanceId: bush.instanceId, aroundCornerInstanceId: "around-the-corner#ATC",
|
||||||
|
target: { kind: "cell", cell: hidden },
|
||||||
|
});
|
||||||
|
expect(state.squareContents[cellKey(hidden)]?.kind).toBe("thornbush");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("blind", () => {
|
describe("blind", () => {
|
||||||
|
|||||||
@@ -14,7 +14,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, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
import { allCardDefs, cardDef, dreadDistance, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, bentSightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||||
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
|
import type { CardInstance, GameEvent, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
net.connect();
|
net.connect();
|
||||||
@@ -1172,8 +1172,9 @@
|
|||||||
if (!view || !selectedDef || !yourMoment) return null;
|
if (!view || !selectedDef || !yourMoment) return null;
|
||||||
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
||||||
if (!me) return null;
|
if (!me) return null;
|
||||||
|
const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
|
||||||
if (CELL_CARDS.has(selectedCard!.cardId)) {
|
if (CELL_CARDS.has(selectedCard!.cardId)) {
|
||||||
return eligibleCellsFor(view, selectedCard!.cardId);
|
return eligibleCellsFor(view, selectedCard!.cardId, bent);
|
||||||
}
|
}
|
||||||
if (selectedDef.adjacent === true) {
|
if (selectedDef.adjacent === true) {
|
||||||
const { x, y } = me.position;
|
const { x, y } = me.position;
|
||||||
@@ -1184,19 +1185,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (selectedDef.los !== true) return null;
|
if (selectedDef.los !== true) return null;
|
||||||
const sighted = sightedCellsFor(view);
|
return bent ? bentSightedCellsFor(view) : sightedCellsFor(view);
|
||||||
if (attachedMods.some((m) => m.cardId === "around-the-corner")) {
|
|
||||||
const widened = new Set(sighted);
|
|
||||||
for (const key of Object.keys(view.board.cells)) {
|
|
||||||
if (widened.has(key)) continue;
|
|
||||||
const [cx, cy] = key.split(",").map(Number);
|
|
||||||
if ([[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => sighted.has(`${cx! + dx!},${cy! + dy!}`))) {
|
|
||||||
widened.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return widened;
|
|
||||||
}
|
|
||||||
return sighted;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
|
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
|
||||||
|
|||||||
Reference in New Issue
Block a user