map/archive/generated-history/STEP15_VALIDATION.mjs
2026-08-08 17:41:30 +09:00

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));