Files
wizwar6e/packages/engine/test/wands.test.ts
T
Eric WagonerandClaude Fable 5 e8df6efcde Every wall card learns the rim (rules rev 40)
The full audit Wall of Fire started: WATERWALL crashes on a warp mouth,
washing both rims inward; ILLUSION WALL hangs its scrim on a mouth (the
create-wall bluff); STONE TO WATER melts a brick over a warp at both ends
and breaches a plain rim wall exactly as DESTROY WALL does — far side
washes out, a warp opens, the wave floods the fresh tunnel; the WARP WAND
bores a temporary wraparound that closes when its wall returns; and
CREATE DOOR stops hanging dead doors onto the void. Older ledgers keep
their refusals and danglings, and replay so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:04:27 -04:00

265 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
import { cellKey, edgeKey, SIDES, type Cell, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newExpansionGame as newGame, must, giveCard, toRound2, faceOff } from "./helpers";
describe("magic wands", () => {
it("blaster wand: charges on first use, once per turn, discards when spent", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wand = giveCard(state, attacker, "blaster-wand");
giveCard(state, attacker, "number-2", "N", 1);
// Charged with a 2: fires (3 damage), one charge left, wand displayed.
state = must(state, attacker, {
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
expect(state.wandCharges[wand.instanceId]).toBe(1);
const a = state.players.find((p) => p.id === attacker)!;
expect(a.displayed).toContain(wand.instanceId);
// A second use the same turn is refused ("once per turn") — even with
// adrenaline-style second attacks it is the WAND that is limited.
expect(applyCommand(state, attacker, {
type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender },
}).ok).toBe(false);
// Next turn: the last charge fires and the wand crumbles to the discard.
state = must(state, attacker, { type: "endTurn", draw: 0 });
state = must(state, defender, { type: "endTurn", draw: 0 });
state = must(state, attacker, {
type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(9);
const a2 = state.players.find((p) => p.id === attacker)!;
expect(a2.hand.some((c) => c.instanceId === wand.instanceId)).toBe(false);
expect(state.wandCharges[wand.instanceId]).toBeUndefined();
});
it("wands fire even under NO SPELL, and Absorb Spell cannot eat them", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
// Defender silences the attacker first: give defender the first move.
state.sustained.push({
id: "fx-ns", cardId: "no-spell", casterId: defender, targetId: attacker,
remainingTurns: 3, data: {},
});
const wand = giveCard(state, attacker, "blaster-wand");
giveCard(state, attacker, "number-2", "N", 1);
giveCard(state, defender, "absorb-spell", "AS", 0);
// Fireball is silenced...
const fb = giveCard(state, attacker, "fireball", "F", 2);
expect(applyCommand(state, attacker, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender },
}).ok).toBe(false);
// ...but the wand is isolated from the wizard.
state = must(state, attacker, {
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
target: { kind: "player", playerId: defender },
});
// Absorb Spell has no effect on magic wands.
expect(applyCommand(state, defender, { type: "counteract", instanceId: "absorb-spell#AS" }).ok).toBe(false);
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
});
it("sticky wand webs the target: movement -3 and fire burns hotter", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wand = giveCard(state, attacker, "sticky-wand");
giveCard(state, attacker, "number-3", "N", 1);
state = must(state, attacker, {
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-3#N"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
state = must(state, attacker, { type: "endTurn", draw: 0 });
// Webbed: base 3 movement drops to 0.
expect(activePlayer(state).id).toBe(defender);
expect(state.turn.movementAllowance).toBe(0);
});
it("shift wand shoves a wizard through a wall", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const d = state.players.find((p) => p.id === defender)!;
// Find a walled direction beside the defender with a real cell behind it.
const view = boardView(state);
let through: Cell | null = null;
for (const side of SIDES) {
const k = edgeKey(d.position, side);
const dest = { x: d.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: d.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.edges[k] === "wall" && view.cells[cellKey(dest)]) { through = dest; break; }
}
if (!through) throw new Error("setup: seed 42 lost its adjacent wall");
const wand = giveCard(state, attacker, "shift-wand");
giveCard(state, attacker, "number-2", "N", 1);
state = must(state, attacker, {
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
target: { kind: "player", playerId: defender },
params: { cell: through },
});
state = must(state, defender, { type: "pass" });
expect(cellKey(state.players.find((p) => p.id === defender)!.position)).toBe(cellKey(through));
});
it("warp wand opens a wall for one turn only", () => {
let { state } = newGame();
const me = activePlayer(state);
const view = boardView(state);
let edge: { cell: Cell; side: Side } | null = null;
for (const side of SIDES) {
const k = edgeKey(me.position, side);
const dest = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.edges[k] === "wall" && view.cells[cellKey(dest)]) {
edge = { cell: me.position, side };
break;
}
}
if (!edge) throw new Error("setup: seed 42 lost its adjacent wall");
const key = edgeKey(edge.cell, edge.side);
const wand = giveCard(state, me.id, "warp-wand");
giveCard(state, me.id, "number-2", "N", 1);
state = must(state, me.id, {
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
target: { kind: "edge", cell: edge.cell, side: edge.side },
});
// Open now: walk through.
state = must(state, me.id, { type: "move", direction: edge.side });
// At end of turn the wall reappears.
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(boardView(state).edges[key]).toBe("wall");
});
it("warp wand bores the rim for one turn from rev 40 — a temporary wraparound", () => {
let { state } = createGame({
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 40,
});
const me = activePlayer(state);
const view = boardView(state);
// A plain rim wall: off-board neighbor, no existing warp mouth.
let rim: { cell: Cell; side: Side } | null = null;
outer: for (const k of Object.keys(view.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const dest = { x: x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
if (view.cells[cellKey(dest)]) continue;
if (view.edges[edgeKey({ x, y }, side)] !== "wall") continue;
if (state.board.warps.some((w) => cellKey(w.from.cell) === k && w.from.side === side)) continue;
rim = { cell: { x, y }, side };
break outer;
}
}
if (!rim) throw new Error("setup: no plain rim wall found");
me.position = { ...rim.cell };
const warpsBefore = state.board.warps.length;
const wand = giveCard(state, me.id, "warp-wand");
giveCard(state, me.id, "number-2", "N", 1);
state = must(state, me.id, {
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-2#N"],
target: { kind: "edge", cell: rim.cell, side: rim.side },
});
expect(state.board.warps.length).toBe(warpsBefore + 2);
// The wraparound runs: step off the rim and land on the opposite edge.
state = must(state, me.id, { type: "move", direction: rim.side });
const across = state.players.find((p) => p.id === me.id)!.position;
expect(cellKey(across)).not.toBe(cellKey(rim.cell));
// Turn's end: the walls return and the warp closes with them.
state = must(state, me.id, { type: "endTurn", draw: 0 });
expect(boardView(state).edges[edgeKey(rim.cell, rim.side)]).toBe("wall");
expect(state.board.warps.length).toBe(warpsBefore);
});
it("deja-vu retrieves from the discard, but never a wand", () => {
let { state } = newGame();
const me = activePlayer(state);
state.discard.push({ instanceId: "fireball#D", cardId: "fireball" });
state.discard.push({ instanceId: "blaster-wand#D", cardId: "blaster-wand" });
const dv = giveCard(state, me.id, "deja-vu");
expect(applyCommand(state, me.id, {
type: "cast", instanceId: dv.instanceId, params: { cardId: "blaster-wand" },
}).ok).toBe(false);
state = must(state, me.id, { type: "cast", instanceId: dv.instanceId, params: { cardId: "fireball" } });
expect(state.players.find((p) => p.id === me.id)!.hand.some((c) => c.cardId === "fireball")).toBe(true);
});
});
describe("dropping objects", () => {
it("a charged wand can be dropped and picked up, charges intact", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wand = giveCard(state, attacker, "blaster-wand");
giveCard(state, attacker, "number-3", "N", 1);
// Charge it with a 3 (one charge spent on the shot, two remain).
state = must(state, attacker, {
type: "cast", instanceId: wand.instanceId, numberInstanceIds: ["number-3#N"],
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
expect(state.wandCharges[wand.instanceId]).toBe(2);
// Set it down: leaves the hand and the displayed list, lands on the floor.
state = must(state, attacker, { type: "dropObject", instanceId: wand.instanceId });
const owner = state.players.find((p) => p.id === attacker)!;
expect(owner.hand.some((c) => c.instanceId === wand.instanceId)).toBe(false);
expect(owner.displayed.includes(wand.instanceId)).toBe(false);
const key = cellKey(owner.position);
expect((state.groundObjects[key] ?? []).some((c) => c.instanceId === wand.instanceId)).toBe(true);
// The wizard standing there picks it up on their turn — charges travel.
state = must(state, attacker, { type: "endTurn", draw: 0 });
state = must(state, defender, { type: "pickUpObject", instanceId: wand.instanceId });
const thief = state.players.find((p) => p.id === defender)!;
expect(thief.hand.some((c) => c.instanceId === wand.instanceId)).toBe(true);
expect(state.wandCharges[wand.instanceId]).toBe(2);
expect(state.players.find((p) => p.id === defender)!.id).toBe(activePlayer(state).id);
});
it("the dagger and wizardblade can be set down; spell cards cannot", () => {
let { state } = newGame();
state = toRound2(state);
const who = activePlayer(state).id;
const dagger = giveCard(state, who, "dagger");
const fireball = giveCard(state, who, "fireball", "F", 1);
state = must(state, who, { type: "dropObject", instanceId: dagger.instanceId });
expect((state.groundObjects[cellKey(activePlayer(state).position)] ?? [])
.some((c) => c.cardId === "dagger")).toBe(true);
const refused = applyCommand(state, who, { type: "dropObject", instanceId: fireball.instanceId });
expect(refused.ok).toBe(false);
});
});
describe("a bare wand lights a single charge", () => {
it("'played without a number card, its power is only 1': one shot, then dust", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const bw = giveCard(state, attacker, "blaster-wand");
state = must(state, attacker, {
type: "cast", instanceId: bw.instanceId, target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
// The single charge fired (3 flat damage) and the wand crumbled.
expect(state.players.find((p) => p.id === defender)!.life).toBe(12);
expect(state.players.find((p) => p.id === attacker)!.hand.some((c) => c.cardId === "blaster-wand")).toBe(false);
expect(state.wandCharges[bw.instanceId]).toBeUndefined();
});
});