From a3ff2f5e6ecb4b0d6297960062e831ee20949c64 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Sat, 23 May 2026 13:50:10 +0200 Subject: [PATCH] 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). --- src/atlas-loader.js | 65 +++++++++++++++++++ tests/atlas-loader.test.js | 63 ++++++++++++++++++ .../atlases-dup/atlas_a/tiles.atlas.json | 9 +++ .../fixtures/atlases/atlas_a/tiles.atlas.json | 10 +++ .../fixtures/atlases/atlas_b/tiles.atlas.json | 9 +++ 5 files changed, 156 insertions(+) create mode 100644 src/atlas-loader.js create mode 100644 tests/atlas-loader.test.js create mode 100644 tests/fixtures/atlases-dup/atlas_a/tiles.atlas.json create mode 100644 tests/fixtures/atlases/atlas_a/tiles.atlas.json create mode 100644 tests/fixtures/atlases/atlas_b/tiles.atlas.json diff --git a/src/atlas-loader.js b/src/atlas-loader.js new file mode 100644 index 0000000..ed04885 --- /dev/null +++ b/src/atlas-loader.js @@ -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 }; diff --git a/tests/atlas-loader.test.js b/tests/atlas-loader.test.js new file mode 100644 index 0000000..24d7a3c --- /dev/null +++ b/tests/atlas-loader.test.js @@ -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); +}); diff --git a/tests/fixtures/atlases-dup/atlas_a/tiles.atlas.json b/tests/fixtures/atlases-dup/atlas_a/tiles.atlas.json new file mode 100644 index 0000000..ae450c1 --- /dev/null +++ b/tests/fixtures/atlases-dup/atlas_a/tiles.atlas.json @@ -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] } + ] +} diff --git a/tests/fixtures/atlases/atlas_a/tiles.atlas.json b/tests/fixtures/atlases/atlas_a/tiles.atlas.json new file mode 100644 index 0000000..817b3e1 --- /dev/null +++ b/tests/fixtures/atlases/atlas_a/tiles.atlas.json @@ -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 } + ] +} diff --git a/tests/fixtures/atlases/atlas_b/tiles.atlas.json b/tests/fixtures/atlases/atlas_b/tiles.atlas.json new file mode 100644 index 0000000..605900a --- /dev/null +++ b/tests/fixtures/atlases/atlas_b/tiles.atlas.json @@ -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 } + ] +}