195 lines
12 KiB
JavaScript
195 lines
12 KiB
JavaScript
|
|
import assert from 'node:assert/strict';
|
||
|
|
import fs from 'node:fs';
|
||
|
|
import path from 'node:path';
|
||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
import { Worker } from 'node:worker_threads';
|
||
|
|
|
||
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||
|
|
const ROOT = path.resolve(HERE, '..');
|
||
|
|
|
||
|
|
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}`)); }, 180000);
|
||
|
|
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) {
|
||
|
|
let total = 0;
|
||
|
|
for (const p of paths || []) for (let k = 1; k < (p?.length || 0); k++) total += Math.hypot(p[k][0] - p[k - 1][0], p[k][1] - p[k - 1][1]);
|
||
|
|
return total;
|
||
|
|
}
|
||
|
|
function maxVertexGap(paths) {
|
||
|
|
let max = 0;
|
||
|
|
for (const p of paths || []) for (let k = 1; k < (p?.length || 0); k++) max = Math.max(max, Math.hypot(p[k][0] - p[k - 1][0], p[k][1] - p[k - 1][1]));
|
||
|
|
return max;
|
||
|
|
}
|
||
|
|
function pointPathDistance(point, paths) {
|
||
|
|
let best = Infinity;
|
||
|
|
for (const p of paths || []) for (const q of p || []) best = Math.min(best, Math.hypot(point.x - q[0], point.y - q[1]));
|
||
|
|
return best;
|
||
|
|
}
|
||
|
|
function tangent(p, k) {
|
||
|
|
const a = p[Math.max(0, k - 2)], b = p[Math.min(p.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 longestParallelRun(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 = tangent(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 = tangent(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 nearMissEndpointCount(map, source, target, maxGap) {
|
||
|
|
let nearMiss = 0, checked = 0;
|
||
|
|
for (const p of source || []) {
|
||
|
|
if (!p?.length) continue;
|
||
|
|
for (const raw of [p[0], p[p.length - 1]]) {
|
||
|
|
const point = { x: raw[0], y: raw[1] };
|
||
|
|
if (point.x <= 1 || point.y <= 1 || point.x >= map.width - 2 || point.y >= map.height - 2) continue;
|
||
|
|
checked++;
|
||
|
|
let best = Infinity;
|
||
|
|
for (const q of target || []) {
|
||
|
|
if (!q || q === p) continue;
|
||
|
|
best = Math.min(best, pointPathDistance(point, [q]));
|
||
|
|
}
|
||
|
|
if (best > 0.75 && best <= maxGap) nearMiss++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { nearMiss, checked, rate: nearMiss / Math.max(1, checked) };
|
||
|
|
}
|
||
|
|
function transportTerrainAudit(map, paths) {
|
||
|
|
let seaCells = 0, highElevationCells = 0, extremeSlopeCells = 0, mountainRidgeCells = 0;
|
||
|
|
for (const p of paths || []) for (const raw of p || []) {
|
||
|
|
const x = Math.round(raw[0]), y = Math.round(raw[1]);
|
||
|
|
if (x < 0 || y < 0 || x >= map.width || y >= map.height) { seaCells++; continue; }
|
||
|
|
const i = y * map.width + x;
|
||
|
|
if (map.sea?.[i]) seaCells++;
|
||
|
|
const e = map.elevation?.[i] || 0, s = map.slope?.[i] || 0, r = map.ridgeField?.[i] || 0;
|
||
|
|
if (e >= 0.695) highElevationCells++;
|
||
|
|
if (s >= 0.60) extremeSlopeCells++;
|
||
|
|
if (e >= 0.58 && r >= 0.72) mountainRidgeCells++;
|
||
|
|
}
|
||
|
|
return { seaCells, highElevationCells, extremeSlopeCells, mountainRidgeCells };
|
||
|
|
}
|
||
|
|
function expresswayDeadEnds(map) {
|
||
|
|
const main = map.expressways || [], external = map.externalExpressways || [];
|
||
|
|
const bad = [];
|
||
|
|
for (let i = 0; i < main.length; i++) {
|
||
|
|
const path = main[i]; if (!path?.length) continue;
|
||
|
|
for (const raw of [path[0], path[path.length - 1]]) {
|
||
|
|
const p = { x: raw[0], y: raw[1] };
|
||
|
|
if (p.x <= 2 || p.y <= 2 || p.x >= map.width - 3 || p.y >= map.height - 3) continue;
|
||
|
|
const joined = pointPathDistance(p, [...main.filter((_, j) => j !== i), ...external]) <= 3.5;
|
||
|
|
const ic = (map.interchanges || []).some((q) => Math.hypot(q.x - p.x, q.y - p.y) <= 9.0);
|
||
|
|
const city = (map.modernCities || []).some((q) => (q.population || 0) >= 50000 && Math.hypot(q.x - p.x, q.y - p.y) <= 28);
|
||
|
|
const port = (map.ports || []).some((q) => Math.hypot(q.x - p.x, q.y - p.y) <= 18);
|
||
|
|
if (!(joined || (ic && (city || port)))) bad.push({ x: p.x, y: p.y, joined, ic, city, port });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return bad;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const seed of [1, 2]) {
|
||
|
|
const map = await runWorker(seed);
|
||
|
|
const national = map.nationalRoads || [];
|
||
|
|
const expressway = map.expressways || [];
|
||
|
|
const rail = [...(map.railways || []), ...(map.branchRailways || [])];
|
||
|
|
const local = map.minorRoads || [];
|
||
|
|
const trunk = [...national, ...expressway, ...rail];
|
||
|
|
|
||
|
|
assert(maxVertexGap(trunk) <= Math.SQRT2 + 1e-6, `seed ${seed}: all emitted trunk geometry is raster-contiguous`);
|
||
|
|
const terrain = transportTerrainAudit(map, trunk);
|
||
|
|
assert.equal(terrain.seaCells, 0, `seed ${seed}: no published trunk cell crosses sea`);
|
||
|
|
assert.equal(terrain.highElevationCells, 0, `seed ${seed}: no published trunk cell crosses the hard high-elevation ceiling`);
|
||
|
|
assert.equal(terrain.extremeSlopeCells, 0, `seed ${seed}: no published trunk cell crosses an extreme slope`);
|
||
|
|
assert.equal(terrain.mountainRidgeCells, 0, `seed ${seed}: no published trunk cell traverses a high mountain ridge`);
|
||
|
|
|
||
|
|
assert(longestParallelRun(national, 3) <= 5, `seed ${seed}: national-road kilometre-scale parallelism is bounded`);
|
||
|
|
assert(longestParallelRun(expressway, 4) <= 3, `seed ${seed}: expressway parallel corridors are collapsed`);
|
||
|
|
|
||
|
|
const nationalMiss = nearMissEndpointCount(map, national, [...national, ...(map.externalRoads || [])], 4.6);
|
||
|
|
const expressMiss = nearMissEndpointCount(map, expressway, [...expressway, ...(map.externalExpressways || [])], 5.0);
|
||
|
|
const railMiss = nearMissEndpointCount(map, rail, [...rail, ...(map.externalRailways || [])], 4.5);
|
||
|
|
const localMiss = nearMissEndpointCount(map, local, [...local, ...national, ...(map.externalRoads || [])], 3.6);
|
||
|
|
assert(nationalMiss.nearMiss <= 3, `seed ${seed}: national near-miss endpoints are rare (${nationalMiss.nearMiss})`);
|
||
|
|
assert.equal(expressMiss.nearMiss, 0, `seed ${seed}: expressways have no close-but-unjoined endpoints`);
|
||
|
|
assert.equal(railMiss.nearMiss, 0, `seed ${seed}: railways have no close-but-unjoined endpoints`);
|
||
|
|
assert(localMiss.rate <= 0.015, `seed ${seed}: local close-but-unjoined endpoint rate is ${(localMiss.rate * 100).toFixed(2)}%`);
|
||
|
|
|
||
|
|
const nationalLen = pathLength(national), expressLen = pathLength(expressway), railLen = pathLength(rail);
|
||
|
|
assert(nationalLen > 0 && railLen / nationalLen >= 0.82 && railLen / nationalLen <= 1.02,
|
||
|
|
`seed ${seed}: rail density remains high and slightly below national-road scale (${(railLen / nationalLen).toFixed(3)})`);
|
||
|
|
const icCount = (map.interchanges || []).length;
|
||
|
|
assert(icCount <= Math.ceil(expressLen / 12) + 2, `seed ${seed}: IC count is not over-dense (${icCount} for ${expressLen.toFixed(1)} cells)`);
|
||
|
|
assert(icCount >= Math.max(1, Math.floor(expressLen / 30)), `seed ${seed}: retained expressway still has usable IC coverage`);
|
||
|
|
assert.equal(expresswayDeadEnds(map).length, 0, `seed ${seed}: internal expressway endpoints are connected or terminate at a city/port IC`);
|
||
|
|
|
||
|
|
const ordinary = [...local, ...national, ...(map.externalRoads || [])];
|
||
|
|
const villages = map.villages || [];
|
||
|
|
const ruralServed = villages.filter((v) => pointPathDistance(v, ordinary) <= 4).length;
|
||
|
|
assert(!villages.length || ruralServed / villages.length >= 0.80, `seed ${seed}: rural-road village coverage is ${(ruralServed / Math.max(1, villages.length)).toFixed(3)}`);
|
||
|
|
assert(local.length >= 70, `seed ${seed}: countryside retains a substantial organic local-road network (${local.length})`);
|
||
|
|
|
||
|
|
const post = map.transportDebug?.postAdminTransportFinalization || {};
|
||
|
|
assert.equal(post.visibleCropMajorCityService?.missing?.length || 0, 0, `seed ${seed}: visible major-city trunk service has no unresolved city`);
|
||
|
|
assert.equal(post.postDedupeMajorCityService?.missing?.length || 0, 0, `seed ${seed}: final major-city national/rail/expressway contract passes`);
|
||
|
|
assert(post.absoluteFinalTerrainInvariant, `seed ${seed}: absolute final terrain invariant executed`);
|
||
|
|
const interchangeAudit = post.absoluteFinalInterchangeRebuild;
|
||
|
|
assert(interchangeAudit, `seed ${seed}: final IC rebuild is audited`);
|
||
|
|
assert(interchangeAudit.added >= icCount, `seed ${seed}: visible ICs are retained from the rebuilt pre-crop IC set`);
|
||
|
|
assert.equal(interchangeAudit.legacyUniformTarget, Math.max(0, Math.round(interchangeAudit.expresswayLength / 19.5)), `seed ${seed}: IC density reference is derived from the audited expressway length`);
|
||
|
|
assert(interchangeAudit.target >= interchangeAudit.added, `seed ${seed}: IC target accounts for every published IC`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const featureSource = fs.readFileSync(path.join(ROOT, 'src/mapFeatures.js'), 'utf8');
|
||
|
|
const postSource = fs.readFileSync(path.join(ROOT, 'src/mapPostAdminTransport.js'), 'utf8');
|
||
|
|
const cropSource = fs.readFileSync(path.join(ROOT, 'src/initialGenerationCrop.js'), 'utf8');
|
||
|
|
const rendererSource = fs.readFileSync(path.join(ROOT, 'src/renderer.js'), 'utf8');
|
||
|
|
const appSource = fs.readFileSync(path.join(ROOT, 'src/app.js'), 'utf8');
|
||
|
|
const cssSource = fs.readFileSync(path.join(ROOT, 'styles/styles.css'), 'utf8');
|
||
|
|
|
||
|
|
assert(!postSource.includes('function directPath(') && !postSource.includes('function directLandConnector('), 'no direct trunk fallback exists');
|
||
|
|
assert(featureSource.includes('const trunkMode = mode === "expressway" || mode === "national" || mode === "rail"'), 'all production trunk route call-sites share one terrain-first policy clamp');
|
||
|
|
assert(featureSource.includes('heuristicWeight: trunkMode') && featureSource.includes('Math.min(options.heuristicWeight ?? 0.08'), 'late trunk callers cannot restore a dominant Euclidean heuristic');
|
||
|
|
assert(featureSource.includes('literal endpoint chord') && postSource.includes('geometric chord'), 'subtle near-straight terrain-blind corridors are explicitly audited, not just coordinate jumps');
|
||
|
|
assert(postSource.includes('if (direct >= 18 && chordHardShare >= 0.08 && straightness > 0.90) return false'), 'final production terrain validator rejects near-chord trunks when the chord crosses hostile terrain');
|
||
|
|
assert(postSource.includes('runs.maxSeaRun > 0'), 'shared trunk alignment cannot reintroduce a sea crossing');
|
||
|
|
assert(cropSource.includes('let alreadyConnected = false') && cropSource.includes('passes: 2'), 'visible-crop road welding distinguishes exact junctions from near misses and performs a second bounded pass');
|
||
|
|
assert(postSource.includes('if (!hit || hit.connected) continue'), 'post-admin topology repair does not keep extending already-connected endpoints');
|
||
|
|
assert(postSource.includes('if (total < 8)') && postSource.includes('final-terminal-ic'), 'retained short expressway spurs still receive a real terminal IC rather than ending mid-road');
|
||
|
|
|
||
|
|
const municipalDraw = rendererSource.indexOf('drawLabels(ctx, municipalFallbackLabels, Infinity, occupiedLabels)');
|
||
|
|
const prefectureDraw = rendererSource.indexOf('drawLabels(ctx, prefectureLabels, Infinity, occupiedLabels)', municipalDraw + 1);
|
||
|
|
assert(municipalDraw >= 0 && prefectureDraw > municipalDraw, 'prefecture labels render above municipality labels in the general map pass');
|
||
|
|
assert(rendererSource.includes('drawLabels(ctx, prefectureLabels, Infinity, adminOccupied)'), 'prefecture labels also render last in administrative mode');
|
||
|
|
assert(rendererSource.includes('land fill and coastline share the exact same binary sea mask'), 'coastline and land fill retain their shared binary sea-mask contract');
|
||
|
|
|
||
|
|
assert(cssSource.includes('background:rgba(17,22,31,.84)'), 'tooltip is slightly translucent');
|
||
|
|
assert(appSource.includes('cursorY + 10') && appSource.includes('maxTop'), 'tooltip follows the cursor to the bottom before clamping');
|
||
|
|
assert(appSource.includes('const exclusion = 12') && appSource.includes('leftAlt') && appSource.includes('rightAlt'), 'tooltip reserves a cursor exclusion zone and flips horizontally at edges');
|
||
|
|
|
||
|
|
console.log('All r11.8 terrain/topology/tooltip regression checks passed.');
|