Automatons wield Pass Through Wall and deny roads with terrain

Pass Through Wall joins the march: the bot banks a crossing when stepping
through one wall beats the walk by 4+ steps (or no road exists), walks to
the chosen wall, and spends the charge stepping through. passWallCharges
is now in PlayerPublicView — the cast is public at a physical table.

Path denial for guardGold tiers: when an enemy carries the bot's treasure
toward home, or closes on its gold on the floor, the bot reconstructs the
threat's shortest path and prices every corridor line and empty square on
it; CREATE WALL, FILL SQUARE WITH STONE, or THORNBUSH lands wherever the
detour costs the enemy 3+ steps, legality mirrored from the engine
(sighted, empty, off homes and warp tokens).

All five cards leave the bottom discard tier (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:50:41 -04:00
co-authored by Claude Fable 5
parent 2d53ba4bc0
commit 78085e26fb
3 changed files with 247 additions and 18 deletions
+173 -17
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, neighbor, stepTarget, SIDES, type Cell, type Side } from "./board";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
import { sightedCellsFor, type GameView } from "./view";
import type { AmbushTrigger, Command, PlayerId } from "./game";
@@ -121,7 +121,9 @@ 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" || c.cardId === "destroy-wall") return 6;
c.cardId === "glue" || c.cardId === "destroy-wall" ||
c.cardId === "pass-through-wall" || c.cardId === "create-wall" ||
c.cardId === "fill-square-with-stone" || c.cardId === "thornbush") return 6;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
@@ -150,8 +152,12 @@ function walkStep(
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> {
/** Walking distance from any of `starts` to every reachable cell. `avoid`
* lets a planner ask "and if this edge or square were blocked?". */
function distancesFrom(
view: GameView, starts: Cell[], canUnlock: boolean,
avoid?: { edge?: string; cell?: string },
): Map<string, number> {
const dist = new Map<string, number>();
let frontier: Cell[] = [];
for (const s of starts) {
@@ -163,9 +169,11 @@ function distancesFrom(view: GameView, starts: Cell[], canUnlock: boolean): Map<
const next: Cell[] = [];
for (const c of frontier) {
for (const dir of SIDES) {
if (avoid?.edge && edgeKey(c, dir) === avoid.edge) continue;
const step = walkStep(view, c, dir, canUnlock);
if (!step) continue;
const k = cellKey(step.to);
if (avoid?.cell === k) continue;
if (dist.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
dist.set(k, depth);
@@ -180,17 +188,21 @@ function distancesFrom(view: GameView, starts: Cell[], canUnlock: boolean): Map<
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 {
interface WallCrossing {
cell: Cell;
side: Side;
/** The side of the wall the clockwork approaches from... */
near: Cell;
/** ...and the cell it would emerge into. */
far: Cell;
/** Steps to the wall + through it + on to the nearest objective. */
total: number;
}
/** The one wall whose removal (or crossing) most shortens the road. */
function bestWallCrossing(
view: GameView, self: { position: Cell }, goals: Set<string>, canUnlock: boolean,
): WallCrossing | null {
if (goals.size === 0) return null;
const dHere = distancesFrom(view, [self.position], canUnlock);
const goalCells = [...goals].map((k) => {
@@ -198,7 +210,7 @@ function wallBlastTarget(
return { x, y };
});
const dGoal = distancesFrom(view, goalCells, canUnlock);
let best: { cell: Cell; side: Side; total: number } | null = null;
let best: WallCrossing | null = null;
for (const [key, state] of Object.entries(view.board.edges)) {
if (state !== "wall") continue;
const [kind, coords] = key.split(":") as [string, string];
@@ -212,9 +224,126 @@ function wallBlastTarget(
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 === null || total < best.total) best = { cell, side, near: a, far: b, total };
}
}
return best;
}
/**
* Path denial: when an enemy's march threatens something dear — they carry
* one of the clockwork's treasures home, or close on its gold lying on the
* floor — find the blockade that lengthens their road the most. CREATE WALL
* seals a corridor line; FILL SQUARE WITH STONE bricks a square; THORNBUSH
* makes a square nobody walks through willingly. Only placements the engine
* would accept are offered (sighted, empty, off homes and warp tokens), and
* only when the detour costs the enemy 3+ extra steps.
*/
function pathDenial(view: GameView): Command | null {
const wall = inHand(view, "create-wall");
const stone = inHand(view, "fill-square-with-stone");
const bush = inHand(view, "thornbush");
if (!wall && !stone && !bush) return null;
// Threats, most urgent first: my treasure being carried to an enemy home,
// then an enemy within reach of my treasure on the floor.
const threats: { enemy: Cell; goal: Cell }[] = [];
for (const t of view.treasures) {
if (t.owner !== view.you || !t.carriedBy || t.carriedBy === view.you) continue;
const carrier = view.players.find((p) => p.id === t.carriedBy && p.alive);
if (carrier) threats.push({ enemy: carrier.position, goal: carrier.home });
}
const floorGold = view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy);
if (floorGold) {
for (const e of livingEnemies(view)) {
const d = Math.abs(e.position.x - floorGold.position!.x) + Math.abs(e.position.y - floorGold.position!.y);
if (d <= 6) threats.push({ enemy: e.position, goal: floorGold.position! });
}
}
if (threats.length === 0) return null;
const sighted = sightedCellsFor(view);
const cellCastable = (k: string): boolean => {
if (!sighted.has(k)) return false;
if (view.squareContents[k]) return false;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.board.homes.some((h) => h.x === x && h.y === y)) return false;
if (view.players.some((p) => p.alive && cellKey(p.position) === k)) return false;
if (view.treasures.some((t) => t.position && cellKey(t.position) === k)) return false;
if ((view.groundObjects[k] ?? []).length > 0) return false;
if (view.dimWarps.some((w) => cellKey(w.a) === k || cellKey(w.b) === k)) return false;
return true;
};
for (const { enemy, goal } of threats) {
const dEnemy = distancesFrom(view, [enemy], true);
const oldDist = dEnemy.get(cellKey(goal));
if (oldDist === undefined || oldDist === 0) continue; // already there, or already cut off
// Walk one shortest path back from the goal to the enemy.
const path: Cell[] = [goal];
let cursor = goal;
for (let d = oldDist; d > 0; d--) {
let stepped = false;
for (const dir of SIDES) {
const s = walkStep(view, cursor, dir, true);
if (s && dEnemy.get(cellKey(s.to)) === d - 1) {
cursor = s.to;
path.push(cursor);
stepped = true;
break;
}
}
if (!stepped) break;
}
if (cellKey(path[path.length - 1]!) !== cellKey(enemy)) continue;
let best: { cmd: Command; gain: number } | null = null;
const consider = (cmd: Command, avoid: { edge?: string; cell?: string }) => {
const after = distancesFrom(view, [enemy], true, avoid).get(cellKey(goal)) ?? Infinity;
const gain = after - oldDist;
if (gain >= 3 && (best === null || gain > best.gain)) best = { cmd, gain };
};
for (let i = 0; i + 1 < path.length; i++) {
// path runs goal -> enemy; block between consecutive squares.
const a = path[i + 1]!, b = path[i]!;
if (Math.abs(a.x - b.x) + Math.abs(a.y - b.y) !== 1) continue; // a warp hop, not a corridor
if (wall) {
const side: Side = b.x > a.x ? "E" : b.x < a.x ? "W" : b.y > a.y ? "S" : "N";
const k = edgeKey(a, side);
if ((view.board.edges[k] ?? "open") === "open" &&
(sighted.has(cellKey(a)) || sighted.has(cellKey(b)))) {
consider(
{ type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: a, side } },
{ edge: k },
);
}
}
const filler = stone ?? bush;
// Never brick the square the treasure needs to stay reachable on.
if (filler && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
consider(
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
{ cell: cellKey(b) },
);
}
}
if (best !== null) return (best as { cmd: Command; gain: number }).cmd;
}
return null;
}
/**
* 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 {
const best = bestWallCrossing(view, self, goals, canUnlock);
if (!best) return null;
const adjacent = cellKey(self.position) === cellKey(best.cell) ||
cellKey(self.position) === cellKey(neighbor(best.cell, best.side));
@@ -638,6 +767,11 @@ function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Comm
};
}
}
// Deny the road: wall, brick, or bush the path of whoever threatens the gold.
if (tier.guardGold) {
const denial = pathDenial(view);
if (denial) return denial;
}
// Empty of violence: DEJA-VU pulls the best attack back from the pile.
const dv = tier.dejaVu ? inHand(view, "deja-vu") : undefined;
if (dv && !view.yourHand.some((c) => ATTACKS[c.cardId] != null)) {
@@ -794,6 +928,28 @@ export function automatonCommand(
return { type: "cast", instanceId: dw.instanceId, target: { kind: "edge", ...blast } };
}
}
// A banked PASS THROUGH WALL crossing: walk to the best wall and step in.
if (self.passWallCharges > 0) {
const crossing = bestWallCrossing(view, self, objectives, canUnlock);
if (crossing && crossing.total < (path?.distance ?? Infinity)) {
if (cellKey(self.position) === cellKey(crossing.near)) {
const dir = cellKey(crossing.near) === cellKey(crossing.cell)
? crossing.side : opposite(crossing.side);
return { type: "move", direction: dir };
}
const approach = pathToward(view, self.position, new Set([cellKey(crossing.near)]), { canUnlock });
if (approach) return { type: "move", direction: approach.dir };
}
}
// Or bank one now, when a crossing beats the walk by enough to spend a card.
const ptw = inHand(view, "pass-through-wall");
if (ptw && self.passWallCharges === 0 && !view.turn.actionsEnded) {
const crossing = bestWallCrossing(view, self, objectives, canUnlock);
const normal = path?.distance ?? Infinity;
if (crossing && (normal === Infinity || normal - crossing.total >= 4)) {
return { type: "cast", instanceId: ptw.instanceId };
}
}
if (path) {
// A locked door on the very next step: use the key first.
if (path.doorAhead) {
+3
View File
@@ -28,6 +28,8 @@ export interface PlayerPublicView {
carriedTreasureId: string | null;
lostTurns: number;
extraTurns: number;
/** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */
passWallCharges: number;
displayed: CardInstance[];
/** Which of the six physical wizard colors this player plays. */
colorIndex: number;
@@ -118,6 +120,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView {
carriedTreasureId: p.carriedTreasureId,
lostTurns: p.lostTurns,
extraTurns: p.extraTurns,
passWallCharges: p.passWallCharges,
displayed: p.hand.filter((c) => p.displayed.includes(c.instanceId)),
})),
yourHand: you ? [...you.hand] : [],
+71 -1
View File
@@ -5,7 +5,7 @@ import {
type GameState,
type PlayerId,
} from "../src/game";
import { edgeKey } from "../src/board";
import { cellKey, edgeKey } from "../src/board";
import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback, type AutomatonStyle, type AutomatonTier } from "../src/automaton";
@@ -260,3 +260,73 @@ describe("the clockwork wields destroy wall", () => {
}
});
});
describe("the clockwork wields pass through wall", () => {
it("banks a crossing when bricked in, then steps through the wall", () => {
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")!;
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: "pass-through-wall#T", cardId: "pass-through-wall" };
const cast = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cast).toMatchObject({ type: "cast", instanceId: "pass-through-wall#T" });
let r = applyCommand(state, "bot", cast!);
if (!r.ok) throw new Error(r.error);
state = r.state;
expect(state.players.find((p) => p.id === "bot")!.passWallCharges).toBe(1);
// The charge is spent on a step through the bricks.
const step = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(step?.type).toBe("move");
r = applyCommand(state, "bot", step!);
if (!r.ok) throw new Error(r.error);
const after = r.state.players.find((p) => p.id === "bot")!;
expect(cellKey(after.position)).not.toBe(cellKey({ x: 4, y: 4 }));
expect(after.passWallCharges).toBe(0);
});
});
describe("the clockwork denies the road", () => {
it("walls a thief's corridor when its treasure is being carried home", () => {
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")!;
const thief = state.players.find((p) => p.id === "other")!;
// The thief carries the bot's treasure down a one-lane tube to its home.
const t = state.treasures.find((t) => t.owner === "bot")!;
t.position = null;
t.carriedBy = "other";
thief.carriedTreasureId = t.id;
thief.position = { x: 4, y: 2 };
thief.home = { x: 4, y: 6 };
for (let y = 2; y <= 6; y++) {
state.edgeOverrides[edgeKey({ x: 4, y }, "E")] = "wall";
state.edgeOverrides[edgeKey({ x: 4, y }, "W")] = "wall";
}
state.edgeOverrides[edgeKey({ x: 4, y: 6 }, "S")] = "wall";
for (let y = 2; y <= 5; y++) {
state.edgeOverrides[edgeKey({ x: 4, y }, "S")] = "open";
}
bot.position = { x: 4, y: 4 };
bot.hand = [{ instanceId: "create-wall#T", cardId: "create-wall" }];
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
expect(cmd).toMatchObject({ type: "cast", instanceId: "create-wall#T" });
const r = applyCommand(state, "bot", cmd!);
if (!r.ok) throw new Error(r.error);
// The blockade must actually sever the thief's road home.
const target = (cmd as { target: { cell: { x: number; y: number }; side: string } }).target;
expect(["N", "S"]).toContain(target.side);
expect(target.cell.x).toBe(4);
});
});