Initial commit: LuHost - Luanti Server Management Web Interface
A modern web interface for Luanti (Minetest) server management with ContentDB integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
768
utils/server-manager.js
Normal file
768
utils/server-manager.js
Normal file
@@ -0,0 +1,768 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Always check for externally running Luanti servers if we don't have a running one
|
||||
if (!this.isRunning) {
|
||||
const externalServer = await this.detectExternalLuantiServer();
|
||||
if (externalServer) {
|
||||
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 executable = await this.findMinetestExecutable();
|
||||
|
||||
this.serverProcess = spawn(executable, 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');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.serverProcess && this.isRunning) {
|
||||
// Force kill if graceful shutdown failed
|
||||
this.serverProcess.kill('SIGKILL');
|
||||
resolve({ success: true, message: 'Server force-stopped' });
|
||||
}
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
this.serverProcess.on('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ success: true, message: 'Server stopped gracefully' });
|
||||
});
|
||||
|
||||
// Try graceful shutdown first
|
||||
if (force) {
|
||||
this.serverProcess.kill('SIGTERM');
|
||||
} else {
|
||||
// Send shutdown command to server
|
||||
try {
|
||||
this.serverProcess.stdin.write('/shutdown\n');
|
||||
} catch (error) {
|
||||
// If stdin fails, use SIGTERM
|
||||
this.serverProcess.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
// Whitelist of allowed executable names to prevent command injection
|
||||
const allowedExecutables = ['luanti', 'minetest', 'minetestserver'];
|
||||
const foundExecutables = [];
|
||||
|
||||
for (const name of allowedExecutables) {
|
||||
try {
|
||||
// Validate executable name against whitelist
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const execPath = await new Promise((resolve, reject) => {
|
||||
// Use spawn instead of exec to avoid command injection
|
||||
const { spawn } = require('child_process');
|
||||
const whichProcess = spawn('which', [name], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
whichProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
whichProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
whichProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(new Error(`which command failed: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
whichProcess.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Validate that the returned path is safe
|
||||
if (execPath && path.isAbsolute(execPath)) {
|
||||
foundExecutables.push({ name, path: execPath });
|
||||
this.addLogLine('info', `Found executable: ${name} at ${execPath}`);
|
||||
return execPath; // Return the full path for security
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue to next possibility
|
||||
}
|
||||
}
|
||||
|
||||
// Provide detailed error message
|
||||
const errorMsg = `Minetest/Luanti executable not found. Please install Luanti or add it to your PATH.\n` +
|
||||
`Searched for: ${allowedExecutables.join(', ')}\n` +
|
||||
`Try: sudo apt install luanti (Ubuntu/Debian) or your system's package manager`;
|
||||
|
||||
this.addLogLine('error', errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
console.log('DEBUG: Looking for players active since:', cutoffTime.toISOString());
|
||||
|
||||
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;
|
||||
|
||||
console.log('DEBUG: Found potential player:', playerName, 'action:', actionDescription);
|
||||
|
||||
// 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)) {
|
||||
|
||||
console.log('DEBUG: Player passed filters:', 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 {
|
||||
console.log('DEBUG: Player filtered out:', playerName, 'reason: failed validation');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
Reference in New Issue
Block a user