258 lines
12 KiB
JavaScript
Executable File
258 lines
12 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 { 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 <check|smoke|play|test>');
|
|
}
|
|
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; });
|