map/test.js
2026-05-21 02:55:58 +09:00

314 lines
19 KiB
JavaScript

import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js";
import {
CUSTOM_NAMES,
FORCED_NAMES,
NAME_KANJI_POOLS,
NAME_PARTS,
NAME_PROBABILITIES,
NAME_TEMPLATES,
NAME_TEMPLATE_WEIGHTS,
generateTemplateName,
} from "./names.js";
import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js";
const result = document.getElementById("result");
const logLines = [];
let failed = 0;
const [namesSource, mapGeneratorSource, testSource] = await Promise.all([
fetch("./names.js").then((response) => response.text()),
fetch("./mapGenerator.js").then((response) => response.text()),
fetch("./test.js").then((response) => response.text()),
]);
function assert(condition, message) {
if (condition) logLines.push(`OK: ${message}`);
else {
failed += 1;
logLines.push(`NG: ${message}`);
}
}
try {
const map = generateMap(12345);
const other = generateMap(54321);
const size = MAP_W * MAP_H;
const urbanCellCount = [...map.landuse].filter((value) => value >= 2 && value <= 8).length;
const cityPopulations = map.modernCities.map((city) => city.population || 0);
const maxPopulation = Math.max(...cityPopulations);
const minPopulation = Math.min(...cityPopulations);
const landElevations = [...map.elevation].filter((_, i) => !map.sea[i]);
const meanElevation = landElevations.reduce((sum, value) => sum + value, 0) / landElevations.length;
const elevationStdDev = Math.sqrt(landElevations.reduce((sum, value) => sum + (value - meanElevation) ** 2, 0) / landElevations.length);
const modernPaths = [
...map.railways,
...map.branchRailways,
...map.externalRailways,
...(map.ringRailways || []),
...map.nationalRoads,
...(map.ringRoads || []),
...map.expressways,
...(map.ringExpressways || []),
...map.externalRoads,
...map.externalExpressways,
];
const endpointDegree = new Map();
for (const path of modernPaths) {
if (path.length < 2) continue;
for (const point of [path[0], path[path.length - 1]]) {
const key = point.join(",");
endpointDegree.set(key, (endpointDegree.get(key) || 0) + 1);
}
}
const maxModernEndpointDegree = Math.max(0, ...endpointDegree.values());
let maxCoastalElevationStep = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (map.sea[i]) continue;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const ni = indexOf(x + dx, y + dy);
if (map.sea[ni]) maxCoastalElevationStep = Math.max(maxCoastalElevationStep, Math.abs(map.elevation[i] - map.elevation[ni]));
}
}
}
let railExpressHighMountainCells = 0;
for (const path of [...map.railways, ...map.branchRailways, ...(map.ringRailways || []), ...map.externalRailways, ...map.expressways, ...(map.ringExpressways || []), ...map.externalExpressways]) {
for (const [x, y] of path) {
const i = indexOf(x, y);
if (map.elevation[i] > 0.82) railExpressHighMountainCells += 1;
}
}
let trunkHighElevationCells = 0;
for (const path of modernPaths) {
for (const [x, y] of path) {
if (map.elevation[indexOf(x, y)] > 0.72) trunkHighElevationCells += 1;
}
}
const flatPlainCells = [...map.plain].filter((value, i) => !map.sea[i] && value > 0.72 && map.slope[i] < 0.12).length;
const largeMountainCities = map.modernCities.filter((city) => {
const i = indexOf(city.x, city.y);
return (city.population || 0) >= 250000 && (map.elevation[i] > 0.66 || map.plain[i] < 0.18 || map.slope[i] > 0.88);
});
const capitalInside = map.prefecturalCapital && map.prefectureMask[indexOf(map.prefecturalCapital.x, map.prefecturalCapital.y)];
const allNameable = map.entitiesForNames || [];
const uniqueNames = new Set(allNameable.map((item) => item.name));
const duplicateNameRatio = allNameable.length ? 1 - uniqueNames.size / allNameable.length : 0;
const activePoolChars = new Set(Object.values(NAME_KANJI_POOLS).flat().flatMap((part) => Array.from(String(part))));
const namedEntityCount = [
...map.villages,
...map.ports,
...map.crossings,
...map.passes,
...map.markets,
...map.castles,
...map.castleTowns,
...map.modernCities,
...map.stations,
...map.industrialZones,
...map.interchanges,
...map.logisticsParks,
...map.satelliteCities,
...map.newTowns,
...map.castleRuins,
...map.externalGateways,
...map.adminCenters,
].filter((item) => item?.id && item?.name).length;
const villageClusterMean = map.villages.length
? map.villages.reduce((sum, p) => sum + (map.settlementCluster?.[indexOf(p.x, p.y)] || 0), 0) / map.villages.length
: 0;
const meaningfulTransportNodes = [
...map.modernCities,
...map.ports,
...map.markets,
...map.externalGateways,
...map.interchanges,
...map.industrialZones,
...map.logisticsParks,
];
const endpointPaths = [
...map.railways,
...map.branchRailways,
...map.externalRailways,
...map.nationalRoads,
...map.expressways,
...map.externalRoads,
...map.externalExpressways,
...(map.icAccessRoads || []),
];
const endpointDistances = endpointPaths.flatMap((path) => path.length >= 2 ? [path[0], path[path.length - 1]] : [])
.map(([x, y]) => Math.min(...meaningfulTransportNodes.map((p) => Math.hypot(p.x - x, p.y - y))));
const saneEndpointRatio = endpointDistances.length
? endpointDistances.filter((d) => d <= 10).length / endpointDistances.length
: 1;
const adminMetrics = adminBoundaryMetrics(map);
const cityCoreIntegrity = majorCityCoreIntegrity(map);
assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists");
assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists");
assert(NAME_TEMPLATE_WEIGHTS && NAME_TEMPLATE_WEIGHTS.generic?.modifierTerrain > 0, "NAME_TEMPLATE_WEIGHTS exists");
assert(NAME_PROBABILITIES && NAME_PROBABILITIES.contextCategoryWeights?.generic, "NAME_PROBABILITIES exists");
const removedContextModule = "placeName" + "Context.js";
assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent");
assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays");
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty");
assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates");
const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES";
const removedContextSuffixKey = "context" + "Suffixes";
assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent");
assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source");
assert(map.elevation.length === size, "elevation length matches map size");
assert(map.sea.length === size, "sea length matches map size");
assert(map.river.length === size, "river length matches map size");
assert(map.landuse.length === size, "land-use length matches map size");
assert(map.adminId.length === size, "municipal id length matches map size");
assert(map.prefectureMask.length === size, "prefecture mask length matches map size");
assert(map.populationDensity.length === size, "population density length matches map size");
assert(map.ridgeField.length === size && map.valleyField.length === size && map.flowAccum.length === size, "causal terrain fields match map size");
assert(map.erosionField.length === size && map.depositionField.length === size, "erosion and deposition fields match map size");
assert(map.settlementCluster.length === size, "settlement cluster field matches map size");
assert(Array.isArray(map.bridges) && Array.isArray(map.tunnels) && Array.isArray(map.harborWorks), "legacy bridge/tunnel arrays and harbor arrays exist");
assert(map.prefectureRegionId.length === size && Array.isArray(map.regionalPrefectureBorders), "neighbor prefecture regions exist");
assert(Array.isArray(map.tributaryRivers) && Array.isArray(map.smallStreams), "river hierarchy arrays exist");
assert(Array.isArray(map.icAccessRoads), "IC access road array exists");
assert(Array.isArray(map.satelliteCities), "satelliteCities is an array");
assert(Array.isArray(map.ringRoads) && Array.isArray(map.ringExpressways) && Array.isArray(map.ringRailways), "ring transport arrays exist");
assert(Array.isArray(map.mainRivers), "mainRivers is an array");
assert(Array.isArray(map.minorRoads), "minorRoads is an array");
assert(Array.isArray(map.externalGateways), "externalGateways is an array");
let prefectureComponents = 0;
const seenPrefecture = new Uint8Array(size);
for (let i = 0; i < size; i++) {
if (!map.prefectureMask[i] || seenPrefecture[i]) continue;
prefectureComponents++;
const queue = [i];
seenPrefecture[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue;
const ni = ny * MAP_W + nx;
if (!map.prefectureMask[ni] || seenPrefecture[ni]) continue;
seenPrefecture[ni] = 1;
queue.push(ni);
}
}
}
}
assert(prefectureComponents === 1, "prefecture area is a single connected component");
assert(map.prefectureBorder.length > 0, "prefecture border exists");
assert(map.adminBorders.length > 0, "municipal borders exist");
assert(map.mainRivers.length > 0, "at least one major river exists");
assert(map.tributaryRivers.length > 0, "tributary river network exists");
assert(map.smallStreams.length > 0, "small stream network exists");
assert(map.harborWorks.length <= map.ports.length, "harbor works are attached to ports");
assert(map.ports.some((p) => p.portClass === "major"), "at least one major port is classified");
assert(map.ports.every((p) => ["major", "regional", "fishing", "lake"].includes(p.portClass)), "ports have explicit classes");
assert(map.externalGateways.length > 0, "external gateways exist");
assert(map.minorRoads.length > 0, "minor roads exist");
assert(map.adminCenters.length >= 12, "municipality count is sufficiently large");
assert(adminMetrics.municipalityCount >= 10, "terrain snapping preserves a reasonable municipality count");
assert(adminMetrics.centerValidRatio >= 0.95, "municipality centers remain on valid assigned land cells");
assert(adminMetrics.maxComponents <= 4, "municipal topology repair prevents excessive disconnected fragments");
assert(adminMetrics.disconnectedMunicipalities <= Math.max(2, Math.ceil(adminMetrics.municipalityCount * 0.20)), "most municipalities remain connected after terrain snapping");
assert(adminMetrics.avgTarget > 0.18, "admin borders align with terrain target features often enough");
assert(adminMetrics.denseUrbanRate < 0.42, "admin borders avoid excessive dense urban crossings");
assert(adminMetrics.rightAngleRate < 0.46, "admin borders avoid excessive unsupported stair-step artifacts");
assert(adminMetrics.voronoiLikeRate < 0.58, "admin borders are not dominated by weak-terrain center bisectors");
assert(adminMetrics.lowScoreFlatRate < 0.40, "admin borders avoid excessive low-score flat-plain cuts");
assert(adminMetrics.areaDiversity > 1.45, "municipality areas retain natural size diversity");
assert(cityCoreIntegrity >= 0.62, "major city cores remain mostly inside one municipality");
assert(urbanCellCount > 1000, "large-city urbanized cells are broad enough");
const cbdCells = [...map.landuse].filter((value) => value === 3).length;
assert(cbdCells > 0, "CBD is represented as land-use cells rather than markers");
assert(map.modernCities.every((city) => Number.isFinite(city.population) && city.population > 0), "modern cities have population properties");
assert(maxPopulation / Math.max(1, minPopulation) > 3, "city populations vary strongly");
assert(map.totalPopulation >= cityPopulations.reduce((sum, value) => sum + value, 0), "total population includes city and satellite populations");
assert(Math.max(...map.populationDensity) > 0.9, "population density is normalized and populated");
assert(elevationStdDev > 0.18, "terrain relief has sufficient contrast");
assert(maxCoastalElevationStep < 0.12, "coastline and elevation do not create cliff artifacts");
assert(railExpressHighMountainCells === 0, "railways and expressways avoid huge mountain cells");
assert(trunkHighElevationCells === 0, "trunk roads, railways, and expressways avoid high-elevation cells");
assert(flatPlainCells >= 160, "broad flat plains exist as actual low-slope cells");
assert(largeMountainCities.length === 0, "large cities are not placed on unsuitable mountain sites");
assert(capitalInside, "prefectural capital is inside the prefecture");
assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized");
assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names");
assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings");
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented");
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug");
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
assert(
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
"nameDebug accounting covers named entities"
);
assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates");
assert(activePoolChars.size === 0, "no active pool characters exist until configured");
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
assert(
map.adminCenters.length !== other.adminCenters.length ||
map.villages.length !== other.villages.length ||
map.markets.length !== other.markets.length,
"feature counts vary between seeds"
);
const againA = generateMap(999);
const againB = generateMap(999);
assert(JSON.stringify(againA.modernCities) === JSON.stringify(againB.modernCities), "generation is deterministic for the same seed");
assert(JSON.stringify(againA.entitiesForNames.map((item) => [item.id, item.name])) === JSON.stringify(againB.entitiesForNames.map((item) => [item.id, item.name])), "generated names are deterministic for the same seed");
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
CUSTOM_NAMES["city-0"] = "C1";
const customSameA = generateMap(321);
const customSameB = generateMap(321);
const sameTargetA = customSameA.modernCities.find((item) => item.id === "city-0");
const sameTargetB = customSameB.modernCities.find((item) => item.id === "city-0");
const customSeedMaps = [301, 302, 303, 304, 305, 306, 307, 308].map((seedValue) => generateMap(seedValue));
const customTargets = customSeedMaps.map((seeded) => seeded.modernCities.find((item) => item.id === "city-0")).filter(Boolean);
const customHits = customTargets.filter((item) => item.name === "C1").length;
assert(sameTargetA?.name === sameTargetB?.name, "custom-name probability is deterministic for the same seed");
assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed");
delete CUSTOM_NAMES["city-0"];
for (const seed of [101, 2026, 54321]) {
const seeded = generateMap(seed);
const metrics = adminBoundaryMetrics(seeded);
const invalidLandCells = [...seeded.adminId].filter((id, i) => seeded.prefectureMask[i] && !seeded.sea[i] && id < 0).length;
assert(invalidLandCells === 0, `seed ${seed}: every prefecture land cell has a valid adminId`);
assert(seeded.adminBorders.length > 0, `seed ${seed}: municipal borders exist`);
assert(metrics.municipalityCount >= 8, `seed ${seed}: municipality count remains reasonable`);
assert(metrics.centerValidRatio >= 0.90, `seed ${seed}: municipality centers remain valid`);
assert(metrics.maxComponents <= 5, `seed ${seed}: topology repair limits disconnected fragments`);
assert(metrics.avgTarget > 0.14, `seed ${seed}: borders retain terrain-boundary affinity`);
assert(metrics.denseUrbanRate < 0.50, `seed ${seed}: borders avoid excessive dense urban cuts`);
assert(metrics.voronoiLikeRate < 0.66, `seed ${seed}: weak-terrain Voronoi-like border ratio stays bounded`);
assert(metrics.lowScoreFlatRate < 0.50, `seed ${seed}: low-score flat border ratio stays bounded`);
assert(metrics.areaDiversity > 1.25, `seed ${seed}: municipality sizes are not overly uniform`);
assert(majorCityCoreIntegrity(seeded) >= 0.55, `seed ${seed}: major city cores remain coherent`);
assert(seeded.prefecturalCapital && seeded.adminId[indexOf(seeded.prefecturalCapital.x, seeded.prefecturalCapital.y)] >= 0, `seed ${seed}: capital municipality is not deleted`);
}
result.className = failed === 0 ? "ok" : "ng";
result.textContent = `${failed === 0 ? "All tests passed." : `${failed} tests failed.`}\n\n${logLines.join("\n")}`;
} catch (error) {
result.className = "ng";
result.textContent = String(error?.stack || error);
}