## 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>
293 lines
10 KiB
JavaScript
293 lines
10 KiB
JavaScript
const path = require('path');
|
|
const os = require('os');
|
|
const fs = require('fs');
|
|
const appConfig = require('./app-config');
|
|
|
|
class LuantiPaths {
|
|
constructor() {
|
|
// Initialize with default, will be updated when app config loads
|
|
this.setDataDirectory(this.getDefaultDataDirectory());
|
|
}
|
|
|
|
async initialize() {
|
|
// Load app config and update data directory
|
|
await appConfig.load();
|
|
const configuredDataDir = appConfig.getDataDirectory();
|
|
this.setDataDirectory(configuredDataDir);
|
|
console.log(`Paths initialized with data directory: ${configuredDataDir}`);
|
|
}
|
|
|
|
async forceReload() {
|
|
// Force reload the app config and reinitialize paths
|
|
delete require.cache[require.resolve('./app-config')];
|
|
const appConfig = require('./app-config');
|
|
await appConfig.load();
|
|
const configuredDataDir = appConfig.getDataDirectory();
|
|
this.setDataDirectory(configuredDataDir);
|
|
console.log(`Paths force reloaded with data directory: ${configuredDataDir}`);
|
|
}
|
|
|
|
getDefaultDataDirectory() {
|
|
const validDirs = this.detectLuantiDataDirectories();
|
|
return validDirs.length > 0 ? validDirs[0].path : path.join(os.homedir(), '.minetest');
|
|
}
|
|
|
|
detectLuantiDataDirectories() {
|
|
const homeDir = os.homedir();
|
|
const candidateDirs = [
|
|
// Flatpak installations
|
|
{
|
|
path: path.join(homeDir, '.var/app/org.luanti.luanti/.minetest'),
|
|
type: 'Flatpak (org.luanti.luanti)',
|
|
description: 'Luanti installed via Flatpak'
|
|
},
|
|
{
|
|
path: path.join(homeDir, '.var/app/net.minetest.Minetest/.minetest'),
|
|
type: 'Flatpak (net.minetest.Minetest)',
|
|
description: 'Minetest installed via Flatpak'
|
|
},
|
|
// Snap installations (they typically use ~/snap/app-name/current/.local/share/minetest)
|
|
{
|
|
path: path.join(homeDir, 'snap/luanti/current/.local/share/minetest'),
|
|
type: 'Snap (luanti)',
|
|
description: 'Luanti installed via Snap'
|
|
},
|
|
{
|
|
path: path.join(homeDir, 'snap/minetest/current/.local/share/minetest'),
|
|
type: 'Snap (minetest)',
|
|
description: 'Minetest installed via Snap'
|
|
},
|
|
// System package installations
|
|
{
|
|
path: path.join(homeDir, '.luanti'),
|
|
type: 'System Package',
|
|
description: 'System-wide Luanti installation'
|
|
},
|
|
{
|
|
path: path.join(homeDir, '.minetest'),
|
|
type: 'System Package',
|
|
description: 'System-wide Minetest installation'
|
|
},
|
|
// AppImage or manual installations might use these
|
|
{
|
|
path: path.join(homeDir, '.local/share/minetest'),
|
|
type: 'User Installation',
|
|
description: 'User-local installation'
|
|
}
|
|
];
|
|
|
|
// Check which directories exist and contain expected Luanti files
|
|
const validDirs = [];
|
|
for (const candidate of candidateDirs) {
|
|
if (fs.existsSync(candidate.path)) {
|
|
// Check if it looks like a valid Luanti data directory
|
|
const hasConfig = fs.existsSync(path.join(candidate.path, 'minetest.conf'));
|
|
const hasWorlds = fs.existsSync(path.join(candidate.path, 'worlds'));
|
|
const hasDebug = fs.existsSync(path.join(candidate.path, 'debug.txt'));
|
|
|
|
// Even if some files don't exist, the directory might be valid if it exists
|
|
// (Luanti creates files on first run)
|
|
candidate.confidence = (hasConfig ? 1 : 0) + (hasWorlds ? 1 : 0) + (hasDebug ? 0.5 : 0);
|
|
candidate.exists = true;
|
|
candidate.hasConfig = hasConfig;
|
|
candidate.hasWorlds = hasWorlds;
|
|
candidate.hasDebug = hasDebug;
|
|
|
|
validDirs.push(candidate);
|
|
}
|
|
}
|
|
|
|
// Sort by confidence (directories with more Luanti files first)
|
|
validDirs.sort((a, b) => b.confidence - a.confidence);
|
|
|
|
return validDirs;
|
|
}
|
|
|
|
async getGameDisplayName(gameId) {
|
|
try {
|
|
const games = await this.getInstalledGames();
|
|
const game = games.find(g => g.name === gameId);
|
|
return game ? game.title : gameId;
|
|
} catch (error) {
|
|
console.error('Error getting game display name:', error);
|
|
return gameId;
|
|
}
|
|
}
|
|
|
|
setDataDirectory(dataDir) {
|
|
this.minetestDir = path.resolve(dataDir);
|
|
this.worldsDir = path.join(this.minetestDir, 'worlds');
|
|
this.modsDir = path.join(this.minetestDir, 'mods');
|
|
this.gamesDir = path.join(this.minetestDir, 'games');
|
|
this.texturesDir = path.join(this.minetestDir, 'textures');
|
|
this.configFile = path.join(this.minetestDir, 'minetest.conf');
|
|
this.debugFile = path.join(this.minetestDir, 'debug.txt');
|
|
}
|
|
|
|
getDataDirectory() {
|
|
return this.minetestDir;
|
|
}
|
|
|
|
getWorldPath(worldName) {
|
|
return path.join(this.worldsDir, worldName);
|
|
}
|
|
|
|
getWorldConfigPath(worldName) {
|
|
return path.join(this.getWorldPath(worldName), 'world.mt');
|
|
}
|
|
|
|
getWorldModsPath(worldName) {
|
|
return path.join(this.getWorldPath(worldName), 'worldmods');
|
|
}
|
|
|
|
getModPath(modName) {
|
|
return path.join(this.modsDir, modName);
|
|
}
|
|
|
|
getModConfigPath(modName) {
|
|
return path.join(this.getModPath(modName), 'mod.conf');
|
|
}
|
|
|
|
getGamePath(gameName) {
|
|
return path.join(this.gamesDir, gameName);
|
|
}
|
|
|
|
getGameConfigPath(gameName) {
|
|
return path.join(this.getGamePath(gameName), 'game.conf');
|
|
}
|
|
|
|
ensureDirectories() {
|
|
const dirs = [this.minetestDir, this.worldsDir, this.modsDir, this.gamesDir, this.texturesDir];
|
|
dirs.forEach(dir => {
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
isValidWorldName(name) {
|
|
if (!name || typeof name !== 'string') return false;
|
|
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length >= 3 && name.length <= 50;
|
|
}
|
|
|
|
isValidModName(name) {
|
|
if (!name || typeof name !== 'string') return false;
|
|
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 50;
|
|
}
|
|
|
|
isPathSafe(targetPath) {
|
|
const resolvedPath = path.resolve(targetPath);
|
|
return resolvedPath.startsWith(path.resolve(this.minetestDir));
|
|
}
|
|
|
|
mapToActualGameId(directoryName) {
|
|
// Map directory names to the actual game IDs that Luanti recognizes
|
|
// For most cases, the directory name IS the game ID
|
|
const gameIdMap = {
|
|
// 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
|
|
// System installations might also have access to additional directories, but we should
|
|
// primarily focus on the configured data directory to match what Luanti actually sees
|
|
const possibleGameDirs = [
|
|
this.gamesDir // Only the configured games directory
|
|
];
|
|
|
|
for (const gameDir of possibleGameDirs) {
|
|
try {
|
|
const exists = fs.existsSync(gameDir);
|
|
if (!exists) continue;
|
|
|
|
const gameDirs = fs.readdirSync(gameDir);
|
|
for (const gameName of gameDirs) {
|
|
const possibleConfigPaths = [
|
|
path.join(gameDir, gameName, 'game.conf'),
|
|
path.join(gameDir, gameName, gameName, 'game.conf') // Handle nested structure
|
|
];
|
|
|
|
for (const gameConfigPath of possibleConfigPaths) {
|
|
try {
|
|
if (fs.existsSync(gameConfigPath)) {
|
|
const ConfigParser = require('./config-parser');
|
|
const gameConfig = await ConfigParser.parseGameConfig(gameConfigPath);
|
|
|
|
// Map directory names to actual game IDs that Luanti recognizes
|
|
const actualGameId = this.mapToActualGameId(gameName);
|
|
|
|
// Check if we already have this game (avoid duplicates by game ID, title, and resolved path)
|
|
const resolvedPath = fs.realpathSync(path.dirname(gameConfigPath));
|
|
const existingGame = games.find(g =>
|
|
g.name === actualGameId ||
|
|
(g.title === (gameConfig.title || gameConfig.name || gameName) && g.resolvedPath === resolvedPath)
|
|
);
|
|
if (!existingGame) {
|
|
games.push({
|
|
name: actualGameId, // Use the ID that Luanti recognizes
|
|
directoryName: gameName, // Keep original for path resolution
|
|
title: gameConfig.title || gameConfig.name || gameName,
|
|
description: gameConfig.description || '',
|
|
author: gameConfig.author || '',
|
|
path: path.dirname(gameConfigPath),
|
|
resolvedPath: resolvedPath,
|
|
isSystemGame: !gameDir.includes(this.minetestDir)
|
|
});
|
|
}
|
|
break; // Found valid config, stop checking other paths
|
|
}
|
|
} catch (gameError) {
|
|
// Skip invalid games
|
|
console.warn(`Invalid game at ${gameConfigPath}:`, gameError.message);
|
|
}
|
|
}
|
|
}
|
|
} catch (dirError) {
|
|
// Skip directories that can't be read
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Sort games: system games first, then minetest_game first, then alphabetically
|
|
games.sort((a, b) => {
|
|
if (a.isSystemGame !== b.isSystemGame) {
|
|
return a.isSystemGame ? -1 : 1;
|
|
}
|
|
|
|
// Put minetest_game first as it's the default
|
|
if (a.name === 'minetest_game') return -1;
|
|
if (b.name === 'minetest_game') return 1;
|
|
|
|
return a.title.localeCompare(b.title);
|
|
});
|
|
|
|
return games;
|
|
}
|
|
}
|
|
|
|
module.exports = new LuantiPaths(); |