Waves spend their force as they travel (rev 24); bots wield Destroy Wall

Rules rev 24: a waterwall wave that finds a victim dist cells from its
source has only range-dist spaces of push left — a range-2 wave throws its
adjacent victim two spaces but a victim at its far edge only one, and
spent force never converts into crush damage. Applies to WATERWALL and
both STONE TO WATER waves; older revisions keep the flat full-range wash
so stored games replay unchanged.

Automatons now use DESTROY WALL on the march: two BFS distance maps (from
the bot, from its objectives) price every visible wall by the shortcut its
removal opens; the bot blasts when it saves 4+ steps of walking — or when
no road exists at all — never standing beside the blast unless trapped
and healthy. The card also leaves the shed pile: discardValue 2 -> 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-17 19:40:41 -04:00
co-authored by Claude Fable 5
parent dfd7a2ff56
commit 2d53ba4bc0
6 changed files with 215 additions and 27 deletions
+46
View File
@@ -5,6 +5,7 @@ import {
type GameState,
type PlayerId,
} from "../src/game";
import { edgeKey } from "../src/board";
import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
@@ -214,3 +215,48 @@ describe("the clockwork honors absorb's fine print", () => {
expect(cmd).toEqual({ type: "counteract", instanceId: "blunt#T" });
});
});
describe("the clockwork wields destroy wall", () => {
it("blasts its way out when no road leads to the gold", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
while (actingSeat(state) !== "bot") {
const r = applyCommand(state, actingSeat(state), { type: "endTurn", draw: 0 });
if (!r.ok) throw new Error(r.error);
state = r.state;
}
const bot = state.players.find((p) => p.id === "bot")!;
// Brick the bot into a one-square cell far from everything.
bot.position = { x: 4, y: 4 };
for (const side of ["N", "S", "E", "W"] as const) {
state.edgeOverrides[edgeKey(bot.position, side)] = "wall";
}
state.players.find((p) => p.id === "other")!.position = { x: 0, y: 0 };
bot.hand[0] = { instanceId: "destroy-wall#T", cardId: "destroy-wall" };
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "destroy-wall#T" });
// And the engine accepts the blast it chose.
const r = applyCommand(state, "bot", cmd!);
expect(r.ok).toBe(true);
});
it("values destroy wall above the chaff when forced to discard", () => {
let { state } = createGame({ playerIds: ["bot", "other"], seed: 42, sets: ["basic"], deckRev: 24 });
const bot = state.players.find((p) => p.id === "bot")!;
bot.hand = [
{ instanceId: "destroy-wall#T", cardId: "destroy-wall" },
{ instanceId: "buddy#T", cardId: "buddy" },
{ instanceId: "ugly#T", cardId: "ugly" },
{ instanceId: "fear#T", cardId: "fear" },
{ instanceId: "full-shield#T", cardId: "full-shield" },
{ instanceId: "fireball#T", cardId: "fireball" },
{ instanceId: "number-3#T", cardId: "number-3" },
{ instanceId: "troll#T", cardId: "troll" },
];
state.pendingDiscard = "bot";
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd?.type).toBe("discard");
if (cmd?.type === "discard") {
expect(cmd.instanceIds).not.toContain("destroy-wall#T");
}
});
});
+43 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { applyCommand, activePlayer, boardView, gameLos } from "../src/game";
import { cellKey, SIDES, stepTarget, type Cell } from "../src/board";
import { applyCommand, activePlayer, boardView, createGame, gameLos } from "../src/game";
import { cellKey, edgeKey, SIDES, stepTarget, type Cell } from "../src/board";
import type { CardInstance } from "../src/cards";
import { eligibleCellsFor, sightedCellsFor, viewFor } from "../src/view";
import { newExpansionGame as newGame, must, giveCard, emptyNeighborCell } from "./helpers";
@@ -212,3 +212,44 @@ describe("eligibility dimming mirrors the engine", () => {
for (const k of lit) expect(seen.has(k)).toBe(true);
});
});
describe("a wave's force is spent as it travels (rules rev 24)", () => {
/** Caster at B, one cell above A; the target wall is A's south edge, so
* the range-2 wave covers A (dist 0) and B (dist 1). */
function rig(deckRev: number, behind: "open" | "wall") {
const { state } = createGame({ playerIds: ["a", "b"], seed: 42, sets: ["basic", "expansion1"], deckRev });
const me = activePlayer(state);
const A = { x: 4, y: 5 }, B = { x: 4, y: 4 }, Bn = { x: 4, y: 3 }, Bnn = { x: 4, y: 2 };
for (const c of [A, B, Bn, Bnn]) expect(boardView(state).cells[cellKey(c)]).toBeTruthy();
state.edgeOverrides[edgeKey(A, "S")] = "wall";
state.edgeOverrides[edgeKey(A, "N")] = "open";
state.edgeOverrides[edgeKey(B, "N")] = behind;
state.edgeOverrides[edgeKey(Bn, "N")] = "open";
me.position = { ...B };
// The other wizard waits far outside the wave.
state.players.find((p) => p.id !== me.id)!.position = { x: 0, y: 9 };
const stw = giveCard(state, me.id, "stone-to-water", "SW", 0);
const after = must(state, me.id, {
type: "cast", instanceId: stw.instanceId, target: { kind: "edge", cell: A, side: "S" },
});
return { me: after.players.find((p) => p.id === me.id)!, B, Bn, Bnn };
}
it("a victim at the wave's far edge is carried one space, unhurt", () => {
const { me, Bn } = rig(24, "open");
expect(cellKey(me.position)).toBe(cellKey(Bn));
expect(me.life).toBe(15);
});
it("only unspent force crushes: one space of push blocked is one damage", () => {
const { me, B } = rig(24, "wall");
expect(cellKey(me.position)).toBe(cellKey(B));
expect(me.life).toBe(14);
});
it("older revisions keep the flat full-range wash for replay fidelity", () => {
const { me, Bnn } = rig(23, "open");
expect(cellKey(me.position)).toBe(cellKey(Bnn));
expect(me.life).toBe(15);
});
});