The clockwork plays at a watchable pace

One command per beat — 1.5 seconds, the replay's 1x cadence — each
broadcast with its board state as it lands, starting a beat after
the human's own action settles. The pump is per-room, re-entrancy
guarded, and stops the moment the maze wants a human (or the game
ends). Watching an automaton stalk your treasure across the maze is
now a spectacle instead of a teleport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 16:17:28 -04:00
co-authored by Claude Fable 5
parent ab52628d2f
commit 3842307bf0
3 changed files with 129 additions and 26 deletions
+18 -7
View File
@@ -37,7 +37,7 @@ import {
loadPersistedRooms,
addAutomaton,
addChat,
driveAutomatons,
driveOneAutomaton,
makeTransferCode,
redactFor,
roomCount,
@@ -184,15 +184,26 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
}
}
/** After a human acts, the clockwork answers; every step is broadcast. */
/**
* The clockwork plays at a watchable pace: one command every beat, each
* broadcast as it lands, until the maze wants a human again.
*/
const BOT_STEP_MS = 1500;
const pumping = new Set<string>();
function runBots(room: Room): void {
const steps = driveAutomatons(room);
for (const step of steps) {
if (pumping.has(room.id)) return;
pumping.add(room.id);
const tick = () => {
const step = driveOneAutomaton(room);
if (!step) {
pumping.delete(room.id);
return;
}
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 }));
}
setTimeout(tick, BOT_STEP_MS);
};
setTimeout(tick, 800); // a beat after the human's own action settles
}
function broadcastRoomState(room: Room): void {
+15 -19
View File
@@ -262,29 +262,25 @@ function actingSeat(room: Room): PlayerId | null {
}
/**
* Let the automatons play until the maze wants a human again. Returns the
* event batches produced, one per command, for per-step broadcasting.
* One automaton command, if the maze is waiting on clockwork. Null when a
* human holds the floor (or the game is over, or the clockwork is wedged).
*/
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);
export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null {
const seat = actingSeat(room);
if (!seat || !room.bots.has(seat)) return null;
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) {
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;
}
console.error(`automaton ${seat} wedged in ${room.id}: ${r.error}`);
return null;
}
out.push({ seat, events: r.events });
}
return out;
return { seat, events: r.events };
}
export interface GameSummary {