'use strict'; const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); const fs = require('fs'); const { randomUUID } = require('node:crypto'); const BotFactory = require('./bots/BotFactory'); // --------------------------------------------------------------------------- // Limits & small validation helpers // --------------------------------------------------------------------------- const LIMITS = { roomName: 30, playerName: 20, entry: 280, chat: 300, startText: 300, maxRooms: 200, maxPlayersPerRoom: 16, maxDisconnectedPlayers: 64, settings: { roundLimit: [0, 100], scoreLimit: [0, 9999], timeLimit: [15, 600], botCount: [0, 8] } }; const ROOM_NAME_RE = /^[a-z0-9_-]+$/; const PLAYER_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; const DEFAULT_START_TEXT = 'It was a dark and stormy night...'; function sanitizeRoomName(raw) { if (typeof raw !== 'string') return null; const name = raw.trim().toLowerCase(); if (!name || name.length > LIMITS.roomName) return null; if (!ROOM_NAME_RE.test(name)) return null; return name; } function sanitizePlayerId(raw) { if (typeof raw !== 'string') return null; const id = raw.trim(); return PLAYER_ID_RE.test(id) ? id : null; } function shuffle(items) { const shuffled = [...items]; for (let i = shuffled.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } function clampInt(value, min, max, fallback) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } // Collapse whitespace, trim, cap length. Returns null for empty input. function cleanText(value, maxLength) { if (typeof value !== 'string') return null; const text = value.replace(/\s+/g, ' ').trim(); if (!text) return null; return text.slice(0, maxLength); } function normalizeSettings(current, raw) { const input = raw && typeof raw === 'object' ? raw : {}; const next = { ...current }; if ('scoreLimit' in input) { next.scoreLimit = clampInt( input.scoreLimit, LIMITS.settings.scoreLimit[0], LIMITS.settings.scoreLimit[1], current.scoreLimit ); } if ('roundLimit' in input) { next.roundLimit = clampInt( input.roundLimit, LIMITS.settings.roundLimit[0], LIMITS.settings.roundLimit[1], current.roundLimit ); } if ('timeLimit' in input) { next.timeLimit = clampInt( input.timeLimit, LIMITS.settings.timeLimit[0], LIMITS.settings.timeLimit[1], current.timeLimit ); } if ('botCount' in input) { next.botCount = clampInt( input.botCount, LIMITS.settings.botCount[0], LIMITS.settings.botCount[1], current.botCount ); } if ('startText' in input) { next.startText = cleanText(input.startText, LIMITS.startText) || current.startText; } return next; } const storiesDir = process.env.YARN_STORIES_DIR ? path.resolve(process.env.YARN_STORIES_DIR) : path.join(__dirname, 'stories'); const defaultTimers = { setInterval: (fn, ms) => setInterval(fn, ms), clearInterval: (handle) => clearInterval(handle), setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: (handle) => clearTimeout(handle) }; // --------------------------------------------------------------------------- // YarnGame // --------------------------------------------------------------------------- class YarnGame { constructor(roomName, options = {}) { this.roomName = roomName; this.broadcast = options.broadcast || (() => {}); this.botFactory = options.botFactory || BotFactory; this.timers = options.timers || defaultTimers; this.storiesDir = options.storiesDir || storiesDir; this.botType = options.botType || 'llm'; this.inProgress = false; this.gameHost = null; this.gameSettings = { scoreLimit: 0, roundLimit: 15, timeLimit: 60, botCount: 0, startText: DEFAULT_START_TEXT }; this.bots = []; // { bot_id, bot } this.players = []; // { player_id, name, score, socketId, isBot } this.disconnectedPlayers = []; this.yarnStory = []; // { player, str } this.chat = []; // { id, name, msg } this.round_data = []; // { entry_text, player_id, votes: [] } this.roundCount = 0; // completed rounds this.currentPhase = 'lobby'; // lobby | writing | voting | results | gameOver this.timer = null; this.banterTimer = null; this.nextRoundTimer = null; this.timeRemaining = 0; this.submittedPlayers = new Set(); this.votedPlayers = new Set(); this.lastResults = null; this.gameWinner = null; this.moralAppended = false; this.endingVotes = false; this.transitioning = false; this.votingOrder = []; } emit(event, payload) { try { this.broadcast(event, payload); } catch (err) { console.error(`[${this.roomName}] broadcast of "${event}" failed:`, err); } } // --- players ------------------------------------------------------------- addPlayer(playerId, rawName, socketId) { if (!playerId) return null; const name = cleanText(rawName, LIMITS.playerName); if (!name) return null; const existing = this.players.find((p) => p.player_id === playerId); if (existing) { existing.socketId = socketId; existing.name = name; return existing; } const reconnectIndex = this.disconnectedPlayers.findIndex( (p) => p.player_id === playerId ); if (reconnectIndex !== -1) { const [reconnected] = this.disconnectedPlayers.splice(reconnectIndex, 1); reconnected.socketId = socketId; reconnected.name = name; this.players.push(reconnected); console.log(`[${this.roomName}] ${name} reconnected.`); return reconnected; } if (this.players.length >= LIMITS.maxPlayersPerRoom) return null; const player = { player_id: playerId, name, score: 0, socketId, isBot: false }; this.players.push(player); if (!this.gameHost) this.gameHost = playerId; console.log(`[${this.roomName}] ${name} joined.`); return player; } removePlayer(playerId, { disconnected = true } = {}) { const index = this.players.findIndex((p) => p.player_id === playerId); if (index === -1) return this.players.length; const [removed] = this.players.splice(index, 1); console.log(`[${this.roomName}] ${removed.name} left.`); if (disconnected && !removed.isBot) { this.disconnectedPlayers = this.disconnectedPlayers.filter( (p) => p.player_id !== playerId ); this.disconnectedPlayers.push({ ...removed, socketId: null }); if (this.disconnectedPlayers.length > LIMITS.maxDisconnectedPlayers) { this.disconnectedPlayers.shift(); } } if (this.gameHost === playerId) { const humans = this.getHumanPlayers(); this.gameHost = humans.length ? humans[0].player_id : null; } return this.players.length; } getHumanPlayers() { return this.players.filter((p) => !p.isBot); } 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 clean = cleanText(name, LIMITS.playerName); const player = this.getPlayer(playerId); if (player && clean) player.name = clean; return Boolean(player && clean); } // --- bots ---------------------------------------------------------------- createBotInstance() { try { return this.botFactory.createBot(this.botType); } catch (err) { console.warn( `[${this.roomName}] Bot creation failed (${err.message}); using RandomBot.` ); return this.botFactory.createBot('random'); } } startBots(botCount) { const count = clampInt( botCount, LIMITS.settings.botCount[0], LIMITS.settings.botCount[1], 0 ); const usedNames = this.players.map((p) => p.name); for (let i = 0; i < count; i += 1) { const bot_id = randomUUID(); const bot = this.createBotInstance(); const name = this.botFactory.generateBotName(usedNames); usedNames.push(name); this.bots.push({ bot_id, bot }); this.players.push({ player_id: bot_id, name, score: 0, socketId: null, isBot: true }); console.log(`[${this.roomName}] Created bot ${name} (${bot_id})`); } } withBotTimeout(promise, ms = 30000) { let handle = null; const timeout = new Promise((resolve, reject) => { handle = this.timers.setTimeout( () => reject(new Error('bot request timed out')), ms ); if (handle && typeof handle.unref === 'function') handle.unref(); }); return Promise.race([Promise.resolve(promise), timeout]).finally(() => { if (handle && typeof this.timers.clearTimeout === 'function') { this.timers.clearTimeout(handle); } }); } async safeBotWrite(botObj, storySoFar) { try { const text = await this.withBotTimeout(botObj.bot.Write(storySoFar)); const clean = cleanText(text, LIMITS.entry); if (clean) return clean; } catch (err) { console.warn( `[${this.roomName}] Bot ${botObj.bot_id} failed to write:`, err?.message || err ); } // Degrade gracefully: try the fallback bot, then submit an empty entry. try { const fallback = this.botFactory.createBot('random'); return cleanText(fallback.Write(storySoFar), LIMITS.entry) || ''; } catch (err) { return ''; } } async processBotWriting() { const bots = [...this.bots]; if (!bots.length || this.currentPhase !== 'writing') return; const storySoFar = this.getStoryText(); await Promise.allSettled( bots.map(async (botObj) => { const text = await this.safeBotWrite(botObj, storySoFar); if (this.currentPhase !== 'writing') return; this.submitEntry(botObj.bot_id, text, { forced: !text }); this.checkSubmissionsComplete(); }) ); this.checkSubmissionsComplete(); } checkSubmissionsComplete() { if (this.currentPhase === 'writing' && this.allPlayersSubmitted()) { this.beginVotingPhase(); } } async processBotVoting() { const bots = [...this.bots]; if (!bots.length || this.currentPhase !== 'voting') return; const storySoFar = this.getStoryText(); // Build per-bot choice lists (bots never vote for their own entry). await Promise.allSettled( bots.map(async (botObj) => { const options = this.round_data .map((entry, index) => ({ index, entry_text: entry.entry_text, player_id: entry.player_id })) .filter( (choice) => choice.entry_text.trim().length > 0 && choice.player_id !== botObj.bot_id ); if (!options.length) return; let choice = null; try { choice = await this.withBotTimeout( botObj.bot.Vote(storySoFar, options) ); } catch (err) { console.warn( `[${this.roomName}] Bot ${botObj.bot_id} failed to vote:`, err?.message || err ); } let index = Number.parseInt(choice, 10); if (!Number.isInteger(index) || !options.some((o) => o.index === index)) { index = options[Math.floor(Math.random() * options.length)].index; } this.submitVote(botObj.bot_id, index); this.checkVotesComplete(); }) ); this.checkVotesComplete(); } checkVotesComplete() { if (this.currentPhase === 'voting' && this.allPlayersVoted()) { this.endVotingPhase(); } } async botBanter() { if (!this.inProgress || !this.bots.length) return; const chatSoFar = this.chat .slice(-8) .map((message) => `${message.name}: ${message.msg}`) .join('\n') || '(nobody has spoken yet)'; const talkers = this.bots.filter(() => Math.random() < 0.35); if (!talkers.length) return; await Promise.allSettled( talkers.map(async (botObj) => { try { const reply = await this.withBotTimeout(botObj.bot.Banter(chatSoFar)); const text = cleanText(reply, LIMITS.chat); if (!text) return; const chatMsg = this.addChatMessage(botObj.bot_id, text); this.emit('newChatMessage', chatMsg); } catch (err) { console.warn( `[${this.roomName}] Bot ${botObj.bot_id} failed to banter:`, err?.message || err ); } }) ); } startBanterTimer() { this.stopBanterTimer(); if (!this.bots.length) return; this.banterTimer = this.timers.setInterval(() => { this.botBanter(); }, 20000); } stopBanterTimer() { if (this.banterTimer) { this.timers.clearInterval(this.banterTimer); this.banterTimer = null; } } // --- game lifecycle ------------------------------------------------------ async startGame(settings = {}) { if (this.inProgress) return false; this.gameSettings = normalizeSettings(this.gameSettings, settings); this.roundCount = 0; this.moralAppended = false; this.gameWinner = null; this.lastResults = null; this.endingVotes = false; this.transitioning = false; this.round_data = []; this.submittedPlayers = new Set(); this.votedPlayers = new Set(); this.startBots(this.gameSettings.botCount); this.inProgress = true; this.yarnStory = [ { player: 'Narrator', str: this.gameSettings.startText || DEFAULT_START_TEXT } ]; this.players.forEach((player) => { player.score = 0; }); this.startBanterTimer(); this.startWritingPhase(); return true; } startWritingPhase() { if (!this.inProgress) return; this.currentPhase = 'writing'; this.endingVotes = false; this.votingOrder = []; this.round_data = []; this.submittedPlayers = new Set(); this.votedPlayers = new Set(); this.timeRemaining = this.gameSettings.timeLimit; this.emit('newRound', this.getState()); this.startWritingTimer(); this.processBotWriting(); } startWritingTimer() { this.stopTimer(); this.timer = this.timers.setInterval(() => this.tick(), 1000); } startVotingTimer() { this.stopTimer(); this.timer = this.timers.setInterval(() => this.tick(), 1000); } stopTimer() { if (this.timer) { this.timers.clearInterval(this.timer); this.timer = null; } } tick() { if (this.currentPhase !== 'writing' && this.currentPhase !== 'voting') { this.stopTimer(); return; } this.timeRemaining = Math.max(0, this.timeRemaining - 1); this.emit('timerUpdate', this.timeRemaining); if (this.timeRemaining > 0) return; this.stopTimer(); if (this.currentPhase === 'writing') { this.beginVotingPhase({ force: true }); } else if (this.currentPhase === 'voting') { this.endVotingPhase(); } } getStoryText() { return this.yarnStory .map((entry) => (typeof entry.str === 'string' ? entry.str.trim() : '')) .filter(Boolean) .join(' '); } submitEntry(playerId, entryText, { forced = false } = {}) { if (this.currentPhase !== 'writing') { return { ok: false, error: 'Submissions are closed right now.' }; } if (this.submittedPlayers.has(playerId)) { return { ok: false, error: 'You already submitted an entry this round.' }; } if (!this.getPlayer(playerId)) { return { ok: false, error: 'Unknown player.' }; } let text = ''; if (!forced) { const clean = cleanText(entryText, LIMITS.entry); if (!clean) return { ok: false, error: 'Your entry cannot be empty.' }; text = clean; } this.round_data.push({ entry_text: text, player_id: playerId, votes: [] }); this.submittedPlayers.add(playerId); this.emit('playersUpdate', this.getState().players); return { ok: true }; } allPlayersSubmitted() { return ( this.players.length > 0 && this.players.every((player) => this.submittedPlayers.has(player.player_id)) ); } eligibleEntries() { return this.round_data.filter( (entry) => typeof entry.entry_text === 'string' && entry.entry_text.trim() ); } publicEntries() { const entries = this.round_data .map((entry, index) => ({ index, entry_text: entry.entry_text, voteCount: entry.votes.length })) .filter((entry) => entry.entry_text.trim().length > 0); if (!this.votingOrder.length) return entries; // Show entries in a shuffled order so the submission order does not // reveal who wrote which line. `index` stays the real round_data index, // so votes still map correctly. const byIndex = new Map(entries.map((entry) => [entry.index, entry])); const ordered = this.votingOrder .map((index) => byIndex.get(index)) .filter(Boolean); const leftovers = entries.filter((entry) => !this.votingOrder.includes(entry.index)); return [...ordered, ...leftovers]; } getOwnEntryIndex(playerId) { return this.round_data.findIndex( (entry) => entry.player_id === playerId && entry.entry_text.trim() ); } beginVotingPhase({ force = false } = {}) { if (this.currentPhase !== 'writing' || this.transitioning) return; this.transitioning = true; try { if (force) { for (const player of [...this.players]) { if (!this.submittedPlayers.has(player.player_id)) { this.submitEntry(player.player_id, '', { forced: true }); } } } this.stopTimer(); this.currentPhase = 'voting'; this.votedPlayers = new Set(); const eligibleIndexes = this.round_data .map((entry, index) => ({ entry, index })) .filter(({ entry }) => entry.entry_text.trim().length > 0) .map(({ index }) => index); this.votingOrder = shuffle(eligibleIndexes); const entries = this.publicEntries(); this.timeRemaining = Math.max(15, 10 * entries.length); if (!entries.length) { // Nothing to vote on: close the round immediately. this.emit('votingPhase', { entries: [], timeRemaining: 0 }); this.endVotingPhase(); return; } this.emit('votingPhase', { entries, timeRemaining: this.timeRemaining }); this.emit('playersUpdate', this.getState().players); this.startVotingTimer(); this.processBotVoting(); } finally { this.transitioning = false; } } submitVote(playerId, entryIndex) { if (this.currentPhase !== 'voting') { return { ok: false, error: 'Voting is closed right now.' }; } if (this.votedPlayers.has(playerId)) { return { ok: false, error: 'You already voted this round.' }; } if (!this.getPlayer(playerId)) { return { ok: false, error: 'Unknown player.' }; } const index = Number.parseInt(entryIndex, 10); if (!Number.isInteger(index) || index < 0 || index >= this.round_data.length) { return { ok: false, error: 'Invalid vote.' }; } const entry = this.round_data[index]; if (!entry || !entry.entry_text.trim()) { return { ok: false, error: 'You cannot vote for an empty entry.' }; } if (entry.player_id === playerId) { return { ok: false, error: 'You cannot vote for your own entry.' }; } entry.votes.push(playerId); this.votedPlayers.add(playerId); this.emit('playersUpdate', this.getState().players); return { ok: true }; } allPlayersVoted() { return ( this.players.length > 0 && this.players.every((player) => this.votedPlayers.has(player.player_id)) ); } pointsMultiplier() { const limit = this.gameSettings.roundLimit; if (!limit) return 1; return this.roundCount + 1 >= limit ? 2 : 1; } tallyVotes() { const multiplier = this.pointsMultiplier(); const entries = this.eligibleEntries(); const bonusByEntry = new Map(); if (entries.length) { const maxVotes = Math.max(...entries.map((entry) => entry.votes.length)); if (maxVotes > 0) { const leaders = entries.filter((entry) => entry.votes.length === maxVotes); const longest = leaders.reduce( (a, b) => (b.entry_text.length > a.entry_text.length ? b : a), leaders[0] ); leaders.forEach((leader) => { bonusByEntry.set(leader, leader === longest ? (leaders.length === 1 ? 3 : 2) : 1); }); } } // 1 point per vote for every eligible entry, plus any bonus. const results = entries.map((entry) => { const player = this.getPlayer(entry.player_id); const bonus = bonusByEntry.get(entry) || 0; const points = (entry.votes.length + bonus) * multiplier; if (player) player.score += points; return { index: this.round_data.indexOf(entry), entry_text: entry.entry_text, player_id: entry.player_id, player_name: player ? player.name : 'Unknown', votes: entry.votes.length, points, bonus }; }); let winner = null; let reason = 'no-entries'; if (entries.length) { const maxVotes = Math.max(...entries.map((entry) => entry.votes.length)); if (maxVotes === 0) { reason = 'no-votes'; } else { const leaders = entries.filter((entry) => entry.votes.length === maxVotes); const longest = leaders.reduce( (a, b) => (b.entry_text.length > a.entry_text.length ? b : a), leaders[0] ); const winnerResult = results.find( (result) => result.entry_text === longest.entry_text && result.player_id === longest.player_id ); winner = winnerResult ? { ...winnerResult } : null; reason = leaders.length === 1 ? 'winner' : 'tie'; if (longest.entry_text) { this.yarnStory.push({ player: this.getPlayer(longest.player_id)?.name ?? 'Unknown', str: longest.entry_text }); } } } if (winner) { winner.reason = reason; winner.wasTie = reason === 'tie'; } this.roundCount += 1; return { winner, results, reason }; } checkGameOver() { if (!this.players.length) return null; const best = () => this.players.reduce( (top, player) => (player.score > top.score ? player : top), this.players[0] ); const { scoreLimit, roundLimit } = this.gameSettings; if (scoreLimit) { const candidate = best(); if (candidate && candidate.score >= scoreLimit) return candidate; } if (roundLimit && this.roundCount >= roundLimit) return best(); return null; } endVotingPhase() { if (this.currentPhase !== 'voting' || this.endingVotes) return; this.endingVotes = true; this.stopTimer(); this.currentPhase = 'results'; const { winner, results, reason } = this.tallyVotes(); this.lastResults = { winner, results, reason, round: this.roundCount }; this.emit('roundResults', { winner, results, reason, round: this.roundCount, players: this.getState().players, story: this.yarnStory }); const gameWinner = this.checkGameOver(); if (gameWinner) { this.finishGame(gameWinner); return; } this.scheduleNextRound(); } scheduleNextRound() { if (this.nextRoundTimer) this.timers.clearTimeout(this.nextRoundTimer); this.nextRoundTimer = this.timers.setTimeout(() => { this.nextRoundTimer = null; if (this.currentPhase !== 'results') return; if (this.shouldAppendMoral()) { this.moralAppended = true; this.yarnStory.push({ player: 'Narrator', str: 'The moral of this story: ' }); this.emit('storyUpdate', this.yarnStory); } this.startWritingPhase(); }, 5000); } shouldAppendMoral() { const limit = this.gameSettings.roundLimit; if (!limit || this.moralAppended) return false; return this.roundCount + 1 === limit; } finishGame(gameWinner) { this.currentPhase = 'gameOver'; this.inProgress = false; this.stopTimer(); this.stopBanterTimer(); this.gameWinner = { player_id: gameWinner.player_id, name: gameWinner.name, score: gameWinner.score }; try { this.saveStory(); } catch (err) { console.warn(`[${this.roomName}] Could not save story:`, err.message); } this.emit('gameOver', { winner: this.gameWinner, players: this.getState().players, story: this.yarnStory }); } purgeBots() { for (const botObj of this.bots) { this.removePlayer(botObj.bot_id, { disconnected: false }); } this.bots = []; } resetGame() { this.destroy(); this.purgeBots(); this.inProgress = false; this.currentPhase = 'lobby'; this.round_data = []; this.yarnStory = []; this.submittedPlayers = new Set(); this.votedPlayers = new Set(); this.roundCount = 0; this.timeRemaining = 0; this.lastResults = null; this.gameWinner = null; this.moralAppended = false; this.endingVotes = false; this.transitioning = false; this.votingOrder = []; this.emit('gameReset', this.getState()); } destroy() { this.stopTimer(); this.stopBanterTimer(); if (this.nextRoundTimer) { this.timers.clearTimeout(this.nextRoundTimer); this.nextRoundTimer = null; } } // --- stories & chat ------------------------------------------------------ saveStory() { const storyText = 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 safeRoom = sanitizeRoomName(this.roomName) || 'room'; const baseName = `${safeRoom} - ${dateStamp} ${timeStamp}`; const directory = this.storiesDir; fs.mkdirSync(directory, { recursive: true }); // path.basename guarantees the write stays inside stories/. let fileName = `${baseName}.txt`; let suffix = 2; while (fs.existsSync(path.join(directory, fileName))) { fileName = `${baseName} (${suffix}).txt`; suffix += 1; } const filePath = path.join(directory, path.basename(fileName)); fs.writeFileSync(filePath, storyText, 'utf8'); console.log(`[${this.roomName}] Saved story to ${filePath}`); return path.basename(filePath); } addChatMessage(playerId, msg) { const player = this.getPlayer(playerId); const chatMsg = { id: playerId, name: player ? player.name : 'Unknown', msg: cleanText(msg, LIMITS.chat) || '', at: Date.now() }; if (!chatMsg.msg) return null; this.chat.push(chatMsg); if (this.chat.length > 100) this.chat.shift(); return chatMsg; } // --- state --------------------------------------------------------------- getState() { const showResults = this.currentPhase === 'results' || this.currentPhase === 'gameOver'; const roundLimit = this.gameSettings.roundLimit; const currentRound = roundLimit ? Math.min(this.roundCount + 1, roundLimit) : this.roundCount + 1; return { roomName: this.roomName, inProgress: this.inProgress, gameHost: this.gameHost, gameSettings: { ...this.gameSettings }, players: this.players.map((player) => ({ player_id: player.player_id, name: player.name, score: player.score, isBot: Boolean(player.isBot), hasSubmitted: this.submittedPlayers.has(player.player_id), hasVoted: this.votedPlayers.has(player.player_id) })), yarnStory: this.yarnStory.map((entry) => ({ ...entry })), currentPhase: this.currentPhase, currentRound, roundLimit, scoreLimit: this.gameSettings.scoreLimit, timeRemaining: this.timeRemaining, round_data: this.currentPhase === 'voting' ? this.publicEntries() : [], lastResults: showResults ? this.lastResults : null, gameWinner: this.gameWinner }; } getStateFor(playerId) { return { ...this.getState(), ownEntryIndex: this.getOwnEntryIndex(playerId) }; } } // --------------------------------------------------------------------------- // HTTP / Socket.IO wiring // --------------------------------------------------------------------------- const app = express(); const server = http.createServer(app); const io = new Server(server); app.use(express.static(path.join(__dirname, 'public'))); // Store all game rooms const games = {}; function createBroadcaster(roomName) { return (event, payload) => { // Voting entries are broadcast per-socket so each player learns which // entry is theirs (without revealing authorship to everyone). if (event === 'votingPhase') { const game = games[roomName]; const roomSockets = io.sockets.adapter.rooms.get(roomName); if (!roomSockets || !roomSockets.size) return; for (const socketId of roomSockets) { const player = game ? game.getPlayerBySocket(socketId) : null; const ownEntryIndex = player ? game.getOwnEntryIndex(player.player_id) : -1; io.to(socketId).emit(event, { ...payload, ownEntryIndex }); } return; } io.to(roomName).emit(event, payload); }; } function getOrCreateGame(roomName) { const name = sanitizeRoomName(roomName); if (!name) return null; if (!games[name]) { if (Object.keys(games).length >= LIMITS.maxRooms) return null; console.log(`[rooms] Creating room ${name}`); games[name] = new YarnGame(name, { broadcast: createBroadcaster(name), storiesDir }); } return games[name]; } function removeRoomIfEmpty(roomName) { const game = games[roomName]; if (!game) return; if (game.getHumanPlayers().length > 0) return; console.log(`[rooms] Removing empty room ${roomName}`); game.destroy(); game.purgeBots(); delete games[roomName]; } // Routes -------------------------------------------------------------------- app.get('/game/:roomName', (req, res) => { const roomName = sanitizeRoomName(req.params.roomName); if (!roomName) return res.status(400).send('Invalid room name.'); return res.sendFile(path.join(__dirname, 'public', 'game.html')); }); app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'menu.html')); }); app.get('/api/rooms', (req, res) => { const roomList = Object.keys(games).map((name) => ({ name, playerCount: games[name].getHumanPlayers().length, inProgress: games[name].inProgress, phase: games[name].currentPhase })); res.json({ rooms: roomList }); }); app.get('/api/stories', (req, res) => { try { if (!fs.existsSync(storiesDir)) return res.json({ stories: [] }); const stories = fs .readdirSync(storiesDir) .filter((name) => name.endsWith('.txt')) .sort() .reverse() .slice(0, 200); return res.json({ stories }); } catch (err) { console.error('Could not list stories:', err.message); return res.status(500).json({ stories: [], error: 'Could not list stories.' }); } }); app.get('/api/stories/:fileName', (req, res) => { const fileName = path.basename(String(req.params.fileName)); if (!/^[A-Za-z0-9 _.-]+\.txt$/.test(fileName)) { return res.status(400).json({ error: 'Invalid story name.' }); } const filePath = path.join(storiesDir, fileName); if (!filePath.startsWith(storiesDir + path.sep)) { return res.status(400).json({ error: 'Invalid story name.' }); } if (!fs.existsSync(filePath)) { return res.status(404).json({ error: 'Story not found.' }); } return res.type('text/plain').send(fs.readFileSync(filePath, 'utf8')); }); // Socket.IO ----------------------------------------------------------------- io.on('connection', (socket) => { let currentRoom = null; let playerId = null; const sendError = (message) => socket.emit('error', { message }); socket.on('joinRoom', (data) => { const roomName = sanitizeRoomName(data?.roomName); const uuid = sanitizePlayerId(data?.playerUUID); if (!roomName) return sendError('Invalid room name.'); if (!uuid) return sendError('Invalid player id.'); currentRoom = roomName; playerId = uuid; socket.join(roomName); const game = getOrCreateGame(roomName); if (!game) return sendError('The server is at capacity. Try again later.'); const rawName = typeof data?.playerName === 'string' ? data.playerName : ''; if (rawName.trim()) game.addPlayer(playerId, rawName, socket.id); socket.emit('gameState', game.getStateFor(playerId)); socket.emit('chatHistory', game.chat); socket.emit('settingsUpdate', game.gameSettings); if (rawName.trim() && game.getPlayer(playerId)) { io.to(roomName).emit('playerJoined', { player_id: playerId, name: game.getPlayer(playerId).name }); io.to(roomName).emit('playersUpdate', game.getState().players); io.to(roomName).emit('gameState', game.getState()); } }); socket.on('setName', (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; const name = cleanText(data?.name, LIMITS.playerName); if (!name) return sendError('Please choose a name (1-20 characters).'); const player = game.addPlayer(playerId, name, socket.id); if (!player) return sendError('Could not join the game.'); socket.emit('nameSet', { success: true, name: player.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; if (game.inProgress) return sendError('Settings are locked during a game.'); game.gameSettings = normalizeSettings(game.gameSettings, data); io.to(currentRoom).emit('settingsUpdate', game.gameSettings); io.to(currentRoom).emit('gameState', game.getState()); }); socket.on('startGame', async (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; if (game.gameHost !== playerId) return sendError('Only the host can start the game.'); if (game.inProgress) return sendError('A game is already in progress.'); const settings = normalizeSettings(game.gameSettings, data); if (game.players.length + settings.botCount < 3) { return sendError('Need at least 3 players (including bots) to start.'); } console.log(`[${currentRoom}] Starting game ${JSON.stringify(settings)}`); try { await game.startGame(settings); } catch (err) { console.error(`[${currentRoom}] Failed to start game:`, err); return sendError('Failed to start the game. Please try again.'); } }); socket.on('submitEntry', (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; const result = game.submitEntry(playerId, data?.entry); if (!result.ok) return sendError(result.error); game.checkSubmissionsComplete(); }); socket.on('submitVote', (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; const result = game.submitVote(playerId, data?.entryIndex); if (!result.ok) return sendError(result.error); game.checkVotesComplete(); }); socket.on('skipPhase', () => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; if (game.gameHost !== playerId) return sendError('Only the host can skip a phase.'); if (game.currentPhase === 'writing') { game.beginVotingPhase({ force: true }); } else if (game.currentPhase === 'voting') { game.endVotingPhase(); } }); socket.on('kickPlayer', (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; if (game.gameHost !== playerId) return sendError('Only the host can remove players.'); const targetId = sanitizePlayerId(data?.playerId); if (!targetId || targetId === playerId) return sendError('Invalid player.'); const target = game.getPlayer(targetId); if (!target || target.isBot) return sendError('Invalid player.'); const targetSocketId = target.socketId; game.removePlayer(targetId, { disconnected: false }); const targetSocket = targetSocketId ? io.sockets.sockets.get(targetSocketId) : null; if (targetSocket) { targetSocket.leave(currentRoom); targetSocket.emit('kicked', { message: 'You were removed from the game by the host.' }); } io.to(currentRoom).emit('playerLeft', { player_id: targetId, name: target.name }); io.to(currentRoom).emit('playersUpdate', game.getState().players); io.to(currentRoom).emit('gameState', game.getState()); if (game.currentPhase === 'writing' && game.allPlayersSubmitted()) { game.beginVotingPhase(); } else if (game.currentPhase === 'voting' && game.allPlayersVoted()) { game.endVotingPhase(); } removeRoomIfEmpty(currentRoom); }); socket.on('chatMessage', (data) => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game || !game.getPlayer(playerId)) return; const chatMsg = game.addChatMessage(playerId, data?.msg); if (chatMsg) io.to(currentRoom).emit('newChatMessage', chatMsg); }); socket.on('playAgain', () => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game || game.gameHost !== playerId) return; if (game.inProgress) return sendError('The game is still in progress.'); game.resetGame(); }); socket.on('disconnect', () => { if (!currentRoom || !playerId) return; const game = games[currentRoom]; if (!game) return; const player = game.getPlayer(playerId); if (!player) return; // A reconnecting socket may already own this player id; ignore stale // disconnects so the fresh session is not kicked out. if (player.socketId && player.socketId !== socket.id) return; const playerName = player.name; 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()); // Don't leave the remaining players waiting on the timer if the person // who was still typing or voting just dropped. if (game.inProgress) { if (game.currentPhase === 'writing' && game.allPlayersSubmitted()) { game.beginVotingPhase(); } else if (game.currentPhase === 'voting' && game.allPlayersVoted()) { game.endVotingPhase(); } } removeRoomIfEmpty(currentRoom); }); }); const PORT = process.env.PORT || 3000; if (require.main === module) { server.listen(PORT, () => { console.log(`YARN server running on http://localhost:${PORT}`); console.log(`Join a room at http://localhost:${PORT}/game/lobby`); }); } module.exports = { YarnGame, app, server, io, games, storiesDir, LIMITS, sanitizeRoomName, sanitizePlayerId, normalizeSettings, cleanText, clampInt, shuffle, getOrCreateGame, removeRoomIfEmpty };