#!/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 . 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 |backup --offline|restore --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; }); }