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

361 lines
21 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Original MIT-licensed launcher. Node built-ins only; never invokes a shell.
import { spawn } from 'node:child_process';
import { createHash, randomInt, randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { backupWorld, restoreWorld } from './backup.mjs';
import { absolute, atomicDirectory, copyTree, ensureDirectory, exists, safePath, withLauncherLock } from './files.mjs';
import { packageGame, SOURCE, verifyPackage } from './package.mjs';
import { coordinates, engineExecutable, environmentSettings, fixtureSettings, graphicsSettings, prepareCourse, runtimeHome, snapshotBeforePlay, validateEngine } from './launcher.mjs';
import { isTerrainSetting, terrainSettings } from './terrain.mjs';
const source = SOURCE;
const command = process.argv[2];
const TEST_TIMEOUT = 90_000;
const PLAY_TIMEOUT = 8 * 60 * 60 * 1000;
const EMBEDDED_MARKER = '.last-hearth-embedded-session.json';
// JSON escapes are not Lua escapes (notably \uXXXX). Encode UTF-8 bytes with
// fixed-width decimal escapes, including quotes, backslashes and control bytes.
const luaString = value => '"' + [...Buffer.from(value)].map(byte => `\\${String(byte).padStart(3, '0')}`).join('') + '"';
async function pinPackage(game, gameId, verified, requireCourse = false) {
const manifest = verified ?? await verifyPackage(game);
const release = JSON.parse(await fs.readFile(path.join(game, 'release.json'), 'utf8'));
const digest = createHash('sha256').update(JSON.stringify(manifest)).digest('hex');
const token = randomUUID();
const marker = `LAST_HEARTH_PACKAGE_LOADED ${token} ${release.version} ${digest}`;
const init = path.join(game, 'mods/hearth/init.lua');
const original = await fs.readFile(init);
// Only the private runtime copy is instrumented. The exact payload was verified
// above; path checks run BEFORE its code and the marker follows mod loading.
// A stale installed game or a worldmod overriding hearth cannot emit this token.
const normalize = value => value.replaceAll('\\', '/').replace(/\/+$/, '');
await fs.writeFile(init, Buffer.concat([Buffer.from([
'do',
' local function normalized(p) return (p:gsub("\\\\", "/"):gsub("/+$", "")) end',
' local info = core.get_game_info()',
` assert(info.id == ${luaString(gameId)} and normalized(info.path) == ${luaString(normalize(game))}, "Launcher game path/id mismatch")`,
` assert(normalized(core.get_modpath("hearth")) == ${luaString(normalize(path.join(game, 'mods/hearth')))}, "Launcher hearth path mismatch")`,
' core.register_on_mods_loaded(function()',
...(requireCourse ? [
' assert(hearth.course and type(hearth.course.report) == "function", "Course module must be wired by the game bootstrap")',
' assert(hearth.storage:get_string("m1_course_world_nonce") == core.settings:get("hearth_m1_course_nonce"), "Course must enroll before clock/players on first load")',
] : []),
` core.log("action", ${luaString(marker)})`,
' end)',
'end', '',
].join('\n')), original]));
return marker;
}
async function embeddedOwner(world, runtime) {
const game = await safePath(path.join(world, 'game'));
if (!await exists(game)) return undefined;
const marker = await safePath(path.join(game, EMBEDDED_MARKER));
if (!(await fs.lstat(game)).isDirectory() || !await exists(marker) || !(await fs.lstat(marker)).isFile() ||
(await fs.stat(marker)).size > 4096) throw new Error('Refusing to overwrite an unmanaged embedded world game.');
const owner = JSON.parse(await fs.readFile(marker, 'utf8'));
if (owner.format !== 1 || owner.owner !== 'last-hearth-runner' || owner.world !== world || owner.runtime !== runtime ||
typeof owner.token !== 'string' || !/^[0-9a-f-]{36}$/.test(owner.token)) {
throw new Error('Refusing an unrecognized embedded world game marker.');
}
return owner;
}
async function playGame(world, runtime, packaged, action) {
// Embedded games take precedence in findWorldSubgame(), including the GUI
// client, which otherwise ignores --gameid for an existing world. Never alias
// or rewrite world.mt: its logical last_hearth identity survives even SIGKILL.
const text = await fs.readFile(await safePath(path.join(world, 'world.mt')), 'utf8');
const ids = [...text.matchAll(/^\s*gameid\s*=\s*([^\r\n]*?)\s*$/gm)];
if (ids.length !== 1 || ids[0][1] !== 'last_hearth') throw new Error('Play requires logical world gameid last_hearth.');
const game = path.join(world, 'game');
if (await embeddedOwner(world, runtime)) {
// A hard-killed launcher can leave its private package behind. Snapshot was
// taken already; preserve rather than delete it (including any added files).
await fs.rename(game, path.join(runtime, `preserved-embedded-game-${randomUUID()}`));
}
const token = randomUUID();
let verified;
await atomicDirectory(game, async directory => {
await copyTree(packaged, directory, { intoEmpty: true });
verified = await verifyPackage(directory);
await fs.writeFile(path.join(directory, EMBEDDED_MARKER), JSON.stringify({
format: 1, owner: 'last-hearth-runner', token, world, runtime,
}), { flag: 'wx', mode: 0o600 });
});
try {
// Keep ownership present continuously, including if instrumentation is
// interrupted. Only this verified private copy gains launcher-only files.
const marker = await pinPackage(game, 'last_hearth', verified);
await action(marker);
} finally {
if ((await embeddedOwner(world, runtime))?.token !== token) throw new Error('Embedded game ownership changed; cleanup refused.');
await fs.rm(game, { recursive: true, force: true });
}
}
async function luaFiles(directory, prefix = '') {
const result = [];
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
if (entry.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${entry.name}`);
if (['.git', 'scripts', 'dist', 'node_modules'].includes(entry.name) || entry.name.startsWith('.nfs')) continue;
const relative = prefix + entry.name;
if (entry.isDirectory()) result.push(...await luaFiles(path.join(directory, entry.name), relative + '/'));
else if (entry.isFile() && entry.name.endsWith('.lua')) result.push(relative);
}
return result.sort();
}
function run(executable, args, cwd, timeout, successMarker, games) {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
cwd, shell: false, windowsHide: !['play', 'course'].includes(command), stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...(games ? { LUANTI_GAME_PATH: games, MINETEST_GAME_PATH: '' } : {}) },
});
const pending = new Set([successMarker].flat().filter(Boolean));
let timedOut = false, interrupted = false;
const capture = stream => {
let tail = '';
stream.setEncoding('utf8');
stream.on('data', text => {
// Bound memory even if the engine emits excessive diagnostics.
tail = (tail + text).slice(-1024 * 1024);
for (const marker of pending) if (tail.includes(marker)) pending.delete(marker);
process.stdout.write(text);
});
};
capture(child.stdout);
capture(child.stderr);
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout);
const interrupt = () => { interrupted = true; child.kill('SIGKILL'); };
process.once('SIGINT', interrupt);
process.once('SIGTERM', interrupt);
const cleanup = () => {
clearTimeout(timer);
process.removeListener('SIGINT', interrupt);
process.removeListener('SIGTERM', interrupt);
};
child.once('error', error => { cleanup(); reject(error); });
child.once('close', (code, signal) => {
cleanup();
if (timedOut) reject(new Error(`Process exceeded ${timeout / 1000}s timeout.`));
else if (interrupted) reject(new Error('Interrupted; child process stopped.'));
else if (code !== 0) reject(new Error(`Process failed (exit ${code}, signal ${signal || 'none'}).`));
else if (pending.size) reject(new Error('Engine exited without the required package-load/test success marker.'));
else resolve();
});
});
}
export async function writeWorld(world, config, location, extra = [], environment = 'production', gameId = 'last_hearth', env = process.env) {
const graphics = graphicsSettings(env);
const settings = environmentSettings(environment, [...location, ...extra]);
const graphicsKeys = new Set(graphics.map(line => line.split('=')[0].trim()));
if (settings.some(line => graphicsKeys.has(line.split('=')[0].trim()))) {
throw new Error('Graphics settings are launcher-managed; use HEARTH_VIEW_DISTANCE.');
}
await ensureDirectory(world);
const worldFile = path.join(world, 'world.mt');
// Decide before writing world.mt. Saved mapgen parameters are engine-owned;
// even a legacy/orphan map_meta.txt opts out of creation-time overrides.
const worldExists = await exists(worldFile);
const newWorld = !worldExists && !await exists(path.join(world, 'map_meta.txt'));
if (!worldExists) {
await fs.writeFile(worldFile, [
`gameid = ${gameId}`, 'backend = sqlite3', 'player_backend = sqlite3',
'auth_backend = sqlite3', 'mod_storage_backend = sqlite3', 'load_mod_hearth = true', '',
].join('\n'), { flag: 'wx' });
}
await safePath(config);
await fs.writeFile(config, [
'enable_damage = true', 'enable_pvp = false', 'creative_mode = false',
'bind_address = 127.0.0.1', 'server_announce = false', 'enable_ipv6 = false',
'time_speed = 0', 'movement_speed_walk = 4', 'movement_speed_fast = 6',
'movement_speed_jump = 6.5', 'secure.enable_security = true',
...graphics,
...terrainSettings(newWorld),
...settings.filter(line => newWorld || !isTerrainSetting(line)), '',
].join('\n'), { flag: 'wx', mode: 0o600 });
}
async function engineTest(mode, runtime, games, executable, location, gameId, packageMarker, options = {}) {
const temporary = await fs.mkdtemp(path.join(runtime, `${mode}-`));
try {
const world = path.join(temporary, 'world');
const config = path.join(temporary, 'minetest.conf');
const token = randomUUID();
const mod = path.join(world, 'worldmods', 'hearth_smoke');
await fs.mkdir(mod, { recursive: true });
await fs.copyFile(path.join(source, 'tests', 'engine_smoke.lua'), path.join(mod, 'init.lua'));
await fs.writeFile(path.join(mod, 'mod.conf'), 'name = hearth_smoke\ndepends = hearth\n');
// Compile standalone tests, never execute their mocked core in the live engine.
const syntax = path.join(mod, 'syntax');
await fs.mkdir(syntax);
const files = await luaFiles(source);
const names = [];
for (let index = 0; index < files.length; index++) {
const name = `${index}.lua`;
await fs.copyFile(path.join(source, files[index]), path.join(syntax, name));
names.push(`{file = ${JSON.stringify(name)}, label = ${JSON.stringify(files[index])}}`);
}
await fs.writeFile(path.join(mod, 'syntax_manifest.lua'), `return {${names.join(',\n')}}\n`);
// Explicit synthetic fixture only; real play never defaults to a location.
const testLocation = location.length ? location : ['hearth_latitude = 45', 'hearth_longitude = 0'];
await writeWorld(world, config, testLocation, [
'load_mod_hearth_smoke = true', `hearth_launcher_mode = ${mode}`,
`hearth_smoke_token = ${token}`, 'max_users = 0',
...(Number.isSafeInteger(options.seed) && options.seed >= 0 ? [`fixed_map_seed = ${options.seed}`] : []),
...await fixtureSettings(options.fixture === false ? undefined : process.env.HEARTH_FIXTURE, 'staging'),
], 'staging', gameId);
await run(executable, [
'--server', '--world', world, '--gameid', gameId, '--config', config,
'--port', String(randomInt(49152, 65536)), '--logfile', path.join(temporary, 'engine.log'),
], runtime, TEST_TIMEOUT, [packageMarker, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`], games);
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
}
async function standaloneTests(runtime) {
const candidates = process.env.HEARTH_LUA
? [process.env.HEARTH_LUA]
: (process.platform === 'win32' ? ['luajit.exe', 'lua.exe', 'bin/luajit.exe', 'bin/lua.exe']
: ['luajit', 'lua', 'bin/luajit', 'bin/lua']).map(name => path.join(runtime, name));
let lua;
for (const candidate of candidates) {
if (!path.isAbsolute(candidate)) {
throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable.');
}
if (await exists(candidate)) { lua = await safePath(candidate); break; }
}
if (!lua) throw new Error('Standalone Lua unavailable: engine checks passed, but unit tests were NOT run. Install Lua 5.1+/LuaJIT locally and set HEARTH_LUA to its absolute executable path.');
const specs = (await fs.readdir(path.join(source, 'tests'))).filter(name => name.endsWith('_spec.lua')).sort();
if (!specs.includes('content_spec.lua')) throw new Error('Missing content_spec.lua; refusing an empty test suite.');
for (const spec of specs) {
const module = spec.replace(/_spec\.lua$/, '.lua');
const modulePath = path.join(source, 'mods', 'hearth', module);
if (!await exists(modulePath)) throw new Error(`Cannot determine module for ${spec}.`);
await run(lua, [path.join(source, 'tests', spec), modulePath], source, 30_000);
}
}
async function launchCourse(stage, runtime, games, packaged, gameId, executable, headless) {
// No original world, snapshot, restored copy, or inherited location/fixture.
// Every invocation gets a new stage/course, marker and UUID, removed on exit.
const { world, nonce, settings } = await prepareCourse(stage, { environment: headless ? 'staging' : 'local' });
const extra = [...settings];
if (headless) {
const probe = await safePath(path.join(source, 'tests/course_engine.lua'));
if (!await exists(probe)) throw new Error('test:course requires the main-owned tests/course_engine.lua native probe; no fake player or exported build API is assumed.');
const mod = path.join(world, 'worldmods/hearth_course_probe');
await fs.mkdir(mod, { recursive: true });
await fs.copyFile(probe, path.join(mod, 'init.lua'));
await fs.writeFile(path.join(mod, 'mod.conf'), 'name = hearth_course_probe\ndepends = hearth\n', { flag: 'wx' });
// Probe contract: native API/enrollment checks, request_shutdown on success,
// and LAST_HEARTH_COURSE_PASS <hearth_smoke_token>. Generation requires live
// /course consent; course.report is the only exported API, not course.build.
extra.push('load_mod_hearth_course_probe = true', 'hearth_launcher_mode = course',
`hearth_smoke_token = ${nonce}`, 'max_users = 0');
}
const config = path.join(stage, 'course.conf');
await writeWorld(world, config, [], extra, headless ? 'staging' : 'local', gameId);
const packageMarker = await pinPackage(packaged, gameId, undefined, true);
if (!headless) {
console.log('Disposable summer course: use /course after arrival to generate and receive the kit. /course retry resets kit/needs/timer; /course report shows server step intervals and Lua callback wall duration, NOT FPS or CPU profiling. No automatic opponents. Original play world is not opened.');
}
await run(executable, [
headless ? '--server' : '--go', '--world', world, '--gameid', gameId, '--config', config,
'--port', String(randomInt(49152, 65536)), '--logfile', path.join(stage, 'course.log'),
], runtime, headless ? TEST_TIMEOUT : PLAY_TIMEOUT,
headless ? [packageMarker, `LAST_HEARTH_COURSE_PASS ${nonce}`] : packageMarker, games);
}
async function main() {
if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Node.js 22+ is required.');
const args = process.argv.slice(3);
if (command === 'package' && args.length <= 1) {
const destination = args.length ? absolute(args[0]) : path.join(source, 'dist/last_hearth');
if (!args.length) await ensureDirectory(path.dirname(destination));
await packageGame(destination);
console.log('LAST_HEARTH_PACKAGE_PASS: verified game directory and SHA256 manifest.');
return;
}
if (command === 'verify' && args.length === 1) {
await verifyPackage(absolute(args[0]));
console.log('LAST_HEARTH_PACKAGE_VERIFY_PASS');
return;
}
if (['backup', 'restore'].includes(command) && args.length === 3 && args[2] === '--offline') {
const options = { offline: true, runtime: runtimeHome() };
await (command === 'backup' ? backupWorld : restoreWorld)(absolute(args[0]), absolute(args[1]), options);
console.log(`LAST_HEARTH_${command.toUpperCase()}_PASS`);
return;
}
if (!['check', 'smoke', 'play', 'test', 'test:seeds', 'course', 'test:course'].includes(command) || args.length) {
throw new Error('Usage: node scripts/run.mjs <check|smoke|play|test|test:seeds|course|test:course|package [absolute-new-folder]|verify <absolute-package>|backup <absolute-world> <absolute-new-backup> --offline|restore <absolute-backup> <absolute-new-world> --offline>. OFFLINE: stop ALL external engines; lock checks cannot prove remote idleness. Backups contain private data: use a protected/encrypted volume.');
}
const course = ['course', 'test:course'].includes(command);
// Reject malformed graphics input before engine execution, staging or backups.
graphicsSettings();
const location = course || command === 'test:seeds' ? [] : coordinates();
const runtime = runtimeHome();
const executable = engineExecutable(process.env, process.platform, runtime);
// Fail before staging or touching worlds when the engine is absent/wrong.
await validateEngine(executable);
await withLauncherLock(runtime, async lease => {
// Never install into or overwrite the user's engine/game directories.
const stage = await fs.mkdtemp(path.join(runtime, 'session-'));
try {
const games = path.join(stage, 'games');
await fs.mkdir(games);
// Installed/share games precede LUANTI_GAME_PATH; never reuse their ID.
const gameId = `last_hearth_session_${randomUUID().replaceAll('-', '')}`;
const packaged = path.join(games, gameId);
await packageGame(packaged);
if (course) {
await launchCourse(stage, runtime, games, packaged, gameId, executable, command === 'test:course');
} else if (command === 'play') {
const fixture = await fixtureSettings(process.env.HEARTH_FIXTURE, 'local');
const world = path.join(runtime, 'worlds', 'last_hearth');
await embeddedOwner(world, runtime); // Refuse unmanaged content before touching the world.
if (await snapshotBeforePlay(runtime, lease)) {
console.log('LAST_HEARTH_PRELAUNCH_BACKUP_PASS: full snapshot retained outside the game package.');
}
const config = path.join(stage, 'play.conf');
await writeWorld(world, config, location, fixture, 'local');
await playGame(world, runtime, packaged, packageMarker => run(executable, [
'--go', '--world', world, '--gameid', gameId, '--config', config,
'--port', String(randomInt(49152, 65536)), '--logfile', path.join(stage, 'play.log'),
], runtime, PLAY_TIMEOUT, packageMarker, games));
} else if (command === 'test:seeds') {
const seeds = [731104, 19027, 2026, 987654321, 271828, 314159, 42, 8675309];
const packageMarker = await pinPackage(packaged, gameId);
await engineTest('check', runtime, games, executable, location, gameId, packageMarker);
for (const seed of seeds) {
await engineTest('seed', runtime, games, executable, location, gameId, packageMarker,
{seed, fixture: false});
}
console.log(`LAST_HEARTH_SEED_TEST_PASS: ${seeds.length} fixed seeds, required starting resources and safe arrival surfaces.`);
} else {
const packageMarker = await pinPackage(packaged, gameId);
if (command === 'test') {
await engineTest('check', runtime, games, executable, location, gameId, packageMarker);
await engineTest('smoke', runtime, games, executable, location, gameId, packageMarker);
if (process.env.HEARTH_LUA) await standaloneTests(runtime);
console.log('LAST_HEARTH_TEST_PASS: real-engine integration and isolated model/content suites completed.');
} else {
await engineTest(command, runtime, games, executable, location, gameId, packageMarker);
}
}
} finally { await fs.rm(stage, { recursive: true, force: true }); }
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch(error => { console.error(`Last Hearth: ${error.message}`); process.exitCode = 1; });
}