Add world management for Space Engineers (list/switch/create/clone/rename/delete)
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:
2026-06-27 23:12:45 +02:00
parent 1db72ce92f
commit a88437f95f
5 changed files with 825 additions and 2 deletions

View File

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

View File

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

View File

@@ -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`, {

View 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>
)
}

View File

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