MASTER KEY and PICK LOCK each read: "You may 'hold the door open' for
others, if you wish" — and the engine always slammed it at end of
turn. Now the cast takes a hold param: the door stays unlocked past
the turn, for anyone, as long as its holder stands adjacent and
alive. A step away, a shove, a teleport, or a killing blow lets it
swing shut — swept after every command, since anything can move a
wizard. New state, new param: no old ledger contains either, so no
rev gate is needed.
The client offers a "hold the door open" checkbox when either card is
selected; a held door shows pale with a green jamb ("held open by a
standing wizard"), and the chronicle records the holding and the
shutting. Pinned: a held door outlives the turn and admits the other
wizard; walking away releases it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
412 lines
18 KiB
TypeScript
412 lines
18 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" });
|
|
state = must(state, defender, { 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"], deckRev: 14 });
|
|
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);
|
|
});
|
|
});
|