Add map builder that turns AST + atlas registry into v2 JSON

Validates atlas declarations against the registry, resolves tile
names per atlas, bounds-checks each cell, and emits a flat
row-major (y*w + x) GID array per layer. Roof block is only
serialised when at least one roof directive was present so empty
maps stay compact.
This commit is contained in:
Axel Meyer
2026-05-23 16:45:06 +02:00
parent e96431b44f
commit 7adcb407fa
2 changed files with 233 additions and 0 deletions

133
tests/builder.test.js Normal file
View File

@@ -0,0 +1,133 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { parseDsl } = require('../src/dsl-parser');
const { loadAtlasDirs } = require('../src/atlas-loader');
const { buildMap } = require('../src/builder');
const { encodeGid } = require('../src/gid');
const FIX_ATLAS = path.join(__dirname, 'fixtures', 'atlases');
const { registry } = loadAtlasDirs([FIX_ATLAS]);
function build(src, opts = {}) {
const ast = parseDsl(src);
return buildMap(ast, registry, opts);
}
test('buildMap: minimal map produces schema-v2 shape', () => {
const map = build('size 2 2\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\n', { defaultId: 'fallback' });
assert.equal(map.schema_version, 2);
assert.equal(map.id, 'fallback');
assert.deepEqual(map.size, { w: 2, h: 2 });
assert.deepEqual(map.atlases, ['atlas_a']);
assert.equal(map.layers.surface.tiles.length, 4);
// Cell (0,0) is index 0 (row-major: y*w + x)
assert.equal(map.layers.surface.tiles[0], encodeGid(0, 1, 0));
assert.equal(map.layers.surface.tiles[1], 0); // (1,0) empty
});
test('buildMap: AST id wins over defaultId', () => {
const map = build('id from_dsl\nsize 1 1\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\n', { defaultId: 'fallback' });
assert.equal(map.id, 'from_dsl');
});
test('buildMap: fill produces correct row-major GID array', () => {
const src = 'size 3 3\natlas atlas_a\nlayer surface\n fill 0 0 2 2 atlas_a:stone\n';
const map = build(src, { defaultId: 'test' });
const gid = encodeGid(0, 2, 0);
for (let i = 0; i < 9; i++) assert.equal(map.layers.surface.tiles[i], gid, `cell ${i}`);
});
test('buildMap: rotation encodes into GID', () => {
const src = 'size 1 1\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass rot 3\n';
const map = build(src, { defaultId: 't' });
assert.equal(map.layers.surface.tiles[0], encodeGid(0, 1, 3));
});
test('buildMap: multiple atlases get correct atlas_index', () => {
const src = 'size 1 1\natlas atlas_a\natlas atlas_b\nlayer surface\n set 0 0 atlas_b:water\n';
const map = build(src, { defaultId: 't' });
// atlas_b is index 1 in declaration order
assert.equal(map.layers.surface.tiles[0], encodeGid(1, 1, 0));
});
test('buildMap: roof block produces flat array of size w*h', () => {
const src = 'size 2 2\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\nroof\n set 1 1 1\n';
const map = build(src, { defaultId: 't' });
assert.equal(map.roof.length, 4);
assert.equal(map.roof[3], 1);
assert.equal(map.roof[0], 0);
});
test('buildMap: empty layers are not serialised', () => {
const src = 'size 2 2\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\n';
const map = build(src, { defaultId: 't' });
assert.deepEqual(Object.keys(map.layers), ['surface']);
assert.equal(map.roof, undefined);
});
test('buildMap: unknown atlas reference is an error', () => {
const src = 'size 1 1\natlas atlas_a\nlayer surface\n set 0 0 atlas_c:grass\n';
assert.throws(
() => build(src, { defaultId: 't' }),
(err) => err.message.match(/atlas_c.*not declared/) && err.line === 4
);
});
test('buildMap: undeclared atlas in DSL is an error', () => {
const src = 'size 1 1\natlas missing_atlas\nlayer surface\n set 0 0 missing_atlas:grass\n';
assert.throws(
() => build(src, { defaultId: 't' }),
(err) => !!err.message.match(/missing_atlas.*registry/)
);
});
test('buildMap: unknown tile name in atlas is an error', () => {
const src = 'size 1 1\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:nonexistent\n';
assert.throws(
() => build(src, { defaultId: 't' }),
(err) => err.message.match(/nonexistent/) && err.line === 4
);
});
test('buildMap: out-of-bounds set is an error', () => {
const src = 'size 4 4\natlas atlas_a\nlayer surface\n set 5 0 atlas_a:grass\n';
assert.throws(
() => build(src, { defaultId: 't' }),
(err) => err.message.match(/out of bounds/) && err.line === 4
);
});
test('buildMap: out-of-bounds fill is an error', () => {
const src = 'size 4 4\natlas atlas_a\nlayer surface\n fill 0 0 4 4 atlas_a:grass\n';
assert.throws(
() => build(src, { defaultId: 't' }),
(err) => err.message.match(/out of bounds/) && err.line === 4
);
});
test('buildMap: out-of-bounds roof set is an error', () => {
const src = 'size 2 2\natlas atlas_a\nlayer surface\n set 0 0 atlas_a:grass\nroof\n set 2 2 1\n';
assert.throws(
() => build(src, { defaultId: 't' }),
(err) => err.message.match(/out of bounds/) && err.line === 6
);
});
test('buildMap: no atlases declared is an error', () => {
const src = 'size 1 1\n';
assert.throws(
() => build(src, { defaultId: 't' }),
(err) => !!err.message.match(/atlases.*non-empty/)
);
});
test('buildMap: empty defaultId AND no DSL id is an error', () => {
const src = 'size 1 1\natlas atlas_a\n';
assert.throws(
() => build(src, { defaultId: '' }),
(err) => !!err.message.match(/id/)
);
});