From 530bceadddb7fed17ab04e5c167aa1f0d24f3ccf Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Sat, 23 May 2026 16:55:56 +0200 Subject: [PATCH] Add map inspector that emits a human-readable stats report Counts set vs empty cells per layer, lists the top-10 most frequent non-empty GIDs with decoded components, and reports roof coverage. When an atlas registry is supplied, atlas-id+tile-name annotations are added; without one the report still works using indices only. --- src/inspector.js | 77 +++++++++++++++++++ .../atlas_a/tiles.atlas.json | 10 +++ tests/fixtures/maps/inspect_sample.map.json | 30 ++++++++ tests/inspector.test.js | 76 ++++++++++++++++++ 4 files changed, 193 insertions(+) create mode 100644 src/inspector.js create mode 100644 tests/fixtures/atlases-inspector/atlas_a/tiles.atlas.json create mode 100644 tests/fixtures/maps/inspect_sample.map.json create mode 100644 tests/inspector.test.js diff --git a/src/inspector.js b/src/inspector.js new file mode 100644 index 0000000..fd07a7c --- /dev/null +++ b/src/inspector.js @@ -0,0 +1,77 @@ +'use strict'; + +const { decodeGid } = require('./gid'); + +function tileLabel(atlasIdx, tileId, atlasIdsByIndex, registry) { + const atlasId = atlasIdsByIndex[atlasIdx]; + const reg = atlasId ? registry[atlasId] : null; + const name = reg ? reg.tilesById[tileId] : null; + const components = `atlas=${atlasIdx} tile=${tileId}`; + if (atlasId && name) return `${components.padEnd(20)} (${atlasId}:${name})`; + if (atlasId) return `${components.padEnd(20)} (${atlasId}:?)`; + return components; +} + +function atlasResolutionMarker(atlasId, registry) { + if (!registry || Object.keys(registry).length === 0) return '(no registry)'; + if (registry[atlasId]) return '(resolved)'; + return '(not found)'; +} + +function inspectMap(map, registry) { + const lines = []; + const reg = registry || {}; + const atlasIdsByIndex = map.atlases || []; + + lines.push(`Map: ${map.id} (schema_version=${map.schema_version}, size=${map.size.w}x${map.size.h})`); + lines.push(''); + + lines.push('Atlases:'); + if (atlasIdsByIndex.length === 0) { + lines.push(' (none declared)'); + } else { + atlasIdsByIndex.forEach((aid, i) => { + lines.push(` [${i}] ${aid.padEnd(40)} ${atlasResolutionMarker(aid, reg)}`); + }); + } + lines.push(''); + + lines.push('Layers:'); + const layerEntries = Object.entries(map.layers || {}); + if (layerEntries.length === 0) { + lines.push(' (no layers)'); + } else { + for (const [layerName, layer] of layerEntries) { + const tiles = layer.tiles || []; + let set = 0, empty = 0; + const counts = new Map(); + for (const gid of tiles) { + if (gid === 0) { empty++; continue; } + set++; + counts.set(gid, (counts.get(gid) || 0) + 1); + } + lines.push(` ${layerName.padEnd(13)} ${String(set).padStart(3)} cells set, ${String(empty).padStart(3)} empty`); + if (counts.size > 0) { + lines.push(' top tiles:'); + const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10); + for (const [gid, n] of top) { + const { atlas, tile } = decodeGid(gid); + lines.push(` ${tileLabel(atlas, tile, atlasIdsByIndex, reg)} × ${n}`); + } + } + } + } + lines.push(''); + + const roof = map.roof; + if (Array.isArray(roof)) { + const flagged = roof.reduce((a, v) => a + (v ? 1 : 0), 0); + lines.push(`Roof: ${flagged} cells flagged`); + } else { + lines.push('Roof: (none)'); + } + + return lines.join('\n') + '\n'; +} + +module.exports = { inspectMap }; diff --git a/tests/fixtures/atlases-inspector/atlas_a/tiles.atlas.json b/tests/fixtures/atlases-inspector/atlas_a/tiles.atlas.json new file mode 100644 index 0000000..817b3e1 --- /dev/null +++ b/tests/fixtures/atlases-inspector/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/maps/inspect_sample.map.json b/tests/fixtures/maps/inspect_sample.map.json new file mode 100644 index 0000000..24f9881 --- /dev/null +++ b/tests/fixtures/maps/inspect_sample.map.json @@ -0,0 +1,30 @@ +{ + "schema_version": 2, + "id": "inspect_sample", + "size": { "w": 4, "h": 4 }, + "atlases": ["atlas_a", "atlas_b"], + "layers": { + "surface": { + "tiles": [ + 16, 16, 16, 16, + 16, 32, 32, 16, + 16, 32, 32, 16, + 16, 16, 16, 16 + ] + }, + "wall": { + "tiles": [ + 16777232, 16777232, 16777232, 16777232, + 16777232, 0, 0, 16777232, + 16777232, 0, 0, 16777232, + 16777232, 16777232, 16777232, 16777232 + ] + } + }, + "roof": [ + 0, 0, 0, 0, + 0, 1, 1, 0, + 0, 1, 1, 0, + 0, 0, 0, 0 + ] +} diff --git a/tests/inspector.test.js b/tests/inspector.test.js new file mode 100644 index 0000000..2a536df --- /dev/null +++ b/tests/inspector.test.js @@ -0,0 +1,76 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { inspectMap } = require('../src/inspector'); +const { loadAtlasDirs } = require('../src/atlas-loader'); + +const FIX_MAP = path.join(__dirname, 'fixtures', 'maps', 'inspect_sample.map.json'); +const FIX_ATLAS = path.join(__dirname, 'fixtures', 'atlases-inspector'); +const loadMap = () => JSON.parse(fs.readFileSync(FIX_MAP, 'utf8')); + +test('inspectMap: header lists id, schema_version, size', () => { + const text = inspectMap(loadMap(), {}); + assert.match(text, /Map: inspect_sample/); + assert.match(text, /schema_version=2/); + assert.match(text, /size=4x4/); +}); + +test('inspectMap: atlas section lists each declared atlas with resolved/not-found marker', () => { + const { registry } = loadAtlasDirs([FIX_ATLAS]); + const text = inspectMap(loadMap(), registry); + assert.match(text, /\[0\] atlas_a.*resolved/); + // atlas_b is not in the FIX_ATLAS fixture dir + assert.match(text, /\[1\] atlas_b.*not found/); +}); + +test('inspectMap: without registry, atlases show "no registry" marker', () => { + const text = inspectMap(loadMap(), {}); + assert.match(text, /\[0\] atlas_a.*no registry/); +}); + +test('inspectMap: per-layer cell counts are correct', () => { + const text = inspectMap(loadMap(), {}); + // surface has all 16 cells set + assert.match(text, /surface\s+16 cells set,\s+0 empty/); + // wall has 12 set, 4 empty (the 2x2 hole) + assert.match(text, /wall\s+12 cells set,\s+4 empty/); +}); + +test('inspectMap: top tiles include atlas/tile components', () => { + const text = inspectMap(loadMap(), {}); + // surface tile (0,1,0) = grass appears 12 times + assert.match(text, /atlas=0 tile=1.*× ?12/); + // surface tile (0,2,0) = stone appears 4 times + assert.match(text, /atlas=0 tile=2.*× ?4/); +}); + +test('inspectMap: with registry, top tiles show resolved name', () => { + const { registry } = loadAtlasDirs([FIX_ATLAS]); + const text = inspectMap(loadMap(), registry); + assert.match(text, /atlas_a:grass/); + assert.match(text, /atlas_a:stone/); +}); + +test('inspectMap: roof line shows flagged-cell count', () => { + const text = inspectMap(loadMap(), {}); + assert.match(text, /Roof: 4 cells flagged/); +}); + +test('inspectMap: map without roof omits roof section gracefully', () => { + const m = loadMap(); + delete m.roof; + const text = inspectMap(m, {}); + assert.match(text, /Roof:\s*0 cells flagged|Roof: \(none\)/); +}); + +test('inspectMap: empty layers are not listed as top-tile groups', () => { + const m = loadMap(); + m.layers.topsurface = { tiles: new Array(16).fill(0) }; + const text = inspectMap(m, {}); + assert.match(text, /topsurface\s+0 cells set,\s+16 empty/); + // The "top tiles" block under topsurface should be absent (or "none") + assert.doesNotMatch(text.split('topsurface')[1].split(/\n[a-z]/)[0], /atlas=\d+ tile=\d+/); +});