Final wave: complete the 6th edition basic set (69/69 cards)

The hard six: AROUND THE CORNER attaches to any LOS attack and bends
the sight line through one intermediate cell. BLIND victims lurch in
die-rolled directions (bumping a wall costs the movement point, per
the card) and their attacks fly wherever the die says — hitting
whoever stands that way, or dissipating. UGLY drives every opponent
in sight fleeing along shortest paths (breadth-first, die-broken ties)
until they cannot see the caster. ILLUSION WALL is per-player reality:
each opponent rolls 50/50 the first time it matters and the wall is
real for believers forever — believers' views render it as a wall,
the caster and those who saw through it get a ghostly dashed line,
and Dispel Creation banishes it. ROTATE SECTOR turns a sector 90
degrees with every wall, door, wizard, treasure, object, firewall and
alteration turning in place (the home star, being the exact center,
never moves); RELOCATE SECTOR slides a sector anywhere that keeps all
sectors adjacent, reassembling the map and recomputing wraparounds.

Client: modifier attachment (Amplify/Add/Extend/Around The Corner),
two-stage relocate, rotation direction toggle, dashed known-illusions.
Every card in the 6th edition basic deck is now implemented.
81 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 20:29:09 -04:00
co-authored by Claude Fable 5
parent 4ab44cc535
commit 1924114b6d
7 changed files with 844 additions and 21 deletions
+442 -17
View File
@@ -17,6 +17,7 @@ import {
type EdgeState,
type Side,
SIDES,
assembleBoard,
cellKey,
edgeKey,
hasLineOfSight,
@@ -123,6 +124,7 @@ export interface CastParams {
cell?: Cell;
cardId?: string;
points?: number;
clockwise?: boolean;
}
export interface GameConfig {
@@ -149,6 +151,8 @@ export interface GameState {
groundObjects: Record<string, CardInstance[]>;
/** The last spell card each player used (for REUSE SPELL). */
lastSpellUsed: Record<PlayerId, string>;
/** ILLUSION WALLs by edge key: real only for those who believe. */
illusionWalls: Record<string, { createdBy: PlayerId; belief: Record<PlayerId, "believes" | "seesThrough"> }>;
players: PlayerState[];
treasures: TreasureState[];
sustained: SustainedEffect[];
@@ -180,22 +184,105 @@ export function gameLos(state: GameState, from: Cell, to: Cell): boolean {
return hasLineOfSight(boardView(state), from, to, blockers);
}
/** Parse an edge key back into its north/west cell and side. */
function parseEdgeKey(key: string): { cell: Cell; side: Side } {
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
return { cell: { x, y }, side: kind === "V" ? "E" : "S" };
}
/**
* What does this player believe about an illusion wall? Rolls the 50% chance
* lazily the first time it matters ("when they gain L.O.S. to it").
*/
function illusionBelief(
state: GameState,
events: GameEvent[],
playerId: PlayerId,
key: string,
): "believes" | "seesThrough" {
const wall = state.illusionWalls[key]!;
if (wall.createdBy === playerId) return "seesThrough";
const known = wall.belief[playerId];
if (known) return known;
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
const result = roll <= 2 ? "seesThrough" : "believes";
wall.belief[playerId] = result;
events.push({ type: "illusionTested", player: playerId, edge: key, result });
return result;
}
/** The board as one player perceives it: believed illusions become walls. */
function perceivedBoard(
state: GameState,
events: GameEvent[],
viewerId: PlayerId,
sightLine?: { from: Cell; to: Cell },
): AssembledBoard {
const view = boardView(state);
const keys = Object.keys(state.illusionWalls);
if (keys.length === 0) return view;
const edges = { ...view.edges };
for (const key of keys) {
const known = state.illusionWalls[key]!.belief[viewerId];
const isCreator = state.illusionWalls[key]!.createdBy === viewerId;
if (isCreator || known === "seesThrough") continue;
if (known === "believes") {
edges[key] = "wall";
continue;
}
// Untested: only roll if this sight line would actually cross it.
if (sightLine) {
const { cell, side } = parseEdgeKey(key);
const test = { ...view, edges: { [key]: "wall" as const } };
void cell; void side;
const crossesIt = !hasLineOfSight(test, sightLine.from, sightLine.to);
if (crossesIt) {
if (illusionBelief(state, events, viewerId, key) === "believes") edges[key] = "wall";
}
} else {
edges[key] = "wall"; // no sight context: treat as real until tested
}
}
return { ...view, edges };
}
/**
* LOS for a caster: VISIONSTONE lets its holder see through exactly one
* wall or door (of any type), at their option.
* wall or door (of any type); believed ILLUSION WALLs block them.
*/
function casterLos(state: GameState, caster: PlayerState, from: Cell, to: Cell): boolean {
if (gameLos(state, from, to)) return true;
if (!displays(caster, "visionstone")) return false;
// Try ignoring each single blocking edge in turn.
const view = boardView(state);
function casterLos(
state: GameState,
caster: PlayerState,
from: Cell,
to: Cell,
events: GameEvent[] = [],
): boolean {
const board = perceivedBoard(state, events, caster.id, { from, to });
const blockers: Record<string, true> = {};
for (const key of Object.keys(state.squareContents)) blockers[key] = true;
for (const key of Object.keys(view.edges)) {
if ((view.edges[key] ?? "open") === "open") continue;
const edges = { ...view.edges };
if (hasLineOfSight(board, from, to, blockers)) return true;
if (!displays(caster, "visionstone")) return false;
for (const key of Object.keys(board.edges)) {
if ((board.edges[key] ?? "open") === "open") continue;
const edges = { ...board.edges };
delete edges[key];
if (hasLineOfSight({ ...view, edges }, from, to, blockers)) return true;
if (hasLineOfSight({ ...board, edges }, from, to, blockers)) return true;
}
return false;
}
/** 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 {
if (casterLos(state, caster, from, to, events)) return true;
const view = boardView(state);
for (const key of Object.keys(view.cells)) {
const [mx, my] = key.split(",").map(Number) as [number, number];
const mid = { x: mx, y: my };
if (casterLos(state, caster, from, mid, events) && casterLos(state, caster, mid, to, events)) {
return true;
}
}
return false;
}
@@ -271,6 +358,14 @@ export type GameEvent =
| { type: "objectPickedUp"; player: PlayerId; card: CardInstance; at: Cell }
| { type: "objectDragged"; caster: PlayerId; what: string; from: Cell; to: Cell }
| { type: "spellReused"; player: PlayerId; card: CardInstance }
| { type: "castAroundCorner"; caster: PlayerId }
| { type: "moveBumped"; player: PlayerId; direction: Side }
| { type: "attackMisdirected"; attacker: PlayerId; intended: PlayerId; rolledDirection: Side; newTarget: PlayerId | null }
| { type: "retreatedInHorror"; player: PlayerId; from: Cell; to: Cell }
| { type: "illusionWallCreated"; caster: PlayerId; edge: { cell: Cell; side: Side } }
| { type: "illusionTested"; player: PlayerId; edge: string; result: "believes" | "seesThrough" }
| { type: "sectorRotated"; caster: PlayerId; sectorIndex: number; clockwise: boolean }
| { type: "sectorRelocated"; caster: PlayerId; sectorIndex: number; from: Cell; to: Cell }
| { type: "wallDestroyed"; caster: PlayerId; edge: { cell: Cell; side: Side }; wasDoor: boolean }
| { type: "doorUnlocked"; player: PlayerId; edge: { cell: Cell; side: Side }; withCardId: string }
| { type: "doorsRelocked"; count: number }
@@ -323,6 +418,8 @@ export type Command =
addInstanceId?: string;
/** EXTEND card attached (doubles duration). */
extendInstanceId?: string;
/** AROUND THE CORNER card attached (bends this cast's line of sight). */
aroundCornerInstanceId?: string;
target?: CastTarget;
params?: CastParams;
}
@@ -961,6 +1058,12 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
const view = boardView(state);
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";
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";
const was = view.edges[key];
@@ -1054,6 +1157,80 @@ const CARD_EFFECTS: Record<string, AttackEffect | NeutralEffect | CounterEffect>
attachSustained(ctx.state, ctx.events, "slow-death", ctx.attacker.id, ctx.defender.id, 1_000_000_000);
},
},
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",
},
ugly: {
kind: "neutral",
// "All opponents in L.O.S. retreat as far away as necessary to avoid
// L.O.S., along the shortest path available."
resolve: (state, events, caster) => {
for (const opp of state.players) {
if (!opp.alive || opp.id === caster.id) continue;
if (!gameLos(state, caster.position, opp.position)) continue;
if (isLockedInPlace(state, opp.id) || sustainedOn(state, opp.id, "medusa").length > 0) continue;
retreatFromSight(state, events, opp, caster.position);
}
return null;
},
},
"illusion-wall": {
kind: "neutral",
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "edge") return "illusion wall targets a wall edge";
const { cell, side } = cmd.target;
const view = boardView(state);
if (!view.cells[cellKey(cell)] || !view.cells[cellKey(neighbor(cell, side))]) {
return "the illusion must span two spaces on the board";
}
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";
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
events.push({ type: "illusionWallCreated", caster: caster.id, edge: { cell, side } });
return null;
},
},
"rotate-sector": {
kind: "neutral",
// "Allows you to rotate any one sector 90 degrees."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "cell") return "click a square in the sector to rotate";
const idx = sectorIndexAt(state.board, cmd.target.cell);
if (idx === -1) return "that is not on a sector";
rotateSector(state, idx, cmd.params?.clockwise ?? true);
events.push({ type: "sectorRotated", caster: caster.id, sectorIndex: idx, clockwise: cmd.params?.clockwise ?? true });
return null;
},
},
"relocate-sector": {
kind: "neutral",
// "Relocate (but not rotate) any one sector to any other area, so long as
// all sectors are still adjacent to at least one other."
resolve: (state, events, caster, cmd) => {
if (!cmd.target || cmd.target.kind !== "cell") return "click the destination area";
const fromCell = cmd.params?.cell;
if (!fromCell) return "pick the sector to move first";
const idx = sectorIndexAt(state.board, fromCell);
if (idx === -1) return "that is not on a sector";
const dest: Cell = {
x: Math.floor(cmd.target.cell.x / 5) * 5,
y: Math.floor(cmd.target.cell.y / 5) * 5,
};
const fromOrigin = { ...state.board.placements[idx]!.origin };
const problem = relocateSector(state, idx, dest);
if (problem) return problem;
events.push({
type: "sectorRelocated", caster: caster.id, sectorIndex: idx,
from: fromOrigin, to: dest,
});
return null;
},
},
"reuse-spell": {
kind: "neutral",
// "You may retrieve any spell you use immediately after you use it (but
@@ -1142,6 +1319,150 @@ function washBack(state: GameState, events: GameEvent[], p: PlayerState, dir: Si
}
}
/** UGLY: breadth-first flee to the nearest cell out of the horror's sight. */
function retreatFromSight(state: GameState, events: GameEvent[], p: PlayerState, horror: Cell): void {
const view = boardView(state);
const start = p.position;
const seen = new Set<string>([cellKey(start)]);
let frontier: Cell[] = [start];
const safeAt = (c: Cell) => !gameLos(state, horror, c);
for (let depth = 0; depth < 60 && frontier.length > 0; depth++) {
const safe = frontier.filter(safeAt);
if (safe.length > 0) {
// Multiple equally short refuges: the die decides.
let choice = safe[0]!;
if (safe.length > 1) {
const [i, rngNext] = nextInt(state.rng, safe.length);
state.rng = rngNext;
choice = safe[i]!;
}
const from = p.position;
p.position = choice;
events.push({ type: "retreatedInHorror", player: p.id, from, to: choice });
return;
}
const next: Cell[] = [];
for (const c of frontier) {
for (const side of SIDES) {
const step = stepTarget(view, c, side);
if (step.kind === "blocked") continue;
if (state.squareContents[cellKey(step.to)]?.kind === "stone") continue;
if (seen.has(cellKey(step.to))) continue;
seen.add(cellKey(step.to));
next.push(step.to);
}
}
frontier = next;
}
// Nowhere to hide: they cower where they stand.
}
/** Which placement contains this cell? */
function sectorIndexAt(board: AssembledBoard, cell: Cell): number {
return board.placements.findIndex(
(p) => cell.x >= p.origin.x && cell.x < p.origin.x + 5 && cell.y >= p.origin.y && cell.y < p.origin.y + 5,
);
}
/** Remap every piece of coordinate-keyed state through cell/side transforms. */
function remapState(
state: GameState,
inSector: (c: Cell) => boolean,
mapCell: (c: Cell) => Cell,
mapSide: (s: Side) => Side,
): void {
const mapEdgeKey = (key: string): string => {
const { cell, side } = parseEdgeKey(key);
const other = neighbor(cell, side);
if (!inSector(cell) || !inSector(other)) return key; // boundary: leave
return edgeKey(mapCell(cell), mapSide(side));
};
const remapRecord = <T,>(rec: Record<string, T>, mapKey: (k: string) => string): Record<string, T> =>
Object.fromEntries(Object.entries(rec).map(([k, v]) => [mapKey(k), v]));
const mapCellKey = (key: string): string => {
const [x, y] = key.split(",").map(Number) as [number, number];
const c = { x, y };
return inSector(c) ? cellKey(mapCell(c)) : key;
};
state.edgeOverrides = remapRecord(state.edgeOverrides, mapEdgeKey);
state.createdEdges = remapRecord(state.createdEdges, mapEdgeKey);
state.doorStates = remapRecord(state.doorStates, mapEdgeKey);
state.illusionWalls = remapRecord(state.illusionWalls, mapEdgeKey);
state.openDoorEdges = state.openDoorEdges.map(mapEdgeKey);
state.squareContents = remapRecord(state.squareContents, mapCellKey);
state.groundObjects = remapRecord(state.groundObjects, mapCellKey);
for (const fx of state.sustained) {
if (fx.edge) fx.edge = mapEdgeKey(fx.edge);
}
for (const p of state.players) {
if (inSector(p.position)) p.position = mapCell(p.position);
if (inSector(p.home)) p.home = mapCell(p.home);
}
for (const t of state.treasures) {
if (t.position && inSector(t.position)) t.position = mapCell(t.position);
}
}
/** ROTATE SECTOR: 90 degrees, pieces and alterations turning with it. */
function rotateSector(state: GameState, index: number, clockwise: boolean): void {
const placement = state.board.placements[index]!;
const { x: ox, y: oy } = placement.origin;
const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5;
const mapCell = (c: Cell): Cell => {
const lx = c.x - ox, ly = c.y - oy;
return clockwise
? { x: ox + (4 - ly), y: oy + lx }
: { x: ox + ly, y: oy + (4 - lx) };
};
const order: Side[] = ["N", "E", "S", "W"];
const mapSide = (s: Side): Side => order[(order.indexOf(s) + (clockwise ? 1 : 3)) % 4]!;
const newPlacements = state.board.placements.map((p, i) =>
i === index
? { ...p, rotation: (((p.rotation + (clockwise ? 90 : 270)) % 360) as 0 | 90 | 180 | 270) }
: p,
);
const oldWarps = state.board.warps;
const rebuilt = assembleBoard(newPlacements);
rebuilt.warps = oldWarps; // openings are centered: rotation never moves them
state.board = rebuilt;
remapState(state, inSector, mapCell, mapSide);
}
/** RELOCATE SECTOR: slide a sector to a new area; wraparounds recompute. */
function relocateSector(state: GameState, index: number, dest: Cell): string | null {
const placements = state.board.placements;
const current = placements[index]!.origin;
if (dest.x === current.x && dest.y === current.y) return "the sector is already there";
if (dest.x < 0 || dest.y < 0) return "the sector cannot go there";
for (let i = 0; i < placements.length; i++) {
if (i === index) continue;
const o = placements[i]!.origin;
if (o.x === dest.x && o.y === dest.y) return "another sector is there";
}
// "All sectors still adjacent to at least one other."
const origins = placements.map((p, i) => (i === index ? dest : p.origin));
const adjacent = (a: Cell, b: Cell) =>
(Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x);
for (let i = 0; i < origins.length; i++) {
if (!origins.some((o, j) => j !== i && adjacent(origins[i]!, o))) {
return "every sector must stay adjacent to at least one other";
}
}
const { x: ox, y: oy } = current;
const inSector = (c: Cell) => c.x >= ox && c.x < ox + 5 && c.y >= oy && c.y < oy + 5;
const dx = dest.x - ox, dy = dest.y - oy;
const mapCell = (c: Cell): Cell => ({ x: c.x + dx, y: c.y + dy });
const newPlacements = placements.map((p, i) => (i === index ? { ...p, origin: dest } : p));
// "Only opposite board edges connect" after a relocation: default pairings.
state.board = assembleBoard(newPlacements);
remapState(state, inSector, mapCell, (s) => s);
return null;
}
/** DRAG a player straight toward the caster, stopping at walls. */
function dragToward(state: GameState, target: PlayerState, dest: Cell): void {
const view = boardView(state);
@@ -1334,6 +1655,7 @@ export function createGame(config: GameConfig): { state: GameState; events: Game
squareContents: {},
groundObjects: {},
lastSpellUsed: {},
illusionWalls: {},
players,
treasures,
sustained: [],
@@ -1443,9 +1765,32 @@ function doMove(prev: GameState, direction: Side): CommandResult {
const state = clone(prev);
const p = activePlayer(state);
const events: GameEvent[] = [];
// BLIND: "must roll direction on D4 if attempting to move ... bumping into
// a wall counts as one space of movement. Reroll for each movement point."
if (sustainedOn(state, p.id, "blind").length > 0) {
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
direction = SIDES[roll - 1]!;
}
// A believed (or untested, when bumped) ILLUSION WALL blocks the believer.
{
const key = edgeKey(p.position, direction);
if (state.illusionWalls[key] &&
illusionBelief(state, events, p.id, key) === "believes") {
if (sustainedOn(state, p.id, "blind").length > 0) {
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
}
return err("blocked by wall");
}
}
const view = boardView(state);
const target = stepTarget(view, p.position, direction);
const events: GameEvent[] = [];
const misted = isMisted(state, p.id);
const from = p.position;
@@ -1455,7 +1800,14 @@ function doMove(prev: GameState, direction: Side): CommandResult {
const key = edgeKey(p.position, direction);
const edge = view.edges[key] ?? "open";
const dest = neighbor(p.position, direction);
if (!view.cells[cellKey(dest)]) return err("blocked");
if (!view.cells[cellKey(dest)]) {
if (sustainedOn(state, p.id, "blind").length > 0) {
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
}
return err("blocked");
}
if (edge === "door" && (state.doorStates[key] === "removed" || state.openDoorEdges.includes(key))) {
p.position = dest;
via = "step";
@@ -1473,6 +1825,11 @@ function doMove(prev: GameState, direction: Side): CommandResult {
p.passWallCharges--;
p.position = dest;
via = "passWall";
} else if (sustainedOn(state, p.id, "blind").length > 0) {
// Blind bump: the wasted lurch costs a movement point.
state.turn.movementUsed++;
events.push({ type: "moveBumped", player: p.id, direction });
return { ok: true, state, events };
} else {
return err(`blocked by ${target.by}`);
}
@@ -1613,6 +1970,7 @@ interface CastConsumables {
amplifies: CardInstance[];
add: CardInstance | null;
extend: CardInstance | null;
aroundCorner: CardInstance | null;
magnitude: Magnitude;
}
@@ -1661,6 +2019,13 @@ function gatherModifiers(
extend = c;
}
let aroundCorner: CardInstance | null = null;
if (cmd.aroundCornerInstanceId) {
const c = find(cmd.aroundCornerInstanceId);
if (!c || c.cardId !== "around-the-corner") return "AROUND THE CORNER card not in hand";
aroundCorner = c;
}
// POWERSTONE: "Add 1 to any NUMBER card played."
const stoneBonus = displays(caster, "powerstone") ? numbers.length : 0;
const sum = numbers.length > 0
@@ -1673,6 +2038,7 @@ function gatherModifiers(
amplifies,
add,
extend,
aroundCorner,
magnitude: {
numberValue: sum,
power: (sum ?? 1) * amp,
@@ -1694,7 +2060,7 @@ function consumeCast(
takeFromHand(caster, card.instanceId);
state.discard.push(card);
}
for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend]) {
for (const c of [...mods.numbers, ...mods.amplifies, mods.add, mods.extend, mods.aroundCorner]) {
if (!c) continue;
takeFromHand(caster, c.instanceId);
state.discard.push(c);
@@ -1745,8 +2111,12 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
}
const statusBlock = attackBlockedByStatus(state, caster, target);
if (statusBlock) return err(statusBlock);
if (effect.requiresLos && !casterLos(state, caster, caster.position, target.position)) {
return err("no line of sight to the target");
const preEvents: GameEvent[] = [];
if (effect.requiresLos) {
const sighted = mods.aroundCorner
? bentLos(state, caster, caster.position, target.position, preEvents)
: casterLos(state, caster, caster.position, target.position, preEvents);
if (!sighted) return err("no line of sight to the target");
}
// Attacking someone breaks any BUDDY pact you swore to them.
state.sustained = state.sustained.filter(
@@ -1765,6 +2135,59 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
}
}
// BLIND: casts at others fly in a rolled direction. "Misdirected spells
// go intended distance" — if the die disagrees with the true direction,
// the spell hits whoever lies that way, or dissipates.
let actualTarget = target;
if (sustainedOn(state, caster.id, "blind").length > 0 &&
cellKey(target.position) !== cellKey(caster.position)) {
const dx = target.position.x - caster.position.x;
const dy = target.position.y - caster.position.y;
const intended: Side =
Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
const [roll, rngNext] = rollDie(state.rng);
state.rng = rngNext;
const rolled = SIDES[roll - 1]!;
if (rolled !== intended) {
const along = state.players.find((p) => {
if (!p.alive || p.id === caster.id) return false;
const px = p.position.x - caster.position.x;
const py = p.position.y - caster.position.y;
const dirOf: Side | null =
Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null;
return dirOf === rolled && casterLos(state, caster, caster.position, p.position);
});
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
state.turn.attackUsed = true;
state.lastSpellUsed[caster.id] = inHand.cardId;
const missEvents: GameEvent[] = [...preEvents, {
type: "attackMisdirected", attacker: caster.id, intended: target.id,
rolledDirection: rolled, newTarget: along?.id ?? null,
}];
if (!along) return { ok: true, state, events: missEvents }; // dissipates
actualTarget = along;
state.stack = {
attackerId: caster.id,
defenderId: along.id,
attackCard: inHand,
numberValue: mods.magnitude.numberValue,
amplifyFactor: 2 ** mods.amplifies.length,
extendFactor: mods.extend ? 2 : 1,
params: cmd.params ?? null,
kind: effect.physical ? "physical" : "spell",
counters: [],
waitingOn: along.id,
};
missEvents.push({
type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId,
numberCards: mods.numbers, numberValue: mods.magnitude.numberValue,
from: caster.position, target: along.id, targetCell: along.position,
});
return { ok: true, state, events: missEvents };
}
}
void actualTarget;
consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false);
state.turn.attackUsed = true;
state.stack = {
@@ -1780,7 +2203,9 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
waitingOn: target.id,
};
state.lastSpellUsed[caster.id] = inHand.cardId;
const events: GameEvent[] = [{
const events: GameEvent[] = [...preEvents];
if (mods.aroundCorner) events.push({ type: "castAroundCorner", caster: caster.id });
events.push({
type: "spellCast",
caster: caster.id,
card: inHand,
@@ -1790,7 +2215,7 @@ function doCast(prev: GameState, cmd: Extract<Command, { type: "cast" }>): Comma
from: caster.position,
target: target.id,
targetCell: target.position,
}];
});
if (effect.keepInHand) {
events.push({ type: "cardDisplayed", player: caster.id, card: inHand });
}