Files
wizwar6e/packages/engine/test/expansion-combat.test.ts
T
Eric WagonerandClaude Fable 5 45666bca16 Ambushes: the async-native form of Interrupt and Opportunity Fire
Eric's diagnosis: "in the moment" interruption cards are worthless in
correspondence play — there is no moment. The answer was already in
the game: WARD is a contingency card, and ambushes generalize it. On
your turn, playing Interrupt or Opportunity Fire now arms an ambush:
commit it with an attack from your hand (plus an optional number
card) and a trigger — an opponent entering your line of sight, coming
beside you, or grabbing a treasure — and it springs automatically
when the condition occurs, whether you are watching or asleep. The
sprung attack opens the normal counteraction stack, so the victim
gets their defense (asynchronously, like any attack). Committed cards
leave your hand until the trap springs or you disarm it; ambushes are
invisible to everyone but their owner (view-level redaction), die
with their owner, stay armed if the shot is momentarily illegal, and
honor the no-combat first round. Live play in the moment still works
too — and the client now actually offers it (the old UI never let
you click those cards out of turn). The rail shows your armed traps
with a disarm control; the chronicle announces AMBUSH! when one
springs. 124 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 01:36:53 -04:00

335 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
applyCommand,
activePlayer,
boardView,
createGame,
gameLos,
sustainedOn,
type Command,
type GameState,
type PlayerId,
} from "../src/game";
import { cellKey, stepTarget } 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);
});
});
describe("ambushes (async interrupts)", () => {
it("an armed Opportunity Fire springs when prey walks into sight", () => {
let { state } = newGame();
state = toRound2(state);
const owner = activePlayer(state);
const of_ = giveCard(state, owner.id, "opportunity-fire", "OF", 0);
const fb = giveCard(state, owner.id, "fireball", "FB", 1);
state = must(state, owner.id, {
type: "setAmbush", instanceId: of_.instanceId, trigger: { kind: "los" },
spellInstanceId: fb.instanceId,
});
const o = state.players.find((p) => p.id === owner.id)!;
expect(o.hand.some((c) => c.cardId === "fireball")).toBe(false);
expect(state.ambushes.length).toBe(1);
state = must(state, owner.id, { type: "endTurn", draw: 0 });
const preyNow = state.players.find((p) => p.id !== owner.id)!;
const ownerNow = state.players.find((p) => p.id === owner.id)!;
// Find a step that goes from a no-LOS cell into a LOS cell.
let found: { from: { x: number; y: number }; side: "N" | "S" | "E" | "W" } | null = null;
outer: for (const key of Object.keys(state.board.cells)) {
const [x, y] = key.split(",").map(Number) as [number, number];
const cell = { x, y };
if (!gameLos(state, ownerNow.position, cell)) continue;
for (const side of ["N", "S", "E", "W"] as const) {
const dx = side === "E" ? 1 : side === "W" ? -1 : 0;
const dy = side === "S" ? 1 : side === "N" ? -1 : 0;
const from = { x: x - dx, y: y - dy };
if (!state.board.cells[cellKey(from)]) continue;
if (gameLos(state, ownerNow.position, from)) continue;
const st = stepTarget(boardView(state), from, side);
if (st.kind === "step" && cellKey(st.to) === key) { found = { from, side }; break outer; }
}
}
expect(found).not.toBeNull();
preyNow.position = found!.from;
state = must(state, preyNow.id, { type: "move", direction: found!.side });
expect(state.stack).not.toBeNull();
expect(state.stack!.attackerId).toBe(owner.id);
expect(state.stack!.attackCard!.cardId).toBe("fireball");
expect(state.ambushes.length).toBe(0);
state = must(state, preyNow.id, { type: "pass" });
expect(state.players.find((p) => p.id === preyNow.id)!.life).toBe(10);
});
it("cancelling an ambush returns the committed cards", () => {
let { state } = newGame();
const owner = activePlayer(state);
const int_ = giveCard(state, owner.id, "interrupt", "I", 0);
const lb = giveCard(state, owner.id, "lightning-blast", "LB", 1);
giveCard(state, owner.id, "number-3", "N", 2);
state = must(state, owner.id, {
type: "setAmbush", instanceId: int_.instanceId, trigger: { kind: "near" },
spellInstanceId: lb.instanceId, numberInstanceIds: ["number-3#N"],
});
const id = state.ambushes[0]!.id;
state = must(state, owner.id, { type: "cancelAmbush", ambushId: id });
const o = state.players.find((p) => p.id === owner.id)!;
expect(o.hand.some((c) => c.cardId === "interrupt")).toBe(true);
expect(o.hand.some((c) => c.cardId === "lightning-blast")).toBe(true);
expect(state.ambushes.length).toBe(0);
});
});