From e1bb10ff8ae6dd42cc1e6ae48d5db9bd3dad5294 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 20 May 2026 17:15:09 +0900 Subject: [PATCH] tweak --- adminRegions.js | 663 ++++++++++++++++++++++++++++++++++++++++++++ entitySelection.js | 17 ++ fields.js | 28 ++ graph.js | 34 +++ grid.js | 26 ++ mapGenerator.js | 652 ++++++++++--------------------------------- placeNameContext.js | 59 ++++ qualityMetrics.js | 141 ++++++++++ random.js | 51 ++++ renderer.js | 94 +++++-- scoring.js | 5 + test.js | 88 ++++++ 12 files changed, 1334 insertions(+), 524 deletions(-) create mode 100644 adminRegions.js create mode 100644 entitySelection.js create mode 100644 fields.js create mode 100644 graph.js create mode 100644 grid.js create mode 100644 placeNameContext.js create mode 100644 qualityMetrics.js create mode 100644 random.js create mode 100644 scoring.js diff --git a/adminRegions.js b/adminRegions.js new file mode 100644 index 0000000..fdbd166 --- /dev/null +++ b/adminRegions.js @@ -0,0 +1,663 @@ +import { MinHeap } from "./graph.js"; +import { INF, MAP_H, MAP_W, SIZE, clamp, indexOf, inside, xyOf } from "./grid.js"; +import { weightedScore } from "./scoring.js"; + +function neighbors8(x, y) { + const out = []; + 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 (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]); + } + } + return out; +} + +function neighbors4(x, y) { + const out = []; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny)) out.push([nx, ny, 1]); + } + return out; +} + +export function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) { + const adminId = new Int16Array(SIZE); + adminId.fill(-1); + const dist = new Float32Array(SIZE); + dist.fill(INF); + const heap = new MinHeap(); + + centers.forEach((center, regionId) => { + const i = indexOf(center.x, center.y); + dist[i] = 0; + adminId[i] = regionId; + heap.push({ i, f: 0, regionId }); + }); + + let guard = 0; + while (heap.length > 0 && guard++ < SIZE * 12) { + const current = heap.pop(); + if (!current) continue; + const curIndex = current.i; + const curRegion = adminId[curIndex]; + if (curRegion < 0 || current.f > dist[curIndex] + 1e-5) continue; + + const [cx, cy] = xyOf(curIndex); + for (const [nx, ny, step] of neighbors8(cx, cy)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + + 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 watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0); + 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 nextDist = dist[curIndex] + stepCost; + + if (nextDist < dist[ni]) { + dist[ni] = nextDist; + adminId[ni] = curRegion; + heap.push({ i: ni, f: nextDist, regionId: curRegion }); + } + } + } + + return adminId; +} + +function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) { + return clamp(weightedScore([ + [ridgeField[i], 3.15], + [river[i], 2.45], + [valleyField[i], 0.62], + [slope[i], 1.06], + [Math.max(0, elevation[i] - 0.5), 1.18], + ])); +} + +export function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 5) { + let current = new Int16Array(adminId); + for (let pass = 0; pass < passes; pass++) { + const next = new Int16Array(current); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + const own = current[i]; + if (!prefectureMask[i] || sea[i] || own < 0) continue; + const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.24; + const barrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField); + if (barrier > 0.62 || urbanCell) continue; + + const counts = new Map(); + let ownCount = 0; + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const id = current[ni]; + if (id < 0) continue; + const weight = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField) > 0.72 ? 0.45 : 1; + counts.set(id, (counts.get(id) || 0) + weight); + if (id === own) ownCount += weight; + } + let bestId = own; + let best = ownCount; + for (const [id, score] of counts) if (score > best) { best = score; bestId = id; } + if (bestId !== own && (best >= 4.2 || ownCount <= 2.1)) next[i] = bestId; + } + } + current = next; + } + adminId.set(current); +} + +export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 360) { + const seen = new Uint8Array(SIZE); + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || !prefectureMask[i] || sea[i]) continue; + const isUrbanStart = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.20; + if (!isUrbanStart) continue; + const component = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + component.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; + const isUrban = (landuse[ni] >= 2 && landuse[ni] <= 4) || landuse[ni] === 7 || landuse[ni] === 8 || populationDensity[ni] > 0.20; + if (!isUrban) continue; + seen[ni] = 1; + queue.push(ni); + } + } + if (component.length === 0 || component.length > maxCells) continue; + const counts = new Map(); + for (const ci of component) { + const id = adminId[ci]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + populationDensity[ci]); + } + let bestId = -1; + let best = -1; + for (const [id, score] of counts) if (score > best) { best = score; bestId = id; } + if (bestId >= 0) for (const ci of component) adminId[ci] = bestId; + } +} + +export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) { + const area = new Map(); + const pop = new Map(); + const adjacency = new Map(); + const cityMunicipalities = new Set(); + for (const city of modernCities || []) if (inside(city.x, city.y)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]); + + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const id = adminId[i]; + if (id < 0) continue; + area.set(id, (area.get(id) || 0) + 1); + pop.set(id, (pop.get(id) || 0) + populationDensity[i]); + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other < 0 || other === id) continue; + const key = id < other ? `${id}:${other}` : `${other}:${id}`; + adjacency.set(key, (adjacency.get(key) || 0) + 1); + } + } + } + + const mergeTarget = new Map(); + for (const [id, cells] of area) { + const score = cells + (pop.get(id) || 0) * 16; + if (cells >= minArea || cityMunicipalities.has(id)) continue; + let bestNeighbor = -1; + let bestScore = -1; + for (const [key, border] of adjacency) { + const [a, b] = key.split(":").map(Number); + if (a !== id && b !== id) continue; + const other = a === id ? b : a; + const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24; + if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; } + } + if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) mergeTarget.set(id, bestNeighbor); + } + if (mergeTarget.size === 0) return; + for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]); +} + +export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxIslandCells = 220) { + const protectedByAdmin = new Map(); + for (const p of [...adminCenters, ...protectedPoints]) { + if (!p || !inside(p.x, p.y)) continue; + const id = adminId[indexOf(p.x, p.y)]; + if (id < 0) continue; + if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set()); + protectedByAdmin.get(id).add(indexOf(p.x, p.y)); + } + + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); + + const globalSeen = new Uint8Array(SIZE); + const queue = []; + for (const id of ids) { + const components = []; + for (let i = 0; i < SIZE; i++) { + if (globalSeen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; + const comp = []; + let hasProtected = protectedByAdmin.get(id)?.has(i) || false; + queue.length = 0; + queue.push(i); + globalSeen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + comp.push(cur); + if (protectedByAdmin.get(id)?.has(cur)) hasProtected = true; + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (globalSeen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; + globalSeen[ni] = 1; + queue.push(ni); + } + } + components.push({ cells: comp, hasProtected }); + } + if (components.length <= 1) continue; + components.sort((a, b) => (b.hasProtected ? 1000000 : 0) + b.cells.length - ((a.hasProtected ? 1000000 : 0) + a.cells.length)); + for (const component of components.slice(1)) { + const mainSize = components[0].cells.length; + if (component.hasProtected && component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.42) continue; + if (component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.36) continue; + const counts = new Map(); + for (const ci of component.cells) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1); + } + } + let target = -1; + let best = -1; + for (const [other, count] of counts) if (count > best) { best = count; target = other; } + if (target >= 0) for (const ci of component.cells) adminId[ci] = target; + } + } +} + +export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) { + const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse); + 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); +} + +function urbanBoundaryPenalty(i, populationDensity, landuse) { + const lu = landuse[i]; + const core = 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; + return clamp(core + populationDensity[i] * 1.35); +} + +function isAdminBoundaryCell(labels, prefectureMask, sea, x, y, useEight = true) { + const i = indexOf(x, y); + const own = labels[i]; + if (!prefectureMask[i] || sea[i] || own < 0) return false; + const neighbors = useEight ? neighbors8(x, y) : neighbors4(x, y); + for (const [nx, ny] of neighbors) { + const ni = indexOf(nx, ny); + if (prefectureMask[ni] && !sea[ni] && labels[ni] >= 0 && labels[ni] !== own) return true; + } + return false; +} + +function buildBoundaryBand(labels, prefectureMask, sea, radius = 5) { + const band = new Uint8Array(SIZE); + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + if (!isAdminBoundaryCell(labels, prefectureMask, sea, x, y, true)) continue; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (Math.hypot(dx, dy) > radius) continue; + const nx = x + dx; + const ny = y + dy; + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (prefectureMask[ni] && !sea[ni]) band[ni] = 1; + } + } + } + } + return band; +} + +function buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], populationDensity, landuse) { + const protectedMask = new Uint8Array(SIZE); + function protectDisk(p, radius) { + if (!p || !inside(p.x, p.y)) return; + const owner = adminId[indexOf(p.x, p.y)]; + if (owner < 0) return; + const r = Math.ceil(radius); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.hypot(dx, dy) > radius) continue; + const x = p.x + dx; + const y = p.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (prefectureMask[i] && !sea[i] && adminId[i] === owner) protectedMask[i] = 1; + } + } + } + for (const center of adminCenters) protectDisk(center, 2.2); + for (const p of protectedPoints || []) protectDisk(p, p.population ? clamp(1.6 + Math.sqrt(p.population) / 520, 2.1, 6.0) : p.portClass ? 2.0 : 1.7); + for (let i = 0; i < SIZE; i++) { + if (prefectureMask[i] && !sea[i] && (landuse[i] === 3 || populationDensity[i] > 0.72)) protectedMask[i] = 1; + } + return protectedMask; +} + +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 same4 = 0; + let diff4 = 0; + let diagDiff = 0; + + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + const neighborId = labels[ni]; + if (neighborId < 0) continue; + const isCardinal = nx === x || ny === y; + const differs = neighborId !== candidateId; + if (isCardinal) { + if (differs) { + diff4++; + const boundaryTarget = (targetScore[i] + targetScore[ni]) * 0.5; + const urbanCut = (urbanBoundaryPenalty(i, populationDensity, landuse) + urbanBoundaryPenalty(ni, populationDensity, landuse)) * 0.5; + const minorValley = (valleyField[i] + valleyField[ni]) * 0.5 > 0.34 && Math.max(river[i], river[ni]) < 0.30 ? 0.72 : 0; + const dHere = centerDist[candidateId]?.[i] ?? 99; + const dThere = centerDist[neighborId]?.[i] ?? 99; + const weakBisectorPenalty = Math.abs(dHere - dThere) < 4.0 && boundaryTarget < 0.42 ? 0.62 : 0; + energy += 2.15 - boundaryTarget * 1.55 + urbanCut * 3.0 + minorValley + weakBisectorPenalty; + } else same4++; + } else if (differs) diagDiff++; + } + + if (same4 === 0) energy += 5.2; + if (same4 === 1) energy += 1.7; + if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25; + 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); + } + return energy; +} + +function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) { + const fields = []; + for (const id of adminIds) { + const center = adminCenters[id]; + const field = new Float32Array(SIZE); + if (!center || !inside(center.x, center.y) || sea[indexOf(center.x, center.y)] || !prefectureMask[indexOf(center.x, center.y)]) field.fill(24); + else { + for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) field[indexOf(x, y)] = Math.hypot(x - center.x, y - center.y); + } + fields[id] = field; + } + return fields; +} + +function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) { + const ids = new Set(); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); + const queue = []; + + for (const id of ids) { + const seen = new Uint8Array(SIZE); + const components = []; + for (let i = 0; i < SIZE; i++) { + if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; + const cells = []; + queue.length = 0; + queue.push(i); + seen[i] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + cells.push(cur); + const [x, y] = xyOf(cur); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (seen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + components.push(cells); + } + if (components.length <= 1) continue; + + const centerIndex = adminCenters[id] && inside(adminCenters[id].x, adminCenters[id].y) ? indexOf(adminCenters[id].x, adminCenters[id].y) : -1; + let keepIndex = centerIndex >= 0 ? components.findIndex((cells) => cells.includes(centerIndex)) : -1; + if (keepIndex < 0) { + let bestSize = -1; + for (let c = 0; c < components.length; c++) if (components[c].length > bestSize) { bestSize = components[c].length; keepIndex = c; } + } + + for (let c = 0; c < components.length; c++) { + if (c === keepIndex) continue; + const counts = new Map(); + for (const ci of components[c]) { + const [x, y] = xyOf(ci); + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const other = adminId[ni]; + if (other < 0 || other === id) continue; + const terrainFit = targetScore ? targetScore[ci] * 0.18 : 0; + const urbanFit = populationDensity && landuse ? (1 - urbanBoundaryPenalty(ci, populationDensity, landuse)) * 0.08 : 0; + counts.set(other, (counts.get(other) || 0) + 1 + terrainFit + urbanFit); + } + } + let target = -1; + let best = -1; + for (const [other, score] of counts) if (score > best) { best = score; target = other; } + if (target >= 0) for (const ci of components[c]) adminId[ci] = target; + } + } +} + +function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { + if (landuse[i] === 3 || populationDensity[i] > 0.70) return 1; + if ((landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.24) return 2; + if (landuse[i] === 5 || landuse[i] === 6) return 3; + if ((river[i] > 0.50 && flowAccum[i] > 0.34) || flowAccum[i] > 0.68) return 4; + if (coastalLowland[i] > 0.42 && elevation[i] < 0.44) return 5; + if (basinField[i] > 0.38 && plain[i] > 0.26) return 6; + if (valleyField[i] > 0.42 && ridgeField[i] < 0.55) return 7; + if (ridgeField[i] > 0.54 || (ridgeField[i] > 0.40 && elevation[i] > 0.54)) return 8; + if (elevation[i] > 0.62 || slope[i] > 0.42) return 9; + if (landuse[i] === 0 || agriculture[i] > 0.45 || plain[i] > 0.48) return 10; + return 11; +} + +function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) { + const unitId = new Int32Array(SIZE); + unitId.fill(-1); + const cellClass = new Int16Array(SIZE); + cellClass.fill(-1); + for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse); + + const units = []; + const queue = []; + for (let i = 0; i < SIZE; i++) { + if (cellClass[i] < 0 || unitId[i] >= 0) continue; + const id = units.length; + const klass = cellClass[i]; + const cells = []; + let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0; + queue.length = 0; + queue.push(i); + unitId[i] = id; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]; + const [x, y] = xyOf(cur); + cells.push(cur); + sx += x; sy += y; pop += populationDensity[cur]; + urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse); + ridgeExposure += ridgeField[cur]; + riverExposure += river[cur] + flowAccum[cur] * 0.45; + for (const [nx, ny] of neighbors4(x, y)) { + const ni = indexOf(nx, ny); + if (unitId[ni] >= 0 || cellClass[ni] !== klass) continue; + unitId[ni] = id; + queue.push(ni); + } + } + units.push({ id, classId: klass, cells, area: cells.length, x: sx / cells.length, y: sy / cells.length, population: pop, urbanWeight: urbanWeight / cells.length, ridgeExposure: ridgeExposure / cells.length, riverExposure: riverExposure / cells.length, adjacent: new Map(), centerIds: [], owner: -1 }); + } + + 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); + rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); + mergeTinyLandscapeUnits(unitId, units, 10); + rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea); + return { unitId, units, targetScore }; +} + +function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea) { + for (const unit of units) unit.adjacent = new Map(); + for (let y = 0; y < MAP_H; y++) { + for (let x = 0; x < MAP_W; x++) { + const i = indexOf(x, y); + if (!prefectureMask[i] || sea[i]) continue; + const a = unitId[i]; + if (a < 0 || !units[a] || units[a].area === 0) continue; + for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) { + if (!inside(nx, ny)) continue; + const ni = indexOf(nx, ny); + if (!prefectureMask[ni] || sea[ni]) continue; + const b = unitId[ni]; + if (b < 0 || b === a || !units[b] || units[b].area === 0) continue; + const v = (targetScore[i] + targetScore[ni]) * 0.5; + const keyA = units[a].adjacent.get(b) || { count: 0, target: 0 }; + keyA.count++; keyA.target += v; units[a].adjacent.set(b, keyA); + const keyB = units[b].adjacent.get(a) || { count: 0, target: 0 }; + keyB.count++; keyB.target += v; units[b].adjacent.set(a, keyB); + } + } + } +} + +function mergeTinyLandscapeUnits(unitId, units, minArea = 10) { + for (const unit of units) { + if (unit.area === 0 || unit.area >= minArea) continue; + let bestId = -1, bestScore = -INF; + for (const [otherId, edge] of unit.adjacent) { + const other = units[otherId]; + if (!other || other.area === 0) continue; + const score = edge.count * 3 + (other.classId === unit.classId ? 8 : 0) + other.area * 0.01 - edge.target / Math.max(1, edge.count); + if (score > bestScore) { bestScore = score; bestId = otherId; } + } + if (bestId < 0) continue; + const target = units[bestId]; + for (const i of unit.cells) { unitId[i] = bestId; target.cells.push(i); } + const totalArea = target.area + unit.area; + target.x = (target.x * target.area + unit.x * unit.area) / totalArea; + target.y = (target.y * target.area + unit.y * unit.area) / totalArea; + target.population += unit.population; + target.urbanWeight = (target.urbanWeight * target.area + unit.urbanWeight * unit.area) / totalArea; + target.ridgeExposure = (target.ridgeExposure * target.area + unit.ridgeExposure * unit.area) / totalArea; + target.riverExposure = (target.riverExposure * target.area + unit.riverExposure * unit.area) / totalArea; + target.area = totalArea; + unit.area = 0; + unit.cells = []; + } +} + +function landscapeTransitionCost(a, b, edge) { + const boundaryTarget = edge.target / Math.max(1, edge.count); + const bothUrban = a.classId <= 3 && b.classId <= 3; + const bothCorridor = (a.classId === 5 || a.classId === 7 || a.classId === 10) && (b.classId === 5 || b.classId === 7 || b.classId === 10); + const urbanContinuity = bothUrban ? 2.1 : (a.urbanWeight + b.urbanWeight) > 0.75 && Math.abs(a.urbanWeight - b.urbanWeight) < 0.35 ? 0.9 : 0; + 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); + if (units.length === 0) return; + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + const unit = units[unitId[indexOf(center.x, center.y)]]; + if (unit) unit.centerIds.push(id); + } + + const owner = new Int16Array(units.length); + const dist = new Float32Array(units.length); + owner.fill(-1); dist.fill(INF); + const heap = new MinHeap(); + for (const unit of units) { + if (unit.area === 0 || unit.centerIds.length === 0) continue; + const id = unit.centerIds[0]; + owner[unit.id] = id; dist[unit.id] = 0; heap.push({ i: unit.id, f: 0 }); + } + while (heap.length) { + const cur = heap.pop(); + if (!cur || cur.f > dist[cur.i] + 1e-5) continue; + const unit = units[cur.i]; + const currentOwner = owner[cur.i]; + if (!unit || currentOwner < 0) continue; + for (const [nextId, edge] of unit.adjacent) { + const next = units[nextId]; + if (!next || next.area === 0) continue; + const nextDist = dist[cur.i] + landscapeTransitionCost(unit, next, edge) + Math.sqrt(next.area) * 0.012 + (next.urbanWeight > 0.75 && next.centerIds.length === 0 ? -0.20 : 0); + if (nextDist < dist[nextId]) { + dist[nextId] = nextDist; owner[nextId] = currentOwner; heap.push({ i: nextId, f: nextDist }); + } + } + } + for (const unit of units) { + const assigned = owner[unit.id]; + if (assigned >= 0) for (const i of unit.cells) adminId[i] = assigned; + } + for (let id = 0; id < adminCenters.length; id++) { + const center = adminCenters[id]; + if (!center || !inside(center.x, center.y)) continue; + for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { + if (Math.hypot(dx, dy) > 2) continue; + const x = center.x + dx, y = center.y + dy; + if (!inside(x, y)) continue; + const i = indexOf(x, y); + if (prefectureMask[i] && !sea[i]) adminId[i] = id; + } + } + 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) { + 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); + const protectedMask = buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters, protectedPoints, populationDensity, landuse); + const band = buildBoundaryBand(adminId, prefectureMask, sea, 5); + const adminIds = [...new Set([...adminId].filter((id) => id >= 0))]; + const centerDist = buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea); + let current = new Int16Array(adminId); + + for (let pass = 0; pass < passes; pass++) { + const next = new Int16Array(current); + let changed = 0; + for (let y = 1; y < MAP_H - 1; y++) { + for (let x = 1; x < MAP_W - 1; x++) { + const i = indexOf(x, y); + const own = current[i]; + if (!band[i] || protectedMask[i] || !prefectureMask[i] || sea[i] || own < 0) continue; + if (!isAdminBoundaryCell(current, prefectureMask, sea, x, y, true)) continue; + const candidates = new Set(); + for (const [nx, ny] of neighbors8(x, y)) { + const ni = indexOf(nx, ny); + if (prefectureMask[ni] && !sea[ni] && current[ni] >= 0 && current[ni] !== own) candidates.add(current[ni]); + } + if (candidates.size === 0) continue; + const currentEnergy = localBoundaryEnergy(current, i, own, targetScore, centerDist, populationDensity, landuse, river, valleyField); + let bestId = own, bestEnergy = currentEnergy; + for (const candidate of candidates) { + const candidateEnergy = localBoundaryEnergy(current, i, candidate, targetScore, centerDist, populationDensity, landuse, river, valleyField); + const threshold = 0.18 + (targetScore[i] < 0.36 ? 0.16 : 0) + urbanBoundaryPenalty(i, populationDensity, landuse) * 0.25; + if (bestEnergy - candidateEnergy > threshold) { bestEnergy = candidateEnergy; bestId = candidate; } + } + if (bestId !== own) { next[i] = bestId; changed++; } + } + } + current = next; + if (changed === 0) break; + } + adminId.set(current); + repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse); +} diff --git a/entitySelection.js b/entitySelection.js new file mode 100644 index 0000000..21fa8e2 --- /dev/null +++ b/entitySelection.js @@ -0,0 +1,17 @@ +import { hash2 } from "./random.js"; + +export function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) { + const sorted = candidates + .filter((p) => Number.isFinite(p.score) && p.score >= threshold) + .map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter })) + .sort((a, b) => b.score - a.score); + + const out = []; + for (const candidate of sorted) { + if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) { + out.push(candidate); + if (out.length >= max) break; + } + } + return out; +} diff --git a/fields.js b/fields.js new file mode 100644 index 0000000..593080c --- /dev/null +++ b/fields.js @@ -0,0 +1,28 @@ +import { SIZE } from "./grid.js"; + +export function createMapFields() { + const flowTo = new Int32Array(SIZE); + flowTo.fill(-1); + + return { + elevation: new Float32Array(SIZE), + moisture: new Float32Array(SIZE), + slope: new Float32Array(SIZE), + sea: new Uint8Array(SIZE), + river: new Float32Array(SIZE), + floodplain: new Float32Array(SIZE), + plain: new Float32Array(SIZE), + agriculture: new Float32Array(SIZE), + ridgeField: new Float32Array(SIZE), + valleyField: new Float32Array(SIZE), + basinField: new Float32Array(SIZE), + coastalLowland: new Float32Array(SIZE), + flowAccum: new Float32Array(SIZE), + erosionField: new Float32Array(SIZE), + depositionField: new Float32Array(SIZE), + flowTo, + portSuitability: new Float32Array(SIZE), + crossingSuitability: new Float32Array(SIZE), + passSuitability: new Float32Array(SIZE), + }; +} diff --git a/graph.js b/graph.js new file mode 100644 index 0000000..1311ca0 --- /dev/null +++ b/graph.js @@ -0,0 +1,34 @@ +export class MinHeap { + constructor() { this.items = []; } + push(item) { + this.items.push(item); + let i = this.items.length - 1; + while (i > 0) { + const parent = (i - 1) >> 1; + if (this.items[parent].f <= item.f) break; + this.items[i] = this.items[parent]; + i = parent; + } + this.items[i] = item; + } + pop() { + if (this.items.length === 0) return null; + const root = this.items[0]; + const last = this.items.pop(); + if (this.items.length > 0) { + let i = 0; + while (true) { + const left = i * 2 + 1; + const right = left + 1; + if (left >= this.items.length) break; + const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left; + if (this.items[child].f >= last.f) break; + this.items[i] = this.items[child]; + i = child; + } + this.items[i] = last; + } + return root; + } + get length() { return this.items.length; } +} diff --git a/grid.js b/grid.js new file mode 100644 index 0000000..355da28 --- /dev/null +++ b/grid.js @@ -0,0 +1,26 @@ +export const MAP_W = 172; +export const MAP_H = 122; +export const CELL_SIZE = 6; + +export const SIZE = MAP_W * MAP_H; +export const INF = 1e9; + +export function indexOf(x, y) { + return y * MAP_W + x; +} + +export function xyOf(i) { + return [i % MAP_W, Math.floor(i / MAP_W)]; +} + +export function inside(x, y) { + return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H; +} + +export function clamp(v, a = 0, b = 1) { + return Math.max(a, Math.min(b, v)); +} + +export function nearMapEdge(x, y, margin = 1) { + return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin; +} diff --git a/mapGenerator.js b/mapGenerator.js index 16c2841..6321ecf 100644 --- a/mapGenerator.js +++ b/mapGenerator.js @@ -1,81 +1,21 @@ import { CUSTOM_NAMES, NAME_PARTS } from "./names.js"; +import { + applyLandscapeUnitAdminPartition, + generateAdminRegions, + lockSmallUrbanComponentsToMunicipality, + mergeTinyMunicipalities, + removeMunicipalExclaves, + smoothAdminRegionsTerrainAware, + snapAdminBoundariesToTerrain, +} from "./adminRegions.js"; +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 const MAP_W = 172; -export const MAP_H = 122; -export const CELL_SIZE = 6; - -const SIZE = MAP_W * MAP_H; -const INF = 1e9; - -export function indexOf(x, y) { - return y * MAP_W + x; -} - -function xyOf(i) { - return [i % MAP_W, Math.floor(i / MAP_W)]; -} - -function inside(x, y) { - return x >= 0 && y >= 0 && x < MAP_W && y < MAP_H; -} - -function clamp(v, a = 0, b = 1) { - return Math.max(a, Math.min(b, v)); -} - -function nearMapEdge(x, y, margin = 1) { - return x < margin || y < margin || x >= MAP_W - margin || y >= MAP_H - margin; -} - -function hash2(x, y, seed) { - let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263); - h = (h ^ (h >>> 13)) >>> 0; - h = Math.imul(h, 1274126177) >>> 0; - return ((h ^ (h >>> 16)) >>> 0) / 4294967295; -} - -function rand(seed, n) { - return hash2(n * 7919 + 17, n * 104729 + 31, seed); -} - -function smoothstep(t) { - t = clamp(t); - return t * t * (3 - 2 * t); -} - -function lerp(a, b, t) { - return a + (b - a) * t; -} - -function valueNoise(x, y, seed, scale) { - const sx = x / scale; - const sy = y / scale; - const x0 = Math.floor(sx); - const y0 = Math.floor(sy); - const tx = smoothstep(sx - x0); - const ty = smoothstep(sy - y0); - - const a = hash2(x0, y0, seed); - const b = hash2(x0 + 1, y0, seed); - const c = hash2(x0, y0 + 1, seed); - const d = hash2(x0 + 1, y0 + 1, seed); - - return lerp(lerp(a, b, tx), lerp(c, d, tx), ty); -} - -function fbm(x, y, seed) { - let amp = 1; - let scale = 54; - let sum = 0; - let norm = 0; - for (let i = 0; i < 5; i++) { - sum += valueNoise(x, y, seed + i * 101, scale) * amp; - norm += amp; - amp *= 0.5; - scale *= 0.5; - } - return sum / norm; -} +export { CELL_SIZE, MAP_H, MAP_W, indexOf } from "./grid.js"; function neighbors8(x, y) { const out = []; @@ -106,57 +46,6 @@ function distanceToNearest(points, x, y, fallback = 999) { return best; } -function pickEntities(candidates, { max = 99, minDistance = 5, threshold = 0, seed = 0, jitter = 0.04 } = {}) { - const sorted = candidates - .filter((p) => Number.isFinite(p.score) && p.score >= threshold) - .map((p) => ({ ...p, score: p.score + hash2(p.x, p.y, seed + 54321) * jitter })) - .sort((a, b) => b.score - a.score); - - const out = []; - for (const candidate of sorted) { - if (out.every((p) => Math.hypot(p.x - candidate.x, p.y - candidate.y) >= minDistance)) { - out.push(candidate); - if (out.length >= max) break; - } - } - return out; -} - -class MinHeap { - constructor() { this.items = []; } - push(item) { - this.items.push(item); - let i = this.items.length - 1; - while (i > 0) { - const parent = (i - 1) >> 1; - if (this.items[parent].f <= item.f) break; - this.items[i] = this.items[parent]; - i = parent; - } - this.items[i] = item; - } - pop() { - if (this.items.length === 0) return null; - const root = this.items[0]; - const last = this.items.pop(); - if (this.items.length > 0) { - let i = 0; - while (true) { - const left = i * 2 + 1; - const right = left + 1; - if (left >= this.items.length) break; - const child = right < this.items.length && this.items[right].f < this.items[left].f ? right : left; - if (this.items[child].f >= last.f) break; - this.items[i] = this.items[child]; - i = child; - } - this.items[i] = last; - } - return root; - } - get length() { return this.items.length; } -} - function aStar(start, goal, costAt) { const startIndex = indexOf(start.x, start.y); const goalIndex = indexOf(goal.x, goal.y); @@ -673,348 +562,43 @@ function tagInsidePrefecture(points, prefectureMask) { return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) })); } -function defaultName(seed, id) { - const prefixKey = id.split("-")[0]; - const n = Number(id.split("-")[1] || 0); - const prefixes = NAME_PARTS.prefixes || [""]; - const infixes = NAME_PARTS.infixes || [""]; - const suffixes = NAME_PARTS.suffixes || [""]; - const prefix = prefixes[(seed + n * 7) % prefixes.length]; - const useInfix = hash2(n + prefixKey.length * 17, seed + n * 31, seed + 2777) >= 0.7; - const infix = useInfix ? infixes[(seed * 3 + n * 11) % infixes.length] : ""; - const suffixWord = suffixes[(seed * 5 + n * 13 + prefixKey.length) % suffixes.length]; - return `${prefix}${infix}${suffixWord}`; +function defaultName(seed, id, entity, nameFields, attempt = 0) { + return contextualDefaultName(seed, id, entity, NAME_PARTS, nameFields, attempt); } -function attachIdsAndNames(points, prefix, seed, kindOverride = null) { +function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null) { return points.map((p, i) => { const id = `${prefix}-${i}`; const kind = kindOverride || p.kind; + let name = CUSTOM_NAMES[id]; + 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); return { ...p, id, - name: CUSTOM_NAMES[id] || defaultName(seed + prefix.length * 1000, id), + name, insidePrefecture: Boolean(p.insidePrefecture), }; }); } -function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) { - const adminId = new Int16Array(SIZE); - adminId.fill(-1); - const dist = new Float32Array(SIZE); - dist.fill(INF); - const heap = new MinHeap(); - - centers.forEach((center, regionId) => { - const i = indexOf(center.x, center.y); - dist[i] = 0; - adminId[i] = regionId; - heap.push({ i, f: 0, regionId }); - }); - - let guard = 0; - while (heap.length > 0 && guard++ < SIZE * 12) { - const current = heap.pop(); - if (!current) continue; - const curIndex = current.i; - const curRegion = adminId[curIndex]; - if (curRegion < 0) continue; - if (current.f > dist[curIndex] + 1e-5) continue; - - const [cx, cy] = xyOf(curIndex); - for (const [nx, ny, step] of neighbors8(cx, cy)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - - 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 watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0); - 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 nextDist = dist[curIndex] + stepCost; - - if (nextDist < dist[ni]) { - dist[ni] = nextDist; - adminId[ni] = curRegion; - heap.push({ i: ni, f: nextDist, regionId: curRegion }); - } - } - } - - return adminId; -} - -function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) { - return clamp( - ridgeField[i] * 3.15 + - river[i] * 2.45 + - valleyField[i] * 0.62 + - slope[i] * 1.06 + - Math.max(0, elevation[i] - 0.5) * 1.18 - ); -} - -function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 5) { - let current = new Int16Array(adminId); - for (let pass = 0; pass < passes; pass++) { - const next = new Int16Array(current); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - const own = current[i]; - if (!prefectureMask[i] || sea[i] || own < 0) continue; - const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.24; - const barrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField); - if (barrier > 0.62 || urbanCell) continue; - - const counts = new Map(); - let ownCount = 0; - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const id = current[ni]; - if (id < 0) continue; - const weight = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField) > 0.72 ? 0.45 : 1; - counts.set(id, (counts.get(id) || 0) + weight); - if (id === own) ownCount += weight; - } - let bestId = own; - let best = ownCount; - for (const [id, score] of counts) { - if (score > best) { best = score; bestId = id; } - } - if (bestId !== own && (best >= 4.2 || ownCount <= 2.1)) next[i] = bestId; - } - } - current = next; - } - adminId.set(current); -} - -function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 360) { - const seen = new Uint8Array(SIZE); - const queue = []; - for (let i = 0; i < SIZE; i++) { - if (seen[i] || !prefectureMask[i] || sea[i]) continue; - const isUrbanStart = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.20; - if (!isUrbanStart) continue; - const component = []; - queue.length = 0; - queue.push(i); - seen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - component.push(cur); - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue; - const isUrban = (landuse[ni] >= 2 && landuse[ni] <= 4) || landuse[ni] === 7 || landuse[ni] === 8 || populationDensity[ni] > 0.20; - if (!isUrban) continue; - seen[ni] = 1; - queue.push(ni); - } - } - if (component.length === 0 || component.length > maxCells) continue; - const counts = new Map(); - for (const ci of component) { - const id = adminId[ci]; - if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + populationDensity[ci]); - } - let bestId = -1; - let best = -1; - for (const [id, score] of counts) { - if (score > best) { best = score; bestId = id; } - } - if (bestId >= 0) for (const ci of component) adminId[ci] = bestId; - } -} - - -function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320) { - const area = new Map(); - const pop = new Map(); - const adjacency = new Map(); - const cityMunicipalities = new Set(); - for (const city of modernCities || []) { - if (inside(city.x, city.y)) cityMunicipalities.add(adminId[indexOf(city.x, city.y)]); - } - for (let y = 0; y < MAP_H; y++) { - for (let x = 0; x < MAP_W; x++) { - const i = indexOf(x, y); - if (!prefectureMask[i] || sea[i]) continue; - const id = adminId[i]; - if (id < 0) continue; - area.set(id, (area.get(id) || 0) + 1); - pop.set(id, (pop.get(id) || 0) + populationDensity[i]); - for (const [nx, ny] of [[x+1,y],[x,y+1]]) { - if (!inside(nx, ny)) continue; - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const other = adminId[ni]; - if (other < 0 || other === id) continue; - const key = id < other ? `${id}:${other}` : `${other}:${id}`; - adjacency.set(key, (adjacency.get(key) || 0) + 1); - } - } - } - const mergeTarget = new Map(); - for (const [id, cells] of area) { - const score = cells + (pop.get(id) || 0) * 16; - if (cells >= minArea || cityMunicipalities.has(id)) continue; - let bestNeighbor = -1; - let bestScore = -1; - for (const [key, border] of adjacency) { - const [a, b] = key.split(':').map(Number); - if (a !== id && b !== id) continue; - const other = a === id ? b : a; - const otherArea = area.get(other) || 0; - const otherPop = pop.get(other) || 0; - const candidate = border * 3 + otherArea * 0.012 + otherPop * 0.24; - if (candidate > bestScore) { - bestScore = candidate; - bestNeighbor = other; - } - } - if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) mergeTarget.set(id, bestNeighbor); - } - if (mergeTarget.size === 0) return; - for (let i = 0; i < SIZE; i++) { - const id = adminId[i]; - if (mergeTarget.has(id)) adminId[i] = mergeTarget.get(id); - } -} - - -function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxIslandCells = 220) { - const protectedByAdmin = new Map(); - for (const p of [...adminCenters, ...protectedPoints]) { - if (!p || !inside(p.x, p.y)) continue; - const id = adminId[indexOf(p.x, p.y)]; - if (id < 0) continue; - if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set()); - protectedByAdmin.get(id).add(indexOf(p.x, p.y)); - } - - const ids = new Set(); - for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]); - - const globalSeen = new Uint8Array(SIZE); - const queue = []; - for (const id of ids) { - const components = []; - for (let i = 0; i < SIZE; i++) { - if (globalSeen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue; - const comp = []; - let hasProtected = protectedByAdmin.get(id)?.has(i) || false; - queue.length = 0; - queue.push(i); - globalSeen[i] = 1; - for (let q = 0; q < queue.length; q++) { - const cur = queue[q]; - comp.push(cur); - if (protectedByAdmin.get(id)?.has(cur)) hasProtected = true; - const [x, y] = xyOf(cur); - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (globalSeen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue; - globalSeen[ni] = 1; - queue.push(ni); - } - } - components.push({ cells: comp, hasProtected }); - } - if (components.length <= 1) continue; - components.sort((a, b) => (b.hasProtected ? 1000000 : 0) + b.cells.length - ((a.hasProtected ? 1000000 : 0) + a.cells.length)); - const keep = new Set(components[0].cells); - for (const component of components.slice(1)) { - const mainSize = components[0].cells.length; - if (component.hasProtected && component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.42) continue; - if (component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.36) continue; - const counts = new Map(); - for (const ci of component.cells) { - const [x, y] = xyOf(ci); - for (const [nx, ny] of neighbors4(x, y)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const other = adminId[ni]; - if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1); - } - } - let target = -1; - let best = -1; - for (const [other, count] of counts) if (count > best) { best = count; target = other; } - if (target >= 0) for (const ci of component.cells) adminId[ci] = target; - } - } - -} - -function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 6) { - let current = new Int16Array(adminId); - for (let pass = 0; pass < passes; pass++) { - const next = new Int16Array(current); - for (let y = 1; y < MAP_H - 1; y++) { - for (let x = 1; x < MAP_W - 1; x++) { - const i = indexOf(x, y); - const own = current[i]; - if (!prefectureMask[i] || sea[i] || own < 0) continue; - - const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.24; - if (urbanCell) continue; - - let isBoundary = false; - const counts = new Map([[own, 0]]); - for (const [nx, ny] of neighbors8(x, y)) { - const ni = indexOf(nx, ny); - if (!prefectureMask[ni] || sea[ni]) continue; - const id = current[ni]; - if (id < 0) continue; - if (id !== own) isBoundary = true; - const terrain = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField); - const weight = terrain > 0.60 ? 0.45 : 1.0; - counts.set(id, (counts.get(id) || 0) + weight); - } - if (!isBoundary) continue; - - let localBarrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField); - for (let dy = -1; dy <= 1; dy++) { - for (let dx = -1; dx <= 1; dx++) { - const nx = x + dx; - const ny = y + dy; - if (!inside(nx, ny)) continue; - localBarrier = Math.max(localBarrier, terrainBoundaryStrength(indexOf(nx, ny), elevation, slope, river, ridgeField, valleyField)); - } - } - if (localBarrier > 0.50) continue; - - let bestId = own; - let best = counts.get(own) || 0; - for (const [id, score] of counts) { - if (id === own) continue; - const adjusted = score + (populationDensity[i] < 0.12 ? 0.42 : 0) + (plainnessForBoundary(elevation, slope, ridgeField, i) ? 0.24 : 0); - if (adjusted > best + 0.65) { - best = adjusted; - bestId = id; - } - } - if (bestId !== own) next[i] = bestId; - } - } - current = next; - } - adminId.set(current); -} - -function plainnessForBoundary(elevation, slope, ridgeField, i) { - return elevation[i] < 0.58 && slope[i] < 0.26 && ridgeField[i] < 0.34; +function applyOutputOptions(map, options = {}) { + if (options.includeDebugFields !== false) return map; + const slim = { ...map }; + delete slim.settlementCluster; + delete slim.ridgeField; + delete slim.valleyField; + delete slim.basinField; + delete slim.coastalLowland; + delete slim.flowAccum; + delete slim.erosionField; + delete slim.depositionField; + return slim; } function recalculatePopulationAfterLanduse(modernCities, satelliteCities, populationDensity, landuse, prefectureMask, sea, stationInfluence, roadInfluence, railInfluence) { @@ -1078,32 +662,33 @@ function recalculatePopulationAfterLanduse(modernCities, satelliteCities, popula } } -export function generateMap(seedInput = 114514) { +export function generateMap(seedInput = 114514, options = {}) { const seed = Number(seedInput) >>> 0; let prefectureMask; let prefectureBorder; - const elevation = new Float32Array(SIZE); - const moisture = new Float32Array(SIZE); - const slope = new Float32Array(SIZE); - const sea = new Uint8Array(SIZE); - const river = new Float32Array(SIZE); - const floodplain = new Float32Array(SIZE); - const plain = new Float32Array(SIZE); - const agriculture = new Float32Array(SIZE); - const ridgeField = new Float32Array(SIZE); - const valleyField = new Float32Array(SIZE); - const basinField = new Float32Array(SIZE); - const coastalLowland = new Float32Array(SIZE); - const flowAccum = new Float32Array(SIZE); - const erosionField = new Float32Array(SIZE); - const depositionField = new Float32Array(SIZE); - const flowTo = new Int32Array(SIZE); - flowTo.fill(-1); - const portSuitability = new Float32Array(SIZE); - const crossingSuitability = new Float32Array(SIZE); - const passSuitability = new Float32Array(SIZE); + const { + elevation, + moisture, + slope, + sea, + river, + floodplain, + plain, + agriculture, + ridgeField, + valleyField, + basinField, + coastalLowland, + flowAccum, + erosionField, + depositionField, + flowTo, + portSuitability, + crossingSuitability, + passSuitability, + } = createMapFields(); const coastAngle = rand(seed, 11) * Math.PI * 2; const coastX = Math.cos(coastAngle); @@ -2000,6 +1585,20 @@ export function generateMap(seedInput = 114514) { predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "Pass" })); + const settlementCluster = new Float32Array(SIZE); + for (let y = 2; y < MAP_H - 2; y++) { + for (let x = 2; x < MAP_W - 2; x++) { + const i = indexOf(x, y); + if (sea[i]) continue; + const valleyCorridor = clamp(valleyField[i] * 0.62 + river[i] * 0.16); + const lowlandCorridor = clamp(coastalLowland[i] * 0.38 + basinField[i] * 0.34 + plain[i] * 0.24 + agriculture[i] * 0.18); + const terrainGate = clamp(1.0 - slope[i] * 1.18 - ridgeField[i] * 0.52 - Math.max(0, elevation[i] - 0.62) * 1.35, 0.08, 1); + const localPatch = valueNoise(x * 0.7, y * 0.7, seed + 1037, 10); + const broadPatch = fbm(x * 0.32 + 71, y * 0.32 - 19, seed + 1038); + settlementCluster[i] = clamp((valleyCorridor + lowlandCorridor) * terrainGate * (0.72 + broadPatch * 0.42 + localPatch * 0.18)); + } + } + const settlementScore = new Float32Array(SIZE); for (let y = 2; y < MAP_H - 2; y++) { for (let x = 2; x < MAP_W - 2; x++) { @@ -2009,14 +1608,16 @@ export function generateMap(seedInput = 114514) { for (const p of [...ports, ...crossings, ...passes]) nearFeature = Math.max(nearFeature, 1 / (1 + Math.hypot(x - p.x, y - p.y) / 4)); const riverPull = Math.min(0.32, river[i] * 0.14 + valleyField[i] * 0.16); const mountainVillage = valleyField[i] * clamp(elevation[i] - 0.42, 0, 0.3) * 0.52; - settlementScore[i] = clamp(agriculture[i] * 0.55 + plain[i] * 0.16 + nearFeature * 0.24 + riverPull + basinField[i] * 0.12 + mountainVillage - slope[i] * 0.42 - ridgeField[i] * 0.2 - floodplain[i] * 0.06); + const remoteMountainPenalty = Math.max(0, elevation[i] - 0.58) * Math.max(0, ridgeField[i] - 0.22) * (1 - valleyField[i]) * 0.75; + const base = agriculture[i] * 0.50 + plain[i] * 0.14 + nearFeature * 0.23 + riverPull + basinField[i] * 0.13 + coastalLowland[i] * 0.08 + mountainVillage - slope[i] * 0.48 - ridgeField[i] * 0.24 - floodplain[i] * 0.06 - remoteMountainPenalty; + settlementScore[i] = clamp(base * (0.74 + settlementCluster[i] * 0.66) + settlementCluster[i] * 0.13); } } let villages = pickPoints(settlementScore, { threshold: 0.32 + rand(seed, 1031) * 0.1, max: 28 + Math.floor(rand(seed, 1032) * 44), - minDistance: 4 + Math.floor(rand(seed, 1033) * 4), + minDistance: 3 + Math.floor(rand(seed, 1033) * 3), seedOffset: 1030, predicate: (x, y, i) => !sea[i], }).map((p) => ({ ...p, kind: "Village" })); @@ -2481,12 +2082,35 @@ export function generateMap(seedInput = 114514) { const nationalRoads = []; const roadDegree = new Map(); - const roadTargets = pickEntities([...modernCities.filter((p) => (p.population || 0) >= 90000), ...ports, ...markets].map((p) => ({ ...p, score: p.score + ((p.population || 0) >= 180000 ? 0.18 : 0.05) })), { + function transportDemand(p) { + const pop = Math.sqrt(Math.max(0, p.population || 0)) / 700; + const capitalBoost = p.isPrefecturalCapital || p.rank === "Prefectural Capital" ? 2.1 : 0; + const portBoost = p.portClass === "major" ? 1.4 : p.portClass === "regional" ? 0.8 : p.portClass ? 0.35 : 0; + const historyBoost = p.kind?.includes("Castle") ? 0.55 : p.kind === "Market Town" ? 0.42 : 0; + const gatewayBoost = p.kind === "External Gateway" ? 1.1 : 0; + return 0.35 + pop + capitalBoost + portBoost + historyBoost + gatewayBoost; + } + + function sameCorridorAffinity(a, b) { + const ai = indexOf(a.x, a.y); + const bi = indexOf(b.x, b.y); + return Math.min(0.6, (basinField[ai] + basinField[bi]) * 0.14 + (valleyField[ai] + valleyField[bi]) * 0.10 + (coastalLowland[ai] + coastalLowland[bi]) * 0.10); + } + + const roadTargetCandidates = [...modernCities.filter((p) => (p.population || 0) >= 90000), ...ports, ...markets, ...castles] + .map((p) => ({ ...p, demand: transportDemand(p), score: (p.score || 0.4) + transportDemand(p) * 0.24 + ((p.population || 0) >= 180000 ? 0.18 : 0.05) })); + const pickedRoadTargets = pickEntities(roadTargetCandidates, { max: 8 + Math.floor(rand(seed, 1101) * 10), minDistance: 9, threshold: 0, seed: seed + 1100, }); + const roadTargets = [ + capital, + ...pickedRoadTargets + .filter((p) => Math.hypot(p.x - capital.x, p.y - capital.y) > 2) + .sort((a, b) => transportDemand(b) - transportDemand(a)), + ]; const roadHubs = [...modernCities, ...ports, ...markets, ...stations]; const roadCore = [capital]; @@ -2511,12 +2135,24 @@ export function generateMap(seedInput = 114514) { const anchor = nearestConnectable(roadCore, target, roadDegree, 3) || capital; if (addNationalRoad(anchor, target)) roadCore.push(target); } - for (let i = 1; i < roadTargets.length - 1; i++) { - const a = roadTargets[i]; - const b = pickEntities(roadTargets.filter((p) => p !== a && getDegree(roadDegree, p) < 4).map((p) => ({ ...p, score: 1 / (1 + Math.hypot(p.x - a.x, p.y - a.y)) })), { max: 1, minDistance: 1, threshold: 0 })[0]; - const d = b ? Math.hypot(a.x - b.x, a.y - b.y) : 0; - if (b && d >= 18 && d < 52 && getDegree(roadDegree, a) < 4 && rand(seed, i + 1111) > 0.24) { - addNationalRoad(a, b); + const roadLinkCandidates = []; + for (let i = 0; i < roadTargets.length; i++) { + for (let j = i + 1; j < roadTargets.length; j++) { + const a = roadTargets[i]; + const b = roadTargets[j]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + if (d < 18 || d > 58) continue; + const demand = Math.sqrt(transportDemand(a) * transportDemand(b)); + roadLinkCandidates.push({ a, b, score: demand / (1 + d / 18) + sameCorridorAffinity(a, b) + hash2(a.x + b.x, a.y + b.y, seed + 1111) * 0.05 }); + } + } + roadLinkCandidates.sort((a, b) => b.score - a.score); + let extraRoadLinks = 0; + for (const link of roadLinkCandidates) { + if (extraRoadLinks >= 4) break; + if (getDegree(roadDegree, link.a) >= 4 || getDegree(roadDegree, link.b) >= 4) continue; + if (addNationalRoad(link.a, link.b)) { + extraRoadLinks++; } } @@ -3229,7 +2865,8 @@ export function generateMap(seedInput = 114514) { lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620); mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 260); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 180); - snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, 5); + 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); removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...satelliteCities], 360); mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, [...modernCities, ...satelliteCities], 220); const adminBorders = extractAdminBorderSegments(adminId, prefectureMask); @@ -3276,24 +2913,26 @@ export function generateMap(seedInput = 114514) { const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); + const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland }; + const usedNames = new Set(Object.values(CUSTOM_NAMES)); - villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed); - ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed); - crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed); - passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed); - markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed); - castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed); - castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed); - modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed); - stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed); - industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed); - interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed); - logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed); - satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed); - newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed); - castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed); - externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway"); - const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center"); + villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames); + ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames); + crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames); + passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames); + markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames); + castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames); + castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames); + modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames); + stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames); + industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames); + interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames); + logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames); + satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames); + newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames); + castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames); + externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames); + const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames); const entitiesForNames = [ ...modernCities, @@ -3311,7 +2950,7 @@ export function generateMap(seedInput = 114514) { ...externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - return { + return applyOutputOptions({ width: MAP_W, height: MAP_H, cellSize: CELL_SIZE, @@ -3327,6 +2966,7 @@ export function generateMap(seedInput = 114514) { floodplain, plain, agriculture, + settlementCluster, ridgeField, valleyField, basinField, @@ -3380,5 +3020,5 @@ export function generateMap(seedInput = 114514) { smallStreams, externalGateways, entitiesForNames, - }; + }, options); } diff --git a/placeNameContext.js b/placeNameContext.js new file mode 100644 index 0000000..61489b1 --- /dev/null +++ b/placeNameContext.js @@ -0,0 +1,59 @@ +import { MAP_H, MAP_W, indexOf, inside } from "./grid.js"; +import { hash2 } from "./random.js"; + +const CONTEXT_SUFFIXES = { + coastal: ["\u6d5c", "\u6e4a", "\u6e2f", "\u6d66", "\u6d25", "\u5d0e", "\u6e7e"], + river: ["\u5ddd", "\u702c", "\u6a4b", "\u6e21", "\u6cbc", "\u6ca2"], + plain: ["\u539f", "\u91ce", "\u7530", "\u91cc", "\u6751", "\u5e73"], + mountain: ["\u8c37", "\u5ce0", "\u5c3e\u6839", "\u5c71", "\u6ca2", "\u9e93"], + historic: ["\u57ce", "\u5e9c", "\u9928", "\u5bae", "\u753a"], + suburban: ["\u4e18", "\u53f0", "\u91ce", "\u539f", "\u65b0\u753a", "\u30f6\u4e18"], +}; + +function isNearSea(x, y, sea, radius = 3) { + if (!sea) return false; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = x + dx; + const ny = y + dy; + if (inside(nx, ny) && sea[indexOf(nx, ny)]) return true; + } + } + return false; +} + +export function chooseNameSuffixPool(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 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; +} + +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}`; +} diff --git a/qualityMetrics.js b/qualityMetrics.js new file mode 100644 index 0000000..d8dc82d --- /dev/null +++ b/qualityMetrics.js @@ -0,0 +1,141 @@ +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 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)); +} + +export function adminBoundaryMetrics(map) { + let borderEdges = 0; + let targetSum = 0; + let denseUrbanEdges = 0; + let rightAngleRuns = 0; + let voronoiLikeEdges = 0; + let lowScoreFlatEdges = 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.prefectureMask[i] || map.sea[i] || map.adminId[i] < 0) continue; + for (const [dx, dy] of [[1, 0], [0, 1]]) { + const ni = indexOf(x + dx, y + dy); + 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; + targetSum += edgeTarget; + 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]]; + const cb = map.adminCenters[map.adminId[ni]]; + if (ca && cb) { + const mx = x + dx * 0.5; + 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++; + } + 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++; + } + const sideA = indexOf(x + (dy ? 1 : 0), y + (dx ? 1 : 0)); + const sideB = indexOf(x - (dy ? 1 : 0), y - (dx ? 1 : 0)); + if (map.prefectureMask[sideA] && map.prefectureMask[sideB] && !map.sea[sideA] && !map.sea[sideB]) { + const turnA = map.adminId[sideA] !== map.adminId[i] && map.adminId[sideA] !== map.adminId[ni]; + const turnB = map.adminId[sideB] !== map.adminId[i] && map.adminId[sideB] !== map.adminId[ni]; + if ((turnA || turnB) && terrainBoundaryTargetForMetrics(map, i) < 0.46) rightAngleRuns++; + } + } + } + } + + const ids = new Set([...map.adminId].filter((id, i) => id >= 0 && map.prefectureMask[i] && !map.sea[i])); + const areaById = new Map(); + for (let i = 0; i < map.adminId.length; i++) { + if (map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0) areaById.set(map.adminId[i], (areaById.get(map.adminId[i]) || 0) + 1); + } + const areas = [...areaById.values()].sort((a, b) => a - b); + const medianArea = areas.length ? areas[Math.floor(areas.length / 2)] : 1; + const maxArea = areas.length ? areas[areas.length - 1] : 1; + let disconnectedMunicipalities = 0; + let maxComponents = 0; + const seen = new Uint8Array(MAP_W * MAP_H); + for (const id of ids) { + let comps = 0; + seen.fill(0); + for (let i = 0; i < map.adminId.length; i++) { + if (seen[i] || map.adminId[i] !== id || !map.prefectureMask[i] || map.sea[i]) continue; + comps++; + const queue = [i]; + seen[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 (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= MAP_W || ny >= MAP_H) continue; + const ni = indexOf(nx, ny); + if (seen[ni] || map.adminId[ni] !== id || !map.prefectureMask[ni] || map.sea[ni]) continue; + seen[ni] = 1; + queue.push(ni); + } + } + } + if (comps > 1) disconnectedMunicipalities++; + maxComponents = Math.max(maxComponents, comps); + } + + const centerValidCount = map.adminCenters.filter((center) => { + const i = indexOf(center.x, center.y); + return map.prefectureMask[i] && !map.sea[i] && map.adminId[i] >= 0; + }).length; + + return { + borderEdges, + avgTarget: borderEdges ? targetSum / borderEdges : 0, + denseUrbanRate: borderEdges ? denseUrbanEdges / borderEdges : 0, + rightAngleRate: borderEdges ? rightAngleRuns / borderEdges : 0, + voronoiLikeRate: borderEdges ? voronoiLikeEdges / borderEdges : 0, + lowScoreFlatRate: borderEdges ? lowScoreFlatEdges / borderEdges : 0, + areaDiversity: maxArea / Math.max(1, medianArea), + municipalityCount: ids.size, + disconnectedMunicipalities, + maxComponents, + centerValidRatio: map.adminCenters.length ? centerValidCount / map.adminCenters.length : 1, + }; +} + +export function majorCityCoreIntegrity(map) { + const majorCities = map.modernCities.filter((city) => (city.population || 0) >= 180000); + if (majorCities.length === 0) return 1; + let sum = 0; + let checked = 0; + for (const city of majorCities) { + const counts = new Map(); + const r = Math.ceil(Math.max(3, city.coreRadius || 4)); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H || Math.hypot(dx, dy) > r) continue; + const i = indexOf(x, y); + if (!map.prefectureMask[i] || map.sea[i]) continue; + if (map.landuse[i] !== 3 && map.populationDensity[i] < 0.38) continue; + const id = map.adminId[i]; + if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1); + } + } + const total = [...counts.values()].reduce((a, b) => a + b, 0); + if (total === 0) continue; + sum += Math.max(...counts.values()) / total; + checked++; + } + return checked ? sum / checked : 1; +} diff --git a/random.js b/random.js new file mode 100644 index 0000000..d657951 --- /dev/null +++ b/random.js @@ -0,0 +1,51 @@ +import { clamp } from "./grid.js"; + +export function hash2(x, y, seed) { + let h = Math.imul((x | 0) ^ (seed | 0), 374761393) + Math.imul((y | 0) ^ ((seed >>> 1) | 0), 668265263); + h = (h ^ (h >>> 13)) >>> 0; + h = Math.imul(h, 1274126177) >>> 0; + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; +} + +export function rand(seed, n) { + return hash2(n * 7919 + 17, n * 104729 + 31, seed); +} + +export function smoothstep(t) { + t = clamp(t); + return t * t * (3 - 2 * t); +} + +export function lerp(a, b, t) { + return a + (b - a) * t; +} + +export function valueNoise(x, y, seed, scale) { + const sx = x / scale; + const sy = y / scale; + const x0 = Math.floor(sx); + const y0 = Math.floor(sy); + const tx = smoothstep(sx - x0); + const ty = smoothstep(sy - y0); + + const a = hash2(x0, y0, seed); + const b = hash2(x0 + 1, y0, seed); + const c = hash2(x0, y0 + 1, seed); + const d = hash2(x0 + 1, y0 + 1, seed); + + return lerp(lerp(a, b, tx), lerp(c, d, tx), ty); +} + +export function fbm(x, y, seed) { + let amp = 1; + let scale = 54; + let sum = 0; + let norm = 0; + for (let i = 0; i < 5; i++) { + sum += valueNoise(x, y, seed + i * 101, scale) * amp; + norm += amp; + amp *= 0.5; + scale *= 0.5; + } + return sum / norm; +} diff --git a/renderer.js b/renderer.js index a687b11..9a25052 100644 --- a/renderer.js +++ b/renderer.js @@ -1,8 +1,5 @@ import { MAP_W, MAP_H, CELL_SIZE, indexOf } from "./mapGenerator.js"; - -function clamp(v, a = 0, b = 1) { - return Math.max(a, Math.min(b, v)); -} +import { clamp } from "./grid.js"; function distToNearest(points, x, y, fallback = 999) { let best = fallback; @@ -197,12 +194,73 @@ function drawPath(ctx, path, color, width, dashed = false) { ctx.restore(); } -function drawSegments(ctx, segments, color, width, dashed = false) { +function segmentPointKey(p) { + return `${p[0]},${p[1]}`; +} + +function chainSegments(segments) { + const unused = segments.map((seg) => [seg[0], seg[1]]); + const chains = []; + while (unused.length) { + const chain = unused.pop(); + let grew = true; + while (grew) { + grew = false; + const head = segmentPointKey(chain[0]); + const tail = segmentPointKey(chain[chain.length - 1]); + for (let i = unused.length - 1; i >= 0; i--) { + const [a, b] = unused[i]; + const ak = segmentPointKey(a); + const bk = segmentPointKey(b); + if (ak === tail) { chain.push(b); unused.splice(i, 1); grew = true; break; } + if (bk === tail) { chain.push(a); unused.splice(i, 1); grew = true; break; } + if (bk === head) { chain.unshift(a); unused.splice(i, 1); grew = true; break; } + if (ak === head) { chain.unshift(b); unused.splice(i, 1); grew = true; break; } + } + } + chains.push(chain); + } + return chains; +} + +function chaikin(points, passes = 1) { + let out = points; + for (let pass = 0; pass < passes; pass++) { + if (out.length < 3) return out; + const next = [out[0]]; + for (let i = 0; i < out.length - 1; i++) { + const a = out[i]; + const b = out[i + 1]; + next.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); + next.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); + } + next.push(out[out.length - 1]); + out = next; + } + return out; +} + +function drawSegments(ctx, segments, color, width, dashed = false, smooth = false) { ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = width; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; if (dashed) ctx.setLineDash([4, 4]); + if (smooth) { + for (const chain of chainSegments(segments)) { + const points = chaikin(chain, 1); + if (points.length < 2) continue; + ctx.beginPath(); + ctx.moveTo(points[0][0] * CELL_SIZE, points[0][1] * CELL_SIZE); + for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0] * CELL_SIZE, points[i][1] * CELL_SIZE); + ctx.stroke(); + } + ctx.restore(); + return; + } + for (const seg of segments) { ctx.beginPath(); ctx.moveTo(seg[0][0] * CELL_SIZE, seg[0][1] * CELL_SIZE); @@ -375,13 +433,13 @@ function labelWithCollision(ctx, p, occupied) { return false; } -function drawLabels(ctx, points) { +function drawLabels(ctx, points, limit = Infinity) { const occupied = []; const prioritized = points .filter((p) => p?.name) - .map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 1000 + (p.kind === "Major Port" ? 160 : 0) + (p.kind === "Market Town" ? 55 : 0) + (p.kind?.includes("Castle") ? 45 : 0) })) + .map((p) => ({ ...p, labelPriority: (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) + (p.kind === "Market Town" ? 48 : 0) + (p.kind?.includes("Castle") ? 70 : 0) + (p.kind === "External Gateway" ? 60 : 0) })) .sort((a, b) => b.labelPriority - a.labelPriority); - for (const p of prioritized) labelWithCollision(ctx, p, occupied); + for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, occupied); } export function drawMap(canvas, map, options) { @@ -408,9 +466,9 @@ export function drawMap(canvas, map, options) { for (const path of map.mainRivers) drawPath(ctx, path, waterBlue, 3.2); drawHarborWorks(ctx, map); - if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, "rgba(95,95,95,0.30)", 1.0); - drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4); - drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05); + if (map.regionalPrefectureBorders) drawSegments(ctx, map.regionalPrefectureBorders, mode === "all" ? "rgba(95,95,95,0.18)" : "rgba(95,95,95,0.30)", 1.0, false, mode === "all"); + drawSegments(ctx, map.prefectureBorder, "rgba(30,30,30,0.82)", 2.4, false, true); + drawSegments(ctx, map.prefectureBorder, "rgba(255,255,255,0.74)", 1.05, false, true); if (!showFeatures) return; @@ -419,11 +477,11 @@ export function drawMap(canvas, map, options) { const showRoads = ["roads", "all", "development", "landuse"].includes(mode); const showAdmin = ["admin", "all"].includes(mode); - if (showAdmin) drawSegments(ctx, map.adminBorders, "rgba(120,120,120,0.65)", 1.3); + if (showAdmin) drawSegments(ctx, map.adminBorders, mode === "all" ? "rgba(120,120,120,0.28)" : "rgba(120,120,120,0.65)", mode === "all" ? 0.9 : 1.3); if (showHistory) { - for (const path of map.premodernRoads) drawPath(ctx, path, "rgba(150, 120, 90, 0.55)", 1.45, true); - for (const path of map.minorRoads) drawPath(ctx, path, "rgba(180, 150, 120, 0.62)", 1.05); + for (const path of map.premodernRoads) drawPath(ctx, path, mode === "all" ? "rgba(150, 120, 90, 0.34)" : "rgba(150, 120, 90, 0.55)", mode === "all" ? 1.15 : 1.45, true); + for (const path of map.minorRoads) drawPath(ctx, path, mode === "all" ? "rgba(180, 150, 120, 0.28)" : "rgba(180, 150, 120, 0.62)", mode === "all" ? 0.8 : 1.05); } if (showModern) { @@ -445,7 +503,7 @@ export function drawMap(canvas, map, options) { } if (showHistory) { - for (const p of map.villages) dot(ctx, p, 1.9, "rgba(120, 100, 80, 0.72)"); + for (const p of map.villages) dot(ctx, p, mode === "all" ? 1.35 : 1.9, mode === "all" ? "rgba(120, 100, 80, 0.42)" : "rgba(120, 100, 80, 0.72)"); for (const p of map.markets) dot(ctx, p, 4.8, "rgba(200, 130, 80, 0.95)"); for (const p of map.ports) { const color = p.portClass === "major" ? "rgba(40, 105, 190, 0.98)" : p.portClass === "regional" ? "rgba(70, 130, 200, 0.95)" : p.portClass === "lake" ? "rgba(80, 155, 180, 0.92)" : "rgba(95, 150, 195, 0.82)"; @@ -480,13 +538,13 @@ export function drawMap(canvas, map, options) { const important = [ ...map.modernCities, ...map.ports, - ...map.markets.slice(0, 10), - ...map.castles.slice(0, 8), + ...map.markets.slice(0, mode === "all" ? 6 : 10), + ...map.castles.slice(0, mode === "all" ? 5 : 8), ...(map.satelliteCities || []), ...map.newTowns, ...map.externalGateways, ].filter((p) => p.insidePrefecture || p.kind === "External Gateway"); - drawLabels(ctx, important); + drawLabels(ctx, important, mode === "all" ? 28 : Infinity); } } diff --git a/scoring.js b/scoring.js new file mode 100644 index 0000000..b4577c9 --- /dev/null +++ b/scoring.js @@ -0,0 +1,5 @@ +export function weightedScore(terms) { + let total = 0; + for (const [value, weight] of terms) total += value * weight; + return total; +} diff --git a/test.js b/test.js index 9afa705..7a70125 100644 --- a/test.js +++ b/test.js @@ -1,4 +1,6 @@ import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js"; +import { CUSTOM_NAMES } from "./names.js"; +import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js"; const result = document.getElementById("result"); const logLines = []; @@ -74,6 +76,48 @@ try { 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 coastalSuffixes = ["\u6d5c", "\u6e4a", "\u6e2f", "\u6d66", "\u6d25", "\u5d0e", "\u6e7e"]; + const mountainSuffixes = ["\u8c37", "\u5ce0", "\u5c3e\u6839", "\u5c71", "\u6ca2", "\u9e93"]; + const farInlandCoastalNames = allNameable.filter((p) => { + const i = indexOf(p.x, p.y); + return !p.portClass && (map.coastalLowland[i] || 0) < 0.12 && coastalSuffixes.some((suffix) => p.name?.endsWith(suffix)); + }).length; + const flatCoastalMountainNames = allNameable.filter((p) => { + const i = indexOf(p.x, p.y); + return (map.coastalLowland[i] || 0) > 0.35 && (map.slope[i] || 0) < 0.18 && mountainSuffixes.some((suffix) => p.name?.endsWith(suffix)); + }).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(map.elevation.length === size, "elevation length matches map size"); assert(map.sea.length === size, "sea length matches map size"); @@ -84,6 +128,7 @@ try { 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"); @@ -132,6 +177,17 @@ try { 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"); @@ -148,6 +204,17 @@ try { 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) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters"); + assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); + assert(farInlandCoastalNames <= Math.max(2, Math.ceil(allNameable.length * 0.12)), "coastal suffixes are not overused far inland"); + assert(flatCoastalMountainNames <= Math.max(2, Math.ceil(allNameable.length * 0.10)), "mountain suffixes are not overused on flat coastal lowlands"); + assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells"); + assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes"); + if (Object.keys(CUSTOM_NAMES).length > 0) { + assert(map.entitiesForNames.every((item) => !CUSTOM_NAMES[item.id] || item.name === CUSTOM_NAMES[item.id]), "CUSTOM_NAMES override generated names"); + } else { + assert(true, "CUSTOM_NAMES override hook remains available"); + } assert( map.adminCenters.length !== other.adminCenters.length || @@ -159,6 +226,27 @@ try { 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"); + + 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")}`;