## 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>
379 lines
12 KiB
JavaScript
379 lines
12 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const sqlite3 = require('sqlite3');
|
|
const { promisify } = require('util');
|
|
|
|
const paths = require('../utils/paths');
|
|
const ConfigParser = require('../utils/config-parser');
|
|
const serverManager = require('../utils/shared-server-manager');
|
|
|
|
const router = express.Router();
|
|
|
|
// Worlds listing page
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
paths.ensureDirectories();
|
|
|
|
let worlds = [];
|
|
|
|
try {
|
|
const worldDirs = await fs.readdir(paths.worldsDir);
|
|
|
|
for (const worldDir of worldDirs) {
|
|
const worldPath = paths.getWorldPath(worldDir);
|
|
const configPath = paths.getWorldConfigPath(worldDir);
|
|
|
|
try {
|
|
const stats = await fs.stat(worldPath);
|
|
if (!stats.isDirectory()) continue;
|
|
|
|
const config = await ConfigParser.parseWorldConfig(configPath);
|
|
|
|
let playerCount = 0;
|
|
try {
|
|
const playersDbPath = path.join(worldPath, 'players.sqlite');
|
|
// 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';
|
|
const gameTitle = await paths.getGameDisplayName(gameid);
|
|
|
|
worlds.push({
|
|
name: worldDir,
|
|
displayName: config.server_name || worldDir,
|
|
description: config.server_description || '',
|
|
gameid: gameid,
|
|
gameTitle: gameTitle,
|
|
creativeMode: config.creative_mode || false,
|
|
enableDamage: config.enable_damage !== false,
|
|
enablePvp: config.enable_pvp !== false,
|
|
playerCount,
|
|
lastModified: stats.mtime,
|
|
size: stats.size
|
|
});
|
|
} catch (worldError) {
|
|
console.error(`Error reading world ${worldDir}:`, worldError);
|
|
}
|
|
}
|
|
} catch (dirError) {}
|
|
|
|
res.render('worlds/index', {
|
|
title: 'Worlds',
|
|
worlds: worlds,
|
|
currentPage: 'worlds'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting worlds:', error);
|
|
res.status(500).render('error', {
|
|
error: 'Failed to load worlds',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// New world page
|
|
router.get('/new', async (req, res) => {
|
|
try {
|
|
const games = await paths.getInstalledGames();
|
|
|
|
res.render('worlds/new', {
|
|
title: 'Create World',
|
|
currentPage: 'worlds',
|
|
games: games
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting games for new world:', error);
|
|
res.render('worlds/new', {
|
|
title: 'Create World',
|
|
currentPage: 'worlds',
|
|
games: [
|
|
{ name: 'minetest_game', title: 'Minetest Game (Default)', description: '' },
|
|
{ name: 'minimal', title: 'Minimal', description: '' }
|
|
],
|
|
error: 'Could not load installed games, showing defaults only.'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Create world
|
|
router.post('/create', async (req, res) => {
|
|
console.log('=== WORLD CREATION STARTED ===');
|
|
console.log('Request body:', req.body);
|
|
|
|
try {
|
|
const { name, gameid } = req.body;
|
|
|
|
console.log('Extracted name:', name, 'gameid:', gameid);
|
|
|
|
if (!paths.isValidWorldName(name)) {
|
|
return res.status(400).render('worlds/new', {
|
|
title: 'Create World',
|
|
currentPage: 'worlds',
|
|
error: 'Invalid world name. Only letters, numbers, underscore and hyphen allowed.',
|
|
formData: req.body
|
|
});
|
|
}
|
|
|
|
const worldPath = paths.getWorldPath(name);
|
|
|
|
try {
|
|
await fs.access(worldPath);
|
|
return res.status(409).render('worlds/new', {
|
|
title: 'Create World',
|
|
currentPage: 'worlds',
|
|
error: 'World already exists',
|
|
formData: req.body
|
|
});
|
|
} catch {}
|
|
|
|
console.log('Creating world:', name, 'with gameid:', gameid);
|
|
|
|
// 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:', name);
|
|
// Notify clients via WebSocket
|
|
const io = req.app.get('socketio');
|
|
if (io) {
|
|
io.emit('worldCreated', {
|
|
worldName: name,
|
|
gameId: finalGameId,
|
|
success: true
|
|
});
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error creating world:', error);
|
|
// Notify clients via WebSocket about the error
|
|
const io = req.app.get('socketio');
|
|
if (io) {
|
|
io.emit('worldCreated', {
|
|
worldName: name,
|
|
gameId: finalGameId,
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Respond immediately with a "creating" status
|
|
res.redirect('/worlds?creating=' + encodeURIComponent(name));
|
|
} catch (error) {
|
|
console.error('Error starting world creation:', error);
|
|
res.status(500).render('worlds/new', {
|
|
title: 'Create World',
|
|
currentPage: 'worlds',
|
|
error: 'Failed to start world creation: ' + error.message,
|
|
formData: req.body
|
|
});
|
|
}
|
|
});
|
|
|
|
// World details page
|
|
router.get('/:worldName', async (req, res) => {
|
|
try {
|
|
const { worldName } = req.params;
|
|
|
|
if (!paths.isValidWorldName(worldName)) {
|
|
return res.status(400).render('error', {
|
|
error: 'Invalid world name'
|
|
});
|
|
}
|
|
|
|
const worldPath = paths.getWorldPath(worldName);
|
|
const configPath = paths.getWorldConfigPath(worldName);
|
|
|
|
try {
|
|
await fs.access(worldPath);
|
|
} catch {
|
|
return res.status(404).render('error', {
|
|
error: 'World not found'
|
|
});
|
|
}
|
|
|
|
const config = await ConfigParser.parseWorldConfig(configPath);
|
|
const stats = await fs.stat(worldPath);
|
|
|
|
let worldSize = 0;
|
|
try {
|
|
const mapDbPath = path.join(worldPath, 'map.sqlite');
|
|
const mapStats = await fs.stat(mapDbPath);
|
|
worldSize = mapStats.size;
|
|
} 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);
|
|
for (const modDir of modDirs) {
|
|
const modConfigPath = path.join(worldModsPath, modDir, 'mod.conf');
|
|
try {
|
|
const modConfig = await ConfigParser.parseModConfig(modConfigPath);
|
|
enabledMods.push({
|
|
name: modDir,
|
|
title: modConfig.title || modDir,
|
|
description: modConfig.description || '',
|
|
author: modConfig.author || '',
|
|
location: 'world-installed',
|
|
path: path.join(worldModsPath, modDir)
|
|
});
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
const worldDetails = {
|
|
name: worldName,
|
|
displayName: config.server_name || worldName,
|
|
description: config.server_description || '',
|
|
gameid: config.gameid || 'minetest_game',
|
|
creativeMode: config.creative_mode || false,
|
|
enableDamage: config.enable_damage !== false,
|
|
enablePvp: config.enable_pvp !== false,
|
|
serverAnnounce: config.server_announce || false,
|
|
worldSize,
|
|
created: stats.birthtime,
|
|
lastModified: stats.mtime,
|
|
enabledMods,
|
|
config: config
|
|
};
|
|
|
|
res.render('worlds/details', {
|
|
title: `World: ${worldDetails.displayName}`,
|
|
world: worldDetails,
|
|
currentPage: 'worlds'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting world details:', error);
|
|
res.status(500).render('error', {
|
|
error: 'Failed to load world details',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Update world
|
|
router.post('/:worldName/update', async (req, res) => {
|
|
try {
|
|
const { worldName } = req.params;
|
|
const updates = req.body;
|
|
|
|
if (!paths.isValidWorldName(worldName)) {
|
|
return res.status(400).json({ error: 'Invalid world name' });
|
|
}
|
|
|
|
const worldPath = paths.getWorldPath(worldName);
|
|
const configPath = paths.getWorldConfigPath(worldName);
|
|
|
|
try {
|
|
await fs.access(worldPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'World not found' });
|
|
}
|
|
|
|
const currentConfig = await ConfigParser.parseWorldConfig(configPath);
|
|
|
|
// Convert form data
|
|
const updatedConfig = {
|
|
...currentConfig,
|
|
server_name: updates.displayName || currentConfig.server_name,
|
|
server_description: updates.description || currentConfig.server_description,
|
|
creative_mode: updates.creativeMode === 'on',
|
|
enable_damage: updates.enableDamage !== 'off',
|
|
enable_pvp: updates.enablePvp !== 'off',
|
|
server_announce: updates.serverAnnounce === 'on'
|
|
};
|
|
|
|
await ConfigParser.writeWorldConfig(configPath, updatedConfig);
|
|
|
|
res.redirect(`/worlds/${worldName}?updated=true`);
|
|
} catch (error) {
|
|
console.error('Error updating world:', error);
|
|
res.status(500).json({ error: 'Failed to update world' });
|
|
}
|
|
});
|
|
|
|
// Delete world
|
|
router.post('/:worldName/delete', async (req, res) => {
|
|
try {
|
|
const { worldName } = req.params;
|
|
|
|
if (!paths.isValidWorldName(worldName)) {
|
|
return res.status(400).json({ error: 'Invalid world name' });
|
|
}
|
|
|
|
const worldPath = paths.getWorldPath(worldName);
|
|
|
|
try {
|
|
await fs.access(worldPath);
|
|
} catch {
|
|
return res.status(404).json({ error: 'World not found' });
|
|
}
|
|
|
|
// Deletion confirmed by frontend confirmation dialog
|
|
|
|
await fs.rm(worldPath, { recursive: true, force: true });
|
|
|
|
res.redirect('/worlds?deleted=' + encodeURIComponent(worldName));
|
|
} catch (error) {
|
|
console.error('Error deleting world:', error);
|
|
res.status(500).json({ error: 'Failed to delete world' });
|
|
}
|
|
});
|
|
|
|
module.exports = router; |