Since 6e49fc9. Engine: the teleport BFS in the view and the engine's
wallIgnoringDistance share teleportStep; the counter trail is weighed
between steps instead of on six arms; the bush attack takes its
adjacency from SIDES like the wall does; a dead redirected clause, a
one-member set, an identity ternary, and a stray try/catch are gone;
two tests now assert what their names promise. Server: KEEPER and
MAX_SEATS live in rooms.ts, the three Sentry bells ring through one
helper, the unread reply field is dropped, actingSeat comes from the
engine, and the protocol header names every message. Web: ReplayStep
is one type, cardName one helper, the say box and the reference section
one snippet each, the keeper's name arrives from the server rather than
the source, and the lifesaver ruling that ruled nothing is out.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
611 lines
28 KiB
TypeScript
611 lines
28 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
|
|
import { cellKey, edgeKey, neighbor, opposite, SIDES, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newGame, must, giveCard, toRound2, faceOff, castAt, drain } 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);
|
|
});
|
|
});
|
|
|
|
describe("POWER DRAIN drains the number played (rev 23)", () => {
|
|
it("gains the number even when the blow is blunted", () => {
|
|
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);
|
|
const d = state.players.find((p) => p.id === defender)!;
|
|
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
|
|
state = must(state, attacker, { type: "cast", instanceId: pd.instanceId, target: { kind: "player", playerId: defender }, numberInstanceIds: ["number-4#N"] });
|
|
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
|
|
state = drain(state);
|
|
expect(state.players.find((p) => p.id === defender)!.life).toBe(13);
|
|
expect(state.players.find((p) => p.id === attacker)!.life).toBe(19);
|
|
});
|
|
|
|
it("drains a wall for its points", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state);
|
|
const board = boardView(state);
|
|
const side = SIDES.find((s) => board.edges[edgeKey(me.position, s)] === "wall")!;
|
|
const pd = giveCard(state, me.id, "power-drain");
|
|
giveCard(state, me.id, "number-3", "N", 1);
|
|
state = must(state, me.id, { type: "cast", instanceId: pd.instanceId, target: { kind: "edge", cell: me.position, side }, numberInstanceIds: ["number-3#N"] });
|
|
expect(state.wallDamage[edgeKey(me.position, side)]).toBe(3);
|
|
expect(state.players.find((p) => p.id === me.id)!.life).toBe(18);
|
|
});
|
|
|
|
it("older games took nothing from a drained wall", () => {
|
|
let state = toRound2(createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic"], deckRev: 22 }).state);
|
|
const me = activePlayer(state);
|
|
const board = boardView(state);
|
|
const side = SIDES.find((s) => board.edges[edgeKey(me.position, s)] === "wall")!;
|
|
const pd = giveCard(state, me.id, "power-drain");
|
|
giveCard(state, me.id, "number-3", "N", 1);
|
|
state = must(state, me.id, { type: "cast", instanceId: pd.instanceId, target: { kind: "edge", cell: me.position, side }, numberInstanceIds: ["number-3#N"] });
|
|
expect(state.players.find((p) => p.id === me.id)!.life).toBe(15);
|
|
});
|
|
});
|