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