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
+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}`);