Files
wizwar6e/packages/engine/test/casting.test.ts
T
Eric WagonerandClaude Fable 5 efec82e88d Two-player decks shed LIFESAVER, as the card itself instructs
"Not applicable in a 2-player game." — the card face. New games deal
from deck revision 2, which removes it when exactly two wizards sit
down; three or more keep it. The revision travels in GameConfig and
the persisted start line, and stored games without one replay against
the original build — changing an existing game's deck composition
would scramble its deterministic replay. Test pins all three cases:
removed at two, present at three, present in legacy two-player games.

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

331 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, createGame } from "../src/game";
import { cellKey, edgeKey, hasLineOfSight, neighbor, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
import { newGame, must, giveCard, toRound2, faceOff } from "./helpers";
/** Test surgery: put a specific card into a player's hand (swapping one out). */
/** Advance past round 1 (both players just end their turns). */
/** Put attacker and defender in mutual LOS (same square works for spells too). */
describe("attack spells", () => {
it("fireball does 5 flat damage when unopposed", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
expect(state.stack).not.toBeNull();
state = must(state, defender, { type: "pass" });
expect(state.stack).toBeNull();
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
});
it("fireball destroys carried magic stones only if damage gets through", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "powerstone#T", cardId: "powerstone" };
d.hand[1] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
// Full shield stops everything -> stones survive.
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(after.hand.some((c) => c.cardId === "powerstone")).toBe(true);
});
it("lightning blast deals number-card damage and stuns", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const lb = giveCard(state, attacker, "lightning-blast");
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
state = must(state, attacker, {
type: "cast", instanceId: lb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
const d = state.players.find((p) => p.id === defender)!;
expect(d.life).toBe(11);
expect(d.lostTurns).toBe(1);
// The stunned player's next turn is skipped.
state = must(state, attacker, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(attacker); // defender was skipped
});
it("blunt halves damage rounding the result up; absorb takes 3 off", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "blunt#T", cardId: "blunt" };
d.hand[1] = { instanceId: "absorb#T", cardId: "absorb" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "blunt#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "counteract", instanceId: "absorb#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
// 5 -> blunt -> ceil(2.5)=3 -> absorb -> 0
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
// All damage stopped -> no stones logic, no stun, attack fully spent.
});
it("anti-anti nullifies a counteraction", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const a = state.players.find((p) => p.id === attacker)!;
a.hand[1] = { instanceId: "anti-anti#T", cardId: "anti-anti" };
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "full-shield#T", cardId: "full-shield" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "full-shield#T" });
state = must(state, attacker, { type: "counteract", instanceId: "anti-anti#T" });
state = must(state, defender, { type: "pass" });
// Shield nullified: full 5 damage lands.
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
});
it("full reflection sends the whole spell back at the caster", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "full-reflection#T", cardId: "full-reflection" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "full-reflection#T" });
state = must(state, attacker, { type: "pass" });
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
it("absorb spell nullifies the attack and takes the card into hand", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
const d = state.players.find((p) => p.id === defender)!;
d.hand[0] = { instanceId: "absorb-spell#T", cardId: "absorb-spell" };
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
state = must(state, defender, { type: "counteract", instanceId: "absorb-spell#T" });
const after = state.players.find((p) => p.id === defender)!;
expect(after.life).toBe(15);
expect(after.hand.some((c) => c.cardId === "fireball")).toBe(true);
expect(state.stack).toBeNull();
});
it("waterbolt splits number value between damage and knockback", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Same square: waterbolt needs a legal target; knockback is the engine's problem.
defender.position = { ...attacker.position };
const wb = giveCard(state, attacker.id, "waterbolt");
const a = state.players.find((p) => p.id === attacker.id)!;
a.hand[1] = { instanceId: "number-4#T", cardId: "number-4" };
const bad = applyCommand(state, attacker.id, {
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender.id },
params: { damage: 1, knockback: 1 },
});
expect(bad.ok).toBe(false); // must total 4
state = must(state, attacker.id, {
type: "cast", instanceId: wb.instanceId, numberInstanceId: "number-4#T",
target: { kind: "player", playerId: defender.id },
params: { damage: 3, knockback: 1 },
});
state = must(state, defender.id, { type: "pass" });
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(12);
});
it("requires line of sight for LOS attacks", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Find any cell without LOS from the attacker.
const view = boardView(state);
let hidden = null;
for (const key of Object.keys(view.cells)) {
const [x, y] = key.split(",").map(Number);
if (!hasLineOfSight(view, attacker.position, { x: x!, y: y! })) { hidden = { x: x!, y: y! }; break; }
}
expect(hidden).not.toBeNull();
defender.position = hidden!;
const fb = giveCard(state, attacker.id, "fireball");
const result = applyCommand(state, attacker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender.id },
});
expect(result.ok).toBe(false);
});
});
describe("neutral spells", () => {
it("create wall then destroy wall, with collapse damage", () => {
let { state } = newGame();
const caster = activePlayer(state);
// Find an open edge adjacent to the caster.
const view = boardView(state);
let edge: { cell: typeof caster.position; side: Side } | null = null;
for (const side of ["N", "S", "E", "W"] as Side[]) {
const n = neighbor(caster.position, side);
if (view.cells[cellKey(n)] && !(edgeKey(caster.position, side) in view.edges)) {
edge = { cell: caster.position, side };
break;
}
}
expect(edge).not.toBeNull();
const cw = giveCard(state, caster.id, "create-wall");
state = must(state, caster.id, {
type: "cast", instanceId: cw.instanceId,
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
});
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("wall");
// Destroying it hurts anyone standing beside it — the caster is adjacent.
const dw = giveCard(state, caster.id, "destroy-wall");
state = must(state, caster.id, {
type: "cast", instanceId: dw.instanceId,
target: { kind: "edge", cell: edge!.cell, side: edge!.side },
});
expect(boardView(state).edges[edgeKey(edge!.cell, edge!.side)]).toBe("open");
expect(state.players.find((p) => p.id === caster.id)!.life).toBe(11);
});
it("neutrals do not consume the attack and multiple can be cast", () => {
let { state } = newGame();
const caster = activePlayer(state);
const s1 = giveCard(state, caster.id, "speed", "T1");
state = must(state, caster.id, { type: "cast", instanceId: s1.instanceId });
expect(state.turn.attackUsed).toBe(false);
const s2 = giveCard(state, caster.id, "speed", "T2");
state = must(state, caster.id, { type: "cast", instanceId: s2.instanceId });
expect(state.players.find((p) => p.id === caster.id)!.extraTurns).toBe(2);
});
it("speed grants an extra turn before play passes", () => {
let { state } = newGame();
const caster = activePlayer(state);
const sp = giveCard(state, caster.id, "speed");
state = must(state, caster.id, { type: "cast", instanceId: sp.instanceId });
state = must(state, caster.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(caster.id); // extra turn, same player
state = must(state, caster.id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).not.toBe(caster.id);
});
});
describe("stack discipline", () => {
it("locks other commands while an attack is unresolved", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
state = must(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } });
// Attacker cannot move while awaiting the defender.
expect(applyCommand(state, attacker, { type: "move", direction: "N" }).ok).toBe(false);
// Defender cannot move either — only counteract or pass.
expect(applyCommand(state, defender, { type: "move", direction: "N" }).ok).toBe(false);
});
it("unimplemented cards refuse to cast with a clear error", () => {
let { state } = newGame();
const caster = activePlayer(state);
const card = giveCard(state, caster.id, "bomb"); // expansion2: historical, never implemented
const result = applyCommand(state, caster.id, { type: "cast", instanceId: card.instanceId });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/not implemented/);
});
});
describe("speedstone", () => {
it("displaying it grants the extra movement point immediately", () => {
let { state } = newGame();
state = toRound2(state);
const who = activePlayer(state).id;
giveCard(state, who, "speedstone");
expect(state.turn.movementAllowance).toBe(3);
state = must(state, who, { type: "cast", instanceId: "speedstone#T" });
expect(state.turn.movementAllowance).toBe(4);
// And it persists into later turns via the displayed stone.
state = must(state, who, { type: "endTurn", draw: 0 });
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
expect(activePlayer(state).id).toBe(who);
expect(state.turn.movementAllowance).toBe(4);
});
});
describe("the post-game reveal", () => {
it("shows an eliminated player's hand as they fell, not their emptied one", async () => {
const { viewFor } = await import("../src/view");
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
defender.position = { ...attacker.position };
defender.life = 1;
defender.hand = [
{ instanceId: "fireball#J", cardId: "fireball" },
{ instanceId: "number-6#J", cardId: "number-6" },
];
state = must(state, attacker.id, { type: "punch", targetId: defender.id });
state = must(state, defender.id, { type: "pass" });
expect(state.phase).toBe("finished");
// The killer took the cards — but the reveal remembers the fallen hand.
const view = viewFor(state, attacker.id);
const fallen = view.revealedHands?.[defender.id] ?? [];
expect(fallen.map((c) => c.cardId).sort()).toEqual(["fireball", "number-6"]);
});
});
describe("stored-log compatibility", () => {
it("replays the single-number command form older logs contain", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
giveCard(state, attacker, "number-3", "N", 1);
state = must(state, attacker, {
type: "cast", instanceId: fb.instanceId, numberInstanceId: "number-3#N",
target: { kind: "player", playerId: defender },
});
state = must(state, defender, { type: "pass" });
expect(state.players.find((p) => p.id === defender)!.life).toBe(15 - 5); // fireball: 2 + the 3
});
});
describe("deck revisions", () => {
it("revision 2 removes LIFESAVER at two players, and only there", () => {
const two = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
const inGame = (s: typeof two.state) =>
[...s.deck, ...s.discard, ...s.players.flatMap((p) => p.hand)].some((c) => c.cardId === "lifesaver");
expect(inGame(two.state)).toBe(false);
// Three players keep it; old two-player games (no deckRev) keep it too.
const three = createGame({ playerIds: ["a", "b", "c"], seed: 9, sets: ["basic", "expansion1"], deckRev: 2 });
expect(inGame(three.state)).toBe(true);
const legacy = createGame({ playerIds: ["a", "b"], seed: 9, sets: ["basic", "expansion1"] });
expect(inGame(legacy.state)).toBe(true);
});
});