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:
@@ -280,6 +280,13 @@ router.get('/updates', async (req, res) => {
|
||||
// View installed packages
|
||||
router.get('/installed', async (req, res) => {
|
||||
try {
|
||||
// Verify and clean registry before showing installed packages
|
||||
try {
|
||||
await packageRegistry.verifyIfNeeded();
|
||||
} catch (verifyError) {
|
||||
console.warn('Registry verification failed:', verifyError);
|
||||
}
|
||||
|
||||
const { location } = req.query;
|
||||
const packages = await packageRegistry.getInstalledPackages(location);
|
||||
const stats = await packageRegistry.getStatistics();
|
||||
@@ -384,6 +391,14 @@ router.post('/install-url', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify registry and clean up stale entries before checking installation status
|
||||
try {
|
||||
await packageRegistry.verifyIfNeeded();
|
||||
} catch (verifyError) {
|
||||
console.warn('Registry verification failed:', verifyError);
|
||||
// Continue anyway - don't block installation
|
||||
}
|
||||
|
||||
// Check if already installed at this location
|
||||
let installLocationKey;
|
||||
if (packageType === 'game') {
|
||||
|
@@ -121,8 +121,52 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Install mod to world
|
||||
router.post('/install/:worldName/:modName', async (req, res) => {
|
||||
// Enable mod for world (configuration-based)
|
||||
router.post('/enable/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
||||
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
||||
return res.status(400).json({ error: 'Invalid world or mod name' });
|
||||
}
|
||||
|
||||
const worldPath = paths.getWorldPath(worldName);
|
||||
const worldConfigPath = paths.getWorldConfigPath(worldName);
|
||||
const globalModPath = paths.getModPath(modName);
|
||||
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'World not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(globalModPath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Mod not found' });
|
||||
}
|
||||
|
||||
// Read current world configuration
|
||||
const config = await ConfigParser.parseWorldConfig(worldConfigPath);
|
||||
|
||||
// Enable the mod
|
||||
if (!config.enabled_mods) {
|
||||
config.enabled_mods = {};
|
||||
}
|
||||
config.enabled_mods[modName] = true;
|
||||
|
||||
// Write updated configuration
|
||||
await ConfigParser.writeWorldConfig(worldConfigPath, config);
|
||||
|
||||
res.redirect(`/mods?world=${worldName}&enabled=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error enabling mod for world:', error);
|
||||
res.status(500).json({ error: 'Failed to enable mod for world' });
|
||||
}
|
||||
});
|
||||
|
||||
// Copy mod to world (physical installation)
|
||||
router.post('/copy/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
||||
@@ -149,20 +193,49 @@ router.post('/install/:worldName/:modName', async (req, res) => {
|
||||
|
||||
try {
|
||||
await fs.access(targetModPath);
|
||||
return res.status(409).json({ error: 'Mod already installed in world' });
|
||||
return res.status(409).json({ error: 'Mod already copied to world' });
|
||||
} catch {}
|
||||
|
||||
await fs.mkdir(worldModsPath, { recursive: true });
|
||||
await fs.cp(globalModPath, targetModPath, { recursive: true });
|
||||
|
||||
res.redirect(`/mods?world=${worldName}&installed=${modName}`);
|
||||
res.redirect(`/mods?world=${worldName}&copied=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error installing mod to world:', error);
|
||||
res.status(500).json({ error: 'Failed to install mod to world' });
|
||||
console.error('Error copying mod to world:', error);
|
||||
res.status(500).json({ error: 'Failed to copy mod to world' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove mod from world
|
||||
// Disable mod from world (configuration-based)
|
||||
router.post('/disable/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
||||
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
|
||||
return res.status(400).json({ error: 'Invalid world or mod name' });
|
||||
}
|
||||
|
||||
const worldConfigPath = paths.getWorldConfigPath(worldName);
|
||||
|
||||
// Read current world configuration
|
||||
const config = await ConfigParser.parseWorldConfig(worldConfigPath);
|
||||
|
||||
// Disable the mod
|
||||
if (config.enabled_mods && config.enabled_mods[modName]) {
|
||||
config.enabled_mods[modName] = false;
|
||||
}
|
||||
|
||||
// Write updated configuration
|
||||
await ConfigParser.writeWorldConfig(worldConfigPath, config);
|
||||
|
||||
res.redirect(`/mods?world=${worldName}&disabled=${modName}`);
|
||||
} catch (error) {
|
||||
console.error('Error disabling mod for world:', error);
|
||||
res.status(500).json({ error: 'Failed to disable mod for world' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove mod from world (delete physical copy)
|
||||
router.post('/remove/:worldName/:modName', async (req, res) => {
|
||||
try {
|
||||
const { worldName, modName } = req.params;
|
||||
|
@@ -33,11 +33,17 @@ router.get('/', async (req, res) => {
|
||||
let playerCount = 0;
|
||||
try {
|
||||
const playersDbPath = path.join(worldPath, 'players.sqlite');
|
||||
const db = new sqlite3.Database(playersDbPath);
|
||||
const all = promisify(db.all.bind(db));
|
||||
const result = await all('SELECT COUNT(*) as count FROM players');
|
||||
playerCount = result[0]?.count || 0;
|
||||
db.close();
|
||||
// Only try to read if the database file already exists and has content
|
||||
if (fs.existsSync(playersDbPath)) {
|
||||
const stats = fs.statSync(playersDbPath);
|
||||
if (stats.size > 0) {
|
||||
const db = new sqlite3.Database(playersDbPath, sqlite3.OPEN_READONLY);
|
||||
const all = promisify(db.all.bind(db));
|
||||
const result = await all('SELECT COUNT(*) as count FROM players');
|
||||
playerCount = result[0]?.count || 0;
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
} catch (dbError) {}
|
||||
|
||||
const gameid = config.gameid || 'minetest_game';
|
||||
@@ -131,18 +137,29 @@ router.post('/create', async (req, res) => {
|
||||
});
|
||||
} catch {}
|
||||
|
||||
console.log('Starting world creation for:', name, 'with gameid:', gameid);
|
||||
console.log('Creating world:', name, 'with gameid:', gameid);
|
||||
|
||||
// Start world creation asynchronously and respond immediately
|
||||
serverManager.createWorld(name, gameid || 'minetest_game')
|
||||
// Get the default game if none specified
|
||||
let finalGameId = gameid;
|
||||
if (!finalGameId) {
|
||||
try {
|
||||
const games = await paths.getInstalledGames();
|
||||
finalGameId = games.length > 0 ? games[0].name : 'minetest_game';
|
||||
} catch (error) {
|
||||
finalGameId = 'minetest_game';
|
||||
}
|
||||
}
|
||||
|
||||
// Start world creation and redirect immediately with creating status
|
||||
serverManager.createWorld(name, finalGameId)
|
||||
.then(() => {
|
||||
console.log('Successfully created world using Luanti:', name);
|
||||
console.log('Successfully created world:', name);
|
||||
// Notify clients via WebSocket
|
||||
const io = req.app.get('socketio');
|
||||
if (io) {
|
||||
io.emit('worldCreated', {
|
||||
worldName: name,
|
||||
gameId: gameid || 'minetest_game',
|
||||
gameId: finalGameId,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
@@ -154,7 +171,7 @@ router.post('/create', async (req, res) => {
|
||||
if (io) {
|
||||
io.emit('worldCreated', {
|
||||
worldName: name,
|
||||
gameId: gameid || 'minetest_game',
|
||||
gameId: finalGameId,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
@@ -207,6 +224,40 @@ router.get('/:worldName', async (req, res) => {
|
||||
} catch {}
|
||||
|
||||
let enabledMods = [];
|
||||
|
||||
// Get configuration-enabled mods (from global mods directory)
|
||||
if (config.enabled_mods) {
|
||||
for (const [modName, enabled] of Object.entries(config.enabled_mods)) {
|
||||
if (enabled) {
|
||||
try {
|
||||
const globalModPath = paths.getModPath(modName);
|
||||
const globalModConfigPath = paths.getModConfigPath(modName);
|
||||
await fs.access(globalModPath);
|
||||
const modConfig = await ConfigParser.parseModConfig(globalModConfigPath);
|
||||
enabledMods.push({
|
||||
name: modName,
|
||||
title: modConfig.title || modName,
|
||||
description: modConfig.description || '',
|
||||
author: modConfig.author || '',
|
||||
location: 'global-enabled',
|
||||
path: globalModPath
|
||||
});
|
||||
} catch {
|
||||
// Global mod not found, but still enabled in config - show as missing
|
||||
enabledMods.push({
|
||||
name: modName,
|
||||
title: modName,
|
||||
description: 'Missing global mod',
|
||||
author: '',
|
||||
location: 'global-missing',
|
||||
path: null
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get physically installed world mods
|
||||
try {
|
||||
const worldModsPath = paths.getWorldModsPath(worldName);
|
||||
const modDirs = await fs.readdir(worldModsPath);
|
||||
@@ -219,7 +270,8 @@ router.get('/:worldName', async (req, res) => {
|
||||
title: modConfig.title || modDir,
|
||||
description: modConfig.description || '',
|
||||
author: modConfig.author || '',
|
||||
location: 'world'
|
||||
location: 'world-installed',
|
||||
path: path.join(worldModsPath, modDir)
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
Reference in New Issue
Block a user