"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>
269 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|