diff --git a/README.md b/README.md index 6523903..75474cf 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@
![license](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square) -![version](https://img.shields.io/badge/version-1.2.1-brightgreen?style=flat-square) +![version](https://img.shields.io/badge/version-1.2.2-brightgreen?style=flat-square) ![docker](https://img.shields.io/badge/docker-supported-blue?style=flat-square) [![discord](https://img.shields.io/discord/667479986214666272?logo=discord&logoColor=white&style=flat-square)](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(/]*id="levelType"[^>]*name="levelType"/.test(res.html), 'no World Type select'); + 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. %>
-
+
-
+
-
+
+
+
+ + +
+
diff --git a/views/servers/edit.ejs b/views/servers/edit.ejs index 55167f2..4588a98 100644 --- a/views/servers/edit.ejs +++ b/views/servers/edit.ejs @@ -92,8 +92,11 @@ const _gate = 'stopped crashed'; %>
+
+ + <%# World options, laid out as on the create page %>
-
+
-
+
-
+
+
+
+ + +
+
diff --git a/views/servers/properties.ejs b/views/servers/properties.ejs index 693b9f4..daea7d4 100644 --- a/views/servers/properties.ejs +++ b/views/servers/properties.ejs @@ -45,12 +45,13 @@ <% } else if (meta.type === 'enum') { %> <% if (meta.description) { %>