Compare commits

...

10 Commits

Author SHA1 Message Date
Axel Meyer
ae2885a453 Expand README with concrete usage examples
Covers all four subcommands, the GID bit layout, the test runner,
and a pointer to the design spec in sporel-meta.
2026-05-23 17:01:06 +02:00
Axel Meyer
eb255480a1 Add inspect subcommand: read-only stats for v2 map files
Reads the map JSON, optionally loads --atlas-dir paths for name
resolution, and prints the inspector report to stdout. Missing
file, bad JSON, and missing --atlas-dir paths all produce
labelled stderr errors with exit 1.
2026-05-23 16:59:07 +02:00
Axel Meyer
530bceaddd 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.
2026-05-23 16:55:56 +02:00
Axel Meyer
f516e2acda 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'.
2026-05-23 16:50:42 +02:00
Axel Meyer
7adcb407fa 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.
2026-05-23 16:45:06 +02:00
Axel Meyer
e96431b44f Add text-DSL parser with line-numbered errors
Two-pass-free line-based scanner. Each directive carries its source
line so downstream validation errors can be attributed correctly.
Parser checks syntax only — atlas/tile resolution and bounds checks
are deferred to the builder so each tier has one responsibility.
2026-05-23 14:19:10 +02:00
Axel Meyer
a3ff2f5e6e 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).
2026-05-23 13:50:10 +02:00
Axel Meyer
cef9c5c9a2 Add decode subcommand
Accepts decimal or 0x-hex GID input. gid=0 is rendered as the
literal 'empty'; non-zero values print the three components in a
parse-stable space-separated key=value form.
2026-05-23 13:47:39 +02:00
Axel Meyer
7316ebc7dd Add encode subcommand
Wraps gid.encodeGid, prints the packed-u32 as decimal on stdout,
emits usage on stderr on bad arity, and a labelled error on
out-of-range or non-integer input.
2026-05-23 13:46:03 +02:00
Axel Meyer
b1743ad19f Add CLI dispatcher and bin entry
Top-level run(argv,{out,err}) accepts an injectable output sink so
handlers can be unit-tested without spawning a subprocess. Handler
map is empty for now; subcommands are wired in as they land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:44:10 +02:00
28 changed files with 1594 additions and 8 deletions

View File

@@ -1,16 +1,86 @@
# sporel-tool-mapper
Standalone CLI for v2 Sporel map files.
Standalone CLI for v2 Sporel map files: encode/decode packed-u32 GIDs, build maps from a text DSL, and inspect stats.
## Status
## Install
`v0.1.0` — initial scaffold. See `docs/superpowers/specs/2026-05-23-sporel-tool-mapper-design.md` in `sporel-meta` for the full spec.
This is part of the Sporel monorepo workspace. No npm publish — clone the repo and run via `node bin/mapper.js` or symlink the bin entry into `$PATH`.
```bash
node bin/mapper.js <subcommand> [args...]
```
Requires Node ≥18. Zero runtime dependencies.
## Subcommands
- `encode <atlas_idx> <tile_id> [rotation]`
- `decode <gid>`
- `build <spec.txt> <out.json> [--atlas-dir <path> ...]`
- `inspect <map.json> [--atlas-dir <path> ...]`
### `encode <atlas_idx> <tile_id> [rotation]`
(Detailed usage and examples are added in Task 12.)
Print the packed-u32 GID for the given components.
```bash
$ node bin/mapper.js encode 0 12 1
196
```
### `decode <gid>`
Print the components of a GID. Accepts decimal or `0x...` hex. `gid=0` prints `empty`.
```bash
$ node bin/mapper.js decode 196
atlas=0 tile=12 rotation=1
$ node bin/mapper.js decode 0
empty
```
### `build <spec.txt> <out.json> [--atlas-dir <path> ...]`
Compile a text-DSL map spec into a v2 map JSON file. See `docs/dsl.md` for full syntax (in `sporel-meta`, design spec §4). Minimal example:
```
id border_demo
size 8 8
atlas fa_terrain_v1
layer surface
fill 0 0 7 7 fa_terrain_v1:grass_field
set 3 3 fa_terrain_v1:stone_wall_brick rot 1
```
```bash
$ node bin/mapper.js build map.txt out.json --atlas-dir ../sporel-libs/lib-asset/prototype-fa-starter/assets/atlases
```
If the DSL has no `id` directive the output filename basename is used.
### `inspect <map.json> [--atlas-dir <path> ...]`
Read-only stats report. Without `--atlas-dir`, atlas references show as indices only; with it, atlas-id and tile names are resolved.
```bash
$ node bin/mapper.js inspect demo.map.json --atlas-dir ../sporel-libs/lib-asset/prototype-fa-starter/assets/atlases
```
## Encoding
GIDs use the same packed-u32 layout as `lib-core.maps` (see `sporel-libs/lib-core/maps/init.lua`):
```
bit 31 ...... 24 | 23 ............ 4 | 3 .. 2 | 1 .. 0
[ atlas:8 ] [ tile_id:20 ] [ rot:2 ] [res:2]
```
`gid=0` is the canonical empty cell.
## Tests
```bash
npm test
```
Uses Node's built-in `node --test` runner. No mocking framework.
## Spec
`docs/superpowers/specs/2026-05-23-sporel-tool-mapper-design.md` in the `sporel-meta` repo.

