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

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