Fix population aggregation system to eliminate double-counting

- Fixed core issue where cells.pop and burg.population were both being counted
- Changed aggregation logic across all modules to use either burg OR cell population, never both
- If cell has burg: count only burg population (represents all people in that area)
- If cell has no burg: count only cells.pop (represents scattered population)

Files modified:
- modules/burgs-and-states.js: Fixed state population aggregation
- modules/ui/provinces-editor.js: Fixed province population aggregation
- modules/dynamic/editors/cultures-editor.js: Fixed culture population aggregation
- modules/dynamic/editors/religions-editor.js: Fixed religion population aggregation
- modules/ui/biomes-editor.js: Fixed biome population aggregation
- modules/ui/zones-editor.js: Fixed zone population calculations (2 locations)
- modules/military-generator.js: Redesigned military generation to use only burg populations

Military system changes:
- Removed rural military generation (all forces now come from settlements)
- Only burgs with 500+ people can maintain military forces
- Military strength based on actual burg population (2.5% mobilization rate)

Result: Population totals now consistent across all CSV exports (~2M total vs previous 40x discrepancy)
This commit is contained in:
barrulus 2025-08-13 18:54:32 +01:00
parent 334ef2b58b
commit e669549390
18 changed files with 2960 additions and 297 deletions

View file

