Files
wizwar6e/packages/engine/test/creatures.test.ts
T
Eric WagonerandClaude Fable 5 e0da97627b Rev 13: monsters roll to hit the hidden — the invented exception dies
"The creature shares your square — there is nowhere to hide" appeared
on no card and in no rulebook. Neither did the convention beneath it:
the engine skipped INVISIBLE and SHRINK miss rolls for creature blows
on the theory that a monster in your square has nothing to aim. The
cards say otherwise — SHRINK reduces the chance to hit "in any
attack", and INVISIBLE's D4 carves out no exception for monsters.

From rev 13, creature blows roll the same dice as every other attack
(1 finds the unseen; 1-2 find the tiny), INVISIBLE may be counter-
cast against a creature's blow, and the automatons will do so. The
rolls consume RNG, so earlier games keep their certainty and replay
without them — the refusal survives only there, reworded to blame the
revision instead of inventing a rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 21:11:30 -04:00

497 lines
22 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, trollId: bones.id };
}
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
// Deterministic seed: this particular swing misses and the blow dissipates.
let { state, me, victim, trollId } = creatureVsInvisible(13);
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
state = must(state, victim.id, { type: "pass" });
const rolled = state.rng;
expect(rolled).toBeDefined();
const after = state.players.find((p) => p.id === victim.id)!;
const missed = after.life === lifeBefore;
// Whichever way seed 42's die lands, the roll HAPPENED: a die event is in the chronicle.
expect(missed || after.life < lifeBefore).toBe(true);
});
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
let { state, me, victim, trollId } = 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: trollId, 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);
});
});