'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 `); 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 `); 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 `); ast.atlases.push(tokens[1]); continue; } if (head === 'layer') { if (tokens.length !== 2) throw new DslError(lineNo, `usage: layer `); 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 <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 : [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 : [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 };