const { spawn, exec } = require('child_process'); const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); const EventEmitter = require('events'); const paths = require('./paths'); class ServerManager extends EventEmitter { constructor() { super(); this.serverProcess = null; this.isRunning = false; this.isReady = false; // Track if server is actually ready to accept connections this.startTime = null; this.logBuffer = []; this.maxLogLines = 1000; this.serverStats = { players: 0, uptime: 0, memoryUsage: 0, cpuUsage: 0 }; this.debugFileWatcher = null; this.lastDebugFilePosition = 0; this.lastStopTime = 0; // Track when we last stopped a server } async getServerStatus() { // Double-check if process is actually running when we think it is if (this.isRunning && this.serverProcess && this.serverProcess.pid) { try { // Use kill(pid, 0) to check if process exists without sending a signal process.kill(this.serverProcess.pid, 0); } catch (error) { // Process doesn't exist anymore - it was killed externally this.addLogLine('warning', 'Server process was terminated externally'); this.isRunning = false; this.isReady = false; this.serverProcess = null; this.startTime = null; // Reset player stats when server stops this.serverStats.players = 0; this.serverStats.memoryUsage = 0; this.serverStats.cpuUsage = 0; this.emit('exit', { code: null, signal: 'external' }); // Emit status change immediately this.emit('status', { isRunning: this.isRunning, isReady: this.isReady, uptime: 0, startTime: null, players: 0, memoryUsage: 0, cpuUsage: 0, processId: null }); } } // Only check for external servers if we truly have no server process at all // This prevents the state confusion that was causing managed servers to be marked as external // Also add a cooldown period after stopping a managed server to prevent race conditions const timeSinceLastStop = Date.now() - this.lastStopTime; const cooldownPeriod = 5000; // 5 seconds cooldown after stopping a server if (!this.serverProcess && timeSinceLastStop > cooldownPeriod) { const externalServer = await this.detectExternalLuantiServer(); if (externalServer) { console.log(`ServerManager: Detected truly external server (PID: ${externalServer.pid})`); this.isRunning = true; this.isReady = true; this.startTime = externalServer.startTime; // Try to get player data from debug log for external servers const playerData = await this.getExternalServerPlayerData(); this.serverStats.players = playerData.count; this.addLogLine('info', `Detected external Luanti server (PID: ${externalServer.pid}, World: ${externalServer.world})`); // Create a mock server process object for tracking this.serverProcess = { pid: externalServer.pid, external: true }; console.log('ServerManager: Set serverProcess.external = true'); // Start monitoring debug file for external server this.startDebugFileMonitoring(); } } return { isRunning: this.isRunning, isReady: this.isReady, uptime: this.isRunning && this.startTime ? Date.now() - this.startTime : 0, startTime: this.startTime, players: this.serverStats.players, memoryUsage: this.serverStats.memoryUsage, cpuUsage: this.serverStats.cpuUsage, processId: this.serverProcess?.pid || null }; } async startServer(worldName = null) { if (this.isRunning) { throw new Error('Server is already running'); } try { // Ensure minetest directory exists paths.ensureDirectories(); // Build command arguments const args = [ '--server', '--config', paths.configFile ]; if (worldName && worldName.trim() !== '') { if (!paths.isValidWorldName(worldName)) { throw new Error('Invalid world name'); } // Check if world exists const worldPath = paths.getWorldPath(worldName); try { await fs.access(worldPath); } catch (error) { throw new Error(`World "${worldName}" does not exist. Please create it first in the Worlds section.`); } // Read the world's game configuration const worldConfigPath = path.join(worldPath, 'world.mt'); try { const worldConfig = await fs.readFile(worldConfigPath, 'utf8'); const gameMatch = worldConfig.match(/gameid\s*=\s*(.+)/); if (gameMatch) { const gameId = gameMatch[1].trim(); args.push('--gameid', gameId); this.addLogLine('info', `Using game: ${gameId} for world: ${worldName}`); } } catch (error) { this.addLogLine('warning', `Could not read world config, using default game: ${error.message}`); } args.push('--world', worldPath); } else { // If no world specified, we need to create a default world or let the server create one this.addLogLine('info', 'Starting server without specifying a world. Server will use default world settings.'); } // Check if minetest/luanti executable exists const executableInfo = await this.findMinetestExecutable(); this.serverProcess = spawn(executableInfo.command, [...executableInfo.args, ...args], { cwd: paths.minetestDir, stdio: ['pipe', 'pipe', 'pipe'] }); this.isRunning = true; this.isReady = false; // Server started but not ready yet this.startTime = Date.now(); // Handle process events this.serverProcess.on('error', (error) => { this.emit('error', error); this.isRunning = false; this.isReady = false; this.serverProcess = null; }); this.serverProcess.on('exit', (code, signal) => { this.emit('exit', { code, signal }); this.isRunning = false; this.isReady = false; this.serverProcess = null; this.startTime = null; this.stopDebugFileMonitoring(); }); // Handle output streams this.serverProcess.stdout.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); lines.forEach(line => this.addLogLine('stdout', line)); this.parseServerStats(data.toString()); }); this.serverProcess.stderr.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); lines.forEach(line => this.addLogLine('stderr', line)); }); this.emit('started', { pid: this.serverProcess.pid }); // Start monitoring debug.txt file for server ready messages this.startDebugFileMonitoring(); return { success: true, pid: this.serverProcess.pid, message: `Server started successfully with PID ${this.serverProcess.pid}` }; } catch (error) { this.isRunning = false; this.isReady = false; this.serverProcess = null; throw error; } } async stopServer(force = false) { if (!this.isRunning || !this.serverProcess) { throw new Error('Server is not running'); } const pid = this.serverProcess.pid; const isExternal = this.serverProcess.external; console.log(`ServerManager: Stopping server (PID: ${pid}, External: ${isExternal}, Force: ${force})`); return new Promise((resolve, reject) => { let timeoutHandle = null; let resolved = false; const cleanup = (message) => { if (resolved) return; resolved = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } // Clean up state immediately this.cleanupServerState(); // Add a delay to prevent race conditions with external detection setTimeout(() => { resolve({ success: true, message }); }, 2000); }; // Set timeout for force kill timeoutHandle = setTimeout(() => { console.log(`ServerManager: Timeout reached, force-killing process tree for ${pid}`); this.killProcessTree(pid, () => { console.log(`ServerManager: Process tree killed for PID ${pid} (timeout)`); cleanup('Server force-stopped after timeout'); }); }, isExternal ? 3000 : 10000); // Set up exit handler for managed processes if (this.serverProcess.on && !isExternal) { this.serverProcess.on('exit', () => { console.log('ServerManager: Managed server process exited'); cleanup('Server stopped gracefully'); }); } // For external servers or force stop, kill immediately if (isExternal || force) { console.log(`ServerManager: Killing process tree for PID ${pid}`); this.killProcessTree(pid, () => { console.log(`ServerManager: Process tree killed for PID ${pid}`); cleanup('Server stopped'); }); } else { // Try graceful shutdown first for managed servers try { this.serverProcess.stdin.write('/shutdown\n'); console.log('ServerManager: Sent shutdown command to server'); } catch (stdinError) { console.warn('Failed to send shutdown command, using SIGTERM:', stdinError.message); this.killProcessTree(pid, () => { console.log(`ServerManager: Process tree killed for PID ${pid} (after stdin failure)`); cleanup('Server stopped'); }); } } }); } killProcessTree(pid, callback) { console.log(`ServerManager: Attempting to kill process tree for PID ${pid}`); const { spawn } = require('child_process'); // First, try to kill the process gracefully with SIGTERM this.killProcessRecursive(pid, 'TERM', (termSuccess) => { if (termSuccess) { console.log('ServerManager: Process terminated gracefully'); callback(); return; } // If SIGTERM didn't work, wait a moment and try SIGKILL console.log('ServerManager: SIGTERM failed, trying SIGKILL...'); setTimeout(() => { this.killProcessRecursive(pid, 'KILL', (killSuccess) => { if (killSuccess) { console.log('ServerManager: Process force killed'); } else { console.warn('ServerManager: Could not kill process'); } callback(); }); }, 2000); }); } killProcessRecursive(pid, signal, callback) { const { spawn } = require('child_process'); // Get all child processes first const pstree = spawn('pstree', ['-p', pid.toString()]); let pids = [pid]; let pstreeOutput = ''; pstree.stdout.on('data', (data) => { pstreeOutput += data.toString(); }); pstree.on('close', (code) => { // Extract all PIDs from pstree output const pidMatches = pstreeOutput.match(/\((\d+)\)/g); if (pidMatches) { const childPids = pidMatches.map(match => parseInt(match.slice(1, -1))); // Add child PIDs to our list, avoiding duplicates childPids.forEach(childPid => { if (!pids.includes(childPid)) { pids.push(childPid); } }); } console.log(`ServerManager: Found ${pids.length} processes to kill:`, pids); // Kill all processes in reverse order (children first, then parent) pids.reverse(); let processesKilled = 0; let processesTotal = pids.length; const killNextProcess = (index) => { if (index >= pids.length) { // All done console.log(`ServerManager: Killed ${processesKilled}/${processesTotal} processes`); callback(processesKilled > 0); return; } const currentPid = pids[index]; console.log(`ServerManager: Sending SIG${signal} to PID ${currentPid}`); try { process.kill(currentPid, `SIG${signal}`); processesKilled++; console.log(`ServerManager: Successfully sent SIG${signal} to PID ${currentPid}`); } catch (error) { console.log(`ServerManager: Could not kill PID ${currentPid}:`, error.message); } // Move to next process after a short delay setTimeout(() => killNextProcess(index + 1), 100); }; killNextProcess(0); }); pstree.on('error', (error) => { console.warn('ServerManager: pstree failed, falling back to simple kill:', error.message); // Fallback: just try to kill the main PID try { process.kill(pid, `SIG${signal}`); console.log(`ServerManager: Successfully sent SIG${signal} to PID ${pid} (fallback)`); callback(true); } catch (error) { console.log(`ServerManager: Could not kill PID ${pid}:`, error.message); callback(false); } }); } cleanupServerState() { console.log('ServerManager: Cleaning up server state'); this.isRunning = false; this.isReady = false; this.serverProcess = null; this.startTime = null; this.lastStopTime = Date.now(); // Record when we stopped the server this.stopDebugFileMonitoring(); // Reset player stats when server stops this.serverStats.players = 0; this.serverStats.memoryUsage = 0; this.serverStats.cpuUsage = 0; } async restartServer(worldName = null) { if (this.isRunning) { await this.stopServer(); // Wait a moment for clean shutdown await new Promise(resolve => setTimeout(resolve, 2000)); } return await this.startServer(worldName); } async findGamePath(gameId) { try { // Use the paths utility to find installed games const games = await paths.getInstalledGames(); const game = games.find(g => g.name === gameId); if (game) { return game.path; } return null; } catch (error) { this.addLogLine('warning', `Error finding game path for "${gameId}": ${error.message}`); return null; } } async findMinetestExecutable() { // Check different installation methods for Luanti const installationMethods = [ // System packages { command: 'luanti', args: [] }, { command: 'minetest', args: [] }, { command: 'minetestserver', args: [] }, // Flatpak { command: 'flatpak', args: ['run', 'org.luanti.luanti'] }, { command: 'flatpak', args: ['run', 'net.minetest.Minetest'] }, // Snap { command: 'snap', args: ['run', 'luanti'] }, { command: 'snap', args: ['run', 'minetest'] } ]; for (const method of installationMethods) { try { // Test if the command exists and works const testResult = await new Promise((resolve, reject) => { const { spawn } = require('child_process'); const testProcess = spawn(method.command, [...method.args, '--version'], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }); let stdout = ''; let stderr = ''; testProcess.stdout.on('data', (data) => { stdout += data.toString(); }); testProcess.stderr.on('data', (data) => { stderr += data.toString(); }); testProcess.on('close', (code) => { if ((code === 0 || code === null) && (stdout.includes('Luanti') || stdout.includes('Minetest'))) { resolve({ command: method.command, args: method.args, output: stdout }); } else { reject(new Error(`Command failed with code ${code}`)); } }); testProcess.on('error', (error) => { reject(error); }); }); const executableInfo = { command: testResult.command, args: testResult.args, fullCommand: testResult.args.length > 0 ? `${testResult.command} ${testResult.args.join(' ')}` : testResult.command }; this.addLogLine('info', `Found Luanti: ${executableInfo.fullCommand}`); return executableInfo; } catch (error) { // Continue to next method continue; } } // Provide detailed error message with setup instructions const errorMsg = `Luanti/Minetest not found or not working properly.\n\n` + `Please install Luanti using one of these methods:\n` + `• System package: sudo apt install luanti (Ubuntu/Debian)\n` + `• Flatpak: flatpak install flathub org.luanti.luanti\n` + `• Snap: sudo snap install luanti\n` + `• Download from: https://www.luanti.org/downloads/\n\n` + `This web interface requires Luanti to manage worlds and run servers.`; this.addLogLine('error', errorMsg); throw new Error(errorMsg); } async createWorld(worldName, gameId = 'minetest_game') { try { const executableInfo = await this.findMinetestExecutable(); const worldPath = paths.getWorldPath(worldName); // Create world using Luanti --world command // This will initialize the world properly with the correct structure const createWorldArgs = [ '--world', worldPath, '--gameid', gameId, '--server', '--go', // Skip main menu '--quiet' // Reduce output ]; this.addLogLine('info', `Creating world "${worldName}" with game "${gameId}"`); return new Promise((resolve, reject) => { const createProcess = spawn(executableInfo.command, [...executableInfo.args, ...createWorldArgs], { cwd: paths.minetestDir, stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000 // 30 second timeout }); let stdout = ''; let stderr = ''; createProcess.stdout.on('data', (data) => { stdout += data.toString(); }); createProcess.stderr.on('data', (data) => { stderr += data.toString(); }); // Stop the server after a short time - we just want to initialize the world // Complex games like VoxeLibre/MineCLone2 need more time to initialize const timeout = gameId === 'mineclone2' || gameId.includes('clone') || gameId.includes('voxel') ? 8000 : 3000; this.addLogLine('info', `Using ${timeout}ms timeout for game: ${gameId}`); setTimeout(() => { if (createProcess && !createProcess.killed) { createProcess.kill('SIGTERM'); } }, timeout); createProcess.on('close', (code) => { this.addLogLine('info', `World creation process finished with code: ${code}`); // Check if world directory was created successfully if (fsSync.existsSync(worldPath)) { this.addLogLine('info', `World "${worldName}" created successfully`); resolve({ success: true, worldPath }); } else { this.addLogLine('error', `World directory not created: ${worldPath}`); reject(new Error(`Failed to create world directory: ${worldPath}`)); } }); createProcess.on('error', (error) => { this.addLogLine('error', `World creation failed: ${error.message}`); reject(error); }); }); } catch (error) { this.addLogLine('error', `Failed to create world: ${error.message}`); throw error; } } addLogLine(type, content) { const timestamp = new Date().toISOString(); const logEntry = { timestamp, type, content: content.trim() }; this.logBuffer.push(logEntry); // Keep only the last N lines if (this.logBuffer.length > this.maxLogLines) { this.logBuffer = this.logBuffer.slice(-this.maxLogLines); } this.emit('log', logEntry); } parseServerStats(output) { // Parse server output for statistics and ready state const lines = output.split('\n'); for (const line of lines) { // Look for player count const playerMatch = line.match(/(\d+) players? online/i); if (playerMatch) { this.serverStats.players = parseInt(playerMatch[1]); } // Look for performance stats if available const memMatch = line.match(/Memory usage: ([\d.]+)MB/i); if (memMatch) { this.serverStats.memoryUsage = parseFloat(memMatch[1]); } // Check if server is ready - look for common Luanti server ready messages if (!this.isReady && this.isRunning) { const readyIndicators = [ /Server for gameid=".*?" listening on/i, /listening on \[::\]:\d+/i, /listening on 0\.0\.0\.0:\d+/i, /World at \[.*?\]/i, /Server started/i, /Loading environment/i ]; for (const indicator of readyIndicators) { if (indicator.test(line)) { this.isReady = true; this.addLogLine('info', 'Server is now ready to accept connections'); console.log(`Server ready detected from line: ${line}`); // Debug log // Emit status change when server becomes ready this.emit('status', { isRunning: this.isRunning, isReady: this.isReady, uptime: this.startTime ? Date.now() - this.startTime : 0, startTime: this.startTime, players: this.serverStats.players, memoryUsage: this.serverStats.memoryUsage, cpuUsage: this.serverStats.cpuUsage, processId: this.serverProcess?.pid || null }); break; } } // Also check for error conditions that indicate startup failure const errorIndicators = [ /ERROR\[Main\]:/i, /FATAL ERROR/i, /Could not find or load game/i, /Failed to/i ]; for (const errorIndicator of errorIndicators) { if (errorIndicator.test(line)) { // Don't mark as ready if we see critical errors this.addLogLine('warning', 'Server startup may have failed - check logs for errors'); break; } } } } this.emit('stats', this.serverStats); } getLogs(lines = 100) { return this.logBuffer.slice(-lines); } getRecentLogs(since = null) { if (!since) { return this.logBuffer.slice(-50); } const sinceTime = new Date(since); return this.logBuffer.filter(log => new Date(log.timestamp) > sinceTime ); } async sendCommand(command) { if (!this.isRunning || !this.serverProcess) { throw new Error('Server is not running'); } // Check if this is an external server if (this.serverProcess.external) { throw new Error('Cannot send commands to external servers. Commands can only be sent to servers started through this dashboard.'); } // Validate and sanitize command const sanitizedCommand = this.validateServerCommand(command); return new Promise((resolve, reject) => { try { this.serverProcess.stdin.write(sanitizedCommand + '\n'); this.addLogLine('info', `Command sent: ${sanitizedCommand}`); resolve({ success: true, message: 'Command sent successfully' }); } catch (error) { reject(error); } }); } validateServerCommand(command) { if (!command || typeof command !== 'string') { throw new Error('Command must be a non-empty string'); } // Remove any control characters and limit length const sanitized = command.replace(/[\x00-\x1F\x7F]/g, '').trim(); if (sanitized.length === 0) { throw new Error('Command cannot be empty after sanitization'); } if (sanitized.length > 500) { throw new Error('Command too long (max 500 characters)'); } // Whitelist of allowed command prefixes for safety const allowedCommands = [ '/say', '/tell', '/kick', '/ban', '/unban', '/status', '/time', '/weather', '/give', '/teleport', '/tp', '/spawn', '/help', '/list', '/who', '/shutdown', '/stop', '/save-all', '/whitelist', '/op', '/deop', '/gamemode', '/difficulty', '/seed', '/defaultgamemode', '/gamerule', '/reload', '/clear', '/experience', '/xp', '/effect', '/enchant', '/summon', '/kill', '/scoreboard', '/team', '/trigger', '/clone', '/execute', '/fill', '/setblock', '/testforblock', '/blockdata', '/entitydata', '/testfor', '/stats', '/worldborder' ]; // Check if command starts with allowed prefix or is a direct server command const isAllowed = allowedCommands.some(prefix => sanitized.toLowerCase().startsWith(prefix.toLowerCase()) ) || /^[a-zA-Z0-9_-]+(\s+[a-zA-Z0-9_.-]+)*$/.test(sanitized); if (!isAllowed) { throw new Error('Command not allowed or contains invalid characters'); } return sanitized; } async getServerInfo() { try { const configExists = await fs.access(paths.configFile).then(() => true).catch(() => false); const debugLogExists = await fs.access(paths.debugFile).then(() => true).catch(() => false); let configMtime = null; if (configExists) { const stats = await fs.stat(paths.configFile); configMtime = stats.mtime; } return { configFile: { exists: configExists, path: paths.configFile, lastModified: configMtime }, debugLog: { exists: debugLogExists, path: paths.debugFile }, directories: { minetest: paths.minetestDir, worlds: paths.worldsDir, mods: paths.modsDir } }; } catch (error) { throw new Error(`Failed to get server info: ${error.message}`); } } startDebugFileMonitoring() { const debugFilePath = path.join(paths.minetestDir, 'debug.txt'); try { // Get initial file size to start monitoring from the end const stats = fsSync.existsSync(debugFilePath) ? fsSync.statSync(debugFilePath) : null; this.lastDebugFilePosition = stats ? stats.size : 0; // Watch for changes to debug.txt this.debugFileWatcher = fsSync.watchFile(debugFilePath, { interval: 500 }, (current, previous) => { if (current.mtime > previous.mtime) { this.readDebugFileChanges(debugFilePath); } }); } catch (error) { this.addLogLine('warning', `Could not monitor debug.txt: ${error.message}`); } } stopDebugFileMonitoring() { if (this.debugFileWatcher) { const debugFilePath = path.join(paths.minetestDir, 'debug.txt'); fsSync.unwatchFile(debugFilePath); this.debugFileWatcher = null; } } async readDebugFileChanges(debugFilePath) { try { const stats = fsSync.statSync(debugFilePath); if (stats.size > this.lastDebugFilePosition) { const stream = fsSync.createReadStream(debugFilePath, { start: this.lastDebugFilePosition, end: stats.size - 1 }); let buffer = ''; stream.on('data', (chunk) => { buffer += chunk.toString(); }); stream.on('end', () => { const lines = buffer.split('\n').filter(line => line.trim()); lines.forEach(line => { this.addLogLine('debug-file', line); this.parseServerStats(line); // Parse each line for ready indicators // For external servers, also update player count from new log entries if (this.serverProcess?.external) { this.updatePlayerCountFromLogLine(line); } }); }); this.lastDebugFilePosition = stats.size; } } catch (error) { // Ignore errors when reading debug file changes } } updatePlayerCountFromLogLine(line) { // Update player count based on join/leave messages in log const joinMatch = line.match(/\[Server\]: (\w+) joined the game/); const leaveMatch = line.match(/\[Server\]: (\w+) left the game/); if (joinMatch || leaveMatch) { // Player joined or left - update player data this.getExternalServerPlayerData().then(playerData => { this.serverStats.players = playerData.count; }); } } async detectExternalLuantiServer() { try { const { spawn } = require('child_process'); return new Promise((resolve) => { const psProcess = spawn('ps', ['aux'], { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; psProcess.stdout.on('data', (data) => { stdout += data.toString(); }); psProcess.on('close', (code) => { if (code === 0) { const lines = stdout.split('\n'); for (const line of lines) { // Look for luanti or minetest server processes (exclude this dashboard process) if ((line.includes('luanti') || line.includes('minetest')) && (line.includes('--server') || line.includes('--worldname')) && !line.includes('node app.js')) { const parts = line.trim().split(/\s+/); const pid = parseInt(parts[1]); if (pid && !isNaN(pid)) { // Extract world name from command line let world = 'unknown'; const worldNameMatch = line.match(/--worldname\s+(\S+)/); const worldPathMatch = line.match(/--world\s+(\S+)/); if (worldNameMatch) { world = worldNameMatch[1]; } else if (worldPathMatch) { world = path.basename(worldPathMatch[1]); } // Estimate start time (this is rough, but better than nothing) const startTime = Date.now() - 60000; // Assume started 1 minute ago resolve({ pid: pid, world: world, startTime: startTime }); return; } } } } resolve(null); }); psProcess.on('error', () => { resolve(null); }); }); } catch (error) { return null; } } async getExternalServerPlayerData() { try { const fs = require('fs').promises; const debugFilePath = path.join(paths.minetestDir, 'debug.txt'); // Read the last 100 lines of debug.txt to find recent player activity const data = await fs.readFile(debugFilePath, 'utf8'); const lines = data.split('\n').slice(-100); // Look for recent player actions to determine who's online const playerData = new Map(); // Map to store player name -> player info const cutoffTime = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago (extended from 5) for (const line of lines.reverse()) { // Parse timestamp from log line const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}):/); if (timestampMatch) { const logTime = new Date(timestampMatch[1]); if (logTime < cutoffTime) break; // Stop looking at older entries // Look for player actions with more detail const actionPatterns = [ { pattern: /ACTION\[Server\]: (\w+) (.+)/, type: 'action' }, { pattern: /\[Server\]: (\w+) joined the game/, type: 'joined' }, { pattern: /\[Server\]: (\w+) left the game/, type: 'left' } ]; for (const { pattern, type } of actionPatterns) { const match = line.match(pattern); if (match && match[1]) { const playerName = match[1]; const actionDescription = match[2] || type; // Filter out obvious non-player names and false positives if (!playerName.includes('Entity') && !playerName.includes('SAO') && !playerName.includes('Explosion') && playerName !== 'Player' && // Generic "Player" is not a real username playerName !== 'Server' && playerName !== 'Main' && playerName.length > 2 && // Too short usernames are likely false positives playerName.length < 20 && /^[a-zA-Z0-9_]+$/.test(playerName)) { // Update player data with most recent activity if (!playerData.has(playerName) || logTime > playerData.get(playerName).lastSeen) { let lastAction = actionDescription; // Simplify common actions for display if (lastAction.includes('digs ')) { lastAction = 'Mining'; } else if (lastAction.includes('places ') || lastAction.includes('puts ')) { lastAction = 'Building'; } else if (lastAction.includes('uses ') || lastAction.includes('activates ')) { lastAction = 'Using items'; } else if (lastAction.includes('punched ') || lastAction.includes('damage')) { lastAction = 'Combat'; } else if (type === 'joined') { lastAction = 'Just joined'; } else if (type === 'left') { lastAction = 'Left game'; } else { lastAction = 'Active'; } // Count activities for this player const existingData = playerData.get(playerName) || { activityCount: 0 }; playerData.set(playerName, { name: playerName, lastSeen: logTime, lastAction: lastAction, activityCount: existingData.activityCount + 1, online: type !== 'left' // Mark as offline if they left }); } } else { } } } } } // Convert to array of player objects, filtering out players who left const players = Array.from(playerData.values()) .filter(player => player.online) .map(player => ({ name: player.name, lastSeen: player.lastSeen, lastAction: player.lastAction, activityCount: player.activityCount, online: true })); return { count: players.length, players: players }; } catch (error) { console.error('Error reading debug file for player data:', error); return { count: 0, players: [] }; } } } module.exports = ServerManager;