181 lines
8.3 KiB
JavaScript
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));
|
||
|
|
}
|