5
bin/mapper.js Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env node
'use strict';
const { run } = require('../src/cli');
process.exit(run(process.argv.slice(2), { out: process.stdout, err: process.stderr }));

65
src/atlas-loader.js Normal file
View 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(
`atlas_id '${spec.atlas_id}' is a duplicate; ` +
`${specPath} ignored, keeping earlier registration from ${registry[spec.atlas_id].specPath}`
);
continue;
}
registry[spec.atlas_id] = spec;
}
}
return { registry, warnings };
}
module.exports = { loadAtlasDirs, loadAtlasSpec };

100
src/builder.js Normal file
View File

@@ -0,0 +1,100 @@
'use strict';
const { encodeGid } = require('./gid');
class BuildError extends Error {
constructor(line, message) {
super(message);
this.line = line;
}
}
function buildMap(ast, registry, opts = {}) {
if (ast.atlases.length === 0) {
throw new BuildError(1, `at least one 'atlas' declaration required (atlases[] must be non-empty)`);
}
// Resolve declared atlas_ids against registry, build atlasId → atlas_index map.
const atlasIndex = {};
ast.atlases.forEach((atlasId, idx) => {
if (!registry[atlasId]) {
throw new BuildError(1, `atlas '${atlasId}' declared in DSL but not found in atlas registry (check --atlas-dir paths)`);
}
atlasIndex[atlasId] = idx;
});
const id = ast.id || opts.defaultId;
if (!id || typeof id !== 'string' || id.length === 0) {
throw new BuildError(1, `map id is required (declare with 'id <name>' in the DSL or pass a non-empty out-file basename)`);
}
const { w, h } = ast.size;
const cellCount = w * h;
function resolveTile(atlasId, tileName, line) {
const atlas = registry[atlasId];
if (!atlas) {
throw new BuildError(line, `atlas '${atlasId}' not declared in DSL`);
}
const tileId = atlas.tilesByName[tileName];
if (tileId === undefined) {
throw new BuildError(line, `tile name '${tileName}' not found in atlas '${atlasId}'`);
}
return tileId;
}
function checkBounds(x, y, line) {
if (x < 0 || y < 0 || x >= w || y >= h) {
throw new BuildError(line, `cell (${x},${y}) out of bounds for size ${w}x${h}`);
}
}
// Build each layer.
const layers = {};
for (const [layerName, directives] of Object.entries(ast.layers)) {
const tiles = new Array(cellCount).fill(0);
for (const d of directives) {
const ai = atlasIndex[d.atlasId];
if (ai === undefined) {
throw new BuildError(d.line, `atlas '${d.atlasId}' not declared in DSL`);
}
const tileId = resolveTile(d.atlasId, d.tileName, d.line);
const gid = encodeGid(ai, tileId, d.rot);
if (d.type === 'set') {
checkBounds(d.x, d.y, d.line);
tiles[d.y * w + d.x] = gid;
} else {
// fill — bounds-check the corners; parser already enforced x0<=x1 and y0<=y1
checkBounds(d.x0, d.y0, d.line);
checkBounds(d.x1, d.y1, d.line);
for (let y = d.y0; y <= d.y1; y++) {
for (let x = d.x0; x <= d.x1; x++) {
tiles[y * w + x] = gid;
}
}
}
}
layers[layerName] = { tiles };
}
const map = {
schema_version: 2,
id,
size: { w, h },
atlases: ast.atlases.slice(),
layers,
};
if (ast.roof.length > 0) {
const roof = new Array(cellCount).fill(0);
for (const r of ast.roof) {
checkBounds(r.x, r.y, r.line);
roof[r.y * w + r.x] = r.value;
}
map.roof = roof;
}
return map;
}
module.exports = { buildMap, BuildError };

