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
+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 {