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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user