46
src/cli.js Normal file
View File

@@ -0,0 +1,46 @@
'use strict';
const USAGE = `usage: sporel-mapper <subcommand> [args...]
Subcommands:
encode <atlas_idx> <tile_id> [rotation]
Print the packed-u32 GID for the given components.
decode <gid>
Print the (atlas, tile, rotation) components of a GID.
build <spec.txt> <out.json> [--atlas-dir <path> ...]
Compile a text-DSL map spec into a v2 map JSON file.
inspect <map.json> [--atlas-dir <path> ...]
Print a human-readable stats report for a v2 map.
Options:
-h, --help Show this message.
`;
function run(argv, { out, err }) {
if (argv.length === 0) {
err.write(USAGE);
return 1;
}
const [head, ...rest] = argv;
if (head === '-h' || head === '--help') {
out.write(USAGE);
return 0;
}
const handlers = {
encode: require('./commands/encode'),
decode: require('./commands/decode'),
build: require('./commands/build'),
inspect: require('./commands/inspect'),
};
const handler = handlers[head];
if (!handler) {
err.write(`unknown subcommand: ${head}\n${USAGE}`);
return 1;
}
return handler.run(rest, { out, err });
}
module.exports = { run };

94
src/commands/build.js Normal file
View 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 };

39
src/commands/decode.js Normal file
View File

@@ -0,0 +1,39 @@
'use strict';
const { decodeGid } = require('../gid');
const USAGE = 'usage: sporel-mapper decode <gid>\n';
function parseGid(s) {
let n;
if (/^0x[0-9a-fA-F]+$/.test(s)) {
n = parseInt(s, 16);
} else if (/^\d+$/.test(s)) {
n = parseInt(s, 10);
} else {
throw new RangeError(`gid '${s}' is not a decimal or 0x-hex integer`);
}
return n;
}
function run(args, { out, err }) {
if (args.length !== 1) {
err.write(USAGE);
return 1;
}
try {
const gid = parseGid(args[0]);
if (gid === 0) {
out.write('empty\n');
return 0;
}
const { atlas, tile, rotation } = decodeGid(gid);
out.write(`atlas=${atlas} tile=${tile} rotation=${rotation}\n`);
return 0;
} catch (e) {
err.write(`error: ${e.message}\n`);
return 1;
}
}
module.exports = { run };

32
src/commands/encode.js Normal file
View File

