diff --git a/packages/server/src/banter.ts b/packages/server/src/banter.ts new file mode 100644 index 0000000..6c058b2 --- /dev/null +++ b/packages/server/src/banter.ts @@ -0,0 +1,119 @@ +// The automatons' table talk, one voice per temperament. Edit freely — the +// server picks from these pools at random, sparingly (menace over chatter). +// +// Triggers: grabGold (picked up a treasure), deliverGold (dropped one on a +// home base), dealPain (hurt somebody), takePain (got hurt), kill, die, +// summon (created a creature), buildWall, escape (self-teleported away), +// springTrap (drew a TRAP card), dodge (an attack missed them), win. + +import type { AutomatonStyle } from "@wizwar/engine"; + +export type BanterTrigger = + | "grabGold" | "deliverGold" | "dealPain" | "takePain" | "kill" | "die" + | "summon" | "buildWall" | "escape" | "springTrap" | "dodge" | "win"; + +export const BOT_LINES: Record>> = { + hunter: { + grabGold: [ + "ACQUISITION COMPLETE.", + "YOUR GOLD HAS BEEN REALLOCATED.", + "ASSET SECURED.", + "APPRAISAL: ADEQUATE. TAKING IT ANYWAY.", + ], + deliverGold: [ + "DELIVERY CONFIRMED. PLEASURE DOING BUSINESS.", + "LOGISTICS IS MY LOVE LANGUAGE.", + "RECEIPT AVAILABLE UPON REQUEST.", + ], + dealPain: [ + "AN INEFFICIENT USE OF YOUR TURN.", + "COSTS HAVE BEEN PASSED TO THE CONSUMER.", + "THIS IS BILLABLE.", + ], + takePain: [ + "DAMAGE NOTED. INVOICE PENDING.", + "THAT COMES OUT OF YOUR ESTATE.", + ], + kill: [ + "INVENTORY TRANSFERRED. CONDOLENCES.", + "ACCOUNT CLOSED.", + ], + die: [ + "FILING FOR BANKRUPTCY.", + "THE MARKET HAS CORRECTED.", + ], + summon: ["A SUBCONTRACTOR HAS BEEN RETAINED."], + buildWall: ["ZONING APPROVED."], + escape: ["RELOCATION EXPENSES: JUSTIFIED."], + springTrap: ["AN UNFORESEEN LIABILITY."], + dodge: ["PROJECTIONS SAID YOU WOULD MISS."], + win: ["PLEASURE DOING BUSINESS WITH ALL OF YOU."], + }, + berserker: { + grabGold: [ + "GOLD IS MERELY BAIT.", + "SHINY. IRRELEVANT.", + ], + dealPain: [ + "YES. AGAIN.", + "PAIN RECEIPT PRINTED.", + "HOLD STILL. THIS IS THE FUN PART.", + "THE MAZE PROVIDES.", + ], + takePain: [ + "GOOD. NOW IT IS INTERESTING.", + "I FELT THAT. DO IT AGAIN.", + ], + kill: [ + "SCHEDULED DEMISE: DELIVERED.", + "NEXT.", + "THE PILE GROWS.", + ], + die: [ + "SAVE MY SEAT.", + "I WILL BE BACK FOR THE REMATCH.", + ], + summon: [ + "I HAVE MADE YOU A FRIEND. IT IS NOT FRIENDLY.", + "IT EATS WHAT I TELL IT TO EAT.", + ], + buildWall: ["MORE CORNERS TO TRAP YOU IN."], + escape: ["A TACTICAL LEAP. NOT A RETREAT."], + springTrap: ["WHO PUT THAT THERE. RESPECT."], + dodge: ["SWING HARDER."], + win: ["LAST ONE STANDING. AS FORETOLD."], + }, + worrier: { + grabGold: [ + "taking this. sorry. sorry.", + "borrowing it. permanently. sorry.", + ], + deliverGold: ["home. safe. breathe."], + dealPain: [ + "ow. logged.", + "unnecessary!", + "that looked like it hurt. it was supposed to. i think.", + ], + takePain: [ + "rude.", + "i wrote it down. the list is long.", + ], + kill: [ + "oh no. oh no. it worked?", + "i'm not proud. mostly.", + ], + die: [ + "called it.", + "this is fine. this is fine. this is not fine.", + ], + summon: ["please be nice to it. actually don't."], + buildWall: [ + "good wall. safe wall.", + "one more wall between me and everything.", + ], + escape: ["nope nope nope."], + springTrap: ["i knew the floor was suspicious."], + dodge: ["missed me. oh thank goodness. i mean: ha."], + win: ["wait. me? check again."], + }, +}; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c056047..0d7778e 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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> = { - 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(); @@ -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; }