Wands charge on first use from the number card(s) played (Amplify and Add both work when setting charges), spend one charge per use with a hard once-per-turn limit, stay displayed while charged, and crumble to the discard when the last charge goes. They are isolated from their wielder — usable under NO SPELL — and Absorb Spell cannot eat them. BLASTER WAND fires 3-point bolts through the normal counteraction stack; STICKY WAND webs its victim (movement -3 for a turn, +2 to any fire damage, including hotter firewall crossings); SHIFT WAND shoves a wizard one space in any direction straight through stone walls; WARP WAND opens a wall section that snaps back at the end of the turn. DEJA-VU retrieves any discard except a wand. The wands' 5e-era "stick" subtype is renamed "wand" per Eric (the 6e faces say Wand; Exp2 shelf cards keep their period names). Client: charge badges, shove targeting, retrieve-by-name. 101 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
8.7 KiB
TypeScript
201 lines
8.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyCommand,
|
|
activePlayer,
|
|
createGame,
|
|
boardView,
|
|
type Command,
|
|
type GameState,
|
|
type PlayerId,
|
|
} from "../src/game";
|
|
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
|
|
function newGame(seed = 42) {
|
|
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
|
|
}
|
|
|
|
function must(state: GameState, player: PlayerId, command: Command): GameState {
|
|
const result = applyCommand(state, player, command);
|
|
if (!result.ok) throw new Error(`command failed: ${result.error}`);
|
|
return result.state;
|
|
}
|
|
|
|
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
|
|
const p = state.players.find((p) => p.id === playerId)!;
|
|
const instance = { instanceId: `${cardId}#${tag}`, cardId };
|
|
p.hand[slot] = instance;
|
|
return instance;
|
|
}
|
|
|
|
function toRound2(state: GameState): GameState {
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
return state;
|
|
}
|
|
|
|
function faceOff(state: GameState): { attacker: PlayerId; defender: PlayerId } {
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
return { attacker: attacker.id, defender: defender.id };
|
|
}
|
|
|
|
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);
|
|
|
|
// First use without a number card is refused.
|
|
expect(applyCommand(state, attacker, {
|
|
type: "cast", instanceId: wand.instanceId, target: { kind: "player", playerId: defender },
|
|
}).ok).toBe(false);
|
|
|
|
// 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) return; // no adjacent wall on this seed; covered elsewhere
|
|
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) return;
|
|
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("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);
|
|
});
|
|
});
|