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 });
}
+18 -1
View File
@@ -50,17 +50,33 @@ export interface GameView {
groundObjects: Record<string, CardInstance[]>;
doorStates: Record<string, "jammed" | "removed">;
openDoorEdges: string[];
/** Illusion edges YOU know are fake (creator or saw through); others see walls. */
knownIllusionEdges: string[];
}
export function viewFor(state: GameState, playerId: PlayerId): GameView {
const you = state.players.find((p) => p.id === playerId);
// Illusion walls render as real walls unless this viewer knows better.
const base = boardView(state);
const knownIllusionEdges: string[] = [];
let edges = base.edges;
for (const [key, wall] of Object.entries(state.illusionWalls)) {
const knows = wall.createdBy === playerId || wall.belief[playerId] === "seesThrough";
if (knows) {
knownIllusionEdges.push(key);
} else {
if (edges === base.edges) edges = { ...base.edges };
edges[key] = "wall";
}
}
const board = edges === base.edges ? base : { ...base, edges };
return {
you: playerId,
phase: state.phase,
winner: state.winner,
turn: state.turn,
activePlayerId: state.players[state.turn.activeIndex]!.id,
board: boardView(state),
board,
players: state.players.map((p) => ({
id: p.id,
position: p.position,
@@ -86,5 +102,6 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
),
doorStates: { ...state.doorStates },
openDoorEdges: [...state.openDoorEdges],
knownIllusionEdges,
};
}
+1 -1
View File
@@ -292,7 +292,7 @@ describe("stack discipline", () => {
it("unimplemented cards refuse to cast with a clear error", () => {
let { state } = newGame();
const caster = activePlayer(state);
const card = giveCard(state, caster.id, "ugly");
const card = giveCard(state, caster.id, "chaos"); // expansion1, unimplemented
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/not implemented/);
+295
View File
@@ -0,0 +1,295 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
boardView,
gameLos,
sustainedOn,
viewFor,
type Command,
type GameState,
type PlayerId,
} from "../src";
import { cellKey, edgeKey, neighbor, SIDES, stepTarget, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
function must(state: GameState, player: PlayerId, command: Command): GameState {
const result = applyCommand(state, player, command);
if (!result.ok) throw new Error(`command failed: ${result.error}`);
return result.state;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
function toRound2(state: GameState): GameState {
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
return state;
}
function emptyNeighborCell(state: GameState, of: Cell): { cell: Cell; side: Side } {
const view = boardView(state);
for (const side of SIDES) {
const t = stepTarget(view, of, side);
if (t.kind !== "step") continue;
const key = cellKey(t.to);
if (view.homes.some((h) => cellKey(h) === key)) continue;
if (state.treasures.some((tr) => tr.position && cellKey(tr.position) === key)) continue;
if (state.players.some((p) => cellKey(p.position) === key)) continue;
return { cell: t.to, side };
}
throw new Error("no empty neighbor");
}
describe("around the corner", () => {
it("bends line of sight past a wall that blocks a straight cast", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Find a hidden cell (no direct LOS) that IS reachable with one bend.
const view = boardView(state);
let hidden: Cell | null = null;
outer: 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, attacker.position, cell)) 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, attacker.position, mid) && gameLos(state, mid, cell)) {
hidden = cell;
break outer;
}
}
}
expect(hidden).not.toBeNull();
defender.position = hidden!;
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
giveCard(state, attacker.id, "around-the-corner", "ATC", 1);
// Straight cast: refused.
const straight = applyCommand(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
});
expect(straight.ok).toBe(false);
// Bent cast: lands.
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).toBe(10);
});
});
describe("blind", () => {
it("blinded movement lurches in rolled directions and bumps cost movement", () => {
let { state } = newGame(7);
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
const bl = giveCard(state, attacker.id, "blind");
giveCard(state, attacker.id, "number-3", "N", 1);
state = must(state, attacker.id, {
type: "cast", instanceId: bl.instanceId, numberInstanceIds: ["number-3#N"],
target: { kind: "player", playerId: defender.id },
});
state = must(state, defender.id, { type: "pass" });
expect(sustainedOn(state, defender.id, "blind").length).toBe(1);
state = must(state, attacker.id, { type: "endTurn", draw: 0 });
// Every move consumes exactly one movement point whether it lands or bumps.
const before = state.turn.movementUsed;
state = must(state, defender.id, { type: "move", direction: "N" });
expect(state.turn.movementUsed).toBe(before + 1);
});
it("blinded casts fly in a rolled direction and can miss entirely", () => {
// Across seeds: a blinded caster aiming at a real target sometimes hits
// (roll matches), usually misses (attack dissipates, card still spent).
let hits = 0, misses = 0;
for (let seed = 1; seed <= 10; seed++) {
let { state } = newGame(seed);
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Stand the defender one cell east-ish with LOS.
const spot = emptyNeighborCell(state, attacker.position);
defender.position = spot.cell;
state.sustained.push({
id: "fx-test", cardId: "blind", casterId: defender.id,
targetId: attacker.id, remainingTurns: 3, data: {},
});
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
const result = applyCommand(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
});
expect(result.ok).toBe(true);
if (!result.ok) continue;
const dLife = result.state.players.find((p) => p.id === defender.id)!;
if (result.state.stack) {
hits++; // the roll matched: attack proceeds normally
} else {
misses++;
expect(result.state.turn.attackUsed).toBe(true); // card spent anyway
}
void dLife;
}
expect(hits + misses).toBe(10);
expect(misses).toBeGreaterThan(0);
});
});
describe("ugly", () => {
it("drives every visible opponent out of line of sight", () => {
let { state } = newGame();
const caster = activePlayer(state);
const opp = state.players.find((p) => p.id !== caster.id)!;
opp.position = { ...caster.position }; // same square: definitely in LOS
const ug = giveCard(state, caster.id, "ugly");
state = must(state, caster.id, { type: "cast", instanceId: ug.instanceId });
const after = state.players.find((p) => p.id === opp.id)!;
expect(gameLos(state, state.players.find((p) => p.id === caster.id)!.position, after.position)).toBe(false);
});
});
describe("illusion wall", () => {
function setupIllusion(seed = 42) {
let { state } = newGame(seed);
const caster = activePlayer(state);
const spot = emptyNeighborCell(state, caster.position);
const key = edgeKey(caster.position, spot.side);
const iw = giveCard(state, caster.id, "illusion-wall");
state = must(state, caster.id, {
type: "cast", instanceId: iw.instanceId,
target: { kind: "edge", cell: caster.position, side: spot.side },
});
return { state, caster: caster.id, key, side: spot.side, cell: spot.cell };
}
it("the caster walks through their own illusion; others see a wall", () => {
const { state, caster, key, side } = setupIllusion();
// Caster's view knows it's fake; the opponent's view shows a wall.
const casterView = viewFor(state, caster);
expect(casterView.knownIllusionEdges).toContain(key);
const other = state.players.find((p) => p.id !== caster)!.id;
const otherView = viewFor(state, other);
expect(otherView.board.edges[key]).toBe("wall");
// And the caster can move through it freely.
const after = applyCommand(state, caster, { type: "move", direction: side });
expect(after.ok).toBe(true);
});
it("opponents test the illusion when they bump it — some see through, some believe", () => {
let believed = 0, sawThrough = 0;
for (let seed = 1; seed <= 12; seed++) {
const { state, caster, side } = setupIllusion(seed);
const other = state.players.find((p) => p.id !== caster)!;
other.position = { ...state.players.find((p) => p.id === caster)!.position };
let s = must(state, caster, { type: "endTurn", draw: 0 });
const result = applyCommand(s, other.id, { type: "move", direction: side });
if (result.ok) sawThrough++;
else believed++;
}
expect(believed + sawThrough).toBe(12);
expect(believed).toBeGreaterThan(0);
expect(sawThrough).toBeGreaterThan(0);
});
it("dispel creation removes an illusion wall", () => {
let { state, caster, key, side } = setupIllusion();
const me = state.players.find((p) => p.id === caster)!;
const dc = giveCard(state, caster, "dispel-creation", "DC", 1);
state = must(state, caster, {
type: "cast", instanceId: dc.instanceId,
target: { kind: "edge", cell: me.position, side },
});
expect(state.illusionWalls[key]).toBeUndefined();
});
});
describe("sector manipulation", () => {
it("rotate sector turns walls and pieces together; the home stays centered", () => {
let { state } = newGame();
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.x >= p.origin.x && me.position.x < p.origin.x + 5 &&
me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const wallsBefore = Object.values(boardView(state).edges).filter((e) => e === "wall").length;
const homeBefore = { ...me.home };
const treasuresBefore = state.treasures.filter((t) => t.owner === me.id).map((t) => ({ ...t.position! }));
const rs = giveCard(state, me.id, "rotate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rs.instanceId,
target: { kind: "cell", cell: me.position }, params: { clockwise: true },
});
const after = state.players.find((p) => p.id === me.id)!;
// Home star is the exact center: rotation cannot move it.
expect(cellKey(after.home)).toBe(cellKey(homeBefore));
// The wizard stood on the home (center) at setup? They may have been
// anywhere; either way they remain inside the same sector.
const p = state.board.placements[idx]!;
expect(after.position.x).toBeGreaterThanOrEqual(p.origin.x);
expect(after.position.x).toBeLessThan(p.origin.x + 5);
// Wall count is invariant under rotation.
const wallsAfter = Object.values(boardView(state).edges).filter((e) => e === "wall").length;
expect(wallsAfter).toBe(wallsBefore);
// Treasures rotated with the sector (diagonal flips to the other diagonal
// or stays, but they remain on the board inside the sector).
for (const t of state.treasures.filter((t) => t.owner === after.id)) {
expect(state.board.cells[cellKey(t.position!)]).toBe(true);
}
void treasuresBefore;
});
it("relocate sector slides everything and keeps adjacency", () => {
let { state } = newGame(); // 2 players: 5x10 column, sectors at y=0 and y=5
const me = activePlayer(state);
const idx = state.board.placements.findIndex(
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
);
const myOrigin = state.board.placements[idx]!.origin;
const otherIdx = idx === 0 ? 1 : 0;
const otherOrigin = state.board.placements[otherIdx]!.origin;
// Move my sector to the EAST side of the other sector (still adjacent).
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
const posBefore = { ...me.position };
const rel = giveCard(state, me.id, "relocate-sector");
state = must(state, me.id, {
type: "cast", instanceId: rel.instanceId,
target: { kind: "cell", cell: dest }, params: { cell: me.position },
});
const after = state.players.find((p) => p.id === me.id)!;
const dx = dest.x - myOrigin.x, dy = dest.y - myOrigin.y;
expect(after.position).toEqual({ x: posBefore.x + dx, y: posBefore.y + dy });
expect(cellKey(after.home)).toBe(cellKey({ x: after.home.x, y: after.home.y }));
expect(state.board.placements[idx]!.origin).toEqual(dest);
// The map reassembled: every treasure/wizard cell exists on the new board.
for (const t of state.treasures) {
if (t.position) expect(state.board.cells[cellKey(t.position)]).toBe(true);
}
// An island move is refused.
const rel2 = giveCard(state, me.id, "relocate-sector", "R2");
const refused = applyCommand(state, me.id, {
type: "cast", instanceId: rel2.instanceId,
target: { kind: "cell", cell: { x: 40, y: 40 } }, params: { cell: after.position },
});
expect(refused.ok).toBe(false);
});
});