Basic bot support and option to change opening text

This commit is contained in:
Cam Spry 2026-03-01 08:10:35 -05:00
parent 7bc9d67544
commit 499a5954f5
7 changed files with 264 additions and 13 deletions

37
bots/BotFactory.js Normal file
View File

@ -0,0 +1,37 @@
const RandomBot = require('./RandomBot');
const LLMBot = require('./LLMBot');
const fs = require('fs');
const path = require('path');
class BotFactory {
static createBot(type) {
switch (type.toLowerCase()) {
case 'random':
return new RandomBot();
case 'llm':
return new LLMBot();
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);
if (names.length === 0) {
return 'DefaultBot'; // Fallback if no names are found
}
const randomIndex = Math.floor(Math.random() * names.length);
return names[randomIndex];
}
static getAvailableBotTypes() {
return ['random'];
}
}
module.exports = BotFactory;

44
bots/LLMBot.js Normal file
View File

@ -0,0 +1,44 @@
const { LLM } = require('@themaximalist/llm.js'); // or wherever your LLM package lives
class LLMBot {
constructor() {
// One instance keeps the whole conversation in memory
this.llm = new LLM();
// Prompt fragments well reuse
this.writePrompt =
`You are playing a collaborative storytelling game.
Continue the following story with exactly ONE short, creative sentence.
Do NOT repeat whats already written.`;
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.`;
this.banterPrompt =
`You are a playful, slightly sarcastic AI taking part in a writing-game chat.
Keep it short, fun, and on-topic.`;
}
async Write(story_so_far) {
const msg = `${this.writePrompt}\n\nStory so far:\n${story_so_far}`;
return (await this.llm.chat(msg)).trim();
}
async Vote(story_so_far, choices) {
const choicesBlock = choices.map((c, i) => `${i}: ${c}`).join('\n');
const msg = `${this.votePrompt}\n\nStory:\n${story_so_far}\n\nChoices:\n${choicesBlock}`;
const reply = (await this.llm.chat(msg)).trim();
// extract first digit found, fallback to 0
const match = reply.match(/\d+/);
return match ? parseInt(match[0], 10) : 0;
}
async Banter(chat_so_far) {
const msg = `${this.banterPrompt}\n\nChat so far:\n${chat_so_far}`;
return (await this.llm.chat(msg)).trim();
}
}
module.exports = LLMBot;

50
bots/RandomBot.js Normal file
View File

@ -0,0 +1,50 @@
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."
];
this.banterPhrases = [
"Did anyone else hear that?",
"I have a good feeling about this!",
"Not sure what's happening, but I'm excited!",
"Anyone else getting hungry?",
"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?",
"Is this thing on?",
"I'm having a great time, how about you?"
];
}
Write(story_so_far) {
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;
}
Banter(chat_so_far) {
const randomIndex = Math.floor(Math.random() * this.banterPhrases.length);
return this.banterPhrases[randomIndex];
}
}
module.exports = RandomBot;

42
bots/bot_names.txt Normal file
View File

@ -0,0 +1,42 @@
PunBot3000
SarcasmBot
DadJokeBot
WattBot
ChaiBot
NachoBot
EyeBot
FryBot
GuacBot
TacoBot
ByteMeBot
CtrlAltBot
DeCAFbot
SirMixABot
HolyBot
BrewBot
KnotBot
WheyBot
YodaBot
ObiWanBot
RickRollBot
NeverGonnaBot
SuspiciousBot
TaskBot
MemeLordBot
YeetBot
SkibidiBot
RizzBot
OhioBot
GyattBot
SoupBot
BDEBot
RatioBot
LurkingBot
GhostingBot
SnackBot
ExistentialBot
ProcrastinBot
FOMO_Bot
ImpostorBot
SendingMemesBot
PepeBot

View File

@ -11,8 +11,10 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@themaximalist/llm.js": "^1.0.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"node-fetch": "^3.3.2",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"uuid": "^13.0.0" "uuid": "^13.0.0"
} }

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>YARN 2026 Spin Your Story!</title> <title>YARN 2026 Spin a Story!</title>
<style> <style>
:root { :root {
--bg-main: #4b0082; --bg-main: #4b0082;
@ -561,6 +561,14 @@
<label>Time per Round (sec):</label> <label>Time per Round (sec):</label>
<input type="number" id="time-limit" value="${gameState.gameSettings.timeLimit}" min="15" max="180"> <input type="number" id="time-limit" value="${gameState.gameSettings.timeLimit}" min="15" max="180">
</div> </div>
<div class="setting-group">
<label>Bots:</label>
<input type="number" id="bot-count" value="${gameState.gameSettings.botCount}" min="0" max="4">
</div>
<div class="setting-group">
<label>Starting Text:</label>
<input type="text" id="start-text" value="${gameState.gameSettings.startText}">
</div>
<button id="start-game-btn">Start Game</button> <button id="start-game-btn">Start Game</button>
`; `;
@ -748,10 +756,14 @@
function startGame() { function startGame() {
const scoreLimit = parseInt(document.getElementById('score-limit').value) || 100; const scoreLimit = parseInt(document.getElementById('score-limit').value) || 100;
const timeLimit = parseInt(document.getElementById('time-limit').value) || 60; const timeLimit = parseInt(document.getElementById('time-limit').value) || 60;
const botCount = parseInt(document.getElementById('bot-count').value) || 0;
const startText = document.getElementById('start-text').value;
socket.emit('startGame', { socket.emit('startGame', {
scoreLimit: Math.max(10, Math.min(9999, scoreLimit)), scoreLimit: Math.max(10, Math.min(9999, scoreLimit)),
timeLimit: Math.max(15, Math.min(600, timeLimit)) timeLimit: Math.max(15, Math.min(600, timeLimit)),
botCount: Math.max(0, Math.min(5, botCount)),
startText
}); });
} }

View File

@ -5,6 +5,7 @@ const path = require('path');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
//const { v4: uuidv4 } = require('uuid'); //const { v4: uuidv4 } = require('uuid');
const { randomUUID } = require('node:crypto'); const { randomUUID } = require('node:crypto');
const BotFactory = require('./bots/BotFactory');
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
@ -25,8 +26,10 @@ class YarnGame {
this.gameSettings = { this.gameSettings = {
scoreLimit: 50, scoreLimit: 50,
timeLimit: 60, timeLimit: 60,
botCount: 0 botCount: 0,
startText: "It was a dark and stormy night..."
}; };
this.bots = [];
this.players = []; // { player_id, name, score, socketId } this.players = []; // { player_id, name, score, socketId }
this.disconnectedPlayers = []; // So people can keep their score when they reconnect this.disconnectedPlayers = []; // So people can keep their score when they reconnect
this.yarnStory = []; // { player: "", str: "" } this.yarnStory = []; // { player: "", str: "" }
@ -78,20 +81,23 @@ class YarnGame {
removePlayer(playerId) { removePlayer(playerId) {
const index = this.players.findIndex(p => p.player_id === playerId); const index = this.players.findIndex(p => p.player_id === playerId);
if (index !== -1) { if (index !== -1) {
console.log(`Player ${this.players[index].name} dropped.`)
const removed = this.players.splice(index, 1); const removed = this.players.splice(index, 1);
this.disconnectedPlayers.push(removed[0]); this.disconnectedPlayers.push(removed[0]);
// Assign new host if host left // Assign new host if host left
if (this.gameHost === playerId && this.players.length > 0) { if (this.gameHost === playerId && this.getHumanPlayers().length > 0) {
this.gameHost = this.players[0].player_id; const human_players = this.getHumanPlayers();
this.gameHost = human_players[0].player_id;
} }
} }
if (this.players.length > 0) {
console.log(`Player ${this.players[index].name} dropped.`)
}
return this.players.length; return this.players.length;
} }
getHumanPlayers() {
return this.players.filter(p => !this.bots.some(b => b.bot_id === p.player_id));
}
getPlayer(playerId) { getPlayer(playerId) {
return this.players.find(p => p.player_id === playerId); return this.players.find(p => p.player_id === playerId);
} }
@ -107,19 +113,49 @@ class YarnGame {
} }
} }
startBots(botCount) {
console.log("Creating bots...")
for (let i = 0; i < botCount; i++) {
const bot_id = randomUUID()
const bot = BotFactory.createBot("random");
this.bots.push({ bot_id, bot });
const bot_player = {
player_id: bot_id,
name: BotFactory.generateBotName(),
score: 0,
socketId: null
};
this.players.push(bot_player);
console.log(`Created bot with UUID ${bot_id}`)
}
}
startGame(settings) { startGame(settings) {
this.gameSettings = { ...this.gameSettings, ...settings }; this.gameSettings = { ...this.gameSettings, ...settings };
this.startBots(this.gameSettings.botCount);
this.inProgress = true; this.inProgress = true;
this.yarnStory = [{ player: "Narrator", str: "It was a dark and stormy night..." }]; this.yarnStory = [{ player: "Narrator", str: this.gameSettings.startText }];
this.players.forEach(p => p.score = 0); this.players.forEach(p => p.score = 0);
this.startWritingPhase(); this.startWritingPhase();
} }
processBotWriting() {
for (const botObj of this.bots) {
const botText = botObj.bot.Write(this.getStoryText());
this.submitEntry(botObj.bot_id, botText)
}
}
startWritingPhase() { startWritingPhase() {
this.currentPhase = 'writing'; this.currentPhase = 'writing';
this.round_data = []; this.round_data = [];
this.submittedPlayers = new Set(); this.submittedPlayers = new Set();
this.timeRemaining = this.gameSettings.timeLimit; this.timeRemaining = this.gameSettings.timeLimit;
this.processBotWriting()
}
getStoryText() {
return this.yarnStory.map(entry => entry.str).join('');
} }
submitEntry(playerId, entryText) { submitEntry(playerId, entryText) {
@ -140,10 +176,18 @@ class YarnGame {
return this.submittedPlayers.size >= this.players.length; return this.submittedPlayers.size >= this.players.length;
} }
processBotVoting() {
for (const botObj of this.bots) {
const botVote = botObj.bot.Vote(this.getStoryText(), this.round_data);
this.submitVote(botObj.bot_id, botVote)
}
}
startVotingPhase() { startVotingPhase() {
this.currentPhase = 'voting'; this.currentPhase = 'voting';
this.votedPlayers = new Set(); this.votedPlayers = new Set();
this.timeRemaining = 30; // 30 seconds for voting this.timeRemaining = 30; // 30 seconds for voting
this.processBotVoting()
} }
submitVote(playerId, entryIndex) { submitVote(playerId, entryIndex) {
@ -203,9 +247,17 @@ class YarnGame {
return null; return null;
} }
purgeBots() {
for (const botObj of this.bots) {
this.removePlayer(botObj.bot_id)
}
this.bots = [];
}
resetGame() { resetGame() {
this.inProgress = false; this.inProgress = false;
this.currentPhase = 'lobby'; this.currentPhase = 'lobby';
this.purgeBots();
this.round_data = []; this.round_data = [];
this.yarnStory = []; this.yarnStory = [];
this.submittedPlayers = new Set(); this.submittedPlayers = new Set();
@ -216,6 +268,18 @@ class YarnGame {
} }
} }
botBanter() {
for (const botObj of this.bots) {
if (Math.random() > 0.8) {
console.log("Bot bantering")
const botBanter = botObj.bot.Banter(this.getStoryText());
console.log(botBanter)
const chatMsg = this.addChatMessage(botObj.bot_id, botBanter)
io.to(this.roomName).emit('newChatMessage', chatMsg); //I don't like using io calls here but chat is weird
}
}
}
addChatMessage(playerId, msg) { addChatMessage(playerId, msg) {
const player = this.getPlayer(playerId); const player = this.getPlayer(playerId);
const chatMsg = { const chatMsg = {
@ -331,7 +395,7 @@ io.on('connection', (socket) => {
const game = games[currentRoom]; const game = games[currentRoom];
if (!game || game.gameHost !== playerId) return; if (!game || game.gameHost !== playerId) return;
if (game.players.length < 2) { if (game.players.length < 2 && settings.botCount == 0) {
socket.emit('error', { message: 'Need at least 2 players to start' }); socket.emit('error', { message: 'Need at least 2 players to start' });
return; return;
} }
@ -422,9 +486,9 @@ io.on('connection', (socket) => {
io.to(currentRoom).emit('playersUpdate', game.getState().players); io.to(currentRoom).emit('playersUpdate', game.getState().players);
io.to(currentRoom).emit('gameState', game.getState()); io.to(currentRoom).emit('gameState', game.getState());
// Clean up empty rooms if (remaining === 0 || game.bots.length === game.players.length) {
console.log(`Removing empty room ${currentRoom}`) // Clean up empty rooms
if (remaining === 0) { console.log(`Removing empty room ${currentRoom}`)
if (game.timer) clearInterval(game.timer); if (game.timer) clearInterval(game.timer);
delete games[currentRoom]; delete games[currentRoom];
} }