#!/usr/bin/env node // Original MIT-licensed launcher. Node built-ins only; never invokes a shell. import { spawn } from 'node:child_process'; import { randomInt, randomUUID } from 'node:crypto'; import { constants } from 'node:fs'; import * as fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { generateAssets } from './assets.mjs'; const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const command = process.argv[2]; const excluded = new Set(['scripts', 'tests', '.git']); const marker = '.last-hearth-launcher-managed'; const TEST_TIMEOUT = 90_000; const PLAY_TIMEOUT = 8 * 60 * 60 * 1000; async function exists(filename) { try { await fs.access(filename, constants.F_OK); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } } // copyFile avoids fs.cp's realpath failures on Windows SMB shares. Refuse // symlinks/junctions rather than following them out of the selected source. async function copyTree(from, to, skip = new Set()) { const stat = await fs.lstat(from); if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${from}`); if (stat.isDirectory()) { await fs.mkdir(to, { recursive: true }); for (const entry of await fs.readdir(from)) { if (!skip.has(entry)) await copyTree(path.join(from, entry), path.join(to, entry), skip); } } else if (stat.isFile()) { await fs.copyFile(from, to); } else { throw new Error(`Not a regular file or directory: ${from}`); } } 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 (entry.name === '.git' || entry.name === 'scripts') 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 coordinates() { const latitude = process.env.HEARTH_LATITUDE; const longitude = process.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, name) => { if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value)) throw new Error(`Invalid ${name}.`); const number = Number(value); if (!Number.isFinite(number) || Math.abs(number) > limit) throw new Error(`${name} out of range.`); return number; }; return [ `hearth_latitude = ${parse(latitude, 90, 'latitude')}`, `hearth_longitude = ${parse(longitude, 180, 'longitude')}`, ]; } function run(executable, args, cwd, timeout, successMarker) { return new Promise((resolve, reject) => { const child = spawn(executable, args, { cwd, shell: false, windowsHide: command !== 'play', stdio: ['ignore', 'pipe', 'pipe'], }); let tail = '', timedOut = false, interrupted = false; const capture = stream => { stream.setEncoding('utf8'); stream.on('data', text => { // Bound memory even if the engine emits excessive diagnostics. tail = (tail + text).slice(-1024 * 1024); 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 (successMarker && !tail.includes(successMarker)) reject(new Error('Engine exited without the required success marker.')); else resolve(); }); }); } async function stageGame(engine) { for (const required of ['game.conf', 'mods/hearth/init.lua', 'mods/hearth/model.lua', 'mods/hearth/runtime.lua', 'mods/hearth/content.lua']) { if (!await exists(path.join(source, required))) throw new Error(`Game is incomplete: missing ${required}.`); } const games = path.join(engine, 'games'); await fs.mkdir(games, { recursive: true }); const destination = path.join(games, 'last_hearth'); if (await exists(destination)) { if ((await fs.lstat(destination)).isSymbolicLink() || !await exists(path.join(destination, marker))) { throw new Error(`Refusing to overwrite an unmanaged game: ${destination}`); } } const stage = await fs.mkdtemp(path.join(games, '.last-hearth-stage-')); const backup = path.join(games, `.last-hearth-backup-${randomUUID()}`); let backedUp = false; try { await copyTree(source, stage, excluded); await generateAssets(stage, source); await fs.writeFile(path.join(stage, marker), 'Managed copy; edit the repository, not this directory.\n'); if (await exists(destination)) { await fs.rename(destination, backup); backedUp = true; } try { await fs.rename(stage, destination); } catch (error) { if (backedUp) await fs.rename(backup, destination); throw error; } if (backedUp) await fs.rm(backup, { recursive: true, force: true }); } finally { await fs.rm(stage, { recursive: true, force: true }); } } async function writeWorld(world, config, location, extra = []) { await fs.mkdir(world, { recursive: true }); const worldFile = path.join(world, 'world.mt'); if (!await exists(worldFile)) { await fs.writeFile(worldFile, [ 'gameid = last_hearth', 'backend = sqlite3', 'player_backend = sqlite3', 'auth_backend = sqlite3', 'mod_storage_backend = sqlite3', 'load_mod_hearth = true', '', ].join('\n'), { flag: 'wx' }); } 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', 'mg_name = v7', 'fixed_map_seed = 731104', ...location, ...extra, '', ].join('\n'), { mode: 0o600 }); } async function engineTest(mode, runtime, engine, executable, location) { 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', ]); await run(executable, [ '--server', '--world', world, '--gameid', 'last_hearth', '--config', config, '--port', String(randomInt(49152, 65536)), '--logfile', path.join(temporary, 'engine.log'), ], engine, TEST_TIMEOUT, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`); } finally { await fs.rm(temporary, { recursive: true, force: true }); } } async function standaloneTests(runtime) { const candidates = process.env.HEARTH_LUA ? [process.env.HEARTH_LUA] : ['luajit.exe', 'lua.exe', 'bin/luajit.exe', 'bin/lua.exe'].map(name => path.join(runtime, name)); let lua; for (const candidate of candidates) { if (!path.isAbsolute(candidate) || path.extname(candidate).toLowerCase() !== '.exe') { throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable (.exe).'); } if (await exists(candidate)) { lua = 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 main() { if (!['check', 'smoke', 'play', 'test'].includes(command) || process.argv.length !== 3) { throw new Error('Usage: node scripts/run.mjs '); } if (process.platform !== 'win32' || !process.env.LOCALAPPDATA || !path.isAbsolute(process.env.LOCALAPPDATA)) { throw new Error('This launcher requires Windows and an absolute LOCALAPPDATA path.'); } const location = coordinates(); const runtime = path.join(process.env.LOCALAPPDATA, 'last-hearth-runtime'); const engine = path.join(runtime, 'luanti-5.18.0-win64'); const executable = path.join(engine, 'bin', 'luanti.exe'); if (!await exists(executable)) throw new Error(`Official Luanti 5.18.0 runtime not found: ${executable}`); const lockPath = path.join(runtime, 'launcher.lock'); let lock; try { lock = await fs.open(lockPath, 'wx', 0o600); } catch (error) { if (error.code === 'EEXIST') throw new Error('Another launcher may be active. If it crashed, close its engine before removing LOCALAPPDATA/last-hearth-runtime/launcher.lock.'); throw error; } try { await lock.writeFile(`${process.pid}\n`); await stageGame(engine); if (command === 'play') { const world = path.join(runtime, 'worlds', 'last_hearth'); const config = path.join(runtime, 'last-hearth-play.conf'); await writeWorld(world, config, location); await run(executable, [ '--go', '--world', world, '--gameid', 'last_hearth', '--config', config, '--port', String(randomInt(49152, 65536)), '--logfile', path.join(runtime, 'last-hearth-play.log'), ], engine, PLAY_TIMEOUT); } else if (command === 'test') { await engineTest('check', runtime, engine, executable, location); await engineTest('smoke', runtime, engine, executable, location); 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, engine, executable, location); } } finally { await lock.close(); await fs.unlink(lockPath); } } main().catch(error => { console.error(`Last Hearth: ${error.message}`); process.exitCode = 1; });