Back to Black Site
SOURCE / PINNED RELEASE

Made of little things.

Black Site

Release
666bc7c74ebd…
Author-recorded commit
05e95af79676…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-d2225ddc391

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

tests/maps.test.mjs
// Map format checks: every map validates, builds at the right size and can be finished.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { validateMap } from '../spec/lib/validate-map.mjs';

const root = dirname(dirname(fileURLToPath(import.meta.url)));
const readJson = async (path) => JSON.parse(await readFile(join(root, path), 'utf8'));

const { campaign } = await readJson('src/game/maps/index.json');
const builtIn = await Promise.all(campaign.map((id) => readJson(`src/game/maps/${id}.json`)));
const example = await readJson('spec/examples/maps/outpost.json');
const schema = await readJson('spec/schemas/black-site-map.schema.json');

// data.js is a classic script that expects MAPS in scope (vite.config.ts injects it)
const dataJs = await readFile(join(root, 'src/game/data.js'), 'utf8');
const { buildLevel, BLOCKING_TILES } = new Function('MAPS', `${dataJs}\nreturn { buildLevel, BLOCKING_TILES };`)(builtIn);

function reachable(L, [sx, sy], [gx, gy]) {
  const seen = new Set([`${sx},${sy}`]);
  const queue = [[sx, sy]];
  while (queue.length) {
    const [x, y] = queue.pop();
    if (x === gx && y === gy) return true;
    for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
      const nx = x + dx, ny = y + dy, key = `${nx},${ny}`;
      if (seen.has(key) || !L.grid[ny] || L.grid[ny][nx] === undefined || BLOCKING_TILES.includes(L.grid[ny][nx])) continue;
      seen.add(key);
      queue.push([nx, ny]);
    }
  }
  return false;
}

test('campaign index lists each built-in map once, by its id', () => {
  assert.equal(new Set(campaign).size, campaign.length);
  builtIn.forEach((m, i) => assert.equal(m.id, campaign[i]));
});

for (const map of [...builtIn, example]) {
  test(`${map.id}: validates, builds and can be finished`, () => {
    assert.deepEqual(validateMap(map), []);
    const L = buildLevel(map);
    const [w, h] = map.size || [64, 44];
    const k = map.generate?.expand ? 1.5 : 1;
    assert.equal(L.w, Math.round(w * k));
    assert.equal(L.h, Math.round(h * k));
    assert.equal(L.grid.length, L.h);
    assert.ok(L.grid.every((row) => row.length === L.w));
    assert.ok(reachable(L, L.start, L.exfil), 'exfil is reachable from start');
  });
}

test('a map without generate is built exactly as authored', () => {
  const L = buildLevel(example);
  assert.equal(L.grid[14][10], '=', 'road tile');
  assert.equal(L.grid[4][6], '#', 'building corner');
  assert.equal(L.grid[11][10], ',', 'door');
  assert.equal(L.grid[4][20], 'K', 'container');
  assert.equal(L.grid[9][31], 'o', 'tank');
  assert.deepEqual(L.enemies.length, example.enemies.length, 'no generated guards');
  assert.deepEqual(L.spawns, {});
});

test('validator and schema accept the same top-level fields', () => {
  const unknown = validateMap(Object.fromEntries(Object.keys(schema.properties).map((k) => [k, undefined])))
    .filter((e) => e.endsWith('unknown field'));
  assert.deepEqual(unknown, []);
  assert.ok(validateMap({ ...example, bogus: 1 }).includes('bogus: unknown field'));
  assert.deepEqual(schema.required.filter((k) => !(k in example)), []);
});

test('validator reports readable errors', () => {
  const bad = {
    ...example,
    version: 2,
    start: [0, 5],
    crates: [[12, 3, 10, 3]],
    buildings: [{ r: [6, 4, 15, 11], doors: [[10, 8]] }],
    enemies: [[5, 5, 'x']],
    caches: [[50, 5]],
    theme: { ...example.theme, style: 'lava' },
  };
  assert.deepEqual(validateMap(bad).sort(), [
    'buildings[0].doors[0]: must be on the building\'s outer wall',
    'caches[0]: [50, 5] is outside the playable area (40x30)',
    'crates[0]: corners must be ordered (x0 <= x1, y0 <= y1)',
    'enemies[0]: third value must be "p" (patrol)',
    'start: [0, 5] is outside the playable area (40x30)',
    'theme.style: must be one of grass, concrete, sand, snow',
    'version: must be 1',
  ]);
});