@@ -0,0 +1,32 @@
'use strict';
const { encodeGid } = require('../gid');
const USAGE = 'usage: sporel-mapper encode <atlas_idx> <tile_id> [rotation]\n';
function parseIntStrict(s, field) {
if (!/^-?\d+$/.test(s)) {
throw new RangeError(`${field} '${s}' is not an integer`);
}
return parseInt(s, 10);
}
function run(args, { out, err }) {
if (args.length < 2 || args.length > 3) {
err.write(USAGE);
return 1;
}
try {
const atlas = parseIntStrict(args[0], 'atlas');
const tile = parseIntStrict(args[1], 'tile');
const rotation = args.length === 3 ? parseIntStrict(args[2], 'rotation') : 0;
const gid = encodeGid(atlas, tile, rotation);
out.write(`${gid}\n`);
return 0;
} catch (e) {
err.write(`error: ${e.message}\n`);
return 1;
}
}
module.exports = { run };

77
src/commands/inspect.js Normal file
View File

@@ -0,0 +1,77 @@
'use strict';
const fs = require('node:fs');
const { loadAtlasDirs } = require('../atlas-loader');
const { inspectMap } = require('../inspector');
const USAGE = 'usage: sporel-mapper inspect <map.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 !== 1) {
err.write(USAGE);
return 1;
}
const [mapPath] = parsed.positional;
let raw;
try {
raw = fs.readFileSync(mapPath, 'utf8');
} catch (e) {
err.write(`error: cannot read ${mapPath}: ${e.message}\n`);
return 1;
}
let map;
try {
map = JSON.parse(raw);
} catch (e) {
err.write(`error: ${mapPath} is not valid JSON: ${e.message}\n`);
return 1;
}
let registry = {};
if (parsed.atlasDirs.length > 0) {
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;
}
}
out.write(inspectMap(map, registry));
return 0;
}
module.exports = { run };

180
src/dsl-parser.js Normal file
View File

