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>
250 lines
12 KiB
TypeScript
250 lines
12 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, plainRimWall } 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: a wraparound that closes at turn's end", () => {
|
|
let { state } = createGame({
|
|
playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"],
|
|
});
|
|
const me = activePlayer(state);
|
|
const rim = plainRimWall(state);
|
|
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();
|
|
});
|
|
});
|