From 2d53ba4bc08a099e5ce3f02f46bf7ab496148786 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 19:40:41 -0400 Subject: [PATCH] Waves spend their force as they travel (rev 24); bots wield Destroy Wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc --- packages/engine/src/automaton.ts | 127 +++++++++++++++--- packages/engine/src/game.ts | 20 ++- packages/engine/test/automaton.test.ts | 46 +++++++ .../engine/test/expansion-terrain.test.ts | 45 ++++++- packages/server/src/rooms.ts | 2 +- packages/web/src/local.svelte.ts | 2 +- 6 files changed, 215 insertions(+), 27 deletions(-) diff --git a/packages/engine/src/automaton.ts b/packages/engine/src/automaton.ts index 3230166..907c16a 100644 --- a/packages/engine/src/automaton.ts +++ b/packages/engine/src/automaton.ts @@ -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 { + const dist = new Map(); + 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, + 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) { diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index f609ea9..183d471 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -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); } } } diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 86c07d1..256f5ed 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -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"); + } + }); +}); diff --git a/packages/engine/test/expansion-terrain.test.ts b/packages/engine/test/expansion-terrain.test.ts index fd04265..133d815 100644 --- a/packages/engine/test/expansion-terrain.test.ts +++ b/packages/engine/test/expansion-terrain.test.ts @@ -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); + }); +}); diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 4fd4c07..39c3fcd 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -54,7 +54,7 @@ export interface Room { const rooms = new Map(); /** 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"; diff --git a/packages/web/src/local.svelte.ts b/packages/web/src/local.svelte.ts index 86d5c95..fedbf10 100644 --- a/packages/web/src/local.svelte.ts +++ b/packages/web/src/local.svelte.ts @@ -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) {