150 lines
8.6 KiB
JavaScript
150 lines
8.6 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { Worker } from 'node:worker_threads';
|
|
|
|
function runWorker(seed) {
|
|
return new Promise((resolve, reject) => {
|
|
const worker = new Worker(new URL('./helpers-generation-worker-node-wrapper.mjs', import.meta.url), { type: 'module' });
|
|
const timer = setTimeout(() => { worker.terminate(); reject(new Error(`worker timeout seed ${seed}`)); }, 120000);
|
|
worker.on('error', (error) => { clearTimeout(timer); reject(error); });
|
|
worker.on('message', async (message) => {
|
|
if (message?.type !== 'result' || message.id !== seed) return;
|
|
clearTimeout(timer);
|
|
worker.removeAllListeners();
|
|
await worker.terminate();
|
|
if (!message.ok) reject(new Error(message.error || 'generation failed'));
|
|
else resolve(message.map);
|
|
});
|
|
worker.postMessage({ id: seed, seed, options: { terrainType: 'auto' } });
|
|
});
|
|
}
|
|
function pathLength(paths) {
|
|
return (paths || []).reduce((sum, path) => {
|
|
let length = 0;
|
|
for (let i = 1; i < (path?.length || 0); i++) length += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
|
|
return sum + length;
|
|
}, 0);
|
|
}
|
|
function prefCounts(map) {
|
|
const groups = new Map();
|
|
for (let i = 0; i < map.adminId.length; i++) {
|
|
if (map.sea[i]) continue;
|
|
const a = Number(map.adminId[i]), p = Number(map.prefectureRegionId[i]);
|
|
if (a < 0 || p < 0) continue;
|
|
if (!groups.has(p)) groups.set(p, new Set());
|
|
groups.get(p).add(a);
|
|
}
|
|
return [...groups.values()].map((s) => s.size);
|
|
}
|
|
function pathTangent(path, k) {
|
|
const a = path[Math.max(0, k - 2)], b = path[Math.min(path.length - 1, k + 2)];
|
|
const dx = b[0] - a[0], dy = b[1] - a[1], d = Math.hypot(dx, dy) || 1;
|
|
return [dx / d, dy / d];
|
|
}
|
|
function parallelRun(paths, radius) {
|
|
let worst = 0;
|
|
for (let a = 0; a < (paths?.length || 0); a++) for (let b = a + 1; b < paths.length; b++) {
|
|
let run = 0;
|
|
for (let k = 0; k < paths[a].length; k += 2) {
|
|
const p = paths[a][k], t = pathTangent(paths[a], k); let parallel = false;
|
|
for (let q = 0; q < paths[b].length; q += 2) {
|
|
const z = paths[b][q], dx = p[0] - z[0], dy = p[1] - z[1], d2 = dx * dx + dy * dy;
|
|
if (d2 < 0.75 || d2 > radius * radius) continue;
|
|
const u = pathTangent(paths[b], q);
|
|
if (Math.abs(t[0] * u[0] + t[1] * u[1]) >= 0.90) { parallel = true; break; }
|
|
}
|
|
run = parallel ? run + 1 : 0; worst = Math.max(worst, run);
|
|
}
|
|
}
|
|
return worst;
|
|
}
|
|
function extremeRun(path) {
|
|
let run = 0, best = 0;
|
|
for (let k = 2; k < (path?.length || 0) - 2; k += 2) {
|
|
const a = path[k - 2], b = path[k], c = path[k + 2];
|
|
const ux = b[0] - a[0], uy = b[1] - a[1], vx = c[0] - b[0], vy = c[1] - b[1];
|
|
const ud = Math.hypot(ux, uy), vd = Math.hypot(vx, vy);
|
|
if (!ud || !vd) { run = 0; continue; }
|
|
const dot = Math.max(-1, Math.min(1, (ux * vx + uy * vy) / (ud * vd)));
|
|
const angle = Math.acos(dot) * 180 / Math.PI;
|
|
run = angle >= 82 ? run + 1 : 0; best = Math.max(best, run);
|
|
}
|
|
return best;
|
|
}
|
|
function edgeHits(paths, width, height) {
|
|
return (paths || []).filter((path) => (path || []).some(([x, y]) => x <= 0 || y <= 0 || x >= width - 1 || y >= height - 1)).length;
|
|
}
|
|
function pointPathDistance(point, path) {
|
|
let best = Infinity;
|
|
for (const tuple of path || []) best = Math.min(best, Math.hypot(tuple[0] - point.x, tuple[1] - point.y));
|
|
return best;
|
|
}
|
|
function clearInteriorMinorOrphans(map) {
|
|
const minor = map.minorRoads || [];
|
|
const trunks = [...(map.nationalRoads || []), ...(map.externalRoads || []), ...(map.expressways || []), ...(map.railways || []), ...(map.branchRailways || [])];
|
|
const civic = [...(map.modernCities || []), ...(map.markets || []), ...(map.villages || []), ...(map.ports || []), ...(map.adminCenters || []).filter(Boolean)];
|
|
const connected = (tuple, self) => {
|
|
const pt = { x: tuple[0], y: tuple[1] };
|
|
if (trunks.some((path) => pointPathDistance(pt, path) <= 2.5)) return true;
|
|
return minor.some((path, index) => index !== self && pointPathDistance(pt, path) <= 2.2);
|
|
};
|
|
const out = [];
|
|
for (let index = 0; index < minor.length; index++) {
|
|
const path = minor[index];
|
|
if (!path?.length) continue;
|
|
let length = 0;
|
|
for (let k = 1; k < path.length; k++) length += Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]);
|
|
if (length >= 24) continue;
|
|
if (path.some(([x, y]) => x <= 0.5 || y <= 0.5 || x >= map.width - 1.5 || y >= map.height - 1.5)) continue;
|
|
if (civic.some((point) => pointPathDistance(point, path) <= 2.6)) continue;
|
|
const a = path[0], b = path[path.length - 1];
|
|
if (!connected(a, index) && !connected(b, index)) out.push({ index, length });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
for (const seed of [1, 2, 5]) {
|
|
console.log('r11.5 audit seed', seed);
|
|
const map = await runWorker(seed);
|
|
console.log('r11.5 generated seed', seed);
|
|
const post = map.transportDebug?.postAdminTransportFinalization || {};
|
|
const visible = post.visibleCropMajorCityService;
|
|
const hidden = post.postDedupeMajorCityService;
|
|
const visibleFinalizer = post.initialVisibleCropFinalizer;
|
|
|
|
assert.equal(map.initialGenerationOverscan?.version, 'literal-hidden-raster-center-crop-v1', `seed ${seed}: literal production overscan`);
|
|
assert(visibleFinalizer?.productionOnly && visibleFinalizer?.simplifiedOutputForbidden, `seed ${seed}: visible finalizer uses full production output only`);
|
|
assert(post.visibleCropOrphanMinorRoadCleanup?.enabled === true, `seed ${seed}: crop-induced minor-road orphan cleanup ran on published raster`);
|
|
assert.equal(clearInteriorMinorOrphans(map).length, 0, `seed ${seed}: no short crop-created interior minor road is disconnected at both ends without serving a settlement`);
|
|
assert.equal(visible?.missing?.length || 0, 0, `seed ${seed}: every publishable major city gets all required trunk modes`);
|
|
assert.equal(visible?.edgeTruncated?.length || 0, 0, `seed ${seed}: crop-edge is no longer an excuse for missing major-city trunk service`);
|
|
for (const ex of visible?.geographicExceptions || []) {
|
|
assert(ex.visibleLandComponentArea < 96 && ex.national && ex.rail && !ex.expressway, `seed ${seed}: only tiny-islet motorway omission is allowed`);
|
|
}
|
|
assert.equal(hidden?.missing?.length || 0, 0, `seed ${seed}: hidden production audit has no unresolved major-city service failure`);
|
|
|
|
const counts = prefCounts(map);
|
|
assert(counts.length >= 2 && Math.min(...counts) >= 10, `seed ${seed}: every published prefecture has at least ten municipalities`);
|
|
assert(map.regionalDebug?.visibleCropPrefectureRepair?.minimumVisibleMunicipalities >= 10, `seed ${seed}: post-crop prefecture repair is active`);
|
|
|
|
const nationalLen = pathLength(map.nationalRoads);
|
|
const railLen = pathLength([...(map.railways || []), ...(map.branchRailways || [])]);
|
|
const ratio = nationalLen > 0 ? railLen / nationalLen : 0;
|
|
assert(ratio >= 0.78 && ratio <= 1.00, `seed ${seed}: railway density (${ratio.toFixed(3)}) is high but remains below national-road density`);
|
|
assert(parallelRun(map.nationalRoads || [], 3) <= 5, `seed ${seed}: no long ~1km-class national-road parallel corridor`);
|
|
assert(parallelRun(map.expressways || [], 4) <= 5, `seed ${seed}: no long ~1km-class expressway parallel corridor`);
|
|
assert(Math.max(0, ...(map.expressways || []).map(extremeRun)) <= 1, `seed ${seed}: expressway avoids repeated extreme bends`);
|
|
|
|
const trunkEdgeHits = edgeHits([...(map.nationalRoads || []), ...(map.expressways || []), ...(map.railways || []), ...(map.branchRailways || [])], map.width, map.height);
|
|
assert(trunkEdgeHits >= 2, `seed ${seed}: hidden OD context produces real trunk continuations across published boundary`);
|
|
}
|
|
|
|
const cropSource = fs.readFileSync(new URL('../src/initialGenerationCrop.js', import.meta.url), 'utf8');
|
|
const postSource = fs.readFileSync(new URL('../src/mapPostAdminTransport.js', import.meta.url), 'utf8');
|
|
const rendererSource = fs.readFileSync(new URL('../src/renderer.js', import.meta.url), 'utf8');
|
|
assert(cropSource.includes('rebalanceVisiblePrefectureMunicipalityFloor(out, 10)'), 'visible prefecture floor is applied after exact crop');
|
|
assert(cropSource.includes('pruneVisibleCropOrphanMinorRoads(out)'), 'minor-road orphan cleanup is re-run after exact crop');
|
|
assert(postSource.includes('ensureVisibleCropMajorCityInternalService') && postSource.includes('densifyNationalToRailRatio'), 'visible-core transport quality finalizers exist');
|
|
assert(rendererSource.includes('land fill and coastline share the exact same binary sea mask') && rendererSource.includes('color = centerWater ? waterColor : landColor'), 'coastline and land/water fill use the same binary sea mask');
|
|
|
|
console.log('All r11.5 visible-quality finalizer regression checks passed.');
|