@ -7,17 +7,23 @@ window.BurgsAndStates = (() => {
cells.burg = new Uint16Array(n); // cell burg
const burgs = (pack.burgs = placeCapitals());
pack.states = createStates();
// Measure performance of each phase
const perf = window.PerformanceOptimizer || { measureTime: (name, fn) => fn() };
const burgs = (pack.burgs = perf.measureTime('burgGeneration', () => placeCapitals()));
pack.states = createStates();
perf.measureTime('burgGeneration', () => {
identifyLargePorts();
placeRegionalCenters();
placeTowns();
});
identifyLargePorts();
placeRegionalCenters();
placeTowns();
expandStates();
normalizeStates();
getPoles();
specifyBurgs();
perf.measureTime('burgGeneration', () => specifyBurgs());
collectStatistics();
assignColors();
@ -118,24 +124,24 @@ window.BurgsAndStates = (() => {
TIME && console.time("identifyLargePorts");
const {cells, features} = pack;
const temp = grid.cells.temp;
// Track primary population centers (capitals + large ports)
pack.primaryCenters = burgs.slice(1).map(b => b.i); // Start with capitals
const potentialPorts = [];
// Find potential large port locations
for (const b of burgs) {
if (!b.i || b.i === 0) continue; // Skip first element and undefined burgs
const i = b.cell;
const haven = cells.haven[i];
if (haven && temp[cells.g[i]] > 0) {
const f = cells.f[haven];
const waterBodySize = features[f].cells;
const harborQuality = cells.harbor[i];
// Large port criteria: large water body and good harbor
if (waterBodySize > 10 && harborQuality === 1) {
const portScore = waterBodySize * harborQuality + cells.s[i];
@ -143,20 +149,20 @@ window.BurgsAndStates = (() => {
}
}
}
// Sort by score and select best ports, ensuring spacing
potentialPorts.sort((a, b) => b.score - a.score);
const burgsTree = d3.quadtree();
// Add existing capitals to tree
for (const b of burgs) {
if (b.i && b.capital) {
burgsTree.add([b.x, b.y]);
}
}
const minPortSpacing = (graphWidth + graphHeight) / 8; // Minimum spacing between major ports
for (const portData of potentialPorts) {
const b = portData.burg;
if (burgsTree.find(b.x, b.y, minPortSpacing) === undefined) {
@ -166,7 +172,7 @@ window.BurgsAndStates = (() => {
burgsTree.add([b.x, b.y]);
}
}
TIME && console.timeEnd("identifyLargePorts");
}
@ -174,43 +180,43 @@ window.BurgsAndStates = (() => {
function placeRegionalCenters() {
TIME && console.time("placeRegionalCenters");
const {cells} = pack;
if (!pack.primaryCenters || pack.primaryCenters.length < 2) {
pack.regionalCenters = [];
TIME && console.timeEnd("placeRegionalCenters");
return;
}
pack.regionalCenters = [];
const primaryBurgs = pack.primaryCenters.map(id => burgs[id]);
// Calculate target number of regional centers
const targetRegionalCenters = Math.max(1, Math.floor(pack.primaryCenters.length * 0.6));
const score = new Int16Array(cells.s.map(s => s * (0.7 + Math.random() * 0.6))); // Regional center score
const candidates = cells.i
.filter(i => !cells.burg[i] && score[i] > 0 && cells.culture[i])
.sort((a, b) => score[b] - score[a]);
const burgsTree = d3.quadtree();
// Add primary centers to tree
primaryBurgs.forEach(b => burgsTree.add([b.x, b.y]));
const minPrimaryDistance = (graphWidth + graphHeight) / 12; // Min distance from primary centers
const minRegionalSpacing = (graphWidth + graphHeight) / 20; // Min distance between regional centers
let placedRegional = 0;
for (const cell of candidates) {
if (placedRegional >= targetRegionalCenters) break;
const [x, y] = cells.p[cell];
// Check distance from primary centers (should be far enough to be useful)
const nearestPrimary = burgsTree.find(x, y, minPrimaryDistance);
if (nearestPrimary) continue;
// Check distance from other regional centers
let tooClose = false;
for (const regionalId of pack.regionalCenters) {
@ -222,151 +228,318 @@ window.BurgsAndStates = (() => {
}
}
if (tooClose) continue;
// Create regional center burg
const burg = burgs.length;
const culture = cells.culture[cell];
const name = Names.getCulture(culture);
burgs.push({
cell, x, y,
state: 0,
i: burg,
culture,
name,
capital: 0,
cell, x, y,
state: 0,
i: burg,
culture: culture,
name,
capital: 0,
feature: cells.f[cell],
isRegionalCenter: true,
guaranteedPlaza: true
});
cells.burg[cell] = burg;
pack.regionalCenters.push(burg);
placedRegional++;
}
TIME && console.timeEnd("placeRegionalCenters");
}
// place secondary settlements based on hierarchical population distribution
function placeTowns() {
TIME && console.time("placeTowns");
// Helper function to calculate distance-based population multiplier
const getPopulationMultiplier = (cellId) => {
const [x, y] = cells.p[cellId];
let minDistanceToPrimary = Infinity;
let minDistanceToRegional = Infinity;
// Find distance to nearest primary center (capital or large port)
if (pack.primaryCenters) {
for (const primaryId of pack.primaryCenters) {
const primary = burgs[primaryId];
if (primary) {
const distance = Math.sqrt((x - primary.x) ** 2 + (y - primary.y) ** 2);
minDistanceToPrimary = Math.min(minDistanceToPrimary, distance);
}
}
}
// Find distance to nearest regional center
if (pack.regionalCenters) {
for (const regionalId of pack.regionalCenters) {
const regional = burgs[regionalId];
if (regional) {
const distance = Math.sqrt((x - regional.x) ** 2 + (y - regional.y) ** 2);
minDistanceToRegional = Math.min(minDistanceToRegional, distance);
}
}
}
// Calculate influence from primary centers (stronger influence)
const primaryInfluence = minDistanceToPrimary === Infinity ? 0 :
Math.max(0, 1 - (minDistanceToPrimary / ((graphWidth + graphHeight) / 4)));
// Calculate influence from regional centers (medium influence)
const regionalInfluence = minDistanceToRegional === Infinity ? 0 :
Math.max(0, 0.6 - (minDistanceToRegional / ((graphWidth + graphHeight) / 6)));
// Combine influences with primary having more weight
const combinedInfluence = Math.max(primaryInfluence * 1.0, regionalInfluence * 0.7);
// Return multiplier between 0.3 and 1.5
return 0.3 + combinedInfluence * 1.2;
};
// Calculate hierarchical score for each cell
const baseScore = cells.s.map(s => s * gauss(1, 3, 0, 20, 3));
const hierarchicalScore = new Float32Array(cells.i.length);
for (const i of cells.i) {
if (cells.burg[i] || !cells.culture[i] || baseScore[i] <= 0) {
hierarchicalScore[i] = 0;
continue;
}
const populationMultiplier = getPopulationMultiplier(i);
hierarchicalScore[i] = baseScore[i] * populationMultiplier;
}
const sorted = cells.i
.filter(i => hierarchicalScore[i] > 0)
.sort((a, b) => hierarchicalScore[b] - hierarchicalScore[a]);
// Place hamlets - smallest settlements (10-50 pop)
function placeHamlets() {
TIME && console.time("placeHamlets");
const desiredNumber =
manorsInput.value == 100000
? rn(sorted.length / 5 / (grid.points.length / 10000) ** 0.8)
: manorsInput.valueAsNumber;
const burgsNumber = Math.min(desiredNumber, sorted.length);
let burgsAdded = 0;
const score = new Int16Array(cells.s.map(s => s * gauss(0.8, 1.2, 0, 10, 2)));
const candidates = cells.i
.filter(i => !cells.burg[i] && score[i] > 0 && cells.culture[i])
.sort((a, b) => score[b] - score[a]);
// Calculate desired number of hamlets (should be ~60% of all settlements)
const totalSettlements = manorsInput.value == 100000
? rn(candidates.length / 5 / (grid.points.length / 10000) ** 0.8)
: manorsInput.valueAsNumber;
const hamletCount = Math.floor(totalSettlements * 0.6);
const burgsTree = burgs[0];
let spacing = (graphWidth + graphHeight) / 150 / (burgsNumber ** 0.7 / 66);
// Add existing burgs to tree for spacing calculations
const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000); // Normalize to 1000x1000 map
let spacing = 3 * mapScale; // 1-3 km spacing scaled to map size
let hamletsAdded = 0;
while (hamletsAdded < hamletCount && spacing > 0.5) {
for (let i = 0; hamletsAdded < hamletCount && i < candidates.length; i++) {
const cell = candidates[i];
const [x, y] = cells.p[cell];
// Get cultural modifiers
const culture = pack.cultures[cells.culture[cell]];
let culturalSpacingModifier = 1;
if (culture && culture.settlementPattern) {
// Adjust spacing based on cultural settlement patterns
switch(culture.settlementPattern) {
case "dispersed": culturalSpacingModifier = 1.5; break; // Nomadic - wider spacing
case "scattered": culturalSpacingModifier = 1.3; break; // Hunting - scattered
case "coastal": // Naval cultures cluster near coasts
if (cells.t[cell] === 1) culturalSpacingModifier = 0.7;
else culturalSpacingModifier = 1.2;
break;
case "linear": // River cultures follow waterways
if (cells.r[cell]) culturalSpacingModifier = 0.6;
break;
case "valley": // Highland cultures in valleys
if (cells.h[cell] > 44 && cells.h[cell] < 62) culturalSpacingModifier = 0.7;
break;
case "lakeside": // Lake cultures near water
if (cells.haven[cell]) culturalSpacingModifier = 0.7;
break;
default: culturalSpacingModifier = 1; // Clustered/Generic
}
}
// Biome-based spacing modifier
const biome = cells.biome[cell];
let biomeModifier = 1;
if (biome === 6 || biome === 8) biomeModifier = 0.5; // Fertile regions
else if (cells.h[cell] > 50) biomeModifier = 2; // Mountains
const adjustedSpacing = spacing * biomeModifier * culturalSpacingModifier * gauss(1, 0.2, 0.5, 1.5, 2);
if (burgsTree.find(x, y, adjustedSpacing) !== undefined) continue;
const burg = burgs.length;
const cultureId = cells.culture[cell];
const name = Names.getCulture(cultureId);
burgs.push({
cell, x, y,
state: 0,
i: burg,
culture: cultureId,
name,
capital: 0,
feature: cells.f[cell],
settlementType: "hamlet",
basePopulation: gauss(30, 20, 10, 50, 2) // 10-50 population
});
burgsTree.add([x, y]);
cells.burg[cell] = burg;
hamletsAdded++;
}
spacing *= 0.8;
}
TIME && console.timeEnd("placeHamlets");
return hamletsAdded;
}
// Place small villages (50-500 pop)
function placeSmallVillages() {
TIME && console.time("placeSmallVillages");
const score = new Int16Array(cells.s.map(s => s * gauss(1, 1.5, 0, 15, 2)));
const candidates = cells.i
.filter(i => !cells.burg[i] && score[i] > 0 && cells.culture[i])
.sort((a, b) => score[b] - score[a]);
// Small villages should be ~20% of settlements
const totalSettlements = manorsInput.value == 100000
? rn(candidates.length / 5 / (grid.points.length / 10000) ** 0.8)
: manorsInput.valueAsNumber;
const villageCount = Math.floor(totalSettlements * 0.2);
const burgsTree = burgs[0];
const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000);
let spacing = 6 * mapScale; // 3-6 km spacing
let villagesAdded = 0;
while (villagesAdded < villageCount && spacing > 1) {
for (let i = 0; villagesAdded < villageCount && i < candidates.length; i++) {
const cell = candidates[i];
const [x, y] = cells.p[cell];
// Biome and river modifiers
const biome = cells.biome[cell];
let modifier = 1;
if (biome === 6 || biome === 8) modifier = 0.7; // Fertile
if (cells.r[cell]) modifier *= 0.8; // Near river
if (cells.h[cell] > 50) modifier = 1.5; // Mountains
const adjustedSpacing = spacing * modifier * gauss(1, 0.25, 0.5, 1.5, 2);
if (burgsTree.find(x, y, adjustedSpacing) !== undefined) continue;
const burg = burgs.length;
const cultureId = cells.culture[cell];
const name = Names.getCulture(cultureId);
burgs.push({
cell, x, y,
state: 0,
i: burg,
culture: cultureId,
name,
capital: 0,
feature: cells.f[cell],
settlementType: "smallVillage",
basePopulation: gauss(275, 225, 50, 500, 2) // 50-500 population
});
burgsTree.add([x, y]);
cells.burg[cell] = burg;
villagesAdded++;
}
spacing *= 0.8;
}
TIME && console.timeEnd("placeSmallVillages");
return villagesAdded;
}
// Place large villages (200-1000 pop)
function placeLargeVillages() {
TIME && console.time("placeLargeVillages");
const score = new Int16Array(cells.s.map(s => s * gauss(1.2, 2, 0, 20, 3)));
const candidates = cells.i
.filter(i => !cells.burg[i] && score[i] > 0 && cells.culture[i])
.sort((a, b) => score[b] - score[a]);
// Large villages should be ~12% of settlements
const totalSettlements = manorsInput.value == 100000
? rn(candidates.length / 5 / (grid.points.length / 10000) ** 0.8)
: manorsInput.valueAsNumber;
const villageCount = Math.floor(totalSettlements * 0.12);
const burgsTree = burgs[0];
const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000);
let spacing = 12 * mapScale; // 8-12 km spacing
let villagesAdded = 0;
while (villagesAdded < villageCount && spacing > 2) {
for (let i = 0; villagesAdded < villageCount && i < candidates.length; i++) {
const cell = candidates[i];
const [x, y] = cells.p[cell];
const adjustedSpacing = spacing * gauss(1, 0.3, 0.5, 1.5, 2);
if (burgsTree.find(x, y, adjustedSpacing) !== undefined) continue;
const burg = burgs.length;
const cultureId = cells.culture[cell];
const name = Names.getCulture(cultureId);
burgs.push({
cell, x, y,
state: 0,
i: burg,
culture: cultureId,
name,
capital: 0,
feature: cells.f[cell],
settlementType: "largeVillage",
basePopulation: gauss(600, 400, 200, 1000, 2) // 200-1000 population
});
burgsTree.add([x, y]);
cells.burg[cell] = burg;
villagesAdded++;
}
spacing *= 0.8;
}
TIME && console.timeEnd("placeLargeVillages");
return villagesAdded;
}
// Place market towns (1000-10000 pop)
function placeMarketTowns() {
TIME && console.time("placeMarketTowns");
const score = new Int16Array(cells.s.map(s => s * gauss(1.5, 3, 0, 25, 3)));
const candidates = cells.i
.filter(i => !cells.burg[i] && score[i] > 0 && cells.culture[i])
.sort((a, b) => score[b] - score[a]);
// Market towns should be ~7% of settlements
const totalSettlements = manorsInput.value == 100000
? rn(candidates.length / 5 / (grid.points.length / 10000) ** 0.8)
: manorsInput.valueAsNumber;
const townCount = Math.floor(totalSettlements * 0.07);
const burgsTree = burgs[0];
const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000);
let spacing = 25 * mapScale; // 15-30 km spacing (market day walking distance)
let townsAdded = 0;
// Add existing burgs to tree
for (let i = 1; i < burgs.length; i++) {
if (burgs[i] && burgs[i].x !== undefined) {
burgsTree.add([burgs[i].x, burgs[i].y]);
}
}
while (burgsAdded < burgsNumber && spacing > 1) {
for (let i = 0; burgsAdded < burgsNumber && i < sorted.length; i++) {
if (cells.burg[sorted[i]]) continue;
const cell = sorted[i];
while (townsAdded < townCount && spacing > 5) {
for (let i = 0; townsAdded < townCount && i < candidates.length; i++) {
const cell = candidates[i];
const [x, y] = cells.p[cell];
// Adjust spacing based on hierarchy - closer spacing near population centers
const populationMultiplier = getPopulationMultiplier(cell);
const adjustedSpacing = spacing * gauss(1, 0.3, 0.2, 2, 2) * (2 - populationMultiplier);
const adjustedSpacing = spacing * gauss(1, 0.3, 0.7, 1.3, 2);
if (burgsTree.find(x, y, adjustedSpacing) !== undefined) continue;
const burg = burgs.length;
const culture = cells.culture[cell];
const name = Names.getCulture(culture);
const cultureId = cells.culture[cell];
const name = Names.getCulture(cultureId);
burgs.push({
cell, x, y,
state: 0,
i: burg,
culture,
name,
capital: 0,
cell, x, y,
state: 0,
i: burg,
culture: cultureId,
name,
capital: 0,
feature: cells.f[cell],
hierarchicalScore: hierarchicalScore[cell]
settlementType: "marketTown",
basePopulation: gauss(5500, 4500, 1000, 10000, 2), // 1000-10000 population
plaza: 1 // Market towns always have market squares
});
burgsTree.add([x, y]);
cells.burg[cell] = burg;
burgsAdded++;
townsAdded++;
}
spacing *= 0.5;
}
if (manorsInput.value != 1000 && burgsAdded < desiredNumber) {
ERROR && console.error(`Cannot place all burgs. Requested ${desiredNumber}, placed ${burgsAdded}`);
spacing *= 0.8;
}
burgs[0] = {name: undefined};
TIME && console.timeEnd("placeMarketTowns");
return townsAdded;
}
// Modified placeTowns to call the tiered functions
function placeTowns() {
TIME && console.time("placeTowns");
// Place settlements in hierarchical order
const hamletsPlaced = placeHamlets();
const smallVillagesPlaced = placeSmallVillages();
const largeVillagesPlaced = placeLargeVillages();
const marketTownsPlaced = placeMarketTowns();
const totalPlaced = hamletsPlaced + smallVillagesPlaced + largeVillagesPlaced + marketTownsPlaced;
INFO && console.info(`Settlements placed: ${totalPlaced} total`);
INFO && console.info(`- Hamlets (10-50 pop): ${hamletsPlaced}`);
INFO && console.info(`- Small villages (50-500 pop): ${smallVillagesPlaced}`);
INFO && console.info(`- Large villages (200-1000 pop): ${largeVillagesPlaced}`);
INFO && console.info(`- Market towns (1000-10000 pop): ${marketTownsPlaced}`);
TIME && console.timeEnd("placeTowns");
}
};
@ -390,25 +563,28 @@ window.BurgsAndStates = (() => {
b.port = port ? f : 0; // port is defined by water body id it lays on
} else b.port = 0;
// calculate hierarchical population based on burg type and position
let basePopulation = Math.max(cells.s[i] / 8 + b.i / 1000 + (i % 100) / 1000, 0.1);
// Apply hierarchical multipliers
if (b.capital) {
basePopulation *= 1.8; // Capitals are major population centers
// Use settlement type-based population if available (from new tiered system)
let basePopulation;
if (b.basePopulation) {
// New tiered settlements have predefined base populations
basePopulation = b.basePopulation / 1000; // Convert to thousands for consistency
} else if (b.capital) {
// Capitals: major cities (10,000-200,000)
basePopulation = gauss(50, 75, 10, 200, 2);
} else if (b.isLargePort) {
basePopulation *= 1.6; // Large ports are significant population centers
} else if (b.isRegionalCenter) {
basePopulation *= 1.3; // Regional centers have elevated population
} else if (b.hierarchicalScore) {
// Use the hierarchical score calculated during placement for population gradient
const maxHierarchicalScore = Math.max(...pack.burgs.filter(burg => burg.hierarchicalScore).map(burg => burg.hierarchicalScore));
if (maxHierarchicalScore > 0) {
const hierarchicalMultiplier = 0.7 + (b.hierarchicalScore / maxHierarchicalScore) * 0.6;
basePopulation *= hierarchicalMultiplier;
}
// Large ports: significant cities (5,000-50,000)
basePopulation = gauss(20, 30, 5, 50, 2);
} else if (b.isRegionalCenter || b.guaranteedPlaza) {
// Regional centers: market towns (1,000-10,000)
basePopulation = gauss(5.5, 4.5, 1, 10, 2);
} else {
// Default: scale down significantly for medieval demographics
// Most settlements should be under 100 people
const cellScore = Math.max(cells.s[i] / 80, 0.01); // Reduced from /8 to /80
basePopulation = cellScore * gauss(0.05, 0.045, 0.01, 0.1, 2); // 10-100 people for most
}
b.population = rn(basePopulation, 3);
if (b.port) {
@ -420,8 +596,12 @@ window.BurgsAndStates = (() => {
b.y = y;
}
// add random factor (reduced to maintain hierarchy)
b.population = rn(b.population * gauss(1.8, 2.5, 0.7, 15, 2.5), 3);
// Apply minor random variation while maintaining hierarchy
// Much reduced from original to preserve medieval demographics
if (!b.basePopulation) {
// Only apply variation to non-tiered settlements
b.population = rn(b.population * gauss(1, 0.2, 0.8, 1.2, 3), 3);
}
// shift burgs on rivers semi-randomly and just a bit
if (!b.port && cells.r[i]) {
@ -432,17 +612,34 @@ window.BurgsAndStates = (() => {
else b.y = rn(b.y - shift, 2);
}
// define emblem
const state = pack.states[b.state];
const stateCOA = state.coa;
let kinship = 0.25;
if (b.capital) kinship += 0.1;
else if (b.port) kinship -= 0.1;
if (b.culture !== state.culture) kinship -= 0.25;
b.type = getType(i, b.port);
const type = b.capital && P(0.2) ? "Capital" : b.type === "Generic" ? "City" : b.type;
b.coa = COA.generate(stateCOA, kinship, null, type);
b.coa.shield = COA.getShield(b.culture, b.state);
// define emblem - only for settlements with 500+ population (0.5 in thousands)
// Small hamlets and tiny villages don't have coats of arms
if (b.population >= 0.5 || b.capital || b.port) {
const state = pack.states[b.state];
const stateCOA = state.coa;
let kinship = 0.25;
if (b.capital) kinship += 0.1;
else if (b.port) kinship -= 0.1;
if (b.culture !== state.culture) kinship -= 0.25;
b.type = getType(i, b.port);
const type = b.capital && P(0.2) ? "Capital" : b.type === "Generic" ? "City" : b.type;
// Use performance optimizer for COA generation if available
const perf = window.PerformanceOptimizer;
if (perf) {
perf.measureTime('coaGeneration', () => {
b.coa = COA.generate(stateCOA, kinship, null, type);
b.coa.shield = COA.getShield(b.culture, b.state);
});
} else {
b.coa = COA.generate(stateCOA, kinship, null, type);
b.coa.shield = COA.getShield(b.culture, b.state);
}
} else {
// No COA for tiny settlements
b.type = getType(i, b.port);
b.coa = null;
}
}
// de-assign port status if it's the only one on feature
@ -495,6 +692,80 @@ window.BurgsAndStates = (() => {
return "Generic";
};
// Assign economic features based on strategic location
const assignEconomicFeatures = burg => {
const {cells, routes} = pack;
const cellId = burg.cell;
// Trading Post: Located at river crossings, mountain passes, or route intersections
burg.tradingPost = 0;
burg.seasonalFair = 0;
// Check if at river crossing
const isRiverCrossing = cells.r[cellId] && Routes.isCrossroad(cellId);
// Check if at mountain pass (moderate elevation with routes)
const isMountainPass = cells.h[cellId] > 50 && cells.h[cellId] < 67 && Routes.hasRoad(cellId);
// Check if at route intersection
const isRouteHub = Routes.isCrossroad(cellId);
// Trading posts at strategic locations
if (isRiverCrossing || isMountainPass || isRouteHub) {
// Higher chance for larger settlements
let tradingPostChance = 0.2;
if (burg.settlementType === "marketTown" || burg.plaza === 1) tradingPostChance = 0.8;
else if (burg.settlementType === "largeVillage") tradingPostChance = 0.5;
else if (burg.settlementType === "smallVillage") tradingPostChance = 0.3;
burg.tradingPost = Number(P(tradingPostChance));
}
// Seasonal Fairs: Market towns and larger settlements
// Based on medieval Champagne fairs model - 6 major fairs rotating through the year
if (burg.settlementType === "marketTown" || burg.capital || burg.population > 5) {
let fairChance = 0.3;
if (burg.capital) fairChance = 0.7;
if (burg.population > 10) fairChance = 0.8;
if (burg.tradingPost) fairChance *= 1.2; // Trading posts more likely to have fairs
burg.seasonalFair = Number(P(Math.min(fairChance, 1)));
// Assign fair season if settlement has a fair
if (burg.seasonalFair) {
const seasons = ["Spring", "Summer", "Autumn", "Winter"];
const months = [
"Early Spring", "Mid Spring", "Late Spring",
"Early Summer", "Midsummer", "Late Summer",
"Early Autumn", "Harvest", "Late Autumn",
"Early Winter", "Midwinter", "Late Winter"
];
// Major fairs get specific months, smaller get seasons
if (burg.capital || burg.population > 15) {
burg.fairTime = ra(months);
} else {
burg.fairTime = ra(seasons);
}
}
}
// Port markets - enhanced maritime trade
if (burg.port) {
// All ports have some market activity
if (!burg.plaza) burg.plaza = Number(P(0.7));
// Major ports likely to have permanent markets and fairs
if (burg.isLargePort) {
burg.plaza = 1;
if (!burg.seasonalFair) {
burg.seasonalFair = Number(P(0.6));
if (burg.seasonalFair) burg.fairTime = "Maritime Trade Season";
}
}
}
};
const defineBurgFeatures = burg => {
const {cells} = pack;
@ -502,53 +773,84 @@ window.BurgsAndStates = (() => {
.filter(b => (burg ? b.i == burg.i : b.i && !b.removed && !b.lock))
.forEach(b => {
const pop = b.population;
// Citadel assignment - capitals and major centers get priority
b.citadel = Number(b.capital || b.isLargePort || (pop > 50 && P(0.75)) || (pop > 15 && P(0.5)) || P(0.1));
// Plaza assignment - ensure regional centers get plazas, scale with hierarchy
if (b.guaranteedPlaza || b.isRegionalCenter) {
b.plaza = 1; // Regional centers always get plazas
} else if (b.capital || b.isLargePort) {
b.plaza = Number(P(0.9)); // Primary centers very likely to have plazas
} else {
// Regular settlements based on population and proximity to centers
let plazaChance = 0.6;
if (pop > 20) plazaChance = 0.9;
else if (pop > 10) plazaChance = 0.8;
else if (pop > 4) plazaChance = 0.7;
// Reduce chance if far from any major center
if (b.hierarchicalScore) {
const maxScore = Math.max(...pack.burgs.filter(burg => burg.hierarchicalScore).map(burg => burg.hierarchicalScore));
if (maxScore > 0) {
const hierarchyFactor = b.hierarchicalScore / maxScore;
plazaChance *= (0.5 + hierarchyFactor * 0.5); // Scale with hierarchy
}
// Check for strategic economic locations
assignEconomicFeatures(b);
// Settlement type-based feature assignment for new tiered system
if (b.settlementType) {
switch(b.settlementType) {
case "hamlet":
b.citadel = 0;
b.plaza = Number(P(0.05)); // Very rare
b.walls = 0;
b.shanty = 0;
b.temple = Number(P(0.1)); // Small shrine maybe
break;
case "smallVillage":
b.citadel = Number(P(0.05));
b.plaza = Number(P(0.2)); // Some have small market areas
b.walls = Number(P(0.1)); // Rarely walled
b.shanty = 0;
b.temple = Number(P(0.3)); // Parish church
break;
case "largeVillage":
b.citadel = Number(P(0.1));
b.plaza = Number(P(0.5)); // Half have market squares
b.walls = Number(P(0.25)); // Some are walled
b.shanty = Number(P(0.05));
b.temple = Number(P(0.6)); // Most have churches
break;
case "marketTown":
b.citadel = Number(P(0.4));
b.plaza = 1; // All market towns have market squares
b.walls = Number(P(0.7)); // Most are walled
b.shanty = Number(P(0.2));
b.temple = Number(P(0.8)); // Most have significant churches
break;
}
b.plaza = Number(P(plazaChance));
} else {
// Original logic for non-tiered settlements
// Citadel assignment - capitals and major centers get priority
b.citadel = Number(b.capital || b.isLargePort || (pop > 50 && P(0.75)) || (pop > 15 && P(0.5)) || P(0.1));
// Plaza assignment - ensure regional centers get plazas, scale with hierarchy
if (b.guaranteedPlaza || b.isRegionalCenter || b.plaza === 1) {
b.plaza = 1; // Keep existing plazas and regional centers
} else if (b.capital || b.isLargePort) {
b.plaza = Number(P(0.9)); // Primary centers very likely to have plazas
} else {
// Adjusted for medieval scale populations
let plazaChance = 0.1;
if (pop > 10) plazaChance = 0.8;
else if (pop > 5) plazaChance = 0.6;
else if (pop > 1) plazaChance = 0.3;
else if (pop > 0.5) plazaChance = 0.15;
b.plaza = Number(P(plazaChance));
}
// Walls assignment - adjusted for medieval populations
b.walls = Number(b.capital || b.isLargePort || pop > 10 || (pop > 5 && P(0.6)) || (pop > 1 && P(0.3)) || P(0.05));
// Shanty assignment - adjusted for medieval populations
b.shanty = Number(pop > 20 || (pop > 10 && P(0.5)) || (pop > 5 && b.walls && P(0.2)));
// Temple assignment - influenced by hierarchy and theocracy
const religion = cells.religion[b.cell];
const theocracy = pack.states[b.state].form === "Theocracy";
let templeChance = 0;
if (religion && theocracy && P(0.5)) templeChance = 1;
else if (b.capital || b.isLargePort) templeChance = 0.8;
else if (b.isRegionalCenter) templeChance = 0.6;
else if (pop > 10) templeChance = 0.6;
else if (pop > 5) templeChance = 0.4;
else if (pop > 1) templeChance = 0.2;
else templeChance = 0.05;
b.temple = Number(P(templeChance));
}
// Walls assignment - hierarchy-aware
b.walls = Number(b.capital || b.isLargePort || pop > 30 || (pop > 20 && P(0.75)) || (pop > 10 && P(0.5)) || P(0.1));
// Shanty assignment - more common in larger population centers
b.shanty = Number(pop > 60 || (pop > 40 && P(0.75)) || (pop > 20 && b.walls && P(0.4)));
// Temple assignment - influenced by hierarchy and theocracy
const religion = cells.religion[b.cell];
const theocracy = pack.states[b.state].form === "Theocracy";
let templeChance = 0;
if (religion && theocracy && P(0.5)) templeChance = 1;
else if (b.capital || b.isLargePort) templeChance = 0.8;
else if (b.isRegionalCenter) templeChance = 0.6;
else if (pop > 50) templeChance = 0.7;
else if (pop > 35) templeChance = 0.5;
else if (pop > 20) templeChance = 0.3;
b.temple = Number(P(templeChance));
});
};
@ -723,10 +1025,13 @@ window.BurgsAndStates = (() => {
// collect stats
states[s].cells += 1;
states[s].area += cells.area[i];
states[s].rural += cells.pop[i];
if (cells.burg[i]) {
// Burg represents ALL population for this cell (stored in thousands)
states[s].urban += pack.burgs[cells.burg[i]].population;
states[s].burgs++;
} else {
// Only count cells.pop for unsettled areas (no burg present)
states[s].rural += cells.pop[i];
}
}

View file

@ -166,12 +166,40 @@ window.Cultures = (function () {
function defineCultureExpansionism(type) {
let base = 1; // Generic
if (type === "Lake") base = 0.8;
else if (type === "Naval") base = 1.5;
else if (type === "River") base = 0.9;
else if (type === "Nomadic") base = 1.5;
else if (type === "Hunting") base = 0.7;
else if (type === "Highland") base = 1.2;
let routeDensity = 1; // Route density modifier
let settlementPattern = "clustered"; // Settlement distribution pattern
// Define cultural characteristics that affect routes and settlements
if (type === "Lake") {
base = 0.8;
routeDensity = 0.9; // Moderate route density around lakes
settlementPattern = "lakeside";
} else if (type === "Naval") {
base = 1.5;
routeDensity = 1.3; // High route density for maritime trade
settlementPattern = "coastal";
} else if (type === "River") {
base = 0.9;
routeDensity = 1.2; // Dense routes along rivers
settlementPattern = "linear"; // Settlements follow river lines
} else if (type === "Nomadic") {
base = 1.5;
routeDensity = 0.5; // Few permanent routes
settlementPattern = "dispersed";
} else if (type === "Hunting") {
base = 0.7;
routeDensity = 0.6; // Minimal routes, mostly trails
settlementPattern = "scattered";
} else if (type === "Highland") {
base = 1.2;
routeDensity = 0.8; // Routes follow valleys
settlementPattern = "valley";
}
// Store additional cultural characteristics
cultures[cultures.length - 1].routeDensity = routeDensity;
cultures[cultures.length - 1].settlementPattern = settlementPattern;
return rn(((Math.random() * byId("sizeVariety").value) / 2 + 1) * base, 1);
}

View file

@ -109,9 +109,14 @@ function culturesCollectStatistics() {
const cultureId = cells.culture[i];
cultures[cultureId].cells += 1;
cultures[cultureId].area += cells.area[i];
cultures[cultureId].rural += cells.pop[i];
const burgId = cells.burg[i];
if (burgId) cultures[cultureId].urban += burgs[burgId].population;
if (burgId) {
// Burg represents ALL population for this cell (stored in thousands)
cultures[cultureId].urban += burgs[burgId].population;
} else {
// Only count cells.pop for unsettled areas (no burg present)
cultures[cultureId].rural += cells.pop[i];
}
}
}
@ -128,7 +133,7 @@ function culturesEditorAddLines() {
if (c.removed) continue;
const area = getArea(c.area);
const rural = c.rural * populationRate;
const urban = c.urban * populationRate * urbanization;
const urban = c.urban * 1000 * urbanization;
const population = rn(rural + urban);
const populationTip = `Total population: ${si(population)}. Rural population: ${si(rural)}. Urban population: ${si(
urban
@ -635,7 +640,7 @@ async function showHierarchy() {
const getDescription = culture => {
const {name, type, rural, urban} = culture;
const population = rural * populationRate + urban * populationRate * urbanization;
const population = rural * populationRate + urban * 1000 * urbanization;
const populationText = population > 0 ? si(rn(population)) + " people" : "Extinct";
return `${name} culture. ${type}. ${populationText}`;
};

View file

@ -119,9 +119,14 @@ function religionsCollectStatistics() {
const religionId = cells.religion[i];
religions[religionId].cells += 1;
religions[religionId].area += cells.area[i];
religions[religionId].rural += cells.pop[i];
const burgId = cells.burg[i];
if (burgId) religions[religionId].urban += burgs[burgId].population;
if (burgId) {
// Burg represents ALL population for this cell (stored in thousands)
religions[religionId].urban += burgs[burgId].population;
} else {
// Only count cells.pop for unsettled areas (no burg present)
religions[religionId].rural += cells.pop[i];
}
}
}
@ -138,7 +143,7 @@ function religionsEditorAddLines() {
const area = getArea(r.area);
const rural = r.rural * populationRate;
const urban = r.urban * populationRate * urbanization;
const urban = r.urban * 1000 * urbanization;
const population = rn(rural + urban);
const populationTip = `Believers: ${si(population)}; Rural areas: ${si(rural)}; Urban areas: ${si(
urban
@ -610,7 +615,7 @@ async function showHierarchy() {
};
const formText = form === type ? "" : ". " + form;
const population = rural * populationRate + urban * populationRate * urbanization;
const population = rural * populationRate + urban * 1000 * urbanization;
const populationText = population > 0 ? si(rn(population)) + " people" : "Extinct";
return `${name}${getTypeText()}${formText}. ${populationText}`;

View file

@ -161,7 +161,7 @@ function statesEditorAddLines() {
if (s.removed) continue;
const area = getArea(s.area);
const rural = s.rural * populationRate;
const urban = s.urban * populationRate * urbanization;
const urban = s.urban * 1000 * urbanization;
const population = rn(rural + urban);
const populationTip = `Total population: ${si(population)}; Rural population: ${si(rural)}; Urban population: ${si(
urban
@ -1417,10 +1417,12 @@ function downloadStatesCsv() {
const headers = `Id,State,Full Name,Form,Color,Capital,Culture,Type,Expansionism,Cells,Burgs,Area ${unit},Total Population,Rural Population,Urban Population`;
const lines = Array.from($body.querySelectorAll(":scope > div"));
const data = lines.map($line => {
const {id, name, form, color, capital, culture, type, expansionism, cells, burgs, area, population} = $line.dataset;
const {id, name, form, color, capital, culture, type, expansionism, cells, burgs, area} = $line.dataset;
const {fullName = "", rural, urban} = pack.states[+id];
// Rural: convert abstract points to people, Urban: already in thousands so convert to people
const ruralPopulation = Math.round(rural * populationRate);
const urbanPopulation = Math.round(urban * populationRate * urbanization);
const urbanPopulation = Math.round(urban * 1000 * urbanization);
const totalPopulation = ruralPopulation + urbanPopulation; // Ensure total matches parts
return [
id,
name,
@ -1434,7 +1436,7 @@ function downloadStatesCsv() {
cells,
burgs,
area,
population,
totalPopulation, // Use calculated total instead of dataset.population
ruralPopulation,
urbanPopulation
].join(",");

View file

@ -136,9 +136,12 @@ window.Military = (function () {
return true;
}
// Rural military generation disabled - all military now comes from burgs only
/*
// rural cells
for (const i of cells.i) {
if (!cells.pop[i]) continue;
// Only generate rural regiments for cells without burgs (unsettled areas)
if (!cells.pop[i] || cells.burg[i]) continue;
const biome = cells.biome[i];
const state = cells.state[i];
@ -148,7 +151,10 @@ window.Military = (function () {
const stateObj = states[state];
if (!state || stateObj.removed) continue;
let modifier = cells.pop[i] / 100; // basic rural army in percentages
// Medieval military: typically 1-3% of population could be mobilized
// cells.pop is the rural population for this cell
// modifier represents the base military force from this cell
let modifier = cells.pop[i] / 50; // ~2% mobilization rate
if (culture !== stateObj.culture) modifier = stateObj.form === "Union" ? modifier / 1.2 : modifier / 2; // non-dominant culture
if (religion !== cells.religion[stateObj.center])
modifier = stateObj.form === "Theocracy" ? modifier / 2.2 : modifier / 1.4; // non-dominant religion
@ -164,7 +170,7 @@ window.Military = (function () {
const cellTypeMod = type === "generic" ? 1 : cellTypeModifier[type][unit.type]; // cell specific modifier
const army = modifier * perc * cellTypeMod; // rural cell army
const total = rn(army * stateObj.temp[unit.name] * populationRate); // total troops
const total = rn(army * stateObj.temp[unit.name]); // total troops - NO populationRate multiplier!
if (!total) continue;
let [x, y] = p[i];
@ -190,6 +196,7 @@ window.Military = (function () {
});
}
}
*/
// burgs
for (const b of pack.burgs) {
@ -201,7 +208,13 @@ window.Military = (function () {
const religion = cells.religion[b.cell];
const stateObj = states[state];
let m = (b.population * urbanization) / 100; // basic urban army in percentages
// Only burgs with significant population can maintain military forces
const actualPopulation = b.population * 1000; // Convert from thousands to actual people
if (actualPopulation < 500) continue; // Skip burgs under 500 people
// Medieval military: 2-3% mobilization rate for settlements
let m = actualPopulation / 40; // ~2.5% mobilization rate based on actual burg population
if (b.capital) m *= 1.2; // capital has household troops
if (culture !== stateObj.culture) m = stateObj.form === "Union" ? m / 1.2 : m / 2; // non-dominant culture
if (religion !== cells.religion[stateObj.center]) m = stateObj.form === "Theocracy" ? m / 2.2 : m / 1.4; // non-dominant religion
@ -212,11 +225,12 @@ window.Military = (function () {
const perc = +unit.urban;
if (isNaN(perc) || perc <= 0 || !stateObj.temp[unit.name]) continue;
if (!passUnitLimits(unit, biome, state, culture, religion)) continue;
if (unit.type === "naval" && (!b.port || !cells.haven[b.cell])) continue; // only ports create naval units
// Naval units only from significant ports
if (unit.type === "naval" && (!b.port || !cells.haven[b.cell] || b.population < 0.5)) continue;
const mod = type === "generic" ? 1 : burgTypeModifier[type][unit.type]; // cell specific modifier
const army = m * perc * mod; // urban cell army
const total = rn(army * stateObj.temp[unit.name] * populationRate); // total troops
const total = rn(army * stateObj.temp[unit.name]); // total troops - NO populationRate multiplier!
if (!total) continue;
let [x, y] = p[b.cell];
@ -243,7 +257,7 @@ window.Military = (function () {
}
}
const expected = 3 * populationRate; // expected regiment size
const expected = 300; // expected regiment size - realistic medieval unit (company/battalion)
const mergeable = (n0, n1) => (!n0.s && !n1.s) || n0.u === n1.u; // check if regiments can be merged
// get regiments for each state

View file

@ -0,0 +1,355 @@
"use strict";
window.PerformanceOptimizer = (function() {
// Performance monitoring
const metrics = {
burgGeneration: 0,
routeGeneration: 0,
coaGeneration: 0,
provinceGeneration: 0,
renderTime: 0
};
// Cache for expensive calculations
const cache = new Map();
const CACHE_SIZE_LIMIT = 1000;
// Spatial index for fast nearest neighbor queries
class SpatialIndex {
constructor() {
this.tree = null;
this.points = [];
}
build(points) {
this.points = points;
this.tree = d3.quadtree()
.x(d => d.x)
.y(d => d.y)
.addAll(points);
}
findWithin(x, y, radius) {
if (!this.tree) return [];
const results = [];
this.tree.visit((node, x1, y1, x2, y2) => {
if (!node.length) {
do {
const d = node.data;
const dx = d.x - x;
const dy = d.y - y;
if (dx * dx + dy * dy < radius * radius) {
results.push(d);
}
} while (node = node.next);
}
return x1 > x + radius || x2 < x - radius ||
y1 > y + radius || y2 < y - radius;
});
return results;
}
findNearest(x, y, maxDistance = Infinity) {
if (!this.tree) return null;
let closest = null;
let closestDistance = maxDistance * maxDistance;
this.tree.visit((node, x1, y1, x2, y2) => {
if (!node.length) {
do {
const d = node.data;
const dx = d.x - x;
const dy = d.y - y;
const dist = dx * dx + dy * dy;
if (dist < closestDistance) {
closest = d;
closestDistance = dist;
}
} while (node = node.next);
}
const dx = x < x1 ? x1 - x : x > x2 ? x - x2 : 0;
const dy = y < y1 ? y1 - y : y > y2 ? y - y2 : 0;
return dx * dx + dy * dy > closestDistance;
});
return closest;
}
}
// Lazy loading wrapper for expensive computations
class LazyProperty {
constructor(computeFn) {
this.computeFn = computeFn;
this.computed = false;
this.value = undefined;
}
get() {
if (!this.computed) {
this.value = this.computeFn();
this.computed = true;
}
return this.value;
}
reset() {
this.computed = false;
this.value = undefined;
}
}
// Cache management
function getCached(key, computeFn) {
if (cache.has(key)) {
return cache.get(key);
}
const value = computeFn();
// Limit cache size
if (cache.size >= CACHE_SIZE_LIMIT) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
cache.set(key, value);
return value;
}
function clearCache() {
cache.clear();
}
// Performance measurement helpers
function measureTime(name, fn) {
const start = performance.now();
const result = fn();
const duration = performance.now() - start;
metrics[name] = (metrics[name] || 0) + duration;
return result;
}
// Batch processing for large datasets
function processBatch(items, processFn, batchSize = 100, onProgress) {
return new Promise((resolve) => {
let index = 0;
const results = [];
function processNextBatch() {
const batch = items.slice(index, index + batchSize);
for (const item of batch) {
results.push(processFn(item));
}
index += batchSize;
if (onProgress) {
onProgress(Math.min(index / items.length, 1));
}
if (index < items.length) {
// Use requestIdleCallback if available, otherwise setTimeout
if (window.requestIdleCallback) {
requestIdleCallback(processNextBatch);
} else {
setTimeout(processNextBatch, 0);
}
} else {
resolve(results);
}
}
processNextBatch();
});
}
// Optimize burg feature assignment using lazy evaluation
function optimizeBurgFeatures(burgs) {
TIME && console.time("optimizeBurgFeatures");
for (const burg of burgs) {
if (!burg.i || burg.removed) continue;
// Convert expensive properties to lazy evaluation
if (!burg.lazyProperties) {
burg.lazyProperties = {};
// Trading post calculation - only compute when needed
burg.lazyProperties.tradingPost = new LazyProperty(() => {
const {cells} = pack;
const cellId = burg.cell;
const isRiverCrossing = cells.r[cellId] && Routes.isCrossroad && Routes.isCrossroad(cellId);
const isMountainPass = cells.h[cellId] > 50 && cells.h[cellId] < 67 && Routes.hasRoad && Routes.hasRoad(cellId);
const isRouteHub = Routes.isCrossroad && Routes.isCrossroad(cellId);
if (isRiverCrossing || isMountainPass || isRouteHub) {
let chance = 0.2;
if (burg.settlementType === "marketTown" || burg.plaza === 1) chance = 0.8;
else if (burg.settlementType === "largeVillage") chance = 0.5;
else if (burg.settlementType === "smallVillage") chance = 0.3;
return Number(P(chance));
}
return 0;
});
// Seasonal fair calculation
burg.lazyProperties.seasonalFair = new LazyProperty(() => {
if (burg.settlementType === "marketTown" || burg.capital || burg.population > 5) {
let fairChance = 0.3;
if (burg.capital) fairChance = 0.7;
if (burg.population > 10) fairChance = 0.8;
if (burg.tradingPost) fairChance *= 1.2;
if (P(Math.min(fairChance, 1))) {
const seasons = ["Spring", "Summer", "Autumn", "Winter"];
const months = [
"Early Spring", "Mid Spring", "Late Spring",
"Early Summer", "Midsummer", "Late Summer",
"Early Autumn", "Harvest", "Late Autumn",
"Early Winter", "Midwinter", "Late Winter"
];
burg.fairTime = (burg.capital || burg.population > 15) ? ra(months) : ra(seasons);
return 1;
}
}
return 0;
});
}
}
TIME && console.timeEnd("optimizeBurgFeatures");
}
// Optimized route generation using spatial indexing
function createOptimizedRouteFinder() {
const burgIndex = new SpatialIndex();
return {
initialize(burgs) {
const burgPoints = burgs
.filter(b => b.i && !b.removed)
.map(b => ({x: b.x, y: b.y, id: b.i, data: b}));
burgIndex.build(burgPoints);
},
findNearbyBurgs(x, y, radius) {
return burgIndex.findWithin(x, y, radius).map(p => p.data);
},
findNearestBurg(x, y, maxDistance) {
const result = burgIndex.findNearest(x, y, maxDistance);
return result ? result.data : null;
}
};
}
// Progressive rendering for large datasets
async function renderProgressive(elements, renderFn, options = {}) {
const {
batchSize = 50,
priority = 'high', // 'high', 'medium', 'low'
onProgress = null,
container = null
} = options;
// Sort elements by priority (capitals first, then by population)
const sorted = [...elements].sort((a, b) => {
if (a.capital && !b.capital) return -1;
if (!a.capital && b.capital) return 1;
if (a.population && b.population) return b.population - a.population;
return 0;
});
// Render high-priority items immediately
const highPriority = sorted.filter(e =>
e.capital || e.population > 10 || e.isLargePort
);
for (const element of highPriority) {
renderFn(element);
}
// Render remaining items progressively
const remaining = sorted.filter(e => !highPriority.includes(e));
if (remaining.length > 0) {
await processBatch(remaining, renderFn, batchSize, onProgress);
}
}
// Memory management
function optimizeMemory() {
// Clear unused properties from burgs
pack.burgs.forEach(b => {
if (!b.i || b.removed) return;
// Remove temporary properties
delete b._temp;
delete b._cache;
// Convert rarely-used properties to lazy evaluation
if (b.lazyProperties) {
// Reset lazy properties to free memory
Object.values(b.lazyProperties).forEach(prop => prop.reset());
}
});
// Clear cache
clearCache();
// Force garbage collection if available
if (window.gc) {
window.gc();
}
}
// Performance report
function getPerformanceReport() {
const report = {
metrics: {...metrics},
cacheSize: cache.size,
memory: performance.memory ? {
used: Math.round(performance.memory.usedJSHeapSize / 1048576) + ' MB',
total: Math.round(performance.memory.totalJSHeapSize / 1048576) + ' MB',
limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576) + ' MB'
} : 'Not available',
recommendations: []
};
// Add recommendations based on metrics
if (metrics.routeGeneration > 5000) {
report.recommendations.push('Consider reducing route density for better performance');
}
if (metrics.coaGeneration > 3000) {
report.recommendations.push('Many COAs generated - consider increasing population threshold');
}
if (cache.size > CACHE_SIZE_LIMIT * 0.9) {
report.recommendations.push('Cache is nearly full - consider clearing old entries');
}
return report;
}
// Export public API
return {
SpatialIndex,
LazyProperty,
getCached,
clearCache,
measureTime,
processBatch,
optimizeBurgFeatures,
createOptimizedRouteFinder,
renderProgressive,
optimizeMemory,
getPerformanceReport,
metrics
};
})();

View file

@ -51,24 +51,40 @@ window.Provinces = (function () {
.sort((a, b) => b.population * gauss(1, 0.2, 0.5, 1.5, 3) - a.population)
.sort((a, b) => b.capital - a.capital);
if (stateBurgs.length < 2) return; // at least 2 provinces are required
const provincesNumber = Math.max(Math.ceil((stateBurgs.length * provincesRatio) / 100), 2);
// Cap provinces based on state size and importance, not total burgs
// Use only major settlements (capitals, market towns, large villages) as province centers
const majorBurgs = stateBurgs.filter(b =>
b.capital ||
b.settlementType === "marketTown" ||
b.settlementType === "largeVillage" ||
b.isRegionalCenter ||
b.population > 1 // population in thousands
);
// If not enough major burgs, use the most populous ones
const provinceCenters = majorBurgs.length >= 2 ? majorBurgs : stateBurgs.slice(0, Math.min(20, stateBurgs.length));
// Reasonable number of provinces: 2-20 based on ratio
const targetProvinces = Math.max(2, Math.min(20, Math.ceil(provinceCenters.length * provincesRatio / 100)));
const provincesNumber = Math.min(targetProvinces, provinceCenters.length);
const form = Object.assign({}, forms[s.form]);
for (let i = 0; i < provincesNumber; i++) {
const provinceId = provinces.length;
const center = stateBurgs[i].cell;
const burg = stateBurgs[i].i;
const c = stateBurgs[i].culture;
const center = provinceCenters[i].cell;
const burg = provinceCenters[i].i;
const c = provinceCenters[i].culture;
const nameByBurg = P(0.5);
const name = nameByBurg ? stateBurgs[i].name : Names.getState(Names.getCultureShort(c), c);
const name = nameByBurg ? provinceCenters[i].name : Names.getState(Names.getCultureShort(c), c);
const formName = rw(form);
form[formName] += 10;
const fullName = name + " " + formName;
const color = getMixedColor(s.color);
const kinship = nameByBurg ? 0.8 : 0.4;
const type = BurgsAndStates.getType(center, burg.port);
const coa = COA.generate(stateBurgs[i].coa, kinship, null, type);
const type = BurgsAndStates.getType(center, provinceCenters[i].port);
const coa = COA.generate(provinceCenters[i].coa, kinship, null, type);
coa.shield = COA.getShield(c, s.i);
s.provinces.push(provinceId);

View file

@ -10,21 +10,49 @@ const ROUTE_TYPE_MODIFIERS = {
default: 8 // far ocean
};
// Route tier modifiers for different route types (lower = preferred)
const ROUTE_TIER_MODIFIERS = {
majorSea: { cost: 0.3, priority: "immediate" }, // Major maritime trade routes
royal: { cost: 0.4, priority: "immediate" }, // Capital-to-capital roads
market: { cost: 1.0, priority: "background" }, // Regional trade roads
local: { cost: 1.5, priority: "background" }, // Village-to-market roads
footpath: { cost: 2.0, priority: "background" }, // Hamlet paths
regional: { cost: 1.2, priority: "background" } // Regional sea routes
};
window.Routes = (function () {
function generate(lockedRoutes = []) {
TIME && console.time("generateRoutes");
const {capitalsByFeature, burgsByFeature, portsByFeature, primaryByFeature, plazaByFeature, unconnectedBurgsByFeature} = sortBurgsByFeature(pack.burgs);
const connections = new Map();
lockedRoutes.forEach(route => addConnections(route.points.map(p => p[2])));
const mainRoads = generateMainRoads();
const secondaryRoads = generateSecondaryRoads();
const trails = generateTrails();
const seaRoutes = generateSeaRoutes();
// PHASE 1: IMMEDIATE PROCESSING (blocking - critical routes for trade and diplomacy)
TIME && console.time("generateCriticalRoutes");
const majorSeaRoutes = generateMajorSeaRoutes(); // Tier 1: Long-distance maritime trade
const royalRoads = generateRoyalRoads(); // Tier 2: Capital-to-capital connections
TIME && console.timeEnd("generateCriticalRoutes");
// Create initial routes with critical paths only
pack.routes = createRoutesData(lockedRoutes);
pack.cells.routes = buildLinks(pack.routes);
// PHASE 2: BACKGROUND PROCESSING (non-blocking - local and regional routes)
setTimeout(() => {
TIME && console.time("generateRegionalRoutes");
const marketRoads = generateMarketRoads(); // Tier 3: Regional trade networks (was mainRoads)
const localRoads = generateLocalRoads(); // Tier 4: Village-to-market connections (was secondaryRoads)
const footpaths = generateFootpaths(); // Tier 5: Hamlet networks (was trails)
const regionalSeaRoutes = generateRegionalSeaRoutes(); // Regional sea connections
TIME && console.timeEnd("generateRegionalRoutes");
// Append regional routes to existing critical routes
appendRoutesToPack(marketRoads, localRoads, footpaths, regionalSeaRoutes);
}, 100);
TIME && console.timeEnd("generateRoutes");
function sortBurgsByFeature(burgs) {
const burgsByFeature = {};
const capitalsByFeature = {};
@ -66,6 +94,467 @@ window.Routes = (function () {
return {burgsByFeature, capitalsByFeature, portsByFeature, primaryByFeature, plazaByFeature, unconnectedBurgsByFeature};
}
// Tier 1: Major Sea Routes - Connect capitals and major ports across ALL water bodies
// Simulates long-distance maritime trade like Hanseatic League routes
function generateMajorSeaRoutes() {
TIME && console.time("generateMajorSeaRoutes");
const majorSeaRoutes = [];
// Get all significant ports for major trade routes
const allMajorPorts = [];
pack.burgs.forEach(b => {
if (b.i && !b.removed && b.port) {
// Include more ports in major routes: capitals, large ports, and wealthy market towns
if (b.capital ||
b.isLargePort ||
(b.population >= 5 && b.plaza) || // Major market towns (5000+ pop with plaza)
(b.population >= 10)) { // Large cities regardless of status
allMajorPorts.push(b);
}
}
});
if (allMajorPorts.length < 2) {
TIME && console.timeEnd("generateMajorSeaRoutes");
return majorSeaRoutes;
}
// Sort ports by importance (capitals first, then by population)
allMajorPorts.sort((a, b) => {
if (a.capital && !b.capital) return -1;
if (!a.capital && b.capital) return 1;
return b.population - a.population;
});
// Create a more comprehensive trade network
// Primary hubs: ALL capital ports and top large ports
const capitalPorts = allMajorPorts.filter(p => p.capital);
const largePorts = allMajorPorts.filter(p => !p.capital && (p.isLargePort || p.population >= 10));
const mediumPorts = allMajorPorts.filter(p => !p.capital && !p.isLargePort && p.population < 10);
// Use all capitals and top large ports as primary hubs
const hubs = [...capitalPorts, ...largePorts.slice(0, Math.max(10, Math.floor(largePorts.length * 0.5)))];
const secondaryHubs = [...largePorts.slice(Math.max(10, Math.floor(largePorts.length * 0.5))), ...mediumPorts.slice(0, 20)];
// Connect primary hubs strategically (not all-to-all to avoid too many routes)
// Connect capitals to each other
for (let i = 0; i < capitalPorts.length; i++) {
for (let j = i + 1; j < capitalPorts.length; j++) {
const start = capitalPorts[i].cell;
const exit = capitalPorts[j].cell;
const distance = Math.sqrt((capitalPorts[i].x - capitalPorts[j].x) ** 2 + (capitalPorts[i].y - capitalPorts[j].y) ** 2);
// Connect if reasonably distant (long-distance trade) or same cultural sphere
if (distance > 50 || capitalPorts[i].culture === capitalPorts[j].culture) {
const segments = findPathSegments({isWater: true, connections, start, exit, routeType: "majorSea"});
for (const segment of segments) {
addConnections(segment);
majorSeaRoutes.push({feature: -1, cells: segment, type: "majorSea"});
}
}
}
}
// Connect large ports to nearest 2-3 capitals for trade network
largePorts.slice(0, 15).forEach(port => {
const nearestCapitals = capitalPorts
.map(cap => ({
cap,
distance: Math.sqrt((port.x - cap.x) ** 2 + (port.y - cap.y) ** 2)
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, Math.min(3, capitalPorts.length)); // Connect to up to 3 nearest capitals
nearestCapitals.forEach(({cap}) => {
const segments = findPathSegments({
isWater: true,
connections,
start: port.cell,
exit: cap.cell,
routeType: "majorSea"
});
for (const segment of segments) {
addConnections(segment);
majorSeaRoutes.push({feature: -1, cells: segment, type: "majorSea"});
}
});
});
// Connect secondary hubs to nearest primary hub
secondaryHubs.forEach(port => {
let nearestHub = null;
let minDistance = Infinity;
hubs.forEach(hub => {
const distance = Math.sqrt((port.x - hub.x) ** 2 + (port.y - hub.y) ** 2);
if (distance < minDistance) {
minDistance = distance;
nearestHub = hub;
}
});
if (nearestHub) {
const segments = findPathSegments({
isWater: true,
connections,
start: port.cell,
exit: nearestHub.cell,
routeType: "majorSea"
});
for (const segment of segments) {
addConnections(segment);
majorSeaRoutes.push({feature: -1, cells: segment, type: "majorSea"});
}
}
});
TIME && console.timeEnd("generateMajorSeaRoutes");
return majorSeaRoutes;
}
// Tier 2: Royal Roads - Connect all state capitals for diplomatic and military movement
function generateRoyalRoads() {
TIME && console.time("generateRoyalRoads");
const royalRoads = [];
// Get all state capitals
const capitals = [];
pack.states.forEach(state => {
if (state.i && !state.removed && state.capital) {
const capital = pack.burgs[state.capital];
if (capital && !capital.removed) {
capitals.push(capital);
}
}
});
if (capitals.length < 2) {
TIME && console.timeEnd("generateRoyalRoads");
return royalRoads;
}
// Create a minimum spanning tree of capitals using Kruskal's algorithm
// This ensures all capitals are connected with minimal total distance
const edges = [];
for (let i = 0; i < capitals.length; i++) {
for (let j = i + 1; j < capitals.length; j++) {
const distance = Math.sqrt(
(capitals[i].x - capitals[j].x) ** 2 +
(capitals[i].y - capitals[j].y) ** 2
);
edges.push({
from: i,
to: j,
distance,
fromCell: capitals[i].cell,
toCell: capitals[j].cell
});
}
}
// Sort edges by distance
edges.sort((a, b) => a.distance - b.distance);
// Use union-find to build minimum spanning tree
const parent = Array.from({length: capitals.length}, (_, i) => i);
const find = (x) => {
if (parent[x] !== x) parent[x] = find(parent[x]);
return parent[x];
};
const union = (x, y) => {
const px = find(x);
const py = find(y);
if (px !== py) {
parent[px] = py;
return true;
}
return false;
};
// Build the tree
for (const edge of edges) {
if (union(edge.from, edge.to)) {
const segments = findPathSegments({
isWater: false,
connections,
start: edge.fromCell,
exit: edge.toCell,
routeType: "royal"
});
for (const segment of segments) {
addConnections(segment);
royalRoads.push({
feature: pack.cells.f[edge.fromCell],
cells: segment,
type: "royal"
});
}
}
}
TIME && console.timeEnd("generateRoyalRoads");
return royalRoads;
}
// Tier 3: Market Roads - Regional trade networks (enhanced main roads)
function generateMarketRoads() {
TIME && console.time("generateMarketRoads");
const marketRoads = [];
// Get all market towns (from new settlement hierarchy)
const marketTowns = pack.burgs.filter(b =>
b.i && !b.removed && (b.settlementType === "marketTown" || b.plaza === 1)
);
// Group market towns by feature/region
const marketsByFeature = {};
marketTowns.forEach(town => {
const feature = town.feature;
if (!marketsByFeature[feature]) marketsByFeature[feature] = [];
marketsByFeature[feature].push(town);
});
// Connect market towns within regions (15-30 km spacing as per research)
for (const [feature, towns] of Object.entries(marketsByFeature)) {
if (towns.length < 2) continue;
// Use Delaunay triangulation for regional connections
const points = towns.map(t => [t.x, t.y]);
const edges = calculateUrquhartEdges(points);
edges.forEach(([fromId, toId]) => {
const fromTown = towns[fromId];
const toTown = towns[toId];
// Check distance is within daily travel range (15-30 km)
const distance = Math.sqrt((fromTown.x - toTown.x) ** 2 + (fromTown.y - toTown.y) ** 2);
const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000);
const kmDistance = distance / mapScale;
// Only connect if within reasonable market day travel distance
if (kmDistance <= 35) {
const segments = findPathSegments({
isWater: false,
connections,
start: fromTown.cell,
exit: toTown.cell,
routeType: "market"
});
for (const segment of segments) {
addConnections(segment);
marketRoads.push({
feature: Number(feature),
cells: segment,
type: "market"
});
}
}
});
}
// Also use existing main roads logic for primary centers
const mainRoads = generateMainRoads();
marketRoads.push(...mainRoads);
TIME && console.timeEnd("generateMarketRoads");
return marketRoads;
}
// Tier 4: Local Roads - Village to nearest market town connections
function generateLocalRoads() {
TIME && console.time("generateLocalRoads");
const localRoads = [];
// Get villages from settlement hierarchy
const villages = pack.burgs.filter(b =>
b.i && !b.removed && (
b.settlementType === "largeVillage" ||
b.settlementType === "smallVillage"
)
);
// Get market towns and regional centers
const marketCenters = pack.burgs.filter(b =>
b.i && !b.removed && (
b.settlementType === "marketTown" ||
b.plaza === 1 ||
b.isRegionalCenter ||
b.capital
)
);
// Connect each village to nearest market center
villages.forEach(village => {
let nearestMarket = null;
let minDistance = Infinity;
marketCenters.forEach(market => {
const distance = Math.sqrt(
(village.x - market.x) ** 2 +
(village.y - market.y) ** 2
);
// Prefer markets in same state/culture
let culturalModifier = 1;
if (village.state === market.state) culturalModifier = 0.8;
if (village.culture === market.culture) culturalModifier *= 0.9;
const adjustedDistance = distance * culturalModifier;
if (adjustedDistance < minDistance) {
minDistance = adjustedDistance;
nearestMarket = market;
}
});
if (nearestMarket) {
const segments = findPathSegments({
isWater: false,
connections,
start: village.cell,
exit: nearestMarket.cell,
routeType: "local"
});
for (const segment of segments) {
addConnections(segment);
localRoads.push({
feature: village.feature,
cells: segment,
type: "local"
});
}
}
});
// Also include existing secondary roads
const secondaryRoads = generateSecondaryRoads();
localRoads.push(...secondaryRoads);
TIME && console.timeEnd("generateLocalRoads");
return localRoads;
}
// Tier 5: Footpaths - Hamlet to village networks
function generateFootpaths() {
TIME && console.time("generateFootpaths");
const footpaths = [];
// Get hamlets from settlement hierarchy
const hamlets = pack.burgs.filter(b =>
b.i && !b.removed && b.settlementType === "hamlet"
);
// Get villages and larger settlements
const largerSettlements = pack.burgs.filter(b =>
b.i && !b.removed && (
b.settlementType === "smallVillage" ||
b.settlementType === "largeVillage" ||
b.settlementType === "marketTown" ||
b.plaza === 1
)
);
// Connect each hamlet to nearest village (3-6 km as per research)
hamlets.forEach(hamlet => {
let nearestVillage = null;
let minDistance = Infinity;
largerSettlements.forEach(village => {
const distance = Math.sqrt(
(hamlet.x - village.x) ** 2 +
(hamlet.y - village.y) ** 2
);
// Strong preference for same culture/state
let modifier = 1;
if (hamlet.state === village.state) modifier = 0.7;
if (hamlet.culture === village.culture) modifier *= 0.8;
const adjustedDistance = distance * modifier;
// Only connect to nearby settlements (6 km max range)
const mapScale = Math.sqrt(graphWidth * graphHeight / 1000000);
const kmDistance = distance / mapScale;
if (kmDistance <= 8 && adjustedDistance < minDistance) {
minDistance = adjustedDistance;
nearestVillage = village;
}
});
if (nearestVillage) {
const segments = findPathSegments({
isWater: false,
connections,
start: hamlet.cell,
exit: nearestVillage.cell,
routeType: "footpath"
});
for (const segment of segments) {
addConnections(segment);
footpaths.push({
feature: hamlet.feature,
cells: segment,
type: "footpath"
});
}
}
});
// Also include existing trails for backward compatibility
const trails = generateTrails();
footpaths.push(...trails);
TIME && console.timeEnd("generateFootpaths");
return footpaths;
}
// Regional sea routes (within water bodies)
function generateRegionalSeaRoutes() {
TIME && console.time("generateRegionalSeaRoutes");
const regionalSeaRoutes = [];
// Filter ports to only include significant ones (500+ population or special status)
// Small fishing villages don't participate in trade routes
const significantPortsByFeature = {};
for (const [featureId, featurePorts] of Object.entries(portsByFeature)) {
const significantPorts = featurePorts.filter(burg =>
burg.population >= 0.5 || // 500+ population (in thousands)
burg.capital || // Capital cities
burg.isLargePort || // Designated large ports
burg.plaza || // Market towns with plazas
burg.isRegionalCenter // Regional centers
);
if (significantPorts.length >= 2) {
significantPortsByFeature[featureId] = significantPorts;
}
}
// Connect significant ports within each water body
for (const [featureId, featurePorts] of Object.entries(significantPortsByFeature)) {
const points = featurePorts.map(burg => [burg.x, burg.y]);
const urquhartEdges = calculateUrquhartEdges(points);
urquhartEdges.forEach(([fromId, toId]) => {
const start = featurePorts[fromId].cell;
const exit = featurePorts[toId].cell;
const segments = findPathSegments({isWater: true, connections, start, exit, routeType: "regional"});
for (const segment of segments) {
addConnections(segment);
regionalSeaRoutes.push({feature: Number(featureId), cells: segment, type: "regional"});
}
});
}
TIME && console.timeEnd("generateRegionalSeaRoutes");
return regionalSeaRoutes;
}
function generateMainRoads() {
TIME && console.time("generateMainRoads");
const mainRoads = [];
@ -260,8 +749,8 @@ window.Routes = (function () {
}
}
function findPathSegments({isWater, connections, start, exit}) {
const getCost = createCostEvaluator({isWater, connections});
function findPathSegments({isWater, connections, start, exit, routeType}) {
const getCost = createCostEvaluator({isWater, connections, routeType});
const pathCells = findPath(start, current => current === exit, getCost);
if (!pathCells) return [];
const segments = getRouteSegments(pathCells, connections);
@ -271,32 +760,88 @@ window.Routes = (function () {
function createRoutesData(routes) {
const pointsArray = preparePointsArray();
for (const {feature, cells, merged} of mergeRoutes(mainRoads)) {
if (merged) continue;
const points = getPoints("roads", cells, pointsArray);
routes.push({i: routes.length, group: "roads", feature, points});
}
for (const {feature, cells, merged} of mergeRoutes(secondaryRoads)) {
if (merged) continue;
const points = getPoints("secondary", cells, pointsArray);
routes.push({i: routes.length, group: "secondary", feature, points});
}
for (const {feature, cells, merged} of mergeRoutes(trails)) {
if (merged) continue;
const points = getPoints("trails", cells, pointsArray);
routes.push({i: routes.length, group: "trails", feature, points});
}
for (const {feature, cells, merged} of mergeRoutes(seaRoutes)) {
// Process critical routes (Tier 1 & 2) - these run immediately
for (const {feature, cells, merged, type} of mergeRoutes(majorSeaRoutes)) {
if (merged) continue;
const points = getPoints("searoutes", cells, pointsArray);
routes.push({i: routes.length, group: "searoutes", feature, points});
routes.push({i: routes.length, group: "searoutes", feature, points, type: type || "majorSea"});
}
for (const {feature, cells, merged, type} of mergeRoutes(royalRoads)) {
if (merged) continue;
const points = getPoints("roads", cells, pointsArray);
routes.push({i: routes.length, group: "roads", feature, points, type: type || "royal"});
}
return routes;
}
// Function to append background-generated routes to pack
function appendRoutesToPack(marketRoads, localRoads, footpaths, regionalSeaRoutes) {
const pointsArray = preparePointsArray();
const routes = pack.routes;
// Tier 3: Market Roads
for (const {feature, cells, merged} of mergeRoutes(marketRoads)) {
if (merged) continue;
const points = getPoints("roads", cells, pointsArray);
const routeId = getNextId();
routes.push({i: routeId, group: "roads", feature, points, type: "market"});
// Update cell routes
for (let i = 0; i < cells.length - 1; i++) {
addRouteConnection(cells[i], cells[i + 1], routeId);
}
}
// Tier 4: Local Roads
for (const {feature, cells, merged} of mergeRoutes(localRoads)) {
if (merged) continue;
const points = getPoints("secondary", cells, pointsArray);
const routeId = getNextId();
routes.push({i: routeId, group: "secondary", feature, points, type: "local"});
for (let i = 0; i < cells.length - 1; i++) {
addRouteConnection(cells[i], cells[i + 1], routeId);
}
}
// Tier 5: Footpaths
for (const {feature, cells, merged} of mergeRoutes(footpaths)) {
if (merged) continue;
const points = getPoints("trails", cells, pointsArray);
const routeId = getNextId();
routes.push({i: routeId, group: "trails", feature, points, type: "footpath"});
for (let i = 0; i < cells.length - 1; i++) {
addRouteConnection(cells[i], cells[i + 1], routeId);
}
}
// Regional Sea Routes
for (const {feature, cells, merged} of mergeRoutes(regionalSeaRoutes)) {
if (merged) continue;
const points = getPoints("searoutes", cells, pointsArray);
const routeId = getNextId();
routes.push({i: routeId, group: "searoutes", feature, points, type: "regional"});
for (let i = 0; i < cells.length - 1; i++) {
addRouteConnection(cells[i], cells[i + 1], routeId);
}
}
// Rebuild route links after adding new routes
pack.cells.routes = buildLinks(pack.routes);
}
function addRouteConnection(from, to, routeId) {
const routes = pack.cells.routes || {};
if (!routes[from]) routes[from] = {};
routes[from][to] = routeId;
if (!routes[to]) routes[to] = {};
routes[to][from] = routeId;
pack.cells.routes = routes;
}
// merge routes so that the last cell of one route is the first cell of the next route
function mergeRoutes(routes) {
@ -322,7 +867,7 @@ window.Routes = (function () {
}
}
function createCostEvaluator({isWater, connections}) {
function createCostEvaluator({isWater, connections, routeType = "market"}) {
return isWater ? getWaterPathCost : getLandPathCost;
function getLandPathCost(current, next) {
@ -336,8 +881,16 @@ window.Routes = (function () {
const heightModifier = 1 + Math.max(pack.cells.h[next] - 25, 25) / 25; // [1, 3];
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
const burgModifier = pack.cells.burg[next] ? 1 : 3;
// Medieval travel constraints
const riverCrossingPenalty = pack.cells.r[next] && !pack.cells.burg[next] ? 1.5 : 1; // Bridges rare except at settlements
const borderPenalty = getBorderPenalty(current, next, routeType); // Political boundaries affect some routes
// Apply route tier modifier
const tierModifier = ROUTE_TIER_MODIFIERS[routeType]?.cost || 1;
const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier * burgModifier;
const pathCost = distanceCost * habitabilityModifier * heightModifier * connectionModifier *
burgModifier * riverCrossingPenalty * borderPenalty * tierModifier;
return pathCost;
}
@ -348,10 +901,28 @@ window.Routes = (function () {
const distanceCost = dist2(pack.cells.p[current], pack.cells.p[next]);
const typeModifier = ROUTE_TYPE_MODIFIERS[pack.cells.t[next]] || ROUTE_TYPE_MODIFIERS.default;
const connectionModifier = connections.has(`${current}-${next}`) ? 0.5 : 1;
// Apply route tier modifier for sea routes
const tierModifier = ROUTE_TIER_MODIFIERS[routeType]?.cost || 1;
const pathCost = distanceCost * typeModifier * connectionModifier;
const pathCost = distanceCost * typeModifier * connectionModifier * tierModifier;
return pathCost;
}
function getBorderPenalty(current, next, routeType) {
// Royal roads and major sea routes ignore borders (diplomatic/trade importance)
if (routeType === "royal" || routeType === "majorSea") return 1;
// Check if crossing state border
const currentState = pack.cells.state[current];
const nextState = pack.cells.state[next];
if (currentState === nextState) return 1;
// Higher penalty for local routes crossing borders
if (routeType === "footpath") return 3;
if (routeType === "local") return 2;
return 1.5; // Market roads have moderate border penalty
}
}
function buildLinks(routes) {

View file

@ -70,8 +70,13 @@ function editBiomes() {
const b = cells.biome[i];
biomesData.cells[b] += 1;
biomesData.area[b] += cells.area[i];
biomesData.rural[b] += cells.pop[i];
if (cells.burg[i]) biomesData.urban[b] += pack.burgs[cells.burg[i]].population;
if (cells.burg[i]) {
// Burg represents ALL population for this cell (stored in thousands)
biomesData.urban[b] += pack.burgs[cells.burg[i]].population;
} else {
// Only count cells.pop for unsettled areas (no burg present)
biomesData.rural[b] += cells.pop[i];
}
}
}

View file

@ -74,7 +74,7 @@ function overviewMilitary() {
const states = pack.states.filter(s => s.i && !s.removed);
for (const s of states) {
const population = rn((s.rural + s.urban * urbanization) * populationRate);
const population = rn((s.rural * populationRate) + (s.urban * 1000 * urbanization));
const getForces = u => s.military.reduce((s, r) => s + (r.u[u.name] || 0), 0);
const total = options.military.reduce((s, u) => s + getForces(u) * u.crew, 0);
const rate = (total / population) * 100;
@ -146,7 +146,7 @@ function overviewMilitary() {
u => (line.dataset[u.name] = line.querySelector(`div[data-type='${u.name}']`).innerHTML = getForces(u))
);
const population = rn((s.rural + s.urban * urbanization) * populationRate);
const population = rn((s.rural * populationRate) + (s.urban * 1000 * urbanization));
const total = (line.dataset.total = options.military.reduce((s, u) => s + getForces(u) * u.crew, 0));
const rate = (line.dataset.rate = (total / population) * 100);
line.querySelector("div[data-type='total']").innerHTML = si(total);

View file

@ -87,10 +87,14 @@ function editProvinces() {
if (!p) continue;
provinces[p].area += cells.area[i];
provinces[p].rural += cells.pop[i];
if (!cells.burg[i]) continue;
provinces[p].urban += burgs[cells.burg[i]].population;
provinces[p].burgs.push(cells.burg[i]);
if (cells.burg[i]) {
// Burg represents ALL population for this cell (stored in thousands)
provinces[p].urban += burgs[cells.burg[i]].population;
provinces[p].burgs.push(cells.burg[i]);
} else {
// Only count cells.pop for unsettled areas (no burg present)
provinces[p].rural += cells.pop[i];
}
}
provinces.forEach(p => {
@ -1092,9 +1096,13 @@ function editProvinces() {
data += el.dataset.color + ",";
data += el.dataset.capital + ",";
data += el.dataset.area + ",";
data += el.dataset.population + ",";
data += Math.round(provincePack.rural * populationRate) + ",";
data += Math.round(provincePack.urban * populationRate * urbanization) + ",";
// Rural: convert abstract points to people, Urban: already in thousands so convert to people
const ruralPop = Math.round(provincePack.rural * populationRate);
const urbanPop = Math.round(provincePack.urban * 1000 * urbanization);
const totalPop = ruralPop + urbanPop;
data += totalPop + ",";
data += ruralPop + ",";
data += urbanPop + ",";
data += el.dataset.burgs + "\n";
});

View file

@ -79,9 +79,17 @@ function editZones() {
const lines = filteredZones.map(({i, name, type, cells, color, hidden}) => {
const area = getArea(d3.sum(cells.map(i => pack.cells.area[i])));
const rural = d3.sum(cells.map(i => pack.cells.pop[i])) * populationRate;
const urban =
d3.sum(cells.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization;
// Calculate population: burg population for settled cells, cells.pop for unsettled
let rural = 0, urban = 0;
cells.forEach(i => {
if (pack.cells.burg[i]) {
// Burg represents ALL population for this cell
urban += pack.burgs[pack.cells.burg[i]].population * 1000 * urbanization;
} else {
// Only count cells.pop for unsettled areas
rural += pack.cells.pop[i] * populationRate;
}
});
const population = rn(rural + urban);
const populationTip = `Total population: ${si(population)}; Rural population: ${si(
rural
@ -412,10 +420,19 @@ function editZones() {
if (!landCells.length) return tip("Zone does not have any land cells, cannot change population", false, "error");
const burgs = pack.burgs.filter(b => !b.removed && landCells.includes(b.cell));
const rural = rn(d3.sum(landCells.map(i => pack.cells.pop[i])) * populationRate);
const urban = rn(
d3.sum(landCells.map(i => pack.cells.burg[i]).map(b => pack.burgs[b].population)) * populationRate * urbanization
);
// Calculate population: burg population for settled cells, cells.pop for unsettled
let rural = 0, urban = 0;
landCells.forEach(i => {
if (pack.cells.burg[i]) {
// Burg represents ALL population for this cell
urban += pack.burgs[pack.cells.burg[i]].population * 1000 * urbanization;
} else {
// Only count cells.pop for unsettled areas
rural += pack.cells.pop[i] * populationRate;
}
});
rural = rn(rural);
urban = rn(urban);
const total = rural + urban;
const l = n => Number(n).toLocaleString();