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)); }