const WebSocket = require('ws'); const http = require('http'); const crypto = require('crypto'); const PORT = process.env.PORT || 3001; // Room storage: { roomId: { users: Map, createdAt: Date, name: string, veto: object } } const rooms = new Map(); // Global room (the default public room with bots) const GLOBAL_ROOM_ID = 'global'; // Veto threshold - when collective boredom exceeds this, veto becomes available const VETO_THRESHOLD = 66; const VETO_DURATION = 15000; // 15 seconds to vote // Generate short room codes const generateRoomCode = () => { return crypto.randomBytes(3).toString('hex').toUpperCase(); }; // Generate user ID const generateUserId = () => crypto.randomBytes(8).toString('hex'); // Simulated users for global room const bots = [ { id: 'bot-restless', name: 'Restless Rita', boredom: 65, volatility: 15, speed: 3000 }, { id: 'bot-chill', name: 'Chill Charlie', boredom: 25, volatility: 8, speed: 7000 }, { id: 'bot-moody', name: 'Moody Morgan', boredom: 50, volatility: 25, speed: 4000 }, { id: 'bot-sleepy', name: 'Sleepy Sam', boredom: 80, volatility: 10, speed: 10000 }, ]; // Initialize global room with bots const globalRoom = { users: new Map(), createdAt: new Date(), name: 'Global Boredom', isGlobal: true }; bots.forEach(bot => { globalRoom.users.set(bot.id, { boredom: bot.boredom, ws: null, isBot: true, name: bot.name }); }); rooms.set(GLOBAL_ROOM_ID, globalRoom); // Start bot simulation for global room bots.forEach(bot => { setInterval(() => { const room = rooms.get(GLOBAL_ROOM_ID); if (!room) return; const user = room.users.get(bot.id); if (!user) return; const drift = (bot.boredom - user.boredom) * 0.1; const randomChange = (Math.random() - 0.5) * bot.volatility; user.boredom = Math.max(0, Math.min(100, user.boredom + drift + randomChange)); broadcastToRoom(GLOBAL_ROOM_ID); }, bot.speed); }); // Get room stats const getRoomStats = (roomId) => { const room = rooms.get(roomId); if (!room) return null; const entries = Array.from(room.users.entries()); const values = entries.map(([_, u]) => u.boredom); const count = values.length; const average = count > 0 ? Math.round(values.reduce((a, b) => a + b, 0) / count) : 50; const individuals = entries.map(([id, u]) => ({ id, boredom: Math.round(u.boredom), isBot: u.isBot || false, name: u.name || null })); // Include veto state const veto = room.veto ? { active: true, initiator: room.veto.initiatorName || 'Someone', votes: room.veto.votes.size, needed: Math.ceil(count / 2), timeLeft: Math.max(0, Math.ceil((room.veto.endTime - Date.now()) / 1000)) } : null; return { average, count, individuals, roomName: room.name, roomId, vetoAvailable: average >= VETO_THRESHOLD, veto }; }; // Start a veto vote const startVeto = (roomId, initiatorId, initiatorName) => { const room = rooms.get(roomId); if (!room || room.veto) return false; const stats = getRoomStats(roomId); if (stats.average < VETO_THRESHOLD) return false; room.veto = { initiatorId, initiatorName: initiatorName || 'Someone', votes: new Set([initiatorId]), // Initiator automatically votes yes startTime: Date.now(), endTime: Date.now() + VETO_DURATION }; console.log(`Veto started in room ${roomId} by ${initiatorName}`); // Broadcast veto started broadcastToRoom(roomId); // Set timeout to end veto setTimeout(() => { endVeto(roomId, false); }, VETO_DURATION); return true; }; // Cast a veto vote const castVetoVote = (roomId, voterId) => { const room = rooms.get(roomId); if (!room || !room.veto) return false; room.veto.votes.add(voterId); // Check if majority reached const realUsers = Array.from(room.users.values()).filter(u => !u.isBot && u.ws); const needed = Math.ceil(realUsers.length / 2); if (room.veto.votes.size >= needed) { endVeto(roomId, true); return true; } broadcastToRoom(roomId); return true; }; // End veto voting const endVeto = (roomId, passed) => { const room = rooms.get(roomId); if (!room || !room.veto) return; const voteCount = room.veto.votes.size; room.veto = null; // Broadcast result const message = JSON.stringify({ type: 'vetoResult', passed, votes: voteCount }); room.users.forEach((user) => { if (user.ws && user.ws.readyState === WebSocket.OPEN) { user.ws.send(message); } }); console.log(`Veto in room ${roomId} ${passed ? 'PASSED' : 'failed'} with ${voteCount} votes`); // Also broadcast updated stats (veto no longer active) setTimeout(() => broadcastToRoom(roomId), 100); }; // Broadcast to all users in a room const broadcastToRoom = (roomId) => { const room = rooms.get(roomId); if (!room) return; const stats = getRoomStats(roomId); const message = JSON.stringify({ type: 'stats', ...stats }); room.users.forEach((user) => { if (user.ws && user.ws.readyState === WebSocket.OPEN) { user.ws.send(message); } }); }; // Clean up old empty rooms (except global) setInterval(() => { const now = Date.now(); rooms.forEach((room, roomId) => { if (roomId === GLOBAL_ROOM_ID) return; // Count real users (with websocket connections) const realUsers = Array.from(room.users.values()).filter(u => u.ws); // Remove room if empty for more than 1 hour if (realUsers.length === 0 && now - room.createdAt.getTime() > 3600000) { rooms.delete(roomId); console.log(`Cleaned up empty room: ${roomId}`); } }); }, 60000); // HTTP server for health checks and room creation const server = http.createServer((req, res) => { // CORS headers res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; } if (req.url === '/health') { const stats = getRoomStats(GLOBAL_ROOM_ID); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', rooms: rooms.size, globalUsers: stats?.count || 0 })); return; } if (req.url === '/api/rooms' && req.method === 'POST') { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { try { const data = JSON.parse(body || '{}'); const roomId = generateRoomCode(); const roomName = data.name || `Room ${roomId}`; rooms.set(roomId, { users: new Map(), createdAt: new Date(), name: roomName, isGlobal: false }); console.log(`Created room: ${roomId} - ${roomName}`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ roomId, roomName })); } catch (err) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid request' })); } }); return; } if (req.url.startsWith('/api/rooms/') && req.method === 'GET') { const roomId = req.url.split('/')[3]; const room = rooms.get(roomId); if (room) { const stats = getRoomStats(roomId); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(stats)); } else { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Room not found' })); } return; } res.writeHead(404); res.end(); }); // WebSocket server const wss = new WebSocket.Server({ server }); wss.on('connection', (ws, req) => { // Extract room ID from URL query parameter const url = new URL(req.url, `http://${req.headers.host}`); let roomId = url.searchParams.get('room') || GLOBAL_ROOM_ID; // Validate room exists if (!rooms.has(roomId)) { // Create room if it looks like a valid code if (roomId.length === 6 && /^[A-Z0-9]+$/.test(roomId)) { rooms.set(roomId, { users: new Map(), createdAt: new Date(), name: `Room ${roomId}`, isGlobal: false }); } else { roomId = GLOBAL_ROOM_ID; } } const room = rooms.get(roomId); const userId = generateUserId(); // Get name from query or generate const userName = url.searchParams.get('name') || null; // Add user to room room.users.set(userId, { boredom: 50, ws, isBot: false, name: userName }); console.log(`User ${userId} joined room ${roomId}. Users in room: ${room.users.size}`); // Send welcome message const stats = getRoomStats(roomId); ws.send(JSON.stringify({ type: 'welcome', userId, roomId, roomName: room.name, boredom: 50, ...stats })); // Broadcast updated stats broadcastToRoom(roomId); // Handle messages ws.on('message', (data) => { try { const message = JSON.parse(data); if (message.type === 'update' && typeof message.boredom === 'number') { const boredom = Math.max(0, Math.min(100, Math.round(message.boredom))); const user = room.users.get(userId); if (user) { user.boredom = boredom; broadcastToRoom(roomId); } } if (message.type === 'setName' && message.name) { const user = room.users.get(userId); if (user) { user.name = message.name.slice(0, 20); broadcastToRoom(roomId); } } // Veto actions if (message.type === 'startVeto') { const user = room.users.get(userId); const name = user?.name || 'Someone'; startVeto(roomId, userId, name); } if (message.type === 'vetoVote') { castVetoVote(roomId, userId); } } catch (err) { console.error('Invalid message:', err.message); } }); // Handle disconnect ws.on('close', () => { room.users.delete(userId); console.log(`User ${userId} left room ${roomId}. Users in room: ${room.users.size}`); broadcastToRoom(roomId); }); ws.on('error', (err) => { console.error(`WebSocket error for ${userId}:`, err.message); }); }); server.listen(PORT, () => { console.log(`Boredom Dial server running on port ${PORT}`); console.log(`Global room initialized with ${bots.length} bots`); }); process.on('SIGTERM', () => { console.log('Shutting down...'); wss.clients.forEach((client) => client.close()); server.close(() => process.exit(0)); });