Files

253 lines
8.2 KiB
JavaScript

'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);
});