Back to Black Site Map Editor
SOURCE / PINNED RELEASE

Made of little things.

Black Site Map Editor

Release
32a57733b7e9…
Author-recorded commit
3f54890f542f…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor

Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/vendor/validate-map.mjs
// Dependency-free validator for Black Site maps (format "black-site-map", version 1).
// It mirrors spec/schemas/black-site-map.schema.json and adds the checks JSON Schema cannot express:
// rect corner order, everything inside the map, doors on their building's wall.
// Usage: validateMap(map) -> [] when valid, otherwise a list of "path: problem" strings.
// Map editors can copy this file and the schema as the format contract.

export const MAP_FORMAT = 'black-site-map';
export const MAP_VERSION = 1;
export const DEFAULT_MAP_SIZE = [64, 44];

const TOP_KEYS = new Set([
  '$schema', 'format', 'version', 'id', 'name', 'desc', 'size', 'theme', 'generate', 'spawns', 'start', 'exfil',
  'water', 'paved', 'roads', 'walls', 'buildings', 'containers', 'tanks', 'pipes', 'crates', 'wrecks', 'sandbags',
  'enemies', 'caches',
]);
const AREA_LAYERS = ['water', 'paved', 'roads', 'walls', 'pipes', 'crates', 'wrecks', 'sandbags'];
const STYLES = ['grass', 'concrete', 'sand', 'snow'];
const CLUTTER = ['b', 'c', 'T', 'h'];

