init
This commit is contained in:
625
server.js
Normal file
625
server.js
Normal file
@@ -0,0 +1,625 @@
|
||||
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 };
|
Reference in New Issue
Block a user