import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { absolute, atomicDirectory, ensureDirectory, exists, safePath } from './files.mjs'; import { assertWorldOffline, backupWorld } from './backup.mjs'; import { readRelease, SOURCE } from './package.mjs'; const exec = promisify(execFile); export function runtimeHome(env = process.env, platform = process.platform, home = os.homedir()) { if (env.HEARTH_HOME !== undefined) return absolute(env.HEARTH_HOME); if (platform === 'win32') { if (!env.LOCALAPPDATA) throw new Error('LOCALAPPDATA or HEARTH_HOME is required.'); return path.join(absolute(env.LOCALAPPDATA), 'last-hearth-runtime'); } if (platform === 'darwin') return path.join(absolute(home), 'Library/Application Support/last-hearth-runtime'); return path.join(env.XDG_DATA_HOME ? absolute(env.XDG_DATA_HOME) : path.join(absolute(home), '.local/share'), 'last-hearth-runtime'); } export function engineExecutable(env = process.env, platform = process.platform, runtime = runtimeHome(env, platform)) { if (env.HEARTH_ENGINE !== undefined) return absolute(env.HEARTH_ENGINE); if (platform !== 'win32') throw new Error('Set HEARTH_ENGINE to an absolute Luanti 5.18.0 executable.'); return path.join(runtime, 'luanti-5.18.0-win64/bin/luanti.exe'); } export function parseEngineVersion(text) { const match = /^Luanti ([0-9]+\.[0-9]+\.[0-9]+)(?=\s|$)/m.exec(text); if (!match) throw new Error('Unrecognized Luanti --version response.'); return match[1]; } export function assertEngineVersion(text, expected = '5.18.0') { const actual = parseEngineVersion(text); if (actual !== expected) throw new Error(`Luanti version mismatch: expected ${expected}, received ${actual}.`); return actual; } export async function validateEngine(executable, { source = SOURCE } = {}) { executable = await safePath(absolute(executable)); if (!(await fs.lstat(executable)).isFile()) throw new Error('Engine must be a regular executable file.'); const release = await readRelease(source); const { stdout } = await exec(executable, ['--version'], { shell: false, windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024, encoding: 'utf8', }); return assertEngineVersion(stdout, release.engine.version); } export async function snapshotBeforePlay(runtime, lease) { runtime = absolute(runtime); const world = path.join(runtime, 'worlds/last_hearth'); await safePath(world); if (await exists(path.join(world, 'world.mt'))) { await assertWorldOffline(world); const backups = path.join(runtime, 'backups'); await ensureDirectory(backups); const destination = path.join(backups, `prelaunch-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID()}`); await backupWorld(world, destination, { offline: true, runtime, lease }); return destination; } if (await exists(world) && (await fs.readdir(world)).length) throw new Error('Refusing to initialize a nonempty unmanaged world directory.'); return undefined; } // Full-detail terrain distance, not LOD or a simulation/activation radius. export function graphicsSettings(env = process.env) { const raw = env.HEARTH_VIEW_DISTANCE; // Validate the entire string (including trailing newlines) before config I/O. if (raw !== undefined && (typeof raw !== 'string' || !/^[1-9][0-9]{2,3}$/.test(raw) || /[\r\n]/.test(raw))) { throw new Error('HEARTH_VIEW_DISTANCE must be an integer from 128 to 1024 nodes.'); } const distance = raw === undefined ? 512 : Number(raw); if (!Number.isSafeInteger(distance) || distance < 128 || distance > 1024) { throw new Error('HEARTH_VIEW_DISTANCE must be an integer from 128 to 1024 nodes.'); } const blocks = Math.min(66, Math.ceil(distance / 16) + 2); return [ `viewing_range = ${distance}`, `max_block_send_distance = ${blocks}`, `max_block_generate_distance = ${blocks}`, 'client_mesh_chunk = 2', 'fog_start = 0.65', ]; } export function coordinates(env = process.env) { const latitude = env.HEARTH_LATITUDE, longitude = env.HEARTH_LONGITUDE; if (latitude === undefined && longitude === undefined) return []; if (latitude === undefined || longitude === undefined) throw new Error('Provide both HEARTH_LATITUDE and HEARTH_LONGITUDE, or neither.'); const parse = (value, limit) => { if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value) || !Number.isFinite(Number(value)) || Math.abs(Number(value)) > limit) { throw new Error('Invalid geographic coordinate.'); } return Number(value); }; return [`hearth_latitude = ${parse(latitude, 90)}`, `hearth_longitude = ${parse(longitude, 180)}`]; } export async function fixtureSettings(name, environment, source = SOURCE) { if (!['local', 'staging', 'production'].includes(environment)) throw new Error('Invalid hearth_environment.'); if (name === undefined) return []; if (environment === 'production') throw new Error('Production rejects fixture/test settings.'); if (!['first-session', 'summer', 'winter'].includes(name)) throw new Error('Unknown HEARTH_FIXTURE.'); const filename = await safePath(path.join(source, 'tests/fixtures/scenarios.json')); const data = JSON.parse(await fs.readFile(filename, 'utf8')); const fixture = Object.hasOwn(data.scenarios ?? {}, name) ? data.scenarios[name] : undefined; if (data.format !== 1 || !fixture || fixture.seed !== 731104 || !['real-date', 'fixed'].includes(fixture.clock)) throw new Error('Invalid scenario contract.'); const settings = [`fixed_map_seed = ${fixture.seed}`]; if (fixture.clock === 'fixed') { if (!Number.isSafeInteger(fixture.utc) || fixture.utc <= 0 || !['clear', 'rain'].includes(fixture.weather)) throw new Error('Invalid scenario date/weather.'); settings.push(`hearth_preview_date = ${fixture.utc}`, `hearth_preview_weather = ${fixture.weather}`, ...coordinates({ HEARTH_LATITUDE: String(fixture.latitude), HEARTH_LONGITUDE: String(fixture.longitude) })); } return settings; } // The parent is the launcher's private, fresh session stage, never the persistent // worlds directory. Refuse even an empty pre-existing course; no reuse/reset/rm. // The Lua module enrolls this marker before clock/player code can save anything. export async function prepareCourse(stage, { environment = 'local', source = SOURCE } = {}) { if (!['local', 'staging'].includes(environment)) throw new Error('Course requires local/staging, never production.'); stage = await safePath(absolute(stage)); if (!(await fs.lstat(stage)).isDirectory()) throw new Error('Course stage must be an existing private directory.'); const fixture = await fixtureSettings('summer', environment, source); const world = path.join(stage, 'course'), nonce = randomUUID(); await atomicDirectory(world, async directory => { await fs.writeFile(path.join(directory, '.hearth-m1-course-nonce'), nonce + '\n', { flag: 'wx', mode: 0o600 }); }); return { world, nonce, settings: [...fixture, 'hearth_m1_course = true', `hearth_m1_course_nonce = ${nonce}`] }; } // Dedicated engines default to production; the managed SP launcher opts into local. export function environmentSettings(environment = 'production', extra = []) { if (!['local', 'staging', 'production'].includes(environment)) throw new Error('Invalid hearth_environment.'); if (extra.some(line => /[\r\n]/.test(line) || /^\s*hearth_environment\s*=/.test(line))) throw new Error('Invalid configuration setting.'); if (environment === 'production' && extra.some(line => /^\s*hearth_(?:preview_|launcher_mode|smoke_token|m1_course|recovery|debug|course_probe)/.test(line))) { throw new Error('Production rejects fixture/test settings.'); } return [`hearth_environment = ${environment}`, ...extra]; }