Compare commits

..

2 Commits

Author SHA1 Message Date
Nathan Schneider
09c4f93ec9 Include .claude directory for development continuity
The .claude/settings.local.json file contains project-specific Claude Code
permissions and settings that are valuable for future development sessions.

Benefits:
- Avoids need to reconfigure permissions for Luanti world/game access
- Maintains established security boundaries
- Speeds up development workflow continuity
- Preserves project-specific AI assistant configuration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-08 10:39:00 -06:00
Nathan Schneider
8aadab1b50 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>
2025-09-08 10:31:08 -06:00
12 changed files with 4047 additions and 92 deletions

7
.gitignore vendored
View File

@@ -50,6 +50,9 @@ Thumbs.db
*.swo
*~
# Claude Code specific files (keep settings for future development)
# .claude/
# Temporary files
tmp/
temp/
@@ -75,8 +78,8 @@ backups/
*.crt
*.csr
# Lock files
package-lock.json
# Lock files (keep package-lock.json for consistent installs)
# package-lock.json
yarn.lock
# Coverage directory used by tools like istanbul

8
app.js
View File

@@ -204,6 +204,14 @@ app.get('/', requireAuth, async (req, res) => {
try {
paths.ensureDirectories();
// Verify and clean package registry
try {
const packageRegistry = require('./utils/package-registry');
await packageRegistry.verifyIfNeeded();
} catch (registryError) {
console.warn('Registry verification failed on dashboard load:', registryError);
}
// Get basic stats for dashboard
const fs = require('fs').promises;
let worldCount = 0;

3707
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -144,18 +144,22 @@ body {
.nav-item {
flex: 1;
min-width: 140px;
min-width: 0;
}
.nav-link {
display: block;
padding: 16px 20px;
padding: 14px 16px;
text-decoration: none;
color: var(--text-light);
font-weight: bold;
text-align: center;
text-transform: uppercase;
letter-spacing: 1px;
letter-spacing: 0.5px;
font-size: clamp(0.75rem, 1.2vw, 0.9rem);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: all 0.1s ease;
border-right: var(--border-width) solid var(--border-dark);
background: linear-gradient(180deg,
@@ -679,14 +683,11 @@ body {
}
/* Responsive design */
@media (max-width: 1024px) and (min-width: 769px) {
.nav-item {
min-width: 160px;
}
@media (max-width: 1200px) {
.nav-link {
padding: 12px 16px;
font-size: 0.85rem;
padding: 12px 8px;
font-size: clamp(0.7rem, 1.1vw, 0.85rem);
letter-spacing: 0.3px;
}
}

View File

@@ -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') {

View File

@@ -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;

View File

@@ -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);
// 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 {}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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

View File

@@ -308,6 +308,7 @@ class ServerManager extends EventEmitter {
});
}
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');
// 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`);
}
}, timeout);
createProcess.on('close', (code) => {
this.addLogLine('info', `World creation process finished with code: ${code}`);
// 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');
// 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);
});
});
return { success: true, worldPath };
} catch (error) {
this.addLogLine('error', `Failed to create world: ${error.message}`);

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> | LuHost</title>
<% if (typeof csrfToken !== 'undefined' && csrfToken) { %>
<meta name="csrf-token" content="<%= csrfToken %>">
<% } %>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
</head>
@@ -48,6 +51,11 @@
Worlds
</a>
</div>
<div class="nav-item">
<a href="/mods" class="nav-link <%= currentPage === 'mods' ? 'active' : '' %>">
Mods
</a>
</div>
<div class="nav-item">
<a href="/extensions" class="nav-link <%= currentPage === 'extensions' ? 'active' : '' %>">
Extensions
@@ -60,7 +68,7 @@
</div>
<div class="nav-item">
<a href="/config" class="nav-link <%= currentPage === 'config' ? 'active' : '' %>">
Configuration
Config
</a>
</div>
<div class="nav-item">