Compare commits
10 Commits
2949a96e11
...
ae2885a453
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2885a453 | ||
|
|
eb255480a1 | ||
|
|
530bceaddd | ||
|
|
f516e2acda | ||
|
|
7adcb407fa | ||
|
|
e96431b44f | ||
|
|
a3ff2f5e6e | ||
|
|
cef9c5c9a2 | ||
|
|
7316ebc7dd | ||
|
|
b1743ad19f |
86
README.md
86
README.md
@@ -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
5
bin/mapper.js
Normal 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
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(
|
||||
`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
100
src/builder.js
Normal 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
46
src/cli.js
Normal 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
94
src/commands/build.js
Normal 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
39
src/commands/decode.js
Normal 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
32
src/commands/encode.js
Normal 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
77
src/commands/inspect.js
Normal 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
180
src/dsl-parser.js
Normal 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
77
src/inspector.js
Normal 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 };
|
||||
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);
|
||||
});
|
||||
98
tests/build.test.js
Normal file
98
tests/build.test.js
Normal 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
133
tests/builder.test.js
Normal 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
46
tests/cli.test.js
Normal 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
61
tests/decode.test.js
Normal 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
125
tests/dsl-parser.test.js
Normal 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
54
tests/encode.test.js
Normal 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);
|
||||
});
|
||||
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-inspector/atlas_a/tiles.atlas.json
vendored
Normal file
10
tests/fixtures/atlases-inspector/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 }
|
||||
]
|
||||
}
|
||||
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 }
|
||||
]
|
||||
}
|
||||
4
tests/fixtures/dsl/error_oob.txt
vendored
Normal file
4
tests/fixtures/dsl/error_oob.txt
vendored
Normal 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
16
tests/fixtures/dsl/full.txt
vendored
Normal 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
4
tests/fixtures/dsl/minimal.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
size 4 4
|
||||
atlas atlas_a
|
||||
layer surface
|
||||
set 0 0 atlas_a:grass
|
||||
30
tests/fixtures/maps/inspect_sample.map.json
vendored
Normal file
30
tests/fixtures/maps/inspect_sample.map.json
vendored
Normal 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
53
tests/inspect.test.js
Normal 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
76
tests/inspector.test.js
Normal 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+/);
|
||||
});
|
||||
Reference in New Issue
Block a user