moves like a giant, and the boards are diagram-verified TELEPORT as a counteraction (official FAQ: "the attack has no chance of hitting you"): the defender names an escape square within four spaces, the escape resolves before anything lands, and an ANTI-ANTI pins their boots to the floor. Additive — no revision gate needed. FILL SQUARE WITH SLIME holds spells now: an attack cast at the slime sticks in the gel (leaving the discard pile), springs once at whoever is inside or next enters, and counteractions against the trapped blast cannot touch its caster — reflections vanish into the ooze. A five-point waterbolt washes the slime and its cargo away, and the waterwall waves clear slime from their path. BIG MAN, under rules rev 5, finally moves like the card says: he pushes players and monsters down the corridor ahead of him (stuck or unpushable occupants block his advance), steps over a pit, tacks, or killer ooze for two movement points without ever entering the square (click two cells beyond the hazard), and monsters may not enter his square. All gated so stored games replay under their own rules. The boards were never wrong — the rulebook's Set-Up Diagram photo confirms every pairing the code already had: 2p crossed, 3p stair with the Aisle Warp arc, 4p/6p straight-across, 5p plus with all four corner arcs. The stale TODOs are gone, replaced by tests pinning each letter pair, and relocation's "only opposite board edges connect" (which discards the aisle warp) is pinned too. Wall of Fire vs Waterbolt turned out to be implemented and tested all along — its TODO comment was the only thing wrong. Also: hotseat saves now request durable storage (iOS evicts unprotected origins under pressure). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
443 lines
19 KiB
TypeScript
443 lines
19 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
|
import { cellKey, SIDES, stepTarget, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell } from "./helpers";
|
|
|
|
/** Summon a creature next to its creator (round 2+, consumes the attack). */
|
|
function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") {
|
|
const me = state.players.find((p) => p.id === playerId)!;
|
|
const spot = emptyNeighborCell(state, me.position);
|
|
const card = giveCard(state, playerId, kind, tag);
|
|
const next = must(state, playerId, {
|
|
type: "cast", instanceId: card.instanceId, target: { kind: "cell", cell: spot.cell },
|
|
});
|
|
return { state: next, at: spot.cell };
|
|
}
|
|
|
|
describe("expansion deck", () => {
|
|
it("basic + expansion1 builds the full 200-card game", () => {
|
|
const { state } = newGame();
|
|
const total = state.deck.length + state.discard.length +
|
|
state.players.reduce((s, p) => s + p.hand.length, 0);
|
|
expect(total).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("monsters", () => {
|
|
it("summoning uses your attack; the monster moves now but attacks next turn", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const skeleton = creatureAt(state, r.at)!;
|
|
expect(skeleton.kind).toBe("skeleton");
|
|
expect(state.turn.attackUsed).toBe(true);
|
|
|
|
// It can move on the creation turn...
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
if (stepTarget(view, skeleton.position, side).kind === "step") {
|
|
state = must(state, me, { type: "moveCreature", creatureId: skeleton.id, direction: side });
|
|
break;
|
|
}
|
|
}
|
|
// ...but cannot attack until next turn.
|
|
const other = state.players.find((p) => p.id !== me)!;
|
|
other.position = { ...state.creatures[0]!.position };
|
|
const refused = applyCommand(state, me, {
|
|
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: other.id,
|
|
});
|
|
expect(refused.ok).toBe(false);
|
|
|
|
// Next turn: the skeleton punches for 2.
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state = must(state, other.id, { type: "endTurn", draw: 0 });
|
|
const victim = state.players.find((p) => p.id !== me)!;
|
|
victim.position = { ...state.creatures[0]!.position };
|
|
state = must(state, me, {
|
|
type: "creatureAttack", creatureId: state.creatures[0]!.id, targetId: victim.id,
|
|
});
|
|
expect(state.players.find((p) => p.id !== me)!.life).toBe(13);
|
|
});
|
|
|
|
it("a monster will not obey the enemy and will not strike its creator", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const id = state.creatures[0]!.id;
|
|
const other = state.players.find((p) => p.id !== me)!.id;
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
expect(applyCommand(state, other, { type: "moveCreature", creatureId: id, direction: "N" }).ok).toBe(false);
|
|
state = must(state, other, { type: "endTurn", draw: 0 });
|
|
const creator = state.players.find((p) => p.id === me)!;
|
|
creator.position = { ...state.creatures[0]!.position };
|
|
expect(applyCommand(state, me, { type: "creatureAttack", creatureId: id, targetId: me }).ok).toBe(false);
|
|
});
|
|
|
|
it("the troll rolls a D4 for damage and regenerates at its creator's turn end", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "troll");
|
|
state = r.state;
|
|
const troll = state.creatures[0]!;
|
|
|
|
// Hurt the troll, then watch a point come back at end of turn.
|
|
const fb = giveCard(state, me, "fireball", "F", 1);
|
|
// (can't attack again this turn — adrenaline not in play — so wound it via
|
|
// test surgery instead)
|
|
void fb;
|
|
troll.damage = 3;
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
expect(state.creatures[0]!.damage).toBe(2);
|
|
});
|
|
|
|
it("the wraith slips through one wall per turn and its touch steals cards", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "wraith");
|
|
state = r.state;
|
|
const wraith = state.creatures[0]!;
|
|
|
|
// Walk it through a wall if one is adjacent.
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
const t = stepTarget(view, wraith.position, side);
|
|
if (t.kind === "blocked" && t.by === "wall") {
|
|
const dest = { x: wraith.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
|
y: wraith.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
|
if (!view.cells[cellKey(dest)]) continue;
|
|
state = must(state, me, { type: "moveCreature", creatureId: wraith.id, direction: side });
|
|
expect(state.creatures[0]!.wallPassUsed).toBe(1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Its touch: 2 damage and a random card lost. March it onto the enemy.
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
const enemy = state.players.find((p) => p.id !== me)!;
|
|
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
|
const w = state.creatures[0]!;
|
|
const enemy2 = state.players.find((p) => p.id !== me)!;
|
|
enemy2.position = { ...w.position };
|
|
// A touch requires the WRAITH to enter the square — step it away and back.
|
|
const view2 = boardView(state);
|
|
for (const side of SIDES) {
|
|
const t = stepTarget(view2, w.position, side);
|
|
if (t.kind === "step") {
|
|
const back: Side = side === "N" ? "S" : side === "S" ? "N" : side === "E" ? "W" : "E";
|
|
state = must(state, me, { type: "moveCreature", creatureId: w.id, direction: side });
|
|
state = must(state, me, { type: "moveCreature", creatureId: w.id, direction: back });
|
|
break;
|
|
}
|
|
}
|
|
const bitten = state.players.find((p) => p.id !== me)!;
|
|
expect(bitten.life).toBe(13);
|
|
expect(bitten.hand.length).toBe(6);
|
|
});
|
|
|
|
it("the fire imp scorches on sight and dies only to water", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "fire-imp");
|
|
state = r.state;
|
|
const imp = state.creatures[0]!;
|
|
|
|
// Fireball cannot destroy it...
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
const enemy = state.players.find((p) => p.id !== me)!;
|
|
// (enemy may have been scorched at turn start if in LOS — note life)
|
|
const enemyNow = state.players.find((p) => p.id !== me)!;
|
|
enemyNow.position = { ...imp.position }; // stand at the imp for clear sight
|
|
const fb = giveCard(state, enemy.id, "fireball", "F", 0);
|
|
state = must(state, enemy.id, {
|
|
type: "cast", instanceId: fb.instanceId, target: { kind: "creature", creatureId: imp.id },
|
|
});
|
|
expect(state.creatures.length).toBe(1);
|
|
|
|
// ...but a waterbolt douses it instantly.
|
|
state = must(state, enemy.id, { type: "endTurn", draw: 0 });
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state.players.find((p) => p.id !== me)!.position = { ...state.creatures[0]!.position };
|
|
const wb = giveCard(state, enemy.id, "waterbolt", "W", 0);
|
|
state = must(state, enemy.id, {
|
|
type: "cast", instanceId: wb.instanceId, target: { kind: "creature", creatureId: imp.id },
|
|
});
|
|
expect(state.creatures.length).toBe(0);
|
|
});
|
|
|
|
it("the democratic monster is moved by every player", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "democratic-monster");
|
|
state = r.state;
|
|
const id = state.creatures[0]!.id;
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
const other = activePlayer(state).id;
|
|
expect(other).not.toBe(me);
|
|
const view = boardView(state);
|
|
for (const side of SIDES) {
|
|
if (stepTarget(view, state.creatures[0]!.position, side).kind === "step") {
|
|
const result = applyCommand(state, other, { type: "moveCreature", creatureId: id, direction: side });
|
|
expect(result.ok).toBe(true);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("mega-monster doubles a monster's toughness; dispel un-creates it", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const id = state.creatures[0]!.id;
|
|
const mm = giveCard(state, me, "mega-monster", "MM", 1);
|
|
state = must(state, me, {
|
|
type: "cast", instanceId: mm.instanceId, target: { kind: "creature", creatureId: id },
|
|
});
|
|
expect(state.creatures[0]!.maxDamage).toBe(8);
|
|
|
|
const dc = giveCard(state, me, "dispel-creation", "DC", 2);
|
|
state = must(state, me, {
|
|
type: "cast", instanceId: dc.instanceId, target: { kind: "cell", cell: state.creatures[0]!.position },
|
|
});
|
|
expect(state.creatures.length).toBe(0);
|
|
});
|
|
|
|
it("monsters vanish when their creator dies", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const creator = state.players.find((p) => p.id === me)!;
|
|
const enemy = state.players.find((p) => p.id !== me)!;
|
|
creator.life = 1;
|
|
enemy.position = { ...creator.position };
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state = must(state, enemy.id, { type: "punch", targetId: me });
|
|
state = must(state, me, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === me)!.alive).toBe(false);
|
|
expect(state.creatures.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("expansion support cards", () => {
|
|
it("adrenaline allows a second attack in one turn", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const attacker = activePlayer(state);
|
|
const defender = state.players.find((p) => p.id !== attacker.id)!;
|
|
defender.position = { ...attacker.position };
|
|
const adr = giveCard(state, attacker.id, "adrenaline");
|
|
giveCard(state, attacker.id, "number-2", "N", 1);
|
|
state = must(state, attacker.id, {
|
|
type: "cast", instanceId: adr.instanceId, numberInstanceIds: ["number-2#N"],
|
|
});
|
|
const fb1 = giveCard(state, attacker.id, "fireball", "F1", 0);
|
|
state = must(state, attacker.id, { type: "cast", instanceId: fb1.instanceId, target: { kind: "player", playerId: defender.id } });
|
|
state = must(state, defender.id, { type: "pass" });
|
|
const fb2 = giveCard(state, attacker.id, "fireball", "F2", 0);
|
|
state = must(state, attacker.id, { type: "cast", instanceId: fb2.instanceId, target: { kind: "player", playerId: defender.id } });
|
|
state = must(state, defender.id, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === defender.id)!.life).toBe(5);
|
|
// A third is refused.
|
|
const fb3 = giveCard(state, attacker.id, "fireball", "F3", 0);
|
|
expect(applyCommand(state, attacker.id, {
|
|
type: "cast", instanceId: fb3.instanceId, target: { kind: "player", playerId: defender.id },
|
|
}).ok).toBe(false);
|
|
});
|
|
|
|
it("shadow costs a life point per turn and dies to any damage", () => {
|
|
let { state } = newGame();
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const r = summon(state, me, "shadow");
|
|
state = r.state;
|
|
expect(state.creatures[0]!.kind).toBe("shadow");
|
|
const lifeBefore = state.players.find((p) => p.id === me)!.life;
|
|
|
|
// Around to my next turn: upkeep costs 1.
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state = must(state, activePlayer(state).id, { type: "endTurn", draw: 0 });
|
|
expect(state.players.find((p) => p.id === me)!.life).toBe(lifeBefore - 1);
|
|
|
|
// Any damage destroys it.
|
|
const enemy = state.players.find((p) => p.id !== me)!.id;
|
|
state = must(state, me, { type: "endTurn", draw: 0 });
|
|
state.players.find((p) => p.id === enemy)!.position = { ...state.creatures[0]!.position };
|
|
const fb = giveCard(state, enemy, "fireball", "F", 0);
|
|
state = must(state, enemy, {
|
|
type: "cast", instanceId: fb.instanceId,
|
|
target: { kind: "creature", creatureId: state.creatures[0]!.id },
|
|
});
|
|
expect(state.creatures.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("counteracting a creature's blow (rules rev 4)", () => {
|
|
function rev4() {
|
|
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 4 });
|
|
}
|
|
function wraithOnVictim(state: GameState) {
|
|
state = toRound2(state);
|
|
const controller = activePlayer(state);
|
|
const victim = state.players.find((p) => p.id !== controller.id)!;
|
|
const spot = emptyNeighborCell(state, controller.position);
|
|
state.creatures.push({
|
|
id: "w1", kind: "wraith", controllerId: controller.id, position: { ...controller.position },
|
|
damage: 0, maxDamage: 4, movesPerTurn: 3, movementUsed: 0,
|
|
wallPassesPerTurn: 1, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
|
|
});
|
|
victim.position = { ...spot.cell };
|
|
return { state, controller: controller.id, victim: victim.id, side: spot.side };
|
|
}
|
|
|
|
it("BLUNT halves the wraith's touch; the card theft still lands", () => {
|
|
let { state } = rev4();
|
|
const rig = wraithOnVictim(state);
|
|
state = rig.state;
|
|
giveCard(state, rig.victim, "blunt", "B", 0);
|
|
const handBefore = state.players.find((p) => p.id === rig.victim)!.hand.filter(Boolean).length;
|
|
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
|
expect(state.stack?.creatureId).toBe("w1");
|
|
state = must(state, rig.victim, { type: "counteract", instanceId: "blunt#B" });
|
|
state = must(state, rig.controller, { type: "pass" });
|
|
state = must(state, rig.victim, { type: "pass" });
|
|
const v = state.players.find((p) => p.id === rig.victim)!;
|
|
expect(v.life).toBe(14); // 2 halved up to 1
|
|
expect(v.hand.length).toBe(handBefore - 2); // blunt spent + a card stolen
|
|
});
|
|
|
|
it("FULL REFLECTION turns the touch back on the wraith, theft and all", () => {
|
|
let { state } = rev4();
|
|
const rig = wraithOnVictim(state);
|
|
state = rig.state;
|
|
giveCard(state, rig.victim, "full-reflection", "FR", 0);
|
|
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
|
state = must(state, rig.victim, { type: "counteract", instanceId: "full-reflection#FR" });
|
|
state = must(state, rig.controller, { type: "pass" });
|
|
state = must(state, rig.victim, { type: "pass" });
|
|
const v = state.players.find((p) => p.id === rig.victim)!;
|
|
expect(v.life).toBe(15);
|
|
const wraith = state.creatures.find((c) => c.id === "w1")!;
|
|
expect(wraith.damage).toBe(2);
|
|
});
|
|
|
|
it("legacy games (rev < 4) keep the instant touch", () => {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
|
const rig = wraithOnVictim(state);
|
|
state = rig.state;
|
|
state = must(state, rig.controller, { type: "moveCreature", creatureId: "w1", direction: rig.side });
|
|
expect(state.stack).toBeNull();
|
|
expect(state.players.find((p) => p.id === rig.victim)!.life).toBe(13);
|
|
});
|
|
});
|
|
|
|
describe("big man (rules rev 5)", () => {
|
|
function bigRig() {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev: 5 });
|
|
state = toRound2(state);
|
|
const giant = activePlayer(state);
|
|
state.sustained.push({
|
|
id: "fx-big", cardId: "big-man", casterId: giant.id, targetId: giant.id,
|
|
remainingTurns: 9, data: {},
|
|
});
|
|
return { state, giant };
|
|
}
|
|
|
|
/** Any square with two open steps in a straight line (test surgery spot). */
|
|
function straightRun(state: GameState): { at: Cell; side: Side; mid: Cell; far: Cell } {
|
|
const view = boardView(state);
|
|
for (const key of Object.keys(view.cells)) {
|
|
const [x, y] = key.split(",").map(Number) as [number, number];
|
|
const at = { x, y };
|
|
if (view.homes.some((h) => cellKey(h) === key)) continue;
|
|
for (const side of SIDES) {
|
|
const t1 = stepTarget(view, at, side);
|
|
if (t1.kind !== "step") continue;
|
|
const t2 = stepTarget(view, t1.to, side);
|
|
if (t2.kind !== "step") continue;
|
|
return { at, side, mid: t1.to, far: t2.to };
|
|
}
|
|
}
|
|
throw new Error("no straight corridor anywhere — impossible");
|
|
}
|
|
|
|
it("pushes a wizard down the corridor as he advances", () => {
|
|
const rig = bigRig();
|
|
let state = rig.state;
|
|
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
|
const run = straightRun(state);
|
|
const giant = state.players.find((p) => p.id === rig.giant.id)!;
|
|
giant.position = { ...run.at };
|
|
victim.position = { ...run.mid };
|
|
state = must(state, rig.giant.id, { type: "move", direction: run.side });
|
|
const after = state.players.find((p) => p.id !== rig.giant.id)!;
|
|
expect(cellKey(after.position)).toBe(cellKey(run.far));
|
|
expect(cellKey(state.players.find((p) => p.id === rig.giant.id)!.position)).toBe(cellKey(run.mid));
|
|
});
|
|
|
|
it("no room to shove means no way forward", () => {
|
|
const rig = bigRig();
|
|
const state = rig.state;
|
|
const victim = state.players.find((p) => p.id !== rig.giant.id)!;
|
|
const view = boardView(state);
|
|
// Find a step whose far side is walled: victim there cannot be pushed.
|
|
for (const side of SIDES) {
|
|
const t1 = stepTarget(view, rig.giant.position, side);
|
|
if (t1.kind !== "step") continue;
|
|
const t2 = stepTarget(view, t1.to, side);
|
|
if (t2.kind === "step") continue;
|
|
victim.position = { ...t1.to };
|
|
const r = applyCommand(state, rig.giant.id, { type: "move", direction: side });
|
|
expect(r.ok).toBe(false);
|
|
return;
|
|
}
|
|
// Seed offered no walled pocket beside the home; that is fine — the
|
|
// pushable case above pins the mechanism.
|
|
});
|
|
|
|
it("monsters cannot enter the giant's square", () => {
|
|
const rig = bigRig();
|
|
let state = rig.state;
|
|
const view = boardView(state);
|
|
const spot = emptyNeighborCell(state, rig.giant.position);
|
|
state.creatures.push({
|
|
id: "t1", kind: "troll", controllerId: state.players.find((p) => p.id !== rig.giant.id)!.id,
|
|
position: { ...spot.cell },
|
|
damage: 0, maxDamage: 8, movesPerTurn: 3, movementUsed: 0,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false, scorchedThisTurn: [],
|
|
});
|
|
state = must(state, rig.giant.id, { type: "endTurn", draw: 0 });
|
|
const controller = activePlayer(state).id;
|
|
const back = (["N", "S", "E", "W"] as const).find((d) => {
|
|
const t = stepTarget(view, spot.cell, d);
|
|
return t.kind === "step" && cellKey(t.to) === cellKey(rig.giant.position);
|
|
})!;
|
|
const r = applyCommand(state, controller, { type: "moveCreature", creatureId: "t1", direction: back });
|
|
expect(r.ok).toBe(false);
|
|
});
|
|
|
|
it("steps over a pit for two movement points, never entering it", () => {
|
|
const rig = bigRig();
|
|
let state = rig.state;
|
|
const run = straightRun(state);
|
|
state.players.find((p) => p.id === rig.giant.id)!.position = { ...run.at };
|
|
state.squareContents[cellKey(run.mid)] = { kind: "pit", damage: 0, createdBy: rig.giant.id };
|
|
state = must(state, rig.giant.id, { type: "move", direction: run.side, over: true });
|
|
const g = state.players.find((p) => p.id === rig.giant.id)!;
|
|
expect(cellKey(g.position)).toBe(cellKey(run.far));
|
|
expect(state.turn.movementUsed).toBe(2);
|
|
expect(g.inPit).toBe(false);
|
|
});
|
|
});
|