63 lines
3.7 KiB
JavaScript
Executable File
63 lines
3.7 KiB
JavaScript
Executable File
// 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)), '..');
|
|
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);
|
|
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']),
|
|
});
|
|
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);
|
|
});
|
|
}
|
|
|
|
export async function verifyPackage(directory) {
|
|
const manifest = await verifyManifest(directory, 'game');
|
|
await readRelease(directory);
|
|
return manifest;
|
|
}
|