@@ -0,0 +1,180 @@
'use strict';
const VALID_LAYERS = new Set([
'foundation', 'subsurface', 'surface', 'topsurface',
'lower_wall', 'wall', 'upper_wall', 'canopy',
]);
class DslError extends Error {
constructor(line, message) {
super(message);
this.line = line;
}
}
function parseIntStrict(s, line, field) {
if (!/^-?\d+$/.test(s)) {
throw new DslError(line, `${field} '${s}' is not an integer`);
}
return parseInt(s, 10);
}
function parseTileRef(s, line) {
const idx = s.indexOf(':');
if (idx <= 0 || idx === s.length - 1) {
throw new DslError(line, `expected atlas_id:tile_name, got '${s}'`);
}
return { atlasId: s.slice(0, idx), tileName: s.slice(idx + 1) };
}
function parseOptionalRot(tokens, line) {
if (tokens.length === 0) return 0;
if (tokens[0] !== 'rot') {
throw new DslError(line, `unexpected token '${tokens[0]}' (only 'rot N' allowed here)`);
}
if (tokens.length < 2) {
throw new DslError(line, `'rot' needs a value 0..3`);
}
if (tokens.length > 2) {
throw new DslError(line, `unexpected tokens after rotation: ${tokens.slice(2).join(' ')}`);
}
const r = parseIntStrict(tokens[1], line, 'rotation');
if (r < 0 || r > 3) {
throw new DslError(line, `rotation ${r} out of range [0,3]`);
}
return r;
}
function parseDsl(text) {
const ast = {
id: null,
size: null,
atlases: [],
layers: {},
roof: [],
};
let currentBlock = null; // null | { kind: 'layer', name } | { kind: 'roof' }
let sawSize = false;
const rawLines = text.split(/\r?\n/);
for (let i = 0; i < rawLines.length; i++) {
const lineNo = i + 1;
const stripped = rawLines[i].replace(/#.*$/, '').trim();
if (stripped.length === 0) continue;
const tokens = stripped.split(/\s+/);
const head = tokens[0];
if (head === 'id') {
if (sawSize) throw new DslError(lineNo, `'id' must appear before 'size'`);
if (ast.id !== null) throw new DslError(lineNo, `'id' declared twice`);
if (tokens.length !== 2) throw new DslError(lineNo, `usage: id <map_id>`);
ast.id = tokens[1];
continue;
}
if (head === 'size') {
if (sawSize) throw new DslError(lineNo, `'size' declared twice`);
if (tokens.length !== 3) throw new DslError(lineNo, `usage: size <W> <H>`);
const w = parseIntStrict(tokens[1], lineNo, 'W');
const h = parseIntStrict(tokens[2], lineNo, 'H');
if (w < 1 || w > 1024 || h < 1 || h > 1024) {
throw new DslError(lineNo, `size ${w}x${h} out of range [1..1024]`);
}
ast.size = { w, h };
sawSize = true;
continue;
}
if (!sawSize) {
throw new DslError(lineNo, `'size' must be the first directive (before '${head}')`);
}
if (head === 'atlas') {
if (currentBlock !== null) {
throw new DslError(lineNo, `'atlas' must come before any layer/roof block`);
}
if (tokens.length !== 2) throw new DslError(lineNo, `usage: atlas <atlas_id>`);
ast.atlases.push(tokens[1]);
continue;
}
if (head === 'layer') {
if (tokens.length !== 2) throw new DslError(lineNo, `usage: layer <layer_name>`);
const name = tokens[1];
if (!VALID_LAYERS.has(name)) {
throw new DslError(lineNo, `unknown layer '${name}' (valid: ${[...VALID_LAYERS].join(', ')})`);
}
currentBlock = { kind: 'layer', name };
if (!ast.layers[name]) ast.layers[name] = [];
continue;
}
if (head === 'roof') {
if (tokens.length !== 1) throw new DslError(lineNo, `usage: roof`);
currentBlock = { kind: 'roof' };
continue;
}
if (head === 'set' || head === 'fill') {
if (currentBlock === null) {
throw new DslError(lineNo, `'${head}' must appear inside a 'layer' or 'roof' block`);
}
if (currentBlock.kind === 'roof') {
if (head !== 'set') {
throw new DslError(lineNo, `'roof' block only allows 'set X Y 0|1'`);
}
if (tokens.length !== 4) {
throw new DslError(lineNo, `usage: set <x> <y> <0|1>`);
}
const x = parseIntStrict(tokens[1], lineNo, 'x');
const y = parseIntStrict(tokens[2], lineNo, 'y');
const v = parseIntStrict(tokens[3], lineNo, 'value');
if (v !== 0 && v !== 1) {
throw new DslError(lineNo, `roof value ${v} must be 0 or 1`);
}
ast.roof.push({ line: lineNo, x, y, value: v });
continue;
}
// layer block
if (head === 'set') {
if (tokens.length < 4) {
throw new DslError(lineNo, `usage: set <x> <y> <atlas_id>:<tile_name> [rot N]`);
}
const x = parseIntStrict(tokens[1], lineNo, 'x');
const y = parseIntStrict(tokens[2], lineNo, 'y');
const { atlasId, tileName } = parseTileRef(tokens[3], lineNo);
const rot = parseOptionalRot(tokens.slice(4), lineNo);
ast.layers[currentBlock.name].push({
type: 'set', line: lineNo, x, y, atlasId, tileName, rot,
});
continue;
}
// fill
if (tokens.length < 6) {
throw new DslError(lineNo, `usage: fill <x0> <y0> <x1> <y1> <atlas_id>:<tile_name> [rot N]`);
}
const x0 = parseIntStrict(tokens[1], lineNo, 'x0');
const y0 = parseIntStrict(tokens[2], lineNo, 'y0');
const x1 = parseIntStrict(tokens[3], lineNo, 'x1');
const y1 = parseIntStrict(tokens[4], lineNo, 'y1');
if (x0 > x1) throw new DslError(lineNo, `fill: x0 (${x0}) > x1 (${x1})`);
if (y0 > y1) throw new DslError(lineNo, `fill: y0 (${y0}) > y1 (${y1})`);
const { atlasId, tileName } = parseTileRef(tokens[5], lineNo);
const rot = parseOptionalRot(tokens.slice(6), lineNo);
ast.layers[currentBlock.name].push({
type: 'fill', line: lineNo, x0, y0, x1, y1, atlasId, tileName, rot,
});
continue;
}
throw new DslError(lineNo, `unknown directive '${head}'`);
}
if (!sawSize) {
throw new DslError(1, `missing required 'size' directive`);
}
return ast;
}
module.exports = { parseDsl, DslError, VALID_LAYERS };

77
src/inspector.js Normal file
View File

@@ -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 };

