t
This commit is contained in:
parent
8f80997b0d
commit
8a9fd9b304
252 changed files with 356 additions and 1487 deletions
296
engine.js
296
engine.js
|
|
@ -360,6 +360,7 @@ this.ethnicities = new Map();
|
|||
this.cities = [];
|
||||
this.cityById = new Map();
|
||||
this.cityBuckets = new Map();
|
||||
this.polityCitiesCache = new Map();
|
||||
this.polities = [];
|
||||
this.polityById = new Map();
|
||||
this.wars = [];
|
||||
|
|
@ -420,6 +421,8 @@ this.routeCandidateScratch = [];
|
|||
this.supportedRouteScratch = new Set();
|
||||
this.populationMoveScratch = [];
|
||||
this.activePopulationScratch = [];
|
||||
this.territoryExitCandidates = [];
|
||||
this.suppressTerritoryExitShock = false;
|
||||
this.technologySourceScratch = [];
|
||||
this.routeCostScratch = new Float32Array(this.world.count);
|
||||
this.routePrevScratch = new Int32Array(this.world.count);
|
||||
|
|
@ -1227,33 +1230,26 @@ step() {
|
|||
for (const city of this.cities) city.activeVisitors = 0;
|
||||
this.dominantEthnicityCache.clear();
|
||||
this.rebuildIndexes();
|
||||
// World fields are visually and behaviorally slow-moving. Updating them once
|
||||
// per simulated month halves full-map work while preserving the same annual
|
||||
// decay/regeneration scale. Annual city processing owns its capacity refresh.
|
||||
const annualTick = this.year % years(1) === 0;
|
||||
const weekOfYear = this.year % WEEKS_PER_YEAR;
|
||||
const fieldTick = this.year % WEEKS_PER_MONTH === 0;
|
||||
const resourceTick = this.year % 12 === 2;
|
||||
const pheromoneChanged = fieldTick ? this.updateWorldFields(WEEKS_PER_MONTH, 0) : false;
|
||||
if (resourceTick) this.regenerateResources(12);
|
||||
if (!annualTick && pheromoneChanged) this.updatePopulationCapacity();
|
||||
const resourceTick = weekOfYear === WEEKS_PER_YEAR - 1;
|
||||
// Pheromone changes update capacity only on touched cells inside updateWorldFields().
|
||||
// This avoids a full-map capacity pass every simulated month.
|
||||
if (fieldTick) this.updateWorldFields(WEEKS_PER_MONTH);
|
||||
// Resources are consumed by the annual economy. Batch the same 48-week
|
||||
// regeneration immediately before the next annual tick.
|
||||
if (resourceTick) this.regenerateResources(WEEKS_PER_YEAR);
|
||||
this.maybeSpawnDisaster();
|
||||
if (this.year % years(1) === 0) this.maybeSpawnFrontierWave();
|
||||
if (this.year % years(1) === 0) {
|
||||
if (annualTick) this.maybeSpawnFrontierWave();
|
||||
if (annualTick) {
|
||||
this.updateCities();
|
||||
this.updateNomadBands();
|
||||
this.consumeTileResources();
|
||||
this.growTilePopulation();
|
||||
this.updatePopulationPressure();
|
||||
this.diffusePopulation();
|
||||
this.updatePopulationPressure();
|
||||
if (this.year % years(2) === 0) {
|
||||
this.updateTileTechnology();
|
||||
this.diffuseTileTechnology();
|
||||
this.cityTechnologyExchange();
|
||||
}
|
||||
if (this.year % years(3) === 0) this.updateTileAssimilation();
|
||||
this.spreadCulturesFromCitiesAndTrade();
|
||||
this.updateNomadInfluence();
|
||||
this.clearSparseHumanData();
|
||||
if (this.year % years(100) === 0 && typeof console !== "undefined" && console.debug) {
|
||||
const d = this.territoryDebug;
|
||||
if (d.disconnectedTerritoryRemoved || d.independentCitiesAbsorbedByTerritory || d.cityTerritoryMismatchesFixed || d.invalidCampaignTargetsRejected) {
|
||||
|
|
@ -1261,10 +1257,27 @@ console.debug("territory topology", { year: Math.floor(this.year / WEEKS_PER_YEA
|
|||
}
|
||||
}
|
||||
}
|
||||
if (this.year % years(5) === 0) {
|
||||
this.updateTradeRoutesFieldBased();
|
||||
// Spread expensive annual/periodic field work across the year instead of
|
||||
// stacking it all on the year boundary. Each population cell still migrates
|
||||
// once per year, in one of four stable quarterly cohorts.
|
||||
if (weekOfYear % 12 === 8) {
|
||||
const cohort = Math.floor(weekOfYear / 12) % 4;
|
||||
this.updatePopulationCapacity();
|
||||
this.diffusePopulation(cohort, 4);
|
||||
this.updatePopulationPressure();
|
||||
}
|
||||
this.updatePolities();
|
||||
if (this.year % years(2) === 12) {
|
||||
this.updateTileTechnology();
|
||||
this.diffuseTileTechnology();
|
||||
this.cityTechnologyExchange();
|
||||
this.clearSparseHumanData();
|
||||
}
|
||||
if (this.year % years(3) === 24) this.updateTileAssimilation();
|
||||
if (weekOfYear === 36) this.spreadCulturesFromCitiesAndTrade();
|
||||
if (this.year % years(5) === 20) this.updateTradeRoutes(true);
|
||||
// State accounting follows the annual city economy, but is offset by one
|
||||
// month so the two largest annual workloads do not land on the same frame.
|
||||
if (weekOfYear === 4) this.updatePolities(true);
|
||||
if (this.year % SimConfig.war.tickWeeks === 0) {
|
||||
this.maybeStartWars();
|
||||
this.updateCampaigns();
|
||||
|
|
@ -1272,7 +1285,9 @@ this.updateWars();
|
|||
}
|
||||
const historyTick = this.year % 12 === 0;
|
||||
if (historyTick) this.updateEthnicStats();
|
||||
if (this.year > 0 && annualTick) {
|
||||
// Ethnic maintenance is annual in frequency but offset from the year boundary.
|
||||
if (this.year > 0 && weekOfYear === 40) {
|
||||
this.updateEthnicStats();
|
||||
this.absorbSmallEthnicities();
|
||||
this.updateEthnicStats();
|
||||
this.splitDivergentEthnicities();
|
||||
|
|
@ -1283,6 +1298,7 @@ this.year++;
|
|||
}
|
||||
rebuildIndexes() {
|
||||
this.cityById.clear();
|
||||
this.polityCitiesCache?.clear();
|
||||
for (const city of this.cities) this.cityById.set(city.id, city);
|
||||
this.polityById.clear();
|
||||
for (const polity of this.polities) this.polityById.set(polity.id, polity);
|
||||
|
|
@ -1483,10 +1499,20 @@ culture.set(fallbackEthnicity, (culture.get(fallbackEthnicity) || 0) + localPopu
|
|||
}
|
||||
}
|
||||
updateCultureTile(tile) {
|
||||
const w = this.world;
|
||||
const sparseThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1);
|
||||
if ((w.population[tile] || 0) < sparseThreshold) {
|
||||
w.dominantEthnicity[tile] = -1;
|
||||
w.cultureDiversity[tile] = 0;
|
||||
w.farmingKnowledge[tile] = 0;
|
||||
w.metallurgyKnowledge[tile] = 0;
|
||||
this.tileEthnicMix.delete(tile);
|
||||
return;
|
||||
}
|
||||
const counts = this.tileEthnicMix.get(tile);
|
||||
if (!counts || !counts.size) {
|
||||
if ((this.world.population[tile] || 0) <= 0.01) this.world.dominantEthnicity[tile] = -1;
|
||||
this.world.cultureDiversity[tile] *= 0.98;
|
||||
w.dominantEthnicity[tile] = -1;
|
||||
w.cultureDiversity[tile] = 0;
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
|
|
@ -1502,12 +1528,13 @@ dominantCount = count;
|
|||
this.world.dominantEthnicity[tile] = dominant;
|
||||
this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0;
|
||||
}
|
||||
diffusePopulation() {
|
||||
diffusePopulation(cohortIndex = 0, cohortCount = 1) {
|
||||
const w = this.world;
|
||||
const moves = this.populationMoveScratch;
|
||||
moves.length = 0;
|
||||
const tiles = this.activePopulationSnapshot();
|
||||
for (const tile of tiles) {
|
||||
if (cohortCount > 1 && tile % cohortCount !== cohortIndex) continue;
|
||||
const population = w.population[tile];
|
||||
const mobile = w.mobilePopulation[tile] || 0;
|
||||
if (population < 1) continue;
|
||||
|
|
@ -1665,6 +1692,25 @@ const demand = (population / SimConfig.population.populationScale) * (0.0045 + M
|
|||
w.resource[tile] = Math.max(0, w.resource[tile] - demand);
|
||||
}
|
||||
}
|
||||
clearSparseHumanData() {
|
||||
const w = this.world;
|
||||
const threshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1);
|
||||
if (threshold <= 0) return 0;
|
||||
let cleared = 0;
|
||||
for (const tile of this.activePopulationSnapshot()) {
|
||||
if ((w.population[tile] || 0) >= threshold) continue;
|
||||
const hadData = w.farmingKnowledge[tile] !== 0 || w.metallurgyKnowledge[tile] !== 0 ||
|
||||
w.dominantEthnicity[tile] >= 0 || w.cultureDiversity[tile] !== 0 || this.tileEthnicMix.has(tile);
|
||||
if (!hadData) continue;
|
||||
w.farmingKnowledge[tile] = 0;
|
||||
w.metallurgyKnowledge[tile] = 0;
|
||||
w.dominantEthnicity[tile] = -1;
|
||||
w.cultureDiversity[tile] = 0;
|
||||
this.tileEthnicMix.delete(tile);
|
||||
cleared++;
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
updateTileAssimilation() {
|
||||
const w = this.world;
|
||||
for (const [tile, mix] of [...this.tileEthnicMix]) {
|
||||
|
|
@ -1820,33 +1866,37 @@ const capacity = w.populationCapacity[i];
|
|||
w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0;
|
||||
}
|
||||
}
|
||||
updatePopulationCapacity() {
|
||||
updatePopulationCapacityTile(i) {
|
||||
const w = this.world;
|
||||
const { carryingCapacityFarmland } = SimConfig.population;
|
||||
const maxPheromone = Math.max(1, SimConfig.route.maxPheromone);
|
||||
const staticCapacity = this.populationCapacityStatic;
|
||||
const dynamicScale = this.populationCapacityDynamicScale;
|
||||
for (let i = 0; i < w.count; i++) {
|
||||
const scale = dynamicScale[i];
|
||||
const scale = this.populationCapacityDynamicScale[i];
|
||||
const staticCapacity = this.populationCapacityStatic[i];
|
||||
if (scale === 0) {
|
||||
w.populationCapacity[i] = staticCapacity[i];
|
||||
w.populationPressure[i] = w.population[i] / staticCapacity[i];
|
||||
continue;
|
||||
w.populationCapacity[i] = staticCapacity;
|
||||
w.populationPressure[i] = staticCapacity > 0 ? w.population[i] / staticCapacity : 0;
|
||||
return;
|
||||
}
|
||||
const maxPheromone = Math.max(1, SimConfig.route.maxPheromone);
|
||||
const routeBonus = w.tradeRoute[i] ? 1.6 : Math.min(1, Math.max(0, w.pheromone[i] / maxPheromone)) * 0.8;
|
||||
const cityBonus = w.city[i] >= 0 ? 3.5 : w.cityPull[i] * 2.2;
|
||||
const capacity = staticCapacity[i] + (
|
||||
w.farmland[i] * carryingCapacityFarmland + routeBonus + cityBonus
|
||||
const capacity = staticCapacity + (
|
||||
w.farmland[i] * SimConfig.population.carryingCapacityFarmland + routeBonus + cityBonus
|
||||
) * scale;
|
||||
w.populationCapacity[i] = capacity;
|
||||
w.populationPressure[i] = w.population[i] / capacity;
|
||||
w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0;
|
||||
}
|
||||
updatePopulationCapacity() {
|
||||
for (let i = 0; i < this.world.count; i++) this.updatePopulationCapacityTile(i);
|
||||
}
|
||||
updateTileTechnology() {
|
||||
const w = this.world;
|
||||
const tiles = this.activePopulationSnapshot();
|
||||
const humanThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1);
|
||||
for (const tile of tiles) {
|
||||
if (w.population[tile] <= 0 || w.terrain[tile] === Terrain.WATER) continue;
|
||||
if (w.population[tile] < humanThreshold || w.terrain[tile] === Terrain.WATER) {
|
||||
w.farmingKnowledge[tile] = 0;
|
||||
w.metallurgyKnowledge[tile] = 0;
|
||||
continue;
|
||||
}
|
||||
const settled = w.settledPopulation[tile] || 0;
|
||||
const density = clamp(Math.log1p(settled) / Math.log(80), 0, 1.4);
|
||||
const cityPull = w.city[tile] >= 0 ? 1 : clamp(w.cityPull[tile], 0, 1);
|
||||
|
|
@ -1862,6 +1912,7 @@ diffuseTileTechnology() {
|
|||
const w = this.world;
|
||||
const nextFarming = this.scratchFloatA;
|
||||
const nextMetallurgy = this.scratchFloatB;
|
||||
const humanThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1);
|
||||
nextFarming.set(w.farmingKnowledge);
|
||||
nextMetallurgy.set(w.metallurgyKnowledge);
|
||||
const sources = this.technologySourceScratch;
|
||||
|
|
@ -1877,9 +1928,17 @@ this.visitStamp[tile] = marker;
|
|||
sources.push(tile);
|
||||
}
|
||||
for (const tile of sources) {
|
||||
if (w.population[tile] <= 0 && !w.tradeRoute[tile]) continue;
|
||||
if ((w.population[tile] || 0) < humanThreshold) {
|
||||
nextFarming[tile] = 0;
|
||||
nextMetallurgy[tile] = 0;
|
||||
continue;
|
||||
}
|
||||
this.forCardinalNeighbors(tile, n => {
|
||||
if (w.terrain[n] === Terrain.WATER) return;
|
||||
if (w.terrain[n] === Terrain.WATER || (w.population[n] || 0) < humanThreshold) {
|
||||
nextFarming[n] = 0;
|
||||
nextMetallurgy[n] = 0;
|
||||
return;
|
||||
}
|
||||
const routeBoost = w.tradeRoute[tile] || w.tradeRoute[n] ? 1.9 : 1;
|
||||
const popBoost = clamp(Math.log1p(w.population[n]) / Math.log(60), 0.2, 1.2);
|
||||
const rate = clamp((0.010 + (w.cityPull[tile] + w.cityPull[n]) * 0.004) * routeBoost * popBoost, 0.002, 0.035);
|
||||
|
|
@ -1935,8 +1994,7 @@ const tempDiff = Math.abs(this.world.temperature[tile] - ethnicity.climateTemp);
|
|||
const humidDiff = Math.abs(this.world.humidity[tile] - ethnicity.climateHumidity);
|
||||
return tempDiff * 0.58 + humidDiff * 0.42;
|
||||
}
|
||||
updateWorldFields(scale = 1, resourceScale = scale) {
|
||||
if (resourceScale > 0) this.regenerateResources(resourceScale);
|
||||
updateWorldFields(scale = 1) {
|
||||
// Preserve the former biweekly pheromone diffusion semantics while keeping
|
||||
// pheromone work sparse. Resource regeneration is batched separately by step().
|
||||
let remaining = Math.max(0, scale);
|
||||
|
|
@ -2009,7 +2067,9 @@ w.city.fill(-1);
|
|||
w.farmland.fill(0);
|
||||
w.cityPull.fill(0);
|
||||
const candidates = new Map();
|
||||
this.updatePopulationCapacity();
|
||||
// Keep the previous valid capacity while rebuilding farmland/city influence.
|
||||
// Recomputing here would scan the whole map only to discard both bonuses; the
|
||||
// refreshed capacity is calculated once after the rebuilt fields are ready.
|
||||
const activeTiles = this.activePopulationSnapshot();
|
||||
for (const tile of activeTiles) {
|
||||
if (w.terrain[tile] === Terrain.WATER) continue;
|
||||
|
|
@ -2453,6 +2513,22 @@ bestDistance = d;
|
|||
}
|
||||
return best;
|
||||
}
|
||||
applyTerritoryExitPopulationShock(tile, rate = SimConfig.population?.territoryExitPopulationLossRate ?? 0.12) {
|
||||
const w = this.world;
|
||||
if (!w?.population || !w?.terrain || tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) return 0;
|
||||
const population = w.population[tile] || 0;
|
||||
if (population <= 0 || rate <= 0) return 0;
|
||||
return this.removeEthnicPopulationProportionally(tile, population * clamp(rate, 0, 0.95));
|
||||
}
|
||||
finishTrackedTerritoryExits() {
|
||||
const w = this.world;
|
||||
let removed = 0;
|
||||
for (const tile of this.territoryExitCandidates) {
|
||||
if (w.territoryOwner[tile] < 0) removed += this.applyTerritoryExitPopulationShock(tile);
|
||||
}
|
||||
this.territoryExitCandidates.length = 0;
|
||||
return removed;
|
||||
}
|
||||
cityInfluence(city) {
|
||||
if (!city || city.population <= 0 || city.strength <= 0) return 0;
|
||||
const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35);
|
||||
|
|
@ -2461,10 +2537,14 @@ return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedReso
|
|||
setTileOwner(tile, polityId, options = {}) {
|
||||
const w = this.world;
|
||||
if (tile == null || tile < 0 || tile >= w.count || (w.terrain[tile] === Terrain.WATER && (polityId ?? -1) >= 0)) return false;
|
||||
const previousOwner = w.territoryOwner[tile];
|
||||
const owner = polityId ?? -1;
|
||||
if (owner >= 0 && !this.getPolityById(owner)) return false;
|
||||
w.territoryOwner[tile] = owner;
|
||||
w.polity[tile] = owner;
|
||||
if (previousOwner >= 0 && owner < 0 && !this.suppressTerritoryExitShock && options.populationShock !== false) {
|
||||
this.applyTerritoryExitPopulationShock(tile);
|
||||
}
|
||||
if (owner < 0) {
|
||||
w.control[tile] = 0;
|
||||
w.claim[tile] = 0;
|
||||
|
|
@ -2497,9 +2577,21 @@ w.contested.fill(0);
|
|||
recomputeTerritories(options = {}) {
|
||||
const w = this.world;
|
||||
const allowOwnershipChanges = !!options.allowOwnershipChanges;
|
||||
const trackTerritoryExits = allowOwnershipChanges && !this.suppressTerritoryExitShock;
|
||||
if (trackTerritoryExits) {
|
||||
this.territoryExitCandidates.length = 0;
|
||||
for (const tile of this.activePopulationTiles) {
|
||||
if (w.territoryOwner[tile] >= 0) this.territoryExitCandidates.push(tile);
|
||||
}
|
||||
this.suppressTerritoryExitShock = true;
|
||||
}
|
||||
this.clearTerritories();
|
||||
if (!this.polities.length || !this.cities.length) {
|
||||
this.enforceTerritoryConnectivity();
|
||||
if (trackTerritoryExits) {
|
||||
this.suppressTerritoryExitShock = false;
|
||||
this.finishTrackedTerritoryExits();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const best = this.scratchFloatA;
|
||||
|
|
@ -2649,6 +2741,10 @@ this.enforceCityTerritoryAnchors(options);
|
|||
if (allowOwnershipChanges) this.smoothTerritoryBorders(1);
|
||||
this.pruneUnpopulatedTerritory();
|
||||
this.enforceTerritoryConnectivity(options);
|
||||
if (trackTerritoryExits) {
|
||||
this.suppressTerritoryExitShock = false;
|
||||
this.finishTrackedTerritoryExits();
|
||||
}
|
||||
}
|
||||
pruneUnpopulatedTerritory() {
|
||||
const w = this.world;
|
||||
|
|
@ -2946,15 +3042,17 @@ this.polityById.set(id, polity);
|
|||
return polity;
|
||||
}
|
||||
getPolityCities(polity) {
|
||||
if (!polity) return [];
|
||||
this.polityCitiesCache ??= new Map();
|
||||
const cached = this.polityCitiesCache.get(polity.id);
|
||||
if (cached) return cached;
|
||||
const living = [];
|
||||
for (const id of [...polity.cityIds]) {
|
||||
const city = this.getCityById(id);
|
||||
if (!city || city.population <= 0) {
|
||||
polity.cityIds.delete(id);
|
||||
} else {
|
||||
living.push(city);
|
||||
}
|
||||
if (!city || city.population <= 0) polity.cityIds.delete(id);
|
||||
else living.push(city);
|
||||
}
|
||||
this.polityCitiesCache.set(polity.id, living);
|
||||
return living;
|
||||
}
|
||||
dominantCityEthnicity(city) {
|
||||
|
|
@ -3256,6 +3354,7 @@ if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity));
|
|||
}
|
||||
let affectedCities = 0;
|
||||
let totalPopulationLoss = 0;
|
||||
let regionalPopulationLoss = 0;
|
||||
for (const city of this.cities) {
|
||||
const d = Math.hypot(city.x - x, city.y - y);
|
||||
if (d > radius) continue;
|
||||
|
|
@ -3304,6 +3403,17 @@ if (d > radius) continue;
|
|||
const falloff = 1 - d / radius;
|
||||
const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
|
||||
const tile = w.idx(tx, ty);
|
||||
if (w.terrain[tile] !== Terrain.WATER && (w.population[tile] || 0) > 0) {
|
||||
const regionalLossRate = clamp(
|
||||
damage * (config?.regionalPopulationLossRate ?? 0.42),
|
||||
0,
|
||||
config?.regionalPopulationLossMax ?? 0.65
|
||||
);
|
||||
const loss = this.removeEthnicPopulationProportionally(tile, w.population[tile] * regionalLossRate);
|
||||
regionalPopulationLoss += loss;
|
||||
totalPopulationLoss += loss;
|
||||
this.deaths += Math.floor(loss * 0.40);
|
||||
}
|
||||
w.resource[tile] *= (1 - damage * 0.45);
|
||||
w.farmland[tile] *= (1 - damage * 0.35);
|
||||
w.pheromone[tile] *= (1 - damage * 0.25);
|
||||
|
|
@ -3331,6 +3441,7 @@ intensity,
|
|||
expires: this.year + years(config?.visualDurationYears ?? 18),
|
||||
affectedCities,
|
||||
totalPopulationLoss,
|
||||
regionalPopulationLoss,
|
||||
affectedPolities
|
||||
};
|
||||
this.disasters.push(record);
|
||||
|
|
@ -3478,11 +3589,15 @@ city.polityId = polity.id;
|
|||
city.loyalty = clamp(initialLoyalty, 0, 1);
|
||||
city.receivedAid = false;
|
||||
polity.cityIds.add(city.id);
|
||||
this.polityCitiesCache?.delete(polity.id);
|
||||
}
|
||||
removeCityFromPolity(city) {
|
||||
if (!city || city.polityId === null) return;
|
||||
const oldPolity = this.getPolityById(city.polityId);
|
||||
if (oldPolity) oldPolity.cityIds.delete(city.id);
|
||||
if (oldPolity) {
|
||||
oldPolity.cityIds.delete(city.id);
|
||||
this.polityCitiesCache?.delete(oldPolity.id);
|
||||
}
|
||||
city.polityId = null;
|
||||
city.loyalty = 0.45;
|
||||
city.receivedAid = false;
|
||||
|
|
@ -3708,22 +3823,25 @@ candidates.push({ tile, score });
|
|||
}
|
||||
}
|
||||
if (!candidates.length) return -1;
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const bestScore = candidates[0].score;
|
||||
let bestScore = -Infinity;
|
||||
for (const candidate of candidates) if (candidate.score > bestScore) bestScore = candidate.score;
|
||||
const scoreWindow = Math.max(0.2, SimConfig.polity?.treasuryCitySiteScoreWindow ?? 6.0);
|
||||
const eligible = candidates.filter(candidate => candidate.score >= bestScore - scoreWindow);
|
||||
const floor = bestScore - scoreWindow;
|
||||
let totalWeight = 0;
|
||||
for (const candidate of eligible) {
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.score < floor) continue;
|
||||
candidate.weight = Math.pow(Math.max(0.20, candidate.score - floor + 0.20), 1.10);
|
||||
totalWeight += candidate.weight;
|
||||
}
|
||||
let roll = this.rng.next() * totalWeight;
|
||||
for (const candidate of eligible) {
|
||||
let fallback = candidates[0].tile;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.score < floor) continue;
|
||||
fallback = candidate.tile;
|
||||
roll -= candidate.weight;
|
||||
if (roll <= 0) return candidate.tile;
|
||||
}
|
||||
return eligible[eligible.length - 1]?.tile ?? candidates[0].tile;
|
||||
return fallback;
|
||||
}
|
||||
maybeBuildTreasuryCities() {
|
||||
const cfg = SimConfig.polity || {};
|
||||
|
|
@ -4516,7 +4634,13 @@ control: options.control ?? 0.58,
|
|||
claim: options.claim ?? 0.70,
|
||||
reason: options.reason ?? "cityTransfer"
|
||||
});
|
||||
if (this.isConquestTransferReason(options.reason)) this.protectCityFromRebellion(city, options.rebellionProtectionYears);
|
||||
if (this.isConquestTransferReason(options.reason)) {
|
||||
this.protectCityFromRebellion(city, options.rebellionProtectionYears);
|
||||
// City occupation also disrupts the surrounding rural population. Keep this
|
||||
// separate from ordinary battle-zone damage so every conquest-style transfer
|
||||
// receives the same occupation shock exactly once.
|
||||
if (fromPolityId !== toPolity.id) this.applyOccupationPopulationShock(tile);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
releaseCityTerritory(city, fromPolityId, radius = 4, reason = "fragmentation") {
|
||||
|
|
@ -4952,7 +5076,8 @@ const key = `${attacker.id}:${selected.city.id}`;
|
|||
const memory = (war.cityPressureById?.get(key) || 0) * (SimConfig.war.pressureMemoryDecay ?? 0.88) + pressure * (war.intensity || 0.75) * 1.6;
|
||||
war.cityPressureById?.set(key, memory);
|
||||
const captureThreshold = war.goal === "collapse_exploitation" ? 1.55 : war.goal === "city_conquest" ? 1.75 : war.goal === "capital_pressure" ? 1.85 : 2.15;
|
||||
const chance = clamp((memory - captureThreshold * 0.65 + pressure * 0.35) * 0.45, 0.05, 0.85);
|
||||
const baseCaptureChance = clamp((memory - captureThreshold * 0.65 + pressure * 0.35) * 0.45, 0.05, 0.85);
|
||||
const chance = clamp(baseCaptureChance * (SimConfig.war.captureChanceMultiplier ?? 1), 0, 0.85);
|
||||
const damageRate = clamp(
|
||||
(SimConfig.war.cityDamageMinRate ?? 0.003) + pressure * 0.030 + memory * 0.018,
|
||||
SimConfig.war.cityDamageMinRate ?? 0.003,
|
||||
|
|
@ -5256,6 +5381,33 @@ polity.cohesion = clamp((polity.cohesion ?? 0.6) - rate * 1.1, 0, 1);
|
|||
}
|
||||
return total;
|
||||
}
|
||||
applyOccupationPopulationShock(tile, radius = SimConfig.war.occupationPopulationLossRadius ?? 4, rate = SimConfig.war.occupationPopulationLossRate ?? 0.08) {
|
||||
const w = this.world;
|
||||
if (tile == null || tile < 0 || tile >= w.count || radius <= 0 || rate <= 0) return 0;
|
||||
const cx = tile % w.size;
|
||||
const cy = Math.floor(tile / w.size);
|
||||
const damageMultiplier = SimConfig.war.damageMultiplier ?? 1;
|
||||
let removed = 0;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const distance = Math.abs(dx) + Math.abs(dy);
|
||||
if (distance === 0 || distance > radius) continue;
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
|
||||
const t = w.idx(x, y);
|
||||
if (w.terrain[t] === Terrain.WATER || w.population[t] <= 0) continue;
|
||||
const falloff = 1 - distance / (radius + 1);
|
||||
const amount = w.population[t] * rate * damageMultiplier * falloff;
|
||||
if (amount <= 0.03) continue;
|
||||
removed += this.removeEthnicPopulationProportionally(t, amount) || 0;
|
||||
w.resource[t] = Math.max(0, w.resource[t] - amount * 0.015);
|
||||
}
|
||||
}
|
||||
this.recordWarDeaths(removed, 0.72);
|
||||
return removed;
|
||||
}
|
||||
|
||||
applyWarZoneTileDamage(tile, rate) {
|
||||
const w = this.world;
|
||||
if (tile == null || tile < 0 || tile >= w.count || rate <= 0) return 0;
|
||||
|
|
@ -5690,27 +5842,26 @@ this.releaseCityTerritory(city, oldPolityId, 5, "collapse");
|
|||
}
|
||||
}
|
||||
}
|
||||
updatePolities() {
|
||||
if (this.year % years(1) !== 0) return;
|
||||
updatePolities(force = false) {
|
||||
if (!force && this.year % years(1) !== 0) return;
|
||||
this.cleanupPolities();
|
||||
this.foundPolities();
|
||||
if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes();
|
||||
this.reinforcePolityTradeRoutes();
|
||||
this.collectAndRedistributeResources();
|
||||
if (this.maybeBuildTreasuryCities) this.maybeBuildTreasuryCities();
|
||||
this.maybeBuildTreasuryCities();
|
||||
this.detectPolityFamines();
|
||||
if (this.erodePolityLegitimacy) this.erodePolityLegitimacy();
|
||||
if (this.updateInstitutionalAging) this.updateInstitutionalAging();
|
||||
if (this.maybeTriggerInstitutionalReforms) this.maybeTriggerInstitutionalReforms();
|
||||
if (this.triggerPolityCrises) this.triggerPolityCrises();
|
||||
if (this.applyOldStateStress) this.applyOldStateStress();
|
||||
if (this.updatePolityLeaders) this.updatePolityLeaders();
|
||||
this.erodePolityLegitimacy();
|
||||
this.updateInstitutionalAging();
|
||||
this.maybeTriggerInstitutionalReforms();
|
||||
this.triggerPolityCrises();
|
||||
this.applyOldStateStress();
|
||||
this.updatePolityLeaders();
|
||||
this.updateCityLoyalty();
|
||||
this.maybeStartCampaigns();
|
||||
this.splitUnloyalCities();
|
||||
this.enforcePolityTreasuryCaps();
|
||||
this.cleanupPolities();
|
||||
if (this.samplePolityHistories) this.samplePolityHistories();
|
||||
this.syncTilePopulationCulture();
|
||||
this.samplePolityHistories();
|
||||
this.recomputeTerritories({ allowOwnershipChanges: true, rebalanceOwnership: true, reason: "normal" });
|
||||
this.reconcileCityTerritoryOwnership();
|
||||
this.campaignPressureOnTerritories();
|
||||
|
|
@ -5741,9 +5892,6 @@ if (!this.payRouteConstructionCost(center, target, path, polity)) continue;
|
|||
this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id);
|
||||
}
|
||||
}
|
||||
updateTradeRoutesFieldBased() {
|
||||
return this.updateTradeRoutes(true);
|
||||
}
|
||||
routeCityLinkLimit(city) {
|
||||
if (!city) return 1;
|
||||
const capitalBonus = city.polityId !== null && this.getPolityById(city.polityId)?.centerCityId === city.id ? 1 : 0;
|
||||
|
|
@ -6415,8 +6563,6 @@ e.population = 0;
|
|||
e.diversity = 0;
|
||||
e.centroidX = 0;
|
||||
e.centroidY = 0;
|
||||
e.activeTraitPopulation = 0;
|
||||
e.traitSums = emptyTraitSums();
|
||||
}
|
||||
for (const band of this.nomadBands || []) {
|
||||
const e = this.ethnicities.get(band.ethnicityId);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue