Files
pointtrackerv2/server.js
2025-05-30 01:50:43 -04:00

702 lines
21 KiB
JavaScript

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 };