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

181 lines
8.3 KiB
JavaScript

import assert from "node:assert/strict";
import { performance } from "node:perf_hooks";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateMap } from "./mapPipeline.js";
import { generatePatch } from "./mapPatch.js";
import { createWorldMap } from "./worldMap.js";
import { MAP_H } from "./mapUtils.js";
function westExpansion(world, leftReach, oldSideReach, topPad, bottomPad) {
const ox = world.originX;
const oy = world.originY;
return {
kind: "lasso",
polygon: [
{ x: ox - leftReach, y: oy - topPad },
{ x: ox + oldSideReach, y: oy - topPad },
{ x: ox + oldSideReach, y: oy + MAP_H + bottomPad },
{ x: ox - leftReach, y: oy + MAP_H + bottomPad },
],
};
}
function pointId(point) {
if (Number.isFinite(point?.prefectureRegionId)) return Math.floor(point.prefectureRegionId);
if (Number.isFinite(point?.id)) return Math.floor(point.id);
return -1;
}
function pointName(point) {
return point?.name || point?.labelName || point?.prefectureName || point?.prefectureRegionName || point?.regionName || "";
}
function segmentKey(x0, y0, x1, y1) {
const a = `${x0},${y0}`;
const b = `${x1},${y1}`;
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
function vectorSegmentSet(world, segments) {
const out = new Set();
for (const seg of segments || []) {
if (!Array.isArray(seg) || seg.length < 2) continue;
const x0 = Math.round(seg[0][0] + world.originX);
const y0 = Math.round(seg[0][1] + world.originY);
const x1 = Math.round(seg[1][0] + world.originX);
const y1 = Math.round(seg[1][1] + world.originY);
out.add(segmentKey(x0, y0, x1, y1));
}
return out;
}
function expectedBoundarySets(world) {
const sea = world.fields.sea;
const admin = world.fields.adminId;
const pref = world.fields.prefectureRegionId;
const municipal = new Set();
const prefecture = new Set();
for (let y = 0; y < world.height; y++) {
for (let x = 0; x < world.width; x++) {
const i = y * world.width + x;
if (sea?.[i]) continue;
if (x + 1 < world.width) {
const j = i + 1;
if (!sea?.[j]) {
if (pref?.[i] >= 0 && pref?.[j] >= 0 && pref[i] !== pref[j]) prefecture.add(segmentKey(x + 1, y, x + 1, y + 1));
if (admin?.[i] >= 0 && admin?.[j] >= 0 && admin[i] !== admin[j] && pref?.[i] >= 0 && pref[i] === pref[j]) municipal.add(segmentKey(x + 1, y, x + 1, y + 1));
}
}
if (y + 1 < world.height) {
const j = i + world.width;
if (!sea?.[j]) {
if (pref?.[i] >= 0 && pref?.[j] >= 0 && pref[i] !== pref[j]) prefecture.add(segmentKey(x, y + 1, x + 1, y + 1));
if (admin?.[i] >= 0 && admin?.[j] >= 0 && admin[i] !== admin[j] && pref?.[i] >= 0 && pref[i] === pref[j]) municipal.add(segmentKey(x, y + 1, x + 1, y + 1));
}
}
}
}
return { municipal, prefecture };
}
function generatedOuterEdgeEndpoints(world, patch) {
const r = patch.rects.coreRect;
const keys = ["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "expressways", "icAccessRoads", "railways", "branchRailways"];
let count = 0;
for (const key of keys) {
for (const path of world.sourceMap[key] || []) {
if (!path?.patchGenerated || path.length < 2) continue;
for (const tuple of [path[0], path[path.length - 1]]) {
const x = tuple[0] + world.originX;
const y = tuple[1] + world.originY;
// Right is the real old/new seam. Only top, bottom, and outer-left are
// synthetic selection edges and should not attract transport gateways.
const d = Math.min(Math.abs(x - r.x0), Math.abs(y - r.y0), Math.abs(y - (r.y1 - 1)));
if (d <= 4) count++;
}
}
}
return count;
}
function validateScenario({ initialSeed, terrainType, patchSeed, leftReach, oldSideReach, topPad, bottomPad }) {
const initial = generateMap(initialSeed, { terrainType, onProgress() {} });
const oldNames = new Map((initial.prefectureRegions || []).map((p) => [pointId(p), pointName(p)]).filter(([id]) => id >= 0));
const world = createWorldMap(structuredClone(initial));
const selection = westExpansion(world, leftReach, oldSideReach, topPad, bottomPad);
const t0 = performance.now();
const patch = generatePatch(world, selection, {
patchMode: "expansion",
terrainType,
seed: patchSeed,
variant: 0,
});
const seconds = (performance.now() - t0) / 1000;
assert.equal(patch.ok, true);
assert.equal(patch.patchGenerationMode, "expansion-production-fast-natural");
assert(seconds < 12, `expansion took ${seconds.toFixed(2)} s; expected browser-scale completion near 10 s`);
assert.equal(patch.candidateQuality?.policyVersion, "step7-fast-natural-expansion-v1");
assert.equal(patch.candidateQuality?.fastPath, true);
assert(patch.candidateQuality.terrainAttempts.length >= 2 && patch.candidateQuality.terrainAttempts.length <= 3);
assert.equal(patch.candidateQuality.fullAttempts.length, 1);
assert(patch.candidateQuality.terrainFrameScale >= 1.4);
assert.equal(patch.candidateQuality.final?.hardPass, true);
assert.equal(patch.seamDiagnostics.roadPortalsBroken, 0);
assert.equal(patch.seamDiagnostics.railPortalsBroken, 0);
assert.equal(patch.seamDiagnostics.duplicateBoundaryPairs, 0);
const currentNames = new Map((world.sourceMap.prefectureRegions || []).map((p) => [pointId(p), pointName(p)]).filter(([id]) => id >= 0));
for (const [id, name] of oldNames) assert.equal(currentNames.get(id), name, `old prefecture name ${id} changed or disappeared`);
const genericNames = [...currentNames.values()].filter((name) => /^県域\d+$/.test(String(name)));
assert.deepEqual(genericNames, [], "candidate prefecture metadata should prevent generic 県域 fallback names");
const expected = expectedBoundarySets(world);
const actualMunicipal = vectorSegmentSet(world, world.sourceMap.adminBorders);
const actualPrefecture = vectorSegmentSet(world, world.sourceMap.regionalPrefectureBorders);
assert.deepEqual(actualMunicipal, expected.municipal, "municipal vectors must cover all final land IDs, not only prefectureMask");
assert.deepEqual(actualPrefecture, expected.prefecture, "prefecture vectors must cover all final land IDs, including old map areas outside the patch");
for (const key of ["externalRoads", "externalRailways", "externalExpressways", "externalGateways"]) {
assert.equal((world.sourceMap[key] || []).filter((item) => item?.patchGenerated).length, 0, `${key} must not be imported from the synthetic candidate perimeter`);
}
const outerEdgeEndpoints = generatedOuterEdgeEndpoints(world, patch);
assert(outerEdgeEndpoints <= 4, `too many generated transport endpoints follow the synthetic lasso edge: ${outerEdgeEndpoints}`);
return {
terrainType,
seconds: Math.round(seconds * 100) / 100,
terrainAttempts: patch.candidateQuality.terrainAttempts.length,
fullAttempts: patch.candidateQuality.fullAttempts.length,
selectedVariant: patch.candidateQuality.selectedVariant,
finalOwnedLandRatio: patch.candidateQuality.final.ownedLandRatio,
finalLabels: patch.candidateQuality.final.labelCount,
oldPrefectureNamesPreserved: oldNames.size,
genericPrefectureNames: genericNames.length,
municipalBoundarySegments: actualMunicipal.size,
prefectureBoundarySegments: actualPrefecture.size,
outerEdgeTransportEndpoints: outerEdgeEndpoints,
};
}
const scenario = process.argv[2] || "all";
const cases = {
setouchi: { initialSeed: 114514, terrainType: "setouchi_inland_sea", patchSeed: 0x1234abcd, leftReach: 150, oldSideReach: 10, topPad: 20, bottomPad: 20 },
auto: { initialSeed: 24681357, terrainType: "auto", patchSeed: 0x11111111, leftReach: 170, oldSideReach: 5, topPad: 15, bottomPad: 18 },
};
if (scenario !== "all") {
console.log(JSON.stringify(validateScenario(cases[scenario]), null, 2));
} else {
const script = fileURLToPath(import.meta.url);
const runChild = (name) => {
const child = spawnSync(process.execPath, [script, name], { cwd: process.cwd(), encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
if (child.status !== 0) {
process.stderr.write(child.stderr || child.stdout || `${name} failed\n`);
process.exit(child.status || 1);
}
return JSON.parse(child.stdout.trim());
};
console.log(JSON.stringify({ ok: true, policy: "step7-fast-natural-expansion-v1", setouchi: runChild("setouchi"), auto: runChild("auto") }, null, 2));
}