Add atlas-spec loader with duplicate handling
loadAtlasDirs walks one level deep into each --atlas-dir, picks up */tiles.atlas.json, and builds bidirectional name/id maps. On duplicate atlas_id across paths the first registration wins and a warning string is returned (callers decide whether to print).
This commit is contained in:
65
src/atlas-loader.js
Normal file
65
src/atlas-loader.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
function loadAtlasSpec(specPath) {
|
||||||
|
const raw = fs.readFileSync(specPath, 'utf8');
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`${specPath}: invalid JSON (${e.message})`);
|
||||||
|
}
|
||||||
|
if (typeof parsed.atlas_id !== 'string' || parsed.atlas_id.length === 0) {
|
||||||
|
throw new Error(`${specPath}: missing or non-string atlas_id`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.tiles)) {
|
||||||
|
throw new Error(`${specPath}: missing tiles[]`);
|
||||||
|
}
|
||||||
|
const tilesByName = {};
|
||||||
|
const tilesById = {};
|
||||||
|
for (const tile of parsed.tiles) {
|
||||||
|
if (typeof tile.id !== 'number' || !Number.isInteger(tile.id)) {
|
||||||
|
throw new Error(`${specPath}: tile missing integer 'id'`);
|
||||||
|
}
|
||||||
|
if (typeof tile.name !== 'string' || tile.name.length === 0) {
|
||||||
|
throw new Error(`${specPath}: tile id=${tile.id} missing 'name'`);
|
||||||
|
}
|
||||||
|
tilesByName[tile.name] = tile.id;
|
||||||
|
tilesById[tile.id] = tile.name;
|
||||||
|
}
|
||||||
|
return { atlas_id: parsed.atlas_id, tilesByName, tilesById, specPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAtlasDirs(dirs) {
|
||||||
|
const registry = {};
|
||||||
|
const warnings = [];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
throw new Error(`no such directory: ${dir}`);
|
||||||
|
}
|
||||||
|
const stat = fs.statSync(dir);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
throw new Error(`not a directory: ${dir}`);
|
||||||
|
}
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const specPath = path.join(dir, entry.name, 'tiles.atlas.json');
|
||||||
|
if (!fs.existsSync(specPath)) continue;
|
||||||
|
const spec = loadAtlasSpec(specPath);
|
||||||
|
if (registry[spec.atlas_id]) {
|
||||||
|
warnings.push(
|
||||||
|
`duplicate atlas_id '${spec.atlas_id}' at ${specPath}; ` +
|
||||||
|
`keeping earlier registration from ${registry[spec.atlas_id].specPath}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
registry[spec.atlas_id] = spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { registry, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { loadAtlasDirs, loadAtlasSpec };
|
||||||
63
tests/atlas-loader.test.js
Normal file
63
tests/atlas-loader.test.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { loadAtlasDirs } = require('../src/atlas-loader');
|
||||||
|
|
||||||
|
const FIX = path.join(__dirname, 'fixtures', 'atlases');
|
||||||
|
const FIX_DUP = path.join(__dirname, 'fixtures', 'atlases-dup');
|
||||||
|
|
||||||
|
test('loadAtlasDirs: discovers two atlases in one dir', () => {
|
||||||
|
const { registry, warnings } = loadAtlasDirs([FIX]);
|
||||||
|
assert.equal(warnings.length, 0);
|
||||||
|
assert.deepEqual(Object.keys(registry).sort(), ['atlas_a', 'atlas_b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadAtlasDirs: builds tilesByName and tilesById maps', () => {
|
||||||
|
const { registry } = loadAtlasDirs([FIX]);
|
||||||
|
const a = registry['atlas_a'];
|
||||||
|
assert.equal(a.tilesByName['grass'], 1);
|
||||||
|
assert.equal(a.tilesByName['stone'], 2);
|
||||||
|
assert.equal(a.tilesById[1], 'grass');
|
||||||
|
assert.equal(a.tilesById[2], 'stone');
|
||||||
|
assert.ok(a.specPath.endsWith('tiles.atlas.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadAtlasDirs: empty input → empty registry, no warnings', () => {
|
||||||
|
const { registry, warnings } = loadAtlasDirs([]);
|
||||||
|
assert.deepEqual(registry, {});
|
||||||
|
assert.deepEqual(warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadAtlasDirs: duplicate atlas_id → first wins, warning emitted', () => {
|
||||||
|
const { registry, warnings } = loadAtlasDirs([FIX, FIX_DUP]);
|
||||||
|
// First dir wins: atlas_a has 'grass' name, not 'ALT_grass'
|
||||||
|
assert.equal(registry['atlas_a'].tilesByName['grass'], 1);
|
||||||
|
assert.equal(registry['atlas_a'].tilesByName['ALT_grass'], undefined);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0], /atlas_a/);
|
||||||
|
assert.match(warnings[0], /duplicate/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadAtlasDirs: reverse path order swaps the winner', () => {
|
||||||
|
const { registry, warnings } = loadAtlasDirs([FIX_DUP, FIX]);
|
||||||
|
assert.equal(registry['atlas_a'].tilesByName['ALT_grass'], 1);
|
||||||
|
assert.equal(registry['atlas_a'].tilesByName['grass'], undefined);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadAtlasDirs: missing directory throws', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => loadAtlasDirs(['/no/such/path/exists']),
|
||||||
|
/no such directory/i
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadAtlasDirs: dir with no tiles.atlas.json subdirs → empty registry', () => {
|
||||||
|
// Use sporel-tool-mapper/src/ which has no atlas specs
|
||||||
|
const noAtlasDir = path.join(__dirname, '..', 'src');
|
||||||
|
const { registry, warnings } = loadAtlasDirs([noAtlasDir]);
|
||||||
|
assert.deepEqual(registry, {});
|
||||||
|
assert.equal(warnings.length, 0);
|
||||||
|
});
|
||||||
9
tests/fixtures/atlases-dup/atlas_a/tiles.atlas.json
vendored
Normal file
9
tests/fixtures/atlases-dup/atlas_a/tiles.atlas.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"atlas_id": "atlas_a",
|
||||||
|
"atlas_version": 1,
|
||||||
|
"atlas_size_px": [32, 32],
|
||||||
|
"tile_size_px": 32,
|
||||||
|
"tiles": [
|
||||||
|
{ "id": 1, "name": "ALT_grass", "uv": [0, 0, 32, 32] }
|
||||||
|
]
|
||||||
|
}
|
||||||
10
tests/fixtures/atlases/atlas_a/tiles.atlas.json
vendored
Normal file
10
tests/fixtures/atlases/atlas_a/tiles.atlas.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"atlas_id": "atlas_a",
|
||||||
|
"atlas_version": 1,
|
||||||
|
"atlas_size_px": [64, 64],
|
||||||
|
"tile_size_px": 32,
|
||||||
|
"tiles": [
|
||||||
|
{ "id": 1, "name": "grass", "uv": [0, 0, 32, 32], "walkable": true },
|
||||||
|
{ "id": 2, "name": "stone", "uv": [32, 0, 32, 32], "walkable": false, "blocks_sight": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
9
tests/fixtures/atlases/atlas_b/tiles.atlas.json
vendored
Normal file
9
tests/fixtures/atlases/atlas_b/tiles.atlas.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"atlas_id": "atlas_b",
|
||||||
|
"atlas_version": 1,
|
||||||
|
"atlas_size_px": [64, 32],
|
||||||
|
"tile_size_px": 32,
|
||||||
|
"tiles": [
|
||||||
|
{ "id": 1, "name": "water", "uv": [0, 0, 32, 32], "walkable": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user