map/tests/r11.8-terrain-topology-tooltip.mjs
2026-08-11 21:51:07 +09:00

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