Files
wizwar6e/packages/engine/test/casting.test.ts
T
Eric WagonerandClaude Fable 5 c9524a37db Thumb Of God lands: the 6th edition is 100% implemented
Eric chose the "divine meteor" redesign for the one card that cannot
be digitized faithfully (the physical version has you flick the die at
the board from six inches). Digital form: aim at a square in sight;
the die drifts 0-2 squares in a random direction, then every token in
and around the landing square — ground objects, treasures, creatures,
and wizards alike — is flung to a random nearby square. Walls mean
nothing to falling cardboard; tokens knocked off the board settle at
the nearest edge, per the original card; there is no counteraction.

With this, all 128 unique cards of the 6th edition game (69 basic +
59 Expansion Set #1) are implemented, tested, and playable online.
The "unimplemented card" guard test now points at an Expansion #2
shelf card, which is the only kind left. 119 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 23:05:47 -04:00

301 lines
14 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, hasLineOfSight, neighbor, type Side } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic"] });
}
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;
}
/** Test surgery: put a specific card into a player's hand (swapping one out). */
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T"): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[0] = instance;
return instance;
}
/** Advance past round 1 (both players just end their turns). */
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 });
expect(state.turn.round).toBe(2);
return state;
}
/** Put attacker and defender in mutual LOS (same square works for spells too). */
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("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)!;
// Stand defender 1 east of attacker with open space behind (find a spot):
// use same square then nudge — simplest robust arrangement: same square,
// knockback direction defaults to none, so instead place east if open.
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/);
});
});