Blind reviews over the rev-37..40 features and the gate collapse. The scripted surgery's scars come out: bare brace blocks unwrapped (with one hiding an unreachable scrambleHands), the amputated comment healed, the dead events parameter unthreaded from the illusion-sight chain, the laziness probe in perceivedBoard collapsed to the rule it always produced, GameView.deckRev retired with its last reader, the ward's arming stub told honestly, and every comment or test title still citing the retired revision numbering reworded to the rule it guards. Rim mechanics unify on rimWarpMouth and breachRim; the test suite gains plainRimWall and pushSustained and loses its loop-shaped scars and rev-named helpers. No behavior changes; 255 tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
568 lines
26 KiB
TypeScript
568 lines
26 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("an untested illusion blocks the bump; a tested eye rolls its verdict", () => {
|
|
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 });
|
|
// Bumping blind is refused: eyes must be tested first.
|
|
expect(applyCommand(s, other.id, { type: "move", direction: side }).ok).toBe(false);
|
|
s = must(s, other.id, { type: "testIllusion", cell: other.position, side });
|
|
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 },
|
|
});
|
|
// The maze zero-anchors after the move; measure against the sector's
|
|
// origin as it settled, not the requested landing.
|
|
const after = state.players.find((p) => p.id === me.id)!;
|
|
const newOrigin = state.board.placements[idx]!.origin;
|
|
const dx = newOrigin.x - myOrigin.x, dy = newOrigin.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 });
|
|
// My sector sits east of the other, as asked.
|
|
const otherAfter = state.board.placements[idx === 0 ? 1 : 0]!.origin;
|
|
expect(newOrigin).toEqual({ x: otherAfter.x + 5, y: otherAfter.y });
|
|
// 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" });
|
|
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", () => {
|
|
function freshGame() {
|
|
const { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"] });
|
|
return state;
|
|
}
|
|
|
|
it("a sector may land at negative coordinates — the maze renormalizes", () => {
|
|
let state = freshGame();
|
|
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("a diagonal landing breaks adjacency and is refused", () => {
|
|
let { state } = newGame();
|
|
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 = freshGame();
|
|
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", () => {
|
|
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"] });
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("illusions are tested by choice", () => {
|
|
function shimmerRig() {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic"] });
|
|
state = toRound2(state);
|
|
const caster = activePlayer(state);
|
|
const mark = state.players.find((p) => p.id !== caster.id)!;
|
|
// Conjure the illusion on an OPEN edge beside the victim (state surgery
|
|
// for a deterministic spot; the cast itself is tested elsewhere).
|
|
const spot = emptyNeighborCell(state, mark.position);
|
|
const key = edgeKey(mark.position, spot.side);
|
|
state.illusionWalls[key] = { createdBy: caster.id, belief: {} };
|
|
state = must(state, caster.id, { type: "endTurn", draw: 0 });
|
|
return {
|
|
state, caster: caster.id, victim: mark.id, side: spot.side,
|
|
cell: { ...state.players.find((p) => p.id === mark.id)!.position },
|
|
};
|
|
}
|
|
|
|
it("an untested shimmer blocks the walker with its own message, no die rolled", () => {
|
|
const { state, victim, side } = shimmerRig();
|
|
const rngBefore = JSON.stringify(state.rng);
|
|
const r = applyCommand(state, victim, { type: "move", direction: side });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toContain("shimmers");
|
|
expect(JSON.stringify(state.rng)).toBe(rngBefore);
|
|
});
|
|
|
|
it("the belief test is an explicit roll; the verdict then governs the wall", () => {
|
|
let { state, victim, cell, side } = shimmerRig();
|
|
state = must(state, victim, { type: "testIllusion", cell, side });
|
|
const verdict = state.illusionWalls[edgeKey(cell, side)]!.belief[victim];
|
|
expect(verdict === "believes" || verdict === "seesThrough").toBe(true);
|
|
const r = applyCommand(state, victim, { type: "move", direction: side });
|
|
if (verdict === "seesThrough") {
|
|
expect(r.ok).toBe(true);
|
|
} else {
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toBe("blocked by wall");
|
|
}
|
|
// A second test is refused: eyes rule once.
|
|
expect(applyCommand(state, victim, { type: "testIllusion", cell, side }).ok).toBe(false);
|
|
});
|
|
|
|
it("the creator strolls through their own fake without any test", () => {
|
|
let { state, caster } = shimmerRig();
|
|
// Skip to the caster's turn; the wall never blocks its maker.
|
|
while (activePlayer(state).id !== caster) {
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
}
|
|
const me = state.players.find((p) => p.id === caster)!;
|
|
const other = state.players.find((p) => p.id !== caster)!;
|
|
me.position = { ...other.position };
|
|
const r = applyCommand(state, caster, { type: "move", direction: "E" });
|
|
// Blocked only if some REAL edge stands there; the illusion itself never objects.
|
|
if (!r.ok) expect(r.error).not.toContain("shimmers");
|
|
});
|
|
});
|