Lots of content
This commit is contained in:
+234
-131
@@ -1,39 +1,100 @@
|
||||
#!/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 { createHash, randomInt, randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { generateAssets } from './assets.mjs';
|
||||
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 = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const source = SOURCE;
|
||||
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;
|
||||
const EMBEDDED_MARKER = '.last-hearth-embedded-session.json';
|
||||
|
||||
async function exists(filename) {
|
||||
try { await fs.access(filename, constants.F_OK); return true; }
|
||||
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +102,7 @@ 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;
|
||||
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);
|
||||
@@ -49,36 +110,21 @@ async function luaFiles(directory, prefix = '') {
|
||||
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) {
|
||||
function run(executable, args, cwd, timeout, successMarker, games) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, {
|
||||
cwd, shell: false, windowsHide: command !== 'play', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
cwd, shell: false, windowsHide: !['play', 'course'].includes(command), stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...(games ? { LUANTI_GAME_PATH: games, MINETEST_GAME_PATH: '' } : {}) },
|
||||
});
|
||||
let tail = '', timedOut = false, interrupted = false;
|
||||
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);
|
||||
});
|
||||
};
|
||||
@@ -99,60 +145,44 @@ function run(executable, args, cwd, timeout, successMarker) {
|
||||
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 if (pending.size) reject(new Error('Engine exited without the required package-load/test 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}.`);
|
||||
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.');
|
||||
}
|
||||
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 });
|
||||
await ensureDirectory(world);
|
||||
const worldFile = path.join(world, 'world.mt');
|
||||
if (!await exists(worldFile)) {
|
||||
// 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 = last_hearth', 'backend = sqlite3', 'player_backend = sqlite3',
|
||||
`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',
|
||||
'mg_name = v7', 'fixed_map_seed = 731104',
|
||||
...location, ...extra, '',
|
||||
].join('\n'), { mode: 0o600 });
|
||||
...graphics,
|
||||
...terrainSettings(newWorld),
|
||||
...settings.filter(line => newWorld || !isTerrainSetting(line)), '',
|
||||
].join('\n'), { flag: 'wx', mode: 0o600 });
|
||||
}
|
||||
|
||||
async function engineTest(mode, runtime, engine, executable, location) {
|
||||
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');
|
||||
@@ -178,11 +208,13 @@ async function engineTest(mode, runtime, engine, executable, location) {
|
||||
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', 'last_hearth', '--config', config,
|
||||
'--server', '--world', world, '--gameid', gameId, '--config', config,
|
||||
'--port', String(randomInt(49152, 65536)), '--logfile', path.join(temporary, 'engine.log'),
|
||||
], engine, TEST_TIMEOUT, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`);
|
||||
], runtime, TEST_TIMEOUT, [packageMarker, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`], games);
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
@@ -191,13 +223,14 @@ async function engineTest(mode, runtime, engine, executable, location) {
|
||||
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));
|
||||
: (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) || path.extname(candidate).toLowerCase() !== '.exe') {
|
||||
throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable (.exe).');
|
||||
if (!path.isAbsolute(candidate)) {
|
||||
throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable.');
|
||||
}
|
||||
if (await exists(candidate)) { lua = candidate; break; }
|
||||
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();
|
||||
@@ -210,48 +243,118 @@ async function standaloneTests(runtime) {
|
||||
}
|
||||
}
|
||||
|
||||
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>');
|
||||
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');
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
main().catch(error => { console.error(`Last Hearth: ${error.message}`); process.exitCode = 1; });
|
||||
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; });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user