Punching was mostly deck arithmetic — nineteen castable damage cards in a 196-card deck — but three leaks made it worse. The march was burning the biggest NUMBER card on long walks, starving waterbolts and lightning; the biggest number is now reserved whenever an attack in hand wants it. Heave-Ho, priced for defense, was being cast without a treasure to throw (refused, turn stumbled) — now never cast. Illusionary Attack was cast without naming a spell to fake — now it fakes a fireball, the best-selling lie. And STONE DEAD joins the arsenal against anyone flashing displayed stones: number times the visible count, cast when the arithmetic beats the alternatives. Measured across thirty berserker mirrors: attack casts up a quarter, the tier handicap sharper (25-5), no stalls. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
749 lines
32 KiB
TypeScript
749 lines
32 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, stepTarget, SIDES, type Cell, type Side } from "./board";
|
|
import { 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 },
|
|
"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"];
|
|
|
|
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. */
|
|
function discardValue(c: CardInstance): 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) return 7;
|
|
if (c.cardId === "speed" || c.cardId === "interrupt" || c.cardId === "opportunity-fire" ||
|
|
c.cardId === "ward" || c.cardId === "drop-object" || c.cardId === "gift-from-above" ||
|
|
c.cardId === "deja-vu" || c.cardId === "amplify" || c.cardId === "safe" ||
|
|
c.cardId === "glue") return 6;
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* 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 } = {},
|
|
): 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 t = stepTarget(view.board, c, dir);
|
|
let to: Cell;
|
|
let viaDoor = false;
|
|
if (t.kind === "blocked") {
|
|
if (t.by !== "door") continue;
|
|
const k = edgeKey(c, dir);
|
|
const alreadyOpen =
|
|
view.openDoorEdges.includes(k) || view.doorStates[k] === "removed";
|
|
if (!alreadyOpen && !opts.canUnlock) continue;
|
|
const n = { x: c.x + (dir === "E" ? 1 : dir === "W" ? -1 : 0),
|
|
y: c.y + (dir === "S" ? 1 : dir === "N" ? -1 : 0) };
|
|
if (!view.board.cells[cellKey(n)]) continue;
|
|
to = n;
|
|
viaDoor = !alreadyOpen;
|
|
} else {
|
|
to = t.to;
|
|
}
|
|
const 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<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;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
function overLimit(view: GameView): number {
|
|
return Math.max(0, view.yourHand.length - 7);
|
|
}
|
|
|
|
|
|
/** 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): Command {
|
|
const limit = me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
|
const deficit = tier.draw - (limit - view.yourHand.length);
|
|
if (tier.sheds && deficit > 0) {
|
|
const shed = [...view.yourHand]
|
|
.filter((c) => discardValue(c) <= 3)
|
|
.sort((a, b) => discardValue(a) - discardValue(b))
|
|
.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): string[] {
|
|
return [...view.yourHand]
|
|
.sort((a, b) => discardValue(a) - discardValue(b))
|
|
.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" };
|
|
}
|
|
|
|
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
|
|
: (atk ? atk.base + (atk.perNumber ? stack.numberValue ?? 1 : 0) : 2)
|
|
: 1;
|
|
if (stack.kind === "spell") {
|
|
// REVERSE turns the biggest blasts into a meal.
|
|
const reverse = find("reverse");
|
|
if (reverse && 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 };
|
|
}
|
|
const absorb = find("absorb");
|
|
if (absorb && 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. */
|
|
function bestAffliction(view: GameView, targetId: PlayerId, isThief: boolean): Command | null {
|
|
const numbers = numbersInHand(view);
|
|
const mid = numbers[Math.floor(numbers.length / 2)];
|
|
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] } : {}),
|
|
};
|
|
}
|
|
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 } };
|
|
}
|
|
}
|
|
}
|
|
// 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],
|
|
};
|
|
}
|
|
}
|
|
// 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))) };
|
|
}
|
|
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);
|
|
|
|
// 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;
|
|
|
|
// 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.
|
|
if (!view.turn.attackUsed && 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" },
|
|
};
|
|
}
|
|
}
|
|
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 };
|
|
}
|
|
}
|
|
// 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 });
|
|
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;
|
|
if (!view.turn.numberPlayedForMovement && path.distance > movesLeft) {
|
|
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;
|
|
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 };
|
|
}
|
|
}
|
|
return { type: "move", direction: path.dir };
|
|
}
|
|
}
|
|
}
|
|
|
|
return endTurnDrawing(view, tier);
|
|
}
|
|
|
|
/** 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 };
|
|
}
|