const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); const fs = require('fs'); const cookieParser = require('cookie-parser'); //const { v4: uuidv4 } = require('uuid'); const { randomUUID } = require('node:crypto'); const BotFactory = require('./bots/BotFactory'); const app = express(); const server = http.createServer(app); const io = new Server(server); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); // Store all game rooms const games = {}; // YarnGame class class YarnGame { constructor(roomName) { this.roomName = roomName; this.inProgress = false; this.gameHost = null; this.gameSettings = { scoreLimit: 50, timeLimit: 60, botCount: 0, startText: "It was a dark and stormy night..." }; this.bots = []; this.players = []; // { player_id, name, score, socketId } this.disconnectedPlayers = []; // So people can keep their score when they reconnect this.yarnStory = []; // { player: "", str: "" } this.chat = []; // { id, name, msg } this.round_data = []; // { entry_text, player_id, votes: [] } this.currentPhase = 'lobby'; // lobby, writing, voting, gameOver this.timer = null; this.timeRemaining = 0; this.submittedPlayers = new Set(); this.votedPlayers = new Set(); } addPlayer(playerId, name, socketId) { const existingPlayer = this.players.find(p => p.player_id === playerId); if (existingPlayer) { existingPlayer.socketId = socketId; existingPlayer.name = name; console.log(`Existing player ${name} reconnected to game ${this.roomName}.`) return existingPlayer; } else { console.log(`New player ${name} joined game ${this.roomName}.`) } const existingDisconnectedPlayer = this.disconnectedPlayers.find(p => p.player_id === playerId); if (existingDisconnectedPlayer) { existingDisconnectedPlayer.socketId = socketId; existingDisconnectedPlayer.name = name; this.players.push(existingDisconnectedPlayer); console.log(`Existing recently-disconnected player ${name} reconnected to game ${this.roomName}.`) return existingDisconnectedPlayer; } const player = { player_id: playerId, name: name, score: 0, socketId: socketId }; this.players.push(player); // First player becomes host if (this.players.length === 1) { this.gameHost = playerId; } return player; } removePlayer(playerId) { const index = this.players.findIndex(p => p.player_id === playerId); if (index !== -1) { console.log(`Player ${this.players[index].name} dropped.`) const removed = this.players.splice(index, 1); this.disconnectedPlayers.push(removed[0]); // Assign new host if host left if (this.gameHost === playerId && this.getHumanPlayers().length > 0) { const human_players = this.getHumanPlayers(); this.gameHost = human_players[0].player_id; } } return this.players.length; } getHumanPlayers() { return this.players.filter(p => !this.bots.some(b => b.bot_id === p.player_id)); } getPlayer(playerId) { return this.players.find(p => p.player_id === playerId); } getPlayerBySocket(socketId) { return this.players.find(p => p.socketId === socketId); } updatePlayerName(playerId, name) { const player = this.getPlayer(playerId); if (player) { player.name = name; } } startBots(botCount) { console.log("Creating bots...") for (let i = 0; i < botCount; i++) { const bot_id = randomUUID() const bot = BotFactory.createBot("llm"); 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}`) } } async startGame(settings) { this.gameSettings = { ...this.gameSettings, ...settings }; this.startBots(this.gameSettings.botCount); this.inProgress = true; this.yarnStory = [{ player: "Narrator", str: this.gameSettings.startText }]; this.players.forEach(p => p.score = 0); await this.startWritingPhase(); } async processBotWriting() { for (const botObj of this.bots) { const botText = await botObj.bot.Write(this.getStoryText()); console.log(`Bot text: ${botText}`) this.submitEntry(botObj.bot_id, botText) } } async startWritingPhase() { this.currentPhase = 'writing'; this.round_data = []; this.submittedPlayers = new Set(); this.timeRemaining = this.gameSettings.timeLimit; this.processBotWriting() } getStoryText() { return this.yarnStory.map(entry => entry.str).join(''); } submitEntry(playerId, entryText) { if (this.currentPhase !== 'writing') return false; if (this.submittedPlayers.has(playerId)) return false; this.round_data.push({ entry_text: entryText.trim(), player_id: playerId, votes: [] }); this.submittedPlayers.add(playerId); return true; } allPlayersSubmitted() { return this.submittedPlayers.size >= this.players.length; } async processBotVoting() { for (const botObj of this.bots) { const botVote = await botObj.bot.Vote(this.getStoryText(), this.round_data); this.submitVote(botObj.bot_id, botVote) } } async startVotingPhase() { this.currentPhase = 'voting'; this.votedPlayers = new Set(); this.timeRemaining = 30; // 30 seconds for voting await this.processBotVoting() } submitVote(playerId, entryIndex) { if (this.currentPhase !== 'voting') return false; if (this.votedPlayers.has(playerId)) return false; const entry = this.round_data[entryIndex]; // Can't vote for own entry //if (!entry || entry.player_id === playerId) return false; entry.votes.push(playerId); this.votedPlayers.add(playerId); return true; } allPlayersVoted() { return this.votedPlayers.size >= this.players.length; } tallyVotes() { if (this.round_data.length === 0) return null; // Find winner (most votes) let winner = this.round_data[0]; for (const entry of this.round_data) { if (entry.votes.length > winner.votes.length) { winner = entry; } } // Award points const winnerPlayer = this.getPlayer(winner.player_id); if (winnerPlayer) { winnerPlayer.score += 5 + winner.votes.length; } // Add to story if (winner.entry_text) { this.yarnStory.push({ player: winnerPlayer ? winnerPlayer.name : "Unknown", str: winner.entry_text }); } return winner; } checkGameOver() { const winner = this.players.find(p => p.score >= this.gameSettings.scoreLimit); if (winner) { this.currentPhase = 'gameOver'; this.inProgress = false; return winner; } return null; } purgeBots() { for (const botObj of this.bots) { this.removePlayer(botObj.bot_id) } this.bots = []; } resetGame() { this.inProgress = false; this.currentPhase = 'lobby'; this.purgeBots(); this.round_data = []; this.yarnStory = []; this.submittedPlayers = new Set(); this.votedPlayers = new Set(); if (this.timer) { clearInterval(this.timer); this.timer = null; } } saveStory() { const story_text = this.getStoryText(); const now = new Date(); const dateStamp = now.toISOString().slice(0, 10); // YYYY-MM-DD const timeStamp = now.toTimeString().slice(0, 8).replace(/:/g, '-'); // HH-MM-SS const fileName = `${this.roomName} - ${dateStamp} ${timeStamp}.txt`; const filePath = path.join('stories', fileName); fs.mkdirSync('stories', { recursive: true }); // ensure dir exists fs.writeFileSync(filePath, story_text, 'utf8'); } 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) { const player = this.getPlayer(playerId); const chatMsg = { id: playerId, name: player ? player.name : 'Unknown', msg: msg }; this.chat.push(chatMsg); // Keep last 100 messages if (this.chat.length > 100) { this.chat.shift(); } return chatMsg; } getState() { return { roomName: this.roomName, inProgress: this.inProgress, gameHost: this.gameHost, gameSettings: this.gameSettings, players: this.players.map(p => ({ player_id: p.player_id, name: p.name, score: p.score, hasSubmitted: this.submittedPlayers.has(p.player_id), hasVoted: this.votedPlayers.has(p.player_id) })), yarnStory: this.yarnStory, currentPhase: this.currentPhase, timeRemaining: this.timeRemaining, round_data: this.currentPhase === 'voting' ? this.round_data.map((e, i) => ({ index: i, entry_text: e.entry_text, voteCount: e.votes.length })) : [] }; } } // Get or create a game room function getOrCreateGame(roomName) { if (!games[roomName]) { console.log(`Creating new room with name ${roomName}`) games[roomName] = new YarnGame(roomName); } return games[roomName]; } // Route for game rooms app.get('/:roomName', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Socket.IO connection handling io.on('connection', (socket) => { let currentRoom = null; let playerId = null; socket.on('joinRoom', (data) => { const { roomName, playerUUID, playerName } = data; currentRoom = roomName; playerId = playerUUID; socket.join(roomName); const game = getOrCreateGame(roomName); if (playerName) { game.addPlayer(playerId, playerName, socket.id); } // Send current game state to the joining player socket.emit('gameState', game.getState()); socket.emit('chatHistory', game.chat); // Notify others if (playerName) { io.to(roomName).emit('playerJoined', { player_id: playerId, name: playerName }); io.to(roomName).emit('playersUpdate', game.getState().players); } }); socket.on('setName', (data) => { const { name } = data; if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; game.addPlayer(playerId, name, socket.id); socket.emit('nameSet', { success: true, name }); io.to(currentRoom).emit('playersUpdate', game.getState().players); io.to(currentRoom).emit('gameState', game.getState()); }); socket.on('updateSettings', (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game || game.gameHost !== playerId) return; game.gameSettings = { ...game.gameSettings, ...data }; io.to(currentRoom).emit('settingsUpdate', game.gameSettings); }); socket.on('startGame', async (settings) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game || game.gameHost !== playerId) return; if (game.players.length < 2 && settings.botCount == 0) { socket.emit('error', { message: 'Need at least 2 players to start' }); return; } console.log(`Starting new game with settings ${JSON.stringify(settings)}`) await game.startGame(settings); io.to(currentRoom).emit('gameStarted', game.getState()); await startTimer(game); }); socket.on('submitEntry', async (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; const success = game.submitEntry(playerId, data.entry); if (success) { io.to(currentRoom).emit('playersUpdate', game.getState().players); if (game.allPlayersSubmitted()) { clearInterval(game.timer); await game.startVotingPhase(); io.to(currentRoom).emit('votingPhase', { entries: game.round_data.map((e, i) => ({ index: i, entry_text: e.entry_text })), timeRemaining: game.timeRemaining }); startVotingTimer(game); } } }); socket.on('submitVote', (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; const success = game.submitVote(playerId, data.entryIndex); if (success) { io.to(currentRoom).emit('playersUpdate', game.getState().players); if (game.allPlayersVoted()) { clearInterval(game.timer); endVotingPhase(game); } } }); socket.on('chatMessage', (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; const chatMsg = game.addChatMessage(playerId, data.msg); io.to(currentRoom).emit('newChatMessage', chatMsg); }); socket.on('playAgain', (settings) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game || game.gameHost !== playerId) return; game.saveStory(); game.resetGame(); io.to(currentRoom).emit('gameReset', game.getState()); }); socket.on('disconnect', () => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; const player = game.getPlayer(playerId); const playerName = player ? player.name : 'Unknown'; const remaining = game.removePlayer(playerId); io.to(currentRoom).emit('playerLeft', { player_id: playerId, name: playerName }); io.to(currentRoom).emit('playersUpdate', game.getState().players); io.to(currentRoom).emit('gameState', game.getState()); if (remaining === 0 || game.bots.length === game.players.length) { // Clean up empty rooms console.log(`Removing empty room ${currentRoom}`) if (game.timer) clearInterval(game.timer); delete games[currentRoom]; } }); }); async function startTimer(game) { if (game.timer) clearInterval(game.timer); game.timer = setInterval(async () => { game.timeRemaining--; io.to(game.roomName).emit('timerUpdate', game.timeRemaining); if (game.timeRemaining <= 0) { clearInterval(game.timer); // Force submit empty entries for players who haven't submitted for (const player of game.players) { if (!game.submittedPlayers.has(player.player_id)) { game.submitEntry(player.player_id, ""); } } await game.startVotingPhase(); io.to(game.roomName).emit('votingPhase', { entries: game.round_data.map((e, i) => ({ index: i, entry_text: e.entry_text })), timeRemaining: game.timeRemaining }); startVotingTimer(game); } }, 1000); } function startVotingTimer(game) { if (game.timer) clearInterval(game.timer); game.timer = setInterval(() => { game.timeRemaining--; io.to(game.roomName).emit('timerUpdate', game.timeRemaining); if (game.timeRemaining <= 0) { clearInterval(game.timer); endVotingPhase(game); } }, 1000); } function endVotingPhase(game) { const winner = game.tallyVotes(); io.to(game.roomName).emit('roundResults', { winner: winner, players: game.getState().players, story: game.yarnStory }); // Check for game over const gameWinner = game.checkGameOver(); if (gameWinner) { io.to(game.roomName).emit('gameOver', { winner: gameWinner, players: game.getState().players, story: game.yarnStory }); } else { // Start next round after a short delay setTimeout(async () => { game.startWritingPhase(); io.to(game.roomName).emit('newRound', game.getState()); await startTimer(game); }, 5000); } } const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(`YARN server running on http://localhost:${PORT}`); console.log(`Join a room at http://localhost:${PORT}/game101`); });