View 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);
});

98
tests/build.test.js Normal file
View 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);
}
});

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/)
);
});

46
tests/cli.test.js Normal file
View File

@@ -0,0 +1,46 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { run } = require('../src/cli');
class Sink {
constructor() { this.chunks = []; }
write(s) { this.chunks.push(s); }
get text() { return this.chunks.join(''); }
}
test('cli: no args prints usage to stderr, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run([], { out, err });
assert.equal(code, 1);
assert.match(err.text, /usage/i);
assert.match(err.text, /encode/);
assert.match(err.text, /decode/);
assert.match(err.text, /build/);
assert.match(err.text, /inspect/);
assert.equal(out.text, '');
});
test('cli: --help prints usage to stdout, exit 0', () => {
const out = new Sink(); const err = new Sink();
const code = run(['--help'], { out, err });
assert.equal(code, 0);
assert.match(out.text, /usage/i);
assert.equal(err.text, '');
});
test('cli: -h alias for --help', () => {
const out = new Sink(); const err = new Sink();
const code = run(['-h'], { out, err });
assert.equal(code, 0);
assert.match(out.text, /usage/i);
});
test('cli: unknown subcommand goes to stderr, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['frobnicate'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /unknown subcommand/i);
assert.match(err.text, /frobnicate/);
});

61
tests/decode.test.js Normal file
View File

@@ -0,0 +1,61 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { run } = require('../src/cli');
class Sink {
constructor() { this.chunks = []; }
write(s) { this.chunks.push(s); }
get text() { return this.chunks.join(''); }
}
test('decode: 196 prints (0,12,1)', () => {
const out = new Sink(); const err = new Sink();
const code = run(['decode', '196'], { out, err });
assert.equal(code, 0);
assert.equal(out.text, 'atlas=0 tile=12 rotation=1\n');
});
test('decode: 0 prints "empty"', () => {
const out = new Sink(); const err = new Sink();
const code = run(['decode', '0'], { out, err });
assert.equal(code, 0);
assert.equal(out.text, 'empty\n');
});
test('decode: hex (0xc4) parses to 196', () => {
const out = new Sink(); const err = new Sink();
const code = run(['decode', '0xc4'], { out, err });
assert.equal(code, 0);
assert.equal(out.text, 'atlas=0 tile=12 rotation=1\n');
});
test('decode: non-zero with tile=0 prints components (not "empty")', () => {
// gid=4 = (0<<24) | (0<<4) | (1<<2) → atlas=0 tile=0 rot=1
const out = new Sink(); const err = new Sink();
const code = run(['decode', '4'], { out, err });
assert.equal(code, 0);
assert.equal(out.text, 'atlas=0 tile=0 rotation=1\n');
});
test('decode: 16777232 from demo_v2 wall layer → (1,1,0)', () => {
const out = new Sink(); const err = new Sink();
const code = run(['decode', '16777232'], { out, err });
assert.equal(code, 0);
assert.equal(out.text, 'atlas=1 tile=1 rotation=0\n');
});
test('decode: no args prints usage, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['decode'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /usage: sporel-mapper decode/);
});
test('decode: bogus arg prints error, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['decode', 'xyz'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /error/i);
});

125
tests/dsl-parser.test.js Normal file
View File

