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");
});
});
+26
View File
@@ -35,7 +35,9 @@ import {
getRoom,
joinRoom,
loadPersistedRooms,
addAutomaton,
addChat,
driveAutomatons,
makeTransferCode,
redactFor,
roomCount,
@@ -170,6 +172,7 @@ function roomInfo(room: Room) {
hostId: room.hostId,
started: room.state !== null,
colors: Object.fromEntries(room.colorChoices),
bots: [...room.bots],
};
}
@@ -181,6 +184,17 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
}
}
/** After a human acts, the clockwork answers; every step is broadcast. */
function runBots(room: Room): void {
const steps = driveAutomatons(room);
for (const step of steps) {
broadcast(room, (playerId) => ({ type: "events", events: redactFor(step.events, playerId) }));
}
if (steps.length > 0) {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
}
}
function broadcastRoomState(room: Room): void {
broadcast(room, () => roomInfo(room));
if (room.state) {
@@ -250,6 +264,16 @@ wss.on("connection", (socket) => {
send(socket, { type: "events", events: redactFor(room.events, name) });
}
broadcastRoomState(room);
runBots(room);
break;
}
case "addBot": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host seats automatons" });
const result = addAutomaton(room);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcastRoomState(room);
break;
}
case "start": {
@@ -260,6 +284,7 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcastRoomState(room);
runBots(room);
break;
}
case "command": {
@@ -272,6 +297,7 @@ wss.on("connection", (socket) => {
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
runBots(room);
break;
}
case "rollDie": {
+67 -4
View File
@@ -17,6 +17,7 @@ import {
} from "@wizwar/engine";
import { appendLine, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
import { recordRoom } from "./stats";
import { automatonCommand, automatonFallback } from "@wizwar/engine";
export interface LoggedCommand {
seq: number;
@@ -41,6 +42,8 @@ export interface Room {
events: GameEvent[]; // full history (unredacted — redact per recipient)
/** Table talk, persisted with the room (public to all seats). */
chat: { player: PlayerId; text: string; at: string }[];
/** Seats the server itself plays. */
bots: Set<PlayerId>;
}
const rooms = new Map<string, Room>();
@@ -98,6 +101,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
log: [],
events: [],
chat: [],
bots: new Set(),
};
rooms.set(room.id, room);
recordRoom(room);
@@ -230,6 +234,59 @@ export function addChat(room: Room, playerId: PlayerId, rawText: string): { text
return { text, at };
}
const AUTOMATON_NAMES = ["Automaton", "Automaton II", "Automaton III", "Automaton IV", "Automaton V"];
/** Seat a clockwork wizard (host's choice, before the game starts). */
export function addAutomaton(room: Room): { name: PlayerId } | { error: string } {
if (room.state) return { error: "the game has started" };
if (room.players.length >= 6) return { error: "room is full" };
const name = AUTOMATON_NAMES.find((n) => !room.players.includes(n));
if (!name) return { error: "the workshop is empty" };
room.players.push(name);
room.bots.add(name);
appendLine(room.id, { kind: "join", name, bot: true });
return { name };
}
/** Whose input does the maze want right now? */
function actingSeat(room: Room): PlayerId | null {
const s = room.state;
if (!s || s.phase !== "playing") return null;
return (
s.stack?.waitingOn ??
s.pendingDiscard ??
s.chaosPending?.queue[0] ??
s.outOfTurnWindow?.playerId ??
s.players[s.turn.activeIndex]!.id
);
}
/**
* Let the automatons play until the maze wants a human again. Returns the
* event batches produced, one per command, for per-step broadcasting.
*/
export function driveAutomatons(room: Room): { seat: PlayerId; events: GameEvent[] }[] {
const out: { seat: PlayerId; events: GameEvent[] }[] = [];
for (let i = 0; i < 300; i++) {
const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) break;
const view = viewFor(room.state!, seat);
const cmd = automatonCommand(view) ?? automatonFallback(view);
let r = runCommand(room, seat, cmd);
if ("error" in r) {
r = runCommand(room, seat, automatonFallback(view));
if ("error" in r) r = runCommand(room, seat, { type: "endTurn", draw: 0 });
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
if ("error" in r) {
console.error(`automaton ${seat} wedged in ${room.id}: ${r.error}`);
break;
}
}
out.push({ seat, events: r.events });
}
return out;
}
export interface GameSummary {
roomId: string;
name: PlayerId;
@@ -427,13 +484,19 @@ export function loadPersistedRooms(): void {
log: [],
events: [],
chat: [],
bots: new Set(),
};
for (const line of lines.slice(1)) {
if (line.kind === "join") {
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
if (!joinHash) throw new Error("join line has no token");
room.players.push(line.name);
room.tokens.set(line.name, joinHash);
if (line.bot) {
room.players.push(line.name);
room.bots.add(line.name);
} else {
const joinHash = line.tokenHash ?? (line.token ? hashToken(line.token) : null);
if (!joinHash) throw new Error("join line has no token");
room.players.push(line.name);
room.tokens.set(line.name, joinHash);
}
} else if (line.kind === "start") {
const r = startInMemory(room, line.expansion, line.colors, line.deckRev);
if ("error" in r) throw new Error(`replay start failed: ${r.error}`);
+2
View File
@@ -24,6 +24,8 @@ export interface JoinLine {
tokenHash?: string;
/** Legacy plaintext token (pre-hashing files only). */
token?: string;
/** An automaton seat: no token; the server plays it. */
bot?: true;
}
export interface StartLine {
+4 -1
View File
@@ -1025,7 +1025,7 @@
{:else}
<span class="dot" style:background="#b3a687"></span>
{/if}
{p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""}
{p}{p === net.hostId ? " — host" : ""}{net.roomBots.includes(p) ? " ⚙" : chosen === undefined ? " — choosing…" : ""}
</li>
{/each}
</ul>
@@ -1046,6 +1046,9 @@
{/each}
</div>
{#if net.you === net.hostId}
{#if net.players.length < 6}
<button class="stamp tiny" onclick={() => net.addBot()}>⚙ seat an automaton</button>
{/if}
<label class="check">
<input type="checkbox" bind:checked={withExpansion} />
Include Expansion Set #1 — monsters &amp; wands
+6
View File
@@ -225,6 +225,7 @@ class Net {
started = $state(false);
/** Lobby standee choices, by player name. */
roomColors = $state<Record<string, number>>({});
roomBots = $state<string[]>([]);
you = $state<string | null>(null);
view = $state<GameView | null>(null);
log = $state<string[]>([]);
@@ -295,6 +296,7 @@ class Net {
case "room":
this.roomId = msg.roomId;
this.roomColors = msg.colors ?? {};
this.roomBots = msg.bots ?? [];
if (this.you && this.token) {
const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
@@ -426,6 +428,10 @@ class Net {
}
/** Ask the server how all our games are doing. */
addBot(): void {
this.send({ type: "addBot" });
}
rollTableDie(): void {
this.send({ type: "rollDie" });
}