Files
wizwar6e/packages/engine/test/expansion-combat.test.ts
T
Eric WagonerandClaude Fable 5 89961bbb59 ADD joins two number cards on movement, as printed
"You may add two NUMBER cards together for any single action" —
movement included. The engine accepts a second movement number when an
ADD accompanies it (once per turn); the client spends your Add
automatically and says so on the button. 120 tests passing.

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

269 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
createGame,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey } from "../src/board";
import type { CardInstance } from "../src/cards";
function newGame(seed = 42) {
return createGame({ playerIds: ["alice", "bob"], seed, sets: ["basic", "expansion1"] });
}
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;
}
function giveCard(state: GameState, playerId: PlayerId, cardId: string, tag = "T", slot = 0): CardInstance {
const p = state.players.find((p) => p.id === playerId)!;
const instance = { instanceId: `${cardId}#${tag}`, cardId };
p.hand[slot] = instance;
return instance;
}
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 });
return state;
}
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 };
}
function castAt(
state: GameState, attacker: PlayerId, defender: PlayerId, card: CardInstance,
extra: Partial<Extract<Command, { type: "cast" }>> = {},
): GameState {
state = must(state, attacker, {
type: "cast", instanceId: card.instanceId,
target: { kind: "player", playerId: defender }, ...extra,
});
return must(state, defender, { type: "pass" });
}
describe("expansion combat cards", () => {
it("power attack burns life for extra damage", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const fb = giveCard(state, attacker, "fireball");
giveCard(state, attacker, "power-attack", "PA", 1);
state = castAt(state, attacker, defender, fb, {
powerAttackInstanceId: "power-attack#PA", powerAttackPoints: 3,
});
expect(state.players.find((p) => p.id === defender)!.life).toBe(7); // 5+3
expect(state.players.find((p) => p.id === attacker)!.life).toBe(12);
});
it("weakness doubles damage taken and forbids carrying treasure", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wk = giveCard(state, attacker, "weakness");
giveCard(state, attacker, "number-3", "N", 1);
state = castAt(state, attacker, defender, wk, { numberInstanceIds: ["number-3#N"] });
expect(sustainedOn(state, defender, "weakness").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
const t = state.treasures.find((t) => t.owner === attacker && t.position)!;
const d = state.players.find((p) => p.id === defender)!;
d.position = { ...t.position! };
expect(applyCommand(state, defender, { type: "pickUpTreasure" }).ok).toBe(false);
state = must(state, defender, { type: "endTurn", draw: 0 });
const fb = giveCard(state, attacker, "fireball", "F", 0);
const d2 = state.players.find((p) => p.id === defender)!;
d2.position = { ...state.players.find((p) => p.id === attacker)!.position };
state = castAt(state, attacker, defender, fb);
expect(state.players.find((p) => p.id === defender)!.life).toBe(5); // 5x2
});
it("walking dead bleeds half a point per space walked", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const wd = giveCard(state, attacker, "walking-dead");
state = castAt(state, attacker, defender, wd);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// Defender walks: every second step costs a point.
let lifeStart = state.players.find((p) => p.id === defender)!.life;
let steps = 0;
for (const dir of ["N", "S", "E", "W", "N", "S"] as const) {
const r = applyCommand(state, defender, { type: "move", direction: dir });
if (r.ok) { state = r.state; steps++; }
if (steps === 2) break;
}
if (steps === 2) {
expect(state.players.find((p) => p.id === defender)!.life).toBe(lifeStart - 1);
}
});
it("mental swap trades entire hands", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const ms = giveCard(state, attacker, "mental-swap");
const aCards = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId);
const dCards = state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId);
state = castAt(state, attacker, defender, ms);
const aAfter = state.players.find((p) => p.id === attacker)!.hand.map((c) => c.instanceId);
expect(aAfter).toEqual(dCards);
// (the swap card itself was consumed from the attacker's hand pre-swap)
expect(state.players.find((p) => p.id === defender)!.hand.map((c) => c.instanceId))
.toEqual(aCards.filter((id) => id !== ms.instanceId));
});
it("butt-head rams for the distance charged", () => {
let { state } = newGame();
state = toRound2(state);
const attacker = activePlayer(state);
const defender = state.players.find((p) => p.id !== attacker.id)!;
// Stand them 3 apart on the same column if possible; else same square +N.
defender.position = { x: attacker.position.x, y: attacker.position.y >= 3 ? attacker.position.y - 3 : attacker.position.y + 3 };
const bh = giveCard(state, attacker.id, "butt-head");
state = castAt(state, attacker.id, defender.id, bh);
const a = state.players.find((p) => p.id === attacker.id)!;
const d = state.players.find((p) => p.id === defender.id)!;
expect(cellKey(a.position)).toBe(cellKey(d.position));
expect(d.life).toBe(12); // 3 spaces = 3 damage
});
it("empathy turns an attack back on its caster as well", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
// Defender raises empathy on their own turn.
state = must(state, attacker, { type: "endTurn", draw: 0 });
const em = giveCard(state, defender, "empathy", "E", 0);
giveCard(state, defender, "number-3", "N", 1);
state = must(state, defender, {
type: "cast", instanceId: em.instanceId, numberInstanceIds: ["number-3#N"],
});
state = must(state, defender, { type: "endTurn", draw: 0 });
const fb = giveCard(state, attacker, "fireball", "F", 0);
state = castAt(state, attacker, defender, fb);
expect(state.players.find((p) => p.id === defender)!.life).toBe(10);
expect(state.players.find((p) => p.id === attacker)!.life).toBe(10);
});
it("ward springs when a trapped treasure is grabbed", () => {
let { state } = newGame();
const me = activePlayer(state);
const enemy = state.players.find((p) => p.id !== me.id)!;
giveCard(state, enemy.id, "ward", "W", 0);
const treasure = state.treasures.find((t) => t.owner === enemy.id && t.position)!;
me.position = { ...treasure.position! };
state = must(state, me.id, { type: "pickUpTreasure" });
expect(state.players.find((p) => p.id === me.id)!.life).toBe(12);
expect(state.players.find((p) => p.id === enemy.id)!.hand.some((c) => c.cardId === "ward")).toBe(false);
});
it("opportunity fire opens an out-of-turn attack window", () => {
let { state } = newGame();
state = toRound2(state);
const active = activePlayer(state).id;
const lurker = state.players.find((p) => p.id !== active)!;
lurker.position = { ...state.players.find((p) => p.id === active)!.position };
const of_ = giveCard(state, lurker.id, "opportunity-fire", "OF", 0);
const fb = giveCard(state, lurker.id, "fireball", "F", 1);
// Out of turn: play opportunity fire, then the attack.
state = must(state, lurker.id, { type: "cast", instanceId: of_.instanceId });
expect(state.outOfTurnWindow?.playerId).toBe(lurker.id);
state = must(state, lurker.id, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: active },
});
state = must(state, active, { type: "pass" });
expect(state.players.find((p) => p.id === active)!.life).toBe(10);
// Turn structure is intact: the original player is still active.
expect(activePlayer(state).id).toBe(active);
expect(state.outOfTurnWindow).toBeNull();
});
it("idiot marches its victim toward their own treasure and forbids casting", () => {
let { state } = newGame();
state = toRound2(state);
const { attacker, defender } = faceOff(state);
const id = giveCard(state, attacker, "idiot");
state = castAt(state, attacker, defender, id);
expect(sustainedOn(state, defender, "idiot").length).toBe(1);
state = must(state, attacker, { type: "endTurn", draw: 0 });
// Casting is refused; moving is steered (any direction request works).
const fb = giveCard(state, defender, "fireball", "F", 0);
expect(applyCommand(state, defender, {
type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: attacker },
}).ok).toBe(false);
const before = state.players.find((p) => p.id === defender)!.position;
const r = applyCommand(state, defender, { type: "move", direction: "N" });
if (r.ok) {
const after = r.state.players.find((p) => p.id === defender)!.position;
expect(cellKey(after)).not.toBe(cellKey(before));
}
});
});
describe("swap home bases", () => {
it("trades homes when both bases hold equal treasures", () => {
let { state } = newGame();
const me = activePlayer(state);
const other = state.players.find((p) => p.id !== me.id)!;
other.position = { ...me.position }; // LOS guaranteed
const myHome = { ...me.home };
const theirHome = { ...other.home };
const shb = giveCard(state, me.id, "swap-home-bases");
state = must(state, me.id, {
type: "cast", instanceId: shb.instanceId, target: { kind: "player", playerId: other.id },
});
expect(cellKey(state.players.find((p) => p.id === me.id)!.home)).toBe(cellKey(theirHome));
expect(cellKey(state.players.find((p) => p.id === other.id)!.home)).toBe(cellKey(myHome));
});
});
describe("thumb of god (divine meteor)", () => {
it("scatters every token near where the die lands", () => {
let { state } = newGame();
state = toRound2(state);
const me = activePlayer(state);
// Sprinkle the blast zone: a ground object and the enemy nearby.
const enemy = state.players.find((p) => p.id !== me.id)!;
enemy.position = { ...me.position };
state.groundObjects[cellKey(me.position)] = [{ instanceId: "dagger#G", cardId: "dagger" }];
const tog = giveCard(state, me.id, "thumb-of-god");
state = must(state, me.id, {
type: "cast", instanceId: tog.instanceId, target: { kind: "cell", cell: me.position },
});
expect(state.turn.attackUsed).toBe(true);
// The dagger moved somewhere on the board.
const allObjects = Object.values(state.groundObjects).flat();
expect(allObjects.some((c) => c.cardId === "dagger")).toBe(true);
});
});
describe("add for movement", () => {
it("an ADD permits a second number card on movement, once", () => {
let { state } = newGame();
const me = activePlayer(state).id;
giveCard(state, me, "number-3", "N1", 0);
giveCard(state, me, "number-2", "N2", 1);
giveCard(state, me, "add", "A", 2);
giveCard(state, me, "number-4", "N3", 3);
state = must(state, me, { type: "playNumberForMovement", instanceId: "number-3#N1" });
expect(state.turn.movementAllowance).toBe(6);
// Second without ADD: refused.
expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2" }).ok).toBe(false);
// With ADD: allowed.
state = must(state, me, { type: "playNumberForMovement", instanceId: "number-2#N2", addInstanceId: "add#A" });
expect(state.turn.movementAllowance).toBe(8);
// A third is refused even with another add wished for.
expect(applyCommand(state, me, { type: "playNumberForMovement", instanceId: "number-4#N3" }).ok).toBe(false);
});
});