Files
last-hearth/scripts/launcher.mjs
T
2026-11-03 12:41:09 -05:00

141 lines
7.8 KiB
JavaScript
Executable File

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];
}