import express from 'express'; import { createServer } from 'http'; import { Server } from 'socket.io'; import cors from 'cors'; import bcrypt from 'bcrypt'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; // Get __dirname equivalent in ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); const server = createServer(app); const io = new Server(server, { cors: { origin: "*", methods: ["GET", "POST"] } }); // Middleware app.use(cors()); app.use(express.json()); app.use(express.static('public')); // Configuration const CONFIG = { passwords: { addPoints: 'add123', admin: 'admin456' }, timers: { solo: 15 * 60, // 15 minutes in seconds together: 60 * 60, // 1 hour in seconds finishUp: 2 * 60 // 2 minutes in seconds }, port: 80 }; // Data file path const DATA_FILE = path.join(__dirname, 'data', 'gameState.json'); // Initial state structure const initialState = { soloPoints: [], togetherPoints: [], customPoints: [], activeTimers: {}, pausedTimers: {}, activityLog: [], togetherUsedToday: 0, lastResetDate: new Date().toDateString(), lastWeeklyReset: '', connectedClients: 0 }; // Load or create game state let gameState = loadGameState(); // Load game state from file function loadGameState() { try { if (fs.existsSync(DATA_FILE)) { const data = fs.readFileSync(DATA_FILE, 'utf8'); const savedState = JSON.parse(data); // Convert timer maps back from objects savedState.activeTimers = savedState.activeTimers || {}; savedState.pausedTimers = savedState.pausedTimers || {}; return { ...initialState, ...savedState }; } } catch (error) { console.error('Error loading game state:', error); } // Initialize with default points const state = { ...initialState }; state.soloPoints = Array(5).fill(null).map((_, i) => ({ id: `solo_${i}`, type: 'solo', status: 'available', duration: CONFIG.timers.solo, label: '15m' })); state.togetherPoints = Array(4).fill(null).map((_, i) => ({ id: `together_${i}`, type: 'together', status: 'available', duration: CONFIG.timers.together, label: '1h' })); return state; } // Save game state to file function saveGameState() { try { const dir = path.dirname(DATA_FILE); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(DATA_FILE, JSON.stringify(gameState, null, 2)); } catch (error) { console.error('Error saving game state:', error); } } // Check and reset daily limit function checkDayReset() { const today = new Date().toDateString(); if (gameState.lastResetDate !== today) { gameState.togetherUsedToday = 0; gameState.lastResetDate = today; saveGameState(); return true; } return false; } // Add log entry function addLogEntry(message) { const entry = { timestamp: new Date().toISOString(), message: message }; gameState.activityLog.unshift(entry); // Keep only last 5 entries if (gameState.activityLog.length > 5) { gameState.activityLog = gameState.activityLog.slice(0, 5); } saveGameState(); } // Find point by ID function findPoint(pointId) { return [...gameState.soloPoints, ...gameState.togetherPoints, ...gameState.customPoints] .find(p => p.id === pointId); } // Timer management const timerIntervals = new Map(); function startTimer(pointId, duration, type = 'normal') { const startTime = Date.now(); const timer = { startTime, duration: duration * 1000, remainingTime: duration * 1000, type, pointId }; gameState.activeTimers[pointId] = timer; // Set up interval to check timer const interval = setInterval(() => { const elapsed = Date.now() - startTime; const remaining = timer.duration - elapsed; if (remaining <= 0) { clearInterval(interval); timerIntervals.delete(pointId); handleTimerComplete(pointId); } else { // Emit timer update io.emit('timerUpdate', { pointId, remainingSeconds: Math.floor(remaining / 1000), type: timer.type }); } }, 1000); timerIntervals.set(pointId, interval); saveGameState(); } function handleTimerComplete(pointId) { const timer = gameState.activeTimers[pointId]; if (!timer) return; delete gameState.activeTimers[pointId]; // Handle different completion types if (timer.type === 'finishUp') { const point = findPoint(pointId); if (point) { point.status = 'used'; addLogEntry(`${point.customName || point.type} point finish-up time completed`); io.emit('timerComplete', { pointId, type: 'finishUp' }); io.emit('stateUpdate', gameState); } } else if (timer.type === 'episode' || timer.type === 'movie') { // Remove temporary point gameState.customPoints = gameState.customPoints.filter(p => p.id !== pointId); addLogEntry(`${timer.type} completed`); io.emit('timerComplete', { pointId, type: timer.type }); io.emit('stateUpdate', gameState); } else { // Regular timer - offer finish-up io.emit('timerComplete', { pointId, type: 'normal', offerFinishUp: true }); } saveGameState(); } // Socket.IO connection handling io.on('connection', (socket) => { console.log('Client connected:', socket.id); gameState.connectedClients++; // Check day reset on new connection if (checkDayReset()) { io.emit('stateUpdate', gameState); } // Send initial state socket.emit('initialState', gameState); // Handle disconnection socket.on('disconnect', () => { console.log('Client disconnected:', socket.id); gameState.connectedClients--; }); // Use point socket.on('usePoint', (data) => { const { pointId } = data; const point = findPoint(pointId); if (!point || point.status !== 'available') { socket.emit('error', { message: 'Point not available' }); return; } // Check solo limitation if (point.type === 'solo' || point.customType === 'solo') { const activeSolo = [...gameState.soloPoints, ...gameState.customPoints] .find(p => (p.type === 'solo' || p.customType === 'solo') && (p.status === 'active' || p.status === 'paused')); if (activeSolo) { socket.emit('error', { message: 'Only one solo point at a time' }); return; } } // Check together daily limit if (point.type === 'together' || point.customType === 'together') { if (gameState.togetherUsedToday >= 1) { socket.emit('error', { message: 'Daily together limit reached' }); return; } gameState.togetherUsedToday++; } point.status = 'active'; startTimer(pointId, point.duration || CONFIG.timers[point.type]); addLogEntry(`Started ${point.customName || point.type} point (${point.label})`); io.emit('stateUpdate', gameState); }); // Pause timer socket.on('pauseTimer', (data) => { const { pointId } = data; const point = findPoint(pointId); const timer = gameState.activeTimers[pointId]; if (!point || !timer) return; // Clear interval const interval = timerIntervals.get(pointId); if (interval) { clearInterval(interval); timerIntervals.delete(pointId); } point.status = 'paused'; const elapsed = Date.now() - timer.startTime; timer.remainingTime = Math.max(0, timer.duration - elapsed); gameState.pausedTimers[pointId] = timer; delete gameState.activeTimers[pointId]; addLogEntry(`Paused ${point.customName || point.type} point`); io.emit('stateUpdate', gameState); }); // Resume timer socket.on('resumeTimer', (data) => { const { pointId } = data; const point = findPoint(pointId); const timer = gameState.pausedTimers[pointId]; if (!point || !timer) return; point.status = 'active'; delete gameState.pausedTimers[pointId]; // Restart with remaining time startTimer(pointId, timer.remainingTime / 1000, timer.type); addLogEntry(`Resumed ${point.customName || point.type} point`); io.emit('stateUpdate', gameState); }); // Admin authentication socket.on('adminAuth', (data) => { const { password } = data; if (password === CONFIG.passwords.admin) { socket.emit('adminAuthSuccess'); addLogEntry('Admin mode activated'); } else { socket.emit('adminAuthFailed'); } }); // Add points with password socket.on('addPoints', (data) => { const { password, type, count } = data; if (password !== CONFIG.passwords.addPoints) { socket.emit('error', { message: 'Incorrect password' }); return; } for (let i = 0; i < count; i++) { const newPoint = { id: `${type}_${Date.now()}_${i}`, type, status: 'available', duration: CONFIG.timers[type], label: type === 'solo' ? '15m' : '1h' }; if (type === 'solo') { gameState.soloPoints.push(newPoint); } else { gameState.togetherPoints.push(newPoint); } } addLogEntry(`Added ${count} ${type} point(s)`); io.emit('stateUpdate', gameState); }); // Quick add (admin only) socket.on('quickAddPoint', (data) => { const { type } = data; const newPoint = { id: `${type}_${Date.now()}`, type, status: 'available', duration: CONFIG.timers[type], label: type === 'solo' ? '15m' : '1h' }; if (type === 'solo') { gameState.soloPoints.push(newPoint); } else { gameState.togetherPoints.push(newPoint); } addLogEntry(`Admin added 1 ${type} point`); io.emit('stateUpdate', gameState); }); // Remove point socket.on('removePoint', (data) => { const { pointId } = data; const point = findPoint(pointId); if (!point) { socket.emit('error', { message: 'Point not found' }); return; } // Handle episode/movie removal const timer = gameState.activeTimers[pointId] || gameState.pausedTimers[pointId]; if (timer && (timer.type === 'episode' || timer.type === 'movie')) { // Restore consumed solo points if (timer.pointIds) { timer.pointIds.forEach(soloId => { const soloPoint = gameState.soloPoints.find(p => p.id === soloId); if (soloPoint) soloPoint.status = 'available'; }); } // Clean up timers const interval = timerIntervals.get(pointId); if (interval) { clearInterval(interval); timerIntervals.delete(pointId); } delete gameState.activeTimers[pointId]; delete gameState.pausedTimers[pointId]; gameState.customPoints = gameState.customPoints.filter(p => p.id !== pointId); addLogEntry(`Admin removed ${timer.type} (restored solo points)`); } else { // Regular point removal if (point.type === 'solo') { gameState.soloPoints = gameState.soloPoints.filter(p => p.id !== pointId); } else if (point.type === 'together') { gameState.togetherPoints = gameState.togetherPoints.filter(p => p.id !== pointId); } else { gameState.customPoints = gameState.customPoints.filter(p => p.id !== pointId); } // Clean up any timers const interval = timerIntervals.get(pointId); if (interval) { clearInterval(interval); timerIntervals.delete(pointId); } delete gameState.activeTimers[pointId]; delete gameState.pausedTimers[pointId]; addLogEntry(`Admin removed ${point.customName || point.type} point`); } io.emit('stateUpdate', gameState); }); // Expire timer socket.on('expireTimer', (data) => { const { pointId } = data; const timer = gameState.activeTimers[pointId] || gameState.pausedTimers[pointId]; if (!timer) return; // Clear existing interval const interval = timerIntervals.get(pointId); if (interval) { clearInterval(interval); timerIntervals.delete(pointId); } // Set to expire in 1 second timer.startTime = Date.now() - timer.duration + 1000; timer.remainingTime = 1000; gameState.activeTimers[pointId] = timer; delete gameState.pausedTimers[pointId]; const point = findPoint(pointId); if (point) point.status = 'active'; // Restart timer with 1 second startTimer(pointId, 1, timer.type); addLogEntry(`Admin expired ${timer.type || 'timer'}`); io.emit('stateUpdate', gameState); }); // Reset all points socket.on('resetAllPoints', () => { // Clear all intervals timerIntervals.forEach((interval) => clearInterval(interval)); timerIntervals.clear(); // Reset to initial state with 5 solo and 4 together points gameState.soloPoints = Array(5).fill(null).map((_, i) => ({ id: `solo_${Date.now()}_${i}`, type: 'solo', status: 'available', duration: CONFIG.timers.solo, label: '15m' })); gameState.togetherPoints = Array(4).fill(null).map((_, i) => ({ id: `together_${Date.now()}_${i}`, type: 'together', status: 'available', duration: CONFIG.timers.together, label: '1h' })); // Clear custom points gameState.customPoints = []; gameState.activeTimers = {}; gameState.pausedTimers = {}; addLogEntry('All points reset by admin'); io.emit('stateUpdate', gameState); }); // Reset daily limit socket.on('resetDailyLimit', () => { gameState.togetherUsedToday = 0; gameState.lastResetDate = new Date().toDateString(); addLogEntry('Daily together point limit reset by admin'); io.emit('stateUpdate', gameState); }); // Clear log socket.on('clearLog', () => { gameState.activityLog = []; addLogEntry('Activity log cleared by admin'); io.emit('stateUpdate', gameState); }); // Create custom timer socket.on('createCustomTimer', (data) => { const { name, hours, minutes, pointType } = data; const duration = (hours * 3600) + (minutes * 60); const label = hours > 0 ? `${hours}h${minutes > 0 ? minutes : ''}` : `${minutes}m`; const customPoint = { id: `custom_${Date.now()}`, type: pointType === 'custom' ? 'solo' : pointType, customType: pointType, customName: name, status: 'available', duration: duration, label: label }; gameState.customPoints.push(customPoint); addLogEntry(`Admin created custom timer: ${name} (${label})`); io.emit('stateUpdate', gameState); }); // Episode/Movie selection socket.on('confirmSelection', (data) => { const { selectedPoints, type } = data; // Mark selected points as used immediately selectedPoints.forEach(pointId => { const point = gameState.soloPoints.find(p => p.id === pointId); if (point) point.status = 'used'; }); const pointCount = selectedPoints.length; const durationText = type === 'episode' ? '30 minutes' : '2 hours'; addLogEntry(`Used ${pointCount} solo points for ${type} (${durationText})`); io.emit('stateUpdate', gameState); }); // Handle finish-up response socket.on('finishUpResponse', (data) => { const { pointId, accepted } = data; const point = findPoint(pointId); if (!point) return; if (accepted) { point.status = 'active'; startTimer(pointId, CONFIG.timers.finishUp, 'finishUp'); addLogEntry(`${point.customName || point.type} point completed - using 2min finish-up time`); } else { point.status = 'used'; addLogEntry(`${point.customName || point.type} point completed`); } io.emit('stateUpdate', gameState); }); }); // REST API endpoints app.get('/api/state', (req, res) => { res.json(gameState); }); app.get('/api/config', (req, res) => { res.json({ timers: CONFIG.timers, port: CONFIG.port }); }); // Scheduled tasks function scheduleResets() { // Check every minute for reset times setInterval(() => { const now = new Date(); const hours = now.getHours(); const minutes = now.getMinutes(); const day = now.getDay(); // 0 = Sunday, 1 = Monday, etc. // Daily reset at 00:01 if (hours === 0 && minutes === 1) { // Reset daily limit const today = new Date().toDateString(); if (gameState.lastResetDate !== today) { gameState.togetherUsedToday = 0; gameState.lastResetDate = today; addLogEntry('Daily together limit reset automatically'); io.emit('stateUpdate', gameState); saveGameState(); } } // Weekly reset on Monday at 00:01 if (day === 1 && hours === 0 && minutes === 1) { // Check if we haven't already reset this week const lastWeeklyReset = gameState.lastWeeklyReset || ''; const thisWeek = `${now.getFullYear()}-W${getWeekNumber(now)}`; if (lastWeeklyReset !== thisWeek) { // Reset to 5 solo and 4 together gameState.soloPoints = Array(5).fill(null).map((_, i) => ({ id: `solo_${Date.now()}_${i}`, type: 'solo', status: 'available', duration: CONFIG.timers.solo, label: '15m' })); gameState.togetherPoints = Array(4).fill(null).map((_, i) => ({ id: `together_${Date.now()}_${i}`, type: 'together', status: 'available', duration: CONFIG.timers.together, label: '1h' })); // Clear custom points and timers gameState.customPoints = []; gameState.activeTimers = {}; gameState.pausedTimers = {}; gameState.lastWeeklyReset = thisWeek; // Clear all timer intervals timerIntervals.forEach((interval) => clearInterval(interval)); timerIntervals.clear(); addLogEntry('Weekly points reset automatically (Monday 00:01)'); io.emit('stateUpdate', gameState); saveGameState(); } } }, 60000); // Check every minute } // Helper function to get week number function getWeekNumber(date) { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); } // Start server server.listen(CONFIG.port, () => { console.log(`Point Tracker Server running on port ${CONFIG.port}`); console.log(`WebSocket server ready for connections`); // Check day reset on startup checkDayReset(); // Start scheduled resets scheduleResets(); // Restore any active timers Object.entries(gameState.activeTimers).forEach(([pointId, timer]) => { const elapsed = Date.now() - timer.startTime; const remaining = timer.duration - elapsed; if (remaining > 0) { // Resume timer const point = findPoint(pointId); if (point) { startTimer(pointId, remaining / 1000, timer.type); console.log(`Restored active timer for ${pointId}`); } } else { // Timer expired while server was down handleTimerComplete(pointId); } }); }); // Graceful shutdown process.on('SIGTERM', () => { console.log('SIGTERM received, shutting down gracefully'); // Clear all timer intervals timerIntervals.forEach((interval) => clearInterval(interval)); // Save final state saveGameState(); server.close(() => { console.log('Server closed'); process.exit(0); }); }); export { app, server };