Files
wizwar6e/packages/engine/test/durations-doors-cards.test.ts
T
Eric WagonerandClaude Fable 5 4a91d56d24 Credibility pass: the collapse loses its scaffolding
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>
2026-08-21 13:04:01 -04:00

462 lines
20 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame, sustainedOn, type GameState } from "../src/game";
import { cellKey, edgeKey, neighbor, 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 until end of turn", () => {
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));
// Relocks when the turn ends.
state = must(state, me.id, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
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 unheld unlocked door still blocks sight", () => {
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 refused = applyCommand(state, holder, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: pursuer },
});
expect(refused.ok).toBe(false);
});
});