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
+108 -19
View File
@@ -7,7 +7,7 @@
// BERSERKER for blood, the WORRIER for the shadows between the two.
import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, stepTarget, SIDES, type Cell, type Side } from "./board";
import { cellKey, edgeKey, neighbor, stepTarget, SIDES, type Cell, type Side } from "./board";
import { sightedCellsFor, type GameView } from "./view";
import type { AmbushTrigger, Command, PlayerId } from "./game";
@@ -121,7 +121,7 @@ function discardValue(c: CardInstance): number {
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" ||
c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" ||
c.cardId === "glue") return 6;
c.cardId === "glue" || c.cardId === "destroy-wall") return 6;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
@@ -134,6 +134,100 @@ interface PathResult {
doorAhead?: { cell: Cell; side: Side };
}
/** One walkable step for the clockwork's pathfinding; null = impassable.
* Doors count when already open, or when `canUnlock` says a key is in hand. */
function walkStep(
view: GameView, c: Cell, dir: Side, canUnlock: boolean,
): { to: Cell; viaDoor: boolean } | null {
const t = stepTarget(view.board, c, dir);
if (t.kind !== "blocked") return { to: t.to, viaDoor: false };
if (t.by !== "door") return null;
const k = edgeKey(c, dir);
const alreadyOpen = view.openDoorEdges.includes(k) || view.doorStates[k] === "removed";
if (!alreadyOpen && !canUnlock) return null;
const n = neighbor(c, dir);
if (!view.board.cells[cellKey(n)]) return null;
return { to: n, viaDoor: !alreadyOpen };
}
/** Walking distance from any of `starts` to every reachable cell. */
function distancesFrom(view: GameView, starts: Cell[], canUnlock: boolean): Map<string, number> {
const dist = new Map<string, number>();
let frontier: Cell[] = [];
for (const s of starts) {
if (!view.board.cells[cellKey(s)] || dist.has(cellKey(s))) continue;
dist.set(cellKey(s), 0);
frontier.push(s);
}
for (let depth = 1; depth <= 60 && frontier.length > 0; depth++) {
const next: Cell[] = [];
for (const c of frontier) {
for (const dir of SIDES) {
const step = walkStep(view, c, dir, canUnlock);
if (!step) continue;
const k = cellKey(step.to);
if (dist.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
dist.set(k, depth);
const hazard = view.squareContents[k]?.kind;
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime") continue;
next.push(step.to);
}
}
frontier = next;
}
return dist;
}
/**
* The wall worth a DESTROY WALL on the march: the one whose removal most
* shortens the road to the objectives. The card wants line of sight to the
* wall, and its collapse deals 4 to anyone beside it — so the clockwork
* blasts from a distance, standing next to the wall only when no road
* exists at all and it can afford the bruise.
*/
function wallBlastTarget(
view: GameView, self: { position: Cell; life: number }, goals: Set<string>,
canUnlock: boolean, normalDistance: number,
): { cell: Cell; side: Side } | null {
if (goals.size === 0) return null;
const dHere = distancesFrom(view, [self.position], canUnlock);
const goalCells = [...goals].map((k) => {
const [x, y] = k.split(",").map(Number) as [number, number];
return { x, y };
});
const dGoal = distancesFrom(view, goalCells, canUnlock);
let best: { cell: Cell; side: Side; total: number } | null = null;
for (const [key, state] of Object.entries(view.board.edges)) {
if (state !== "wall") continue;
const [kind, coords] = key.split(":") as [string, string];
const [x, y] = coords.split(",").map(Number) as [number, number];
const cell = { x, y };
const side: Side = kind === "V" ? "E" : "S";
const beyond = neighbor(cell, side);
if (!view.board.cells[cellKey(beyond)]) continue; // rim breaches are another game
for (const [a, b] of [[cell, beyond], [beyond, cell]] as [Cell, Cell][]) {
const da = dHere.get(cellKey(a));
const db = dGoal.get(cellKey(b));
if (da === undefined || db === undefined) continue;
const total = da + 1 + db;
if (best === null || total < best.total) best = { cell, side, total };
}
}
if (!best) return null;
const adjacent = cellKey(self.position) === cellKey(best.cell) ||
cellKey(self.position) === cellKey(neighbor(best.cell, best.side));
if (adjacent && (normalDistance !== Infinity || self.life <= 4)) return null;
// Worth the card only for a real shortcut — or when there is no road at all.
if (normalDistance !== Infinity && normalDistance - best.total < 4) return null;
const sighted = sightedCellsFor(view);
if (!sighted.has(cellKey(best.cell)) && !sighted.has(cellKey(neighbor(best.cell, best.side)))) {
return null;
}
return { cell: best.cell, side: best.side };
}
/**
* BFS over walkable steps toward the nearest goal. Doors count as passable
* when the clockwork can unlock them; the first such door is reported so the
@@ -158,23 +252,9 @@ function pathToward(
const next: Cell[] = [];
for (const c of frontier) {
for (const dir of SIDES) {
const t = stepTarget(view.board, c, dir);
let to: Cell;
let viaDoor = false;
if (t.kind === "blocked") {
if (t.by !== "door") continue;
const k = edgeKey(c, dir);
const alreadyOpen =
view.openDoorEdges.includes(k) || view.doorStates[k] === "removed";
if (!alreadyOpen && !opts.canUnlock) continue;
const n = { x: c.x + (dir === "E" ? 1 : dir === "W" ? -1 : 0),
y: c.y + (dir === "S" ? 1 : dir === "N" ? -1 : 0) };
if (!view.board.cells[cellKey(n)]) continue;
to = n;
viaDoor = !alreadyOpen;
} else {
to = t.to;
}
const step = walkStep(view, c, dir, opts.canUnlock === true);
if (!step) continue;
const { to, viaDoor } = step;
const k = cellKey(to);
if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
@@ -705,6 +785,15 @@ export function automatonCommand(
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ??
pathToward(view, self.position, enemyCells, { canUnlock });
// A wall between here and the gold may be cheaper to remove than to walk
// around — and sometimes it is the only way through.
const dw = inHand(view, "destroy-wall");
if (dw && !view.turn.actionsEnded) {
const blast = wallBlastTarget(view, self, objectives, canUnlock, path?.distance ?? Infinity);
if (blast) {
return { type: "cast", instanceId: dw.instanceId, target: { kind: "edge", ...blast } };
}
}
if (path) {
// A locked door on the very next step: use the key first.
if (path.doorAhead) {
+16 -4
View File
@@ -2476,6 +2476,16 @@ function terrainEffect(kind: SquareContent["kind"]): NeutralEffect {
};
}
/**
* How hard a wave still pushes someone it finds `dist` cells from its
* source: the force spent reaching them is gone (rules rev 24), so a
* range-2 wave throws its adjacent victim 2 spaces but a victim at its far
* edge only 1 — and never converts spent force into crush damage.
*/
function waveForce(state: GameState, range: number, dist: number): number {
return (state.config.deckRev ?? 1) >= 24 ? range - dist : range;
}
/** A collapsing waterwall wave from an edge: wash players back `range`. */
function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: Side, range: number, reason = "rushing water"): void {
const away = (s2: Side): Side => (s2 === "N" ? "S" : s2 === "S" ? "N" : s2 === "E" ? "W" : "E");
@@ -2486,15 +2496,16 @@ function waveFromEdge(state: GameState, events: GameEvent[], cell: Cell, side: S
for (const { start, dir } of pushes) {
let probe = start;
for (let dist = 0; dist < range; dist++) {
const force = waveForce(state, range, dist);
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, range);
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
}
for (const c of [...state.creatures]) {
if (cellKey(c.position) !== cellKey(probe)) continue;
if (c.kind === "fire-imp") {
destroyCreature(state, events, c, reason);
} else if ((state.config.deckRev ?? 1) >= 17) {
washBackCreature(state, events, c, dir, range);
washBackCreature(state, events, c, dir, force);
}
}
if (state.squareContents[cellKey(probe)]?.kind === "slime") {
@@ -2513,15 +2524,16 @@ function waveFromCell(state: GameState, events: GameEvent[], center: Cell, range
let probe = center;
for (let dist = 0; dist < range; dist++) {
probe = neighbor(probe, dir);
const force = waveForce(state, range, dist);
for (const p of state.players) {
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, range);
if (p.alive && cellKey(p.position) === cellKey(probe)) washBackN(state, events, p, dir, force);
}
for (const c of [...state.creatures]) {
if (cellKey(c.position) !== cellKey(probe)) continue;
if (c.kind === "fire-imp") {
destroyCreature(state, events, c, "rushing water");
} else if ((state.config.deckRev ?? 1) >= 17) {
washBackCreature(state, events, c, dir, range);
washBackCreature(state, events, c, dir, force);
}
}
}
+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);
});
});
+1 -1
View File
@@ -54,7 +54,7 @@ export interface Room {
const rooms = new Map<string, Room>();
/** Rules revision new games are dealt under (stored games keep their own). */
const RULES_REV = 23;
const RULES_REV = 24;
const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+1 -1
View File
@@ -136,7 +136,7 @@ class LocalGame {
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
...(colors ? { colors } : {}),
deckRev: 23,
deckRev: 24,
};
const { state, events } = createGame(config);
for (const e of events) {