Initial commit
This commit is contained in:
Executable
+79
@@ -0,0 +1,79 @@
|
||||
// Original deterministic assets, generated without libraries or downloaded art.
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
function crc32(data) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of data) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function chunk(type, data) {
|
||||
const body = Buffer.concat([Buffer.from(type), data]);
|
||||
const size = Buffer.alloc(4), crc = Buffer.alloc(4);
|
||||
size.writeUInt32BE(data.length); crc.writeUInt32BE(crc32(body));
|
||||
return Buffer.concat([size, body, crc]);
|
||||
}
|
||||
function png(size, pixel) {
|
||||
const rows = Buffer.alloc(size * (size * 4 + 1));
|
||||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
||||
const color = pixel(x, y), offset = y * (size * 4 + 1) + 1 + x * 4;
|
||||
rows.set(color, offset);
|
||||
}
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size); header.writeUInt32BE(size, 4); header[8] = 8; header[9] = 6;
|
||||
return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', header),
|
||||
chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
const icons = {
|
||||
knife: (x,y) => (Math.abs(x + y - 28) < 3 && x > 9 && x < 24) ? [180,185,173,255]
|
||||
: (Math.abs(x + y - 28) < 2 && x >= 23) ? [119,86,57,255] : null,
|
||||
axe: (x,y) => Math.abs(x-y) < 2 && x > 7 && x < 27 ? [139,103,65,255]
|
||||
: x > 5 && x < 20 && y > 4 && y < 14 && x+y < 29 ? [157,164,151,255] : null,
|
||||
pick: (x,y) => Math.abs(x-y) < 2 && x > 10 && x < 28 ? [139,103,65,255]
|
||||
: y > 5 && y < 10 && x > 4 && x < 25 ? [157,164,151,255] : null,
|
||||
spear: (x,y) => Math.abs(x+y-31) < 2 && x > 5 && x < 27 ? [157,124,80,255]
|
||||
: x > 22 && y < 10 && x-y > 18 ? [185,188,177,255] : null,
|
||||
};
|
||||
|
||||
export async function generateAssets(destination, source) {
|
||||
const textures = path.join(destination, 'mods/hearth/textures');
|
||||
await fs.mkdir(textures, {recursive:true});
|
||||
const files = ['content.lua','encounters.lua','runtime.lua'];
|
||||
const colors = new Set(['c7aa83','947853']);
|
||||
for (const file of files) {
|
||||
const text = await fs.readFile(path.join(source, 'mods/hearth', file), 'utf8');
|
||||
for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase());
|
||||
}
|
||||
for (const color of colors) {
|
||||
const rgb = [0,2,4].map(i=>parseInt(color.slice(i,i+2),16));
|
||||
const image = png(32, (x,y)=> {
|
||||
const noise = ((x*73+y*151+x*y*7) % 19)-9;
|
||||
const grain = (y % 8 === 0) ? -12 : (x % 13 === 0 ? 5 : 0);
|
||||
return [...rgb.map(c=>Math.max(0,Math.min(255,c+noise+grain))),255];
|
||||
});
|
||||
await fs.writeFile(path.join(textures, `hearth_${color}.png`), image);
|
||||
}
|
||||
for (const [name, shape] of Object.entries(icons)) {
|
||||
await fs.writeFile(path.join(textures, `hearth_icon_${name}.png`), png(32,(x,y)=>shape(x,y)||[0,0,0,0]));
|
||||
}
|
||||
// A low-poly original player silhouette; stable engine mesh instead of a billboard.
|
||||
const models = path.join(destination, 'mods/hearth/models');
|
||||
await fs.mkdir(models, {recursive:true});
|
||||
let vertices = 0;
|
||||
const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1'];
|
||||
function box(x0,y0,z0,x1,y1,z1) {
|
||||
for(const v of [[x0,y0,z0],[x1,y0,z0],[x1,y1,z0],[x0,y1,z0],
|
||||
[x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]]) obj.push(`v ${v.join(' ')}`);
|
||||
for(const f of [[1,4,3,2],[5,6,7,8],[1,2,6,5],[4,8,7,3],[1,5,8,4],[2,3,7,6]])
|
||||
obj.push(`f ${f.map((n,i)=>`${vertices+n}/${i+1}`).join(' ')}`);
|
||||
vertices += 8;
|
||||
}
|
||||
box(-2.3,8,-1.3,2.3,14,1.3); box(-2,14,-2,2,18,2);
|
||||
box(-4,8,-1,-2.3,14,1); box(2.3,8,-1,4,14,1);
|
||||
box(-2.2,0,-1.2,-0.2,8,1.2); box(0.2,0,-1.2,2.2,8,1.2);
|
||||
await fs.writeFile(path.join(models,'hearth_person.obj'), obj.join('\n')+'\n');
|
||||
}
|
||||
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/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; });
|
||||
Reference in New Issue
Block a user