map/tests/r11.5-visible-quality-finalizer.mjs
2026-08-11 21:51:07 +09:00

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