Three blind reviews over the first-person arc, every finding verified against the source. History-narrating comments made timeless or cut; the stacked BUDDY comment collapsed to one voice. Dead code out: the orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter poke, the impossible-state sentinel. The never-produced "warp" hit kind resolved the right way — warp mouths now hang a translucent veil of the painted warp texture, so art that never rendered finally does. Types tightened (SlabStrike named once, ShareData rides CatchUpStep, botTier loses its casts), the gallery reads MATERIALS instead of a hand-copied list, the chronicle resets through one helper, a superseded share mint rejects instead of stranding, and the conjured safe gains the growth key its siblings had. Tests lose a triple assignment, a tautology, and two self-swallowing regex alternatives. The sprite spec — which still told the artist to paint for additive compositing the renderer no longer uses — now describes the renderer that exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1550 lines
70 KiB
TypeScript
1550 lines
70 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 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! });
|
|
}
|
|
}
|
|
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.
|
|
if (filler && 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")) 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) 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);
|
|
}
|
|
|
|
/** 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" };
|
|
}
|
|
|
|
const flinch = (style === "worrier" ? 1 : 0) - tier.counterThrift;
|
|
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;
|
|
if (stack.kind === "spell") {
|
|
// REVERSE eats points; a pure duration offers it nothing.
|
|
const reverse = find("reverse");
|
|
if (reverse && !affliction && incoming >= 4 - flinch) return { type: "counteract", instanceId: reverse.instanceId };
|
|
// ABSORB SPELL steals the good ones for later.
|
|
const absorbSpell = find("absorb-spell");
|
|
if (absorbSpell && incoming >= 3) return { type: "counteract", instanceId: absorbSpell.instanceId };
|
|
const shield = find("full-shield");
|
|
if (shield && (incoming >= 3 - flinch || (affliction && style === "worrier"))) {
|
|
return { type: "counteract", instanceId: shield.instanceId };
|
|
}
|
|
const reflect = find("full-reflection");
|
|
if (reflect && incoming >= 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 && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId };
|
|
const blunt = find("blunt");
|
|
if (blunt && !permanentCurse && incoming >= 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 && incoming >= 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 && incoming >= 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);
|
|
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;
|
|
let best: { cmd: Command; damage: number } | null = null;
|
|
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 withNumber = (atk.perNumber || uncharged) && biggest;
|
|
let damage = atk.base + (atk.perNumber && withNumber ? biggestValue : 0);
|
|
if (damage <= 0) continue;
|
|
// AMPLIFY doubles the heavy hitters (spells only, not thrown things).
|
|
const amplified = amplify && damage >= 4 && !isWand &&
|
|
c.cardId !== "dagger" && c.cardId !== "large-rock";
|
|
if (amplified) damage *= 2;
|
|
if (!best || damage > best.damage) {
|
|
best = {
|
|
damage,
|
|
cmd: {
|
|
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" } } : {}),
|
|
...(withNumber ? { numberInstanceIds: [biggest.instanceId] } : {}),
|
|
...(amplified ? { 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;
|
|
if (dmg >= 4 && dmg > (best?.damage ?? 0)) {
|
|
best = {
|
|
damage: dmg,
|
|
cmd: {
|
|
type: "cast", instanceId: sd.instanceId,
|
|
target: { kind: "player", playerId: targetId },
|
|
numberInstanceIds: [biggest.instanceId],
|
|
},
|
|
};
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 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 };
|
|
}
|
|
}
|
|
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 } };
|
|
}
|
|
}
|
|
}
|
|
// 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 && t.owner !== you && cellKey(t.position) === here &&
|
|
here !== cellKey(self.home),
|
|
);
|
|
if (prize) return { type: "pickUpTreasure" };
|
|
}
|
|
|
|
// 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 my treasure out of the thief's hands.
|
|
if (thief && target.id === thief.id) {
|
|
const dob = inHand(view, "drop-object");
|
|
if (dob) {
|
|
return {
|
|
type: "cast", instanceId: dob.instanceId,
|
|
target: { kind: "player", playerId: thief.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 (!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" },
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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!)));
|
|
// A thief already underfoot is not a destination — with the chase
|
|
// moot, march on the real goals (deliver, grab) instead of standing
|
|
// on them forever.
|
|
const thiefAfar = thief && cellKey(thief.position) !== here ? thief : null;
|
|
const objectives = cursedIdiot && ownGoldCells.size > 0
|
|
? ownGoldCells
|
|
: thiefAfar
|
|
? new Set([cellKey(thiefAfar.position)])
|
|
: style === "berserker" && !self.carriedTreasureId
|
|
? (enemyCells.size > 0 ? enemyCells : gold)
|
|
: gold;
|
|
const path =
|
|
pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thiefAfar, 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.
|
|
const wantsNumber = 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 };
|
|
}
|