168 lines
9.2 KiB
JavaScript
168 lines
9.2 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
import { performance } from 'node:perf_hooks';
|
|
import { generateMap } from './mapPipeline.js';
|
|
import { createWorldMap } from './worldMap.js';
|
|
import { generatePatch } from './mapPatch.js';
|
|
import { MAP_H, MAP_W, SIZE, indexOf } from './mapUtils.js';
|
|
|
|
function hashObject(value) {
|
|
const hash = createHash('sha256');
|
|
const seen = new WeakSet();
|
|
function visit(v, path = '') {
|
|
if (v == null || typeof v !== 'object') { hash.update(`${path}:${typeof v}:${String(v)}\n`); return; }
|
|
if (ArrayBuffer.isView(v)) {
|
|
hash.update(`${path}:${v.constructor.name}:${v.length}:`);
|
|
hash.update(Buffer.from(v.buffer, v.byteOffset, v.byteLength));
|
|
return;
|
|
}
|
|
if (seen.has(v)) return;
|
|
seen.add(v);
|
|
if (Array.isArray(v)) {
|
|
hash.update(`${path}:array:${v.length}\n`);
|
|
for (let i = 0; i < v.length; i++) visit(v[i], `${path}[${i}]`);
|
|
return;
|
|
}
|
|
const keys = Object.keys(v).sort();
|
|
hash.update(`${path}:object:${keys.join(',')}\n`);
|
|
for (const key of keys) visit(v[key], `${path}.${key}`);
|
|
}
|
|
visit(value);
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
function rasterize(paths) {
|
|
const occ = new Uint8Array(SIZE);
|
|
for (const path of paths || []) for (let k = 1; k < (path?.length || 0); k++) {
|
|
const [x0, y0] = path[k - 1];
|
|
const [x1, y1] = path[k];
|
|
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
|
|
for (let s = 0; s <= steps; s++) {
|
|
const t = s / steps;
|
|
const x = Math.round(x0 + (x1 - x0) * t);
|
|
const y = Math.round(y0 + (y1 - y0) * t);
|
|
if (x >= 0 && y >= 0 && x < MAP_W && y < MAP_H) occ[indexOf(x, y)] = 1;
|
|
}
|
|
}
|
|
return occ;
|
|
}
|
|
|
|
function countCells(occ) { let n = 0; for (const v of occ) n += v ? 1 : 0; return n; }
|
|
function nearPath(paths, point, radius = 0.75) {
|
|
for (const path of paths || []) for (const [x, y] of path || []) {
|
|
if (Math.hypot(x - point.x, y - point.y) <= radius) return true;
|
|
}
|
|
return false;
|
|
}
|
|
function westSelection(world) {
|
|
const ox = world.originX, oy = world.originY;
|
|
return { kind: 'lasso', polygon: [
|
|
{ x: ox - 145, y: oy - 14 }, { x: ox + 8, y: oy - 14 },
|
|
{ x: ox + 8, y: oy + MAP_H + 14 }, { x: ox - 145, y: oy + MAP_H + 14 },
|
|
] };
|
|
}
|
|
|
|
const source = {
|
|
transport: readFileSync(new URL('./mapTransport.js', import.meta.url), 'utf8'),
|
|
features: readFileSync(new URL('./mapFeatures.js', import.meta.url), 'utf8'),
|
|
output: readFileSync(new URL('./mapOutput.js', import.meta.url), 'utf8'),
|
|
names: readFileSync(new URL('./names.js', import.meta.url), 'utf8'),
|
|
admin: readFileSync(new URL('./mapAdminStage.js', import.meta.url), 'utf8'),
|
|
patch: readFileSync(new URL('./mapPatch.js', import.meta.url), 'utf8'),
|
|
utils: readFileSync(new URL('./mapTransportUtils.js', import.meta.url), 'utf8'),
|
|
};
|
|
for (const retired of ['NAME_PARTS', 'oneKanjiAppendFallbackUsed', 'legacyFallbackUsed', 'changedAfterSnap', 'pendingSeedsUsedForLowlandSplit', 'seedLifecycle', 'seedCellRevivalCount', 'pendingSeedCount']) {
|
|
assert.equal(Object.values(source).some((text) => text.includes(retired)), false, `retired compatibility/debug symbol remains: ${retired}`);
|
|
}
|
|
for (const retired of ['nationalPrune', 'skippedSameComponent', 'finalOutputRoadConnectivity', 'all-road-connect:${pass}', 'Cached candidates']) {
|
|
assert.equal(Object.values(source).some((text) => text.includes(retired)), false, `retired transport/cache pattern remains: ${retired}`);
|
|
}
|
|
assert.equal(source.transport.includes('nearestNetworkPoint'), false, 'unused nearestNetworkPoint remains');
|
|
assert.equal(source.utils.includes('occupancyComponentsFromPathGroups'), true, 'shared occupancy component helper missing');
|
|
assert.equal(source.utils.includes('rasterizePathCells'), true, 'shared path rasterization helper missing');
|
|
assert.equal(source.features.includes('debug.networkConnectivity.added > 0'), true, 'minor final dedupe is not conditional');
|
|
const outputStub = source.output.indexOf('const requiredStubsAdded = ensureAdminCenterRoadStubs();');
|
|
const outputEndpoint = source.output.indexOf('const endpointConnectorsAdded = connectNearbyRoadEndpoints();');
|
|
const outputPrune = source.output.indexOf('const prune = pruneIsolatedFinalRoadComponents();');
|
|
assert.ok(outputStub >= 0 && outputEndpoint > outputStub && outputPrune > outputEndpoint, 'output topology mutations must precede final prune');
|
|
assert.equal(source.names.includes('export function generateTemplateName'), false, 'test-only generateTemplateName API remains');
|
|
assert.equal(source.names.includes('export const NAME_PARTS'), false, 'test-only NAME_PARTS API remains');
|
|
assert.equal(source.features.includes('aStarRoutes: 0'), false, 'obsolete constant transport debug remains');
|
|
assert.equal(source.features.includes('fieldCorridorTransport: false'), false, 'obsolete constant transport debug remains');
|
|
assert.equal(source.features.includes('nationalRoadPopulationCoverage: 0'), false, 'obsolete constant transport debug remains');
|
|
|
|
const seeds = [1, 3, 5];
|
|
const transport = [];
|
|
let initial = null;
|
|
for (const seed of seeds) {
|
|
const t0 = performance.now();
|
|
const map = generateMap(seed, { terrainType: 'auto', onProgress() {} });
|
|
if (seed === 1) initial = map;
|
|
const roads = [...(map.minorRoads || []), ...(map.nationalRoads || []), ...(map.externalRoads || []), ...(map.ringRoads || [])];
|
|
const roadCells = countCells(rasterize(roads));
|
|
const centers = map.adminCenters || map.adminCentersRaw || [];
|
|
const covered = centers.filter((center) => nearPath(roads, center)).length;
|
|
const nc = map.transportDebug?.layers?.preAdminRoadFinalization?.networkConnectivity || {};
|
|
const output = map.transportDebug?.layers?.finalOutputRoadTopology || {};
|
|
assert.equal(covered, centers.length, `seed ${seed}: municipal center lost road access`);
|
|
assert.ok(roadCells >= 1200, `seed ${seed}: road coverage collapsed (${roadCells})`);
|
|
assert.ok((nc.attempted || 0) <= 18 * Math.max(1, (nc.rounds || 0)), `seed ${seed}: connectivity candidate loop expanded unexpectedly`);
|
|
assert.ok((nc.failed || 0) <= (nc.attempted || 0), `seed ${seed}: failed attempt accounting invalid`);
|
|
if ((nc.added || 0) === 0) assert.ok((nc.rounds || 0) <= 1, `seed ${seed}: no-success connectivity loop repeated rounds`);
|
|
assert.ok(Number.isFinite(output.components), `seed ${seed}: final output component count missing`);
|
|
transport.push({
|
|
seed,
|
|
seconds: Math.round((performance.now() - t0) / 10) / 100,
|
|
roadCells,
|
|
adminCenters: { total: centers.length, covered },
|
|
connectivity: nc,
|
|
finalOutput: output,
|
|
});
|
|
}
|
|
|
|
const expansionWorld = createWorldMap(structuredClone(initial));
|
|
const expStart = performance.now();
|
|
const expansion = generatePatch(expansionWorld, westSelection(expansionWorld), {
|
|
patchMode: 'expansion', terrainType: 'auto', seed: 0x15151515, variant: 0, maxQualityRetries: 1,
|
|
});
|
|
const expansionSeconds = (performance.now() - expStart) / 1000;
|
|
assert.equal(expansion.ok, true, `expansion failed: ${expansion.code || 'unknown'}`);
|
|
assert.notEqual(expansion.candidateQuality?.hardPass, false, 'committed failed quality candidate');
|
|
assert.equal(expansion.seamDiagnostics?.expansionFootprintEscapedCells || 0, 0, 'footprint write escaped');
|
|
assert.equal(expansion.seamDiagnostics?.roadPortalsUnresolved || 0, 0, 'road portal disconnected');
|
|
assert.equal(expansion.seamDiagnostics?.railPortalsUnresolved || 0, 0, 'rail portal disconnected');
|
|
|
|
const rollbackWorld = createWorldMap(structuredClone(initial));
|
|
const before = {
|
|
fields: hashObject(rollbackWorld.fields),
|
|
sourceMap: hashObject(rollbackWorld.sourceMap),
|
|
generatedRects: structuredClone(rollbackWorld.generatedRects),
|
|
invalidatedRects: structuredClone(rollbackWorld.invalidatedRects),
|
|
serial: rollbackWorld.patchGenerationSerial || 0,
|
|
sourceIdentity: rollbackWorld.sourceMap,
|
|
};
|
|
const rejected = generatePatch(rollbackWorld, { x0: 30, y0: 30, x1: 110, y1: 110 }, {
|
|
patchMode: 'expansion', terrainType: 'oceanic_archipelago', seed: 456, variant: 0, maxQualityRetries: 0,
|
|
});
|
|
assert.equal(rejected.ok, false, 'rollback probe unexpectedly committed');
|
|
assert.equal(rejected.code, 'patch-quality-gate-failed');
|
|
assert.equal(hashObject(rollbackWorld.fields), before.fields, 'field rollback mismatch');
|
|
assert.equal(hashObject(rollbackWorld.sourceMap), before.sourceMap, 'sourceMap rollback mismatch');
|
|
assert.deepEqual(rollbackWorld.generatedRects, before.generatedRects, 'generatedRects rollback mismatch');
|
|
assert.deepEqual(rollbackWorld.invalidatedRects, before.invalidatedRects, 'invalidatedRects rollback mismatch');
|
|
assert.equal(rollbackWorld.patchGenerationSerial || 0, before.serial, 'serial rollback mismatch');
|
|
assert.equal(rollbackWorld.sourceMap, before.sourceIdentity, 'sourceMap identity rollback mismatch');
|
|
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
transport,
|
|
expansion: {
|
|
seconds: Math.round(expansionSeconds * 100) / 100,
|
|
seamStatus: expansion.seamDiagnostics?.status || null,
|
|
footprintEscapedCells: expansion.seamDiagnostics?.expansionFootprintEscapedCells || 0,
|
|
roadPortalsUnresolved: expansion.seamDiagnostics?.roadPortalsUnresolved || 0,
|
|
railPortalsUnresolved: expansion.seamDiagnostics?.railPortalsUnresolved || 0,
|
|
},
|
|
rollback: { code: rejected.code, restored: true, sourceIdentityPreserved: true },
|
|
}, null, 2));
|