625 lines
18 KiB
JavaScript
625 lines
18 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: 3000
|
|
};
|
|
|
|
// 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(),
|
|
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
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
|
|
// 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 }; |