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": {