DESTROY WALL's "anyone in either square next to the wall takes 4 points" swept only wizards; a troll leaned on the falling masonry untouched. The expansion sheet is explicit — "if a card specifies 'Opponent' or 'Anyone' as a target, this includes Monsters" — so the collapse now crushes creatures in both squares, at the rim breach's far side too. Earlier ledgers keep their charmed monsters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
686 lines
31 KiB
TypeScript
686 lines
31 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 Cell, 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);
|
|
|
|
// "Doubles the existing life-points OR movement rate" — the other choice.
|
|
const movesBefore = state.creatures[0]!.movesPerTurn;
|
|
const mm2 = giveCard(state, me, "mega-monster", "MM2", 3);
|
|
state = must(state, me, {
|
|
type: "cast", instanceId: mm2.instanceId, target: { kind: "creature", creatureId: id },
|
|
params: { boost: "movement" },
|
|
});
|
|
expect(state.creatures[0]!.movesPerTurn).toBe(movesBefore * 2);
|
|
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;
|
|
}
|
|
throw new Error("setup: seed offered no walled pocket beside the home");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
|
function creatureVsInvisible(deckRev: number) {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
state = toRound2(state);
|
|
const me = activePlayer(state).id;
|
|
const victim = state.players.find((p) => p.id !== me)!;
|
|
const r = summon(state, me, "skeleton");
|
|
state = r.state;
|
|
const bones = state.creatures[0]!;
|
|
bones.justCreated = false;
|
|
bones.attackUsed = false;
|
|
bones.position = { ...victim.position };
|
|
state.sustained.push({
|
|
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
|
|
remainingTurns: 3, data: {},
|
|
});
|
|
return { state, me, victim, skeletonId: bones.id };
|
|
}
|
|
|
|
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
|
let { state, me, victim, skeletonId } = creatureVsInvisible(13);
|
|
const rngBefore = JSON.stringify(state.rng);
|
|
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
|
state = must(state, victim.id, { type: "pass" });
|
|
// The die was consumed, whichever way it landed.
|
|
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
|
});
|
|
|
|
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
|
let { state, me, victim, skeletonId } = creatureVsInvisible(12);
|
|
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
|
const rngBefore = JSON.stringify(state.rng);
|
|
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
|
state = must(state, victim.id, { type: "pass" });
|
|
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
|
// No die was consumed on the way.
|
|
expect(JSON.stringify(state.rng)).toBe(rngBefore);
|
|
});
|
|
});
|
|
|
|
describe("elimination sweeps the board either way (rules rev 14)", () => {
|
|
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 14 });
|
|
const victim = state.players.find((p) => p.id === "c")!;
|
|
state.creatures.push({
|
|
id: "imp1", kind: "fire-imp", controllerId: "c",
|
|
position: { ...victim.position },
|
|
damage: 0, maxDamage: 5, movesPerTurn: 0, movementUsed: 0,
|
|
attackUsed: false, justCreated: false,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
state.sustained.push({
|
|
id: "fx-med", cardId: "medusa", casterId: "a", targetId: "c",
|
|
remainingTurns: 3, data: {},
|
|
});
|
|
// Both of c's treasures sit captured on living enemies' home bases.
|
|
const mine = state.treasures.filter((t) => t.owner === "c");
|
|
expect(mine.length).toBe(2);
|
|
mine[0]!.position = { ...state.players.find((p) => p.id === "a")!.home };
|
|
mine[0]!.carriedBy = null;
|
|
mine[1]!.position = { ...state.players.find((p) => p.id === "b")!.home };
|
|
mine[1]!.carriedBy = null;
|
|
const active = activePlayer(state).id;
|
|
const r = applyCommand(state, active, { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
expect(state.players.find((p) => p.id === "c")!.alive).toBe(false);
|
|
expect(state.creatures.some((c) => c.controllerId === "c")).toBe(false);
|
|
expect(state.sustained.some((s) => s.targetId === "c")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("creatures walk open doorways", () => {
|
|
it("a skeleton passes a door whose lock was removed", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 16 });
|
|
state = toRound2(state);
|
|
const view = boardView(state);
|
|
for (const [key, edge] of Object.entries(view.edges)) {
|
|
if (edge !== "door") continue;
|
|
const [kind, coords] = key.split(",").length ? key.split(":") as [string, string] : ["", ""];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
|
state.doorStates[key] = "removed";
|
|
state.creatures.push({
|
|
id: "sk1", kind: "skeleton", controllerId: activePlayer(state).id,
|
|
position: { x, y }, damage: 0, maxDamage: 4, movesPerTurn: 3,
|
|
movementUsed: 0, attackUsed: false, justCreated: false,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
const r = applyCommand(state, activePlayer(state).id, {
|
|
type: "moveCreature", creatureId: "sk1", direction: side,
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
const sk = r.state.creatures.find((c) => c.id === "sk1")!;
|
|
expect(cellKey(sk.position)).toBe(cellKey({ x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) }));
|
|
return;
|
|
}
|
|
throw new Error("setup: seed 42 grew a maze with no doors");
|
|
});
|
|
});
|
|
|
|
describe("the wave carries monsters (rules rev 17)", () => {
|
|
it("a cornered skeleton takes the waterwall crush", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 17 });
|
|
state = toRound2(state);
|
|
const caster = activePlayer(state);
|
|
// A skeleton pinned against solid stone, one square from the wave's edge.
|
|
const view = boardView(state);
|
|
for (const [key, edge] of Object.entries(view.edges)) {
|
|
if (edge !== "wall") continue;
|
|
const [kind, coords] = key.split(":") as [string, string];
|
|
if (kind !== "H") continue;
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
// Wave from this wall southward washes the square below.
|
|
const below = { x, y: y + 1 };
|
|
if (!view.cells[cellKey(below)]) continue;
|
|
state.creatures.push({
|
|
id: "sk1", kind: "skeleton", controllerId: caster.id,
|
|
position: { ...below }, damage: 0, maxDamage: 4, movesPerTurn: 3,
|
|
movementUsed: 0, attackUsed: false, justCreated: false,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
// Pin it: stone directly south of it.
|
|
state.squareContents[cellKey({ x, y: y + 2 })] = { kind: "stone", damage: 0, createdBy: caster.id };
|
|
caster.position = { x, y };
|
|
const stw = giveCard(state, caster.id, "stone-to-water");
|
|
const r = applyCommand(state, caster.id, {
|
|
type: "cast", instanceId: stw.instanceId,
|
|
target: { kind: "edge", cell: { x, y }, side: "S" },
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
const sk = r.state.creatures.find((c) => c.id === "sk1");
|
|
// Washed against the stone: it took crush damage (or died of it).
|
|
if (sk) expect(sk.damage).toBeGreaterThan(0);
|
|
return;
|
|
}
|
|
throw new Error("setup: no horizontal wall with a floor below");
|
|
});
|
|
});
|
|
|
|
describe("the democratic monster's claw survives the first wizard's death (rules rev 18)", () => {
|
|
it("refreshes each round even with the roll-off winner dead", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
|
|
state = toRound2(state);
|
|
const firstId = state.players[state.turn.firstIndex]!.id;
|
|
const dm = {
|
|
id: "dm1", kind: "democratic-monster" as const, controllerId: state.players[0]!.id,
|
|
position: { x: 0, y: 0 }, damage: 0, maxDamage: 5, movesPerTurn: 3,
|
|
movementUsed: 0, attackUsed: true, justCreated: false,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [] as string[],
|
|
};
|
|
state.creatures.push(dm);
|
|
// The roll-off winner falls.
|
|
const first = state.players.find((p) => p.id === firstId)!;
|
|
first.alive = false;
|
|
first.finalHand = [...first.hand];
|
|
// Walk a full round of the survivors: the claw must refresh.
|
|
for (let i = 0; i < 4 && state.creatures[0]!.attackUsed; i++) {
|
|
const active = activePlayer(state);
|
|
const r = applyCommand(state, active.id, { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
expect(state.creatures[0]!.attackUsed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("a mid-round democratic monster claws on the next turn (rules rev 18)", () => {
|
|
it("creation spends the turn, not the round", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev: 18 });
|
|
state = toRound2(state);
|
|
const creator = activePlayer(state);
|
|
const r0 = summon(state, creator.id, "democratic-monster");
|
|
state = r0.state;
|
|
const dm = state.creatures.find((c) => c.kind === "democratic-monster")!;
|
|
expect(dm.attackUsed).toBe(false);
|
|
expect(dm.justCreated).toBe(true);
|
|
// Next player's turn: justCreated clears; the claw is live.
|
|
state = must(state, creator.id, { type: "endTurn", draw: 0 });
|
|
const mover = activePlayer(state);
|
|
const victim = state.players.find((p) => p.id !== mover.id)!;
|
|
const fresh = state.creatures.find((c) => c.kind === "democratic-monster")!;
|
|
expect(fresh.justCreated).toBe(false);
|
|
// March it onto the victim: the touch must open.
|
|
fresh.position = { x: victim.position.x - 1, y: victim.position.y };
|
|
const r = applyCommand(state, mover.id, { type: "moveCreature", creatureId: fresh.id, direction: "E" });
|
|
if (!r.ok) throw new Error(r.error);
|
|
expect(r.state.stack?.creatureId ?? null).toBe(fresh.id);
|
|
});
|
|
});
|
|
|
|
describe("collapsing walls crush monsters too (rules rev 21)", () => {
|
|
function wallRig(deckRev: number) {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
|
|
state = toRound2(state);
|
|
const view = boardView(state);
|
|
for (const [key, edge] of Object.entries(view.edges)) {
|
|
if (edge !== "wall") continue;
|
|
const [kind, coords] = key.split(":") as [string, string];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
const side = kind === "V" ? ("E" as Side) : ("S" as Side);
|
|
const other = { x: x + (side === "E" ? 1 : 0), y: y + (side === "S" ? 1 : 0) };
|
|
if (!view.cells[cellKey(other)]) continue;
|
|
const caster = activePlayer(state);
|
|
caster.position = { x, y };
|
|
state.creatures.push({
|
|
id: "tr1", kind: "troll", controllerId: caster.id,
|
|
position: { ...other }, damage: 0, maxDamage: 6, movesPerTurn: 3,
|
|
movementUsed: 0, attackUsed: false, justCreated: false,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
const dw = giveCard(state, caster.id, "destroy-wall");
|
|
const r = applyCommand(state, caster.id, {
|
|
type: "cast", instanceId: dw.instanceId, target: { kind: "edge", cell: { x, y }, side },
|
|
});
|
|
if (!r.ok) throw new Error(r.error);
|
|
return r.state;
|
|
}
|
|
throw new Error("setup: no interior wall found");
|
|
}
|
|
|
|
it("rev 21: the adjacent troll takes the four points", () => {
|
|
const state = wallRig(21);
|
|
const troll = state.creatures.find((c) => c.id === "tr1");
|
|
// Four points on a six-point troll: hurt but standing (or regenerating).
|
|
expect(troll?.damage).toBe(4);
|
|
});
|
|
|
|
it("earlier revisions leave the troll dusty but unharmed", () => {
|
|
const state = wallRig(20);
|
|
expect(state.creatures.find((c) => c.id === "tr1")!.damage).toBe(0);
|
|
});
|
|
});
|