The banter moves to its own book, and learns new verses

BOT_LINES leaves the server internals for banter.ts, keyed by
semantic trigger instead of raw event type, so editing the clockwork's
voice takes no knowledge of the event stream. The repertoire grows
from fourteen lines to fifty across twelve occasions — grabbing and
delivering gold, dealing and taking pain, killing and dying,
summoning, walling, escaping, springing traps, dodging, and winning.

Bots also now speak when things happen TO them: the actor remarks on
its deeds, bystanders on their suffering — a berserker taking a hit
answers "I FELT THAT. DO IT AGAIN." even on your turn. Still at most
one voice per step, still half the time, menace over chatter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 22:38:45 -04:00
co-authored by Claude Fable 5
parent d4565006bc
commit b01e27c644
2 changed files with 157 additions and 41 deletions
+38 -41
View File
@@ -49,6 +49,7 @@ import {
type Room,
} from "./rooms";
import { engagementStats, recordHotseat } from "./stats";
import { BOT_LINES, type BanterTrigger } from "./banter";
// --- Abuse limits: this is a public server on a small box. -----------------
const MAX_SOCKETS = 300; // concurrent connections
@@ -192,49 +193,44 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
*/
const BOT_STEP_MS = 1500;
/** The clockwork occasionally speaks. Sparingly — menace over chatter. */
const BOT_LINES: Record<string, Record<string, string[]>> = {
hunter: {
treasurePickedUp: ["ACQUISITION COMPLETE.", "YOUR GOLD HAS BEEN REALLOCATED."],
treasureDropped: ["DELIVERY CONFIRMED. PLEASURE DOING BUSINESS."],
damaged: ["AN INEFFICIENT USE OF YOUR TURN."],
died: ["INVENTORY TRANSFERRED. CONDOLENCES."],
},
berserker: {
treasurePickedUp: ["GOLD IS MERELY BAIT."],
damaged: ["YES. AGAIN.", "PAIN RECEIPT PRINTED."],
died: ["SCHEDULED DEMISE: DELIVERED.", "NEXT."],
creatureCreated: ["I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY."],
},
worrier: {
treasurePickedUp: ["taking this. sorry. sorry."],
damaged: ["ow. logged.", "unnecessary!"],
wallCreated: ["good wall. safe wall."],
died: ["oh no. oh no. it worked?"],
},
};
/** What a step's event means to this bot — its own deed when it acted,
* its own suffering when somebody else did. Null: nothing worth a word. */
function banterTrigger(
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
): BanterTrigger | null {
if (actor === seat) {
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
if (e.type === "treasureDropped" && e.player === seat) return "deliverGold";
if (e.type === "damaged" && e.player !== seat) return "dealPain";
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
if (e.type === "gameWon" && e.player === seat) return "win";
}
if (e.type === "damaged" && e.player === seat) return "takePain";
if (e.type === "died" && e.player === seat) return "die";
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
return null;
}
function botRemark(room: Room, seat: string, events: { type: string; [k: string]: unknown }[]): void {
const bot = room.bots.get(seat);
if (!bot) return;
const lines = BOT_LINES[bot.style] ?? {};
for (const e of events) {
// Speak only about its own deeds, and rarely.
const mine =
(e.type === "treasurePickedUp" && e.player === seat) ||
(e.type === "treasureDropped" && e.player === seat) ||
(e.type === "damaged" && e.player !== seat) ||
(e.type === "died" && e.killedBy === seat) ||
(e.type === "creatureCreated" && e.controller === seat) ||
(e.type === "wallCreated" && e.caster === seat);
const pool = lines[e.type];
if (!mine || !pool || Math.random() > 0.5) continue;
const text = pool[Math.floor(Math.random() * pool.length)]!;
const said = addChat(room, seat, text);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
/** Every seated bot gets a chance to remark on the step — the actor on its
* deeds, bystanders on their suffering. Rarely, and one voice at most. */
function botRemark(room: Room, actor: string, events: { type: string; [k: string]: unknown }[]): void {
for (const [seat, bot] of room.bots) {
const lines = BOT_LINES[bot.style] ?? {};
for (const e of events) {
const trigger = banterTrigger(e, seat, actor);
const pool = trigger ? lines[trigger] : undefined;
if (!pool || Math.random() > 0.5) continue;
const text = pool[Math.floor(Math.random() * pool.length)]!;
const said = addChat(room, seat, text);
if (!("error" in said)) {
broadcast(room, () => ({ type: "chat", player: seat, text: said.text, at: said.at }));
}
return; // one remark per step, whole table
}
return; // one remark per step at most
}
}
const pumping = new Set<string>();
@@ -363,6 +359,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 }));
botRemark(room, session.playerId, result.events as { type: string }[]);
runBots(room);
break;
}