// Engine-independent, reproducible game-directory packaging; no downloads. import * as fs from 'node:fs/promises'; import { constants } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { generateAssets } from './assets.mjs'; import { absolute, atomicDirectory, copyTree, disjoint, exists, json, relativeName, safePath, verifyManifest, writeManifest } from './files.mjs'; export const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const WORLDGEN_REPO = path.resolve(SOURCE, '..', 'luanti-mapgenerator-epic'); const WORLDGEN_SOURCE = path.join(WORLDGEN_REPO, 'mods', 'epic_terrain'); const BUNDLED_WORLDGEN = path.basename(SOURCE) === 'last_hearth' && path.dirname(WORLDGEN_REPO) === path.dirname(SOURCE); export async function readRelease(source = SOURCE) { await safePath(path.join(absolute(source), 'release.json')); const release = JSON.parse(await fs.readFile(path.join(source, 'release.json'), 'utf8')); if (release.format !== 1 || release.gameId !== 'last_hearth' || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/.test(release.version) || release.engine?.name !== 'Luanti' || release.engine?.version !== '5.18.0' || release.node !== '>=22' || release.packageFormat !== 1 || release.backupFormat !== 1) throw new Error('Unsupported release contract.'); return release; } export async function packageGame(destination, { source = SOURCE } = {}) { source = absolute(source); destination = absolute(destination); // dist/ within the repository is supported; no payload source may overlap it. for (const entry of ['mods', 'fixtures', 'tests', 'scripts']) disjoint(path.join(source, entry), destination); const release = await readRelease(source); if (source === SOURCE && BUNDLED_WORLDGEN) { disjoint(WORLDGEN_SOURCE, destination); const config = await safePath(path.join(WORLDGEN_SOURCE, 'mod.conf')); if (!(await fs.lstat(config)).isFile()) throw new Error('Epic-scale mapgen mod is missing from the workspace.'); } return atomicDirectory(destination, async stage => { for (const name of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt']) { const filename = await safePath(path.join(source, name)); if (!(await fs.lstat(filename)).isFile()) throw new Error('Package input must be a regular file.'); await fs.copyFile(filename, path.join(stage, name), constants.COPYFILE_EXCL); } await copyTree(path.join(source, 'mods'), path.join(stage, 'mods'), { exclude: new Set(['hearth/textures', 'hearth/models']), }); if (source === SOURCE && BUNDLED_WORLDGEN) { await copyTree(WORLDGEN_SOURCE, path.join(stage, 'mods/epic_terrain')); } for (const name of ['init', 'model', 'runtime', 'content', 'encounters']) { if (!await exists(path.join(stage, 'mods/hearth', name + '.lua'))) throw new Error('Game payload is incomplete.'); } // Generate from the copied inputs, not a changing worktree. Refuse links first. await generateAssets(stage, stage); await fs.mkdir(path.join(stage, 'fixtures')); await safePath(path.join(source, 'tests/fixtures/scenarios.json')); const scenarios = JSON.parse(await fs.readFile(path.join(source, 'tests/fixtures/scenarios.json'), 'utf8')); await fs.writeFile(path.join(stage, 'fixtures/scenarios.json'), json(scenarios), { flag: 'wx' }); // Runtime fixture schemas are owned by the game agent; copy JSON contracts // verbatim, including config.json and first-session/summer/winter fixtures. for (const name of (await fs.readdir(path.join(source, 'tests/fixtures'))).sort()) { if (name === 'scenarios.json' || !name.endsWith('.json')) continue; relativeName(name); const fixture = await safePath(path.join(source, 'tests/fixtures', name)); if (!(await fs.lstat(fixture)).isFile()) throw new Error('Fixture must be a regular JSON file.'); JSON.parse(await fs.readFile(fixture, 'utf8')); await fs.copyFile(fixture, path.join(stage, 'fixtures', name), constants.COPYFILE_EXCL); } await fs.writeFile(path.join(stage, 'release.json'), json(release), { flag: 'wx' }); await writeManifest(stage, 'game'); await verifyPackage(stage, {requireBundled: source === SOURCE && BUNDLED_WORLDGEN}); }); } export async function verifyPackage(directory, {requireBundled} = {}) { const manifest = await verifyManifest(directory, 'game'); await readRelease(directory); if (requireBundled === undefined) { const configPath = await safePath(path.join(absolute(directory), 'mods/hearth/mod.conf')); const config = await fs.readFile(configPath, 'utf8'); requireBundled = /^\s*depends\s*=.*(?:^|[,\s])creatura(?:$|[,\s])/m.test(config); } if (requireBundled && BUNDLED_WORLDGEN && !manifest.entries.some(entry => entry.path === 'mods/epic_terrain/init.lua')) { throw new Error('Packaged Last Hearth is missing its bundled epic_terrain generator.'); } if (requireBundled && !manifest.entries.some(entry => entry.path === 'mods/creatura/LICENSE')) { throw new Error('Packaged Last Hearth is missing Creatura license attribution.'); } return manifest; }