Compare commits
5 Commits
2d3b1166fe
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
def0a66028 | ||
|
88ebb4c603 | ||
|
c1a8784cad | ||
|
09c4f93ec9 | ||
|
8aadab1b50 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -50,13 +50,16 @@ Thumbs.db
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Claude Code specific files (keep settings for future development)
|
||||||
|
# .claude/
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
# Luanti/Minetest specific files (if running locally)
|
# Luanti/Minetest specific files (if running locally)
|
||||||
worlds/
|
/worlds/
|
||||||
mods/
|
mods/
|
||||||
games/
|
games/
|
||||||
textures/
|
textures/
|
||||||
@@ -75,8 +78,8 @@ backups/
|
|||||||
*.crt
|
*.crt
|
||||||
*.csr
|
*.csr
|
||||||
|
|
||||||
# Lock files
|
# Lock files (keep package-lock.json for consistent installs)
|
||||||
package-lock.json
|
# package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
|
8
app.js
8
app.js
@@ -204,6 +204,14 @@ app.get('/', requireAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
paths.ensureDirectories();
|
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
|
// Get basic stats for dashboard
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
let worldCount = 0;
|
let worldCount = 0;
|
||||||
|
3707
package-lock.json
generated
Normal file
3707
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -144,18 +144,22 @@ body {
|
|||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 140px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 16px 20px;
|
padding: 14px 16px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-transform: uppercase;
|
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;
|
transition: all 0.1s ease;
|
||||||
border-right: var(--border-width) solid var(--border-dark);
|
border-right: var(--border-width) solid var(--border-dark);
|
||||||
background: linear-gradient(180deg,
|
background: linear-gradient(180deg,
|
||||||
@@ -679,14 +683,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive design */
|
/* Responsive design */
|
||||||
@media (max-width: 1024px) and (min-width: 769px) {
|
@media (max-width: 1200px) {
|
||||||
.nav-item {
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
padding: 12px 16px;
|
padding: 12px 8px;
|
||||||
font-size: 0.85rem;
|
font-size: clamp(0.7rem, 1.1vw, 0.85rem);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -280,6 +280,13 @@ router.get('/updates', async (req, res) => {
|
|||||||
// View installed packages
|
// View installed packages
|
||||||
router.get('/installed', async (req, res) => {
|
router.get('/installed', async (req, res) => {
|
||||||
try {
|
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 { location } = req.query;
|
||||||
const packages = await packageRegistry.getInstalledPackages(location);
|
const packages = await packageRegistry.getInstalledPackages(location);
|
||||||
const stats = await packageRegistry.getStatistics();
|
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
|
// Check if already installed at this location
|
||||||
let installLocationKey;
|
let installLocationKey;
|
||||||
if (packageType === 'game') {
|
if (packageType === 'game') {
|
||||||
|
@@ -121,8 +121,52 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Install mod to world
|
// Enable mod for world (configuration-based)
|
||||||
router.post('/install/:worldName/:modName', async (req, res) => {
|
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 {
|
try {
|
||||||
const { worldName, modName } = req.params;
|
const { worldName, modName } = req.params;
|
||||||
|
|
||||||
@@ -149,20 +193,49 @@ router.post('/install/:worldName/:modName', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(targetModPath);
|
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 {}
|
} catch {}
|
||||||
|
|
||||||
await fs.mkdir(worldModsPath, { recursive: true });
|
await fs.mkdir(worldModsPath, { recursive: true });
|
||||||
await fs.cp(globalModPath, targetModPath, { 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) {
|
} catch (error) {
|
||||||
console.error('Error installing mod to world:', error);
|
console.error('Error copying mod to world:', error);
|
||||||
res.status(500).json({ error: 'Failed to install mod to world' });
|
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) => {
|
router.post('/remove/:worldName/:modName', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { worldName, modName } = req.params;
|
const { worldName, modName } = req.params;
|
||||||
|
@@ -33,11 +33,17 @@ router.get('/', async (req, res) => {
|
|||||||
let playerCount = 0;
|
let playerCount = 0;
|
||||||
try {
|
try {
|
||||||
const playersDbPath = path.join(worldPath, 'players.sqlite');
|
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 all = promisify(db.all.bind(db));
|
||||||
const result = await all('SELECT COUNT(*) as count FROM players');
|
const result = await all('SELECT COUNT(*) as count FROM players');
|
||||||
playerCount = result[0]?.count || 0;
|
playerCount = result[0]?.count || 0;
|
||||||
db.close();
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (dbError) {}
|
} catch (dbError) {}
|
||||||
|
|
||||||
const gameid = config.gameid || 'minetest_game';
|
const gameid = config.gameid || 'minetest_game';
|
||||||
@@ -131,18 +137,29 @@ router.post('/create', async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch {}
|
} 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
|
// Get the default game if none specified
|
||||||
serverManager.createWorld(name, gameid || 'minetest_game')
|
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(() => {
|
.then(() => {
|
||||||
console.log('Successfully created world using Luanti:', name);
|
console.log('Successfully created world:', name);
|
||||||
// Notify clients via WebSocket
|
// Notify clients via WebSocket
|
||||||
const io = req.app.get('socketio');
|
const io = req.app.get('socketio');
|
||||||
if (io) {
|
if (io) {
|
||||||
io.emit('worldCreated', {
|
io.emit('worldCreated', {
|
||||||
worldName: name,
|
worldName: name,
|
||||||
gameId: gameid || 'minetest_game',
|
gameId: finalGameId,
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -154,7 +171,7 @@ router.post('/create', async (req, res) => {
|
|||||||
if (io) {
|
if (io) {
|
||||||
io.emit('worldCreated', {
|
io.emit('worldCreated', {
|
||||||
worldName: name,
|
worldName: name,
|
||||||
gameId: gameid || 'minetest_game',
|
gameId: finalGameId,
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message
|
error: error.message
|
||||||
});
|
});
|
||||||
@@ -207,6 +224,40 @@ router.get('/:worldName', async (req, res) => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
let enabledMods = [];
|
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 {
|
try {
|
||||||
const worldModsPath = paths.getWorldModsPath(worldName);
|
const worldModsPath = paths.getWorldModsPath(worldName);
|
||||||
const modDirs = await fs.readdir(worldModsPath);
|
const modDirs = await fs.readdir(worldModsPath);
|
||||||
@@ -219,7 +270,8 @@ router.get('/:worldName', async (req, res) => {
|
|||||||
title: modConfig.title || modDir,
|
title: modConfig.title || modDir,
|
||||||
description: modConfig.description || '',
|
description: modConfig.description || '',
|
||||||
author: modConfig.author || '',
|
author: modConfig.author || '',
|
||||||
location: 'world'
|
location: 'world-installed',
|
||||||
|
path: path.join(worldModsPath, modDir)
|
||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
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);
|
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
|
// If no dbPath provided, we'll set it during init based on current data directory
|
||||||
this.dbPath = dbPath;
|
this.dbPath = dbPath;
|
||||||
this.db = null;
|
this.db = null;
|
||||||
|
this.lastVerificationTime = 0;
|
||||||
|
this.verificationInterval = 5 * 60 * 1000; // 5 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
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;
|
module.exports = PackageRegistry;
|
@@ -182,13 +182,72 @@ class LuantiPaths {
|
|||||||
|
|
||||||
mapToActualGameId(directoryName) {
|
mapToActualGameId(directoryName) {
|
||||||
// Map directory names to the actual game IDs that Luanti recognizes
|
// Map directory names to the actual game IDs that Luanti recognizes
|
||||||
// For most cases, the directory name IS the game ID
|
// Luanti normalizes game IDs by removing "_game" suffix from all games
|
||||||
const gameIdMap = {
|
// See https://github.com/luanti-org/luanti/blob/c9d4c33174c87ede1f49c5fe5e8e49a784798eb6/src/content/subgames.cpp#L21
|
||||||
// Only add mappings here if you're certain they're needed
|
|
||||||
// 'minetest_game': 'minetest', // This mapping was incorrect
|
|
||||||
};
|
|
||||||
|
|
||||||
return gameIdMap[directoryName] || directoryName;
|
// Remove "_game" suffix if present
|
||||||
|
if (directoryName.endsWith('_game')) {
|
||||||
|
return directoryName.slice(0, -5); // Remove last 5 characters ("_game")
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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
|
||||||
|
// Luanti normalizes all game IDs by removing "_game" suffix
|
||||||
|
|
||||||
|
// Remove "_game" suffix if present
|
||||||
|
if (gameId.endsWith('_game')) {
|
||||||
|
return gameId.slice(0, -5); // Remove last 5 characters ("_game")
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async mapInternalGameIdToDirectory(internalGameId) {
|
||||||
|
// Reverse mapping: convert internal game ID back to directory name for display/reference
|
||||||
|
// This helps when we read a world.mt with normalized game ID but want to show directory name
|
||||||
|
// Since Luanti normalizes by removing "_game" suffix, we need to check if a directory
|
||||||
|
// with "_game" suffix exists for this normalized ID
|
||||||
|
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Check both system and user games directories
|
||||||
|
const gameDirs = [this.getGamesPath()];
|
||||||
|
if (this.getSystemGamesPath() && this.getSystemGamesPath() !== this.getGamesPath()) {
|
||||||
|
gameDirs.push(this.getSystemGamesPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const gameDir of gameDirs) {
|
||||||
|
try {
|
||||||
|
// First check if the internalGameId as-is exists as a directory
|
||||||
|
const directPath = path.join(gameDir, internalGameId);
|
||||||
|
const stat = await fs.stat(directPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return internalGameId;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Directory doesn't exist, try with "_game" suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if there's a directory with "_game" suffix
|
||||||
|
const gameIdWithSuffix = internalGameId + '_game';
|
||||||
|
const suffixPath = path.join(gameDir, gameIdWithSuffix);
|
||||||
|
const stat = await fs.stat(suffixPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
return gameIdWithSuffix;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Directory doesn't exist either
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return the original ID
|
||||||
|
return internalGameId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInstalledGames() {
|
async getInstalledGames() {
|
||||||
|
@@ -308,6 +308,7 @@ class ServerManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
killProcessRecursive(pid, signal, callback) {
|
killProcessRecursive(pid, signal, callback) {
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
@@ -502,68 +503,39 @@ class ServerManager extends EventEmitter {
|
|||||||
|
|
||||||
async createWorld(worldName, gameId = 'minetest_game') {
|
async createWorld(worldName, gameId = 'minetest_game') {
|
||||||
try {
|
try {
|
||||||
const executableInfo = await this.findMinetestExecutable();
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
const worldPath = paths.getWorldPath(worldName);
|
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}"`);
|
this.addLogLine('info', `Creating world "${worldName}" with game "${gameId}"`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
// Map game ID for world creation (e.g., minetest_game -> minetest)
|
||||||
const createProcess = spawn(executableInfo.command, [...executableInfo.args, ...createWorldArgs], {
|
const worldGameId = paths.mapGameIdForWorldCreation(gameId);
|
||||||
cwd: paths.minetestDir,
|
if (worldGameId !== gameId) {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
this.addLogLine('info', `Mapping game ID: ${gameId} -> ${worldGameId} for world.mt`);
|
||||||
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) => {
|
// Create world directory
|
||||||
this.addLogLine('info', `World creation process finished with code: ${code}`);
|
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`);
|
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) => {
|
return { success: true, worldPath };
|
||||||
this.addLogLine('error', `World creation failed: ${error.message}`);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.addLogLine('error', `Failed to create world: ${error.message}`);
|
this.addLogLine('error', `Failed to create world: ${error.message}`);
|
||||||
|
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><%= title %> | LuHost</title>
|
<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="stylesheet" href="/static/css/style.css">
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
@@ -48,6 +51,11 @@
|
|||||||
Worlds
|
Worlds
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item">
|
||||||
|
<a href="/mods" class="nav-link <%= currentPage === 'mods' ? 'active' : '' %>">
|
||||||
|
Mods
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/extensions" class="nav-link <%= currentPage === 'extensions' ? 'active' : '' %>">
|
<a href="/extensions" class="nav-link <%= currentPage === 'extensions' ? 'active' : '' %>">
|
||||||
Extensions
|
Extensions
|
||||||
@@ -60,7 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
<a href="/config" class="nav-link <%= currentPage === 'config' ? 'active' : '' %>">
|
<a href="/config" class="nav-link <%= currentPage === 'config' ? 'active' : '' %>">
|
||||||
Configuration
|
Config
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
|
150
views/mods/details.ejs
Normal file
150
views/mods/details.ejs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<%
|
||||||
|
const body = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/mods">Mods</a>
|
||||||
|
<span>${mod.title}</span>
|
||||||
|
</div>
|
||||||
|
<h2>${mod.title}</h2>
|
||||||
|
${mod.description ? `<p class="text-secondary">${mod.description}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Mod Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div><strong>Name:</strong> ${mod.name}</div>
|
||||||
|
<div><strong>Author:</strong> ${mod.author || 'Unknown'}</div>
|
||||||
|
<div><strong>Files:</strong> ${mod.fileCount} files</div>
|
||||||
|
<div><strong>Size:</strong> ${(mod.totalSize / 1024).toFixed(2)} KB</div>
|
||||||
|
<div><strong>Created:</strong> ${new Date(mod.created).toLocaleString()}</div>
|
||||||
|
<div><strong>Modified:</strong> ${new Date(mod.lastModified).toLocaleString()}</div>
|
||||||
|
${mod.min_minetest_version ? `<div><strong>Min Version:</strong> ${mod.min_minetest_version}</div>` : ''}
|
||||||
|
${mod.max_minetest_version ? `<div><strong>Max Version:</strong> ${mod.max_minetest_version}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${mod.depends.length > 0 ? `
|
||||||
|
<div style="margin-top: 16px;">
|
||||||
|
<strong>Dependencies:</strong>
|
||||||
|
<div class="tag-list">
|
||||||
|
${mod.depends.map(dep => `<span class="tag tag-required">${dep}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${mod.optional_depends.length > 0 ? `
|
||||||
|
<div style="margin-top: 16px;">
|
||||||
|
<strong>Optional Dependencies:</strong>
|
||||||
|
<div class="tag-list">
|
||||||
|
${mod.optional_depends.map(dep => `<span class="tag tag-optional">${dep}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${mod.installedWorlds.length > 0 ? `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Installed in Worlds (${mod.installedWorlds.length})</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="world-list">
|
||||||
|
${mod.installedWorlds.map(world => `
|
||||||
|
<div class="world-item">
|
||||||
|
<div>
|
||||||
|
<strong>${world.displayName}</strong>
|
||||||
|
<span class="text-secondary">(${world.name})</span>
|
||||||
|
</div>
|
||||||
|
<div class="world-actions">
|
||||||
|
<form method="POST" action="/mods/remove/${world.name}/${mod.name}" style="display: inline;" onsubmit="return confirm('Remove this mod from ${world.displayName}?')">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Actions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a href="/mods" class="btn btn-outline">← Back to Mods</a>
|
||||||
|
<form method="POST" action="/mods/delete/${mod.name}" style="display: inline;" onsubmit="return confirm('Permanently delete this mod and remove it from all worlds?')">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-danger">Delete Mod</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-required {
|
||||||
|
background: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-optional {
|
||||||
|
background: var(--warning-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.world-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<%- include('../layout', { body: body, currentPage: 'mods', title: title }) %>
|
275
views/mods/index.ejs
Normal file
275
views/mods/index.ejs
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<%
|
||||||
|
const body = `
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Mod Management</h2>
|
||||||
|
<p class="text-secondary">Install and manage mods for your worlds</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${typeof req !== 'undefined' && req.query && req.query.enabled ? `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Mod "${req.query.enabled}" enabled for ${selectedWorld}!
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${typeof req !== 'undefined' && req.query && req.query.disabled ? `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Mod "${req.query.disabled}" disabled for ${selectedWorld}!
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${typeof req !== 'undefined' && req.query && req.query.copied ? `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Mod "${req.query.copied}" copied to ${selectedWorld}!
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${typeof req !== 'undefined' && req.query && req.query.removed ? `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Mod "${req.query.removed}" removed from ${selectedWorld}!
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${typeof req !== 'undefined' && req.query && req.query.deleted ? `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
Mod "${req.query.deleted}" deleted permanently!
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Select World</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET">
|
||||||
|
<div style="display: flex; gap: 12px; align-items: end;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label>World</label>
|
||||||
|
<select name="world" class="form-control">
|
||||||
|
<option value="">Select a world...</option>
|
||||||
|
${worlds.map(world => `
|
||||||
|
<option value="${world.name}" ${selectedWorld === world.name ? 'selected' : ''}>
|
||||||
|
${world.displayName}
|
||||||
|
</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">View Mods</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${selectedWorld ? `
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>World Mods (${worldMods.length})</h3>
|
||||||
|
<p class="text-secondary">Mods installed specifically for ${selectedWorld}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${worldMods.length > 0 ? `
|
||||||
|
<div class="mod-grid">
|
||||||
|
${worldMods.map(mod => `
|
||||||
|
<div class="mod-card">
|
||||||
|
<div class="mod-header">
|
||||||
|
<h4>${mod.title}</h4>
|
||||||
|
${mod.location === 'global-enabled' ? `
|
||||||
|
<span class="badge badge-success">Enabled</span>
|
||||||
|
` : mod.location === 'world-installed' ? `
|
||||||
|
<span class="badge badge-primary">World Copy</span>
|
||||||
|
` : mod.location === 'global-missing' ? `
|
||||||
|
<span class="badge badge-danger">Missing</span>
|
||||||
|
` : `
|
||||||
|
<span class="badge badge-secondary">Unknown</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
${mod.description ? `<p class="mod-description">${mod.description}</p>` : ''}
|
||||||
|
<div class="mod-meta">
|
||||||
|
${mod.author ? `<span><strong>Author:</strong> ${mod.author}</span>` : ''}
|
||||||
|
${mod.depends && mod.depends.length > 0 ? `<span><strong>Depends:</strong> ${mod.depends.join(', ')}</span>` : ''}
|
||||||
|
<span><strong>Type:</strong> ${
|
||||||
|
mod.location === 'global-enabled' ? 'Global mod (enabled via config)' :
|
||||||
|
mod.location === 'world-installed' ? 'World-specific installation' :
|
||||||
|
mod.location === 'global-missing' ? 'Missing global mod' : 'Unknown'
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mod-actions">
|
||||||
|
${mod.location === 'global-enabled' ? `
|
||||||
|
<form method="POST" action="/mods/disable/${selectedWorld}/${mod.name}" style="display: inline;" onsubmit="return confirm('Disable this mod for the world?')">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-sm btn-warning">Disable</button>
|
||||||
|
</form>
|
||||||
|
` : mod.location === 'world-installed' ? `
|
||||||
|
<form method="POST" action="/mods/remove/${selectedWorld}/${mod.name}" style="display: inline;" onsubmit="return confirm('Delete this mod copy from the world?')">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Delete Copy</button>
|
||||||
|
</form>
|
||||||
|
` : mod.location === 'global-missing' ? `
|
||||||
|
<span class="text-danger">Missing from global mods</span>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<p class="text-secondary">No mods enabled for this world.</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Available Global Mods (${globalMods.length})</h3>
|
||||||
|
<p class="text-secondary">Install these mods to ${selectedWorld}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${globalMods.length > 0 ? `
|
||||||
|
<div class="mod-grid">
|
||||||
|
${globalMods.map(mod => {
|
||||||
|
const isEnabled = worldMods.some(wm => wm.name === mod.name && wm.location === 'global-enabled');
|
||||||
|
const isCopied = worldMods.some(wm => wm.name === mod.name && wm.location === 'world-installed');
|
||||||
|
const isInUse = isEnabled || isCopied;
|
||||||
|
return `
|
||||||
|
<div class="mod-card ${isInUse ? 'mod-installed' : ''}">
|
||||||
|
<div class="mod-header">
|
||||||
|
<h4>${mod.title}</h4>
|
||||||
|
<span class="badge ${isInUse ? 'badge-success' : 'badge-secondary'}">
|
||||||
|
${isEnabled ? 'Enabled' : isCopied ? 'Copied' : 'Available'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${mod.description ? `<p class="mod-description">${mod.description}</p>` : ''}
|
||||||
|
<div class="mod-meta">
|
||||||
|
${mod.author ? `<span><strong>Author:</strong> ${mod.author}</span>` : ''}
|
||||||
|
${mod.depends && mod.depends.length > 0 ? `<span><strong>Depends:</strong> ${mod.depends.join(', ')}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="mod-actions">
|
||||||
|
${!isEnabled ? `
|
||||||
|
<form method="POST" action="/mods/enable/${selectedWorld}/${mod.name}" style="display: inline;">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Enable</button>
|
||||||
|
</form>
|
||||||
|
` : ''}
|
||||||
|
${!isCopied ? `
|
||||||
|
<form method="POST" action="/mods/copy/${selectedWorld}/${mod.name}" style="display: inline;">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary">Copy to World</button>
|
||||||
|
</form>
|
||||||
|
` : ''}
|
||||||
|
<a href="/mods/${mod.name}" class="btn btn-sm btn-outline">Details</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<p class="text-secondary">No global mods found. Upload mods to the mods directory to see them here.</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Global Mods (${globalMods.length})</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${globalMods.length > 0 ? `
|
||||||
|
<div class="mod-grid">
|
||||||
|
${globalMods.map(mod => `
|
||||||
|
<div class="mod-card">
|
||||||
|
<div class="mod-header">
|
||||||
|
<h4>${mod.title}</h4>
|
||||||
|
<span class="badge badge-secondary">Global</span>
|
||||||
|
</div>
|
||||||
|
${mod.description ? `<p class="mod-description">${mod.description}</p>` : ''}
|
||||||
|
<div class="mod-meta">
|
||||||
|
${mod.author ? `<span><strong>Author:</strong> ${mod.author}</span>` : ''}
|
||||||
|
${mod.depends.length > 0 ? `<span><strong>Depends:</strong> ${mod.depends.join(', ')}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="mod-actions">
|
||||||
|
<a href="/mods/${mod.name}" class="btn btn-sm btn-outline">Details</a>
|
||||||
|
<form method="POST" action="/mods/delete/${mod.name}" style="display: inline;" onsubmit="return confirm('Permanently delete this mod?')">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<p class="text-secondary">No global mods found. Upload mods to the mods directory to see them here.</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mod-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-card.mod-installed {
|
||||||
|
border-color: var(--success-color);
|
||||||
|
background: var(--success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-meta span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary { background: var(--primary-color); color: white; }
|
||||||
|
.badge-secondary { background: var(--text-secondary); color: white; }
|
||||||
|
.badge-success { background: var(--success-color); color: white; }
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<%- include('../layout', { body: body, currentPage: 'mods', title: title }) %>
|
131
views/worlds/details.ejs
Normal file
131
views/worlds/details.ejs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<%
|
||||||
|
const body = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/worlds">Worlds</a>
|
||||||
|
<span>${world.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
|
<h2>${world.displayName}</h2>
|
||||||
|
<form method="POST" action="/worlds/${world.name}/delete"
|
||||||
|
style="display: inline;"
|
||||||
|
onsubmit="return confirmDelete('world', '${world.displayName}')">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-danger">Delete World</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
${world.description ? `<p class="text-secondary">${world.description}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${typeof req !== 'undefined' && req.query && req.query.updated ? `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
World settings updated successfully!
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>World Settings</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="/worlds/${world.name}/update">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="displayName"
|
||||||
|
class="form-control"
|
||||||
|
value="${world.displayName}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
class="form-control"
|
||||||
|
rows="3"
|
||||||
|
>${world.description || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Game Settings</label>
|
||||||
|
<div style="display: grid; gap: 8px; margin-top: 8px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<input type="checkbox" name="creativeMode" ${world.creativeMode ? 'checked' : ''} />
|
||||||
|
<span>Enable Creative Mode</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<input type="checkbox" name="enableDamage" ${world.enableDamage ? 'checked' : ''} />
|
||||||
|
<span>Enable Damage</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<input type="checkbox" name="enablePvp" ${world.enablePvp ? 'checked' : ''} />
|
||||||
|
<span>Enable PvP</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<input type="checkbox" name="serverAnnounce" ${world.serverAnnounce ? 'checked' : ''} />
|
||||||
|
<span>Announce Server</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; margin-top: 24px;">
|
||||||
|
<button type="submit" class="btn btn-primary">Update Settings</button>
|
||||||
|
<a href="/mods?world=${world.name}" class="btn btn-outline">Manage Mods</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-color);">
|
||||||
|
<h4>World Information</h4>
|
||||||
|
<div style="display: grid; gap: 8px; margin-top: 12px;">
|
||||||
|
<div><strong>Internal Name:</strong> ${world.name}</div>
|
||||||
|
<div><strong>Game:</strong> ${world.gameid}</div>
|
||||||
|
<div><strong>World Size:</strong> ${(world.worldSize / 1024 / 1024).toFixed(2)} MB</div>
|
||||||
|
<div><strong>Created:</strong> ${new Date(world.created).toLocaleString()}</div>
|
||||||
|
<div><strong>Last Modified:</strong> ${new Date(world.lastModified).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${world.enabledMods && world.enabledMods.length > 0 ? `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Enabled Mods (${world.enabledMods.length})</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mod-list">
|
||||||
|
${world.enabledMods.map(mod => `
|
||||||
|
<div class="mod-item">
|
||||||
|
<div class="mod-info">
|
||||||
|
<strong>${mod.title}</strong>
|
||||||
|
${mod.author ? `<span class="text-secondary">by ${mod.author}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${mod.description ? `<div class="mod-description">${mod.description}</div>` : ''}
|
||||||
|
<div class="mod-location">
|
||||||
|
${mod.location === 'global-enabled' ? `
|
||||||
|
<span class="badge badge-success">Global (Enabled)</span>
|
||||||
|
` : mod.location === 'world-installed' ? `
|
||||||
|
<span class="badge badge-primary">World Copy</span>
|
||||||
|
` : mod.location === 'global-missing' ? `
|
||||||
|
<span class="badge badge-danger">Missing</span>
|
||||||
|
` : `
|
||||||
|
<span class="badge badge-secondary">${mod.location}</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
`;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<%- include('../layout', { body: body, currentPage: 'worlds', title: title }) %>
|
138
views/worlds/index.ejs
Normal file
138
views/worlds/index.ejs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<%
|
||||||
|
const body = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px;">
|
||||||
|
<div>
|
||||||
|
<h2>Worlds Management</h2>
|
||||||
|
<p class="text-secondary">Manage your Luanti worlds</p>
|
||||||
|
</div>
|
||||||
|
<a href="/worlds/new" class="btn btn-primary">Create New World</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="creatingAlert" class="alert alert-info" style="display: none;">
|
||||||
|
<strong>Creating world...</strong><br>
|
||||||
|
<span id="creatingWorldName"></span> is being created.
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<div style="width: 100%; height: 6px; background: rgba(255,255,255,0.3); border-radius: 3px;">
|
||||||
|
<div id="progressBar" style="height: 100%; background: var(--text-light); border-radius: 3px; width: 0%; transition: width 0.3s;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${typeof worlds !== 'undefined' && worlds.length > 0 ? `
|
||||||
|
<div class="grid">
|
||||||
|
${worlds.map(world => `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>${world.displayName}</h3>
|
||||||
|
<span class="badge ${world.creativeMode ? 'badge-info' : 'badge-success'}">
|
||||||
|
${world.creativeMode ? 'Creative' : 'Survival'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${world.description ? `<p class="text-secondary">${world.description}</p>` : ''}
|
||||||
|
<div class="details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Game:</strong> ${world.gameTitle || world.gameid}
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Players:</strong> ${world.playerCount || 0}
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>PvP:</strong> ${world.enablePvp ? 'Enabled' : 'Disabled'}
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Damage:</strong> ${world.enableDamage ? 'Enabled' : 'Disabled'}
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<strong>Last Modified:</strong> ${new Date(world.lastModified).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<a href="/worlds/${world.name}" class="btn btn-primary btn-sm">View Details</a>
|
||||||
|
<form method="POST" action="/worlds/${world.name}/delete"
|
||||||
|
style="display: inline;"
|
||||||
|
onsubmit="return confirmDelete('world', '${world.displayName}')">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No worlds created yet</h3>
|
||||||
|
<p>Create your first world to get started with hosting Luanti servers.</p>
|
||||||
|
<a href="/worlds/new" class="btn btn-primary">Create First World</a>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
`;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<%- include('../layout', {
|
||||||
|
body: body,
|
||||||
|
currentPage: 'worlds',
|
||||||
|
title: title,
|
||||||
|
inlineScript: `
|
||||||
|
// Handle world creation progress
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const creatingWorldName = urlParams.get('creating');
|
||||||
|
|
||||||
|
if (creatingWorldName) {
|
||||||
|
// Show creating alert
|
||||||
|
const creatingAlert = document.getElementById('creatingAlert');
|
||||||
|
const creatingWorldNameSpan = document.getElementById('creatingWorldName');
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
|
||||||
|
if (creatingAlert && creatingWorldNameSpan) {
|
||||||
|
creatingWorldNameSpan.textContent = creatingWorldName;
|
||||||
|
creatingAlert.style.display = 'block';
|
||||||
|
|
||||||
|
// Quick progress animation (since creation is fast but not instant)
|
||||||
|
let progress = 0;
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
progress += 15;
|
||||||
|
if (progress > 85) progress = 85; // Don't complete until we get websocket confirmation
|
||||||
|
progressBar.style.width = progress + '%';
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Listen for websocket events
|
||||||
|
if (typeof socket !== 'undefined') {
|
||||||
|
socket.on('worldCreated', function(data) {
|
||||||
|
if (data.worldName === creatingWorldName) {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
setTimeout(() => {
|
||||||
|
// Remove the creating parameter and reload
|
||||||
|
const newUrl = new URL(window.location);
|
||||||
|
newUrl.searchParams.delete('creating');
|
||||||
|
window.location.href = newUrl.toString();
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
creatingAlert.className = 'alert alert-danger';
|
||||||
|
creatingAlert.innerHTML = '<strong>World creation failed:</strong><br>' +
|
||||||
|
(data.error || 'Unknown error occurred');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: reload after 5 seconds if no websocket response
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
const newUrl = new URL(window.location);
|
||||||
|
newUrl.searchParams.delete('creating');
|
||||||
|
window.location.href = newUrl.toString();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`
|
||||||
|
}) %>
|
70
views/worlds/new.ejs
Normal file
70
views/worlds/new.ejs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<%
|
||||||
|
const body = `
|
||||||
|
<div class="page-header">
|
||||||
|
<div style="margin-bottom: 16px;">
|
||||||
|
<a href="/worlds" style="color: var(--primary-color); text-decoration: none;">← Back to Worlds</a>
|
||||||
|
</div>
|
||||||
|
<h2>Create New World</h2>
|
||||||
|
<p>Create a new Luanti world</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${typeof error !== 'undefined' && error ? `
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
${error}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="POST" action="/worlds/create">
|
||||||
|
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">World Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="form-control"
|
||||||
|
required
|
||||||
|
pattern="[a-zA-Z0-9_-]+"
|
||||||
|
title="Only letters, numbers, underscore and hyphen allowed"
|
||||||
|
value="${typeof formData !== 'undefined' && formData && formData.name ? formData.name : ''}"
|
||||||
|
/>
|
||||||
|
<small style="color: var(--text-secondary);">Only letters, numbers, underscore and hyphen allowed</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="gameid">Game Type *</label>
|
||||||
|
<select id="gameid" name="gameid" class="form-control" required style="max-width: 300px;">
|
||||||
|
${typeof games !== 'undefined' && games.length > 0 ? games.map((game, index) => `
|
||||||
|
<option
|
||||||
|
value="${game.name}"
|
||||||
|
${typeof formData !== 'undefined' && formData && formData.gameid === game.name ? 'selected' : (index === 0 && (typeof formData === 'undefined' || !formData || !formData.gameid) ? 'selected' : '')}
|
||||||
|
>
|
||||||
|
${game.title || game.name}
|
||||||
|
</option>
|
||||||
|
`).join('') : `
|
||||||
|
<option value="minetest_game" selected>Minetest Game</option>
|
||||||
|
<option value="minimal">Minimal</option>
|
||||||
|
`}
|
||||||
|
</select>
|
||||||
|
<small style="color: var(--text-secondary);">Choose the base game for your world</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 12px; margin-top: 24px;">
|
||||||
|
<button type="submit" class="btn btn-primary">Create World</button>
|
||||||
|
<a href="/worlds" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info" style="margin-top: 24px;">
|
||||||
|
<strong>World Creation Notes:</strong><br>
|
||||||
|
• World creation may take a few moments to complete<br>
|
||||||
|
• You will be redirected to the worlds list when creation starts<br>
|
||||||
|
• Additional world settings can be configured after creation<br>
|
||||||
|
• Make sure the selected game is properly installed
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<%- include('../layout', { body: body, currentPage: 'worlds', title: title }) %>
|
Reference in New Issue
Block a user