The automatons awaken (branch only — not for the public droplet yet)

Phase 4 begins. The automaton is a pure function in the engine —
automatonCommand(view) — playing from its own redacted GameView, the
same information a human seat receives: hidden hands stay hidden from
the clockwork. It ranks simple damage spells, counters what hurts
(full shield at 3+, reflection at 4+, blunt at 2+), discards its
worst cards by a value order, BFS-pathfinds to enemy treasures and
home again, refuses to path through hazards, brawls when there is
nothing to steal, and always has a safe fallback; the server's drive
loop steps any bot-held seat through the same runCommand path as
humans, so bot commands log, persist, replay, and broadcast like
anyone's.

Hosts seat them pre-start with "⚙ seat an automaton" (Automaton,
Automaton II, ... V); bot seats persist as tokenless join lines and
restore on boot. Proven three ways: bot-vs-bot engine games conclude
across seeds in 8-14 rounds (~100 commands — human-scale), a full
four-automaton table finishes, and a live websocket game of human
vs. automaton ended with the clockwork carrying two treasures home
through a do-nothing opponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 16:11:54 -04:00
co-authored by Claude Fable 5
parent 5802e1df05
commit ab52628d2f
9 changed files with 559 additions and 5 deletions
+200
View File
@@ -0,0 +1,200 @@
// 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.
import { cardDef, type CardInstance } from "./cards";
import { cellKey, stepTarget, SIDES, type Cell, type Side } from "./board";
import { sightedCellsFor, type GameView } from "./view";
import type { Command, PlayerId } from "./game";
/** Damage attacks simple enough for clockwork: id -> flat damage dealt. */
const SIMPLE_ATTACKS: Record<string, number> = {
fireball: 5,
"sudden-death": 10,
powerthrust: 2,
dagger: 1,
"large-rock": 2,
};
/** Cards the automaton happily discards to churn its hand. */
function discardValue(c: CardInstance): number {
const def = cardDef(c.cardId);
if (def.cardType === "counteraction" || def.cardType === "neutral/counteraction") return 8;
if (SIMPLE_ATTACKS[c.cardId] != null) return 7;
if (def.cardType === "number") return 4 + (def.value ?? 0);
if (def.cardType === "attack") return 3;
return 2; // situational neutrals go first
}
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);
}
/** BFS over walkable steps; returns the first direction of a shortest path. */
function firstStepToward(view: GameView, from: Cell, goals: Set<string>): Side | null {
if (goals.size === 0 || goals.has(cellKey(from))) return null;
const cameBy = new Map<string, { prev: string; dir: Side }>();
const seen = new Set<string>([cellKey(from)]);
let frontier: Cell[] = [from];
let found: string | null = null;
for (let depth = 0; 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);
if (t.kind === "blocked") continue;
const k = cellKey(t.to);
if (seen.has(k)) continue;
if (view.squareContents[k]?.kind === "stone") continue;
seen.add(k);
cameBy.set(k, { prev: cellKey(c), dir });
if (goals.has(k)) { found = k; break; }
// The automaton refuses to path THROUGH hazards, but will end on one
// only if it is the goal itself.
const hazard = view.squareContents[k]?.kind;
if (hazard === "pit" || hazard === "ooze" || hazard === "thornbush" ||
hazard === "rosebush" || hazard === "slime") continue;
next.push(t.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 hop.dir;
cursor = hop.prev;
}
}
/** The treasure squares worth marching for, best first. */
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;
// A treasure already delivered to one of MY... any floor treasure of an
// enemy is worth taking; skip ones resting on my own home (already won).
if (cellKey(t.position) === cellKey(self.home)) continue;
goals.add(cellKey(t.position));
}
return goals;
}
function overLimit(view: GameView): number {
const limit = 7;
return Math.max(0, view.yourHand.length - limit);
}
function worstCards(view: GameView, n: number): string[] {
return [...view.yourHand]
.sort((a, b) => discardValue(a) - discardValue(b))
.slice(0, n)
.map((c) => c.instanceId);
}
/** What the automaton does when the maze wants a response from it. */
function respond(view: GameView): Command {
const stack = view.stack!;
if (stack.defenderId !== view.you) return { type: "pass" };
const incoming = stack.attackCard ? (SIMPLE_ATTACKS[stack.attackCard.cardId] ?? 2) : 1;
const hand = view.yourHand;
const find = (id: string) => hand.find((c) => c.cardId === id);
if (stack.kind === "spell") {
const shield = find("full-shield");
if (shield && incoming >= 3) return { type: "counteract", instanceId: shield.instanceId };
const reflect = find("full-reflection");
if (reflect && incoming >= 4) return { type: "counteract", instanceId: reflect.instanceId };
}
const blunt = find("blunt");
if (blunt && incoming >= 2) return { type: "counteract", instanceId: blunt.instanceId };
return { type: "pass" };
}
/**
* One decision from the automaton's seat, or null when the maze is not
* asking it anything.
*/
export function automatonCommand(view: GameView): Command | null {
const you = view.you as PlayerId;
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) return { type: "pass" };
if (view.stack) {
return view.stack.waitingOn === you ? respond(view) : 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 { type: "endTurn", draw: 2 };
// 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" };
}
// One attack per turn: the nearest visible enemy eats the best simple spell.
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 = visible[0]!;
const spells = view.yourHand
.filter((c) => SIMPLE_ATTACKS[c.cardId] != null)
.sort((a, b) => SIMPLE_ATTACKS[b.cardId]! - SIMPLE_ATTACKS[a.cardId]!);
if (spells.length > 0) {
return {
type: "cast", instanceId: spells[0]!.instanceId,
target: { kind: "player", playerId: target.id },
};
}
if (cellKey(target.position) === here) return { type: "punch", targetId: target.id };
}
}
// March toward the objective.
if (view.turn.movementUsed < view.turn.movementAllowance) {
const goals = treasureGoals(view);
const dir = firstStepToward(view, self.position, goals);
if (dir) return { type: "move", direction: dir };
// No treasure path: close on the nearest enemy for a brawl.
const enemyCells = new Set(livingEnemies(view).map((p) => cellKey(p.position)));
const hunt = firstStepToward(view, self.position, enemyCells);
if (hunt) return { type: "move", direction: hunt };
}
return { type: "endTurn", draw: 2 };
}
/** The safe fallback when the automaton's choice was refused. */
export function automatonFallback(view: GameView): Command {
const you = view.you as PlayerId;
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: 2 };
}
+1
View File
@@ -9,3 +9,4 @@ export * from "./cards";
export * from "./setups";
export * from "./game";
export * from "./view";
export * from "./automaton";
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
applyCommand,
createGame,
type GameState,
type PlayerId,
} from "../src/game";
import { viewFor } from "../src/view";
import { automatonCommand, automatonFallback } from "../src/automaton";
/** Whose input does the maze want right now? */
function actingSeat(state: GameState): PlayerId {
return (
state.stack?.waitingOn ??
state.pendingDiscard ??
state.chaosPending?.queue[0] ??
state.outOfTurnWindow?.playerId ??
state.players[state.turn.activeIndex]!.id
);
}
/** Drive a full bot-vs-bot game; returns the final state and command count. */
function playOut(seed: number, players: number, expansion = true) {
const ids = Array.from({ length: players }, (_, i) => `bot${i + 1}`);
let { state } = createGame({
playerIds: ids,
seed,
sets: expansion ? ["basic", "expansion1"] : ["basic"],
deckRev: 8,
});
let commands = 0;
let stuck = 0;
const CAP = 4000;
while (state.phase === "playing" && commands < CAP) {
const seat = actingSeat(state);
const view = viewFor(state, seat);
const cmd = automatonCommand(view) ?? automatonFallback(view);
let r = applyCommand(state, seat, cmd);
if (!r.ok) {
const fb = automatonFallback(view);
r = applyCommand(state, seat, fb);
if (!r.ok) {
stuck++;
if (stuck > 3) {
throw new Error(
`automaton stuck at seat ${seat} after ${commands} commands: ` +
`${JSON.stringify(cmd)} -> ${JSON.stringify(fb)} both refused (${r.error})`,
);
}
// Last-ditch: burn the turn structure forward.
r = applyCommand(state, seat, { type: "endTurn", draw: 0 });
if (!r.ok) r = applyCommand(state, seat, { type: "pass" });
if (!r.ok) throw new Error(`unrecoverable at ${seat}: ${r.error}`);
}
} else {
stuck = 0;
}
state = r.state;
commands++;
}
return { state, commands };
}
describe("automaton vs automaton", () => {
it("two clockwork wizards fight a game to its end", () => {
const { state, commands } = playOut(11, 2);
expect(state.phase).toBe("finished");
expect(state.winner).not.toBeNull();
expect(commands).toBeLessThan(4000);
});
it("holds up across many seeds without wedging", () => {
let finished = 0;
for (const seed of [1, 2, 3, 5, 8, 13, 21, 34]) {
const { state } = playOut(seed, 2);
if (state.phase === "finished") finished++;
}
// Cautious clockwork can stall a maze; most games must still conclude.
expect(finished).toBeGreaterThanOrEqual(6);
});
it("a full table of four automatons concludes", () => {
const { state } = playOut(7, 4);
expect(state.phase).toBe("finished");
});
});