export function validateMap(map) {
  const errors = [];
  const err = (path, msg) => errors.push(`${path}: ${msg}`);
  if (!isObj(map)) return ['map: must be an object'];

  for (const k of Object.keys(map)) if (!TOP_KEYS.has(k)) err(k, 'unknown field');
  if (map.format !== MAP_FORMAT) err('format', `must be "${MAP_FORMAT}"`);
  if (map.version !== MAP_VERSION) err('version', `must be ${MAP_VERSION}`);
  if (typeof map.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(map.id)) err('id', 'must be 1-32 chars of a-z, 0-9 and -, not starting with -');
  if (typeof map.name !== 'string' || !map.name.length || map.name.length > 32) err('name', 'must be a string of 1-32 chars');
  if (map.desc !== undefined && (typeof map.desc !== 'string' || map.desc.length > 200)) err('desc', 'must be a string of at most 200 chars');

  let [W, H] = DEFAULT_MAP_SIZE;
  if (map.size !== undefined) {
    if (!Array.isArray(map.size) || map.size.length !== 2 || !map.size.every(v => isInt(v, 16, 256))) err('size', 'must be [width, height], each 16-256');
    else [W, H] = map.size;
  }

  // shapes: tiles may sit anywhere on the grid (the outer ring becomes wall); units must stand inside it
  const tile = (path, v, inner = false) => {
    if (!Array.isArray(v) || v.length < 2 || !isInt(v[0], 0, 255) || !isInt(v[1], 0, 255)) return err(path, 'must start with integer x, y'), false;
    const lo = inner ? 1 : 0;
    if (v[0] < lo || v[1] < lo || v[0] > W - 1 - lo || v[1] > H - 1 - lo) return err(path, `[${v[0]}, ${v[1]}] is outside the ${inner ? 'playable area' : 'map'} (${W}x${H})`), false;
    return true;
  };
  const point = (path, v, inner) => {
    if (Array.isArray(v) && v.length !== 2) return err(path, 'must be [x, y]'), false;
    return tile(path, v, inner);
  };
  const rect = (path, v, extra = 0) => {
    if (!Array.isArray(v) || v.length !== 4 + extra || !v.slice(0, 4).every(n => isInt(n, 0, 255))) return err(path, `must be [x0, y0, x1, y1${extra ? ', ...' : ''}]`), false;
    if (v[0] > v[2] || v[1] > v[3]) return err(path, 'corners must be ordered (x0 <= x1, y0 <= y1)'), false;
    return tile(path, v) && tile(path, [v[2], v[3]]);
  };
  const area = (path, v) => (Array.isArray(v) && v.length === 2 ? point(path, v) : rect(path, v));
  const list = (key, fn) => {
    const v = map[key];
    if (v === undefined) return;
    if (!Array.isArray(v)) return err(key, 'must be an array');
    v.forEach((item, i) => fn(`${key}[${i}]`, item));
  };

  // theme
  const th = map.theme;
  if (!isObj(th)) err('theme', 'is required');
  else {
    for (const k of Object.keys(th)) if (!['ground', 'road', 'style', 'markings', 'clutter'].includes(k)) err(`theme.${k}`, 'unknown field');
    for (const k of ['ground', 'road']) if (!Array.isArray(th[k]) || th[k].length !== 3 || !th[k].every(n => isInt(n, 0, 255))) err(`theme.${k}`, 'must be [r, g, b], each 0-255');
    if (!STYLES.includes(th.style)) err('theme.style', `must be one of ${STYLES.join(', ')}`);
    if (th.markings !== undefined && typeof th.markings !== 'boolean') err('theme.markings', 'must be a boolean');
    if (th.clutter !== undefined && (!Array.isArray(th.clutter) || !th.clutter.length || !th.clutter.every(c => CLUTTER.includes(c)))) err('theme.clutter', `must be a non-empty list of ${CLUTTER.join(', ')}`);
  }

  // generation and spawns
  const opts = (key, spec) => {
    const v = map[key];
    if (v === undefined) return;
    if (!isObj(v)) return err(key, 'must be an object');
    for (const k of Object.keys(v)) if (!(k in spec)) err(`${key}.${k}`, 'unknown field');
    for (const [k, check] of Object.entries(spec)) if (v[k] !== undefined && !check[0](v[k])) err(`${key}.${k}`, check[1]);
  };
  opts('generate', {
    expand: [v => typeof v === 'boolean', 'must be a boolean'],
    buildings: [v => isInt(v, 0, 64), 'must be an integer 0-64'],
    cover: [v => isInt(v, 0, 400), 'must be an integer 0-400'],
    seed: [v => isInt(v, 0, 4294967295), 'must be an unsigned 32-bit integer'],
  });
  opts('spawns', {
    extraEnemies: [v => typeof v === 'number' && v >= 0 && v <= 2, 'must be a number 0-2'],
    extraCaches: [v => isInt(v, 0, 20), 'must be an integer 0-20'],
  });

  // objective
  if (map.start === undefined) err('start', 'is required');
  else point('start', map.start, true);
  if (map.exfil === undefined) err('exfil', 'is required');
  else point('exfil', map.exfil, true);

  // layers
  for (const key of AREA_LAYERS) list(key, area);
  list('buildings', (path, b) => {
    if (!isObj(b)) return err(path, 'must be an object');
    for (const k of Object.keys(b)) if (!['r', 'doors', 'walls'].includes(k)) err(`${path}.${k}`, 'unknown field');
    if (!rect(`${path}.r`, b.r)) return;
    const [x0, y0, x1, y1] = b.r;
    if (x1 - x0 < 2 || y1 - y0 < 2) err(`${path}.r`, 'must be at least 3x3');
    if (b.doors !== undefined) {
      if (!Array.isArray(b.doors)) err(`${path}.doors`, 'must be an array');
      else b.doors.forEach((d, i) => {
        const p = `${path}.doors[${i}]`;
        if (!point(p, d)) return;
        const onRing = d[0] >= x0 && d[0] <= x1 && d[1] >= y0 && d[1] <= y1 && (d[0] === x0 || d[0] === x1 || d[1] === y0 || d[1] === y1);
        if (!onRing) err(p, "must be on the building's outer wall");
      });
    }
    if (b.walls !== undefined) {
      if (!Array.isArray(b.walls)) err(`${path}.walls`, 'must be an array');
      else b.walls.forEach((w, i) => area(`${path}.walls[${i}]`, w));
    }
  });
  list('containers', (path, c) => {
    if (rect(path, c, 1) && !(typeof c[4] === 'string' && /^#[0-9a-fA-F]{6}$/.test(c[4]))) err(path, 'colour must be "#rrggbb"');
  });
  list('tanks', (path, t) => { if (point(path, t) && (t[0] + 2 > W - 1 || t[1] + 2 > H - 1)) err(path, 'the 3x3 tank must fit inside the map'); });
  list('enemies', (path, e) => {
    if (!Array.isArray(e) || e.length < 2 || e.length > 3) return err(path, 'must be [x, y] or [x, y, "p"]');
    if (tile(path, e, true) && e.length === 3 && e[2] !== 'p') err(path, 'third value must be "p" (patrol)');
  });
  list('caches', (path, c) => {
    if (!Array.isArray(c) || c.length < 2 || c.length > 3) return err(path, 'must be [x, y] or [x, y, kind]');
    if (tile(path, c, true) && c.length === 3 && !['start', 'weapons'].includes(c[2])) err(path, 'kind must be "start" or "weapons"');
  });

  return errors;
}

function isObj(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
function isInt(v, lo, hi) { return Number.isInteger(v) && v >= lo && v <= hi; }