Files
wizwar6e/packages/engine/src/automaton.ts
T
Eric WagonerandClaude Fable 5 75f1217c77 The RNRX coma: tacks are scattered, not thrown
Automaton II froze for three turns of game four: pathDenial proposed
HANDFUL OF TACKS on a square four steps away, the engine refused the
cast every turn ("you must be adjacent to scatter tacks"), and the
refusal-fallback loop read as a coma. The denial planner now offers
tacks only at the caster's feet — and the walker learns tacks are a
floor hazard, so a bot no longer mines its own doorstep and strolls
straight into it (also RNRX, seq 194-195). The frozen ledger moment
replays cured; a regression pins the planner's proposals to
engine-accepted commands. 288 tests; 24 ledgers verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
2026-08-26 12:04:57 -04:00

1731 lines
80 KiB
TypeScript

// The automaton: a clockwork wizard. It plays from its own redacted view —
// the same information a human seat receives — and returns one command per
// consultation; the server keeps consulting until the maze stops asking.
// It never throws: when in doubt it passes, discards, or ends its turn.
//
// Temperaments color its judgment: the HUNTER marches for treasure, the
// BERSERKER for blood, the WORRIER for the shadows between the two.
import { cardDef, type CardInstance } from "./cards";
import { cellKey, edgeKey, neighbor, opposite, stepTarget, SIDES, type Cell, type Side } from "./board";
import { bentSightFor, sightedCellsFor, type GameView } from "./view";
import { wallIgnoringDistance } from "./game";
import type { AmbushTrigger, Command, PlayerId } from "./game";
export type AutomatonStyle = "hunter" | "berserker" | "worrier";
export const AUTOMATON_STYLES: AutomatonStyle[] = ["hunter", "berserker", "worrier"];
/**
* Difficulty degrades resources and repertoire, never judgment. The
* apprentice draws one card a turn and knows a modest spellbook; the adept
* draws two but keeps no ambushes or amplifies; the archmage knows all.
* Every tier plays its cards correctly — none of them is ever stupid.
*/
export type AutomatonTier = "apprentice" | "adept" | "archmage";
export const AUTOMATON_TIERS: AutomatonTier[] = ["apprentice", "adept", "archmage"];
interface TierTraits {
draw: number;
/** Added to every counteraction threshold: thrift, not blindness. */
counterThrift: number;
afflictions: boolean;
amplify: boolean;
ambush: boolean;
guardGold: boolean;
/** Shed dead cards at end of turn to keep the draw flowing. */
sheds: boolean;
buffs: boolean;
dejaVu: boolean;
}
const TIERS: Record<AutomatonTier, TierTraits> = {
apprentice: { draw: 1, counterThrift: 2, afflictions: false, amplify: false, ambush: false, guardGold: false, buffs: false, dejaVu: false, sheds: false },
adept: { draw: 2, counterThrift: 0, afflictions: true, amplify: false, ambush: false, guardGold: true, buffs: true, dejaVu: true, sheds: true },
archmage: { draw: 2, counterThrift: 0, afflictions: true, amplify: true, ambush: true, guardGold: true, buffs: true, dejaVu: true, sheds: true },
};
/** Damage attacks the clockwork understands: flat damage plus per-number scaling. */
const ATTACKS: Record<string, { base: number; perNumber: boolean; needsNumber?: boolean; sameSquare?: boolean }> = {
fireball: { base: 5, perNumber: false },
"sudden-death": { base: 10, perNumber: false },
powerthrust: { base: 2, perNumber: true },
waterbolt: { base: 0, perNumber: true, needsNumber: true },
"lightning-blast": { base: 0, perNumber: true, needsNumber: true },
wizardblade: { base: 0, perNumber: true, needsNumber: true, sameSquare: true },
"power-drain": { base: 0, perNumber: true, needsNumber: true },
dagger: { base: 3, perNumber: false },
"large-rock": { base: 2, perNumber: false },
"blaster-wand": { base: 3, perNumber: false },
disease: { base: 3, perNumber: false, sameSquare: true },
"butt-head": { base: 2, perNumber: false },
"heave-ho": { base: 2, perNumber: false },
"illusionary-attack": { base: 3, perNumber: false },
};
/** Attacks that carry no points: dropping, dragging, swapping, peeking.
* A damage counter drains against nothing — only a full stop denies them. */
const UTILITY_ATTACKS = new Set([
"drop-object", "teleport-opponent", "mental-force", "mental-swap", "swap",
"swap-meet", "card-erasure", "telepath", "sticky-wand", "shift-wand",
]);
/**
* Afflictions cast at an enemy: no damage, but a turn of misery. Cast with a
* number for the duration where one helps.
*/
const AFFLICTIONS: Record<string, { withNumber: boolean; vsCarrier?: boolean }> = {
blind: { withNumber: true },
slow: { withNumber: true },
"no-spell": { withNumber: true },
medusa: { withNumber: true },
"lock-in-place": { withNumber: true },
"walking-dead": { withNumber: false },
"slow-death": { withNumber: false },
idiot: { withNumber: false },
weakness: { withNumber: false, vsCarrier: true },
"go-away": { withNumber: true },
"thought-steal": { withNumber: false },
};
/** Every stone earns its place on the table the moment it is drawn. */
const STONES = new Set([
"bloodstone", "brainstone", "powerstone", "shadowstone",
"shieldstone", "soulstone", "speedstone", "visionstone",
]);
const SUMMONS = new Set(["troll", "skeleton", "wraith", "fire-imp", "shadow", "democratic-monster"]);
const UNLOCKS = ["master-key", "pick-lock", "remove-lock"];
/** Neutrals the brain has a play for — the shed and forced discards spare
* them. Everything here is reachable from some rung of the ladder. */
const USEFUL_NEUTRALS = new Set([
"speed", "interrupt", "opportunity-fire", "ward", "drop-object",
"gift-from-above", "deja-vu", "amplify", "safe", "glue",
// roadwork
"destroy-wall", "pass-through-wall", "dispel-creation", "stone-to-water",
"create-door", "dimensional-warp",
// path denial
"create-wall", "illusion-wall", "jam-lock", "boobytrap",
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
// bursts and breathing room
"mad-dash", "power-run", "add", "around-the-corner", "fear", "ugly", "buddy",
// finishers and boosts
"adrenaline", "strength", "extend", "mega-monster", "warp-wand",
]);
function me(view: GameView) {
return view.players.find((p) => p.id === view.you)!;
}
function livingEnemies(view: GameView) {
return view.players.filter((p) => p.alive && p.id !== view.you);
}
function inHand(view: GameView, cardId: string): CardInstance | undefined {
return view.yourHand.find((c) => c.cardId === cardId);
}
function numbersInHand(view: GameView): CardInstance[] {
return view.yourHand
.filter((c) => cardDef(c.cardId).cardType === "number")
.sort((a, b) => (cardDef(a.cardId).value ?? 0) - (cardDef(b.cardId).value ?? 0));
}
/** Cards the automaton happily discards to churn its hand, worst first.
* A card is only worth keeping to the wizard who can actually play it —
* LIFESAVER is dead weight at a two-seat table, BIG MAN outside the
* berserker's book — so the valuation knows whose hand this is. */
function discardValue(c: CardInstance, view: GameView, style?: AutomatonStyle): number {
const def = cardDef(c.cardId);
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 9;
if (SUMMONS.has(c.cardId) || UNLOCKS.includes(c.cardId) || STONES.has(c.cardId)) return 8;
if (ATTACKS[c.cardId] != null || AFFLICTIONS[c.cardId] != null ||
c.cardId === "stone-dead" || c.cardId === "teleport-opponent") return 7;
if (USEFUL_NEUTRALS.has(c.cardId)) return 6;
if (c.cardId === "lifesaver" && view.players.length > 2) return 6;
if (c.cardId === "big-man" && style === "berserker") return 6;
if ((c.cardId === "mist-body" || c.cardId === "shrink") && style === "worrier") return 6;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
}
interface PathResult {
dir: Side;
distance: number;
/** A locked door stands on the first step of this path. */
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. `mod`
* lets a planner ask "and if this edge or square were blocked?" (avoid...)
* or "and if this wall or filled square were gone?" (open...). */
function distancesFrom(
view: GameView, starts: Cell[], canUnlock: boolean,
mod?: { avoidEdge?: string; avoidCell?: string; openEdge?: string; openCell?: string },
): 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) {
if (mod?.avoidEdge && edgeKey(c, dir) === mod.avoidEdge) continue;
let step = walkStep(view, c, dir, canUnlock);
if (!step && mod?.openEdge === edgeKey(c, dir)) {
const n = neighbor(c, dir);
if (view.board.cells[cellKey(n)]) step = { to: n, viaDoor: false };
}
if (!step) continue;
const k = cellKey(step.to);
if (mod?.avoidCell === k) continue;
if (dist.has(k)) continue;
const opened = mod?.openCell === k;
if (view.squareContents[k]?.kind === "stone" && !opened) continue;
dist.set(k, depth);
const hazard = view.squareContents[k]?.kind;
if (!opened && (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime")) continue;
next.push(step.to);
}
}
frontier = next;
}
return dist;
}
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) => {
const [x, y] = k.split(",").map(Number) as [number, number];
return { x, y };
});
const dGoal = distancesFrom(view, goalCells, canUnlock);
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];
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, near: a, far: b, total };
}
}
return best;
}
/** Square-filling nuisances for path denial, best blocker first. */
const DENIAL_FILLERS = [
"fill-square-with-stone", "thornbush", "rosebush", "create-pit",
"fill-square-with-slime", "killer-ooze", "handful-of-tacks", "dust-cloud",
];
/**
* 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") ?? inHand(view, "illusion-wall");
const jam = inHand(view, "jam-lock");
const filler = DENIAL_FILLERS.map((id) => inHand(view, id)).find(Boolean);
const trap = inHand(view, "boobytrap");
if (!wall && !jam && !filler && !trap) 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! });
}
}
// Enemy gold banked at MY home: my score, snatchable by anyone. A
// raider closing on the bank gets the same road-lengthening treatment.
const me2 = me(view);
const banked = view.treasures.find(
(t) => t.owner !== view.you && t.position && cellKey(t.position) === cellKey(me2.home));
if (banked) {
for (const e of livingEnemies(view)) {
const d = Math.abs(e.position.x - me2.home.x) + Math.abs(e.position.y - me2.home.y);
if (d <= 6) threats.push({ enemy: e.position, goal: me2.home });
}
}
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, mod: { avoidEdge?: string; avoidCell?: string }) => {
const after = distancesFrom(view, [enemy], true, mod).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
const side: Side = b.x > a.x ? "E" : b.x < a.x ? "W" : b.y > a.y ? "S" : "N";
const k = edgeKey(a, side);
const edgeSighted = sighted.has(cellKey(a)) || sighted.has(cellKey(b));
if (wall && (view.board.edges[k] ?? "open") === "open" && edgeSighted) {
consider(
{ type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: a, side } },
{ avoidEdge: k },
);
}
// A door on their road jams shut for good — cheaper than a wall.
if (jam && (view.board.edges[k] ?? "open") === "door" && edgeSighted &&
view.doorStates[k] !== "jammed" && view.doorStates[k] !== "removed") {
consider(
{ type: "cast", instanceId: jam.instanceId, target: { kind: "edge", cell: a, side } },
{ avoidEdge: k },
);
}
// Never brick the square the treasure needs to stay reachable on.
// TACKS are scattered at the caster's feet ("you must be adjacent"),
// not thrown — offering a distant square wedges the whole brain on
// an eternally refused cast.
const fillerReaches = !filler || filler.cardId !== "handful-of-tacks" ||
Math.abs(b.x - me(view).position.x) + Math.abs(b.y - me(view).position.y) <= 1;
if (filler && fillerReaches && cellKey(b) !== cellKey(goal) && cellCastable(cellKey(b))) {
consider(
{ type: "cast", instanceId: filler.instanceId, target: { kind: "cell", cell: b } },
{ avoidCell: cellKey(b) },
);
}
}
if (best !== null) return (best as { cmd: Command; gain: number }).cmd;
// Nothing reroutes them? A BOOBYTRAP mid-path punishes the march instead:
// the real token on their road, three bluffs beside it.
if (trap && path.length >= 3 && view.boobytraps.every((b) => b.casterId !== view.you)) {
const mid = path[Math.floor(path.length / 2)]!;
const spots: Cell[] = [mid];
for (const dir of SIDES) {
if (spots.length === 4) break;
const n = neighbor(mid, dir);
const nk = cellKey(n);
if (view.board.cells[nk] && view.squareContents[nk]?.kind !== "stone" &&
!spots.some((s) => cellKey(s) === nk)) {
spots.push(n);
}
}
if (spots.length === 4) {
return { type: "cast", instanceId: trap.instanceId, params: { cells: spots } };
}
}
}
return null;
}
/**
* Beyond DESTROY WALL, the hand holds other roadwork:
* - DISPEL CREATION un-creates a conjured wall or a filled square.
* - STONE TO WATER melts any wall or stone block — cast from outside the
* wave, which would otherwise wash the caster backward.
* - CREATE DOOR plus a lock-opener turns a wall into a doorway.
* - DIMENSIONAL WARP folds a long march into a single step.
* Costs one card (two for the door), so only a real shortcut pays.
*/
function roadworkPlan(
view: GameView, self: { position: Cell }, goals: Set<string>,
canUnlock: boolean, normalDist: number, movesLeft: number,
): Command | null {
if (goals.size === 0) return null;
const dispel = inHand(view, "dispel-creation");
const s2w = inHand(view, "stone-to-water");
const cdoor = canUnlock ? inHand(view, "create-door") : undefined;
const dwarp = inHand(view, "dimensional-warp");
// WARP WAND holds a wall open only until the turn ends — the crossing
// must be reachable on this turn's legs, and the wand wants a charge.
const wandCard = inHand(view, "warp-wand");
const wandNumber = numbersInHand(view)[0];
const wand = wandCard && !view.turn.wandsUsed.includes(wandCard.instanceId) &&
(view.wandCharges[wandCard.instanceId] != null || wandNumber)
? wandCard : undefined;
if (!dispel && !s2w && !cdoor && !dwarp && !wand) return null;
const sighted = sightedCellsFor(view);
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: { cmd: Command; total: number } | null = null;
const offer = (cmd: Command, total: number, minGain: number) => {
const gain = normalDist - total;
if (gain >= minGain && (best === null || total < best.total)) best = { cmd, total };
};
// Wall edges: dispellable if conjured; meltable by STONE TO WATER always.
const created = new Set(view.createdEdges);
for (const [key, state] of Object.entries(view.board.edges)) {
if (state !== "wall" && state !== "firewall") 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;
if (!sighted.has(cellKey(cell)) && !sighted.has(cellKey(beyond))) continue;
let total = Infinity;
let nearSteps = Infinity;
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 && da + 1 + db < total) {
total = da + 1 + db;
nearSteps = da;
}
}
if (total === Infinity) continue;
const target = { kind: "edge" as const, cell, side };
if (dispel && created.has(key)) {
offer({ type: "cast", instanceId: dispel.instanceId, target }, total, 4);
}
if (wand && state === "wall" && nearSteps + 1 <= movesLeft) {
const uncharged = view.wandCharges[wand.instanceId] == null;
offer({
type: "cast", instanceId: wand.instanceId, target,
...(uncharged && wandNumber ? { numberInstanceIds: [wandNumber.instanceId] } : {}),
}, total, 4);
}
if (s2w && state === "wall") {
// The collapsing wave covers two cells each side of the wall — melt
// it only from outside its reach.
const away = opposite(side);
const wet = [cell, neighbor(cell, away), beyond, neighbor(beyond, side)];
if (!wet.some((w) => cellKey(w) === cellKey(self.position))) {
offer({ type: "cast", instanceId: s2w.instanceId, target }, total, 4);
}
}
if (cdoor && state === "wall") {
// Two cards (door + key), so the shortcut must earn more.
offer({ type: "cast", instanceId: cdoor.instanceId, target }, total, 6);
}
}
// Filled squares: melting or dispelling one joins its open neighbors.
for (const [k, content] of Object.entries(view.squareContents)) {
if (!sighted.has(k)) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
const c = { x, y };
const openNeighbors = SIDES
.map((dir) => ({ dir, n: neighbor(c, dir) }))
.filter(({ dir, n }) =>
view.board.cells[cellKey(n)] &&
(view.board.edges[edgeKey(c, dir)] ?? "open") === "open");
let total = Infinity;
for (const { n: n1 } of openNeighbors) {
for (const { n: n2 } of openNeighbors) {
if (cellKey(n1) === cellKey(n2)) continue;
const da = dHere.get(cellKey(n1));
const db = dGoal.get(cellKey(n2));
if (da !== undefined && db !== undefined) total = Math.min(total, da + 2 + db);
}
}
if (total === Infinity) continue;
const target = { kind: "cell" as const, cell: c };
if (dispel) offer({ type: "cast", instanceId: dispel.instanceId, target }, total, 4);
if (s2w && content.kind === "stone") {
// The melted block bursts a range-4 wave down all four corridors.
const inWave = (self.position.x === x || self.position.y === y) &&
Math.abs(self.position.x - x) + Math.abs(self.position.y - y) <= 4;
if (!inWave) offer({ type: "cast", instanceId: s2w.instanceId, target }, total, 4);
}
}
if (best === null && dwarp) {
// A long march folds through a warp pair: one token underfoot, one by
// the goal. Tokens shun home bases and solid stone.
const legal = (c: Cell) =>
view.board.cells[cellKey(c)] !== undefined &&
!view.board.homes.some((h) => h.x === c.x && h.y === c.y) &&
view.squareContents[cellKey(c)]?.kind !== "stone" &&
!view.dimWarps.some((w) => cellKey(w.a) === cellKey(c) || cellKey(w.b) === cellKey(c));
if (legal(self.position)) {
let far: { cell: Cell; d: number } | null = null;
for (const [k, d] of dGoal) {
if (d > 2) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (!legal({ x, y })) continue;
if (far === null || d < far.d) far = { cell: { x, y }, d };
}
if (far && normalDist - (1 + far.d) >= 6) {
return {
type: "cast", instanceId: dwarp.instanceId,
params: { cell: self.position }, target: { kind: "cell", cell: far.cell },
};
}
}
}
if (best !== null) return (best as { cmd: Command; total: 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));
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
* key gets used before the boot.
*/
function pathToward(
view: GameView,
from: Cell,
goals: Set<string>,
opts: { avoidNearEnemies?: boolean; canUnlock?: boolean; throughHazards?: boolean } = {},
): PathResult | null {
if (goals.size === 0 || goals.has(cellKey(from))) return null;
const enemyCells = livingEnemies(view).map((p) => p.position);
const nearEnemy = (c: Cell) =>
enemyCells.some((e) => Math.abs(e.x - c.x) + Math.abs(e.y - c.y) <= 2);
const cameBy = new Map<string, { prev: string; dir: Side; viaDoor: boolean }>();
const seen = new Set<string>([cellKey(from)]);
let frontier: Cell[] = [from];
let found: string | null = null;
let depth = 0;
for (; depth < 60 && frontier.length > 0 && !found; depth++) {
const next: Cell[] = [];
for (const c of frontier) {
for (const dir of SIDES) {
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;
seen.add(k);
cameBy.set(k, { prev: cellKey(c), dir, viaDoor });
if (goals.has(k)) { found = k; break; }
// Hazards end a clean path but are waded through when asked to.
const hazard = view.squareContents[k]?.kind;
if (!opts.throughHazards &&
(hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime" || hazard === "tacks")) continue;
if (opts.avoidNearEnemies && nearEnemy(to)) continue;
next.push(to);
}
if (found) break;
}
frontier = next;
}
if (!found) return null;
let cursor = found;
for (;;) {
const hop = cameBy.get(cursor)!;
if (hop.prev === cellKey(from)) {
return {
dir: hop.dir,
distance: depth,
...(hop.viaDoor ? { doorAhead: { cell: from, side: hop.dir } } : {}),
};
}
cursor = hop.prev;
}
}
/** The treasure squares worth marching for. */
function treasureGoals(view: GameView): Set<string> {
const self = me(view);
const goals = new Set<string>();
if (self.carriedTreasureId) {
goals.add(cellKey(self.home));
return goals;
}
for (const t of view.treasures) {
if (!t.position || t.carriedBy) continue;
if (t.owner === view.you) {
// REPOSSESSION: my own gold banked at an enemy's home is a point on
// THEIR scoreboard. Marching to take it back is worth the detour
// only when that enemy is one delivery from winning — constant
// re-stealing farms nothing and stalls the whole table.
const banker = view.players.find(
(p) => p.id !== view.you && cellKey(p.home) === cellKey(t.position!));
if (banker && deliveryWins(view, banker)) goals.add(cellKey(t.position));
continue;
}
if (cellKey(t.position) === cellKey(self.home)) continue;
goals.add(cellKey(t.position));
}
return goals;
}
/** Bushes shelter both ways and mist can neither strike nor be struck —
* the engine refuses such attacks, and a refused choice burns the turn. */
function bushOrMist(view: GameView, id: PlayerId): boolean {
const p = view.players.find((q) => q.id === id)!;
return view.squareContents[cellKey(p.position)]?.kind === "thornbush" ||
view.sustained.some((s) => s.cardId === "mist-body" && s.targetId === id);
}
/** Would this wizard's delivery, landed, win them the game right now? */
function deliveryWins(view: GameView, p: { id: PlayerId; home: Cell; carriedTreasureId: string | null }): boolean {
if (!p.carriedTreasureId) return false;
const carried = view.treasures.find((t) => t.id === p.carriedTreasureId);
if (!carried || carried.owner === p.id) return false;
return view.treasures.some(
(t) => t.owner !== p.id && t.position && cellKey(t.position) === cellKey(p.home));
}
/** The wizard making off with MY gold, if any. */
function thiefOfMine(view: GameView) {
const carriers = new Set(
view.treasures.filter((t) => t.owner === view.you && t.carriedBy && t.carriedBy !== view.you)
.map((t) => t.carriedBy!),
);
return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null;
}
/** The hand limit as this seat experiences it (a displayed BRAINSTONE
* raises it by two — mirrors the engine's handLimit). */
function handLimitOf(view: GameView): number {
return me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
}
function overLimit(view: GameView): number {
return Math.max(0, view.yourHand.length - handLimitOf(view));
}
/** End the turn drawing fully — shedding dead weight first if the hand is
* too full to take the draw. doEndTurn caps the draw by the room left, so a
* clogged hand never refreshes; only cards the brain has no play for are
* shed (good counters and attacks are hoarded, as a human would). */
function endTurnDrawing(view: GameView, tier: TierTraits, style?: AutomatonStyle): Command {
const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length);
if (tier.sheds && deficit > 0) {
const shed = [...view.yourHand]
.filter((c) => discardValue(c, view, style) <= 3)
.sort((a, b) => discardValue(a, view, style) - discardValue(b, view, style))
.slice(0, deficit)
.map((c) => c.instanceId);
if (shed.length > 0) return { type: "discard", instanceIds: shed };
}
return { type: "endTurn", draw: tier.draw };
}
function worstCards(view: GameView, n: number, style?: AutomatonStyle): string[] {
return [...view.yourHand]
.sort((a, b) => discardValue(a, view, style) - discardValue(b, view, style))
.slice(0, n)
.map((c) => c.instanceId);
}
/** A square up to four spaces away that maximizes distance from enemies. */
function escapeCell(view: GameView, from: Cell): Cell | null {
const enemies = livingEnemies(view).map((p) => p.position);
let best: { cell: Cell; score: number } | null = null;
for (const k of Object.keys(view.board.cells)) {
const [x, y] = k.split(",").map(Number) as [number, number];
const d = Math.abs(x - from.x) + Math.abs(y - from.y);
if (d === 0 || d > 4) continue;
if (view.squareContents[k]) continue;
const score = Math.min(...enemies.map((e) => Math.abs(e.x - x) + Math.abs(e.y - y)), 99);
if (!best || score > best.score) best = { cell: { x, y }, score };
}
return best?.cell ?? null;
}
/** Counteraction judgment; the worrier flinches at less. */
function respond(view: GameView, style: AutomatonStyle, tier: TierTraits): Command {
const stack = view.stack!;
const you = view.you;
const find = (id: string) => view.yourHand.find((c) => c.cardId === id);
if (stack.defenderId !== you) {
// Pressing the attack: ANTI-ANTI burns the counter that would stop us
// (the engine refuses it against escapes; the fallback pass covers that).
if (stack.attackerId === you) {
const last = [...stack.counters].reverse().find((c) => !c.nullified);
const anti = find("anti-anti");
if (anti && last && last.card.cardId !== "teleport" && last.card.cardId !== "invisible") {
return { type: "counteract", instanceId: anti.instanceId };
}
}
return { type: "pass" };
}
// The LAST counter in hand is a treasure of its own: at a healthy life
// total it waits for something big rather than answering every jab —
// patience the clockwork's best opponent taught it, three counters at
// a time. (Lethal blows override this below.)
const counterCount = view.yourHand.filter((c) => {
const t = cardDef(c.cardId).cardType;
return t === "counteraction" || t === "neutral/counteraction";
}).length;
const lastCounterHold = counterCount <= 1 && me(view).life >= 10 ? 2 : 0;
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift - lastCounterHold;
const attackId = stack.attackCard?.cardId ?? null;
const atk = attackId ? ATTACKS[attackId] : null;
const affliction = attackId ? AFFLICTIONS[attackId] != null : false;
if (attackId && UTILITY_ATTACKS.has(attackId)) {
const stopper = find("full-shield") ?? find("force-field");
const stakes =
(attackId === "drop-object" && me(view).carriedTreasureId != null) ||
((attackId === "teleport-opponent" || attackId === "mental-force" || attackId === "swap") &&
style === "worrier");
if (stopper && stakes) return { type: "counteract", instanceId: stopper.instanceId };
return { type: "pass" };
}
// An affliction's weight is its duration, not points it does not carry.
// A reflected blow arrives pre-priced — its number is on the stack.
const incoming = stack.reflectedBase
? Math.max(stack.reflectedBase.damage, stack.reflectedBase.duration)
: stack.attackCard
? attackId === "stone-dead"
// NUMBER times the stones in the victim's hand — our hand.
? (stack.numberValue ?? 1) * view.yourHand.filter((c) => STONES.has(c.cardId)).length
: affliction
? Math.max(2, stack.numberValue ?? 2)
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
: 1;
// A blow that would be the last one outranks every thrift threshold:
// when the incoming points reach the clockwork's life, any counter
// that can save it is cheap at the price.
const lethal = !affliction && incoming >= me(view).life;
const weight = lethal ? Math.max(incoming, 9) : incoming;
if (stack.kind === "spell") {
// REVERSE eats points; a pure duration offers it nothing.
const reverse = find("reverse");
if (reverse && !affliction && weight >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
// ABSORB SPELL steals the good ones for later.
const absorbSpell = find("absorb-spell");
if (absorbSpell && weight >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
const shield = find("full-shield");
if (shield && (weight >= 3 - flinch || (affliction && style === "worrier"))) {
return { type: "counteract", instanceId: shield.instanceId };
}
const reflect = find("full-reflection");
if (reflect && weight >= 4 - flinch) return { type: "counteract", instanceId: reflect.instanceId };
// A curse in flight is best refused at the door.
if (affliction) {
const cleanse = find("remove-curse");
if (cleanse) return { type: "counteract", instanceId: cleanse.instanceId };
}
const half = find("reflection");
if (half && incoming >= 3) return { type: "counteract", instanceId: half.instanceId };
}
// ABSORB soaks points only ("no effect on duration-based spells");
// BLUNT and the stones halve durations — but a PERMANENT curse (slow
// death, walking dead, idiot) rides no duration pipe, so nothing short
// of a full stop or REMOVE CURSE touches it.
const permanentCurse = affliction &&
(attackId === "slow-death" || attackId === "walking-dead" || attackId === "idiot");
const absorb = find("absorb");
if (absorb && !affliction && weight >= 2 - flinch && (incoming <= 3 || (lethal && incoming - 3 < me(view).life))) {
return { type: "counteract", instanceId: absorb.instanceId };
}
const blunt = find("blunt");
if (blunt && !permanentCurse && weight >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId };
// The berserker shares its pain out of spite — but only pain: a
// damage-less curse gives EMPATHY nothing to mirror.
const empathy = find("empathy");
if (empathy && style === "berserker" && !affliction && incoming >= 2) {
return { type: "counteract", instanceId: empathy.instanceId };
}
// Nothing to block with: teleport clear of the big ones.
const tp = find("teleport");
if (tp && weight >= 4 - flinch) {
const out = escapeCell(view, me(view).position);
if (out) return { type: "counteract", instanceId: tp.instanceId, params: { cell: out } };
}
// Or vanish: INVISIBLE gives the attacker a 1-in-4 hit and lingers after.
const vanish = find("invisible");
if (vanish && weight >= 3 - flinch) {
const num = numbersInHand(view)[0];
return {
type: "counteract", instanceId: vanish.instanceId,
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
};
}
// A displayed shieldstone lets numbers soak the small ones — durations
// included, but never a permanent curse.
const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone");
if (stoneShown && !permanentCurse && incoming >= 2) {
const num = numbersInHand(view)[0];
if (num) return { type: "counteract", instanceId: num.instanceId };
}
return { type: "pass" };
}
/** The best attack available against a visible target, numbers and amplify included. */
function bestAttack(view: GameView, targetId: PlayerId, tier: TierTraits): { cmd: Command; damage: number } | null {
const numbers = numbersInHand(view);
// TURN THEFT: against a carrier one delivery from winning, a
// LIGHTNING BLAST's stolen turn outranks any point total — the number
// rides along so soaks cannot zero the damage and void the stun.
{
const carrier = view.players.find((p) => p.id === targetId);
const bolt = inHand(view, "lightning-blast");
const biggestNum = numbers[numbers.length - 1];
if (carrier && bolt && biggestNum && deliveryWins(view, carrier)) {
return {
damage: cardDef(biggestNum.cardId).value ?? 1,
cmd: {
type: "cast", instanceId: bolt.instanceId,
target: { kind: "player", playerId: targetId },
numberInstanceIds: [biggestNum.instanceId],
},
};
}
}
const biggest = numbers[numbers.length - 1];
const biggestValue = biggest ? (cardDef(biggest.cardId).value ?? 0) : 0;
const target = view.players.find((p) => p.id === targetId)!;
const together = cellKey(target.position) === cellKey(me(view).position);
const amplify = tier.amplify ? inHand(view, "amplify") : undefined;
// A killing blow at minimal spend outranks maximum splash — but only
// when it clears the target's life with soak to spare: an exact-lethal
// blow dies to one ABSORB, so thrift begins three points past the kill.
type BestPick = { cmd: Command; damage: number; kill: boolean; cost: number };
let best: BestPick | null = null;
const offer = (damage: number, cost: number, cmd: Command) => {
const rank = (d: number) => (d >= target.life + 3 ? 2 : d >= target.life ? 1 : 0);
const r = rank(damage);
const better = !best ? true
: r !== rank(best.damage) ? r > rank(best.damage)
: r === 2 ? cost < best.cost
: damage !== best.damage ? damage > best.damage
: cost < best.cost;
if (better) best = { cmd, damage, kill: damage >= target.life, cost };
};
for (const c of view.yourHand) {
const atk = ATTACKS[c.cardId];
if (!atk) continue;
// Heave-Ho throws the treasure we are carrying: priced for defense,
// never worth casting.
if (c.cardId === "heave-ho") continue;
if (atk.sameSquare && !together) continue;
// A wand needs its charges set by a number on first use.
const isWand = c.cardId === "blaster-wand";
const uncharged = isWand && view.wandCharges[c.instanceId] == null;
if ((uncharged || atk.needsNumber) && !biggest) continue;
const mustNumber = uncharged || atk.needsNumber === true;
const canAmplify = amplify && !isWand && c.cardId !== "dagger" && c.cardId !== "large-rock";
const numberChoices: (CardInstance | undefined)[] = atk.perNumber
? [undefined, ...numbers]
: [mustNumber ? biggest : undefined];
for (const num of numberChoices) {
if (mustNumber && !num) continue;
const value = num ? (cardDef(num.cardId).value ?? 0) : 0;
const base = atk.base + (atk.perNumber && num ? value : 0);
if (base <= 0) continue;
for (const amp of canAmplify && base >= 4 ? [false, true] : [false]) {
const damage = amp ? base * 2 : base;
offer(damage, value + (amp ? 5 : 0), {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
// An illusion needs a spell to imitate; a fireball sells best.
...(c.cardId === "illusionary-attack" ? { params: { cardId: "fireball" } } : {}),
...(num ? { numberInstanceIds: [num.instanceId] } : {}),
...(amp && amplify ? { amplifyInstanceIds: [amplify.instanceId] } : {}),
});
}
}
}
// STONE DEAD: number times the stones the victim carries — the displayed
// ones are the visible floor of that count.
const sd = view.yourHand.find((c) => c.cardId === "stone-dead");
if (sd && biggest) {
const shown = target.displayed.filter((c) => STONES.has(c.cardId)).length;
const dmg = biggestValue * shown;
const cur = best as BestPick | null;
if (dmg >= 4 && (!cur || (dmg >= target.life && !cur.kill) || dmg > cur.damage)) {
best = {
damage: dmg, kill: dmg >= target.life, cost: biggestValue,
cmd: {
type: "cast", instanceId: sd.instanceId,
target: { kind: "player", playerId: targetId },
numberInstanceIds: [biggest.instanceId],
},
};
}
}
const picked = best as BestPick | null;
return picked ? { cmd: picked.cmd, damage: picked.damage } : null;
}
/** An affliction worth casting when no damage lands, mid number attached.
* EXTEND doubles the misery's stay when the hand can spare it. */
function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null {
const numbers = numbersInHand(view);
const mid = numbers[Math.floor(numbers.length / 2)];
const extend = inHand(view, "extend");
for (const c of view.yourHand) {
const aff = AFFLICTIONS[c.cardId];
if (!aff) continue;
if (aff.vsCarrier && !isThief) continue;
if (aff.withNumber && !mid) continue;
return {
type: "cast", instanceId: c.instanceId,
target: { kind: "player", playerId: targetId },
...(aff.withNumber && mid ? { numberInstanceIds: [mid.instanceId] } : {}),
...(aff.withNumber && mid && extend ? { extendInstanceId: extend.instanceId } : {}),
};
}
return null;
}
/**
* The cruelest legal square for TELEPORT OPPONENT: farthest from the
* victim's home by walking distance — a sealed pocket, if one exists. The
* destination needs no line of sight, so the whole maze is on the table.
*/
function exileCell(view: GameView, victim: { home: Cell; position: Cell }): Cell | null {
const dHome = distancesFrom(view, [victim.home], true);
const self = me(view);
const UNREACHABLE = 1000;
let best: { cell: Cell; score: number; tie: number } | null = null;
for (const k of Object.keys(view.board.cells)) {
if (view.squareContents[k]?.kind === "stone") continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.board.homes.some((h) => h.x === x && h.y === y)) continue;
if (view.treasures.some((t) => t.position && cellKey(t.position) === k)) continue;
if (view.players.some((p) => p.alive && cellKey(p.position) === k)) continue;
const d = dHome.get(k);
const score = d === undefined ? UNREACHABLE : d;
const tie = Math.abs(x - self.position.x) + Math.abs(y - self.position.y);
if (!best || score > best.score || (score === best.score && tie > best.tie)) {
best = { cell: { x, y }, score, tie };
}
}
// Only worth the turn's attack if it truly worsens their day.
const now = dHome.get(cellKey(victim.position)) ?? 0;
if (best && (best.score >= UNREACHABLE || best.score >= now + 5)) return best.cell;
return null;
}
/** An empty, sighted square near the target for a summoned creature. */
function summonSpot(view: GameView, near: Cell): Cell | null {
const sighted = sightedCellsFor(view);
let best: { cell: Cell; d: number } | null = null;
for (const k of sighted) {
if (view.squareContents[k]) continue;
const [x, y] = k.split(",").map(Number) as [number, number];
if (view.creatures.some((c) => c.position.x === x && c.position.y === y)) continue;
if (view.board.homes.some((h) => h.x === x && h.y === y)) continue;
const d = Math.abs(x - near.x) + Math.abs(y - near.y);
if (!best || d < best.d) best = { cell: { x, y }, d };
}
return best?.cell ?? null;
}
/** Self-buffs and housekeeping worth a neutral cast this turn. */
function selfCare(view: GameView, style: AutomatonStyle, tier: TierTraits): Command | null {
const self = me(view);
const numbers = numbersInHand(view);
const mid = numbers[Math.floor(numbers.length / 2)];
// Extra turns are always good turns.
const speed = inHand(view, "speed");
if (speed && view.turn.round > 1 && !view.turn.actionsEnded) {
return { type: "cast", instanceId: speed.instanceId };
}
// Free life is free.
const gift = inHand(view, "gift-from-above");
if (gift) return { type: "cast", instanceId: gift.instanceId };
// Every stone goes on the table the turn it is drawn.
for (const c of view.yourHand) {
if (STONES.has(c.cardId) && !self.displayed.some((d) => d.instanceId === c.instanceId)) {
return { type: "cast", instanceId: c.instanceId };
}
}
// So does the MASTER KEY: displayed, every workable lock in the maze
// opens under its bearer's hand without another cast.
{
const mk = inHand(view, "master-key");
if (mk && !self.displayed.some((d) => d.instanceId === mk.instanceId)) {
return { type: "cast", instanceId: mk.instanceId };
}
}
if (!tier.buffs) return null; // the apprentice's book ends at the stones
// A curse on the clockwork gets scrubbed off.
const cursed = view.sustained.some(
(e) => e.targetId === view.you && e.casterId !== view.you && AFFLICTIONS[e.cardId] != null,
);
const cleanse = inHand(view, "remove-curse");
if (cursed && cleanse) return { type: "cast", instanceId: cleanse.instanceId };
// Three or more wizards: LIFESAVER takes elimination off the table.
const lifesaver = inHand(view, "lifesaver");
if (lifesaver && view.players.length > 2) return { type: "cast", instanceId: lifesaver.instanceId };
const enemyNear = livingEnemies(view).some(
(p) => Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3,
);
// The berserker grows; the worrier fades.
if (style === "berserker" && enemyNear && mid) {
const big = inHand(view, "big-man");
if (big) {
return { type: "cast", instanceId: big.instanceId, numberInstanceIds: [mid.instanceId] };
}
}
if (style === "worrier" && enemyNear && mid) {
for (const id of ["invisible", "mist-body", "shrink"]) {
const buff = inHand(view, id);
if (buff) {
return { type: "cast", instanceId: buff.instanceId, numberInstanceIds: [mid.instanceId] };
}
}
// Or slam a wall in the pursuer's face.
const wall = inHand(view, "create-wall");
if (wall) {
const foe = livingEnemies(view).sort((a, b) =>
(Math.abs(a.position.x - self.position.x) + Math.abs(a.position.y - self.position.y)) -
(Math.abs(b.position.x - self.position.x) + Math.abs(b.position.y - self.position.y)))[0]!;
const dx = foe.position.x - self.position.x;
const dy = foe.position.y - self.position.y;
const side: Side = Math.abs(dx) >= Math.abs(dy) && dx !== 0
? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N";
if ((view.board.edges[edgeKey(self.position, side)] ?? "open") === "open") {
return { type: "cast", instanceId: wall.instanceId, target: { kind: "edge", cell: self.position, side } };
}
}
}
// Breathing room for anyone pressed: the worrier by temperament, any
// wizard hauling gold, anyone bleeding out.
const pressed = enemyNear &&
(style === "worrier" || self.carriedTreasureId != null || self.life <= 5);
if (pressed && style !== "berserker") {
// FEAR keeps every pursuer three spaces off.
const scare = inHand(view, "fear");
if (scare && !view.sustained.some((s) => s.cardId === "fear" && s.targetId === view.you)) {
return {
type: "cast", instanceId: scare.instanceId,
...(mid ? { numberInstanceIds: [mid.instanceId] } : {}),
};
}
// UGLY shoves everyone in sight away — best when they are on top of you.
const hideous = inHand(view, "ugly");
if (hideous) {
const sighted = sightedCellsFor(view);
const crowded = livingEnemies(view).some((p) =>
sighted.has(cellKey(p.position)) &&
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 2);
if (crowded) return { type: "cast", instanceId: hideous.instanceId };
}
// BUDDY pacifies the nearest hound: they cannot strike first.
const pact = inHand(view, "buddy");
if (pact) {
const sighted = sightedCellsFor(view);
const hound = livingEnemies(view)
.filter((p) => sighted.has(cellKey(p.position)) &&
!view.sustained.some((s) => s.cardId === "buddy" && s.casterId === view.you && s.targetId === p.id))
.sort((a, b) =>
(Math.abs(a.position.x - self.position.x) + Math.abs(a.position.y - self.position.y)) -
(Math.abs(b.position.x - self.position.x) + Math.abs(b.position.y - self.position.y)))[0];
if (hound) {
return { type: "cast", instanceId: pact.instanceId, target: { kind: "player", playerId: hound.id } };
}
}
}
// INFRASTRUCTURE: a wormhole anchored beside home turns every future
// delivery into a three-move stroll — the pattern that won its best
// opponent two games. Placed early, from home's doorstep, far mouth
// by the richest distant gold.
{
const dwarp = inHand(view, "dimensional-warp");
const nearHome = Math.abs(self.position.x - self.home.x) + Math.abs(self.position.y - self.home.y) <= 1;
if (dwarp && nearHome && view.dimWarps.length === 0 && view.turn.round <= 6) {
const legal = (c: Cell) =>
view.board.cells[cellKey(c)] !== undefined &&
!view.board.homes.some((h) => h.x === c.x && h.y === c.y) &&
!view.squareContents[cellKey(c)] &&
!view.treasures.some((t) => t.position && cellKey(t.position) === cellKey(c)) &&
(view.groundObjects[cellKey(c)] ?? []).length === 0;
const goals = treasureGoals(view);
if (legal(self.position) && goals.size > 0) {
const dHome = distancesFrom(view, [self.home], false);
const sighted = sightedCellsFor(view);
let far: { cell: Cell; worth: number } | null = null;
for (const g of goals) {
const [gx, gy] = g.split(",").map(Number) as [number, number];
for (const side of SIDES) {
const c = neighbor({ x: gx, y: gy }, side);
if (!legal(c) || !sighted.has(cellKey(c))) continue;
const worth = dHome.get(cellKey(c)) ?? 0;
if (worth >= 8 && (far === null || worth > far.worth)) far = { cell: c, worth };
}
}
if (far) {
return {
type: "cast", instanceId: dwarp.instanceId,
params: { cell: self.position }, target: { kind: "cell", cell: far.cell },
};
}
}
}
}
// Guard the gold on the floor: a SAFE locks it, GLUE sticks it down.
const myFloorTreasure = tier.guardGold
? view.treasures.find((t) => t.owner === view.you && t.position && !t.carriedBy)
: undefined;
// Both spells demand line of sight to the gold; a blind cast would be
// refused and cost the whole turn.
if (myFloorTreasure && enemyNear && sightedCellsFor(view).has(cellKey(myFloorTreasure.position!))) {
const safe = inHand(view, "safe");
if (safe && !view.squareContents[cellKey(myFloorTreasure.position!)]) {
return { type: "cast", instanceId: safe.instanceId, target: { kind: "cell", cell: myFloorTreasure.position! } };
}
const glue = inHand(view, "glue");
if (glue && mid) {
return {
type: "cast", instanceId: glue.instanceId,
target: { kind: "cell", cell: myFloorTreasure.position! },
numberInstanceIds: [mid.instanceId],
};
}
}
// 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)) {
const buried = [...view.discardPile].reverse().find(
(c) => ATTACKS[c.cardId] != null && c.cardId !== "blaster-wand" && !ATTACKS[c.cardId]!.needsNumber,
);
if (buried) {
return { type: "cast", instanceId: dv.instanceId, params: { cardId: buried.cardId } };
}
}
// An ambush costs nothing to hold and everything to walk into.
const via = tier.ambush ? (inHand(view, "interrupt") ?? inHand(view, "opportunity-fire")) : undefined;
const spell = view.yourHand.find((c) => ATTACKS[c.cardId] != null && !ATTACKS[c.cardId]!.perNumber &&
c.cardId !== "blaster-wand");
if (via && spell && view.yourAmbushes.length === 0) {
const trigger: AmbushTrigger = style === "worrier" ? { kind: "near" } : { kind: "treasure" };
return { type: "setAmbush", instanceId: via.instanceId, trigger, spellInstanceId: spell.instanceId };
}
return null;
}
/**
* One decision from the automaton's seat, or null when the maze is not
* asking it anything.
*/
export function automatonCommand(
view: GameView,
style: AutomatonStyle = "hunter",
tierName: AutomatonTier = "archmage",
): Command | null {
const you = view.you;
const tier = TIERS[tierName] ?? TIERS.archmage;
if (view.phase !== "playing") return null;
if (view.wardPending) {
// A Ward is free damage on a thief of OUR gold: always spring it.
return view.wardPending.ownerId === you ? { type: "wardChoice", play: true } : null;
}
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view)), style) };
}
if (view.chaosPending && view.chaosPending.queue[0] === you) {
const shield = inHand(view, "full-shield");
return style === "worrier" && shield
? { type: "counteract", instanceId: shield.instanceId }
: { type: "pass" };
}
if (view.stack) {
return view.stack.waitingOn === you ? respond(view, style, tier) : null;
}
if (view.outOfTurnWindow?.playerId === you) return { type: "pass" };
if (view.activePlayerId !== you) return null;
const self = me(view);
const here = cellKey(self.position);
if (view.turn.actionsEnded) return endTurnDrawing(view, tier, style);
// IDIOT bars everything but counteractions, the march's aids, and the
// jailbreak drop below: any other choice burns the turn on a refusal.
const cursedIdiot = view.sustained.some((e) => e.cardId === "idiot" && e.targetId === view.you);
const thief = thiefOfMine(view);
if (!cursedIdiot) {
// Deliver or grab treasure underfoot.
if (self.carriedTreasureId && here === cellKey(self.home)) return { type: "dropTreasure" };
if (!self.carriedTreasureId) {
const prize = view.treasures.find(
(t) => t.position && !t.carriedBy && cellKey(t.position) === here &&
here !== cellKey(self.home) &&
(t.owner !== you ||
// Repossessing my own gold off an enemy's home square.
view.players.some((p) => p.id !== you && cellKey(p.home) === here)),
);
if (prize) return { type: "pickUpTreasure", treasureId: prize.id };
}
// Wounded clockwork teleports clear of visible hunters.
if (self.life <= 5) {
const sighted = sightedCellsFor(view);
const hunted = livingEnemies(view).some((p) => sighted.has(cellKey(p.position)));
const tp = inHand(view, "teleport");
if (hunted && tp) {
const out = escapeCell(view, self.position);
if (out) return { type: "cast", instanceId: tp.instanceId, target: { kind: "cell", cell: out } };
}
}
const care = selfCare(view, style, tier);
if (care) return care;
// MEGA-MONSTER: a doubled stride turns a pet into a bloodhound.
if (tier.buffs && livingEnemies(view).length > 0) {
const mm = inHand(view, "mega-monster");
if (mm) {
const sighted = sightedCellsFor(view);
const pet = view.creatures.find((c) =>
c.controllerId === you && c.kind !== "shadow" && c.kind !== "alter-ego" &&
c.movesPerTurn <= 3 && sighted.has(cellKey(c.position)));
if (pet) {
return {
type: "cast", instanceId: mm.instanceId,
target: { kind: "creature", creatureId: pet.id }, params: { boost: "movement" },
};
}
}
}
// Command the menagerie: creatures march and maul before the wizard moves.
for (const c of view.creatures) {
if (c.controllerId !== you || c.justCreated) continue;
const enemyHere = livingEnemies(view).find((p) => cellKey(p.position) === cellKey(c.position));
if (enemyHere && !c.attackUsed &&
(c.kind === "troll" || c.kind === "skeleton" || c.kind === "shadow")) {
return { type: "creatureAttack", creatureId: c.id, targetId: enemyHere.id };
}
if (c.movementUsed < c.movesPerTurn) {
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
const hunt = pathToward(view, c.position, enemyCells, {});
if (hunt && !hunt.doorAhead) return { type: "moveCreature", creatureId: c.id, direction: hunt.dir };
}
}
// One attack per turn: the thief of my gold dies first, then the weakest.
// ADRENALINE stretches that to two while it lasts.
const adrenalized = view.sustained.some((s) => s.cardId === "adrenaline" && s.targetId === you);
if ((!view.turn.attackUsed || (adrenalized && !view.turn.secondAttackUsed)) &&
view.turn.round > 1) {
const sighted = sightedCellsFor(view);
// Whoever cast BUDDY on you is safe from your first strike — the
// engine refuses the attack outright. Your OWN pacts are merely
// precious: striking someone you pacted tears up your protection.
const shieldedFromMe = (id: PlayerId) =>
view.sustained.some((s) => s.cardId === "buddy" && s.casterId === id && s.targetId === you);
const myPact = (id: PlayerId) =>
view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === id);
const visible = bushOrMist(view, you) ? [] : livingEnemies(view)
.filter((p) => sighted.has(cellKey(p.position)) &&
!shieldedFromMe(p.id) && !myPact(p.id) && !bushOrMist(view, p.id));
if (visible.length > 0) {
const target = thief && visible.some((p) => p.id === thief.id)
? thief
: visible.sort((a, b) => a.life - b.life)[0]!;
// DROP OBJECT shakes the treasure out of the hands that matter:
// the thief of my gold, or any carrier whose delivery wins.
{
const mark = (thief && visible.some((p) => p.id === thief.id)) ? thief
: visible.find((p) => deliveryWins(view, p));
const dob = mark ? inHand(view, "drop-object") : undefined;
if (mark && dob) {
return {
type: "cast", instanceId: dob.instanceId,
target: { kind: "player", playerId: mark.id }, params: { cardId: "treasure" },
};
}
}
// TELEPORT OPPONENT exiles a delivery in progress: the thief of my
// gold, or any carrier closing on their home.
const exile = inHand(view, "teleport-opponent");
if (exile) {
const carrier = visible.find((p) => {
if (thief && p.id === thief.id) return true;
if (deliveryWins(view, p)) return true;
if (!p.carriedTreasureId) return false;
return Math.abs(p.position.x - p.home.x) + Math.abs(p.position.y - p.home.y) <= 6;
});
const dest = carrier ? exileCell(view, carrier) : null;
if (carrier && dest) {
return {
type: "cast", instanceId: exile.instanceId,
target: { kind: "player", playerId: carrier.id }, params: { cell: dest },
};
}
}
if (tier.buffs && !view.turn.attackUsed) {
const smallest = numbersInHand(view)[0];
// ADRENALINE: when no single blow finishes the target but two would.
const adr = adrenalized ? undefined : inHand(view, "adrenaline");
if (adr && smallest) {
const bases = view.yourHand
.filter((c) => ATTACKS[c.cardId] && !ATTACKS[c.cardId]!.needsNumber && c.cardId !== "heave-ho")
.map((c) => ATTACKS[c.cardId]!.base)
.sort((a, b) => b - a);
if (bases.length >= 2 && bases[0]! < target.life && bases[0]! + bases[1]! >= target.life) {
return { type: "cast", instanceId: adr.instanceId, numberInstanceIds: [smallest.instanceId] };
}
}
// STRENGTH: doubled steel where it turns a wound into a finisher.
const strengthUp = view.sustained.some((s) => s.cardId === "strength" && s.targetId === you);
const str = strengthUp ? undefined : inHand(view, "strength");
if (str && smallest) {
const physBase = Math.max(0, ...view.yourHand
.filter((c) => c.cardId === "dagger" || c.cardId === "large-rock")
.map((c) => ATTACKS[c.cardId]!.base));
if (physBase > 0 && physBase < target.life && physBase * 2 >= target.life) {
return { type: "cast", instanceId: str.instanceId, numberInstanceIds: [smallest.instanceId] };
}
}
}
const spell = bestAttack(view, target.id, tier);
if (spell) return spell.cmd;
const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null;
if (misery) return misery;
// STRENGTH in force and the treasure in reach: wrest it from their
// arms — worth the attack even for the timid.
if (cellKey(target.position) === here && target.carriedTreasureId &&
view.sustained.some((e) => e.cardId === "strength" && e.targetId === view.you)) {
return { type: "tearTreasure", targetId: target.id };
}
if (cellKey(target.position) === here && style !== "worrier") {
return { type: "punch", targetId: target.id };
}
}
// Only your own pacted wizards in sight? A pact you signed is torn
// up for one thing: a kill. (Pacts signed ON you cannot be broken.)
const pactedVisible = livingEnemies(view)
.filter((p) => sighted.has(cellKey(p.position)) && myPact(p.id) &&
!shieldedFromMe(p.id) && !bushOrMist(view, p.id))
.sort((a, b) => a.life - b.life);
for (const p of pactedVisible) {
const spell = bestAttack(view, p.id, tier);
if (spell && spell.damage >= p.life) return spell.cmd;
}
// AROUND THE CORNER: an enemy tucked one bend out of sight is not safe.
if (visible.length === 0 && tier.buffs) {
const corner = inHand(view, "around-the-corner");
if (corner) {
for (const p of livingEnemies(view)) {
if (shieldedFromMe(p.id) || myPact(p.id) || bushOrMist(view, p.id)) continue;
if (!bentSightFor(view, self.position, p.position)) continue;
const spell = bestAttack(view, p.id, tier);
if (spell && spell.cmd.type === "cast") {
return { ...spell.cmd, aroundCornerInstanceId: corner.instanceId };
}
}
}
}
// No shot at a wizard: raise a creature to do the walking.
const summon = view.yourHand.find((c) => SUMMONS.has(c.cardId));
if (summon && livingEnemies(view).length > 0) {
const near = style === "worrier" ? self.position : livingEnemies(view)[0]!.position;
const spot = summonSpot(view, near);
if (spot) {
return { type: "cast", instanceId: summon.instanceId, target: { kind: "cell", cell: spot } };
}
}
}
// BUDDY after the blow: the wounded cannot come looking for payback —
// the pact holds unless the clockwork strikes them again. Signed only
// when the clockwork actually wants OUT of the fight (hauling gold,
// bleeding, or with other enemies left to hunt); a healthy duelist keeps
// its options, and the berserker would only break its own pact tomorrow.
if (tier.buffs && view.turn.attackUsed && style !== "berserker" &&
(self.carriedTreasureId != null || self.life <= 8 || livingEnemies(view).length > 1)) {
const pact = inHand(view, "buddy");
if (pact) {
const sighted = sightedCellsFor(view);
const mark = livingEnemies(view)
.filter((p) => sighted.has(cellKey(p.position)) &&
!view.sustained.some((s) => s.cardId === "buddy" && s.casterId === you && s.targetId === p.id))
.sort((a, b) => a.life - b.life)[0];
if (mark) {
return { type: "cast", instanceId: pact.instanceId, target: { kind: "player", playerId: mark.id } };
}
}
}
// FEAR's compulsion: "must move away from you on their turn if within
// range, if they can." Caught in a dread bubble, the clockwork spends
// its legs fleeing before any other marching.
if (view.turn.movementUsed < view.turn.movementAllowance) {
const dreadful = livingEnemies(view).filter((p) =>
view.sustained.some((e) => e.cardId === "fear" && e.targetId === p.id) &&
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 3);
for (const source of dreadful) {
const distNow = Math.abs(source.position.x - self.position.x) + Math.abs(source.position.y - self.position.y);
let best: { dir: Side; d: number } | null = null;
for (const dir of SIDES) {
const t = stepTarget(view.board, self.position, dir);
if (t.kind === "blocked") continue;
if (view.squareContents[cellKey(t.to)]?.kind === "stone") continue;
const d = Math.abs(source.position.x - t.to.x) + Math.abs(source.position.y - t.to.y);
if (d > distNow && (best === null || d > best.d)) best = { dir, d };
}
if (best) return { type: "move", direction: best.dir };
// Dead end: the card excuses what cannot be done; march on normally.
}
}
}
// IDIOT's curse: the engine never forces the feet, so the clockwork
// honors the march to its own gold itself — and when a thief carries
// that gold, DROP OBJECT (the one attack the curse permits) shakes it
// onto the floor where it can finally be stood upon.
if (cursedIdiot && view.turn.round > 1 && !view.turn.attackUsed) {
const dropper = inHand(view, "drop-object");
if (dropper) {
const sighted = sightedCellsFor(view);
const thiefOfGold = livingEnemies(view).find((p) =>
sighted.has(cellKey(p.position)) &&
view.treasures.some((t) => t.owner === view.you && t.carriedBy === p.id));
if (thiefOfGold) {
return {
type: "cast", instanceId: dropper.instanceId,
target: { kind: "player", playerId: thiefOfGold.id }, params: { cardId: "treasure" },
};
}
}
}
// TELEPORT DELIVERY: carrying with home four wall-ignoring spaces away
// but farther by boot — blink in and drop next command. The jump beats
// any ambush camped on the walking road.
if (self.carriedTreasureId && !view.turn.actionsEnded) {
const tpHome = inHand(view, "teleport");
if (tpHome) {
const blink = wallIgnoringDistance(view.board, self.position, self.home);
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
const dWalk = blink >= 1 && blink <= 4
? distancesFrom(view, [self.position], UNLOCKS.some((id) => inHand(view, id)))
.get(cellKey(self.home)) ?? Infinity
: 0;
if (blink <= 4 && blink >= 1 && dWalk > movesLeft) {
return { type: "cast", instanceId: tpHome.instanceId, target: { kind: "cell", cell: { ...self.home } } };
}
}
}
// March. The thief-chase outranks everything; the berserker hunts wizards
// over gold; the worrier keeps its distance; the hunter goes to the gold.
if (view.turn.movementUsed < view.turn.movementAllowance) {
const gold = treasureGoals(view);
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
const canUnlock = UNLOCKS.some((id) => inHand(view, id));
const ownGoldCells = new Set(view.treasures
.filter((t) => t.owner === view.you && t.position && !t.carriedBy)
.map((t) => cellKey(t.position!)));
// The chase exists to deliver a blow, so it claims the march only
// while that blow is still live: an attack in hand, this turn's
// attack unspent, casting not yet closed. Those gates hold still as
// the clockwork walks — a goal keyed to the thief's nearness flips
// underfoot and shuttles the walker on and off their square. Caught
// with the strike still live, it stands its ground (a goal underfoot
// ends the march); otherwise the gold has its legs for the turn.
const strikeLive = !view.turn.attackUsed && !view.turn.attackForbidden &&
!view.turn.actionsEnded && view.yourHand.some((c) => ATTACKS[c.cardId] != null);
const chasing = thief !== null && strikeLive;
// BANK GUARD: enemy gold delivered to my home is my scoreboard, and
// anyone may snatch it off the floor. A raider nearer my bank than I
// am, with the bank stocked, outranks the next grab — run home.
const bankedCount = view.treasures.filter(
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home)).length;
// The predicate reads only ENEMY positions — my own steps must not
// flip it mid-march or the walker shuttles (the thief-chase lesson).
const bankThreatened = bankedCount > 0 && !self.carriedTreasureId &&
livingEnemies(view).some((p) =>
!p.carriedTreasureId &&
Math.abs(p.position.x - self.home.x) + Math.abs(p.position.y - self.home.y) <= 2);
const objectives = cursedIdiot && ownGoldCells.size > 0
? ownGoldCells
: chasing
? new Set([cellKey(thief.position)])
: bankThreatened
? new Set([cellKey(self.home)])
: style === "berserker" && !self.carriedTreasureId
? (enemyCells.size > 0 ? enemyCells : gold)
: gold;
const path =
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !chasing, canUnlock }) ??
pathToward(view, self.position, objectives, { canUnlock }) ??
pathToward(view, self.position, enemyCells, { canUnlock }) ??
// No clean road to anything: grit the teeth and wade the hazards,
// as any wizard would rather than stand in a pocket forever.
pathToward(view, self.position, objectives, { canUnlock, throughHazards: true }) ??
pathToward(view, self.position, enemyCells, { canUnlock, throughHazards: true });
// A shimmering illusion on the best crossing costs nothing to doubt:
// roll the free test before spending any card on that wall.
const doubted = bestWallCrossing(view, self, objectives, canUnlock);
if (doubted && doubted.total < (path?.distance ?? Infinity) &&
view.illusionEdges[edgeKey(doubted.cell, doubted.side)] === "untested") {
const sighted = sightedCellsFor(view);
const flanks = [doubted.cell, neighbor(doubted.cell, doubted.side)];
if (flanks.some((c) => cellKey(c) === cellKey(self.position) || sighted.has(cellKey(c)))) {
return { type: "testIllusion", cell: doubted.cell, side: doubted.side };
}
}
// 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 } };
}
}
// 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 };
}
}
// The rest of the toolbox: dispel, melt, door-and-key, wand, warp tokens.
if (!view.turn.actionsEnded) {
const work = roadworkPlan(view, self, objectives, canUnlock,
path?.distance ?? Infinity, view.turn.movementAllowance - view.turn.movementUsed);
if (work) return work;
}
// Standing on a warp token whose far side is closer to the goal: step in.
{
const pair = view.dimWarps.find((w) => cellKey(w.a) === here || cellKey(w.b) === here);
if (pair) {
const dest = cellKey(pair.a) === here ? pair.b : pair.a;
const dFar = distancesFrom(view, [dest], canUnlock);
const farBest = Math.min(...[...objectives].map((g) => dFar.get(g) ?? Infinity));
if (farBest + 1 < (path?.distance ?? Infinity)) return { type: "warpStep" };
}
}
if (path) {
// A locked door on the very next step: use the key first.
if (path.doorAhead) {
for (const id of UNLOCKS) {
const key = inHand(view, id);
if (key) {
return {
type: "cast", instanceId: key.instanceId,
target: { kind: "edge", cell: path.doorAhead.cell, side: path.doorAhead.side },
};
}
}
} else {
const movesLeft = view.turn.movementAllowance - view.turn.movementUsed;
const numbers = numbersInHand(view);
// The war chest: when an attack in hand wants a number, the
// biggest one is reserved — a waterbolt unfired outbids a longer
// march, and walking is free next turn.
// — but a chest hoarded with no enemy in reach just slows the
// march, and no reserve outbids the delivery that wins the game.
const foesClose = livingEnemies(view).some((p) =>
Math.abs(p.position.x - self.position.x) + Math.abs(p.position.y - self.position.y) <= 6);
const wantsNumber = foesClose && !deliveryWins(view, self) &&
view.yourHand.some((c) => {
const atk = ATTACKS[c.cardId];
if (!atk) return false;
return atk.perNumber || atk.needsNumber === true ||
(c.cardId === "blaster-wand" && view.wandCharges[c.instanceId] == null);
});
const spendable = wantsNumber ? numbers.slice(0, -1) : numbers;
// A treasure within free walking ends this turn at the grab (the
// gold-underfoot rung outranks everything) — a number played first
// would evaporate. Even a charging berserker stoops for loot.
const goldWithinLegs = !self.carriedTreasureId &&
(() => {
const dHere = distancesFrom(view, [self.position], canUnlock);
return [...treasureGoals(view)].some((k) => (dHere.get(k) ?? Infinity) <= movesLeft);
})();
if (!view.turn.numberPlayedForMovement && path.distance > movesLeft && !goldWithinLegs) {
const helper = spendable.find(
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined);
if (helper) {
return { type: "playNumberForMovement", instanceId: helper.instanceId };
}
}
// ADD joins a second number when the first still leaves the goal shy.
if (view.turn.numberPlayedForMovement && !view.turn.movementAddUsed &&
path.distance > movesLeft && spendable.length > 0) {
const add = inHand(view, "add");
if (add) {
const second = spendable.find(
(n) => movesLeft + (cardDef(n.cardId).value ?? 0) >= path.distance,
) ?? (path.distance > movesLeft + 2 ? spendable[spendable.length - 1] : undefined);
if (second) {
return {
type: "playNumberForMovement",
instanceId: second.instanceId, addInstanceId: add.instanceId,
};
}
}
}
// MAD DASH doubles what the legs already have — for the sprint to
// gold that would otherwise take another turn. Not while carrying.
if (path.distance > movesLeft && path.distance >= movesLeft + 3 &&
path.distance <= movesLeft * 2 && !self.carriedTreasureId &&
!view.turn.actionsEnded) {
const dash = inHand(view, "mad-dash");
if (dash) return { type: "cast", instanceId: dash.instanceId };
}
// POWER RUN buys the last spaces of a winning delivery with blood.
if (path.distance > movesLeft && self.carriedTreasureId && !view.turn.actionsEnded) {
const carried = view.treasures.find((t) => t.id === self.carriedTreasureId);
const stolenAtHome = view.treasures.filter(
(t) => t.owner !== you && t.position && cellKey(t.position) === cellKey(self.home),
).length;
const points = path.distance - movesLeft;
if (carried && carried.owner !== you && stolenAtHome >= 1 &&
points <= 4 && self.life - points >= 5) {
const run = inHand(view, "power-run");
if (run) return { type: "cast", instanceId: run.instanceId, params: { points } };
}
}
return { type: "move", direction: path.dir };
}
}
}
return endTurnDrawing(view, tier, style);
}
/** The safe fallback when the automaton's choice was refused. */
export function automatonFallback(view: GameView, tierName: AutomatonTier = "archmage"): Command {
const you = view.you;
if (view.wardPending?.ownerId === you) return { type: "wardChoice", play: false };
if (view.pendingDiscard === you) {
return { type: "discard", instanceIds: worstCards(view, Math.max(1, overLimit(view))) };
}
if (view.stack || view.chaosPending || view.outOfTurnWindow) return { type: "pass" };
return { type: "endTurn", draw: (TIERS[tierName] ?? TIERS.archmage).draw };
}