@@ -0,0 +1,125 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { parseDsl } = require('../src/dsl-parser');
const FIX = path.join(__dirname, 'fixtures', 'dsl');
const read = (n) => fs.readFileSync(path.join(FIX, n), 'utf8');
test('parseDsl: minimal map yields correct AST', () => {
const ast = parseDsl(read('minimal.txt'));
assert.equal(ast.id, null);
assert.deepEqual(ast.size, { w: 4, h: 4 });
assert.deepEqual(ast.atlases, ['atlas_a']);
assert.equal(ast.layers.surface.length, 1);
const d = ast.layers.surface[0];
assert.equal(d.type, 'set');
assert.equal(d.x, 0);
assert.equal(d.y, 0);
assert.equal(d.atlasId, 'atlas_a');
assert.equal(d.tileName, 'grass');
assert.equal(d.rot, 0);
assert.deepEqual(ast.roof, []);
});
test('parseDsl: full map parses id, comments, rotation, multiple layers, roof', () => {
const ast = parseDsl(read('full.txt'));
assert.equal(ast.id, 'full_demo');
assert.deepEqual(ast.size, { w: 4, h: 4 });
assert.deepEqual(ast.atlases, ['atlas_a', 'atlas_b']);
const surface = ast.layers.surface;
assert.equal(surface.length, 2);
assert.equal(surface[0].type, 'fill');
assert.equal(surface[0].x1, 3);
assert.equal(surface[1].type, 'set');
assert.equal(surface[1].rot, 2);
assert.equal(ast.layers.wall.length, 1);
assert.equal(ast.layers.wall[0].rot, 1);
assert.equal(ast.roof.length, 2);
assert.deepEqual(ast.roof[0], { line: 15, x: 1, y: 1, value: 1 });
});
test('parseDsl: missing size before content is an error', () => {
assert.throws(
() => parseDsl('atlas a\nlayer surface\n'),
(err) => err.message.match(/size/) && err.line === 1
);
});
test('parseDsl: unknown layer name is an error with line number', () => {
const src = 'size 4 4\natlas a\nlayer bogus\n';
assert.throws(
() => parseDsl(src),
(err) => err.message.match(/unknown layer 'bogus'/) && err.line === 3
);
});
test('parseDsl: fill with x0 > x1 is an error', () => {
const src = 'size 4 4\natlas a\nlayer surface\n fill 3 0 0 3 a:tile\n';
assert.throws(
() => parseDsl(src),
(err) => err.message.match(/fill.*x0.*x1/) && err.line === 4
);
});
test('parseDsl: rotation out of [0,3] is an error', () => {
const src = 'size 4 4\natlas a\nlayer surface\n set 0 0 a:tile rot 5\n';
assert.throws(
() => parseDsl(src),
(err) => err.message.match(/rotation/) && err.line === 4
);
});
test('parseDsl: rot without value is an error', () => {
const src = 'size 4 4\natlas a\nlayer surface\n set 0 0 a:tile rot\n';
assert.throws(
() => parseDsl(src),
(err) => err.line === 4
);
});
test('parseDsl: set with malformed atlas:tile form is an error', () => {
const src = 'size 4 4\natlas a\nlayer surface\n set 0 0 grass\n';
assert.throws(
() => parseDsl(src),
(err) => err.line === 4
);
});
test('parseDsl: roof set with value other than 0/1 is an error', () => {
const src = 'size 4 4\natlas a\nroof\n set 0 0 2\n';
assert.throws(
() => parseDsl(src),
(err) => err.message.match(/0.*1/) && err.line === 4
);
});
test('parseDsl: comments and blank lines do not contribute line offset', () => {
// The error is on line 5 of the source (1-indexed), even though
// lines 2 and 4 are blank/comment.
const src = 'size 4 4\n\n# a comment\n\natlas\n';
assert.throws(
() => parseDsl(src),
(err) => err.line === 5
);
});
test('parseDsl: directive before its layer/roof block is an error', () => {
const src = 'size 4 4\natlas a\nset 0 0 a:tile\n';
assert.throws(
() => parseDsl(src),
(err) => err.line === 3
);
});
test('parseDsl: second "layer surface" appends to the same layer', () => {
const src = 'size 4 4\natlas a\nlayer surface\n set 0 0 a:t1\nlayer surface\n set 1 1 a:t2\n';
const ast = parseDsl(src);
assert.equal(ast.layers.surface.length, 2);
});

