Third pass, scoped from 7a32370. Three blind reviewers (engine, web,
server/tools) each concluded the work is coherent engineering with
seam-level tells; every finding was verified before touching a line.
Session biography left the comments: the bot brain's heuristics no
longer cite the opponent who taught them, the RNRX coma parenthetical
and the thief-chase citation are gone, the seat-wallet comments state
their invariants without the war stories, and process-named test
groups now name the behaviors they pin. The incident record lives
where history belongs — commit messages and the ledgers.
Structural dedup: one VISIONSTONE one-edge-sight loop serves both
LOS paths; one creature-arrival touch handler serves walking and
warp-stepping (error text aligned); one facingWedge helper draws both
keymap ribbons; one spriteVisibleInCol rule serves the draw pass and
the hover test (which also stops re-sorting per pointermove); and
deepestFacing joins the director, replacing four copied scans.
Test hardening exposed real rot the tells were hiding: the tight
CreatureState cast caught two literals with a bogus field masking
three missing ones; the number-hoarding rig had NEVER run (its
column didn't exist on seed 42 — it now carves its own geometry);
the bank-guard rig now drives the whole table to an arrival
assertion; the bent-trace test walls off straight sight so the bend
must answer. Silent `return`-on-rig-failure became loud throws, and
can-never-fail assertions were removed.
Sweep-up: the eyeTurn ghost comment, the stacked leave() doc
comments (leave now delegates to leaveLocal), the dead ternary in
the seat client, the kick handler's name-coercion drift, kick ledger
lines gain timestamps, archiveRoomFile reuses fileFor, the RULES_REV
alias retires in favor of the engine constant, hitTest un-exports,
the NUL-sentinel hover shape becomes an honest "none" variant, and
the steering holds get named constants. The bezel's stride cluster
also centers per the table's note.
288 tests, 24 ledgers verified, all workspaces typecheck.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
790 lines
36 KiB
TypeScript
790 lines
36 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
type CreatureState, 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: [],
|
|
});
|
|
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");
|
|
});
|
|
|
|
it("a wizard caged inside the dread may round a corner to escape", () => {
|
|
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")!;
|
|
pushSustained(state, {
|
|
id: "fx-fear", cardId: "fear", casterId: "b", targetId: "b",
|
|
remainingTurns: 5, data: {},
|
|
});
|
|
// b radiates dread from (2,5). a stands in a dead-end pocket at
|
|
// (2,3): walls on three sides, so the only way out steps SOUTH —
|
|
// closer to b — before the corridor east leads clear of the dread.
|
|
b.position = { x: 2, y: 5 };
|
|
a.position = { x: 2, y: 3 };
|
|
for (const side of ["N", "E", "W"] as const) {
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, side)] = "wall";
|
|
}
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 3 }, "S")] = "open";
|
|
state.edgeOverrides[edgeKey({ x: 2, y: 4 }, "E")] = "open";
|
|
state.edgeOverrides[edgeKey({ x: 3, y: 4 }, "E")] = "open";
|
|
state.edgeOverrides[edgeKey({ x: 4, y: 4 }, "N")] = "open";
|
|
while (state.players[state.turn.activeIndex]!.id !== "a") {
|
|
const r0 = applyCommand(state, state.players[state.turn.activeIndex]!.id, { type: "endTurn", draw: 0 });
|
|
if (!r0.ok) throw new Error(r0.error);
|
|
state = r0.state;
|
|
}
|
|
// The closer step is the only way out: permitted.
|
|
const r = applyCommand(state, "a", { type: "move", direction: "S" });
|
|
if (!r.ok) throw new Error("escape step refused: " + r.error);
|
|
});
|
|
});
|
|
|
|
describe("creatures and the dimensional warp", () => {
|
|
it("a commanded troll steps through the warp tokens", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
|
const you = state.players[state.turn.activeIndex]!.id;
|
|
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
|
state.creatures.push({
|
|
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
|
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
|
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
const troll = r.state.creatures.find((c) => c.id === "troll-w")!;
|
|
expect(cellKey(troll.position)).toBe(cellKey({ x: 3, y: 8 }));
|
|
expect(troll.movementUsed).toBe(1);
|
|
}
|
|
});
|
|
|
|
it("solid stone on the far side refuses the beast", () => {
|
|
let { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"] });
|
|
const you = state.players[state.turn.activeIndex]!.id;
|
|
state.dimWarps.push({ a: { x: 1, y: 1 }, b: { x: 3, y: 8 } });
|
|
state.squareContents[cellKey({ x: 3, y: 8 })] = { kind: "stone", damage: 0, createdBy: "b" };
|
|
state.creatures.push({
|
|
id: "troll-w", kind: "troll", controllerId: you, position: { x: 1, y: 1 },
|
|
damage: 0, maxDamage: 6, movesPerTurn: 2, movementUsed: 0, attackUsed: false,
|
|
justCreated: false, wallPassesPerTurn: 0, wallPassUsed: 0, scorchedThisTurn: [],
|
|
});
|
|
const r = applyCommand(state, you, { type: "creatureWarpStep", creatureId: "troll-w" });
|
|
expect(r.ok).toBe(false);
|
|
if (!r.ok) expect(r.error).toContain("stone");
|
|
});
|
|
});
|