'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const BotFactory = require('../bots/BotFactory'); const RandomBot = require('../bots/RandomBot'); const LLMBot = require('../bots/LLMBot'); const { makeGame, addPlayers } = require('./helpers'); const LLM_CONFIG_PATH = path.join(__dirname, '..', 'bots', 'llm_config.json'); const HAS_LLM_CONFIG = fs.existsSync(LLM_CONFIG_PATH) || Boolean(process.env.OPENAI_API_KEY); const settle = async (predicate, { attempts = 50 } = {}) => { for (let i = 0; i < attempts; i += 1) { if (predicate()) return true; await new Promise((resolve) => setImmediate(resolve)); } return predicate(); }; function stubFactory(bots, { onRandom = null } = {}) { let created = 0; return { createBot(type) { if (String(type).toLowerCase() === 'llm') { const bot = bots[Math.min(created, bots.length - 1)]; created += 1; return bot; } if (onRandom) return onRandom(); return { Write: () => 'A fallback sentence.', Vote: () => 0, Banter: () => 'fallback banter' }; }, generateBotName(exclude = []) { return `Bot${exclude.length + 1}`; } }; } // --------------------------------------------------------------------------- // BotFactory // --------------------------------------------------------------------------- test('BotFactory creates random bots and rejects unknown types', () => { assert.ok(BotFactory.createBot('random') instanceof RandomBot); assert.throws(() => BotFactory.createBot('definitely-not-a-bot')); assert.deepEqual(BotFactory.getAvailableBotTypes(), ['llm', 'random']); }); test('BotFactory falls back to RandomBot when the LLM client cannot be built', { skip: HAS_LLM_CONFIG ? 'an LLM config/API key is present in this environment' : false }, () => { const bot = BotFactory.createBot('llm'); assert.ok(bot instanceof RandomBot, 'missing API key must not crash bot creation'); }); test('bot names are unique within a room', () => { const { game } = makeGame({ botFactory: BotFactory, botType: 'random' }); addPlayers(game, 2); game.startBots(8); const names = game.players.filter((player) => player.isBot).map((player) => player.name); assert.equal(names.length, 8); assert.equal(new Set(names).size, names.length, 'no duplicate bot names'); }); test('bot creation failures fall back to a working bot', () => { const fallback = { Write: () => 'fallback', Vote: () => null, Banter: () => 'fallback' }; const factory = { createBot(type) { if (type === 'llm') throw new Error('no api key'); return fallback; }, generateBotName: () => 'FallbackBot' }; const { game } = makeGame({ botFactory: factory }); const bot = game.createBotInstance(); assert.equal(bot, fallback); }); test('RandomBot vote maps to the real entry index, not the filtered position', () => { const bot = new RandomBot(); const choices = [ { index: 3, entry_text: 'Third.' }, { index: 7, entry_text: 'Seventh.' } ]; for (let i = 0; i < 20; i += 1) { assert.ok([3, 7].includes(bot.Vote('story', choices))); } assert.equal(bot.Vote('story', []), null); }); // --------------------------------------------------------------------------- // Gameplay integration // --------------------------------------------------------------------------- test('a bot that fails to write still submits (empty entry) and the round moves on', async () => { const failingBot = { Write: () => { throw new Error('LLM exploded'); }, Vote: () => null, Banter: () => '' }; const factory = stubFactory([failingBot], { onRandom: () => ({ Write: () => { throw new Error('fallback exploded too'); }, Vote: () => null, Banter: () => '' }) }); const { game } = makeGame({ botFactory: factory, botType: 'llm' }); addPlayers(game, 2); await game.startGame({ botCount: 1, timeLimit: 60 }); await settle(() => game.submittedPlayers.has(game.bots[0].bot_id)); assert.equal(game.submittedPlayers.has(game.bots[0].bot_id), true); assert.equal(game.eligibleEntries().length, 0); assert.equal(game.currentPhase, 'writing', 'humans still owe an entry'); }); test('bots write concurrently and reveal themselves as submitted', async () => { const writes = []; const slowBot = { Write: async () => { writes.push('start'); await new Promise((resolve) => setTimeout(resolve, 5)); writes.push('end'); return 'A slow but steady sentence.'; }, Vote: () => null, Banter: () => '' }; const factory = stubFactory([slowBot, slowBot]); const { game } = makeGame({ botFactory: factory, botType: 'llm' }); addPlayers(game, 2); await game.startGame({ botCount: 2, timeLimit: 60 }); await settle(() => game.submittedPlayers.size >= 2); assert.equal(game.eligibleEntries().length, 2); assert.deepEqual(writes.slice(0, 2), ['start', 'start'], 'writes overlap (concurrent)'); }); test('a bot vote for an invalid index falls back to a valid entry and never self-votes', async () => { const capturedChoices = []; const cheatingBot = { Write: () => 'Bot sentence.', Vote: (story, choices) => { capturedChoices.push(choices); return 999; // invalid }, Banter: () => '' }; const factory = stubFactory([cheatingBot]); const { game } = makeGame({ botFactory: factory, botType: 'llm' }); const [p1, p2] = addPlayers(game, 2); await game.startGame({ botCount: 1, timeLimit: 60 }); await settle(() => game.submittedPlayers.has(game.bots[0].bot_id)); game.submitEntry(p1, 'Human one.'); game.submitEntry(p2, 'Human two.'); game.beginVotingPhase(); await settle(() => game.votedPlayers.has(game.bots[0].bot_id)); const botId = game.bots[0].bot_id; assert.equal(game.votedPlayers.has(botId), true, 'bot still casts a valid vote'); const captured = capturedChoices[0]; assert.ok(captured.length > 0); assert.ok( captured.every((choice) => choice.player_id !== botId), 'bots are never offered their own entry' ); const votedFor = game.round_data.findIndex((entry) => entry.votes.includes(botId)); assert.ok(votedFor >= 0); assert.notEqual(game.round_data[votedFor].player_id, botId); }); test('bot banter is posted to chat with recent chat context', async () => { const seen = []; const chatter = { Write: () => 'Bot sentence.', Vote: () => null, Banter: (chatSoFar) => { seen.push(chatSoFar); return ' I think the duck did it. '; } }; const factory = stubFactory([chatter]); const { game, emitted } = makeGame({ botFactory: factory, botType: 'llm' }); addPlayers(game, 2); await game.startGame({ botCount: 1, timeLimit: 60 }); game.addChatMessage('p1', 'Hello there.'); // Make the banter roll deterministic. const originalRandom = Math.random; Math.random = () => 0; try { await game.botBanter(); } finally { Math.random = originalRandom; } const messages = emitted('newChatMessage'); assert.equal(messages.length, 1); assert.equal(messages[0].payload.msg, 'I think the duck did it.'); assert.equal(messages[0].payload.name, game.players.find((p) => p.isBot).name); assert.match(seen[0], /Hello there\./); }); test('chat messages are length-capped and empty ones ignored', () => { const { game } = makeGame(); addPlayers(game, 1); assert.equal(game.addChatMessage('p1', ' '), null); const message = game.addChatMessage('p1', 'x'.repeat(1000)); assert.equal(message.msg.length, 300); }); test('personality files are selectable and default to the bundled list', () => { const defaultPersonalities = LLMBot.readPersonalities(); assert.ok(defaultPersonalities.length > 0); const customPath = path.join( fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'yarn-personalities-')), 'custom.txt' ); fs.writeFileSync(customPath, 'Pirate bot\n\n Noir bot \n', 'utf8'); assert.deepEqual(LLMBot.readPersonalities(customPath), ['Pirate bot', 'Noir bot']); assert.equal(LLMBot.generateBotPersonality(customPath).includes('bot'), true); assert.equal(LLMBot.readPersonalities(path.join(customPath, 'missing.txt')).length, 0); });