Idle bot turns confess; the chronicle fills its gaps
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
75f1217c77
commit
365cb3821e
@@ -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<string>();
|
const pumping = new Set<string>();
|
||||||
function runBots(room: Room): void {
|
function runBots(room: Room): void {
|
||||||
if (pumping.has(room.id)) return;
|
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 }));
|
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId), seq: room.log.length }));
|
||||||
// The game's end unmasks the mystery machines in the roster.
|
// The game's end unmasks the mystery machines in the roster.
|
||||||
if (room.state?.phase === "finished") broadcast(room, () => roomInfo(room));
|
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 }[]);
|
botRemark(room, step.seat, step.events as { type: string }[]);
|
||||||
setTimeout(tick, BOT_STEP_MS);
|
setTimeout(tick, BOT_STEP_MS);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -318,17 +318,28 @@ function actingSeat(room: Room): PlayerId | null {
|
|||||||
* One automaton command, if the maze is waiting on clockwork. Null when a
|
* 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).
|
* 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);
|
const seat = actingSeat(room);
|
||||||
if (!seat || !room.bots.has(seat)) return null;
|
if (!seat || !room.bots.has(seat)) return null;
|
||||||
const view = viewFor(room.state!, seat);
|
const view = viewFor(room.state!, seat);
|
||||||
const bot = room.bots.get(seat);
|
const bot = room.bots.get(seat);
|
||||||
const chosen = automatonCommand(view, bot?.style, bot?.tier);
|
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));
|
let r = runCommand(room, seat, chosen ?? automatonFallback(view, bot?.tier));
|
||||||
if ("error" in r) {
|
if ("error" in r) {
|
||||||
// A refused choice retries with the fallback — unless the fallback IS
|
// A refused choice retries with the fallback — unless the fallback IS
|
||||||
// what just failed — then burns down the ladder to endTurn and pass.
|
// 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: "endTurn", draw: 0 });
|
||||||
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
|
if ("error" in r) r = runCommand(room, seat, { type: "pass" });
|
||||||
if ("error" in r) {
|
if ("error" in r) {
|
||||||
@@ -336,7 +347,7 @@ export function driveOneAutomaton(room: Room): { seat: PlayerId; events: GameEve
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { seat, events: r.events };
|
return { seat, events: r.events, ...(hesitated ? { hesitated } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSummary {
|
export interface GameSummary {
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
case "doorHeld": return `${e.player} holds the door open.`;
|
case "doorHeld": return `${e.player} holds the door open.`;
|
||||||
case "doorReleased": return `The held door swings shut.`;
|
case "doorReleased": return `The held door swings shut.`;
|
||||||
case "doorsRelocked": return `The door swings shut and relocks.`;
|
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 "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 "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.`;
|
case "doorJammed": return `${e.player} jams a door's lock solid.`;
|
||||||
|
|||||||
Reference in New Issue
Block a user