361 lines
21 KiB
JavaScript
Executable File
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; });
|
|
}
|