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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user