
-
+

[](https://diamonddigital.dev/discord)
diff --git a/docs/API.md b/docs/API.md
index 140c8e6..d7c793a 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -92,7 +92,7 @@ Allowed lifecycle actions: **start** from `stopped`/`crashed`; **stop** from `ru
## Servers
-The server object returned by these endpoints contains the full configuration (name, type, version, port, memory, JVM args, gamemode, difficulty, seed, flags, `group`, timestamps, `state`, `exitCode`, `crashReason`, `javaMajor` — the Java runtime requirement recorded from Mojang metadata at jar download time, null when unknown, …). The on-disk `directory` field is stripped from responses.
+The server object returned by these endpoints contains the full configuration (name, type, version, port, memory, JVM args, gamemode, difficulty, `levelType` (World Type, mirrored from `server.properties`), seed, flags, `group`, timestamps, `state`, `exitCode`, `crashReason`, `javaMajor` — the Java runtime requirement recorded from Mojang metadata at jar download time, null when unknown, …). The on-disk `directory` field is stripped from responses.
### Read
@@ -110,7 +110,7 @@ The server object returned by these endpoints contains the full configuration (n
| Method | Path | Description |
|---|---|---|
-| POST | `/servers` | Create a server. Body: `{name, serverType, version, port, memory, eula, javaArgs?, gamemode?, difficulty?, seed?, group?, customJarUrl?}`. `eula` must be truthy; `customJarUrl` required (http/https) when `serverType` is `custom`. Returns `201 {"success": true, "server": {...}}`; provisioning continues in the background |
+| POST | `/servers` | Create a server. Body: `{name, serverType, version, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, customJarUrl?}`. `eula` must be truthy; `customJarUrl` required (http/https) when `serverType` is `custom`. `gamemode` defaults to `survival` and `difficulty` to `normal`. See [World Type](#world-type) for `levelType`. Returns `201 {"success": true, "server": {...}}`; provisioning continues in the background |
| POST | `/servers/from-modpack` | Create from a Modrinth modpack — see [Modrinth](#modrinth) |
| POST | `/servers/from-mrpack` | Create from an uploaded `.mrpack` file — see [Modrinth](#modrinth) |
| POST | `/servers/:id/duplicate` | Clone a server. Body: `{name, port, includeWorld?, stopFirst?, startAfter?}`. `409` if running and `stopFirst` is not set. Returns `201` |
@@ -145,7 +145,7 @@ The [WebSocket](#websocket-protocol) is the live feed, but it does not accept be
| Method | Path | Description |
|---|---|---|
-| POST | `/servers/:id/edit` | Edit config. Body: `{name, port, memory, javaArgs?, gamemode?, difficulty?, seed?, group?, version?, customJarUrl?, backup?}`. Version changes must be upgrades (release versions compare numerically; snapshot/pre-release versions compare by the provider's chronological ordering) and require the server stopped (`409` otherwise); may download a new jar. Returns `{"success": true, "server": {...}, "versionChanged": bool, "jarChanged": bool}`. With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead |
+| POST | `/servers/:id/edit` | Edit config. Body: `{name, port, memory, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?, version?, customJarUrl?, backup?}`. Version changes must be upgrades (release versions compare numerically; snapshot/pre-release versions compare by the provider's chronological ordering) and require the server stopped (`409` otherwise); may download a new jar. Returns `{"success": true, "server": {...}, "versionChanged": bool, "jarChanged": bool}`. With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead |
| POST | `/servers/:id/group` | Assign the dashboard group. Body: `{group}` (empty/null to ungroup). Returns `{"group": ..., "color": ...}` — `color` is the group's folder color (null when ungrouped) |
| POST | `/servers/:id/autorestart` | Body: `{enabled: bool}`. Returns `{"autoRestart": bool}` |
| POST | `/servers/:id/autostart` | Body: `{enabled: bool}`. Returns `{"autoStart": bool}` |
@@ -155,6 +155,19 @@ The [WebSocket](#websocket-protocol) is the live feed, but it does not accept be
| POST | `/servers/:id/properties` | Update `server.properties`. Body: an object keyed by property name, plus an optional `backup` flag (reserved — never written as a property). A partial update: properties left out keep their current value, and keys not already in the file are ignored. Toggles take `true`/`false` (boolean or string) — anything else is `400`. With `backup: true` see [Restore-point backups](#restore-point-backups) — returns `202` instead of `{"success": true}` |
| POST | `/servers/:id/edit-file` | Save a text file inside the server directory. Body: `{filePath, content}`. `403` on path traversal, `400` if the target is not text (see [Text vs binary](#files)) |
+### World Type
+
+`levelType` is `server.properties`' `level-type`, and like the seed it only applies when a new world is generated. Minecraft 1.19 renamed the world types and reads none of the old names it doesn't happen to share, so each version takes its own:
+
+| Minecraft | Values |
+|---|---|
+| 1.19 and later (including 26.x) | `minecraft:normal`, `minecraft:flat`, `minecraft:large_biomes`, `minecraft:amplified`, `minecraft:single_biome_surface` |
+| Before 1.19 | `default`, `flat`, `largeBiomes`, `amplified` |
+
+Either spelling is accepted and stored in the one the server's version reads, so `flat` on a 26.x server is saved as `minecraft:flat`. A type the version has no equivalent of (Single Biome before 1.19) is `400`. Left out on create, it defaults to the version's normal world; left out on `/edit`, it keeps its value, except that a version change translates it into the new version's spelling. `/edit` also accepts the value already in the file as it stands, so a modpack's own world type survives a settings save.
+
+New servers' `server.properties` carries every setting Minecraft only reads when it generates a world, so they can be set before the first start: `hardcore`, `level-type`, `generator-settings`, and from 1.19.3 `initial-enabled-packs` / `initial-disabled-packs`, each with the default that version would write itself.
+
### Files
Paths are relative to the server directory and are resolved against it with symlinks fully resolved — anything landing outside returns `403 {"error": "Access denied."}`.
@@ -383,10 +396,10 @@ Two quirks of Modrinth's search are worked around inside the proxy, so these end
| Method | Path | Description |
|---|---|---|
-| POST | `/servers/from-modpack` | Body: `{projectId, versionId, name, port, memory, eula, javaArgs?, gamemode?, difficulty?, seed?, group?}`. Pack metadata is re-fetched server-side (client values cannot spoof it); the loader (Fabric/Forge/NeoForge) and Minecraft version come from the pack itself. Returns `201 {"success": true, "server": {...}}`; the install continues in the background (see progress below). `400` for Quilt packs, loaderless packs, or versions with no `.mrpack` file; `404`/`429`/`502` from the Modrinth lookups as above |
+| POST | `/servers/from-modpack` | Body: `{projectId, versionId, name, port, memory, eula, javaArgs?, gamemode?, difficulty?, levelType?, seed?, group?}`. Pack metadata is re-fetched server-side (client values cannot spoof it); the loader (Fabric/Forge/NeoForge) and Minecraft version come from the pack itself. Returns `201 {"success": true, "server": {...}}`; the install continues in the background (see progress below). `400` for Quilt packs, loaderless packs, or versions with no `.mrpack` file; `404`/`429`/`502` from the Modrinth lookups as above |
| POST | `/servers/from-mrpack` | Create from an uploaded `.mrpack`. Multipart, file field `mrpack`, max 2 GiB, plus the same base fields as text fields. The pack is parsed and the loader resolved **before** any server record is created, so malformed or Quilt packs fail with a clean `400`. Returns `201` + background install. Also accepts [chunked uploads](#chunked-uploads-dgup) at `/servers/from-mrpack/upload/*` |
-The background install downloads the pack's files (SHA-512 verified; download hosts restricted to the mrpack spec whitelist), installs the loader server pinned to the pack's loader version, and applies `overrides/` then `server-overrides/`. A `server.properties` the pack ships is kept: the port, gamemode and difficulty from the request are written over it (and the seed, when one is given — a blank seed keeps the pack's), `online-mode`, `enable-rcon`, `rcon.password` and `server-ip` are always reset to `true`, `false`, empty and empty, and Craftbox's defaults fill in any other key it leaves out. If the pack had set any of those four differently, the `complete` message's `warnings` says so. Mods the pack marks as unsupported on the server are still installed, but land disabled on disk and tagged `client` in the mod environment map — so they show as **Client Only** on the plugins page and are included in the status page's mods download for players, without the loader ever seeing them. Progress streams over the WebSocket as `operation: "modpack-install"`, `status: "progress"` messages with payload `{phase, done?, total?}` — phases: `download`, `parse`, `loader`, `files`, `overrides`, `finalize` — ending in `complete` or `failed`. The `files` phase carries `done`/`total` counts of **mods** (every jar destined for `mods/`, from the manifest and from the overrides, client-only ones included — so the total matches what the mods page lists afterwards, not the raw file count); it keeps ticking during the `overrides` phase as any mods shipped there land. On `failed` the half-built server is removed automatically (see [Asynchronous operations](#asynchronous-operations)). The created server records a `modpack` block (`{projectId, versionId, name, versionNumber, iconUrl, source: "modrinth"|"file", installedAt}`) for future tooling; it survives export/import.
+The background install downloads the pack's files (SHA-512 verified; download hosts restricted to the mrpack spec whitelist), installs the loader server pinned to the pack's loader version, and applies `overrides/` then `server-overrides/`. No pack file, whether from the manifest or the overrides, may land among the server's launch files: anything under `libraries/` (where Forge and NeoForge keep the argument file handed to the JVM), a `.jar` or `*_args.txt` at the top of the server directory, `user_jvm_args.txt`, `run.sh`/`run.bat` or `fabric-server-launcher.properties`. Such files are skipped and named in the `complete` message's `warnings`, so a pack cannot change how the server is started. A `server.properties` the pack ships is kept: the port, gamemode and difficulty from the request are written over it (and the seed and `levelType`, when given — left blank, the pack's are kept), `online-mode`, `enable-rcon`, `rcon.password` and `server-ip` are always reset to `true`, `false`, empty and empty, and Craftbox's defaults fill in any other key it leaves out. If the pack had set any of those four differently, the `complete` message's `warnings` says so. Mods the pack marks as unsupported on the server are still installed, but land disabled on disk and tagged `client` in the mod environment map — so they show as **Client Only** on the plugins page and are included in the status page's mods download for players, without the loader ever seeing them. Progress streams over the WebSocket as `operation: "modpack-install"`, `status: "progress"` messages with payload `{phase, done?, total?}` — phases: `download`, `parse`, `loader`, `files`, `overrides`, `finalize` — ending in `complete` or `failed`. The `files` phase carries `done`/`total` counts of **mods** (every jar destined for `mods/`, from the manifest and from the overrides, client-only ones included — so the total matches what the mods page lists afterwards, not the raw file count); it keeps ticking during the `overrides` phase as any mods shipped there land. On `failed` the half-built server is removed automatically (see [Asynchronous operations](#asynchronous-operations)). The created server records a `modpack` block (`{projectId, versionId, name, versionNumber, iconUrl, source: "modrinth"|"file", installedAt}`) for future tooling; it survives export/import.
### Install a mod or plugin into an existing server
@@ -405,7 +418,7 @@ The background install downloads the pack's files (SHA-512 verified; download ho
| POST | `/templates` | Create from an existing server. Body: `{serverId, name, stopFirst?, startAfter?}`. Returns `201` |
| DELETE | `/templates/:id` | Delete a template |
-Templates capture reusable configuration (type, version, memory, JVM args, gamemode, difficulty, port, behavior flags) — not world data, files, or dashboard groups.
+Templates capture reusable configuration (type, version, memory, JVM args, gamemode, difficulty, world type, port, behavior flags) — not world data, files, or dashboard groups.
## API keys
diff --git a/docs/img/screenshots/create.png b/docs/img/screenshots/create.png
index 4e15b8e..85c2a3d 100644
Binary files a/docs/img/screenshots/create.png and b/docs/img/screenshots/create.png differ
diff --git a/docs/img/screenshots/settings.png b/docs/img/screenshots/settings.png
index 79543cd..0ffe213 100644
Binary files a/docs/img/screenshots/settings.png and b/docs/img/screenshots/settings.png differ
diff --git a/package-lock.json b/package-lock.json
index 733b61f..16eb497 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "craftbox",
- "version": "1.2.1",
+ "version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "craftbox",
- "version": "1.2.1",
+ "version": "1.2.2",
"license": "AGPL-3.0-only",
"dependencies": {
"archiver": "^8.0.0",
diff --git a/package.json b/package.json
index cdc6fd7..2930719 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "craftbox",
- "version": "1.2.1",
+ "version": "1.2.2",
"description": "A modern self-hosted platform for managing Minecraft servers with built-in mod support.",
"main": "src/server.js",
"funding": {
diff --git a/public/js/create.js b/public/js/create.js
index 98d8eeb..ec9dbf1 100644
--- a/public/js/create.js
+++ b/public/js/create.js
@@ -49,6 +49,56 @@ const versionPicker = CraftboxVersionPicker({
function setVersionField(id) {
versionHidden.value = id || '';
versionDisplay.value = id || '';
+ if (!inModpackSource()) renderWorldTypes(selectedType === 'custom' ? '' : id);
+}
+
+// ── World Type ──
+// Minecraft 1.19 renamed the world types (default → minecraft:normal) and
+// reads no old name it doesn't happen to share, so the list follows the chosen
+// version. A modpack keeps its own world type unless one is picked.
+const levelTypeSelect = document.getElementById('levelType');
+const WORLD_TYPES = JSON.parse(levelTypeSelect.dataset.worldTypes);
+const MODPACK_WORLD_TYPE = { value: '', label: 'Modpack default' };
+
+function inModpackSource() {
+ return createMode === 'modpack' || selectedSource === 'modpack';
+}
+
+// Mirrors isAtLeast(version, '1.19', '22w11a') in src/utils/mcVersion.js. A
+// blank or unrecognised version (custom jars) counts as current.
+function usesWorldPresets(version) {
+ const v = String(version || '').trim();
+ const snapshot = /^(\d{2})w(\d{2})/.exec(v);
+ if (snapshot) return Number(snapshot[1]) * 100 + Number(snapshot[2]) >= 2211;
+ const release = /^(\d+)\.(\d+)/.exec(v);
+ if (!release) return true;
+ return Number(release[1]) > 1 || Number(release[2]) >= 19;
+}
+
+// Rebuild the options for `version` (or the modpack list), keeping the
+// current choice where the new list has the same world type under another
+// name. `select` picks by label instead.
+function renderWorldTypes(version, { modpack = false, select } = {}) {
+ const list = modpack
+ ? [MODPACK_WORLD_TYPE].concat(WORLD_TYPES.presets)
+ : (usesWorldPresets(version) ? WORLD_TYPES.presets : WORLD_TYPES.legacy);
+ const wanted = select !== undefined ? select : (levelTypeSelect.selectedOptions[0] || {}).textContent;
+ levelTypeSelect.innerHTML = '';
+ list.forEach(function (t) {
+ const opt = document.createElement('option');
+ opt.value = t.value;
+ opt.textContent = t.label;
+ levelTypeSelect.appendChild(opt);
+ });
+ const keep = list.find(function (t) { return t.label === wanted; });
+ levelTypeSelect.value = keep ? keep.value : list[0].value;
+}
+
+// A saved world type's label, whichever list it came from
+function worldTypeLabel(value) {
+ const all = WORLD_TYPES.presets.concat(WORLD_TYPES.legacy);
+ const match = all.find(function (t) { return t.value.toLowerCase() === String(value || '').toLowerCase(); });
+ return match ? match.label : undefined;
}
function openVersionPicker() {
@@ -194,6 +244,7 @@ async function selectType(typeId) {
customUrlGroup.classList.remove('d-none');
versionDisplay.removeAttribute('required');
setCustomNoticeVisible(true);
+ renderWorldTypes('');
centerLoneRowItems(form);
} else {
versionGroup.classList.remove('d-none');
@@ -340,6 +391,9 @@ templateSelect.addEventListener('change', async () => {
if (t.port) document.getElementById('port').value = t.port;
if (t.gamemode) document.getElementById('gamemode').value = t.gamemode;
if (t.difficulty) document.getElementById('difficulty').value = t.difficulty;
+ if (t.levelType && worldTypeLabel(t.levelType)) {
+ renderWorldTypes(t.serverType === 'custom' ? '' : versionHidden.value, { select: worldTypeLabel(t.levelType) });
+ }
// Advanced options
if (t.memory && t.memory !== 2048) {
@@ -380,6 +434,7 @@ function collectBaseFields() {
javaArgs: document.getElementById('javaArgs').value,
gamemode: document.getElementById('gamemode').value,
difficulty: document.getElementById('difficulty').value,
+ levelType: levelTypeSelect.value,
seed: document.getElementById('seed').value,
group: document.getElementById('group').value
};
@@ -438,6 +493,7 @@ async function enterModpackMode() {
createSourceGroup.classList.add('d-none');
hideTypeAndVersionPickers();
modpackSummary.classList.remove('d-none');
+ renderWorldTypes(null, { modpack: true, select: MODPACK_WORLD_TYPE.label });
suggestModpackMemory();
validateCreateForm();
@@ -526,6 +582,7 @@ function applySource(source) {
versionDisplay.removeAttribute('required');
customUrlGroup.classList.add('d-none');
setCustomNoticeVisible(false);
+ renderWorldTypes(null, { modpack: true, select: MODPACK_WORLD_TYPE.label });
suggestModpackMemory();
} else {
mrpackFileInput.removeAttribute('required');
@@ -537,6 +594,11 @@ function applySource(source) {
versionGroup.classList.remove('d-none');
versionDisplay.setAttribute('required', '');
}
+ // Back from the modpack list: "Modpack default" has no counterpart,
+ // so this falls to the version's default unless a type was picked
+ if (levelTypeSelect.querySelector('option[value=""]')) {
+ renderWorldTypes(selectedType === 'custom' ? '' : versionHidden.value);
+ }
revertModpackMemory();
}
validateCreateForm();
diff --git a/src/mc/ServerProcess.js b/src/mc/ServerProcess.js
index 11feac7..1038f71 100644
--- a/src/mc/ServerProcess.js
+++ b/src/mc/ServerProcess.js
@@ -286,6 +286,17 @@ class ServerProcess extends EventEmitter {
}
}
+ /**
+ * The installed build's own folder first. Its args file becomes the JVM's
+ * command line, so another folder under libraries/ (one a modpack or an
+ * upload left there) mustn't outrank it. With no recorded build, or that
+ * folder missing its args file, the newest folder is used as before.
+ */
+ _ownBuildFirst(versions, ownDir) {
+ if (!this.config.build || !versions.includes(ownDir)) return versions;
+ return [ownDir, ...versions.filter(v => v !== ownDir)];
+ }
+
/**
* Find Forge args file for 1.17+ style installations.
*/
@@ -303,7 +314,7 @@ class ServerProcess extends EventEmitter {
}
return 0;
});
- for (const ver of versions) {
+ for (const ver of this._ownBuildFirst(versions, `${this.config.version}-${this.config.build}`)) {
const argsName = process.platform === 'win32' ? 'win_args.txt' : 'unix_args.txt';
const argsPath = path.join(libDir, ver, argsName);
if (fs.existsSync(argsPath)) {
@@ -332,7 +343,7 @@ class ServerProcess extends EventEmitter {
}
return 0;
});
- for (const ver of versions) {
+ for (const ver of this._ownBuildFirst(versions, String(this.config.build))) {
const argsName = process.platform === 'win32' ? 'win_args.txt' : 'unix_args.txt';
const argsPath = path.join(libDir, ver, argsName);
if (fs.existsSync(argsPath)) {
diff --git a/src/mc/modpackInstaller.js b/src/mc/modpackInstaller.js
index af6e091..fc2a16a 100644
--- a/src/mc/modpackInstaller.js
+++ b/src/mc/modpackInstaller.js
@@ -11,6 +11,7 @@ const path = require('path');
const StreamZip = require('node-stream-zip');
const { downloadServerJar } = require('./downloader');
const { writeServerProperties, writeEula, parseServerProperties } = require('./serverProperties');
+const { worldTypeFor } = require('./worldTypes');
const { downloadToFile, assertWhitelistedUrl } = require('../utils/httpDownload');
const { setServerIcon } = require('../utils/serverIcon');
const { DISABLED_SUFFIX } = require('../utils/modEnvironment');
@@ -27,6 +28,22 @@ const DISK_HEADROOM_BYTES = 512 * 1024 * 1024;
// by the filename listModFiles() reports for that folder).
const MOD_JAR_RE = /^mods\/[^/]+\.jar$/i;
+// Files that decide how the server starts rather than what it runs. The
+// loader's own install tree (libraries/, where Forge and NeoForge keep the
+// unix_args.txt handed to the JVM as its arguments), the jars at the top of the
+// server directory, and the start scripts and argument files loaders ship. A
+// pack's mods already run inside the server, but it gets no say in the JVM's
+// command line (-javaagent, -XX:OnOutOfMemoryError=...), so a pack file that
+// would land on one of these is skipped.
+const LAUNCH_FILES = new Set(['user_jvm_args.txt', 'run.sh', 'run.bat', 'fabric-server-launcher.properties']);
+
+function isLaunchFile(serverDir, target) {
+ const rel = path.relative(path.resolve(serverDir), target).split(path.sep).join('/').toLowerCase();
+ if (rel.startsWith('libraries/')) return true;
+ if (rel.includes('/')) return false;
+ return LAUNCH_FILES.has(rel) || rel.endsWith('.jar') || rel.endsWith('_args.txt');
+}
+
// server.properties settings a pack doesn't get to decide for the host, even
// though the rest of its file is kept: switching off account checks, opening
// RCON with a password everyone who has the pack knows, or binding to an
@@ -163,7 +180,7 @@ async function runPool(items, concurrency, worker) {
* @param {string} opts.serverId
* @param {string} opts.serverDir - Absolute path
* @param {object} opts.mrpack - { url, sha512 } (fetch it) or { localPath } (already on disk)
- * @param {object} opts.baseConfig - { port, gamemode, difficulty, seed } for server.properties
+ * @param {object} opts.baseConfig - { port, gamemode, difficulty, levelType, seed } for server.properties
* @param {string|null} [opts.iconUrl] - Modrinth CDN icon to use as the server icon (non-fatal)
* @param {function} [opts.onProgress] - (phase, done, total)
* @returns {Promise<{serverType, mcVersion, loaderBuild, build, filesInstalled, modsInstalled, clientOnlyMods, manifestName, manifestVersionId, warnings}>}
@@ -205,6 +222,7 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl
const files = [];
const seenPaths = new Set();
const clientOnlyMods = [];
+ const skippedLaunchFiles = [];
// Every jar that ends up in mods/, from the manifest or from an override.
// Keyed by filename so an override replacing a manifest mod counts once.
const modJars = new Set();
@@ -219,6 +237,10 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl
}
seenPaths.add(relKey);
let dest = sanitizeEntryPath(serverDir, rel);
+ if (isLaunchFile(serverDir, dest)) {
+ skippedLaunchFiles.push(rel);
+ continue;
+ }
if (MOD_JAR_RE.test(rel)) {
modJars.add(path.basename(rel).toLowerCase());
// Client-only mods are still installed, but land pre-disabled and
@@ -335,7 +357,9 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl
const relative = entryName.slice(prefix.length);
if (!relative) continue;
const target = sanitizeEntryPath(serverDir, relative);
- if (entry.isDirectory) {
+ if (isLaunchFile(serverDir, target)) {
+ if (!entry.isDirectory) skippedLaunchFiles.push(relative);
+ } else if (entry.isDirectory) {
await fs.promises.mkdir(target, { recursive: true });
} else {
await fs.promises.mkdir(path.dirname(target), { recursive: true });
@@ -346,12 +370,24 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl
}
}
+ if (skippedLaunchFiles.length > 0) {
+ const shown = skippedLaunchFiles.slice(0, 5).join(', ')
+ + (skippedLaunchFiles.length > 5 ? ` and ${skippedLaunchFiles.length - 5} more` : '');
+ warnings.push(`Skipped ${skippedLaunchFiles.length} file(s) the modpack tried to place among the server's launch files (${shown}). A modpack can't change how the server is started.`);
+ log('warn', `Modpack install ${serverId}: skipped launch files: ${skippedLaunchFiles.join(', ')}`);
+ }
+
// ── Phase 6: finalize ──
emit('finalize');
// After overrides on purpose: Craftbox-managed values (port, gamemode,
- // difficulty, seed) must win over any server.properties the pack ships,
- // while the rest of the pack's file is kept. A blank seed is no choice
- // at all, so it leaves a seed the pack ships in place.
+ // difficulty, seed, world type) must win over any server.properties the
+ // pack ships, while the rest of the pack's file is kept. A blank seed or
+ // world type is no choice at all, so it leaves the pack's in place;
+ // anything the file still lacks gets this version's default.
+ const levelType = baseConfig.levelType ? worldTypeFor(baseConfig.levelType, mcVersion) : null;
+ if (baseConfig.levelType && !levelType) {
+ warnings.push(`World type ${baseConfig.levelType} isn't available for Minecraft ${mcVersion}, so the modpack's own was kept.`);
+ }
const packProps = parseServerProperties(serverDir);
const overruled = Object.keys(PINNED_PROPERTIES)
.filter(key => packProps[key] && packProps[key] !== String(PINNED_PROPERTIES[key]));
@@ -364,8 +400,9 @@ async function installModpack({ serverId, serverDir, mrpack, baseConfig, iconUrl
serverPort: baseConfig.port,
gamemode: baseConfig.gamemode,
difficulty: baseConfig.difficulty,
+ levelType: levelType || undefined,
levelSeed: baseConfig.seed || undefined
- }, { mergeExisting: true });
+ }, { mergeExisting: true, version: mcVersion });
writeEula(serverDir);
if (iconUrl) {
diff --git a/src/mc/propertyMeta.js b/src/mc/propertyMeta.js
index 7d5cd9e..54aceb6 100644
--- a/src/mc/propertyMeta.js
+++ b/src/mc/propertyMeta.js
@@ -1,6 +1,8 @@
// Metadata for known Minecraft server.properties keys
// Used by the properties editor to render appropriate form fields
+const { WORLD_PRESETS } = require('./worldTypes');
+
const PROPERTY_META = {
// --- Gameplay ---
'gamemode': {
@@ -19,9 +21,9 @@ const PROPERTY_META = {
{ value: 'easy', label: 'Easy' },
{ value: 'normal', label: 'Normal' },
{ value: 'hard', label: 'Hard' }
- ], default: 'easy'
+ ], default: 'normal'
},
- 'hardcore': { type: 'boolean', label: 'Hardcore', group: 'gameplay', default: 'false', description: 'One life only — players cannot respawn' },
+ 'hardcore': { type: 'boolean', label: 'Hardcore', group: 'gameplay', default: 'false', description: 'One life only: players cannot respawn. Only applies when generating a new world' },
'pvp': { type: 'boolean', label: 'PvP', group: 'gameplay', default: 'true' },
'max-players': { type: 'number', label: 'Max Players', group: 'gameplay', min: 0, max: 999, default: '20' },
'force-gamemode': { type: 'boolean', label: 'Force Game Mode', group: 'gameplay', default: 'false', description: 'Force players to join in the default game mode' },
@@ -32,24 +34,22 @@ const PROPERTY_META = {
// --- World ---
'level-name': { type: 'string', label: 'World Name', group: 'world', default: 'world' },
'level-seed': { type: 'string', label: 'World Seed', group: 'world', default: '', description: 'Only applies when generating a new world' },
- // Unescaped, as parseServerProperties returns them. Minecraft writes
- // `minecraft\:normal`, but reads a bare colon in a value just the same.
+ // The 1.19+ presets; the properties page swaps in the list for the
+ // server's own version (worldTypes.js).
'level-type': {
type: 'enum', label: 'World Type', group: 'world',
- options: [
- { value: 'minecraft:normal', label: 'Normal' },
- { value: 'minecraft:flat', label: 'Flat' },
- { value: 'minecraft:large_biomes', label: 'Large Biomes' },
- { value: 'minecraft:amplified', label: 'Amplified' },
- { value: 'minecraft:single_biome_surface', label: 'Single Biome' }
- ], default: 'minecraft:normal'
+ options: WORLD_PRESETS, default: WORLD_PRESETS[0].value,
+ description: 'Only applies when generating a new world'
},
- 'generate-structures': { type: 'boolean', label: 'Generate Structures', group: 'world', default: 'true' },
+ 'generator-settings': { type: 'string', label: 'Generator Settings', group: 'world', default: '', description: 'Layers for a flat world, as JSON. Only applies when generating a new world' },
+ 'generate-structures': { type: 'boolean', label: 'Generate Structures', group: 'world', default: 'true', description: 'Only applies to newly generated chunks' },
'max-world-size': { type: 'number', label: 'Max World Size', group: 'world', min: 1, max: 29999984, default: '29999984', description: 'World border radius in blocks' },
'spawn-animals': { type: 'boolean', label: 'Spawn Animals', group: 'world', default: 'true' },
'spawn-monsters': { type: 'boolean', label: 'Spawn Monsters', group: 'world', default: 'true' },
'spawn-npcs': { type: 'boolean', label: 'Spawn Villagers', group: 'world', default: 'true' },
'allow-nether': { type: 'boolean', label: 'Allow Nether', group: 'world', default: 'true' },
+ 'initial-enabled-packs': { type: 'string', label: 'Enabled Datapacks', group: 'world', default: 'vanilla', description: 'Comma-separated. Only applies when generating a new world' },
+ 'initial-disabled-packs': { type: 'string', label: 'Disabled Datapacks', group: 'world', default: '', description: 'Comma-separated. Only applies when generating a new world' },
// --- Network ---
'server-port': { type: 'number', label: 'Server Port', group: 'network', min: 1024, max: 65535, default: '25565' },
diff --git a/src/mc/serverProperties.js b/src/mc/serverProperties.js
index f803408..b00cbf4 100644
--- a/src/mc/serverProperties.js
+++ b/src/mc/serverProperties.js
@@ -1,10 +1,11 @@
const fs = require('fs');
const path = require('path');
+const { firstStartProperties } = require('./worldTypes');
const DEFAULT_PROPERTIES = {
'server-port': 25565,
'gamemode': 'survival',
- 'difficulty': 'easy',
+ 'difficulty': 'normal',
'max-players': 20,
'motd': 'A Minecraft Server',
'online-mode': true,
@@ -33,6 +34,17 @@ const DEFAULT_PROPERTIES = {
'entity-broadcast-range-percentage': 100
};
+// Craftbox's defaults for a server on `version`, with the settings Minecraft
+// only reads when it generates a world placed after the seed.
+function defaultsFor(version) {
+ const props = {};
+ for (const [key, value] of Object.entries(DEFAULT_PROPERTIES)) {
+ props[key] = value;
+ if (key === 'level-seed') Object.assign(props, firstStartProperties(version));
+ }
+ return props;
+}
+
// Map writeServerProperties overrides (camelCase keys, or raw kebab-case keys)
// to server.properties keys.
function resolveOverrides(overrides) {
@@ -44,6 +56,7 @@ function resolveOverrides(overrides) {
if (overrides.difficulty !== undefined) props['difficulty'] = overrides.difficulty;
if (overrides.viewDistance !== undefined) props['view-distance'] = overrides.viewDistance;
if (overrides.levelSeed !== undefined) props['level-seed'] = overrides.levelSeed;
+ if (overrides.levelType !== undefined) props['level-type'] = overrides.levelType;
// Allow raw property overrides (`level-seed`, `rcon.password`)
Object.entries(overrides).forEach(([key, value]) => {
@@ -60,8 +73,10 @@ function resolveOverrides(overrides) {
* @param {boolean} [options.mergeExisting] - Keep a server.properties already in
* the directory (e.g. one a modpack shipped): only the overrides are forced,
* and defaults fill in just the keys it leaves out.
+ * @param {string} [options.version] - The server's Minecraft version, which
+ * decides the world-generation defaults (see worldTypes.js)
*/
-function writeServerProperties(serverDir, overrides = {}, { mergeExisting = false } = {}) {
+function writeServerProperties(serverDir, overrides = {}, { mergeExisting = false, version = '' } = {}) {
const managed = resolveOverrides(overrides);
if (mergeExisting && fs.existsSync(path.join(serverDir, 'server.properties'))) {
@@ -70,14 +85,14 @@ function writeServerProperties(serverDir, overrides = {}, { mergeExisting = fals
// unicode escapes Minecraft writes for a MOTD's colour codes.
const existing = parseServerProperties(serverDir);
const missingDefaults = {};
- for (const [key, value] of Object.entries(DEFAULT_PROPERTIES)) {
+ for (const [key, value] of Object.entries(defaultsFor(version))) {
if (!(key in existing)) missingDefaults[key] = value;
}
updateServerProperties(serverDir, { ...missingDefaults, ...managed });
return;
}
- const props = { ...DEFAULT_PROPERTIES, ...managed };
+ const props = { ...defaultsFor(version), ...managed };
const lines = ['#Minecraft server properties', `#Generated by Craftbox on ${new Date().toISOString()}`];
for (const [key, value] of Object.entries(props)) {
diff --git a/src/mc/syncServerConfig.js b/src/mc/syncServerConfig.js
index 872a06c..a3b1ad0 100644
--- a/src/mc/syncServerConfig.js
+++ b/src/mc/syncServerConfig.js
@@ -49,6 +49,12 @@ async function syncServerConfig(serverId) {
changed = true;
}
+ if (props['level-type'] && props['level-type'] !== server.levelType) {
+ log('info', `[${server.name}] Config sync: world type ${server.levelType || '(none)'} → ${props['level-type']}`);
+ server.levelType = props['level-type'];
+ changed = true;
+ }
+
// Sync EULA acceptance from eula.txt
const eulaPath = path.join(serverDir, 'eula.txt');
if (fs.existsSync(eulaPath)) {
diff --git a/src/mc/worldTypes.js b/src/mc/worldTypes.js
new file mode 100644
index 0000000..d6332e7
--- /dev/null
+++ b/src/mc/worldTypes.js
@@ -0,0 +1,63 @@
+// World Type (server.properties `level-type`) and the other settings Minecraft
+// only reads when it generates a new world. Values are unescaped, as
+// parseServerProperties returns them; Minecraft reads a bare colon the same.
+const { isAtLeast } = require('../utils/mcVersion');
+
+// Minecraft 1.19 replaced world types with namespaced world presets. It reads
+// none of the old names it doesn't happen to share: `largeBiomes` or `default`
+// on a 1.19+ server generates a normal world, and `minecraft:flat` on 1.18 does
+// the same.
+const WORLD_PRESETS = [
+ { value: 'minecraft:normal', label: 'Normal' },
+ { value: 'minecraft:flat', label: 'Flat' },
+ { value: 'minecraft:large_biomes', label: 'Large Biomes' },
+ { value: 'minecraft:amplified', label: 'Amplified' },
+ { value: 'minecraft:single_biome_surface', label: 'Single Biome' }
+];
+const LEGACY_WORLD_TYPES = [
+ { value: 'default', label: 'Normal' },
+ { value: 'flat', label: 'Flat' },
+ { value: 'largeBiomes', label: 'Large Biomes' },
+ { value: 'amplified', label: 'Amplified' }
+];
+
+const usesPresets = (version) => isAtLeast(version, '1.19', '22w11a');
+
+/** The World Type options a server on `version` understands, default first. */
+function worldTypesFor(version) {
+ return usesPresets(version) ? WORLD_PRESETS : LEGACY_WORLD_TYPES;
+}
+
+/**
+ * A world type in the form `version` reads, matched by what it is rather than
+ * how it's spelt (`flat` ↔ `minecraft:flat`). Null for a value that is no world
+ * type Craftbox knows, or one the version has no equivalent of (Single Biome
+ * before 1.19).
+ */
+function worldTypeFor(value, version) {
+ const wanted = String(value || '').trim().toLowerCase();
+ const known = [...WORLD_PRESETS, ...LEGACY_WORLD_TYPES].find(t => t.value.toLowerCase() === wanted);
+ if (!known) return null;
+ return worldTypesFor(version).find(t => t.label === known.label)?.value || null;
+}
+
+/**
+ * Settings Minecraft only reads when it generates a new world, with the
+ * defaults `version` writes itself. Written into a new server's
+ * server.properties so they can be set before the first start, since some
+ * versions (1.17) never write them at all.
+ */
+function firstStartProperties(version) {
+ const props = {
+ 'hardcore': false,
+ 'level-type': worldTypesFor(version)[0].value,
+ 'generator-settings': isAtLeast(version, '1.18', '21w37a') ? '{}' : ''
+ };
+ if (isAtLeast(version, '1.19.3', '22w42a')) {
+ props['initial-enabled-packs'] = 'vanilla';
+ props['initial-disabled-packs'] = '';
+ }
+ return props;
+}
+
+module.exports = { WORLD_PRESETS, LEGACY_WORLD_TYPES, worldTypesFor, worldTypeFor, firstStartProperties };
diff --git a/src/routes/api-v1/servers.js b/src/routes/api-v1/servers.js
index d60b3c9..7d993e4 100644
--- a/src/routes/api-v1/servers.js
+++ b/src/routes/api-v1/servers.js
@@ -25,6 +25,7 @@ const { clearStatsHistory, getStatsHistory } = require('../../utils/statsHistory
const { setServerIcon, resetServerIcon, removeServerIcon, getIconPath, copyDefaultIcon } = require('../../utils/serverIcon');
const { writeServerProperties, writeEula, parseServerProperties, updateServerProperties } = require('../../mc/serverProperties');
const { PROPERTY_META } = require('../../mc/propertyMeta');
+const { worldTypeFor, worldTypesFor } = require('../../mc/worldTypes');
const { getContentType } = require('../../utils/contentType');
const { copyModEnvMap, setModEnvMap, getModEnvMap } = require('../../utils/modEnvironment');
const { isZipFile, cleanupTempFiles } = require('../../utils/uploadSafety');
@@ -1032,7 +1033,7 @@ router.post('/servers/:id/motd', async (req, res) => {
// from-modpack, from-mrpack) so the rules cannot drift apart.
// Returns { error } on failure, otherwise the normalized values.
function validateBaseServerFields(body) {
- const { name, port, memory, javaArgs, eula, gamemode, difficulty, seed, group } = body || {};
+ const { name, port, memory, javaArgs, eula, gamemode, difficulty, levelType, seed, group } = body || {};
if (!name || !port || !memory) {
return { error: 'All required fields must be filled.' };
@@ -1069,13 +1070,21 @@ function validateBaseServerFields(body) {
return { error: GROUP_NAME_ERROR };
}
+ // Blank means the default (or, for a modpack, the pack's own). Which
+ // spelling a version reads is settled once the version is known.
+ const levelTypeStr = String(levelType || '').trim();
+ if (levelTypeStr && !worldTypeFor(levelTypeStr, '')) {
+ return { error: `Unknown world type: ${levelTypeStr}.` };
+ }
+
return {
trimmedName,
portNum,
memoryNum,
safeJavaArgs: String(javaArgs || '').trim(),
gamemodeStr: validGamemodes.includes(gamemode) ? gamemode : 'survival',
- difficultyStr: validDifficulties.includes(difficulty) ? difficulty : 'easy',
+ difficultyStr: validDifficulties.includes(difficulty) ? difficulty : 'normal',
+ levelTypeStr,
seedStr: String(seed || '').trim(),
group: groupResult.value
};
@@ -1121,6 +1130,14 @@ router.post('/servers', async (req, res) => {
}
}
+ // In the spelling this version reads; the default when none was asked for
+ const levelTypeStr = base.levelTypeStr
+ ? worldTypeFor(base.levelTypeStr, versionStr)
+ : worldTypesFor(versionStr)[0].value;
+ if (!levelTypeStr) {
+ return res.status(400).json({ error: `World type ${base.levelTypeStr} isn't available for Minecraft ${versionStr}.` });
+ }
+
const id = uuidv4();
const serverDir = path.join(SERVERS_DIR, id);
const initiatedBy = req.user.username;
@@ -1152,6 +1169,7 @@ router.post('/servers', async (req, res) => {
version: versionStr,
gamemode: gamemodeStr,
difficulty: difficultyStr,
+ levelType: levelTypeStr,
seed: seedStr,
customJarUrl: type === 'custom' ? customJarUrl.trim() : null,
jarFile: 'server.jar',
@@ -1183,8 +1201,9 @@ router.post('/servers', async (req, res) => {
serverPort: portNum,
gamemode: gamemodeStr,
difficulty: difficultyStr,
+ levelType: levelTypeStr,
levelSeed: seedStr
- });
+ }, { version: versionStr });
writeEula(serverDir);
const fresh = await serversDb.get(`server_${id}`);
@@ -1295,6 +1314,9 @@ function buildModpackServerRecord({ id, base, serverType, version, modpackMeta }
version,
gamemode: base.gamemodeStr,
difficulty: base.difficultyStr,
+ // Provisional: the install settles it against the pack, then the
+ // record is synced from server.properties
+ levelType: base.levelTypeStr || null,
seed: base.seedStr,
customJarUrl: null,
jarFile: 'server.jar',
@@ -1329,6 +1351,7 @@ async function provisionModpackServer({ req, id, serverDir, name, base, mrpack,
port: base.portNum,
gamemode: base.gamemodeStr,
difficulty: base.difficultyStr,
+ levelType: base.levelTypeStr,
seed: base.seedStr
},
iconUrl,
@@ -1361,8 +1384,9 @@ async function provisionModpackServer({ req, id, serverDir, name, base, mrpack,
if (!fresh.modpack.versionNumber) fresh.modpack.versionNumber = result.manifestVersionId;
}
await serversDb.set(`server_${id}`, fresh);
- // A seed the pack ships survives a blank one — mirror it so the
- // settings page shows it (and a save there doesn't clear it).
+ // A seed or world type the pack ships survives a blank one; mirror
+ // them so the settings page shows them (and a save there doesn't
+ // clear them).
await syncServerConfig(id);
}
@@ -2261,6 +2285,25 @@ router.post('/servers/:id/edit', async (req, res) => {
}
}
+ // World Type in the spelling the (possibly new) version reads. A known type
+ // is translated, the value already in the file (a modpack's own type) is
+ // kept as it is, and anything else is refused. When none is sent, a version
+ // change still translates the file's value, so crossing 1.19 keeps it.
+ const targetVersion = type !== 'custom' && newVersion ? newVersion : server.version;
+ const fileLevelType = parseServerProperties(path.join(SERVERS_DIR, id))['level-type'];
+ const sentLevelType = String(req.body.levelType ?? '').trim();
+ let levelTypeStr;
+ if (sentLevelType) {
+ levelTypeStr = worldTypeFor(sentLevelType, targetVersion)
+ || (sentLevelType === fileLevelType ? fileLevelType : null);
+ if (!levelTypeStr) {
+ return res.status(400).json({ error: `World type ${sentLevelType} isn't available for Minecraft ${targetVersion}.` });
+ }
+ } else if (fileLevelType && targetVersion !== server.version) {
+ const translated = worldTypeFor(fileLevelType, targetVersion);
+ if (translated && translated !== fileLevelType) levelTypeStr = translated;
+ }
+
// Everything that mutates the server, deferred into one closure so the
// backup path can run it only once a restore point exists. Throws
// httpError; callers map that to a status code or a WebSocket failure.
@@ -2343,6 +2386,7 @@ router.post('/servers/:id/edit', async (req, res) => {
server.javaArgs = safeJavaArgs;
server.gamemode = gamemodeStr;
server.difficulty = difficultyStr;
+ if (levelTypeStr !== undefined) server.levelType = levelTypeStr;
server.seed = seedStr;
server.group = groupResult.value;
await serversDb.set(`server_${id}`, server);
@@ -2363,6 +2407,7 @@ router.post('/servers/:id/edit', async (req, res) => {
'server-port': String(portNum),
'gamemode': gamemodeStr,
'difficulty': difficultyStr,
+ ...(levelTypeStr !== undefined ? { 'level-type': levelTypeStr } : {}),
'level-seed': seedStr
});
}
diff --git a/src/routes/api-v1/templates.js b/src/routes/api-v1/templates.js
index 2e0771f..3e91abe 100644
--- a/src/routes/api-v1/templates.js
+++ b/src/routes/api-v1/templates.js
@@ -82,7 +82,8 @@ router.post('/templates', async (req, res) => {
memory: server.memory || 2048,
javaArgs: server.javaArgs || '',
gamemode: server.gamemode || 'survival',
- difficulty: server.difficulty || 'easy',
+ difficulty: server.difficulty || 'normal',
+ levelType: server.levelType || null,
port: server.port || 25565,
autoRestart: !!server.autoRestart,
autoStart: !!server.autoStart,
diff --git a/src/routes/servers.js b/src/routes/servers.js
index 88d5037..1b62a26 100644
--- a/src/routes/servers.js
+++ b/src/routes/servers.js
@@ -10,6 +10,7 @@ const { formatSize, getDirectorySize } = require('../utils/resourceStats');
const { serversDb, SERVERS_DIR } = require('../db');
const { parseServerProperties } = require('../mc/serverProperties');
const { PROPERTY_META, GROUPS } = require('../mc/propertyMeta');
+const { WORLD_PRESETS, LEGACY_WORLD_TYPES, worldTypesFor, worldTypeFor } = require('../mc/worldTypes');
const { log } = require('../utils/log');
const { hasIcon } = require('../utils/serverIcon');
const { isPathInside } = require('../utils/pathSafety');
@@ -23,6 +24,8 @@ router.get('/servers/create', ensureAuth, async (req, res) => {
navbar: true,
user: req.user,
groupNames: await getDistinctGroups().catch(() => []),
+ // The version is picked on the page, so both World Type lists go along
+ worldTypeLists: { presets: WORLD_PRESETS, legacy: LEGACY_WORLD_TYPES },
messages: req.session.flash || {},
csrfToken: res.locals.csrfToken
});
@@ -102,6 +105,13 @@ router.get('/servers/:id/edit', ensureAuth, blockWhileProvisioning, async (req,
description: `Configure basic server and runtime settings for ${server.name}.`,
server,
currentMotd,
+ worldTypes: worldTypesFor(server.version),
+ // Read from the file, in the form this version reads (an upgraded
+ // server can still hold a pre-1.19 name). A server that has never
+ // started, or one from before 1.2.2, may have no level-type at all.
+ currentLevelType: props['level-type']
+ ? (worldTypeFor(props['level-type'], server.version) || props['level-type'])
+ : worldTypesFor(server.version)[0].value,
hasIcon: hasIcon(server.id),
user: req.user,
groupNames: await getDistinctGroups().catch(() => []),
@@ -131,7 +141,11 @@ router.get('/servers/:id/properties', ensureAuth, blockWhileProvisioning, async
description: `Edit server properties for ${server.name}.`,
server,
properties,
- propertyMeta: PROPERTY_META,
+ // World Type lists the values the server's own version reads
+ propertyMeta: {
+ ...PROPERTY_META,
+ 'level-type': { ...PROPERTY_META['level-type'], options: worldTypesFor(server.version) }
+ },
groups: GROUPS,
user: req.user,
messages: req.session.flash || {},
diff --git a/src/utils/mcVersion.js b/src/utils/mcVersion.js
index 5bf0280..02efb4f 100644
--- a/src/utils/mcVersion.js
+++ b/src/utils/mcVersion.js
@@ -16,4 +16,35 @@ function isReleaseVersion(v) {
return RELEASE_RE.test(String(v || ''));
}
-module.exports = { MC_VERSION_RE, isReleaseVersion };
+const SNAPSHOT_RE = /^(\d{2})w(\d{2})/;
+
+/**
+ * Whether `version` is `release` or newer. Snapshot ids compare against
+ * `firstSnapshot`, the snapshot that opened `release`'s cycle; pre-release and
+ * rc ids count as their release. A blank version (a custom jar) or one in no
+ * known form is taken as current, since Craftbox tracks newer servers.
+ *
+ * @param {string} version - e.g. "1.18.2", "26.1", "22w45a", "1.19.3-pre1"
+ * @param {string} release - e.g. "1.19.3"
+ * @param {string} firstSnapshot - e.g. "22w42a"
+ */
+function isAtLeast(version, release, firstSnapshot) {
+ const v = String(version || '').trim();
+ const snapshot = SNAPSHOT_RE.exec(v);
+ if (snapshot) {
+ const [, year, week] = snapshot;
+ const [, firstYear, firstWeek] = SNAPSHOT_RE.exec(firstSnapshot);
+ return Number(year) * 100 + Number(week) >= Number(firstYear) * 100 + Number(firstWeek);
+ }
+ const cleaned = v.replace(/[ _-]?(?:pre|rc).*$/i, '');
+ if (!isReleaseVersion(cleaned)) return true;
+ const a = cleaned.split('.').map(Number);
+ const b = release.split('.').map(Number);
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
+ const diff = (a[i] || 0) - (b[i] || 0);
+ if (diff !== 0) return diff > 0;
+ }
+ return true;
+}
+
+module.exports = { MC_VERSION_RE, isReleaseVersion, isAtLeast };
diff --git a/test/ci/api-smoke.mjs b/test/ci/api-smoke.mjs
index 2da0fac..2219f78 100644
--- a/test/ci/api-smoke.mjs
+++ b/test/ci/api-smoke.mjs
@@ -1,19 +1,21 @@
// API smoke test: exercises the documented /api/v1 surface against a fresh
// Craftbox instance, the way an integration would. Starting servers is left to
-// server-start.mjs; this covers auth, validation, create, settings, files,
-// backups and delete on one vanilla server.
+// server-start.mjs; this covers auth, validation, create, settings, world
+// options, the pages that show them, files, backups and delete on one vanilla
+// server, plus a .mrpack install.
//
// CRAFTBOX_URL=http://localhost:6464 node test/ci/api-smoke.mjs
import {
- waitForPanel, bootstrapApiKey, apiClient, waitForState,
- createRunner, assert, assertStatus, sleep
+ waitForPanel, bootstrapPanel, apiClient, waitForState,
+ createRunner, assert, assertStatus, sleep, makeZip
} from './lib.mjs';
const EXPECTED_TYPES = ['vanilla', 'paper', 'purpur', 'folia', 'fabric', 'forge', 'neoforge', 'custom'];
const PROVISION_TIMEOUT = 10 * 60_000;
-// server.properties as the API returns it, parsed into an object
+// server.properties as the API returns it, parsed into an object with the
+// escapes Minecraft writes (minecraft\:flat) undone, as Craftbox reads it
async function readProperties(api, id) {
const res = await api('GET', `/servers/${id}/file?path=server.properties`);
assertStatus(res, 200, 'read server.properties');
@@ -21,14 +23,14 @@ async function readProperties(api, id) {
for (const line of res.body.file.content.split(/\r?\n/)) {
if (!line || line.startsWith('#') || !line.includes('=')) continue;
const eq = line.indexOf('=');
- props[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
+ props[line.slice(0, eq).trim()] = line.slice(eq + 1).trim().replace(/\\:/g, ':').replace(/\\=/g, '=');
}
return props;
}
const run = createRunner('API smoke test');
await waitForPanel();
-const key = await bootstrapApiKey();
+const { key, page } = await bootstrapPanel();
const api = apiClient(key);
const anon = apiClient(null);
let latest = null;
@@ -77,7 +79,7 @@ await run.step('rejects an unknown type in /versions', async () => {
console.log('Create');
const validBody = () => ({
name: 'CI Smoke', serverType: 'vanilla', version: latest || 'latest',
- port: 25565, memory: 2048, eula: true, gamemode: 'survival', difficulty: 'easy'
+ port: 25565, memory: 2048, eula: true // game mode, difficulty and world type left to their defaults
});
await run.step('validates create input', async () => {
const cases = [
@@ -87,6 +89,7 @@ await run.step('validates create input', async () => {
['bad name', { name: 'bad/name' }],
['unknown type', { serverType: 'bogus' }],
['bad version', { version: '../1.21' }],
+ ['unknown world type', { levelType: 'bogus' }],
['custom without a URL', { serverType: 'custom' }]
];
for (const [what, patch] of cases) {
@@ -121,6 +124,20 @@ if (id) {
assert(props[k] === 'true', `${k}=${props[k]}`);
}
assert(props['server-port'] === '25565', `server-port=${props['server-port']}`);
+ assert(props.gamemode === 'survival' && props.difficulty === 'normal',
+ `gamemode=${props.gamemode} difficulty=${props.difficulty} (expected survival/normal)`);
+ });
+ await run.step('writes the settings Minecraft only reads on first start', async () => {
+ // The latest release uses 1.19+ world presets and 1.19.3+ datapack keys
+ const props = await readProperties(api, id);
+ const want = {
+ 'level-type': 'minecraft:normal', 'hardcore': 'false', 'generator-settings': '{}',
+ 'initial-enabled-packs': 'vanilla', 'initial-disabled-packs': ''
+ };
+ const wrong = Object.keys(want).filter((k) => props[k] !== want[k]);
+ assert(wrong.length === 0, wrong.map((k) => `${k}=${props[k]}`).join(', '));
+ const server = (await api('GET', `/servers/${id}`)).body.server;
+ assert(server.levelType === 'minecraft:normal', `record levelType ${server.levelType}`);
});
await run.step('partial update leaves omitted toggles alone', async () => {
assertStatus(await api('POST', `/servers/${id}/properties`, { 'max-players': 7 }), 200, 'partial update');
@@ -153,6 +170,7 @@ if (id) {
assert(props['server-port'] === '25566' && props.gamemode === 'creative' && props.difficulty === 'hard',
`properties: port=${props['server-port']} gamemode=${props.gamemode} difficulty=${props.difficulty}`);
});
+
await run.step('sets the MOTD', async () => {
assertStatus(await api('POST', `/servers/${id}/motd`, { motd: 'CI smoke test' }), 200, 'motd');
assert((await readProperties(api, id)).motd === 'CI smoke test', 'motd not written');
@@ -175,6 +193,37 @@ if (id) {
assert(groups.body.groups.some((g) => g.name === 'CI Group' && g.count === 1), 'group not listed');
});
+ console.log('World options');
+ const editBody = (extra) => ({ name: 'CI Smoke Edited', port: 25566, memory: 1536, ...extra });
+ await run.step('stores World Type in the spelling the version reads', async () => {
+ const res = await api('POST', `/servers/${id}/edit`, editBody({ levelType: 'flat' }));
+ assertStatus(res, 200, 'edit levelType');
+ assert(res.body.server.levelType === 'minecraft:flat', `record levelType ${res.body.server.levelType}`);
+ assert((await readProperties(api, id))['level-type'] === 'minecraft:flat', 'level-type not written');
+ assertStatus(await api('POST', `/servers/${id}/edit`, editBody({ levelType: 'bogus' })), 400, 'unknown world type');
+ });
+ await run.step('shows the world options on the Settings page', async () => {
+ const res = await page(`/servers/${id}/edit`);
+ assert(res.status === 200, `Settings page returned ${res.status}`);
+ assert(/
+
+
+ <%# World options. World Type's list depends on the chosen version
+ (pre-1.19 servers read different names), so create.js fills it
+ from these lists; the presets are rendered for a first paint. %>