Implement configuration-based mod management and fix navigation spacing
## Major Features Added
### Configuration-Based Mod Management
- Implement proper Luanti mod system using load_mod_* entries in world.mt
- Add mod enable/disable via configuration instead of file copying
- Support both global mods (config-enabled) and world mods (physically installed)
- Clear UI distinction with badges: "Global (Enabled)", "World Copy", "Missing"
- Automatic registry verification to sync database with filesystem state
### Game ID Alias System
- Fix minetest_game/minetest technical debt with proper alias mapping
- Map minetest_game → minetest for world.mt files (matches Luanti internal behavior)
- Reference: c9d4c33174/src/content/subgames.cpp (L21)
### Navigation Improvements
- Fix navigation menu spacing and text overflow issues
- Change "Configuration" to "Config" for better fit
- Implement responsive font sizing with clamp() for better scaling
- Even distribution of nav buttons across full width
### Package Registry Enhancements
- Add verifyAndCleanRegistry() to automatically remove stale package entries
- Periodic verification (every 5 minutes) to keep registry in sync with filesystem
- Fix "already installed" errors for manually deleted packages
- Integration across dashboard, ContentDB, and installation workflows
## Technical Improvements
### Mod System Architecture
- Enhanced ConfigParser to handle load_mod_* entries in world.mt files
- Support for both configuration-based and file-based mod installations
- Proper mod type detection and management workflows
- Updated world details to show comprehensive mod information
### UI/UX Enhancements
- Responsive navigation with proper text scaling
- Improved mod management interface with clear action buttons
- Better visual hierarchy and status indicators
- Enhanced error handling and user feedback
### Code Quality
- Clean up gitignore to properly exclude runtime files
- Add package-lock.json for consistent dependency management
- Remove excess runtime database and log files
- Add .claude/ directory to gitignore
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,15 @@ class ConfigParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mod configurations
|
||||
config.enabled_mods = {};
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key.startsWith('load_mod_')) {
|
||||
const modName = key.replace('load_mod_', '');
|
||||
config.enabled_mods[modName] = value === 'true';
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -98,6 +107,14 @@ class ConfigParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mod configurations
|
||||
if (configCopy.enabled_mods) {
|
||||
for (const [modName, enabled] of Object.entries(configCopy.enabled_mods)) {
|
||||
configCopy[`load_mod_${modName}`] = enabled.toString();
|
||||
}
|
||||
delete configCopy.enabled_mods; // Remove the helper object
|
||||
}
|
||||
|
||||
await this.writeConfig(filePath, configCopy);
|
||||
}
|
||||
|
||||
|
@@ -7,6 +7,8 @@ class PackageRegistry {
|
||||
// If no dbPath provided, we'll set it during init based on current data directory
|
||||
this.dbPath = dbPath;
|
||||
this.db = null;
|
||||
this.lastVerificationTime = 0;
|
||||
this.verificationInterval = 5 * 60 * 1000; // 5 minutes
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -277,6 +279,83 @@ class PackageRegistry {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async verifyAndCleanRegistry() {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
const fs = require('fs').promises;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all('SELECT * FROM installed_packages', async (err, rows) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const toRemove = [];
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
// Check if the package still exists at the recorded path
|
||||
await fs.access(row.install_path);
|
||||
|
||||
// Additional verification for games/mods - check for key files
|
||||
if (row.package_type === 'game') {
|
||||
// Check for game.conf
|
||||
const gameConfPath = require('path').join(row.install_path, 'game.conf');
|
||||
await fs.access(gameConfPath);
|
||||
} else if (row.package_type === 'mod') {
|
||||
// Check for mod.conf or init.lua
|
||||
const modConfPath = require('path').join(row.install_path, 'mod.conf');
|
||||
const initLuaPath = require('path').join(row.install_path, 'init.lua');
|
||||
try {
|
||||
await fs.access(modConfPath);
|
||||
} catch {
|
||||
await fs.access(initLuaPath);
|
||||
}
|
||||
}
|
||||
} catch (accessError) {
|
||||
// Package directory or key files don't exist - mark for removal
|
||||
console.log(`Package registry cleanup: Removing stale entry for ${row.author}/${row.name} (path not found: ${row.install_path})`);
|
||||
toRemove.push(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale entries
|
||||
if (toRemove.length > 0) {
|
||||
const placeholders = toRemove.map(() => '?').join(',');
|
||||
this.db.run(`DELETE FROM installed_packages WHERE id IN (${placeholders})`, toRemove, (deleteErr) => {
|
||||
if (deleteErr) {
|
||||
console.error('Error cleaning up registry:', deleteErr);
|
||||
reject(deleteErr);
|
||||
} else {
|
||||
console.log(`Package registry cleanup: Removed ${toRemove.length} stale entries`);
|
||||
resolve(toRemove.length);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async verifyIfNeeded() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastVerificationTime > this.verificationInterval) {
|
||||
try {
|
||||
const cleaned = await this.verifyAndCleanRegistry();
|
||||
this.lastVerificationTime = now;
|
||||
return cleaned;
|
||||
} catch (error) {
|
||||
console.warn('Periodic registry verification failed:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PackageRegistry;
|
@@ -184,13 +184,33 @@ class LuantiPaths {
|
||||
// Map directory names to the actual game IDs that Luanti recognizes
|
||||
// For most cases, the directory name IS the game ID
|
||||
const gameIdMap = {
|
||||
// Only add mappings here if you're certain they're needed
|
||||
// 'minetest_game': 'minetest', // This mapping was incorrect
|
||||
// Luanti internal alias mapping - see https://github.com/luanti-org/luanti/blob/c9d4c33174c87ede1f49c5fe5e8e49a784798eb6/src/content/subgames.cpp#L21
|
||||
'minetest_game': 'minetest',
|
||||
};
|
||||
|
||||
return gameIdMap[directoryName] || directoryName;
|
||||
}
|
||||
|
||||
mapGameIdForWorldCreation(gameId) {
|
||||
// When creating worlds, map game IDs that Luanti expects to use different IDs internally
|
||||
// This is the reverse of directory-based detection - we're setting what goes in world.mt
|
||||
const worldGameIdMap = {
|
||||
'minetest_game': 'minetest', // Luanti expects 'minetest' in world.mt even for minetest_game
|
||||
};
|
||||
|
||||
return worldGameIdMap[gameId] || gameId;
|
||||
}
|
||||
|
||||
mapInternalGameIdToDirectory(internalGameId) {
|
||||
// Reverse mapping: convert internal game ID back to directory name for display/reference
|
||||
// This helps when we read a world.mt with "minetest" but want to show "Minetest Game"
|
||||
const reverseGameIdMap = {
|
||||
'minetest': 'minetest_game', // world.mt has 'minetest' but directory is 'minetest_game'
|
||||
};
|
||||
|
||||
return reverseGameIdMap[internalGameId] || internalGameId;
|
||||
}
|
||||
|
||||
async getInstalledGames() {
|
||||
const games = [];
|
||||
// For Flatpak and other sandboxed installations, only look in the configured data directory
|
||||
|
@@ -307,6 +307,7 @@ class ServerManager extends EventEmitter {
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
killProcessRecursive(pid, signal, callback) {
|
||||
const { spawn } = require('child_process');
|
||||
@@ -502,68 +503,39 @@ class ServerManager extends EventEmitter {
|
||||
|
||||
async createWorld(worldName, gameId = 'minetest_game') {
|
||||
try {
|
||||
const executableInfo = await this.findMinetestExecutable();
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
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);
|
||||
});
|
||||
});
|
||||
// Map game ID for world creation (e.g., minetest_game -> minetest)
|
||||
const worldGameId = paths.mapGameIdForWorldCreation(gameId);
|
||||
if (worldGameId !== gameId) {
|
||||
this.addLogLine('info', `Mapping game ID: ${gameId} -> ${worldGameId} for world.mt`);
|
||||
}
|
||||
|
||||
// Create world directory
|
||||
await fs.mkdir(worldPath, { recursive: true });
|
||||
|
||||
// Create only world.mt file - let Luanti create everything else
|
||||
const worldMtContent = `enable_damage = true
|
||||
creative_mode = false
|
||||
mod_storage_backend = sqlite3
|
||||
auth_backend = sqlite3
|
||||
player_backend = sqlite3
|
||||
backend = sqlite3
|
||||
gameid = ${worldGameId}
|
||||
world_name = ${worldName}
|
||||
server_announce = false
|
||||
`;
|
||||
|
||||
const worldMtPath = path.join(worldPath, 'world.mt');
|
||||
await fs.writeFile(worldMtPath, worldMtContent, 'utf8');
|
||||
|
||||
this.addLogLine('info', `World "${worldName}" created successfully`);
|
||||
|
||||
return { success: true, worldPath };
|
||||
|
||||
} catch (error) {
|
||||
this.addLogLine('error', `Failed to create world: ${error.message}`);
|
||||
|
Reference in New Issue
Block a user