54
tests/encode.test.js Normal file
View File

@@ -0,0 +1,54 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { run } = require('../src/cli');
class Sink {
constructor() { this.chunks = []; }
write(s) { this.chunks.push(s); }
get text() { return this.chunks.join(''); }
}
test('encode: (0,12,1) prints 196 with trailing newline, exit 0', () => {
const out = new Sink(); const err = new Sink();
const code = run(['encode', '0', '12', '1'], { out, err });
assert.equal(code, 0);
assert.equal(out.text, '196\n');
assert.equal(err.text, '');
});
test('encode: rotation defaults to 0', () => {
const out = new Sink(); const err = new Sink();
const code = run(['encode', '0', '12'], { out, err });
assert.equal(code, 0);
assert.equal(out.text, '192\n');
});
test('encode: no args prints usage to stderr, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['encode'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /usage: sporel-mapper encode/);
});
test('encode: too many args prints usage, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['encode', '0', '0', '0', '0'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /usage/);
});
test('encode: out-of-range tile prints error, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['encode', '0', '99999999', '0'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /error: tile/);
});
test('encode: non-integer arg prints error, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['encode', '0', 'abc', '0'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /error/i);
});

View 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] }
]
}

View 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 }
]
}

View 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 }
]
}

View 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 }
]
}

4
tests/fixtures/dsl/error_oob.txt vendored Normal file
View File

@@ -0,0 +1,4 @@
size 4 4
atlas atlas_a
layer surface
set 10 10 atlas_a:grass

16
tests/fixtures/dsl/full.txt vendored Normal file
View File

@@ -0,0 +1,16 @@
# full demo with comments and rotation
id full_demo
size 4 4
atlas atlas_a
atlas atlas_b
layer surface
fill 0 0 3 3 atlas_a:grass
set 1 1 atlas_a:stone rot 2
layer wall
set 0 0 atlas_b:water rot 1
roof
set 1 1 1
set 2 2 0

4
tests/fixtures/dsl/minimal.txt vendored Normal file
View File

@@ -0,0 +1,4 @@
size 4 4
atlas atlas_a
layer surface
set 0 0 atlas_a:grass

View File

@@ -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
]
}

53
tests/inspect.test.js Normal file
View File

@@ -0,0 +1,53 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
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_MAP = path.join(__dirname, 'fixtures', 'maps', 'inspect_sample.map.json');
const FIX_ATLAS = path.join(__dirname, 'fixtures', 'atlases');
test('inspect: prints report to stdout, exit 0', () => {
const out = new Sink(); const err = new Sink();
const code = run(['inspect', FIX_MAP], { out, err });
assert.equal(code, 0, err.text);
assert.match(out.text, /Map: inspect_sample/);
assert.match(out.text, /Roof: 4 cells flagged/);
});
test('inspect: --atlas-dir enables name resolution', () => {
const out = new Sink(); const err = new Sink();
const code = run(['inspect', FIX_MAP, '--atlas-dir', FIX_ATLAS], { out, err });
assert.equal(code, 0);
assert.match(out.text, /atlas_a:grass/);
});
test('inspect: missing positional arg prints usage, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['inspect'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /usage: sporel-mapper inspect/);
});
test('inspect: missing map file prints error, exit 1', () => {
const out = new Sink(); const err = new Sink();
const code = run(['inspect', '/no/such/map.json'], { out, err });
assert.equal(code, 1);
assert.match(err.text, /error: cannot read/);
});
test('inspect: invalid JSON prints error, exit 1', () => {
const out = new Sink(); const err = new Sink();
// Use a non-JSON file as bogus input
const reallyNotJson = path.join(__dirname, '..', 'src', 'cli.js');
const code = run(['inspect', reallyNotJson], { out, err });
assert.equal(code, 1);
assert.match(err.text, /error/i);
});

76
tests/inspector.test.js Normal file
View File

@@ -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+/);
});