From 4bf36b1f59bfec5b97b6ba615d1e14cc89dd1e42 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 20 May 2026 20:55:43 +0900 Subject: [PATCH] names --- adminRegions.js | 29 +++++++------- fields.js | 1 + mapGenerator.js | 65 +++++++++++++++++++++++++++++--- placeNameContext.js | 92 ++++++++++++++++++++++++++++++++++----------- qualityMetrics.js | 10 ++++- test.js | 3 ++ 6 files changed, 157 insertions(+), 43 deletions(-) diff --git a/adminRegions.js b/adminRegions.js index fdbd166..dba3f00 100644 --- a/adminRegions.js +++ b/adminRegions.js @@ -25,7 +25,7 @@ function neighbors4(x, y) { return out; } -export function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) { +export function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, crestCrossingScore, populationDensity, landuse) { const adminId = new Int16Array(SIZE); adminId.fill(-1); const dist = new Float32Array(SIZE); @@ -55,14 +55,16 @@ export function generateAdminRegions(centers, prefectureMask, sea, elevation, sl const ridgeBarrier = Math.max(ridgeField[ni], ridgeField[curIndex]); const riverBarrier = Math.max(river[ni], river[curIndex]); const highDivide = Math.max(elevation[ni], elevation[curIndex]); + const crest = Math.max(crestCrossingScore?.[ni] || 0, crestCrossingScore?.[curIndex] || 0); const watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0); + const crestPenalty = crest * (18.0 + Math.max(0, highDivide - 0.48) * 38.0) * (1 + Math.max(0, highDivide - 0.56) * 1.9); const ridgePenalty = Math.max(0, highDivide - 0.36) * 16.0 + Math.abs(elevation[ni] - elevation[curIndex]) * 12.4 + watershedBarrier; const slopePenalty = slope[ni] * 10.6; const valleyBarrier = valleyField[ni] > 0.50 ? valleyField[ni] * (riverBarrier > 0.16 ? 7.2 : 2.6) : 0; const riverPenalty = riverBarrier > 0.7 ? 22.0 : riverBarrier > 0.42 ? 14.8 : riverBarrier > 0.22 ? 7.4 : riverBarrier > 0.12 ? 2.2 : 0; const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.65 : 0; const valleyLocalityBonus = valleyField[ni] * 0.16; - const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty + valleyBarrier - valleyLocalityBonus - urbanContinuityBonus) * step; + const stepCost = Math.max(0.25, 0.72 + ridgePenalty + crestPenalty + slopePenalty + riverPenalty + valleyBarrier - valleyLocalityBonus - urbanContinuityBonus) * step; const nextDist = dist[curIndex] + stepCost; if (nextDist < dist[ni]) { @@ -266,15 +268,16 @@ export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCente } } -export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) { +export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, crestCrossingScore, populationDensity, landuse) { const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse); + const crest = crestCrossingScore?.[i] || 0; const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75); const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18); const ridgeDivide = clamp(ridgeField[i] * 1.55 + Math.max(0, elevation[i] - 0.54) * ridgeField[i] * 0.95); const slopeBreak = clamp(slope[i] * 0.58 + Math.max(0, slope[i] - 0.32) * 0.68); const highGround = Math.max(0, elevation[i] - 0.56) * 0.22; const valleyFloorPenalty = valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); - return clamp(ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72); + return clamp(ridgeDivide + crest * 1.38 + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72); } function urbanBoundaryPenalty(i, populationDensity, landuse) { @@ -344,7 +347,7 @@ function buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters = [] function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, populationDensity, landuse, river, valleyField) { const [x, y] = xyOf(i); const oldId = labels[i]; - let energy = centerDist[candidateId]?.[i] ?? 0; + let energy = (centerDist[candidateId]?.[i] ?? 0) * 0.035; let same4 = 0; let diff4 = 0; let diagDiff = 0; @@ -375,7 +378,7 @@ function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, po if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42; if (candidateId !== oldId && centerDist[candidateId] && centerDist[oldId]) { const drift = centerDist[candidateId][i] - centerDist[oldId][i]; - if (drift > 0) energy += Math.min(0.9, drift * 0.012); + if (drift > 0) energy += Math.min(0.55, drift * 0.006); } return energy; } @@ -467,7 +470,7 @@ function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyFie return 11; } -function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { +function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse) { const unitId = new Int32Array(SIZE); unitId.fill(-1); const cellClass = new Int16Array(SIZE); @@ -504,7 +507,7 @@ function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridge } const targetScore = new Float32Array(SIZE); - for (let i = 0; i < SIZE; i++) if (cellClass[i] >= 0) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse); + for (let i = 0; i < SIZE; i++) if (cellClass[i] >= 0) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, crestCrossingScore, populationDensity, landuse); rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); mergeTinyLandscapeUnits(unitId, units, 10); rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); @@ -569,8 +572,8 @@ function landscapeTransitionCost(a, b, edge) { return Math.max(0.18, 0.70 + boundaryTarget * 4.2 + (a.classId === b.classId ? 0 : 0.75) + ((a.classId === 8 || b.classId === 8) ? 1.2 : 0) - urbanContinuity - (bothCorridor ? 0.55 : 0)); } -export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) { - const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); +export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, adminCenters = []) { + const { unitId, units, targetScore } = buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse); if (units.length === 0) return; for (let id = 0; id < adminCenters.length; id++) { const center = adminCenters[id]; @@ -621,11 +624,11 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse); } -export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) { +export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, crestCrossingScore, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) { const targetScore = new Float32Array(SIZE); - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, crestCrossingScore, populationDensity, landuse); const protectedMask = buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters, protectedPoints, populationDensity, landuse); - const band = buildBoundaryBand(adminId, prefectureMask, sea, 5); + const band = buildBoundaryBand(adminId, prefectureMask, sea, 8); const adminIds = [...new Set([...adminId].filter((id) => id >= 0))]; const centerDist = buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea); let current = new Int16Array(adminId); diff --git a/fields.js b/fields.js index 593080c..5b432de 100644 --- a/fields.js +++ b/fields.js @@ -18,6 +18,7 @@ export function createMapFields() { basinField: new Float32Array(SIZE), coastalLowland: new Float32Array(SIZE), flowAccum: new Float32Array(SIZE), + crestCrossingScore: new Float32Array(SIZE), erosionField: new Float32Array(SIZE), depositionField: new Float32Array(SIZE), flowTo, diff --git a/mapGenerator.js b/mapGenerator.js index 6321ecf..5766a82 100644 --- a/mapGenerator.js +++ b/mapGenerator.js @@ -1,4 +1,5 @@ import { CUSTOM_NAMES, NAME_PARTS } from "./names.js"; +import { NAME_PROBABILITIES, contextualDefaultName } from "./placeNameContext.js"; import { applyLandscapeUnitAdminPartition, generateAdminRegions, @@ -12,7 +13,6 @@ import { pickEntities } from "./entitySelection.js"; import { createMapFields } from "./fields.js"; import { MinHeap } from "./graph.js"; import { CELL_SIZE, INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside, nearMapEdge, xyOf } from "./grid.js"; -import { contextualDefaultName } from "./placeNameContext.js"; import { fbm, hash2, lerp, rand, smoothstep, valueNoise } from "./random.js"; export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./grid.js"; @@ -570,14 +570,25 @@ function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields return points.map((p, i) => { const id = `${prefix}-${i}`; const kind = kindOverride || p.kind; - let name = CUSTOM_NAMES[id]; + + const customName = CUSTOM_NAMES[id]; + const customProbability = NAME_PROBABILITIES?.customName ?? 1.0; + + const useCustomName = + Boolean(customName) && + hash2(i + prefix.length * 101, seed + i * 37, seed + 99991) < customProbability; + + let name = useCustomName ? customName : null; + if (!name) { for (let attempt = 0; attempt < 8; attempt++) { name = defaultName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, attempt); if (!usedNames || !usedNames.has(name)) break; } } - if (usedNames) usedNames.add(name); + + if (usedNames && name) usedNames.add(name); + return { ...p, id, @@ -596,6 +607,7 @@ function applyOutputOptions(map, options = {}) { delete slim.basinField; delete slim.coastalLowland; delete slim.flowAccum; + delete slim.crestCrossingScore; delete slim.erosionField; delete slim.depositionField; return slim; @@ -662,6 +674,43 @@ function recalculatePopulationAfterLanduse(modernCities, satelliteCities, popula } } +function computeCrestCrossingScore(elevation, sea, ridgeField, valleyField, coastalLowland, flowAccum, out) { + out.fill(0); + const pairs = [ + [[1, 0], [-1, 0]], + [[0, 1], [0, -1]], + [[1, 1], [-1, -1]], + [[1, -1], [-1, 1]], + ]; + + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + let bestProminence = 0; + for (const [[ax, ay], [bx, by]] of pairs) { + const ai = indexOf(x + ax, y + ay); + const bi = indexOf(x + bx, y + by); + if (sea[ai] || sea[bi]) continue; + if (elevation[i] <= elevation[ai] || elevation[i] <= elevation[bi]) continue; + bestProminence = Math.max(bestProminence, elevation[i] - Math.max(elevation[ai], elevation[bi])); + } + + if (bestProminence < 0.018) continue; + const ridge = ridgeField[i]; + if (elevation[i] < 0.38 && ridge < 0.34) continue; + + const elevationBoost = Math.exp(Math.max(0, elevation[i] - 0.42) * 3.2); + const ridgeBoost = 0.55 + ridge * 1.9; + const divideSupport = clamp(ridge * 1.25 + Math.max(0, elevation[i] - 0.56) * 0.85); + const valleyPenalty = valleyField[i] * (divideSupport > 0.58 ? 0.22 : 0.72); + const coastPenalty = coastalLowland[i] * (ridge > 0.50 ? 0.20 : 0.82); + const flowPenalty = flowAccum[i] * (ridge > 0.52 ? 0.18 : 0.88); + out[i] = clamp(bestProminence * 18.0 * elevationBoost * ridgeBoost - valleyPenalty - coastPenalty - flowPenalty); + } + } +} + export function generateMap(seedInput = 114514, options = {}) { const seed = Number(seedInput) >>> 0; @@ -682,6 +731,7 @@ export function generateMap(seedInput = 114514, options = {}) { basinField, coastalLowland, flowAccum, + crestCrossingScore, erosionField, depositionField, flowTo, @@ -1428,6 +1478,8 @@ export function generateMap(seedInput = 114514, options = {}) { const tributaryRivers = riverPaths.filter((path) => path.some(([x, y]) => !mainRiverCells.has(`${x},${y}`)) && !mainRivers.includes(path)); const smallStreams = streamPaths.filter((path) => path.length >= 5); + computeCrestCrossingScore(elevation, sea, ridgeField, valleyField, coastalLowland, flowAccum, crestCrossingScore); + prefectureMask = makePrefectureMask(seed, sea, elevation, slope, river); prefectureBorder = extractMaskBorder(prefectureMask, sea); const regionalPrefectures = generateRegionalPrefectures(seed, sea, elevation, slope, river, ridgeField, flowAccum, prefectureMask); @@ -2805,7 +2857,7 @@ export function generateMap(seedInput = 114514, options = {}) { const extra = pickEntities(municipalityCandidates, { max: 10 - adminCentersRaw.length, minDistance: 8, threshold: 0.32, seed: seed + 1304 }); adminCentersRaw.push(...extra.filter((p) => adminCentersRaw.every((q) => Math.hypot(p.x - q.x, p.y - q.y) >= 6))); } - const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse); + const adminId = generateAdminRegions(adminCentersRaw, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, crestCrossingScore, populationDensity, landuse); smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 7); function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) { @@ -2865,8 +2917,8 @@ export function generateMap(seedInput = 114514, options = {}) { lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 260); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 180); - applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw); - snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); + applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, adminCentersRaw); + snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, crestCrossingScore, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 5); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 360); mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 220); const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); @@ -2972,6 +3024,7 @@ export function generateMap(seedInput = 114514, options = {}) { basinField, coastalLowland, flowAccum, + crestCrossingScore, erosionField, depositionField, villages, diff --git a/placeNameContext.js b/placeNameContext.js index 61489b1..c13be0d 100644 --- a/placeNameContext.js +++ b/placeNameContext.js @@ -1,6 +1,13 @@ import { MAP_H, MAP_W, indexOf, inside } from "./grid.js"; import { hash2 } from "./random.js"; +export const NAME_PROBABILITIES = { + contextSuffix: 0.42, + secondInfix: 0.22, + prefixOnly: 0.38, + customName: 1.0, +}; + const CONTEXT_SUFFIXES = { coastal: ["\u6d5c", "\u6e4a", "\u6e2f", "\u6d66", "\u6d25", "\u5d0e", "\u6e7e"], river: ["\u5ddd", "\u702c", "\u6a4b", "\u6e21", "\u6cbc", "\u6ca2"], @@ -22,38 +29,79 @@ function isNearSea(x, y, sea, radius = 3) { return false; } -export function chooseNameSuffixPool(entity, fields) { +function boundedIndex(entity) { + return indexOf(Math.max(0, Math.min(MAP_W - 1, entity.x)), Math.max(0, Math.min(MAP_H - 1, entity.y))); +} + +function chooseNameContext(entity, fields) { const kind = String(entity.kind || "").toLowerCase(); - const i = indexOf(Math.max(0, Math.min(MAP_W - 1, entity.x)), Math.max(0, Math.min(MAP_H - 1, entity.y))); + const i = boundedIndex(entity); const coastal = (fields?.coastalLowland?.[i] || 0) > 0.35 || isNearSea(entity.x, entity.y, fields?.sea); const river = (fields?.river?.[i] || 0) > 0.35 || (fields?.valleyField?.[i] || 0) > 0.42; const plain = (fields?.plain?.[i] || 0) > 0.48 || (fields?.agriculture?.[i] || 0) > 0.46 || (fields?.basinField?.[i] || 0) > 0.35; const mountain = (fields?.elevation?.[i] || 0) > 0.52 || (fields?.slope?.[i] || 0) > 0.35 || (fields?.ridgeField?.[i] || 0) > 0.42; - if (kind.includes("port") || kind.includes("harbor") || entity.portClass) return CONTEXT_SUFFIXES.coastal; - if (kind.includes("crossing") || kind.includes("bridge")) return CONTEXT_SUFFIXES.river; - if (kind.includes("pass") || kind.includes("mountain")) return CONTEXT_SUFFIXES.mountain; - if (kind.includes("castle") || kind.includes("market")) return CONTEXT_SUFFIXES.historic; - if (kind.includes("new town") || kind.includes("satellite")) return CONTEXT_SUFFIXES.suburban; - if (coastal && !mountain) return CONTEXT_SUFFIXES.coastal; - if (river) return CONTEXT_SUFFIXES.river; - if (mountain && !plain) return CONTEXT_SUFFIXES.mountain; - if (plain) return CONTEXT_SUFFIXES.plain; - return CONTEXT_SUFFIXES.plain; + if (kind.includes("port") || kind.includes("harbor") || entity.portClass) return "coastal"; + if (kind.includes("crossing") || kind.includes("bridge")) return "river"; + if (kind.includes("pass") || kind.includes("mountain")) return "mountain"; + if (kind.includes("castle") || kind.includes("market")) return "historic"; + if (kind.includes("new town") || kind.includes("satellite")) return "suburban"; + if (coastal && !mountain) return "coastal"; + if (river) return "river"; + if (mountain && !plain) return "mountain"; + if (plain) return "plain"; + return "plain"; +} + +export function chooseNameSuffixPool(entity, fields) { + return CONTEXT_SUFFIXES[chooseNameContext(entity, fields)] || CONTEXT_SUFFIXES.plain; +} + +function chooseContextFreePool(entity, fields, nameParts) { + const context = chooseNameContext(entity, fields); + return nameParts.contextFree?.[context] || nameParts.contextFree?.plain || nameParts.infixes || nameParts.free || [""]; +} + +function pick(pool, seed, n, salt) { + if (!pool?.length) return ""; + const index = Math.floor(hash2(n + salt * 101, seed + salt * 1009, seed + n * 37 + salt) * pool.length) % pool.length; + return pool[index]; +} + +function chance(seed, n, salt, probability) { + return hash2(n + salt * 173, seed + salt * 811, seed + n * 43 + salt) < probability; +} + +function appendPart(parts, part) { + if (!part) return; + const previous = parts[parts.length - 1] || ""; + if (previous && (previous === part || previous.endsWith(part) || part.startsWith(previous))) return; + parts.push(part); } export function contextualDefaultName(seed, id, entity, nameParts, fields, attempt = 0) { const prefixKey = id.split("-")[0]; const n = Number(id.split("-")[1] || 0) + attempt * 997; const prefixes = nameParts.prefixes || [""]; - const infixes = nameParts.infixes || [""]; - const suffixPool = chooseNameSuffixPool(entity, fields); - const fallbackSuffixes = nameParts.suffixes || [""]; - const prefix = prefixes[(seed + n * 7) % prefixes.length]; - const useInfix = hash2(n + prefixKey.length * 17, seed + n * 31, seed + 2777) >= 0.78; - const infix = useInfix ? infixes[(seed * 3 + n * 11) % infixes.length] : ""; - const contextualSuffix = suffixPool[(seed * 5 + n * 13 + prefixKey.length) % suffixPool.length]; - const fallbackSuffix = fallbackSuffixes[(seed * 7 + n * 17 + prefixKey.length) % fallbackSuffixes.length]; - const suffix = attempt % 4 === 3 ? fallbackSuffix : contextualSuffix; - return `${prefix}${infix}${suffix}`; + const infixes = nameParts.infixes || nameParts.free || [""]; + const suffixes = nameParts.suffixes || [""]; + const contextFree = chooseContextFreePool(entity, fields, nameParts); + const contextSuffixes = chooseNameSuffixPool(entity, fields); + const parts = []; + + if (chance(seed, n, 11 + prefixKey.length, NAME_PROBABILITIES.prefixOnly)) { + appendPart(parts, pick(prefixes, seed, n, 101)); + } + + const useContextMain = chance(seed, n, 13 + prefixKey.length, 0.42); + appendPart(parts, pick(useContextMain ? contextFree : infixes, seed, n, useContextMain ? 211 : 223)); + + if (chance(seed, n, 19 + prefixKey.length, NAME_PROBABILITIES.secondInfix)) { + appendPart(parts, pick(infixes, seed, n, 401)); + } + + const useContextSuffix = chance(seed, n, 23 + prefixKey.length, NAME_PROBABILITIES.contextSuffix); + appendPart(parts, pick(useContextSuffix ? contextSuffixes : suffixes, seed, n, useContextSuffix ? 503 : 509)); + + return parts.slice(-3).join("") || pick(suffixes, seed, n, 601) || "\u539f"; } diff --git a/qualityMetrics.js b/qualityMetrics.js index d8dc82d..e0c98e8 100644 --- a/qualityMetrics.js +++ b/qualityMetrics.js @@ -3,18 +3,20 @@ import { MAP_H, MAP_W, indexOf } from "./grid.js"; export function terrainBoundaryTargetForMetrics(map, i) { const lu = map.landuse[i]; const urbanPenalty = Math.min(1, (lu === 3 ? 1.45 : lu === 2 ? 1.12 : lu === 4 ? 0.95 : lu === 7 ? 0.90 : lu === 8 ? 0.64 : lu === 5 || lu === 6 ? 0.48 : 0) + map.populationDensity[i] * 1.35); + const crest = map.crestCrossingScore?.[i] || 0; const majorRiver = Math.min(1, Math.max(map.river[i] - 0.32, 0) * 1.9 + Math.max(map.flowAccum[i] - 0.38, 0) * 0.75); const minorStream = Math.min(1, map.river[i] * 0.34 + map.flowAccum[i] * 0.18); const ridgeDivide = Math.min(1, map.ridgeField[i] * 1.55 + Math.max(0, map.elevation[i] - 0.54) * map.ridgeField[i] * 0.95); const slopeBreak = Math.min(1, map.slope[i] * 0.58 + Math.max(0, map.slope[i] - 0.32) * 0.68); const highGround = Math.max(0, map.elevation[i] - 0.56) * 0.22; const valleyFloorPenalty = map.valleyField[i] * (majorRiver > 0.34 ? -0.10 : -0.62); - return Math.max(0, Math.min(1, ridgeDivide + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72)); + return Math.max(0, Math.min(1, ridgeDivide + crest * 1.38 + majorRiver * 0.88 + minorStream * 0.22 + slopeBreak + highGround + valleyFloorPenalty - urbanPenalty * 0.72)); } export function adminBoundaryMetrics(map) { let borderEdges = 0; let targetSum = 0; + let crestSum = 0; let denseUrbanEdges = 0; let rightAngleRuns = 0; let voronoiLikeEdges = 0; @@ -28,7 +30,9 @@ export function adminBoundaryMetrics(map) { if (!map.prefectureMask[ni] || map.sea[ni] || map.adminId[ni] < 0 || map.adminId[ni] === map.adminId[i]) continue; borderEdges++; const edgeTarget = (terrainBoundaryTargetForMetrics(map, i) + terrainBoundaryTargetForMetrics(map, ni)) * 0.5; + const crestSupport = ((map.crestCrossingScore?.[i] || 0) + (map.crestCrossingScore?.[ni] || 0)) * 0.5; targetSum += edgeTarget; + crestSum += crestSupport; const urban = Math.max(map.populationDensity[i], map.populationDensity[ni]) > 0.58 || [2, 3, 4, 7, 8].includes(map.landuse[i]) || [2, 3, 4, 7, 8].includes(map.landuse[ni]); if (urban) denseUrbanEdges++; const ca = map.adminCenters[map.adminId[i]]; @@ -38,7 +42,8 @@ export function adminBoundaryMetrics(map) { const my = y + dy * 0.5; const dA = Math.hypot(mx - ca.x, my - ca.y); const dB = Math.hypot(mx - cb.x, my - cb.y); - if (Math.abs(dA - dB) < 4.2 && edgeTarget < 0.40) voronoiLikeEdges++; + const naturalSupport = Math.max(edgeTarget, crestSupport, map.ridgeField[i], map.ridgeField[ni], map.river[i], map.river[ni]); + if (Math.abs(dA - dB) < 4.2 && naturalSupport < 0.42) voronoiLikeEdges++; } if (edgeTarget < 0.16 && Math.max(map.slope[i], map.slope[ni]) < 0.24 && Math.max(map.ridgeField[i], map.ridgeField[ni]) < 0.28 && Math.max(map.river[i], map.river[ni]) < 0.26) { lowScoreFlatEdges++; @@ -100,6 +105,7 @@ export function adminBoundaryMetrics(map) { return { borderEdges, avgTarget: borderEdges ? targetSum / borderEdges : 0, + avgCrest: borderEdges ? crestSum / borderEdges : 0, denseUrbanRate: borderEdges ? denseUrbanEdges / borderEdges : 0, rightAngleRate: borderEdges ? rightAngleRuns / borderEdges : 0, voronoiLikeRate: borderEdges ? voronoiLikeEdges / borderEdges : 0, diff --git a/test.js b/test.js index 7a70125..7d384c9 100644 --- a/test.js +++ b/test.js @@ -127,6 +127,7 @@ try { 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.crestCrossingScore.length === size, "crest crossing score field matches 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"); @@ -182,6 +183,7 @@ try { 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.avgCrest > 0.006, "admin borders pick up crest crossings"); 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"); @@ -240,6 +242,7 @@ try { 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.avgCrest > 0.004, `seed ${seed}: borders retain crest-crossing 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`);