// 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 = { 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 = { 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 }, "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 = { 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 { const dist = new Map(); 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, 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; } /** * 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. */ /** 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", ]; 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, 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 }, }; } } } return best !== null ? (best as { cmd: Command; total: number }).cmd : 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, 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, opts: { avoidNearEnemies?: boolean; canUnlock?: 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(); const seen = new Set([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; } const hazard = view.squareContents[k]?.kind; if (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 { const self = me(view); const goals = new Set(); 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; } /** 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. const incoming = 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 work on durations too. const absorb = find("absorb"); if (absorb && !affliction && incoming >= 2 - flinch && incoming <= 3) return { type: "counteract", instanceId: absorb.instanceId }; const blunt = find("blunt"); if (blunt && incoming >= 2 - flinch) return { type: "counteract", instanceId: blunt.instanceId }; // The berserker shares its pain out of spite. const empathy = find("empathy"); if (empathy && style === "berserker" && 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 && (!stack.creatureId || view.deckRev >= 13) && 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. const stoneShown = me(view).displayed.some((c) => c.cardId === "shieldstone"); if (stoneShown && 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): Command | 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?.cmd ?? 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 }; } } 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 }; // The ward guards the gold while its owner is away robbing yours. if (inHand(view, "ward") && !view.yourWardArmed) { return { type: "armWard", armed: true }; } 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; if (myFloorTreasure && enemyNear) { const safe = inHand(view, "safe"); if (safe) { 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.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); // 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 }; } } const thief = thiefOfMine(view); // 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); const visible = livingEnemies(view).filter((p) => sighted.has(cellKey(p.position))); 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; const misery = tier.afflictions ? bestAffliction(view, target.id, thief?.id === target.id) : null; if (misery) return misery; if (cellKey(target.position) === here && style !== "worrier") { return { type: "punch", targetId: target.id }; } } // 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 (!bentSightFor(view, self.position, p.position)) continue; const spell = bestAttack(view, p.id, tier); if (spell && spell.type === "cast") { return { ...spell, 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 } }; } } } // 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 objectives = thief ? new Set([cellKey(thief.position)]) : style === "berserker" && !self.carriedTreasureId ? (enemyCells.size > 0 ? enemyCells : gold) : gold; const path = pathToward(view, self.position, objectives, { avoidNearEnemies: style === "worrier" && !thief, canUnlock }) ?? pathToward(view, self.position, objectives, { canUnlock }) ?? pathToward(view, self.position, enemyCells, { canUnlock }); // A wall between here and the gold may be cheaper to remove than to walk // around — and sometimes it is the only way through. const dw = inHand(view, "destroy-wall"); if (dw && !view.turn.actionsEnded) { const blast = wallBlastTarget(view, self, objectives, canUnlock, path?.distance ?? Infinity); if (blast) { return { type: "cast", instanceId: dw.instanceId, target: { kind: "edge", ...blast } }; } } // 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 here = cellKey(self.position); 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; if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) { 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.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 }; }