Fortune: GIFT FROM ABOVE (+3, no ceiling), GIFT FROM BELOW (a trap in the deck — 3 damage on the draw, harmless in the opening deal). Modifiers: POWER ATTACK burns life into any damage spell. Curses: WEAKNESS (drop your treasure, take double, carry nothing), STRENGTH (double physical dealt; the two cancel), WALKING DEAD (half a point per space walked, forever), DISEASE (the victim becomes a carrier who infects everyone in squares they enter), IDIOT (the victim shambles toward their nearest own treasure, able only to counteract, until they stand on it and ask "What am I doing here?"). Defense: EMPATHY (attacks bite their caster too), FORCE FIELD (spell-stopping counteraction). Mischief: MENTAL SWAP (trade whole hands), MENTAL FORCE (march someone three spaces), BUTT-HEAD (become a goat, ram for the distance charged), HEAVE-HO (throw your carried treasure as a weapon), THIEF and SWAP-MEET (item larceny), CHAOS (all hands in a pile, shuffled, redealt), ILLUSIONARY ATTACK (a fake spell that hurts if believed), WARD (a trapped treasure bites its thief), REMOVE CURSE (strip any duration spell, rolling to hit the shrunken or invisible), SWARTHMORE'S ENCHANTMENT (+1 on an enchanted object). Space-time: DIMENSIONAL WARP (paired step-through tokens), REDIRECTION (swap two outer exits' wraparounds), BIG MAN (fills the corridor: no entry, no punches, no casting past him), FEAR (nobody approaches within 3), and the out-of-turn pair — INTERRUPT and OPPORTUNITY FIRE — which open a one-action window in another player's turn. Only THUMB OF GOD remains, awaiting its digital redesign. 117 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
301 lines
14 KiB
TypeScript
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, "thumb-of-god"); // awaiting digital redesign
|
|
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/);
|
|
});
|
|
});
|