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.
// 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',
]);
});
