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:
co-authored by
Claude Fable 5
parent
dfd7a2ff56
commit
2d53ba4bc0
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user