Add build subcommand: DSL spec to v2-map JSON file
Reads the DSL file, parses to AST, loads all --atlas-dir paths into a registry, runs the builder, and writes pretty-printed JSON. The output filename's basename becomes the map id when the DSL has no 'id' directive. Parser and builder errors are surfaced as '<file>:<line>: <message>'. Also reorders the duplicate-atlas warning so the atlas_id appears before the word 'duplicate'.
This commit is contained in:
@@ -51,8 +51,8 @@ function loadAtlasDirs(dirs) {
|
|||||||
const spec = loadAtlasSpec(specPath);
|
const spec = loadAtlasSpec(specPath);
|
||||||
if (registry[spec.atlas_id]) {
|
if (registry[spec.atlas_id]) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`duplicate atlas_id '${spec.atlas_id}' at ${specPath}; ` +
|
`atlas_id '${spec.atlas_id}' is a duplicate; ` +
|
||||||
`keeping earlier registration from ${registry[spec.atlas_id].specPath}`
|
`${specPath} ignored, keeping earlier registration from ${registry[spec.atlas_id].specPath}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function run(argv, { out, err }) {
|
|||||||
const handlers = {
|
const handlers = {
|
||||||
encode: require('./commands/encode'),
|
encode: require('./commands/encode'),
|
||||||
decode: require('./commands/decode'),
|
decode: require('./commands/decode'),
|
||||||
|
build: require('./commands/build'),
|
||||||
};
|
};
|
||||||
const handler = handlers[head];
|
const handler = handlers[head];
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
|
|||||||
94
src/commands/build.js
Normal file
94
src/commands/build.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { parseDsl } = require('../dsl-parser');
|
||||||
|
const { loadAtlasDirs } = require('../atlas-loader');
|
||||||
|
const { buildMap } = require('../builder');
|
||||||
|
|
||||||
|
const USAGE = 'usage: sporel-mapper build <spec.txt> <out.json> [--atlas-dir <path> ...]\n';
|
||||||
|
|
||||||
|
function parseArgs(args) {
|
||||||
|
const positional = [];
|
||||||
|
const atlasDirs = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < args.length) {
|
||||||
|
const a = args[i];
|
||||||
|
if (a === '--atlas-dir') {
|
||||||
|
if (i + 1 >= args.length) {
|
||||||
|
throw new Error(`--atlas-dir requires a path`);
|
||||||
|
}
|
||||||
|
atlasDirs.push(args[i + 1]);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
positional.push(a);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { positional, atlasDirs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(args, { out, err }) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = parseArgs(args);
|
||||||
|
} catch (e) {
|
||||||
|
err.write(`error: ${e.message}\n${USAGE}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (parsed.positional.length !== 2) {
|
||||||
|
err.write(USAGE);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const [specPath, outPath] = parsed.positional;
|
||||||
|
|
||||||
|
let dslText;
|
||||||
|
try {
|
||||||
|
dslText = fs.readFileSync(specPath, 'utf8');
|
||||||
|
} catch (e) {
|
||||||
|
err.write(`error: cannot read ${specPath}: ${e.message}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ast;
|
||||||
|
try {
|
||||||
|
ast = parseDsl(dslText);
|
||||||
|
} catch (e) {
|
||||||
|
const line = e.line || '?';
|
||||||
|
err.write(`${specPath}:${line}: ${e.message}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry;
|
||||||
|
try {
|
||||||
|
const result = loadAtlasDirs(parsed.atlasDirs);
|
||||||
|
registry = result.registry;
|
||||||
|
for (const w of result.warnings) {
|
||||||
|
err.write(`warning: ${w}\n`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err.write(`error: ${e.message}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultId = path.basename(outPath, path.extname(outPath));
|
||||||
|
let map;
|
||||||
|
try {
|
||||||
|
map = buildMap(ast, registry, { defaultId });
|
||||||
|
} catch (e) {
|
||||||
|
const line = e.line || '?';
|
||||||
|
err.write(`${specPath}:${line}: ${e.message}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(map, null, 2) + '\n');
|
||||||
|
} catch (e) {
|
||||||
|
err.write(`error: cannot write ${outPath}: ${e.message}\n`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { run };
|
||||||
98
tests/build.test.js
Normal file
98
tests/build.test.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { run } = require('../src/cli');
|
||||||
|
|
||||||
|
class Sink {
|
||||||
|
constructor() { this.chunks = []; }
|
||||||
|
write(s) { this.chunks.push(s); }
|
||||||
|
get text() { return this.chunks.join(''); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIX_ATLAS = path.join(__dirname, 'fixtures', 'atlases');
|
||||||
|
const FIX_DSL = path.join(__dirname, 'fixtures', 'dsl');
|
||||||
|
|
||||||
|
function tmpOut(name) {
|
||||||
|
return path.join(os.tmpdir(), `${name}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('build: minimal DSL → valid v2 JSON file', () => {
|
||||||
|
const out = new Sink(); const err = new Sink();
|
||||||
|
const outPath = tmpOut('minimal');
|
||||||
|
try {
|
||||||
|
const code = run(['build', path.join(FIX_DSL, 'minimal.txt'), outPath, '--atlas-dir', FIX_ATLAS], { out, err });
|
||||||
|
assert.equal(code, 0, err.text);
|
||||||
|
const json = JSON.parse(fs.readFileSync(outPath, 'utf8'));
|
||||||
|
assert.equal(json.schema_version, 2);
|
||||||
|
assert.equal(json.id, 'minimal'); // derived from out filename
|
||||||
|
assert.deepEqual(json.size, { w: 4, h: 4 });
|
||||||
|
assert.deepEqual(json.atlases, ['atlas_a']);
|
||||||
|
assert.equal(json.layers.surface.tiles.length, 16);
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('build: full DSL with id directive overrides filename-derived id', () => {
|
||||||
|
const out = new Sink(); const err = new Sink();
|
||||||
|
const outPath = tmpOut('full');
|
||||||
|
try {
|
||||||
|
const code = run(['build', path.join(FIX_DSL, 'full.txt'), outPath, '--atlas-dir', FIX_ATLAS], { out, err });
|
||||||
|
assert.equal(code, 0, err.text);
|
||||||
|
const json = JSON.parse(fs.readFileSync(outPath, 'utf8'));
|
||||||
|
assert.equal(json.id, 'full_demo');
|
||||||
|
assert.deepEqual(Object.keys(json.layers).sort(), ['surface', 'wall']);
|
||||||
|
assert.equal(json.roof.length, 16);
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('build: out-of-bounds DSL reports file:line on stderr, exit 1', () => {
|
||||||
|
const out = new Sink(); const err = new Sink();
|
||||||
|
const outPath = tmpOut('err');
|
||||||
|
const inPath = path.join(FIX_DSL, 'error_oob.txt');
|
||||||
|
const code = run(['build', inPath, outPath, '--atlas-dir', FIX_ATLAS], { out, err });
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(err.text, /error_oob\.txt:4:/);
|
||||||
|
assert.match(err.text, /out of bounds/);
|
||||||
|
// Output file must NOT exist
|
||||||
|
assert.equal(fs.existsSync(outPath), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('build: missing --atlas-dir for DSL that needs atlases → error', () => {
|
||||||
|
const out = new Sink(); const err = new Sink();
|
||||||
|
const outPath = tmpOut('noatlas');
|
||||||
|
const code = run(['build', path.join(FIX_DSL, 'minimal.txt'), outPath, '--atlas-dir', '/no/such/dir'], { out, err });
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(err.text, /error/i);
|
||||||
|
if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('build: insufficient positional args prints usage, exit 1', () => {
|
||||||
|
const out = new Sink(); const err = new Sink();
|
||||||
|
const code = run(['build', 'only_one_arg.txt'], { out, err });
|
||||||
|
assert.equal(code, 1);
|
||||||
|
assert.match(err.text, /usage: sporel-mapper build/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('build: duplicate atlas across --atlas-dir paths warns on stderr but exits 0', () => {
|
||||||
|
// Use both fixture dirs (atlases + atlases-dup)
|
||||||
|
const FIX_DUP = path.join(__dirname, 'fixtures', 'atlases-dup');
|
||||||
|
const out = new Sink(); const err = new Sink();
|
||||||
|
const outPath = tmpOut('dup');
|
||||||
|
try {
|
||||||
|
const code = run(
|
||||||
|
['build', path.join(FIX_DSL, 'minimal.txt'), outPath, '--atlas-dir', FIX_ATLAS, '--atlas-dir', FIX_DUP],
|
||||||
|
{ out, err }
|
||||||
|
);
|
||||||
|
assert.equal(code, 0, err.text);
|
||||||
|
assert.match(err.text, /warning.*atlas_a.*duplicate/i);
|
||||||
|
} finally {
|
||||||
|
if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user