Rules rev 23 — IDIOT, per the card and FAQ: - No handling items: pick up / drop of treasures and objects refused (dropping was the exploit: capturing a stolen treasure on your own home, or dropping your own treasure underfoot for an instant cure) - No punching, no thrown dagger / large rock (attacks on players) - No effect on a victim already carrying one of their own treasures Ungated (permissive): counteractions are now castable while idiotized (the one thing the card expressly allows — the gate wrongly blocked them), and goal-aiding spells (IDIOT_AIDS: destroy-wall, teleport, mad-dash, ...) per the FAQ's 'you could, however, destroy a wall'. Sight tracing: while an LOS attack sits on the stack, the board draws the line it traveled — straight when direct, leg by leg through both warp mouths (with pulsing rings) when the maze's wraparound carried it. Engine traceSight/traceSightFor/stackSightTrace; overlay in Board.svelte; shown live and in replays. Verified against H3PC's cross-board Idiot cast. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
503 lines
23 KiB
TypeScript
503 lines
23 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame, gameLos, sustainedOn, viewFor } from "../src";
|
|
import { cellKey, edgeKey, hasLineOfSight, sightBetween, traceSight, type Cell } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
|
|
|
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;
|
|
if (result.state.stack) {
|
|
hits++; // the roll matched: attack proceeds normally
|
|
} else {
|
|
misses++;
|
|
expect(result.state.turn.attackUsed).toBe(true); // card spent anyway
|
|
}
|
|
}
|
|
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 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));
|
|
// Wherever they stood, rotation keeps them inside the 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);
|
|
}
|
|
});
|
|
|
|
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 homeBefore = { ...me.home };
|
|
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(after.home).toEqual({ x: homeBefore.x + dx, y: homeBefore.y + dy });
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("6e card-face corrections", () => {
|
|
it("no spell does not block pick lock — 'This is not a spell'", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
const ns = giveCard(state, attacker.id, "no-spell");
|
|
giveCard(state, attacker.id, "number-3", "N", 1);
|
|
state = must(state, attacker.id, {
|
|
type: "cast", instanceId: ns.instanceId, numberInstanceIds: ["number-3#N"],
|
|
target: { kind: "player", playerId: defender.id },
|
|
});
|
|
state = must(state, defender.id, { type: "pass" });
|
|
state = must(state, attacker.id, { type: "endTurn", draw: 0 });
|
|
|
|
// The silenced defender cannot cast a spell...
|
|
const fb = giveCard(state, defender.id, "fireball", "F", 0);
|
|
expect(applyCommand(state, defender.id, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker.id },
|
|
}).ok).toBe(false);
|
|
// ...but picking a lock is a physical action, not a spell.
|
|
const view = boardView(state);
|
|
const doorEntry = Object.entries(view.edges).find(([, st]) => st === "door")!;
|
|
const [kind, coords] = doorEntry[0].split(":") as [string, string];
|
|
const [dx, dy] = coords.split(",").map(Number) as [number, number];
|
|
const d = state.players.find((p) => p.id === defender.id)!;
|
|
d.position = { x: dx, y: dy };
|
|
const pl = giveCard(state, defender.id, "pick-lock", "PL", 1);
|
|
const result = applyCommand(state, defender.id, {
|
|
type: "cast", instanceId: pl.instanceId,
|
|
target: { kind: "edge", cell: { x: dx, y: dy }, side: kind === "V" ? "E" : "S" },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("blinded punches flail on the die — 'engage in combat'", () => {
|
|
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)!;
|
|
defender.position = { ...attacker.position };
|
|
state.sustained.push({
|
|
id: "fx-test", cardId: "blind", casterId: defender.id,
|
|
targetId: attacker.id, remainingTurns: 3, data: {},
|
|
});
|
|
const result = applyCommand(state, attacker.id, { type: "punch", targetId: defender.id });
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) continue;
|
|
if (result.state.stack) hits++;
|
|
else {
|
|
misses++;
|
|
expect(result.state.turn.attackUsed).toBe(true); // the flail spends the attack
|
|
}
|
|
}
|
|
expect(hits + misses).toBe(10);
|
|
expect(misses).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("wall of fire, as a counteraction, stops a waterbolt cold", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
const wb = giveCard(state, attacker.id, "waterbolt");
|
|
giveCard(state, attacker.id, "number-4", "N", 1);
|
|
giveCard(state, defender.id, "wall-of-fire", "WOF", 0);
|
|
state = must(state, attacker.id, {
|
|
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-4#N"],
|
|
target: { kind: "player", playerId: defender.id },
|
|
params: { damage: 4, knockback: 0 },
|
|
});
|
|
state = must(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF" });
|
|
state = must(state, attacker.id, { type: "pass" });
|
|
state = must(state, defender.id, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(15);
|
|
|
|
// But it cannot counter a fireball.
|
|
state = must(state, attacker.id, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender.id, { type: "endTurn", draw: 0 });
|
|
const fb = giveCard(state, attacker.id, "fireball", "F", 0);
|
|
giveCard(state, defender.id, "wall-of-fire", "WOF2", 0);
|
|
state = must(state, attacker.id, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
|
|
});
|
|
expect(applyCommand(state, defender.id, { type: "counteract", instanceId: "wall-of-fire#WOF2" }).ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("relocation past the origin (rules rev 11)", () => {
|
|
function rev11Game() {
|
|
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 11 });
|
|
return state;
|
|
}
|
|
|
|
it("a sector may land at negative coordinates — the maze renormalizes", () => {
|
|
let state = rev11Game();
|
|
const me = activePlayer(state);
|
|
const other = state.players.find((p) => p.id !== me.id)!;
|
|
const idx = state.board.placements.findIndex(
|
|
(p) => me.position.y >= p.origin.y && me.position.y < p.origin.y + 5,
|
|
);
|
|
const otherIdx = idx === 0 ? 1 : 0;
|
|
const otherOrigin = state.board.placements[otherIdx]!.origin;
|
|
// Land WEST of the other sector: x = otherOrigin.x - 5, likely negative.
|
|
const dest = { x: otherOrigin.x - 5, y: otherOrigin.y };
|
|
const otherPosBefore = { ...other.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 },
|
|
});
|
|
// The maze zero-anchors: origins snug against both axes.
|
|
const origins = state.board.placements.map((p: { origin: Cell }) => p.origin);
|
|
expect(Math.min(...origins.map((o: Cell) => o.x))).toBe(0);
|
|
expect(Math.min(...origins.map((o: Cell) => o.y))).toBe(0);
|
|
// Everyone stands on a cell the reassembled board knows.
|
|
for (const p of state.players) {
|
|
expect(state.board.cells[cellKey(p.position)]).toBe(true);
|
|
expect(state.board.cells[cellKey(p.home)]).toBe(true);
|
|
}
|
|
// The static sector's tenant rode the renormalization shift exactly.
|
|
const finalOther = state.board.placements[otherIdx]!.origin;
|
|
const shifted = state.players.find((p: { id: string }) => p.id === other.id)!;
|
|
expect(shifted.position).toEqual({
|
|
x: otherPosBefore.x + (finalOther.x - otherOrigin.x),
|
|
y: otherPosBefore.y + (finalOther.y - otherOrigin.y),
|
|
});
|
|
});
|
|
|
|
it("older revisions still refuse the negative landing", () => {
|
|
let { state } = newGame(); // helper default: rev-ungated (1)
|
|
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 otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
|
const rel = giveCard(state, me.id, "relocate-sector");
|
|
const refused = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: rel.instanceId,
|
|
target: { kind: "cell", cell: { x: otherOrigin.x - 5, y: otherOrigin.y - 5 } },
|
|
params: { cell: me.position },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
});
|
|
|
|
it("a moving sector carries its creature, glue, warp tokens, and traps", () => {
|
|
let state = rev11Game();
|
|
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 otherOrigin = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
|
const inMine = (c: Cell) =>
|
|
c.x >= myOrigin.x && c.x < myOrigin.x + 5 && c.y >= myOrigin.y && c.y < myOrigin.y + 5;
|
|
// Seed passengers on the moving sector.
|
|
const spot = { x: myOrigin.x + 2, y: myOrigin.y + 2 };
|
|
expect(inMine(spot)).toBe(true);
|
|
state.creatures.push({
|
|
id: "c1", kind: "troll", controllerId: me.id, position: { ...spot },
|
|
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
|
|
attackUsed: false, justCreated: false,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
state.gluedCells[cellKey(spot)] = true;
|
|
state.dimWarps.push({ a: { ...spot }, b: { x: otherOrigin.x + 1, y: otherOrigin.y + 1 } });
|
|
state.boobytraps.push({ casterId: me.id, cells: [{ ...spot }], realKey: cellKey(spot) });
|
|
|
|
const dest = { x: otherOrigin.x + 5, y: otherOrigin.y };
|
|
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 },
|
|
});
|
|
// Expected coordinates follow the final placements (move + zero-anchor).
|
|
const finalMine = state.board.placements[idx]!.origin;
|
|
const finalOther = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
|
const moved = { x: spot.x + finalMine.x - myOrigin.x, y: spot.y + finalMine.y - myOrigin.y };
|
|
// The warp's far token sat one square inside the static sector.
|
|
const staticShifted = { x: finalOther.x + 1, y: finalOther.y + 1 };
|
|
expect(state.creatures[0]!.position).toEqual(moved);
|
|
expect(state.gluedCells[cellKey(moved)]).toBe(true);
|
|
expect(state.dimWarps[0]!.a).toEqual(moved);
|
|
expect(state.dimWarps[0]!.b).toEqual(staticShifted);
|
|
expect(state.boobytraps[0]!.cells[0]).toEqual(moved);
|
|
expect(state.boobytraps[0]!.realKey).toBe(cellKey(moved));
|
|
});
|
|
});
|
|
|
|
describe("junction alterations roll for their sector (rules rev 20)", () => {
|
|
it("a conjured wall on the seam obeys the die: 1-2 stays, 3-4 travels", () => {
|
|
const { state: fresh } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"], deckRev: 20 });
|
|
let state = toRound2(fresh);
|
|
// The 2p board is two sectors stacked: the seam runs between them.
|
|
const [p0, p1] = state.board.placements;
|
|
const seamY = Math.max(p0!.origin.y, p1!.origin.y) - 1;
|
|
const seamCell = { x: p0!.origin.x + 2, y: seamY };
|
|
const key = edgeKey(seamCell, "S");
|
|
// Conjure a wall on the seam (state surgery: the cast needs LOS we may lack).
|
|
state.edgeOverrides[key] = "wall";
|
|
state.createdEdges[key] = true;
|
|
const caster = activePlayer(state);
|
|
const rot = giveCard(state, caster.id, "rotate-sector");
|
|
const rngBefore = JSON.stringify(state.rng);
|
|
state = must(state, caster.id, {
|
|
type: "cast", instanceId: rot.instanceId,
|
|
target: { kind: "cell", cell: { x: p1!.origin.x + 2, y: p1!.origin.y + 2 } },
|
|
params: { clockwise: true },
|
|
});
|
|
// A die was consumed for the seam wall.
|
|
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
|
// The wall is somewhere: either the old seam key or a remapped edge.
|
|
const createdKeys = Object.keys(state.createdEdges);
|
|
expect(createdKeys.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("sight tracing", () => {
|
|
it("agrees with sightBetween on every pair and pins entry/exit to the mouths", () => {
|
|
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
|
const board = boardView(state);
|
|
const cells = Object.keys(board.cells).map((k) => {
|
|
const [x, y] = k.split(",").map(Number) as [number, number];
|
|
return { x, y };
|
|
});
|
|
let warped = 0;
|
|
for (const from of cells) {
|
|
for (const to of cells) {
|
|
const t = traceSight(board, from, to);
|
|
expect(t !== null).toBe(sightBetween(board, from, to));
|
|
if (!t) continue;
|
|
if (hasLineOfSight(board, from, to)) {
|
|
expect(t.kind).toBe("direct");
|
|
} else {
|
|
expect(t.kind).toBe("warp");
|
|
if (t.kind === "warp") {
|
|
warped++;
|
|
// Each crossing point lies on its mouth's one-cell rim segment.
|
|
const onRim = (p: { x: number; y: number }, m: { cell: Cell; side: string }) => {
|
|
if (m.side === "N") return p.y === m.cell.y && p.x > m.cell.x && p.x < m.cell.x + 1;
|
|
if (m.side === "S") return p.y === m.cell.y + 1 && p.x > m.cell.x && p.x < m.cell.x + 1;
|
|
if (m.side === "W") return p.x === m.cell.x && p.y > m.cell.y && p.y < m.cell.y + 1;
|
|
return p.x === m.cell.x + 1 && p.y > m.cell.y && p.y < m.cell.y + 1;
|
|
};
|
|
expect(onRim(t.entry, t.mouthA)).toBe(true);
|
|
expect(onRim(t.exit, t.mouthB)).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expect(warped).toBeGreaterThan(0);
|
|
});
|
|
});
|