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>
249 lines
11 KiB
TypeScript
249 lines
11 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);
|
|
});
|
|
});
|