Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.
Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.
Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.
Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.
Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.
288 tests, 24 ledgers verified, all workspaces typecheck.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
570 lines
25 KiB
TypeScript
570 lines
25 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
|
import { cellKey, edgeKey, neighbor, opposite, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newGame, must, giveCard, toRound2, faceOff, castAt } from "./helpers";
|
|
|
|
describe("duration spells", () => {
|
|
it("slow reduces movement to 1, blocks number cards, and halves attacks", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const slow = giveCard(state, attacker, "slow");
|
|
giveCard(state, attacker, "number-4", "N", 1);
|
|
state = castAt(state, attacker, defender, slow, { numberInstanceIds: ["number-4#N"] });
|
|
expect(sustainedOn(state, defender, "slow").length).toBe(1);
|
|
|
|
// Defender's turn: 1 movement, no number cards, no attack (1st slowed turn).
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(defender);
|
|
expect(state.turn.movementAllowance).toBe(1);
|
|
expect(state.turn.attackForbidden).toBe(true);
|
|
const num = giveCard(state, defender, "number-3", "M", 0);
|
|
expect(applyCommand(state, defender, { type: "playNumberForMovement", instanceId: num.instanceId }).ok).toBe(false);
|
|
|
|
// Second slowed turn: attack allowed again.
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(defender);
|
|
expect(state.turn.attackForbidden).toBe(false);
|
|
});
|
|
|
|
it("duration spells expire at the start of the caster's turn", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const ns = giveCard(state, attacker, "no-spell");
|
|
// Duration 1 (no number card).
|
|
state = castAt(state, attacker, defender, ns);
|
|
expect(sustainedOn(state, defender, "no-spell").length).toBe(1);
|
|
|
|
// Defender cannot cast while it lasts.
|
|
const fb = giveCard(state, defender, "fireball", "F", 1);
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
const refused = applyCommand(state, defender, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
|
|
// Back to the caster: the spell expires at their turn start.
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
expect(sustainedOn(state, defender, "no-spell").length).toBe(0);
|
|
});
|
|
|
|
it("medusa paralyzes and grants damage immunity", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const med = giveCard(state, attacker, "medusa");
|
|
giveCard(state, attacker, "number-2", "N", 1);
|
|
// Duration 2 so it survives past the caster's next turn start.
|
|
state = castAt(state, attacker, defender, med, { numberInstanceIds: ["number-2#N"] });
|
|
|
|
// Defender is immune to damage while paralyzed.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
expect(activePlayer(state).id).toBe(defender);
|
|
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
|
|
// Attacker punches the frozen defender: no damage.
|
|
state = must(state, attacker, { type: "punch", targetId: defender });
|
|
// Defender cannot counteract under medusa; they pass.
|
|
state = must(state, defender, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
|
|
});
|
|
|
|
it("invisible makes attacks miss 3 times out of 4 (deterministic per seed)", () => {
|
|
let hits = 0, misses = 0;
|
|
for (let seed = 1; seed <= 12; seed++) {
|
|
let { state } = newGame(seed);
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const inv = giveCard(state, defender, "invisible", "I", 0);
|
|
// Defender casts invisible on their own turn first — rearrange: give
|
|
// attacker the attack, let defender cast invisible when active.
|
|
// Simpler: attach directly via a cast from the defender's turn.
|
|
state.players.find((p) => p.id === defender)!.hand[0] = inv;
|
|
// Attacker ends turn; defender casts invisible; attacker attacks.
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender, { type: "cast", instanceId: inv.instanceId });
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
const fb = giveCard(state, attacker, "fireball", "F", 0);
|
|
state = castAt(state, attacker, defender, fb);
|
|
const life = state.players.find((p) => p.id === defender)!.life;
|
|
if (life < 15) hits++;
|
|
else misses++;
|
|
}
|
|
expect(hits + misses).toBe(12);
|
|
expect(misses).toBeGreaterThan(hits); // 75% miss rate over 12 seeds
|
|
});
|
|
});
|
|
|
|
describe("doors", () => {
|
|
function findDoor(state: GameState): { cell: { x: number; y: number }; side: Side } {
|
|
const view = boardView(state);
|
|
for (const [key, edgeState] of Object.entries(view.edges)) {
|
|
if (edgeState !== "door") continue;
|
|
const [kind, coords] = key.split(":") as [string, string];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
return kind === "V" ? { cell: { x, y }, side: "E" } : { cell: { x, y }, side: "S" };
|
|
}
|
|
throw new Error("no door on this board");
|
|
}
|
|
|
|
it("pick lock opens an adjacent door — and it relocks behind the walker", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const door = findDoor(state);
|
|
me.position = { ...door.cell };
|
|
const other = neighbor(door.cell, door.side);
|
|
const dir = door.side;
|
|
|
|
// Locked: blocked.
|
|
expect(applyCommand(state, me.id, { type: "move", direction: dir }).ok).toBe(false);
|
|
|
|
const pl = giveCard(state, me.id, "pick-lock");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: pl.instanceId,
|
|
target: { kind: "edge", cell: door.cell, side: door.side },
|
|
});
|
|
state = must(state, me.id, { type: "move", direction: dir });
|
|
expect(cellKey(activePlayer(state).position)).toBe(cellKey(other));
|
|
|
|
// "It will relock behind you": shut at the walker's back at once —
|
|
// the way back is barred without another pick.
|
|
expect(state.openDoorEdges.length).toBe(0);
|
|
expect(applyCommand(state, me.id, { type: "move", direction: dir === "E" ? "W" : "N" }).ok).toBe(false);
|
|
});
|
|
|
|
it("remove lock is permanent; jam lock seals the door for everyone", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const door = findDoor(state);
|
|
me.position = { ...door.cell };
|
|
const key = edgeKey(door.cell, door.side);
|
|
|
|
const rl = giveCard(state, me.id, "remove-lock");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: rl.instanceId,
|
|
target: { kind: "edge", cell: door.cell, side: door.side },
|
|
});
|
|
expect(state.doorStates[key]).toBe("removed");
|
|
state = must(state, me.id, { type: "move", direction: door.side });
|
|
|
|
// Jamming a removed lock is refused.
|
|
const jl = giveCard(state, me.id, "jam-lock");
|
|
const refused = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: jl.instanceId,
|
|
target: { kind: "edge", cell: door.cell, side: door.side },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("movement spells", () => {
|
|
it("teleport jumps up to four spaces through walls and ends movement", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const from = me.position;
|
|
const tp = giveCard(state, me.id, "teleport");
|
|
const far = { x: from.x, y: from.y >= 4 ? from.y - 4 : from.y + 4 };
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: far },
|
|
});
|
|
expect(cellKey(activePlayer(state).position)).toBe(cellKey(far));
|
|
expect(applyCommand(state, me.id, { type: "move", direction: "N" }).ok).toBe(false);
|
|
});
|
|
|
|
it("teleport refuses jumps beyond four spaces", () => {
|
|
const { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const tp = giveCard(state, me.id, "teleport");
|
|
const tooFar = { x: me.position.x, y: me.position.y >= 5 ? me.position.y - 5 : me.position.y + 5 };
|
|
const result = applyCommand(state, me.id, {
|
|
type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: tooFar },
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("swap trades places and consumes movement", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
const aPos = { ...attacker.position };
|
|
const bPos = { ...defender.position };
|
|
const sw = giveCard(state, attacker.id, "swap");
|
|
state = castAt(state, attacker.id, defender.id, sw);
|
|
expect(cellKey(state.players.find((p) => p.id === attacker.id)!.position)).toBe(cellKey(bPos));
|
|
expect(cellKey(state.players.find((p) => p.id === defender.id)!.position)).toBe(cellKey(aPos));
|
|
expect(state.turn.movementUsed).toBe(state.turn.movementAllowance);
|
|
});
|
|
|
|
it("power run trades life for movement", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
const pr = giveCard(state, me.id, "power-run");
|
|
state = must(state, me.id, {
|
|
type: "cast", instanceId: pr.instanceId, params: { points: 3 },
|
|
});
|
|
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
|
|
expect(state.turn.movementAllowance).toBe(6);
|
|
});
|
|
|
|
it("pass through wall grants a one-wall step", () => {
|
|
let { state } = newGame();
|
|
const me = activePlayer(state);
|
|
// Find a direction blocked by a wall with a real cell behind it.
|
|
const view = boardView(state);
|
|
let dir: Side | null = null;
|
|
for (const side of ["N", "S", "E", "W"] as Side[]) {
|
|
const k = edgeKey(me.position, side);
|
|
if (view.edges[k] === "wall" && view.cells[cellKey(neighbor(me.position, side))]) {
|
|
dir = side;
|
|
break;
|
|
}
|
|
}
|
|
if (!dir) throw new Error("setup: seed 42 lost its adjacent wall");
|
|
const ptw = giveCard(state, me.id, "pass-through-wall");
|
|
state = must(state, me.id, { type: "cast", instanceId: ptw.instanceId });
|
|
state = must(state, me.id, { type: "move", direction: dir });
|
|
expect(state.players.find((p) => p.id === me.id)!.passWallCharges).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("card warfare", () => {
|
|
it("card erasure discards a named card; thought steal takes two at random", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
giveCard(state, defender, "fireball", "V", 0);
|
|
const ce = giveCard(state, attacker, "card-erasure");
|
|
state = castAt(state, attacker, defender, ce, { params: { cardId: "fireball" } });
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
expect(d.hand.some((c) => c.cardId === "fireball")).toBe(false);
|
|
expect(d.hand.length).toBe(6);
|
|
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
const ts = giveCard(state, attacker, "thought-steal");
|
|
const handBefore = state.players.find((p) => p.id === attacker)!.hand.length;
|
|
state = castAt(state, attacker, defender, ts);
|
|
// -1 (thought steal cast) +2 stolen
|
|
expect(state.players.find((p) => p.id === attacker)!.hand.length).toBe(handBefore + 1);
|
|
expect(state.players.find((p) => p.id === defender)!.hand.length).toBe(4);
|
|
});
|
|
|
|
it("power drain transfers life; sudden death does 10", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const pd = giveCard(state, attacker, "power-drain");
|
|
giveCard(state, attacker, "number-4", "N", 1);
|
|
state = castAt(state, attacker, defender, pd, { numberInstanceIds: ["number-4#N"] });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(11);
|
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(19);
|
|
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
const sd = giveCard(state, attacker, "sudden-death");
|
|
state = castAt(state, attacker, defender, sd);
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(1);
|
|
});
|
|
|
|
it("wizardblade needs the same square, uses a number card, and stays displayed", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
const wb = giveCard(state, attacker.id, "wizardblade");
|
|
giveCard(state, attacker.id, "number-3", "N", 1);
|
|
|
|
// Not same square: refused.
|
|
defender.position = { x: attacker.position.x, y: attacker.position.y === 0 ? 1 : attacker.position.y - 1 };
|
|
const refused = applyCommand(state, attacker.id, {
|
|
type: "cast", instanceId: wb.instanceId, numberInstanceIds: ["number-3#N"],
|
|
target: { kind: "player", playerId: defender.id },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
|
|
defender.position = { ...attacker.position };
|
|
state = castAt(state, attacker.id, defender.id, wb, { numberInstanceIds: ["number-3#N"] });
|
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12);
|
|
const a = state.players.find((p) => p.id === attacker.id)!;
|
|
expect(a.hand.some((c) => c.cardId === "wizardblade")).toBe(true);
|
|
expect(a.displayed).toContain(wb.instanceId);
|
|
});
|
|
});
|
|
|
|
describe("cast modifiers", () => {
|
|
it("amplify doubles fireball; add joins two number cards", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const fb = giveCard(state, attacker, "fireball");
|
|
giveCard(state, attacker, "amplify", "A", 1);
|
|
state = castAt(state, attacker, defender, fb, { amplifyInstanceIds: ["amplify#A"] });
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5 x2
|
|
|
|
state = must(state, attacker, { type: "endTurn", draw: 0 });
|
|
state = must(state, defender, { type: "endTurn", draw: 0 });
|
|
const lb = giveCard(state, attacker, "lightning-blast");
|
|
giveCard(state, attacker, "number-2", "N1", 1);
|
|
giveCard(state, attacker, "number-3", "N2", 2);
|
|
giveCard(state, attacker, "add", "AD", 3);
|
|
// Two numbers without ADD: refused.
|
|
const refused = applyCommand(state, attacker, {
|
|
type: "cast", instanceId: lb.instanceId,
|
|
numberInstanceIds: ["number-2#N1", "number-3#N2"],
|
|
target: { kind: "player", playerId: defender },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
state = castAt(state, attacker, defender, lb, {
|
|
numberInstanceIds: ["number-2#N1", "number-3#N2"],
|
|
addInstanceId: "add#AD",
|
|
});
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(0); // 5 dmg on 5 life
|
|
});
|
|
|
|
it("reverse turns damage into healing but keeps secondary effects", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const { attacker, defender } = faceOff(state);
|
|
const lb = giveCard(state, attacker, "lightning-blast");
|
|
giveCard(state, attacker, "number-4", "N", 1);
|
|
giveCard(state, defender, "reverse", "R", 0);
|
|
state = must(state, attacker, {
|
|
type: "cast", instanceId: lb.instanceId, numberInstanceIds: ["number-4#N"],
|
|
target: { kind: "player", playerId: defender },
|
|
});
|
|
state = must(state, defender, { type: "counteract", instanceId: "reverse#R" });
|
|
state = must(state, attacker, { type: "pass" });
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
expect(d.life).toBe(19); // gained 4 instead of losing it
|
|
expect(d.lostTurns).toBe(1); // the stun still applies
|
|
});
|
|
});
|
|
|
|
describe("holding the door open (Pick Lock / Master Key)", () => {
|
|
function doorRig() {
|
|
let { state } = createGame({ playerIds: ["holder", "guest"], seed: 42, sets: ["basic"] });
|
|
state = toRound2(state);
|
|
// Find a door edge; stand the acting player beside it.
|
|
const view = boardView(state);
|
|
for (const [key, edge] of Object.entries(view.edges)) {
|
|
if (edge !== "door") continue;
|
|
const [kind, coords] = key.split(":") as [string, string];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
const cell = { x, y };
|
|
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
|
const holder = activePlayer(state);
|
|
holder.position = { ...cell };
|
|
return { state, key, cell, side, holder: holder.id };
|
|
}
|
|
throw new Error("setup: seed 42 grew a maze with no doors");
|
|
}
|
|
|
|
it("a held door outlives the turn and admits another wizard", () => {
|
|
let { state, key, cell, side, holder } = doorRig();
|
|
const pick = giveCard(state, holder, "pick-lock");
|
|
state = must(state, holder, {
|
|
type: "cast", instanceId: pick.instanceId,
|
|
target: { kind: "edge", cell, side }, params: { hold: true },
|
|
});
|
|
state = must(state, holder, { type: "endTurn", draw: 0 });
|
|
expect(state.heldDoors.some((h) => h.key === key)).toBe(true);
|
|
const guest = state.players.find((p) => p.id !== holder)!;
|
|
guest.position = { ...cell };
|
|
const r = applyCommand(state, guest.id, { type: "move", direction: side });
|
|
if (!r.ok) throw new Error(r.error);
|
|
const through = r.state.players.find((p) => p.id === guest.id)!;
|
|
expect(cellKey(through.position)).toBe(cellKey(neighbor(cell, side)));
|
|
});
|
|
|
|
it("the door swings shut the moment the holder steps away", () => {
|
|
let { state, key, cell, side, holder } = doorRig();
|
|
const pick = giveCard(state, holder, "pick-lock");
|
|
state = must(state, holder, {
|
|
type: "cast", instanceId: pick.instanceId,
|
|
target: { kind: "edge", cell, side }, params: { hold: true },
|
|
});
|
|
expect(state.heldDoors.some((h) => h.key === key)).toBe(true);
|
|
// March the holder until adjacency breaks; the sweep must release.
|
|
let walked = state;
|
|
let released = false;
|
|
const dirs: Side[] = ["N", "E", "S", "W"];
|
|
for (const d1 of dirs) {
|
|
const r1 = applyCommand(walked, holder, { type: "move", direction: d1 });
|
|
if (!r1.ok) continue;
|
|
if (!r1.state.heldDoors.some((h) => h.key === key)) { released = true; break; }
|
|
for (const d2 of dirs) {
|
|
const r2 = applyCommand(r1.state, holder, { type: "move", direction: d2 });
|
|
if (!r2.ok) continue;
|
|
if (!r2.state.heldDoors.some((h) => h.key === key)) { released = true; break; }
|
|
}
|
|
if (released) break;
|
|
}
|
|
expect(released).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("a held door is an open doorway to the eye", () => {
|
|
function sightRig() {
|
|
let { state } = createGame({ playerIds: ["holder", "pursuer"], seed: 42, sets: ["basic"] });
|
|
state = toRound2(state);
|
|
const view = boardView(state);
|
|
for (const [key, edge] of Object.entries(view.edges)) {
|
|
if (edge !== "door") continue;
|
|
const [kind, coords] = key.split(":") as [string, string];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
const cell = { x, y };
|
|
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
|
const holder = activePlayer(state);
|
|
const pursuer = state.players.find((p) => p.id !== holder.id)!;
|
|
holder.position = { ...cell };
|
|
pursuer.position = neighbor(cell, side);
|
|
return { state, cell, side, holder: holder.id, pursuer: pursuer.id };
|
|
}
|
|
throw new Error("setup: seed 42 grew a maze with no doors");
|
|
}
|
|
|
|
it("the holder blasts the pursuer through the held doorway", () => {
|
|
let { state, cell, side, holder, pursuer } = sightRig();
|
|
const pick = giveCard(state, holder, "pick-lock");
|
|
state = must(state, holder, {
|
|
type: "cast", instanceId: pick.instanceId,
|
|
target: { kind: "edge", cell, side }, params: { hold: true },
|
|
});
|
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
|
const lifeBefore = state.players.find((p) => p.id === pursuer)!.life;
|
|
state = must(state, holder, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
|
});
|
|
state = must(state, pursuer, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === pursuer)!.life).toBeLessThan(lifeBefore);
|
|
});
|
|
|
|
it("an unlocked door is an open doorway to the wizard at its threshold", () => {
|
|
let { state, cell, side, holder, pursuer } = sightRig();
|
|
const pick = giveCard(state, holder, "pick-lock");
|
|
state = must(state, holder, {
|
|
type: "cast", instanceId: pick.instanceId,
|
|
target: { kind: "edge", cell, side },
|
|
});
|
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
|
const cast = applyCommand(state, holder, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
|
});
|
|
expect(cast.ok).toBe(true);
|
|
});
|
|
|
|
it("a lock REMOVED lets any wizard beside the door peek through", () => {
|
|
let { state, cell, side, holder, pursuer } = sightRig();
|
|
const rm = giveCard(state, holder, "remove-lock");
|
|
state = must(state, holder, {
|
|
type: "cast", instanceId: rm.instanceId,
|
|
target: { kind: "edge", cell, side },
|
|
});
|
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
|
const cast = applyCommand(state, holder, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
|
});
|
|
expect(cast.ok).toBe(true);
|
|
});
|
|
|
|
it("PICK LOCK in hand is enough to ease a locked door open a crack", () => {
|
|
let { state, cell, side, holder, pursuer } = sightRig();
|
|
giveCard(state, holder, "pick-lock");
|
|
const fb = giveCard(state, holder, "fireball", "FB", 1);
|
|
const cast = applyCommand(state, holder, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
|
|
});
|
|
expect(cast.ok).toBe(true);
|
|
});
|
|
|
|
it("the wizard down the hallway sees no further than the shut door", () => {
|
|
let { state, cell, side, holder, pursuer } = sightRig();
|
|
// Pull the pursuer one square further down the hall, so the door is
|
|
// no longer on their threshold; clear their path to the doorway.
|
|
const near = neighbor(cell, side);
|
|
const far = neighbor(near, side);
|
|
if (!boardView(state).cells[cellKey(far)]) {
|
|
throw new Error("setup: seed 42 lost the hallway this rig stands in");
|
|
}
|
|
state.players.find((p) => p.id === pursuer)!.position = far;
|
|
state.edgeOverrides[edgeKey(near, side)] = "open";
|
|
const rm = giveCard(state, holder, "remove-lock");
|
|
state = must(state, holder, {
|
|
type: "cast", instanceId: rm.instanceId,
|
|
target: { kind: "edge", cell, side },
|
|
});
|
|
state = must(state, holder, { type: "endTurn", draw: 0 });
|
|
const fb = giveCard(state, pursuer, "fireball", "FB", 1);
|
|
const refused = applyCommand(state, pursuer, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: holder },
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
if (!refused.ok) expect(refused.error).toContain("line of sight");
|
|
});
|
|
});
|
|
|
|
describe("the displayed MASTER KEY", () => {
|
|
function keyRig() {
|
|
let { state } = createGame({ playerIds: ["keeper", "watcher"], seed: 42, sets: ["basic"] });
|
|
state = toRound2(state);
|
|
const view = boardView(state);
|
|
for (const [key, edge] of Object.entries(view.edges)) {
|
|
if (edge !== "door") continue;
|
|
const [kind, coords] = key.split(":") as [string, string];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
const cell = { x, y };
|
|
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
|
const keeper = activePlayer(state);
|
|
keeper.position = { ...cell };
|
|
return { state, cell, side, key, keeper };
|
|
}
|
|
throw new Error("setup: seed 42 grew a maze with no doors");
|
|
}
|
|
|
|
it("cast bare, the key goes on display — once", () => {
|
|
const { state, keeper } = keyRig();
|
|
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
|
const cast = applyCommand(state, keeper.id, { type: "cast", instanceId: mk.instanceId });
|
|
expect(cast.ok).toBe(true);
|
|
if (cast.ok) {
|
|
const after = cast.state.players.find((p) => p.id === keeper.id)!;
|
|
expect(after.displayed).toContain(mk.instanceId);
|
|
expect(after.hand.some((c) => c.instanceId === mk.instanceId)).toBe(true);
|
|
const again = applyCommand(cast.state, keeper.id, { type: "cast", instanceId: mk.instanceId });
|
|
expect(again.ok).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("its bearer walks through locked doors, which relock at their back", () => {
|
|
const { state, cell, side, key, keeper } = keyRig();
|
|
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
|
keeper.displayed.push(mk.instanceId);
|
|
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
const after = r.state.players.find((p) => p.id === keeper.id)!;
|
|
expect(cellKey(after.position)).toBe(cellKey(neighbor(cell, side)));
|
|
// "Door relocks behind you": no lingering opening for bystanders.
|
|
expect(r.state.openDoorEdges).not.toContain(key);
|
|
expect(r.events.some((e) => e.type === "doorUnlocked")).toBe(true);
|
|
// The key turns again on the way back.
|
|
const back = applyCommand(r.state, keeper.id, { type: "move", direction: opposite(side) });
|
|
expect(back.ok).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("a JAMmed LOCK refuses even the master key", () => {
|
|
const { state, side, key, keeper } = keyRig();
|
|
const mk = giveCard(state, keeper.id, "master-key", "MK");
|
|
keeper.displayed.push(mk.instanceId);
|
|
state.doorStates[key] = "jammed";
|
|
const r = applyCommand(state, keeper.id, { type: "move", direction: side });
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
});
|