Blind reviews over the rev-37..40 features and the gate collapse. The scripted surgery's scars come out: bare brace blocks unwrapped (with one hiding an unreachable scrambleHands), the amputated comment healed, the dead events parameter unthreaded from the illusion-sight chain, the laziness probe in perceivedBoard collapsed to the rule it always produced, GameView.deckRev retired with its last reader, the ward's arming stub told honestly, and every comment or test title still citing the retired revision numbering reworded to the rule it guards. Rim mechanics unify on rimWarpMouth and breachRim; the test suite gains plainRimWall and pushSustained and loses its loop-shaped scars and rev-named helpers. No behavior changes; 255 tests pass unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
724 lines
32 KiB
TypeScript
724 lines
32 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game";
|
|
import { cellKey, edgeKey, SIDES, stepTarget, type Cell, type Side } from "../src/board";
|
|
import type { CardInstance } from "../src/cards";
|
|
import { newExpansionGame as newGame, must, giveCard, toRound2, emptyNeighborCell, pushSustained } 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", () => {
|
|
// Three seats: two-player games shed LIFESAVER from the build.
|
|
const { state } = newGame(42, ["a", "b", "c"]);
|
|
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,
|
|
});
|
|
// The blow opens the victim's counteraction window; they take it raw.
|
|
state = must(state, victim.id, { type: "pass" });
|
|
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;
|
|
}
|
|
}
|
|
state = must(state, enemy2.id, { type: "pass" });
|
|
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", () => {
|
|
function freshGame() {
|
|
return createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
|
}
|
|
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 } = freshGame();
|
|
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 } = freshGame();
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("big man", () => {
|
|
function bigRig() {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
|
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", () => {
|
|
function creatureVsInvisible() {
|
|
let { state } = createGame({ playerIds: ["alice", "bob"], seed: 42, sets: ["basic", "expansion1"] });
|
|
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("the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
|
let { state, me, victim, skeletonId } = creatureVsInvisible();
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("elimination sweeps the board either way", () => {
|
|
it("a treasure-eliminated wizard's fire imp vanishes with them", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
|
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"] });
|
|
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(":") 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", () => {
|
|
it("a cornered skeleton takes the waterwall crush", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
|
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", () => {
|
|
it("refreshes each round even with the roll-off winner dead", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b", "c"], seed: 42, sets: ["basic", "expansion1"] });
|
|
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", () => {
|
|
it("creation spends the turn, not the round", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
|
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", () => {
|
|
function wallRig() {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
|
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("the adjacent troll takes the four points", () => {
|
|
const state = wallRig();
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("the self-stack resolves on a pass", () => {
|
|
function selfTouch() {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
|
state = toRound2(state);
|
|
const creator = activePlayer(state);
|
|
state.creatures.push({
|
|
id: "dm1", kind: "democratic-monster", controllerId: creator.id,
|
|
position: { x: creator.position.x - 1, y: creator.position.y },
|
|
damage: 0, maxDamage: 5, movesPerTurn: 3, movementUsed: 0,
|
|
attackUsed: false, justCreated: false,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
const r = applyCommand(state, creator.id, { type: "moveCreature", creatureId: "dm1", direction: "E" });
|
|
if (!r.ok) throw new Error(r.error);
|
|
return { state: r.state, creator: creator.id };
|
|
}
|
|
|
|
it("passing your own monster's touch takes the claw and moves on", () => {
|
|
const { state, creator } = selfTouch();
|
|
if (!state.stack) return; // a wall between: the touch never opened
|
|
expect(state.stack.attackerId).toBe(creator);
|
|
expect(state.stack.defenderId).toBe(creator);
|
|
const r = applyCommand(state, creator, { type: "pass" });
|
|
if (!r.ok) throw new Error(r.error);
|
|
expect(r.state.stack).toBeNull();
|
|
expect(r.state.players.find((p) => p.id === creator)!.life).toBe(13);
|
|
});
|
|
});
|
|
|
|
describe("fear holds off monsters and unwilling feet alike", () => {
|
|
it("a commanded monster cannot close within three of the fearsome", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
|
const a = state.players.find((p) => p.id === "a")!;
|
|
const b = state.players.find((p) => p.id === "b")!;
|
|
// b radiates fear; a's troll stands exactly four away, aimed straight at b.
|
|
pushSustained(state, {
|
|
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
|
|
remainingTurns: 5, data: {},
|
|
});
|
|
b.position = { x: 2, y: 5 };
|
|
a.position = { x: 0, y: 0 };
|
|
state.creatures.push({
|
|
id: "troll-1", kind: "troll", controllerId: "a", position: { x: 2, y: 9 },
|
|
damage: 0, maxDamage: 6, movesPerTurn: 3, movementUsed: 0,
|
|
wallPassesPerTurn: 0, wallPassUsed: 0, attackUsed: false, justCreated: false,
|
|
scorchedThisTurn: [],
|
|
} as never);
|
|
for (const y of [5, 6, 7, 8]) state.edgeOverrides[edgeKey({ x: 2, y }, "S")] = "open";
|
|
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
|
const r = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
|
if (!r.ok) throw new Error(r.error);
|
|
state = r.state;
|
|
}
|
|
const r = applyCommand(state, "a", { type: "moveCreature", creatureId: "troll-1", direction: "N" });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toContain("dread");
|
|
});
|
|
});
|