Batch of LLM-assisted fixes/feature updates

This commit is contained in:
Cam Spry committed 2026-10-30 23:02:45 -04:00
1 parent 7c6453160a
commit 05aaf394df
16 files changed
+5597 -912

No files matched your search

+58 -17
View File
@@ -1,37 +1,78 @@
const RandomBot = require('./RandomBot');
const LLMBot = require('./LLMBot');
'use strict';
const fs = require('fs');
const path = require('path');
const RandomBot = require('./RandomBot');
const LLMBot = require('./LLMBot');
const NAMES_PATH = path.join(__dirname, 'bot_names.txt');
const FALLBACK_NAMES = ['DefaultBot', 'BackupBot', 'SpareBot', 'ExtraBot'];
function readNames() {
try {
const names = fs
.readFileSync(NAMES_PATH, 'utf8')
.split('\n')
.map((name) => name.trim())
.filter((name) => name.length > 0);
return names.length ? names : FALLBACK_NAMES;
} catch (err) {
console.warn(`[BotFactory] Could not read ${NAMES_PATH}: ${err.message}`);
return FALLBACK_NAMES;
}
}
class BotFactory {
static createBot(type) {
switch (type.toLowerCase()) {
/**
* Create a bot instance. LLM bots fall back to RandomBot when the LLM
* client cannot be constructed (missing API key/config) so a game can
* always start.
*/
static createBot(type = 'llm') {
switch (String(type).toLowerCase()) {
case 'random':
return new RandomBot();
case 'llm':
return new LLMBot();
try {
return new LLMBot();
} catch (err) {
console.warn(
`[BotFactory] LLM bot unavailable (${err.message}); using RandomBot instead.`
);
return new RandomBot();
}
default:
throw new Error(`Unknown bot type: ${type}`);
}
}
static generateBotName() {
const names = fs.readFileSync(path.join(__dirname, 'bot_names.txt'), 'utf8')
.split('\n')
.map(name => name.trim())
.filter(name => name.length > 0);
/**
* Pick a random bot name, avoiding names already used in the room.
*/
static generateBotName(exclude = []) {
const names = readNames();
const taken = new Set(
(Array.isArray(exclude) ? exclude : [exclude])
.filter(Boolean)
.map((name) => String(name).toLowerCase())
);
if (names.length === 0) {
return 'DefaultBot'; // Fallback if no names are found
}
const available = names.filter((name) => !taken.has(name.toLowerCase()));
const pool = available.length ? available : names;
const pick = pool[Math.floor(Math.random() * pool.length)] || FALLBACK_NAMES[0];
const randomIndex = Math.floor(Math.random() * names.length);
return names[randomIndex];
// If every themed name is taken, disambiguate with a number.
if (taken.has(pick.toLowerCase())) {
let suffix = 2;
while (taken.has(`${pick}${suffix}`.toLowerCase())) suffix += 1;
return `${pick}${suffix}`;
}
return pick;
}
static getAvailableBotTypes() {
return ['random'];
return ['llm', 'random'];
}
}
module.exports = BotFactory;
module.exports = BotFactory;
+125 -59
View File
@@ -1,90 +1,156 @@
'use strict';
const fs = require('fs');
const path = require('path');
const OpenAI = require('openai');
// read optional config file
const configPath = path.join(__dirname, 'llm_config.json');
const llmOptions = fs.existsSync(configPath)
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
: {};
const DEFAULT_MODEL = 'meta-llama/Llama-3.2-3B-Instruct-Turbo';
const CONFIG_PATH = path.join(__dirname, 'llm_config.json');
const PERSONALITY_PATH = path.join(__dirname, 'bot_personalities_dumb.txt');
console.log(`Read config from ${configPath}`);
const FALLBACK_PERSONALITY = 'You are a friendly bot playing a collaborative story game';
function generateBotPersonality() {
const personalities = fs.readFileSync(path.join(__dirname, 'bot_personalities_dumb.txt'), 'utf8')
// Read the optional config file once, tolerating missing/corrupt files.
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) return {};
try {
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
console.warn(`[LLMBot] ${CONFIG_PATH} must contain a JSON object; ignoring it.`);
} catch (err) {
console.warn(`[LLMBot] Could not parse ${CONFIG_PATH}: ${err.message}`);
}
return {};
}
function readLines(file) {
try {
return fs
.readFileSync(file, 'utf8')
.split('\n')
.map(personality => personality.trim())
.filter(personality => personality.length > 0);
if (personalities.length === 0) {
return 'You are a friendly bot'; // Fallback if no personalities are found
.map((line) => line.trim())
.filter((line) => line.length > 0);
} catch (err) {
console.warn(`[LLMBot] Could not read ${file}: ${err.message}`);
return [];
}
const randomIndex = Math.floor(Math.random() * personalities.length);
return personalities[randomIndex];
}
function resolvePersonalitiesFile(configured) {
const requested = configured || process.env.YARN_BOT_PERSONALITIES;
if (!requested) return PERSONALITY_PATH;
return path.isAbsolute(requested) ? requested : path.join(__dirname, requested);
}
function readPersonalities(file = PERSONALITY_PATH) {
return readLines(file);
}
function generateBotPersonality(file = PERSONALITY_PATH) {
const personalities = readPersonalities(file);
if (!personalities.length) return FALLBACK_PERSONALITY;
return personalities[Math.floor(Math.random() * personalities.length)];
}
function firstContent(response) {
const content = response?.choices?.[0]?.message?.content;
return typeof content === 'string' ? content.trim() : '';
}
/**
* LLM-backed bot. The constructor throws when the client cannot be
* configured (for example, no API key); BotFactory catches that and
* falls back to RandomBot so a misconfigured server keeps working.
*/
class LLMBot {
constructor() {
// Instantiate the OpenAI client with the config options we just read
this.llm = new OpenAI(llmOptions);
constructor(config) {
const configPathExists = fs.existsSync(CONFIG_PATH);
if (configPathExists) console.log(`[LLMBot] Read config from ${CONFIG_PATH}`);
// Prompt fragments we’ll reuse
this.writePrompt =
`You are playing a collaborative storytelling game.
Continue the following story with exactly ONE short, creative sentence or sentence fragment.
Do NOT repeat what’s already written.`;
this.options = config && typeof config === 'object' ? config : loadConfig();
this.model = this.options.model || process.env.OPENAI_MODEL || DEFAULT_MODEL;
this.votePrompt =
`You are playing a collaborative storytelling game.
Below is the story so far, followed by a list of possible next sentences.
Reply with ONLY the number (0-indexed) of the sentence you like best.`;
// Throws if OPENAI_API_KEY (or an apiKey in llm_config.json) is missing.
this.llm = new OpenAI(this.options);
this.banterPrompt =
`You are a playful, slightly sarcastic AI taking part in a writing-game chat.
Keep it short, fun, and on-topic.`;
this.writePrompt = [
'You are playing a collaborative storytelling game.',
'Continue the following story with exactly ONE short, creative sentence or sentence fragment.',
'Do NOT repeat what is already written.',
'Reply with the sentence only, no quotes, no preamble.'
].join('\n');
this.limitPrompt = "Please do not exceed 25 words."
this.votePrompt = [
'You are playing a collaborative storytelling game.',
'Below is the story so far, followed by a list of possible next sentences.',
'Reply with ONLY the number of the sentence you like best. No other text.'
].join('\n');
this.personality = generateBotPersonality();
this.banterPrompt = [
'You are a playful, slightly sarcastic AI taking part in a writing-game chat.',
'Keep it short, fun, and on-topic (one sentence, under 20 words).'
].join('\n');
this.limitPrompt = 'Please do not exceed 25 words.';
// Defaults to bot_personalities_dumb.txt; override with `personalitiesFile`
// in the config or the YARN_BOT_PERSONALITIES environment variable.
this.personalitiesFile = resolvePersonalitiesFile(this.options.personalitiesFile);
this.personality = generateBotPersonality(this.personalitiesFile);
}
async Write(story_so_far) {
const msg = `${this.personality}. ${this.writePrompt} ${this.limitPrompt}\n\nStory so far:\n${story_so_far}`;
async complete(messages, { temperature = 0.8, maxTokens = 80 } = {}) {
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
temperature: llmOptions.temperature ?? 0.8,
max_tokens: llmOptions.max_tokens ?? 80,
model: this.model,
messages,
temperature,
max_tokens: maxTokens
});
//console.log(`Received response from LLM: ${response.choices[0].message.content}`);
return response.choices[0].message.content.trim();
return firstContent(response);
}
async Vote(story_so_far, choices) {
const choicesBlock = choices.map((c, i) => `${i}: ${(c.entry_text)}`).join('\n');
const msg = `${this.personality}. ${this.votePrompt}\n\nStory:\n${story_so_far}\n\nChoices:\n${choicesBlock}`;
//console.log(msg);
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
async Write(storySoFar) {
const msg = `${this.personality}.\n${this.writePrompt}\n${this.limitPrompt}\n\nStory so far:\n${storySoFar || '(the story has not started yet)'}`;
const text = await this.complete([{ role: 'user', content: msg }], {
temperature: this.options.temperature ?? 0.8,
maxTokens: this.options.max_tokens ?? 80
});
// Strip wrapping quotes and stray newlines so entries read naturally.
return text.replace(/^["'\s]+|["'\s]+$/g, '');
}
async Vote(storySoFar, choices) {
const list = Array.isArray(choices) ? choices : [];
if (!list.length) return null;
const choicesBlock = list
.map((choice, i) => `${choice.index ?? i}: ${choice.entry_text}`)
.join('\n');
const msg = `${this.personality}.\n${this.votePrompt}\n\nStory:\n${storySoFar}\n\nChoices:\n${choicesBlock}`;
const reply = await this.complete([{ role: 'user', content: msg }], {
temperature: 0.2,
max_tokens: 5,
maxTokens: 8
});
const reply = response.choices[0].message.content.trim();
const match = reply.match(/\d+/);
return match ? parseInt(match[0], 10) : 0;
if (!match) return null;
const parsed = Number.parseInt(match[0], 10);
return Number.isInteger(parsed) ? parsed : null;
}
async Banter(chat_so_far) {
const msg = `${this.banterPrompt}\n\nChat so far:\n${chat_so_far}`;
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
temperature: llmOptions.temperature ?? 0.8,
max_tokens: llmOptions.max_tokens ?? 60,
async Banter(chatSoFar) {
const msg = `${this.banterPrompt}\n\nRecent chat:\n${chatSoFar || '(nobody has spoken yet)'}`;
return this.complete([{ role: 'user', content: msg }], {
temperature: this.options.temperature ?? 0.8,
maxTokens: this.options.max_tokens ?? 60
});
return response.choices[0].message.content.trim();
}
}
module.exports = LLMBot;
LLMBot.DEFAULT_MODEL = DEFAULT_MODEL;
LLMBot.CONFIG_PATH = CONFIG_PATH;
LLMBot.PERSONALITY_PATH = PERSONALITY_PATH;
LLMBot.readPersonalities = readPersonalities;
LLMBot.generateBotPersonality = generateBotPersonality;
module.exports = LLMBot;
+24 -21
View File
@@ -1,19 +1,15 @@
'use strict';
class RandomBot {
constructor() {
this.writePhrases = [
" Suddenly, a mysterious figure appeared from the shadows.",
" The ground began to shake violently beneath their feet.",
" In the distance, an eerie sound echoed through the valley.",
" A bright light flashed, blinding everyone momentarily.",
" The ancient artifact started glowing with an otherworldly energy."
];
this.votePhrases = [
"I think we should proceed with caution.",
"Let's take the bold approach!",
"The safe path seems best right now.",
"We should explore further before deciding.",
"My instincts tell me to go left."
'Suddenly, a mysterious figure appeared from the shadows.',
'The ground began to shake violently beneath their feet.',
'In the distance, an eerie sound echoed through the valley.',
'A bright light flashed, blinding everyone momentarily.',
'The ancient artifact started glowing with an otherworldly energy.',
'A gust of wind carried whispers of a forgotten warning.',
'Somewhere nearby, a door creaked open on its own.'
];
this.banterPhrases = [
@@ -24,27 +20,34 @@ class RandomBot {
"Remember when we used to play simple games?",
"What's the worst that could happen?",
"I wonder if there are cookies in this game.",
"Do bots dream of electric sheep?",
'Do bots dream of electric sheep?',
"Is this thing on?",
"I'm having a great time, how about you?"
];
}
Write(story_so_far) {
Write() {
const randomIndex = Math.floor(Math.random() * this.writePhrases.length);
return this.writePhrases[randomIndex];
}
Vote(story_so_far, choices) {
// Return a random choice number (0-indexed)
const randomChoice = Math.floor(Math.random() * choices.length);
return randomChoice;
/**
* Vote for one of the supplied choices. Choices are objects of the form
* { index, entry_text } where `index` is the real entry index; return
* that index (never the position in the filtered list).
*/
Vote(storySoFar, choices) {
const list = Array.isArray(choices) ? choices : [];
if (!list.length) return null;
const pick = list[Math.floor(Math.random() * list.length)];
if (pick && Number.isInteger(pick.index)) return pick.index;
return Number.isInteger(pick) ? pick : 0;
}
Banter(chat_so_far) {
Banter() {
const randomIndex = Math.floor(Math.random() * this.banterPhrases.length);
return this.banterPhrases[randomIndex];
}
}
module.exports = RandomBot;
module.exports = RandomBot;
+7
View File
@@ -0,0 +1,7 @@
{
"apiKey": "your-api-key-here",
"baseURL": "https://api.openai.com/v1",
"model": "meta-llama/Llama-3.2-3B-Instruct-Turbo",
"temperature": 0.8,
"max_tokens": 80
}