Batch of LLM-assisted fixes/feature updates
This commit is contained in:
1 parent
7c6453160a
commit
05aaf394df
16 files changed
+5597
-912
No files matched your search
@@ -0,0 +1,252 @@
|
||||
'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);
|
||||
});
|
||||
Reference in new issue
Block a user