From 365cb3821e228ef4a5a0ed393e47ec22eb2b64e1 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 26 Aug 2026 12:10:55 -0400 Subject: [PATCH] Idle bot turns confess; the chronicle fills its gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clockwork whose chosen command the engine refuses now stumbles visibly: the refusal is logged server-side with the offending command (the RNRX coma took three silent turns to notice), and the bot says so in character at the table — "RECALCULATING." — instead of an unexplained idle turn. And six event types that never spoke in the chronicle find their lines: the wave's wash-back, thornbush entry, dragged objects, the opening deal, stolen-card and revealed-hand privates. Replay captions inherit them all through humanize. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/server/src/index.ts | 18 ++++++++++++++++++ packages/server/src/rooms.ts | 17 ++++++++++++++--- packages/web/src/net.svelte.ts | 8 ++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9ca7352..ae9c05e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -396,6 +396,16 @@ function botRemark(room: Room, actor: string, events: { type: string; [k: string } } } +/** Spoken when a clockwork's chosen command was refused by the engine — + * the table sees a stumble instead of an unexplained idle turn. */ +const HESITATION_LINES = [ + "RECALCULATING.", + "TACTICAL PAUSE. INTENTIONAL. PROBABLY.", + "THE MAZE REFUSES MY GENIUS.", + "ERROR LOGGED. DIGNITY INTACT.", + "I MEANT TO DO THAT.", +]; + const pumping = new Set(); function runBots(room: Room): void { if (pumping.has(room.id)) return; @@ -410,6 +420,14 @@ function runBots(room: Room): void { broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length })); // The game's end unmasks the mystery machines in the roster. if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room)); + // A refused brain-choice becomes a visible stumble, in character — + // an idle bot turn should read as hesitation, never as nothing. + if (step.hesitated) { + const said = addChat(room, step.seat, HESITATION_LINES[Math.floor(Math.random() * HESITATION_LINES.length)]!); + if (!("error" in said)) { + broadcast(room, () => ({ type: "chat", player: step.seat, text: said.text, at: said.at })); + } + } botRemark(room, step.seat, step.events as { type: string }[]); setTimeout(tick, BOT_STEP_MS); }; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 129c3d4..e3e0e23 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -318,17 +318,28 @@ function actingSeat(room: Room): PlayerId | null { * 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 driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEvent[] } | null { +export function driveOneAutomaton( + room: Room, +): { seat: PlayerId; events: GameEvent[]; hesitated?: { refused: Command; error: string } } | null { const seat = actingSeat(room); if (!seat || !room.bots.has(seat)) return null; const view = viewFor(room.state!, seat); const bot = room.bots.get(seat); const chosen = automatonCommand(view, bot?.style, bot?.tier); + let hesitated: { refused: Command; error: string } | undefined; let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier)); if ("error" in r) { // A refused choice retries with the fallback — unless the fallback IS // what just failed — then burns down the ladder to endTurn and pass. - if (chosen) r = runCommand(room, seat, automatonFallback(view, bot?.tier)); + // The refusal is remembered: a brain whose choice the engine rejects + // is a bug in the brain, and the table deserves to see the stumble + // rather than an unexplained idle turn (the RNRX coma lesson). + if (chosen) { + hesitated = { refused: chosen, error: r.error }; + console.warn( + `automaton ${seat} hesitated in ${room.id}: ${JSON.stringify(chosen)} refused (${r.error})`); + r = runCommand(room, seat, automatonFallback(view, bot?.tier)); + } 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) { @@ -336,7 +347,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve return null; } } - return { seat, events: r.events }; + return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) }; } export interface GameSummary { diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index 58b3172..1121c72 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -103,6 +103,14 @@ export function humanize(e: GameEvent): string | null { case "doorHeld": return `${e.player} holds the door open.`; case "doorReleased": return `The held door swings shut.`; case "doorsRelocked": return `The door swings shut and relocks.`; + case "washedBack": return `${e.player} is washed back ${e.blockedSpaces > 0 ? "and crushed against the stone" : "by the wave"}!`; + case "enteredThornbush": return `${e.player} pushes into the thorns — and bleeds for it.`; + case "objectDragged": return `The ${e.what.replace(/-/g, " ")} is dragged across the maze.`; + case "cardsDealt": return `${e.player} is dealt ${e.count} card${e.count === 1 ? "" : "s"}.`; + case "cardsStolenPrivate": return e.cards.length > 0 + ? `Stolen into your hand: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.` + : null; + case "handRevealedPrivate": return `${e.player}'s hand lies open to you: ${e.cards.map((c) => cardDef(c.cardId).name).join(", ")}.`; case "creatureWarpStepped": return `The creature slips through the dimensional warp!`; case "mentalForceFizzled": return `${e.attacker}'s mental force strains at ${e.defender} — and fails to find a path.`; case "doorJammed": return `${e.player} jams a door's lock solid.`;