Add world management for Space Engineers (list/switch/create/clone/rename/delete)
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
Mirrors Factorio's world feature for the SE server. The SE docker image always loads the flat config/World dir and its entrypoint rewrites <LoadWorld> on every start, so repointing the cfg is useless. Instead inactive worlds live in a sibling config/Worlds library (not mounted) and switching is an instant mv-swap; the active world name is tracked in .gsm-worldname and files are chowned to 1000:1000. New worlds are created from the image's built-in CustomWorlds templates or by cloning the active world. Switch/clone require a stopped server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { EmbedBuilder } from 'discord.js';
|
||||
import { authenticateToken, optionalAuth, requireRole, rejectGuest } from '../middleware/auth.js';
|
||||
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig, readHytaleConfig, writeHytaleConfig } from '../services/ssh.js';
|
||||
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig, readHytaleConfig, writeHytaleConfig, listSpaceEngineersWorlds, listSpaceEngineersTemplates, switchSpaceEngineersWorld, createSpaceEngineersWorldFromTemplate, cloneSpaceEngineersWorld, deleteSpaceEngineersWorld, renameSpaceEngineersWorld } from '../services/ssh.js';
|
||||
import { supportsUpdate, checkForUpdate, performUpdate } from '../services/serverUpdates.js';
|
||||
import { supportsVersionInfo, getVersionInfo, invalidateVersionCache } from '../services/serverVersions.js';
|
||||
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
|
||||
@@ -303,6 +303,111 @@ router.get("/factorio/current-save", authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============ SPACE ENGINEERS WORLD ROUTES ============
|
||||
|
||||
function getSEServer() {
|
||||
return loadConfig().servers.find(s => s.type === "spaceengineers");
|
||||
}
|
||||
|
||||
// SE: List worlds (active + library)
|
||||
router.get("/spaceengineers/worlds", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const server = getSEServer();
|
||||
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||
const data = await listSpaceEngineersWorlds(server);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// SE: List built-in templates
|
||||
router.get("/spaceengineers/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const server = getSEServer();
|
||||
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||
const templates = await listSpaceEngineersTemplates(server);
|
||||
res.json({ templates });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// SE: Create new world from a template
|
||||
router.post("/spaceengineers/worlds", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { name, template } = req.body || {};
|
||||
if (!name || !template) return res.status(400).json({ error: "name and template required", message: "name und template erforderlich" });
|
||||
const server = getSEServer();
|
||||
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||
const result = await createSpaceEngineersWorldFromTemplate(server, name, template);
|
||||
logActivity(req.user.id, req.user.username, 'se_world_create', server.id, `${result.name} (Vorlage: ${template})`, req.user.discordId, req.user.avatar);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// SE: Clone the active world
|
||||
router.post("/spaceengineers/worlds/clone", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body || {};
|
||||
if (!name) return res.status(400).json({ error: "name required", message: "name erforderlich" });
|
||||
const server = getSEServer();
|
||||
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||
const status = await getServerStatus(server);
|
||||
if (status === "online") return res.status(409).json({ error: "Server muss gestoppt sein zum Klonen", message: "Server muss gestoppt sein zum Klonen" });
|
||||
const result = await cloneSpaceEngineersWorld(server, name);
|
||||
logActivity(req.user.id, req.user.username, 'se_world_clone', server.id, result.name, req.user.discordId, req.user.avatar);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// SE: Switch the active world (server must be stopped)
|
||||
router.post("/spaceengineers/worlds/:name/activate", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const server = getSEServer();
|
||||
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||
const status = await getServerStatus(server);
|
||||
if (status === "online") return res.status(409).json({ error: "Server muss gestoppt sein zum Wechseln der Welt", message: "Server muss gestoppt sein zum Wechseln der Welt" });
|
||||
const result = await switchSpaceEngineersWorld(server, req.params.name);
|
||||
logActivity(req.user.id, req.user.username, 'se_world_activate', server.id, result.active, req.user.discordId, req.user.avatar);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// SE: Rename a world
|
||||
router.patch("/spaceengineers/worlds/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { newName } = req.body || {};
|
||||
if (!newName) return res.status(400).json({ error: "newName required", message: "newName erforderlich" });
|
||||
const server = getSEServer();
|
||||
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||
const result = await renameSpaceEngineersWorld(server, req.params.name, newName);
|
||||
logActivity(req.user.id, req.user.username, 'se_world_rename', server.id, `${req.params.name} -> ${result.name}`, req.user.discordId, req.user.avatar);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// SE: Delete a world (not the active one)
|
||||
router.delete("/spaceengineers/worlds/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const server = getSEServer();
|
||||
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
|
||||
const result = await deleteSpaceEngineersWorld(server, req.params.name);
|
||||
logActivity(req.user.id, req.user.username, 'se_world_delete', server.id, result.name, req.user.discordId, req.user.avatar);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ ZOMBOID CONFIG ROUTES ============
|
||||
|
||||
// Zomboid: List config files
|
||||
|
||||
@@ -782,6 +782,257 @@ export async function getSpaceEngineersPlayers(server) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ SPACE ENGINEERS WORLD MANAGEMENT ============
|
||||
//
|
||||
// Unlike Factorio (which keeps many named saves in one folder and picks one via
|
||||
// an env var on `docker run`), the SE image always loads exactly ONE flat world
|
||||
// directory: host `<workDir>/appdata/space-engineers/config/World` -> container
|
||||
// `/appdata/space-engineers/World`. The image entrypoint even rewrites
|
||||
// <LoadWorld> back to that fixed path on every start, so repointing the cfg is
|
||||
// pointless and the world MUST be flat (Sandbox.sbc directly inside) or the
|
||||
// container exits (code 130).
|
||||
//
|
||||
// We therefore keep a library of inactive worlds in a sibling folder `Worlds`
|
||||
// (plural, NOT mounted into the container) and "switch" by moving directories.
|
||||
// `Worlds` lives on the same filesystem as `World`, so the move is an instant
|
||||
// rename even for multi-GB worlds. The active world's display name is tracked in
|
||||
// a marker file `.gsm-worldname` inside `World`. All files are owned by UID/GID
|
||||
// 1000 (the in-container wine user), so we chown after every mutation.
|
||||
|
||||
function sePaths(server) {
|
||||
const base = `${server.workDir}/appdata/space-engineers`;
|
||||
return {
|
||||
base,
|
||||
config: `${base}/config`,
|
||||
active: `${base}/config/World`,
|
||||
library: `${base}/config/Worlds`,
|
||||
templates: `${base}/bins/SpaceEngineersDedicated/Content/CustomWorlds`,
|
||||
};
|
||||
}
|
||||
|
||||
// World/template names map to directory names. Allow letters, numbers, spaces,
|
||||
// underscore and hyphen only — this both keeps the file system tidy and makes it
|
||||
// safe to interpolate the (double-quoted) name into shell commands.
|
||||
function validateSEWorldName(name) {
|
||||
if (!name || typeof name !== 'string') throw new Error('Weltname fehlt');
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.includes('..')) throw new Error('Ungültiger Weltname');
|
||||
if (!/^[A-Za-z0-9 _-]{1,48}$/.test(trimmed)) {
|
||||
throw new Error('Ungültiger Weltname (erlaubt: Buchstaben, Zahlen, Leerzeichen, _ und -, max. 48 Zeichen)');
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function sanitizeSEName(raw) {
|
||||
const s = (raw || '').trim().replace(/[^A-Za-z0-9 _-]/g, '').slice(0, 48).trim();
|
||||
return s || 'World';
|
||||
}
|
||||
|
||||
// Resolve the active world's display name. Prefer the marker file; otherwise
|
||||
// derive it from the cfg <WorldName> (falling back to <SessionName>) and persist
|
||||
// a marker so subsequent switches have a stable name to archive under.
|
||||
async function ensureSEActiveMarker(server, ssh) {
|
||||
const p = sePaths(server);
|
||||
const marker = await ssh.execCommand(`cat "${p.active}/.gsm-worldname" 2>/dev/null`);
|
||||
if (marker.stdout.trim()) return marker.stdout.trim();
|
||||
|
||||
let derived = '';
|
||||
const cfg = await ssh.execCommand(
|
||||
`grep -m1 -oE "<WorldName>[^<]*" "${p.config}/SpaceEngineers-Dedicated.cfg" 2>/dev/null | sed -E "s/<WorldName>//"`
|
||||
);
|
||||
derived = cfg.stdout.trim();
|
||||
if (!derived) {
|
||||
const sess = await ssh.execCommand(
|
||||
`grep -m1 -oE "<SessionName>[^<]*" "${p.active}/Sandbox_config.sbc" 2>/dev/null | sed -E "s/<SessionName>//"`
|
||||
);
|
||||
derived = sess.stdout.trim();
|
||||
}
|
||||
derived = sanitizeSEName(derived);
|
||||
// best effort: persist marker so the active name is stable from now on
|
||||
await ssh.execCommand(
|
||||
`echo "${derived}" > "${p.active}/.gsm-worldname" && chown 1000:1000 "${p.active}/.gsm-worldname" 2>/dev/null || true`
|
||||
);
|
||||
return derived;
|
||||
}
|
||||
|
||||
// List the active world + every world in the library, with size and mtime.
|
||||
export async function listSpaceEngineersWorlds(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const p = sePaths(server);
|
||||
await ssh.execCommand(`mkdir -p "${p.library}"`);
|
||||
await ensureSEActiveMarker(server, ssh);
|
||||
|
||||
// One pass emits "active|name|hasSandbox|size|mtime" per world. Names are
|
||||
// restricted to a charset without "|" so the delimiter is unambiguous.
|
||||
const script =
|
||||
`ACTIVE="${p.active}"; LIB="${p.library}"; ` +
|
||||
`emit() { d="$1"; act="$2"; nm="$3"; has=0; [ -f "$d/Sandbox.sbc" ] && has=1; ` +
|
||||
`size=$(du -sh "$d" 2>/dev/null | cut -f1); ` +
|
||||
`mt=$(stat -c %Y "$d/Sandbox.sbc" 2>/dev/null || echo 0); ` +
|
||||
`echo "$act|$nm|$has|$size|$mt"; }; ` +
|
||||
`an=$(cat "$ACTIVE/.gsm-worldname" 2>/dev/null); [ -z "$an" ] && an="World"; ` +
|
||||
`emit "$ACTIVE" 1 "$an"; ` +
|
||||
`for d in "$LIB"/*/ ; do [ -d "$d" ] || continue; emit "${'${d%/}'}" 0 "$(basename "$d")"; done`;
|
||||
const r = await ssh.execCommand(script);
|
||||
|
||||
const worlds = [];
|
||||
for (const line of r.stdout.split('\n').map(l => l.trim()).filter(Boolean)) {
|
||||
const parts = line.split('|');
|
||||
if (parts.length < 5) continue;
|
||||
const [act, name, has, size, mt] = parts;
|
||||
worlds.push({
|
||||
name,
|
||||
active: act === '1',
|
||||
valid: has === '1',
|
||||
size: size || '-',
|
||||
modified: mt && mt !== '0' ? Number(mt) * 1000 : null,
|
||||
});
|
||||
}
|
||||
const active = worlds.find(w => w.active)?.name || null;
|
||||
return { active, worlds };
|
||||
}
|
||||
|
||||
// Built-in premade worlds shipped with the dedicated server, usable as templates
|
||||
// for a fresh world. Some templates store the world under a PC/ subfolder.
|
||||
export async function listSpaceEngineersTemplates(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const p = sePaths(server);
|
||||
const script =
|
||||
`for d in "${p.templates}"/*/ ; do [ -d "$d" ] || continue; nm="$(basename "$d")"; ` +
|
||||
`if [ -f "$d/Sandbox.sbc" ] || [ -f "$d/PC/Sandbox.sbc" ]; then echo "$nm"; fi; done`;
|
||||
const r = await ssh.execCommand(script);
|
||||
return r.stdout.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Swap the active world. The server MUST be stopped (checked by the route).
|
||||
export async function switchSpaceEngineersWorld(server, targetName) {
|
||||
const name = validateSEWorldName(targetName);
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const p = sePaths(server);
|
||||
|
||||
const current = await ensureSEActiveMarker(server, ssh);
|
||||
if (name === current) return { message: 'Welt ist bereits aktiv', active: name };
|
||||
|
||||
const tgt = await ssh.execCommand(`test -f "${p.library}/${name}/Sandbox.sbc" && echo OK`);
|
||||
if (!tgt.stdout.includes('OK')) {
|
||||
throw new Error(`Welt "${name}" nicht in der Bibliothek gefunden (oder keine Sandbox.sbc)`);
|
||||
}
|
||||
const coll = await ssh.execCommand(`test -e "${p.library}/${current}" && echo EXISTS`);
|
||||
if (coll.stdout.includes('EXISTS')) {
|
||||
throw new Error(`Kann aktive Welt nicht archivieren: "Worlds/${current}" existiert bereits`);
|
||||
}
|
||||
|
||||
const cmd =
|
||||
`set -e; mkdir -p "${p.library}"; ` +
|
||||
`mv "${p.active}" "${p.library}/${current}"; ` +
|
||||
`mv "${p.library}/${name}" "${p.active}"; ` +
|
||||
`echo "${name}" > "${p.active}/.gsm-worldname"; ` +
|
||||
`chown -R 1000:1000 "${p.active}" "${p.library}/${current}";`;
|
||||
const r = await ssh.execCommand(cmd);
|
||||
if (r.code !== 0) throw new Error(r.stderr || 'Weltwechsel fehlgeschlagen');
|
||||
|
||||
const ver = await ssh.execCommand(`test -f "${p.active}/Sandbox.sbc" && echo OK`);
|
||||
if (!ver.stdout.includes('OK')) throw new Error('Weltwechsel-Verifikation fehlgeschlagen (Sandbox.sbc fehlt)');
|
||||
return { message: 'Welt gewechselt', active: name };
|
||||
}
|
||||
|
||||
// Create a new library world from a built-in template.
|
||||
export async function createSpaceEngineersWorldFromTemplate(server, newName, templateName) {
|
||||
const name = validateSEWorldName(newName);
|
||||
const tmpl = validateSEWorldName(templateName);
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const p = sePaths(server);
|
||||
|
||||
const active = await ensureSEActiveMarker(server, ssh);
|
||||
if (name === active) throw new Error('Eine Welt mit diesem Namen ist gerade aktiv');
|
||||
const exists = await ssh.execCommand(`test -e "${p.library}/${name}" && echo EXISTS`);
|
||||
if (exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" existiert bereits`);
|
||||
|
||||
const src = await ssh.execCommand(
|
||||
`if [ -f "${p.templates}/${tmpl}/Sandbox.sbc" ]; then echo "${p.templates}/${tmpl}"; ` +
|
||||
`elif [ -f "${p.templates}/${tmpl}/PC/Sandbox.sbc" ]; then echo "${p.templates}/${tmpl}/PC"; fi`
|
||||
);
|
||||
const srcDir = src.stdout.trim();
|
||||
if (!srcDir) throw new Error(`Vorlage "${tmpl}" nicht gefunden oder ohne ladbare Welt`);
|
||||
|
||||
const cmd =
|
||||
`set -e; mkdir -p "${p.library}/${name}"; ` +
|
||||
`cp -a "${srcDir}/." "${p.library}/${name}/"; ` +
|
||||
`echo "${name}" > "${p.library}/${name}/.gsm-worldname"; ` +
|
||||
`chown -R 1000:1000 "${p.library}/${name}";`;
|
||||
const r = await ssh.execCommand(cmd, { execOptions: { timeout: 120000 } });
|
||||
if (r.code !== 0) throw new Error(r.stderr || 'Welterstellung fehlgeschlagen');
|
||||
return { message: 'Welt erstellt', name };
|
||||
}
|
||||
|
||||
// Duplicate the currently active world into the library. Server MUST be stopped
|
||||
// (checked by the route) so the copy is not torn by an autosave.
|
||||
export async function cloneSpaceEngineersWorld(server, newName) {
|
||||
const name = validateSEWorldName(newName);
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const p = sePaths(server);
|
||||
|
||||
const active = await ensureSEActiveMarker(server, ssh);
|
||||
if (name === active) throw new Error('Klon-Name muss sich von der aktiven Welt unterscheiden');
|
||||
const exists = await ssh.execCommand(`test -e "${p.library}/${name}" && echo EXISTS`);
|
||||
if (exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" existiert bereits`);
|
||||
|
||||
const cmd =
|
||||
`set -e; mkdir -p "${p.library}/${name}"; ` +
|
||||
`cp -a "${p.active}/." "${p.library}/${name}/"; ` +
|
||||
`echo "${name}" > "${p.library}/${name}/.gsm-worldname"; ` +
|
||||
`chown -R 1000:1000 "${p.library}/${name}";`;
|
||||
const r = await ssh.execCommand(cmd, { execOptions: { timeout: 300000 } });
|
||||
if (r.code !== 0) throw new Error(r.stderr || 'Klonen fehlgeschlagen');
|
||||
return { message: 'Welt geklont', name };
|
||||
}
|
||||
|
||||
export async function deleteSpaceEngineersWorld(server, worldName) {
|
||||
const name = validateSEWorldName(worldName);
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const p = sePaths(server);
|
||||
|
||||
const active = await ensureSEActiveMarker(server, ssh);
|
||||
if (name === active) throw new Error('Aktive Welt kann nicht gelöscht werden. Wechsle zuerst zu einer anderen Welt.');
|
||||
const exists = await ssh.execCommand(`test -d "${p.library}/${name}" && echo EXISTS`);
|
||||
if (!exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" nicht gefunden`);
|
||||
|
||||
const r = await ssh.execCommand(`rm -rf "${p.library}/${name}"`);
|
||||
if (r.code !== 0) throw new Error(r.stderr || 'Löschen fehlgeschlagen');
|
||||
return { message: 'Welt gelöscht', name };
|
||||
}
|
||||
|
||||
export async function renameSpaceEngineersWorld(server, oldName, newName) {
|
||||
const oldN = validateSEWorldName(oldName);
|
||||
const newN = validateSEWorldName(newName);
|
||||
if (oldN === newN) return { message: 'Keine Änderung', name: newN };
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const p = sePaths(server);
|
||||
|
||||
const active = await ensureSEActiveMarker(server, ssh);
|
||||
if (newN === active) throw new Error('Eine Welt mit dem neuen Namen ist gerade aktiv');
|
||||
const collide = await ssh.execCommand(`test -e "${p.library}/${newN}" && echo EXISTS`);
|
||||
if (collide.stdout.includes('EXISTS')) throw new Error(`Welt "${newN}" existiert bereits`);
|
||||
|
||||
if (oldN === active) {
|
||||
const r = await ssh.execCommand(
|
||||
`echo "${newN}" > "${p.active}/.gsm-worldname" && chown 1000:1000 "${p.active}/.gsm-worldname"`
|
||||
);
|
||||
if (r.code !== 0) throw new Error(r.stderr || 'Umbenennen fehlgeschlagen');
|
||||
return { message: 'Aktive Welt umbenannt', name: newN };
|
||||
}
|
||||
|
||||
const exists = await ssh.execCommand(`test -d "${p.library}/${oldN}" && echo EXISTS`);
|
||||
if (!exists.stdout.includes('EXISTS')) throw new Error(`Welt "${oldN}" nicht gefunden`);
|
||||
const r = await ssh.execCommand(
|
||||
`set -e; mv "${p.library}/${oldN}" "${p.library}/${newN}"; ` +
|
||||
`echo "${newN}" > "${p.library}/${newN}/.gsm-worldname"; ` +
|
||||
`chown 1000:1000 "${p.library}/${newN}/.gsm-worldname";`
|
||||
);
|
||||
if (r.code !== 0) throw new Error(r.stderr || 'Umbenennen fehlgeschlagen');
|
||||
return { message: 'Welt umbenannt', name: newN };
|
||||
}
|
||||
|
||||
// ============ HYTALE FUNCTIONS ============
|
||||
const HYTALE_CONFIG_PATH = "/opt/hytale/Server/config.json";
|
||||
const HYTALE_LOGS_PATH = "/opt/hytale/Server/logs";
|
||||
|
||||
@@ -284,6 +284,57 @@ export async function getFactorioWorldSettings(token, saveName) {
|
||||
})
|
||||
}
|
||||
|
||||
// Space Engineers World Management
|
||||
export async function getSEWorlds(token) {
|
||||
return fetchAPI('/servers/spaceengineers/worlds', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSETemplates(token) {
|
||||
return fetchAPI('/servers/spaceengineers/templates', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createSEWorld(token, name, template) {
|
||||
return fetchAPI('/servers/spaceengineers/worlds', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name, template }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function cloneSEWorld(token, name) {
|
||||
return fetchAPI('/servers/spaceengineers/worlds/clone', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function activateSEWorld(token, name) {
|
||||
return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}/activate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
export async function renameSEWorld(token, name, newName) {
|
||||
return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ newName }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSEWorld(token, name) {
|
||||
return fetchAPI(`/servers/spaceengineers/worlds/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-Shutdown Settings
|
||||
export async function getAutoShutdownSettings(token, serverId) {
|
||||
return fetchAPI(`/servers/${serverId}/autoshutdown`, {
|
||||
|
||||
403
gsm-frontend/src/components/SpaceEngineersWorldManager.jsx
Normal file
403
gsm-frontend/src/components/SpaceEngineersWorldManager.jsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
getSEWorlds,
|
||||
getSETemplates,
|
||||
createSEWorld,
|
||||
cloneSEWorld,
|
||||
activateSEWorld,
|
||||
renameSEWorld,
|
||||
deleteSEWorld,
|
||||
} from '../api'
|
||||
|
||||
const NAME_RE = /^[a-zA-Z0-9 _-]{1,48}$/
|
||||
|
||||
export default function SpaceEngineersWorldManager({ server, token, onServerAction }) {
|
||||
const [worlds, setWorlds] = useState([])
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [actionLoading, setActionLoading] = useState(null)
|
||||
|
||||
// modals
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newTemplate, setNewTemplate] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [showClone, setShowClone] = useState(false)
|
||||
const [cloneName, setCloneName] = useState('')
|
||||
|
||||
const [renaming, setRenaming] = useState(null) // world being renamed
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||
|
||||
// The server must be stopped to swap or clone the active world.
|
||||
const running = server.running || server.status === 'starting' || server.status === 'stopping'
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [worldsData, templatesData] = await Promise.all([
|
||||
getSEWorlds(token),
|
||||
getSETemplates(token),
|
||||
])
|
||||
// active world first, rest alphabetically
|
||||
const list = (worldsData.worlds || []).slice().sort((a, b) => {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
setWorlds(list)
|
||||
setTemplates(templatesData.templates || [])
|
||||
if (!newTemplate && templatesData.templates?.length) setNewTemplate(templatesData.templates[0])
|
||||
setError('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [token])
|
||||
|
||||
const fmtDate = (ms) => (ms ? new Date(ms).toLocaleString() : '—')
|
||||
|
||||
const handleActivate = async (name) => {
|
||||
try {
|
||||
setActionLoading(name)
|
||||
setError('')
|
||||
await activateSEWorld(token, name)
|
||||
await fetchData()
|
||||
if (onServerAction) onServerAction()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!NAME_RE.test(newName.trim())) {
|
||||
setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)')
|
||||
return
|
||||
}
|
||||
if (!newTemplate) {
|
||||
setError('Bitte eine Vorlage auswählen')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
await createSEWorld(token, newName.trim(), newTemplate)
|
||||
setShowCreate(false)
|
||||
setNewName('')
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClone = async () => {
|
||||
if (!NAME_RE.test(cloneName.trim())) {
|
||||
setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
await cloneSEWorld(token, cloneName.trim())
|
||||
setShowClone(false)
|
||||
setCloneName('')
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!NAME_RE.test(renameValue.trim())) {
|
||||
setError('Weltname: nur Buchstaben, Zahlen, Leerzeichen, _ und - (max. 48)')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
await renameSEWorld(token, renaming.name, renameValue.trim())
|
||||
setRenaming(null)
|
||||
setRenameValue('')
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (name) => {
|
||||
try {
|
||||
setActionLoading(name)
|
||||
setError('')
|
||||
await deleteSEWorld(token, name)
|
||||
setDeleteConfirm(null)
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-neutral-400">Lade Welten...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{running && (
|
||||
<div className="card p-4 bg-amber-500/10 border-amber-500/30">
|
||||
<div className="text-amber-300 text-sm">
|
||||
Der Server läuft. Welt wechseln und klonen ist nur bei gestopptem Server möglich.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create World Modal */}
|
||||
{showCreate && (
|
||||
<div className="modal-backdrop" onClick={() => !busy && setShowCreate(false)}>
|
||||
<div className="modal max-w-lg fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Neue Welt erstellen</h3>
|
||||
</div>
|
||||
<div className="modal-body space-y-4">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Weltname</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="meine-neue-welt"
|
||||
disabled={busy}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Vorlage</label>
|
||||
<select
|
||||
value={newTemplate}
|
||||
onChange={(e) => setNewTemplate(e.target.value)}
|
||||
disabled={busy}
|
||||
className="input"
|
||||
>
|
||||
{templates.length === 0 && <option value="">Keine Vorlagen gefunden</option>}
|
||||
{templates.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-neutral-500 text-xs mt-2">
|
||||
Die Welt wird aus einer der mitgelieferten Server-Vorlagen erzeugt und in die
|
||||
Bibliothek gelegt. Aktivieren musst du sie danach separat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={() => setShowCreate(false)} disabled={busy} className="btn btn-secondary">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleCreate} disabled={busy || !newName.trim() || !newTemplate} className="btn btn-primary">
|
||||
{busy ? 'Erstelle...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clone Modal */}
|
||||
{showClone && (
|
||||
<div className="modal-backdrop" onClick={() => !busy && setShowClone(false)}>
|
||||
<div className="modal max-w-lg fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Aktuelle Welt klonen</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name des Klons</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cloneName}
|
||||
onChange={(e) => setCloneName(e.target.value)}
|
||||
placeholder="welt-backup"
|
||||
disabled={busy}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-neutral-500 text-xs mt-2">
|
||||
Kopiert die aktuell aktive Welt inklusive aller Bauten, Charaktere und Inventare in
|
||||
die Bibliothek. Das kann je nach Weltgröße einen Moment dauern.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={() => setShowClone(false)} disabled={busy} className="btn btn-secondary">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleClone} disabled={busy || !cloneName.trim()} className="btn btn-primary">
|
||||
{busy ? 'Klone...' : 'Klonen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename Modal */}
|
||||
{renaming && (
|
||||
<div className="modal-backdrop" onClick={() => !busy && setRenaming(null)}>
|
||||
<div className="modal max-w-md fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Welt umbenennen</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Neuer Name für „{renaming.name}"</label>
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
disabled={busy}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={() => setRenaming(null)} disabled={busy} className="btn btn-secondary">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleRename} disabled={busy || !renameValue.trim()} className="btn btn-primary">
|
||||
{busy ? 'Speichere...' : 'Umbenennen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{deleteConfirm && (
|
||||
<div className="modal-backdrop" onClick={() => setDeleteConfirm(null)}>
|
||||
<div className="modal max-w-md fade-in-scale" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3 className="modal-title">Welt löschen?</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p className="text-neutral-300">
|
||||
Welt <span className="text-white font-medium">{deleteConfirm}</span> wirklich löschen?
|
||||
Das kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button onClick={() => setDeleteConfirm(null)} className="btn btn-secondary">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(deleteConfirm)}
|
||||
disabled={actionLoading === deleteConfirm}
|
||||
className="btn btn-destructive"
|
||||
>
|
||||
{actionLoading === deleteConfirm ? 'Lösche...' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-neutral-300">Welten</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowClone(true)}
|
||||
disabled={running}
|
||||
className="btn btn-secondary"
|
||||
title={running ? 'Server zuerst stoppen' : 'Aktuelle Welt klonen'}
|
||||
>
|
||||
Aktuelle klonen
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
|
||||
+ Neue Welt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* World List */}
|
||||
{worlds.length === 0 ? (
|
||||
<div className="card p-8 text-center">
|
||||
<div className="text-neutral-400">Keine Welten gefunden</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{worlds.map((world) => (
|
||||
<div key={world.name} className="card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">{world.active ? '🪐' : '🌑'}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">{world.name}</span>
|
||||
{world.active && <span className="badge badge-success">Aktiv</span>}
|
||||
{!world.valid && <span className="badge badge-secondary">⚠️ keine Sandbox.sbc</span>}
|
||||
</div>
|
||||
<div className="text-neutral-500 text-xs mt-1">
|
||||
{world.size} · {fmtDate(world.modified)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!world.active && (
|
||||
<button
|
||||
onClick={() => handleActivate(world.name)}
|
||||
disabled={running || actionLoading === world.name || !world.valid}
|
||||
className="btn btn-primary text-sm"
|
||||
title={running ? 'Server zuerst stoppen' : 'Diese Welt laden'}
|
||||
>
|
||||
{actionLoading === world.name ? '...' : 'Laden'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setRenaming(world); setRenameValue(world.name) }}
|
||||
className="btn btn-ghost text-neutral-400 hover:text-white"
|
||||
title="Umbenennen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
{!world.active && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(world.name)}
|
||||
disabled={actionLoading === world.name}
|
||||
className="btn btn-ghost text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
title="Löschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useUser } from '../context/UserContext'
|
||||
import MetricsChart from '../components/MetricsChart'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import FactorioWorldManager from '../components/FactorioWorldManager'
|
||||
import SpaceEngineersWorldManager from '../components/SpaceEngineersWorldManager'
|
||||
import PalworldConfigEditor from '../components/PalworldConfigEditor'
|
||||
import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
|
||||
import TerrariaConfigEditor from '../components/TerrariaConfigEditor'
|
||||
@@ -318,7 +319,7 @@ const formatUptime = (seconds) => {
|
||||
...(isModerator && server?.type === 'minecraft' ? [
|
||||
{ id: 'whitelist', label: 'Whitelist' },
|
||||
] : []),
|
||||
...(isModerator && server?.type === 'factorio' ? [
|
||||
...(isModerator && (server?.type === 'factorio' || server?.type === 'spaceengineers') ? [
|
||||
{ id: 'worlds', label: 'Welten' },
|
||||
] : []),
|
||||
...(isModerator && server?.type === 'palworld' ? [
|
||||
@@ -691,6 +692,18 @@ const formatUptime = (seconds) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worlds Tab - Space Engineers only. The manager stays usable while the
|
||||
server runs (switch/clone are disabled), so no full lock here. */}
|
||||
{activeTab === 'worlds' && isModerator && server.type === 'spaceengineers' && (
|
||||
<div className="tab-content">
|
||||
<SpaceEngineersWorldManager
|
||||
server={server}
|
||||
token={token}
|
||||
onServerAction={fetchServer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Tab - Palworld only */}
|
||||
{activeTab === 'config' && isModerator && server.type === 'palworld' && (
|
||||
<div className="tab-content">
|
||||
|
||||
Reference in New Issue
Block a user