diff --git a/app-state.js b/app-state.js index 1dc2f68..81f9eb7 100644 --- a/app-state.js +++ b/app-state.js @@ -41,10 +41,11 @@ scale: null }; const legendByMode = { terrain: () => terrainInfo.map(t => `${t.name}`).join(""), -ethnicity: () => "Color = tile-level dominant ethnicity. Stronger color marks higher local population; mixed areas are muted.", -pressure: () => "High local population pressure", -polities: () => "Color = territorial state control. Brighter areas are stronger control; pale borders mark frontiers; contested areas are unstable.", -technology: () => "Farming knowledgeMetallurgy knowledge", -pheromone: () => "Pheromone strengthFormal trade routes", -resources: () => "Regenerating local resource stock" +ethnicity: () => "Ethnicity", +pressure: () => "Pressure", +polities: () => "States", +nomads: () => "Nomads", +technology: () => "Technology", +pheromone: () => "Routes", +resources: () => "Resources" }; diff --git a/boot.js b/boot.js index 7a24abc..a4f3861 100644 --- a/boot.js +++ b/boot.js @@ -45,12 +45,12 @@ scheduleLoop(); } function reset() { const size = Number(els.worldSize.value); -const count = Number(els.agentCount.value); +const initialPopulation = Number(els.agentCount.value); els.canvas.width = size; els.canvas.height = size; renderImage = null; renderImageSize = 0; -sim = new Simulation(size, count); +sim = new Simulation(size, initialPopulation); setLegend(); drawFrame(true); renderStateGraph(); diff --git a/config.js b/config.js index 40b42fe..7a2b9c5 100644 --- a/config.js +++ b/config.js @@ -17,13 +17,8 @@ const MONTHS_PER_YEAR = 12; const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH; const SimConfig = Object.freeze({ population: Object.freeze({ -maxAgentsFloor: 8000, -maxAgentsScale: 1.15, softCapStart: 0.85, hardCapScale: 1.35, -offspringCrowdingPenalty: 1.8, -minOffspringAcceptance: 0.035, -maxOffspringPerStep: 360, carryingCapacityBase: 2.4, carryingCapacityFertility: 7.8, carryingCapacityMineral: 1.8, @@ -75,6 +70,40 @@ maxDurationYears: 25, claimRadius: 8, maxTargetDistance: 48 }), +nomad: Object.freeze({ +enabled: true, +maxBands: 24, +enabledAfterYears: 120, +spawnCheckIntervalYears: 25, +baseSpawnChance: 0.28, +minPopulation: 80, +maxPopulation: 420, +minRadius: 5, +maxRadius: 14, +movementIntervalYears: 2, +maxMoveDistance: 9, +grazingGain: 0.035, +overgrazingDamage: 0.018, +resourceConsumption: 0.012, +herdSoftCapPerPop: 2.4, +herdHardCapPerPop: 4.0, +resourceSoftCapPerPop: 1.2, +resourceHardCapPerPop: 2.5, +grazingDiminishingPower: 1.35, +urbanAbsorptionControl: 0.55, +cityInteractionRadius: 8, +tradeChance: 0.22, +raidChance: 0.14, +invasionChance: 0.025, +confederationPopulation: 900, +confederationPrestige: 1.8, +splitPopulation: 1200, +sedentarizationChance: 0.035, +collapseCohesion: 0.12, +influenceCulturalTrace: 0.006, +influenceMobilePopulationTrace: 0.004, +visualDurationYears: 20 +}), polityAccess: Object.freeze({ enabled: true, maxSearchDepth: 8, @@ -92,6 +121,13 @@ minimumInfluence: 0.18 }), route: Object.freeze({ maxRouteLength: 42, +adaptiveMaxRouteLength: 72, +isolatedCityBootstrap: true, +isolatedRouteSearchLimit: 8, +isolatedRouteRadius: 4, +newRoutePheromoneSeed: 2.5, +newRouteGraceBoost: 18, +isolatedRouteMinStrength: 0.12, pheromoneDecay: 0.996, pheromoneDiffusion: 0.045, maxPheromone: 18, @@ -99,9 +135,20 @@ routePheromoneBuild: 0.18, routePheromoneMaintain: 0.12, routeUpkeepPerTile: 0.018, routeWeakThreshold: 8, -routeUnsupportedDecay: 7, +routeUnsupportedDecay: 3, routeMaintainedBoost: 10 }), +war: Object.freeze({ +pressureMemoryDecay: 0.88, +cityDamageMinRate: 0.003, +cityDamageMaxRate: 0.08, +captureLossBase: 0.08, +captureLossMax: 0.32, +exhaustionPopulationLossBase: 0.0007, +exhaustionPopulationLossMax: 0.012, +warZoneTileLossRate: 0.006, +majorSettlementThreshold: 1.25 +}), disaster: Object.freeze({ damageScale: 1.5, majorLossRate: 0.05, @@ -122,13 +169,9 @@ frontierWave: Object.freeze({ enabledAfterYears: 300, intervalYears: 80, chance: 0.45, -minAgents: 60, -maxAgents: 140, -maxPopulationRatio: 1.28, -edgeBand: 4, -mutation: 0.08, -minResources: 12, -maxResources: 28 +minPopulation: 60, +maxPopulation: 140, +edgeBand: 4 }) }); function years(value) { diff --git a/engine.js b/engine.js index b254e99..9fa1d51 100644 --- a/engine.js +++ b/engine.js @@ -37,6 +37,11 @@ this.settledPopulation = new Float32Array(this.count); this.mobilePopulation = new Float32Array(this.count); this.populationCapacity = new Float32Array(this.count); this.populationPressure = new Float32Array(this.count); +this.farmingKnowledge = new Float32Array(this.count); +this.metallurgyKnowledge = new Float32Array(this.count); +this.nomadInfluence = new Float32Array(this.count); +this.nomadBand = new Int32Array(this.count); +this.territoryOwner = new Int32Array(this.count); this.polity = new Int32Array(this.count); this.control = new Float32Array(this.count); this.claim = new Float32Array(this.count); @@ -44,6 +49,8 @@ this.contested = new Uint8Array(this.count); this.dominantEthnicity = new Int32Array(this.count); this.cultureDiversity = new Float32Array(this.count); this.city.fill(-1); +this.nomadBand.fill(-1); +this.territoryOwner.fill(-1); this.polity.fill(-1); this.dominantEthnicity.fill(-1); this.generate(); @@ -295,10 +302,9 @@ return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))]; } } class Simulation { -constructor(size, initialAgents) { +constructor(size, initialPopulation) { this.rng = new Rng(Date.now()); this.world = new World(size, this.rng); -this.agents = []; this.ethnicities = new Map(); this.cities = []; this.cityById = new Map(); @@ -306,6 +312,7 @@ this.cityBuckets = new Map(); this.polities = []; this.polityById = new Map(); this.wars = []; +this.nomadBands = []; this.disasters = []; this.disasterHistory = []; this.graphEvents = []; @@ -313,12 +320,12 @@ this.polityHistory = new Map(); this.deadPolityHistories = []; this.tradeLinks = []; this.activeTradeRouteTiles = new Set(); -this.ethnicDensityCache = new Map(); this.dominantEthnicityCache = new Map(); this.nextEthnicity = 1; this.nextCity = 1; this.nextPolity = 1; this.nextWar = 1; +this.nextNomadBand = 1; this.nextDisaster = 1; this.nextCampaign = 1; this.year = 0; @@ -326,43 +333,151 @@ this.deaths = 0; this.campaigns = []; this.campaignHistory = []; this.territorialClaims = new Map(); +this.territoryDebug = { +disconnectedTerritoryRemoved: 0, +independentCitiesAbsorbedByTerritory: 0, +cityTerritoryMismatchesFixed: 0, +invalidCampaignTargetsRejected: 0 +}; this.populationFieldInitialized = false; -this.maxAgents = Math.max( -Math.floor(initialAgents * SimConfig.population.maxAgentsScale), -SimConfig.population.maxAgentsFloor -); this.tileEthnicMix = new Map(); -this.tileEthnicities = new Map(); this.tileCultures = this.tileEthnicMix; -this.tileAgents = new Map(); -this.spawnInitialAgents(initialAgents); -this.seedInitialPopulationField(); -this.recomputeTerritories(); +this.activePopulationTiles = new Set(); +this.radiusOffsetCache = new Map(); +this.scratchFloatA = new Float32Array(this.world.count); +this.scratchFloatB = new Float32Array(this.world.count); +this.scratchFloatC = new Float32Array(this.world.count); +this.scratchIntA = new Int32Array(this.world.count); +this.scratchIntB = new Int32Array(this.world.count); +this.scratchUintA = new Uint8Array(this.world.count); +this.visitStamp = new Int32Array(this.world.count); +this.nextVisitStamp = 1; +this.cultureInfluence = new Float32Array(64); +this.cultureSeen = new Int32Array(64); +this.cultureSeenIds = []; +this.cultureStamp = 1; +this.cityScoreScratch = []; +this.pairCandidateScratch = []; +this.routeCandidateScratch = []; +this.supportedRouteScratch = new Set(); +this.populationMoveScratch = []; +this.activePopulationScratch = []; +this.technologySourceScratch = []; +this.routeCostScratch = new Float32Array(this.world.count); +this.routePrevScratch = new Int32Array(this.world.count); +this.routeHeapScratch = []; +this.seedInitialPopulationFieldDirectly(initialPopulation); +this.recomputeTerritories({ allowOwnershipChanges: true, reason: "initial" }); this.updateEthnicStats(); } -spawnInitialAgents(count) { -const founders = Math.max(5, Math.min(16, Math.round(count / 180))); -const desertFounders = Math.max(2, Math.floor(founders * 0.25)); -for (let e = 0; e < founders; e++) { -const desertFounder = e < desertFounders; -const origin = this.findHabitableTile(desertFounder ? Terrain.DESERT : null); -const originTile = this.world.idx(origin.x, origin.y); -const id = this.createEthnicity(0, { -temperature: this.world.temperature[originTile], -humidity: this.world.humidity[originTile] -}); -const baseTraits = desertFounder ? this.desertFounderTraits() : this.randomTraits(); -for (let n = 0; n < Math.floor(count / founders); n++) { -const spawn = this.findNearbySpawn(origin.x, origin.y, desertFounder ? Terrain.DESERT : null); -this.agents.push(this.makeAgent( -spawn.x, -spawn.y, -id, -mutateTraits(baseTraits, this.rng, 0.07), -desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15) -)); +markPopulationTile(tile) { +if (tile >= 0 && tile < this.world.count) this.activePopulationTiles.add(tile); +} +cleanupPopulationTile(tile) { +if (tile < 0 || tile >= this.world.count) return; +if ((this.world.population[tile] || 0) > 0.01 || this.tileEthnicMix.has(tile)) return; +this.activePopulationTiles.delete(tile); +} +rebuildActivePopulationTiles() { +const w = this.world; +for (const tile of this.activePopulationTiles) { +if ((w.population[tile] || 0) <= 0.01 && !this.tileEthnicMix.has(tile)) this.activePopulationTiles.delete(tile); +} +for (const tile of this.tileEthnicMix.keys()) { +if ((w.population[tile] || 0) > 0.01 || this.tileEthnicMix.get(tile)?.size) this.activePopulationTiles.add(tile); } } +activePopulationSnapshot() { +this.activePopulationScratch.length = 0; +for (const tile of this.activePopulationTiles) this.activePopulationScratch.push(tile); +return this.activePopulationScratch; +} +getRadiusOffsets(radius, includeCenter = true) { +const key = `${radius}:${includeCenter ? 1 : 0}`; +let offsets = this.radiusOffsetCache.get(key); +if (offsets) return offsets; +offsets = []; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const distance = Math.abs(dx) + Math.abs(dy); +if (distance > radius || (!includeCenter && distance === 0)) continue; +offsets.push({ dx, dy, distance }); +} +} +this.radiusOffsetCache.set(key, offsets); +return offsets; +} +forCardinalNeighbors(tile, visitor) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +if (x > 0 && visitor(tile - 1, x - 1, y) === false) return false; +if (x < w.size - 1 && visitor(tile + 1, x + 1, y) === false) return false; +if (y > 0 && visitor(tile - w.size, x, y - 1) === false) return false; +if (y < w.size - 1 && visitor(tile + w.size, x, y + 1) === false) return false; +return true; +} +nextVisitMarker() { +if (this.nextVisitStamp >= 2147483640) { +this.visitStamp.fill(0); +this.nextVisitStamp = 1; +} +return this.nextVisitStamp++; +} +ensureCultureScratch() { +const needed = Math.max(64, this.nextEthnicity + 8); +if (this.cultureInfluence.length >= needed) return; +let size = this.cultureInfluence.length; +while (size < needed) size *= 2; +this.cultureInfluence = new Float32Array(size); +this.cultureSeen = new Int32Array(size); +this.cultureStamp = 1; +} +nextCultureMarker() { +this.ensureCultureScratch(); +if (this.cultureStamp >= 2147483640) { +this.cultureSeen.fill(0); +this.cultureStamp = 1; +} +return this.cultureStamp++; +} +topHeapPush(heap, item, limit, scoreKey) { +if (limit <= 0) return; +if (heap.length < limit) { +heap.push(item); +this.heapSiftUp(heap, heap.length - 1, scoreKey); +return; +} +if (item[scoreKey] <= heap[0][scoreKey]) return; +heap[0] = item; +this.heapSiftDown(heap, 0, scoreKey); +} +heapSiftUp(heap, index, scoreKey) { +while (index > 0) { +const parent = (index - 1) >> 1; +if (heap[parent][scoreKey] <= heap[index][scoreKey]) break; +const swap = heap[parent]; +heap[parent] = heap[index]; +heap[index] = swap; +index = parent; +} +} +heapSiftDown(heap, index, scoreKey) { +for (;;) { +let smallest = index; +const left = index * 2 + 1; +const right = left + 1; +if (left < heap.length && heap[left][scoreKey] < heap[smallest][scoreKey]) smallest = left; +if (right < heap.length && heap[right][scoreKey] < heap[smallest][scoreKey]) smallest = right; +if (smallest === index) return; +const swap = heap[index]; +heap[index] = heap[smallest]; +heap[smallest] = swap; +index = smallest; +} +} +heapToDescending(heap, scoreKey) { +return heap.sort((a, b) => b[scoreKey] - a[scoreKey]); } createEthnicity(parent, climate = null) { const id = this.nextEthnicity++; @@ -398,48 +513,6 @@ traits.reproductionThreshold = this.rng.range(24, 38); traits.sedentary = this.rng.range(0.18, 0.58); return traits; } -makeAgent(x, y, ethnicity, traits, resources) { -return { -x, -y, -resources, -ethnicity, -traits, -alive: true, -foreignContact: 0, -contactEthnicity: ethnicity, -settled: 0, -movedThisStep: false, -tech: { -farming: 0, -metallurgy: 0 -}, -farmingWork: 0, -lastFarmTile: -1, -tradeOriginCityId: null, -lastTradeCityId: null, -tradeMemory: 0, -tradeCooldown: 0 -}; -} -findNearbySpawn(originX, originY, preferredTerrain = null) { -const w = this.world; -let best = { x: originX, y: originY }; -let bestScore = -Infinity; -for (let tries = 0; tries < 24; tries++) { -const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1); -const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1); -const i = w.idx(x, y); -if (w.terrain[i] === Terrain.WATER) continue; -const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0; -const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16; -if (score > bestScore) { -bestScore = score; -best = { x, y }; -} -} -return best; -} findHabitableTile(preferredTerrain = null) { if (preferredTerrain !== null) { const exact = []; @@ -502,99 +575,643 @@ if (this.year < years(cfg.enabledAfterYears ?? 300)) return; const interval = years(cfg.intervalYears ?? 80); if (!interval || this.year % interval !== 0) return; if (this.rng.next() > (cfg.chance ?? 0.45)) return; -const populationLimit = this.maxAgents * (cfg.maxPopulationRatio ?? 1.28); -if (this.agents.length >= populationLimit) return; +if (SimConfig.nomad?.enabled && this.nomadBands.length < (SimConfig.nomad.maxBands ?? 24) && this.rng.next() < 0.55) { +const tile = this.findNomadSpawnTile(); +if (tile != null && this.spawnNomadBand(tile)) return; +} +this.spawnFrontierPopulationWave(); +} +spawnFrontierPopulationWave() { +const cfg = SimConfig.frontierWave || {}; const side = this.rng.int(4); -const targetCount = this.rng.int(Math.max(1, (cfg.maxAgents ?? 140) - (cfg.minAgents ?? 60) + 1)) + (cfg.minAgents ?? 60); const origin = this.frontierSpawnTile(side, cfg.edgeBand ?? 4); const originTile = this.world.idx(origin.x, origin.y); const ethnicity = this.createEthnicity(0, { temperature: this.world.temperature[originTile], humidity: this.world.humidity[originTile] }); -const baseTraits = this.randomTraits(); -baseTraits.mobility = this.rng.range(0.65, 1.0); -baseTraits.sedentary = this.rng.range(0.02, 0.35); -baseTraits.ethnocentrism = this.rng.range(0.42, 0.95); -baseTraits.assimilation = this.rng.range(0.02, 0.22); -baseTraits.resourceAttraction = this.rng.range(0.78, 1.15); -let spawned = 0; -for (let n = 0; n < targetCount && this.agents.length < populationLimit; n++) { -const spawn = this.frontierSpawnTile(side, cfg.edgeBand ?? 4); +const traits = this.randomTraits(); +traits.mobility = this.rng.range(0.65, 1.0); +traits.sedentary = this.rng.range(0.02, 0.28); +traits.ethnocentrism = this.rng.range(0.42, 0.95); +traits.assimilation = this.rng.range(0.02, 0.22); +traits.resourceAttraction = this.rng.range(0.78, 1.15); +const lineage = this.ethnicities.get(ethnicity); +if (lineage) lineage.averageTraits = traits; +const total = this.rng.range(cfg.minPopulation ?? 60, cfg.maxPopulation ?? 140); +let seeded = 0; +for (let n = 0; n < 7; n++) { +const spawn = this.frontierSpawnTile(side, (cfg.edgeBand ?? 4) + 3); const tile = this.world.idx(spawn.x, spawn.y); if (this.world.terrain[tile] === Terrain.WATER) continue; -const agent = this.makeAgent( -spawn.x, -spawn.y, -ethnicity, -mutateTraits(baseTraits, this.rng, cfg.mutation ?? 0.08), -this.rng.range(cfg.minResources ?? 12, cfg.maxResources ?? 28) -); -agent.tradeMemory = this.rng.range(0.05, 0.22); -this.agents.push(agent); -if (this.addAgentToOccupancy) this.addAgentToOccupancy(agent); -spawned++; +if (this.world.control[tile] > 0.65 && this.world.polity[tile] >= 0) continue; +const amount = total * this.rng.range(0.08, 0.22); +this.seedEthnicPopulationPatch(tile, ethnicity, traits, amount, 2); +seeded += amount; } -if (spawned <= 0) this.ethnicities.delete(ethnicity); -else this.addGraphEvent("newEthnicity", this.year, { +if (seeded <= 0) { +this.ethnicities.delete(ethnicity); +return; +} +this.syncTilePopulationCulture(); +this.addGraphEvent("newEthnicity", this.year, { ethnicity, -population: spawned +population: Math.round(seeded) }); } +maybeSpawnNomadBand() { +const cfg = SimConfig.nomad || {}; +if (!cfg.enabled) return; +if (this.year < years(cfg.enabledAfterYears ?? 120)) return; +const interval = years(cfg.spawnCheckIntervalYears ?? 25); +if (!interval || this.year % interval !== 0) return; +if (this.nomadBands.length >= (cfg.maxBands ?? 24)) return; +if (this.rng.next() > (cfg.baseSpawnChance ?? 0.28)) return; +const tile = this.findNomadSpawnTile(); +if (tile == null) return; +this.spawnNomadBand(tile); +} +findNomadSpawnTile() { +const w = this.world; +let best = null; +let bestScore = -Infinity; +for (let tries = 0; tries < 240; tries++) { +const edgeBias = this.rng.next() < 0.55; +let x = this.rng.int(w.size); +let y = this.rng.int(w.size); +if (edgeBias) { +const side = this.rng.int(4); +const band = Math.max(3, Math.floor(w.size * 0.12)); +if (side === 0) y = this.rng.int(band); +else if (side === 1) y = w.size - 1 - this.rng.int(band); +else if (side === 2) x = this.rng.int(band); +else x = w.size - 1 - this.rng.int(band); +} +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER || w.city[tile] >= 0) continue; +if (w.control[tile] > 0.55 && w.polity[tile] >= 0) continue; +if (this.getCitiesNear(x, y, 8).length) continue; +if ((w.cityPull[tile] || 0) > 0.35) continue; +if ((w.settledPopulation[tile] || 0) > 8 || ((w.settledPopulation[tile] || 0) > (w.mobilePopulation[tile] || 0) * 1.8 && (w.settledPopulation[tile] || 0) > 4)) continue; +if ((w.control[tile] || 0) > 0.45 && (w.populationPressure[tile] || 0) > 0.4) continue; +const dryland = w.terrain[tile] === Terrain.DESERT ? 0.9 : 0; +const plain = w.terrain[tile] === Terrain.PLAINS ? 0.8 : w.terrain[tile] === Terrain.FOREST ? 0.15 : 0; +const frontier = w.polity[tile] < 0 ? 0.55 : (w.control[tile] < 0.35 ? 0.25 : -0.35); +const mobile = clamp((w.mobilePopulation[tile] || 0) / 20, 0, 1.2); +const route = w.tradeRoute[tile] ? 0.25 : clamp(w.pheromone[tile] / 18, 0, 0.25); +const pressure = clamp(w.populationPressure[tile] || 0, 0, 2); +const score = dryland + plain + frontier + mobile + route + w.fertility[tile] * 0.55 + clamp(w.resource[tile] / 35, 0, 0.8) - pressure * 0.8 - w.move[tile] * 0.35 + this.rng.range(0, 0.6); +if (score > bestScore) { +best = tile; +bestScore = score; +} +} +return best; +} +spawnNomadBand(originTile, options = {}) { +const cfg = SimConfig.nomad || {}; +const w = this.world; +if (originTile == null || w.terrain[originTile] === Terrain.WATER) return null; +let ethnicityId = options.ethnicityId; +if (!ethnicityId) { +ethnicityId = this.createEthnicity(0, { +temperature: w.temperature[originTile], +humidity: w.humidity[originTile] +}); +const ethnicity = this.ethnicities.get(ethnicityId); +if (ethnicity) { +ethnicity.averageTraits = { +...this.randomTraits(), +mobility: this.rng.range(0.72, 1.0), +sedentary: this.rng.range(0.02, 0.24), +ethnocentrism: this.rng.range(0.35, 0.9), +assimilation: this.rng.range(0.02, 0.18) +}; +} +} +const population = options.population ?? Math.round(this.rng.range(cfg.minPopulation ?? 80, cfg.maxPopulation ?? 420)); +const band = { +id: this.nextNomadBand++, +name: `Band ${this.nextNomadBand - 1}`, +ethnicityId, +x: originTile % w.size, +y: Math.floor(originTile / w.size), +radius: Math.round(this.rng.range(cfg.minRadius ?? 5, cfg.maxRadius ?? 14)), +population, +herds: options.herds ?? population * this.rng.range(0.8, 1.8), +resources: options.resources ?? population * this.rng.range(0.10, 0.28), +cohesion: this.rng.range(0.42, 0.86), +prestige: this.rng.range(0.15, 0.75), +charisma: this.rng.range(0.55, 1.55), +aggression: this.rng.range(0.12, 0.82), +tradeAffinity: this.rng.range(0.18, 0.86), +raidAffinity: this.rng.range(0.12, 0.88), +migrationPressure: this.rng.range(0.18, 0.72), +sedentarization: this.rng.range(0.02, 0.22), +targetTile: originTile, +targetCityId: null, +mode: "roaming", +born: this.year, +lastMoveYear: -Infinity, +lastRaidYear: -Infinity, +lastTradeYear: -Infinity, +lastSplitYear: -Infinity, +trailTiles: [originTile], +influenceTiles: [], +memory: {} +}; +this.nomadBands.push(band); +this.seedEthnicPopulationPatch(originTile, ethnicityId, this.ethnicities.get(ethnicityId)?.averageTraits || this.randomTraits(), population * 0.22, 2); +this.addGraphEvent("nomadBand", this.year, { bandId: band.id, ethnicity: ethnicityId, population }, 1); +this.updateNomadInfluence(); +return band; +} +clearNomadInfluence() { +const w = this.world; +if (!w.nomadInfluence || !w.nomadBand) return; +w.nomadInfluence.fill(0); +w.nomadBand.fill(-1); +} +updateNomadInfluence() { +const w = this.world; +const cfg = SimConfig.nomad || {}; +this.clearNomadInfluence(); +for (const band of this.nomadBands) { +band.influenceTiles = []; +const cx = Math.round(band.x); +const cy = Math.round(band.y); +const popScale = clamp(Math.log1p(band.population) / Math.log(1200), 0.2, 1.3); +const strength = popScale * clamp(band.cohesion, 0.15, 1) * (0.65 + band.prestige * 0.22); +for (const offset of this.getRadiusOffsets(band.radius)) { +const dx = offset.dx; +const dy = offset.dy; +const distance = offset.distance; +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const local = strength * (1 - distance / (band.radius + 1)); +if (local > w.nomadInfluence[tile]) { +w.nomadInfluence[tile] = local; +w.nomadBand[tile] = band.id; +} +band.influenceTiles.push(tile); +if (local > 0.18 && this.rng.next() < (cfg.influenceCulturalTrace ?? 0.006)) { +const amount = Math.min(0.06, band.population * (cfg.influenceMobilePopulationTrace ?? 0.004) * local); +this.addNomadTrace(tile, band.ethnicityId, amount); +} +} +} +} +addNomadTrace(tile, ethnicityId, amount) { +if (amount <= 0 || ethnicityId == null) return; +const w = this.world; +w.population[tile] += amount; +w.mobilePopulation[tile] += amount; +this.markPopulationTile(tile); +let mix = this.tileEthnicMix.get(tile); +if (!mix) { +mix = new Map(); +this.tileEthnicMix.set(tile, mix); +} +mix.set(ethnicityId, (mix.get(ethnicityId) || 0) + amount); +this.updateCultureTile(tile); +} +updateNomadBands() { +const cfg = SimConfig.nomad || {}; +if (!cfg.enabled) return; +this.maybeSpawnNomadBand(); +const interval = years(cfg.movementIntervalYears ?? 2); +for (const band of [...this.nomadBands]) { +if (!this.nomadBands.includes(band)) continue; +this.sanitizeNomadBand(band); +if (!this.nomadBands.includes(band)) continue; +if (this.year - (band.lastMoveYear ?? -Infinity) >= interval) { +this.chooseNomadTarget(band); +this.moveNomadBandTowardTarget(band); +band.lastMoveYear = this.year; +} +this.grazeNomadBand(band); +this.sanitizeNomadBand(band); +if (!this.nomadBands.includes(band)) continue; +this.interactNomadsWithCities(band); +const urbanPressure = this.nomadUrbanPressure(band); +if (urbanPressure > (cfg.urbanAbsorptionControl ?? 0.55)) this.maybeAbsorbNomadIntoUrbanRegion(band, urbanPressure); +if (!this.nomadBands.includes(band) || band.population < 25) continue; +this.maybeNomadRaidOrInvasion(band); +this.maybeFormNomadConfederation(band); +this.maybeMergeWeakNomadBand(band); +if (!this.nomadBands.includes(band)) continue; +this.splitNomadBand(band); +this.sanitizeNomadBand(band); +} +this.nomadBands = this.nomadBands.filter(band => band.population >= 25 && band.cohesion > (cfg.collapseCohesion ?? 0.12)); +this.updateNomadInfluence(); +} +chooseNomadTarget(band) { +const w = this.world; +const cfg = SimConfig.nomad || {}; +let bestTile = w.idx(Math.round(band.x), Math.round(band.y)); +let bestScore = -Infinity; +const range = cfg.maxMoveDistance ?? 9; +for (let tries = 0; tries < 80; tries++) { +const x = clamp(Math.round(band.x) + this.rng.int(range * 2 + 1) - range, 0, w.size - 1); +const y = clamp(Math.round(band.y) + this.rng.int(range * 2 + 1) - range, 0, w.size - 1); +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +const urbanPressure = this.nomadUrbanPressureAtTile(tile, band); +const terrainSuitability = w.terrain[tile] === Terrain.PLAINS ? 1.1 : w.terrain[tile] === Terrain.DESERT ? 0.85 : w.terrain[tile] === Terrain.FOREST ? 0.25 : -0.8; +const statePenalty = clamp(w.control[tile] || 0, 0, 1) * (band.aggression > 0.65 ? 0.35 : 1.25); +const cityPenalty = city ? (band.mode === "raiding" || band.mode === "trading" ? -0.25 : 1.2) : 0; +const urbanPenalty = urbanPressure * (band.mode === "raiding" || band.mode === "invading" ? 0.75 : 2.4); +const sameTrace = w.dominantEthnicity[tile] === band.ethnicityId ? 0.55 : 0; +const raidAttraction = band.aggression * band.raidAffinity > 0.42 && city ? clamp((city.storedResources || 0) / 120, 0, 1.4) : 0; +const score = +terrainSuitability + +w.fertility[tile] * 0.7 + +clamp(w.resource[tile] / 35, 0, 1.2) + +(w.tradeRoute[tile] ? 0.45 : 0) + +sameTrace + +raidAttraction - +statePenalty - +cityPenalty - +urbanPenalty - +(w.populationPressure[tile] || 0) * 0.55 - +(w.terrain[tile] === Terrain.MOUNTAIN ? 1.2 : 0) + +this.rng.range(0, 0.5); +if (score > bestScore) { +bestScore = score; +bestTile = tile; +} +} +band.targetTile = bestTile; +band.mode = bestScore > 1.2 ? "grazing" : "roaming"; +} +moveNomadBandTowardTarget(band) { +const w = this.world; +const target = band.targetTile; +if (target == null) return; +const tx = target % w.size; +const ty = Math.floor(target / w.size); +let x = Math.round(band.x); +let y = Math.round(band.y); +const steps = Math.max(1, Math.min(3, Math.abs(tx - x) + Math.abs(ty - y))); +for (let step = 0; step < steps; step++) { +let best = w.idx(x, y); +let bestScore = Infinity; +for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [0, 0]]) { +const nx = x + dx; +const ny = y + dy; +if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue; +const tile = w.idx(nx, ny); +if (w.terrain[tile] === Terrain.WATER) continue; +const urbanPenalty = this.nomadUrbanPressureAtTile(tile, band) * (band.mode === "raiding" || band.mode === "invading" ? 0.5 : 1.35); +const score = Math.abs(nx - tx) + Math.abs(ny - ty) + w.move[tile] * 0.35 + clamp(w.control[tile], 0, 1) * 0.55 + urbanPenalty; +if (score < bestScore) { +bestScore = score; +best = tile; +} +} +x = best % w.size; +y = Math.floor(best / w.size); +} +band.x = x; +band.y = y; +const tile = w.idx(x, y); +band.trailTiles.push(tile); +while (band.trailTiles.length > 32) band.trailTiles.shift(); +} +grazeNomadBand(band) { +const w = this.world; +const cfg = SimConfig.nomad || {}; +const tile = w.idx(Math.round(band.x), Math.round(band.y)); +if (w.terrain[tile] === Terrain.WATER) return; +const terrainMultiplier = +w.terrain[tile] === Terrain.PLAINS ? 1.15 : +w.terrain[tile] === Terrain.DESERT ? 0.75 : +w.terrain[tile] === Terrain.FOREST ? 0.45 : +w.terrain[tile] === Terrain.MOUNTAIN ? 0.25 : 0; +const urbanPressure = this.nomadUrbanPressure(band); +const pasture = band.population * terrainMultiplier * (0.8 + w.fertility[tile] * 1.4 + clamp(w.resource[tile] / 35, 0, 1) * 0.8) * clamp(1 - urbanPressure * 0.22, 0.35, 1); +const herdSoftCap = band.population * (cfg.herdSoftCapPerPop ?? 2.4); +const herdHardCap = band.population * (cfg.herdHardCapPerPop ?? 4.0); +const resourceSoftCap = band.population * (cfg.resourceSoftCapPerPop ?? 1.2); +const resourceHardCap = band.population * (cfg.resourceHardCapPerPop ?? 2.5); +const herdPressure = band.herds / Math.max(1, pasture); +const herdGrowthRate = (cfg.grazingGain ?? 0.035) * Math.max(0, 1 - Math.pow(herdPressure, cfg.grazingDiminishingPower ?? 1.35)); +band.herds += band.herds * herdGrowthRate; +band.herds -= band.population * 0.008; +if (band.herds > herdSoftCap) band.herds -= (band.herds - herdSoftCap) * 0.06; +band.herds = clamp(band.herds, 0, herdHardCap); +const produced = Math.min(band.herds * 0.0035, band.population * 0.035) * clamp(1 - urbanPressure * 0.18, 0.4, 1); +const upkeep = band.population * (cfg.resourceConsumption ?? 0.012); +band.resources += produced - upkeep; +if (band.resources > resourceSoftCap) band.resources -= (band.resources - resourceSoftCap) * 0.08; +band.resources = clamp(band.resources, 0, resourceHardCap); +w.resource[tile] = Math.max(0, w.resource[tile] - band.population * (cfg.overgrazingDamage ?? 0.018) * 0.02); +if (band.resources < band.population * 0.03) band.cohesion = clamp(band.cohesion - 0.012, 0, 1); +else band.cohesion = clamp(band.cohesion + 0.003, 0, 1); +} +sanitizeNomadBand(band) { +const cfg = SimConfig.nomad || {}; +if (!Number.isFinite(band.population)) band.population = cfg.minPopulation ?? 80; +if (!Number.isFinite(band.herds)) band.herds = Math.max(0, band.population * 1.1); +if (!Number.isFinite(band.resources)) band.resources = Math.max(0, band.population * 0.18); +band.population = Math.max(0, band.population); +band.herds = clamp(Math.max(0, band.herds), 0, Math.max(1, band.population) * (cfg.herdHardCapPerPop ?? 4.0)); +band.resources = clamp(Math.max(0, band.resources), 0, Math.max(1, band.population) * (cfg.resourceHardCapPerPop ?? 2.5)); +if (band.population < 25) this.nomadBands = this.nomadBands.filter(candidate => candidate !== band); +return band; +} +nomadUrbanPressure(band) { +if (!band) return 0; +const w = this.world; +const tile = w.idx(clamp(Math.round(band.x), 0, w.size - 1), clamp(Math.round(band.y), 0, w.size - 1)); +return this.nomadUrbanPressureAtTile(tile, band); +} +nomadUrbanPressureAtTile(tile, band = null) { +const w = this.world; +if (tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) return 0; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +const cities = this.getCitiesNear(x, y, 12); +let cityPopulation = 0; +let nearest = Infinity; +for (const city of cities) { +cityPopulation += city.population || 0; +nearest = Math.min(nearest, Math.abs(city.x - x) + Math.abs(city.y - y)); +} +const localPopulation = w.population[tile] || 0; +const settled = w.settledPopulation[tile] || 0; +const mobile = w.mobilePopulation[tile] || 0; +const settledRatio = localPopulation > 0 ? settled / Math.max(1, localPopulation) : 0; +const urbanCore = nearest <= 4 ? 0.35 : nearest <= 8 ? 0.18 : 0; +const score = +clamp(cityPopulation / 650, 0, 1.8) * 0.42 + +clamp(cities.length / 4, 0, 1.5) * 0.34 + +clamp(w.cityPull[tile] || 0, 0, 1) * 0.58 + +clamp(w.control[tile] || 0, 0, 1) * 0.36 + +clamp(w.populationPressure[tile] || 0, 0, 2) * 0.22 + +clamp(settledRatio, 0, 1) * 0.34 + +clamp(settled / Math.max(1, mobile + 3), 0, 3) * 0.12 + +urbanCore; +return clamp(score, 0, 2.2); +} +maybeAbsorbNomadIntoUrbanRegion(band, urbanPressure = this.nomadUrbanPressure(band)) { +const cfg = SimConfig.nomad || {}; +const radius = urbanPressure > 1.1 ? 12 : 10; +const city = this.findCityNear(Math.round(band.x), Math.round(band.y), radius); +if (!city) { +band.targetTile = null; +band.mode = "leaving"; +band.cohesion = clamp(band.cohesion - urbanPressure * 0.008, 0, 1); +return false; +} +const absorbRate = clamp(0.08 + (urbanPressure - (cfg.urbanAbsorptionControl ?? 0.55)) * 0.12, 0.08, 0.25); +let migrants = Math.max(4, band.population * absorbRate); +const dissolve = band.population < 40 || urbanPressure > 1.35; +if (dissolve) migrants = band.population; +this.transferNomadsToCity(band, city, migrants, "urbanAbsorption"); +if (dissolve || band.population < 25) this.nomadBands = this.nomadBands.filter(candidate => candidate !== band); +return true; +} +transferNomadsToCity(band, city, migrants, reason = "sedentarization") { +if (!band || !city || migrants <= 0 || band.population <= 0) return 0; +const moved = Math.min(band.population, migrants); +const share = moved / Math.max(1, band.population); +band.population -= moved; +band.herds = Math.max(0, band.herds * (1 - share * 0.82)); +const resourceTransfer = band.resources * share * 0.45; +band.resources = Math.max(0, band.resources - resourceTransfer); +city.population += moved; +city.storedResources += resourceTransfer; +city.ethnicityComposition.set(band.ethnicityId, (city.ethnicityComposition.get(band.ethnicityId) || 0) + moved); +const tile = this.world.idx(city.x, city.y); +this.world.population[tile] += moved * 0.15; +this.world.settledPopulation[tile] += moved; +this.markPopulationTile(tile); +this.addNomadTrace(tile, band.ethnicityId, moved * 0.10); +band.sedentarization = clamp((band.sedentarization || 0) + 0.05, 0, 1); +band.mode = "settling"; +if (this.year - (band.lastUrbanAbsorptionYear ?? -Infinity) > years(18) && (moved >= 30 || reason === "sedentarization")) { +this.addGraphEvent("sedentarization", this.year, { bandId: band.id, cityId: city.id, population: Math.round(moved), reason }, 1); +band.lastUrbanAbsorptionYear = this.year; +} +return moved; +} +interactNomadsWithCities(band) { +const cfg = SimConfig.nomad || {}; +const cities = this.getCitiesNear(Math.round(band.x), Math.round(band.y), cfg.cityInteractionRadius ?? 8); +if (!cities.length) return; +for (const city of cities.slice(0, 3)) { +const exposed = city.polityId === null ? 0.7 : clamp(1 - (this.getPolityById(city.polityId)?.cohesion ?? 0.6), 0, 0.8) + clamp(1 - city.loyalty, 0, 1) * 0.5; +const hungry = band.resources < band.population * 0.08 ? 0.25 : 0; +if (this.year - (band.lastTradeYear ?? -Infinity) > years(6) && this.rng.next() < (cfg.tradeChance ?? 0.22) * band.tradeAffinity) { +this.nomadTradeWithCity(band, city); +} +if (this.year - (band.lastRaidYear ?? -Infinity) > years(8) && this.rng.next() < (cfg.raidChance ?? 0.14) * (band.raidAffinity + hungry + exposed * 0.4)) { +this.nomadRaidCity(band, city, false); +} +if (this.rng.next() < (cfg.sedentarizationChance ?? 0.035) * band.sedentarization) this.sedentarizeNomadsIntoCity(band, city); +} +} +nomadTradeWithCity(band, city) { +const value = Math.min(city.storedResources * 0.05, Math.max(2, band.population * 0.025)); +city.storedResources += value * 0.35; +band.resources += value; +band.prestige = clamp(band.prestige + 0.025, 0, 5); +band.mode = "trading"; +band.lastTradeYear = this.year; +const path = this.findTerrainRoute(Math.round(band.x), Math.round(band.y), city.x, city.y); +if (this.isValidRoutePath(path, city.x, city.y)) this.depositRoutePheromone(path, 0.9); +this.addGraphEvent("nomadTrade", this.year, { bandId: band.id, cityId: city.id }, 1); +} +nomadRaidCity(band, city, severe = false) { +const loot = Math.min(city.storedResources * (severe ? 0.38 : 0.18), band.population * (severe ? 0.16 : 0.08)); +city.storedResources = Math.max(0, city.storedResources - loot); +const loss = Math.min(city.population * (severe ? 0.10 : 0.035), band.population * 0.035); +if (loss > 0) { +city.population = Math.max(0, city.population - loss); +removeFromComposition(city.ethnicityComposition, loss); +} +city.loyalty = clamp((city.loyalty ?? 0.5) - (severe ? 0.10 : 0.035), 0, 1); +if (city.polityId !== null) { +const polity = this.getPolityById(city.polityId); +if (polity) { +polity.cohesion = clamp((polity.cohesion ?? 0.6) - (severe ? 0.035 : 0.012), 0, 1); +polity.treasury = Math.max(0, (polity.treasury || 0) - loot * 0.18); +} +} +band.resources += loot; +band.prestige = clamp(band.prestige + (severe ? 0.18 : 0.06), 0, 5); +band.mode = severe ? "invading" : "raiding"; +band.lastRaidYear = this.year; +this.addGraphEvent("nomadRaid", this.year, { bandId: band.id, cityId: city.id, severe }, severe ? 2 : 1); +} +sedentarizeNomadsIntoCity(band, city) { +if (band.population < 60) return; +const migrants = Math.min(band.population * this.rng.range(0.08, 0.22), Math.max(8, city.population * 0.12)); +this.transferNomadsToCity(band, city, migrants); +} +maybeNomadRaidOrInvasion(band) { +const cfg = SimConfig.nomad || {}; +if (band.population < (cfg.confederationPopulation ?? 900) * 0.55) return; +if (band.cohesion < 0.55 || band.prestige < 1.0 || band.aggression < 0.55) return; +if (this.rng.next() > (cfg.invasionChance ?? 0.025) * band.aggression * band.raidAffinity) return; +const cities = this.getCitiesNear(Math.round(band.x), Math.round(band.y), cfg.cityInteractionRadius ?? 8) +.filter(city => city.population > 20) +.sort((a, b) => this.nomadCityWeaknessScore(band, b) - this.nomadCityWeaknessScore(band, a)); +if (cities.length) this.resolveNomadInvasion(band, cities[0]); +} +nomadCityWeaknessScore(band, city) { +const polity = city.polityId !== null ? this.getPolityById(city.polityId) : null; +const crisis = polity ? (polity.crisis || 0) + clamp(1 - (polity.cohesion ?? 0.6), 0, 1) : 0.7; +return crisis + clamp(1 - (city.loyalty ?? 0.5), 0, 1) + clamp((city.storedResources || 0) / 160, 0, 1.2) - Math.sqrt(city.population || 1) / 80; +} +resolveNomadInvasion(band, city) { +const score = band.population * band.cohesion * (0.5 + band.prestige * 0.25) * band.aggression; +const defense = Math.max(20, city.population * (0.55 + (city.loyalty ?? 0.5)) + (city.polityId !== null ? this.polityPower(this.getPolityById(city.polityId)) * 0.6 : 0)); +if (this.rng.next() > clamp(score / Math.max(1, score + defense), 0.08, 0.72)) { +band.cohesion = clamp(band.cohesion - 0.08, 0, 1); +band.resources = Math.max(0, band.resources - band.population * 0.06); +return; +} +const outcomeRoll = this.rng.next(); +if (outcomeRoll < 0.45) { +this.nomadRaidCity(band, city, true); +} else if (outcomeRoll < 0.75) { +const oldPolityId = city.polityId; +if (city.polityId !== null) this.removeCityFromPolity(city); +this.releaseCityTerritory(city, oldPolityId, 5, "nomadConquest"); +city.loyalty = 0.22; +band.prestige = clamp(band.prestige + 0.28, 0, 5); +this.addGraphEvent("nomadConquest", this.year, { bandId: band.id, cityId: city.id, outcome: "puppetIndependent" }, 3); +} else { +const oldPolityId = city.polityId; +if (city.polityId !== null) this.removeCityFromPolity(city); +this.releaseCityTerritory(city, oldPolityId, 5, "nomadConquest"); +const settlers = Math.min(band.population * 0.28, Math.max(30, city.population * 0.35)); +band.population -= settlers; +city.population += settlers; +city.ethnicityComposition.set(band.ethnicityId, (city.ethnicityComposition.get(band.ethnicityId) || 0) + settlers); +const polity = this.createPolity(city); +if (polity) { +polity.origin = "nomad"; +polity.type = "nomadic_dynasty"; +} +band.prestige = clamp(band.prestige + 0.45, 0, 5); +band.mode = "invading"; +this.addGraphEvent("nomadConquest", this.year, { bandId: band.id, cityId: city.id, outcome: "nomadicDynasty" }, 4); +} +} +maybeFormNomadConfederation(band) { +const cfg = SimConfig.nomad || {}; +if (band.population < (cfg.confederationPopulation ?? 900) || band.prestige < (cfg.confederationPrestige ?? 1.8) || band.cohesion < 0.58) return; +band.mode = "confederating"; +band.radius = Math.min((cfg.maxRadius ?? 14) + 8, band.radius + 1); +band.charisma = clamp(band.charisma + 0.05, 0.5, 2.4); +for (const other of [...this.nomadBands]) { +if (other === band || other.population > band.population * 0.55) continue; +const distance = Math.abs(other.x - band.x) + Math.abs(other.y - band.y); +if (distance > band.radius + other.radius + 8) continue; +if (this.rng.next() < 0.18 * band.charisma) this.mergeNomadBands(band, other); +} +this.addGraphEvent("nomadConfederation", this.year, { bandId: band.id, population: Math.round(band.population) }, 2); +} +mergeNomadBands(leaderBand, subordinateBand) { +leaderBand.population += subordinateBand.population; +leaderBand.herds += subordinateBand.herds; +leaderBand.resources += subordinateBand.resources * 0.7; +leaderBand.prestige = clamp(leaderBand.prestige + subordinateBand.prestige * 0.18, 0, 5); +leaderBand.cohesion = clamp((leaderBand.cohesion + subordinateBand.cohesion) * 0.48, 0, 1); +this.nomadBands = this.nomadBands.filter(band => band !== subordinateBand); +this.sanitizeNomadBand(leaderBand); +} +maybeMergeWeakNomadBand(band) { +if (!this.nomadBands.includes(band) || band.population <= 0) return false; +let strongest = null; +let strongestDistance = Infinity; +for (const other of this.nomadBands) { +if (other === band || other.population <= band.population) continue; +const distance = Math.abs(other.x - band.x) + Math.abs(other.y - band.y); +if (distance > band.radius + other.radius + 6) continue; +if (band.population >= other.population * 0.45) continue; +const sameEthnicity = other.ethnicityId === band.ethnicityId; +const compatible = sameEthnicity || Math.abs((other.sedentarization || 0) - (band.sedentarization || 0)) < 0.18 || Math.abs((other.aggression || 0) - (band.aggression || 0)) < 0.22; +if (!compatible) continue; +if (distance < strongestDistance) { +strongest = other; +strongestDistance = distance; +} +} +if (!strongest) return false; +const important = band.population >= 120 || strongest.population + band.population >= (SimConfig.nomad.confederationPopulation ?? 900); +this.mergeNomadBands(strongest, band); +if (important) this.addGraphEvent("nomadMerge", this.year, { bandId: strongest.id, mergedBandId: band.id, population: Math.round(strongest.population) }, 1); +return true; +} +splitNomadBand(band) { +const cfg = SimConfig.nomad || {}; +if (this.nomadBands.length >= (cfg.maxBands ?? 24)) return null; +if (this.year - (band.lastSplitYear ?? -Infinity) < years(60)) return null; +if (band.population < (cfg.splitPopulation ?? 1200) && band.cohesion > 0.22) return null; +const split = band.population * this.rng.range(0.24, 0.42); +if (split < 80) return null; +band.population -= split; +band.herds *= 0.72; +band.resources *= 0.78; +band.cohesion = clamp(band.cohesion - 0.10, 0, 1); +band.lastSplitYear = this.year; +const tile = this.world.idx(Math.round(band.x), Math.round(band.y)); +const child = this.spawnNomadBand(tile, { +ethnicityId: band.ethnicityId, +population: split, +herds: band.herds * 0.35, +resources: band.resources * 0.25 +}); +if (child) { +child.mode = "roaming"; +child.prestige = Math.max(0.1, band.prestige * 0.35); +this.addGraphEvent("nomadSplit", this.year, { bandId: band.id, newBandId: child.id, population: Math.round(split) }, 1); +} +return child; +} step() { for (const city of this.cities) city.activeVisitors = 0; -this.rebuildOccupancy(); -this.ethnicDensityCache.clear(); -const offspring = []; -for (const a of this.agents) { -if (!a.alive) continue; -this.moveAgent(a); -} -this.rebuildOccupancy(); this.dominantEthnicityCache.clear(); -for (const a of this.agents) { -if (!a.alive) continue; -this.updateAgentTechnology(a); -this.updateAgentTrade(a); -this.gatherConsumeReproduce(a, offspring); -if (a.alive) this.payTechnologyCostOrForget(a); -if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a); -} -this.agents = this.agents.filter(a => a.alive); -const acceptedOffspring = []; -const softCap = Math.max(1, this.maxAgents); -const hardCap = Math.max(softCap, Math.floor(softCap * (SimConfig.population.hardCapScale ?? 1.35))); -const softCapStart = SimConfig.population.softCapStart ?? 0.85; -const crowdingPenalty = SimConfig.population.offspringCrowdingPenalty ?? 1.8; -const minAcceptance = SimConfig.population.minOffspringAcceptance ?? 0.035; -for (const child of offspring) { -const projectedPopulation = this.agents.length + acceptedOffspring.length; -if (projectedPopulation >= hardCap) break; -const pressure = projectedPopulation / softCap; -const overSoftCap = Math.max(0, pressure - softCapStart); -const acceptChance = clamp(1 - overSoftCap * crowdingPenalty, minAcceptance, 1); -if (pressure <= softCapStart || this.rng.next() < acceptChance) acceptedOffspring.push(child); -} -this.agents.push(...acceptedOffspring); -for (const child of acceptedOffspring) this.addAgentToOccupancy(child); -this.updatePopulationCapacity(); +this.rebuildIndexes(); +this.updateFieldPressureFromPopulation(); if (this.year % 2 === 0) this.updateWorldFields(2); +this.updatePopulationCapacity(); this.maybeSpawnDisaster(); if (this.year % years(1) === 0) this.maybeSpawnFrontierWave(); if (this.year % years(1) === 0) { -this.blendAgentPresenceIntoPopulationField(0.015); this.updateCities(); -this.updatePopulationCapacity(); +this.updateNomadBands(); this.consumeTileResources(); this.growTilePopulation(); +this.updatePopulationCapacity(); this.diffusePopulation(); +this.updatePopulationCapacity(); +if (this.year % years(2) === 0) { +this.updateTileTechnology(); +this.diffuseTileTechnology(); +this.cityTechnologyExchange(); +} if (this.year % years(3) === 0) this.updateTileAssimilation(); this.updateRegionalCultures(); -this.recomputeTerritories(); -this.campaignPressureOnTerritories(); +this.updateNomadInfluence(); +if (this.year % years(100) === 0 && typeof console !== "undefined" && console.debug) { +const d = this.territoryDebug; +if (d.disconnectedTerritoryRemoved || d.independentCitiesAbsorbedByTerritory || d.cityTerritoryMismatchesFixed || d.invalidCampaignTargetsRejected) { +console.debug("territory topology", { year: Math.floor(this.year / WEEKS_PER_YEAR), ...d }); +} +} } if (this.year % years(5) === 0) { -this.updateTradeRoutes(); +this.updateTradeRoutesFieldBased(); } this.updatePolities(); this.updateEthnicStats(); @@ -604,35 +1221,16 @@ this.updateEthnicStats(); } this.year++; } -rebuildOccupancy() { +updateFieldPressureFromPopulation() { const w = this.world; -w.pressure.fill(0); -this.tileEthnicities.clear(); -this.tileAgents.clear(); -this.rebuildIndexes(); -for (const a of this.agents) { -if (!a.alive) continue; -const i = w.idx(a.x, a.y); -w.pressure[i]++; -let agents = this.tileAgents.get(i); -if (!agents) { -agents = []; -this.tileAgents.set(i, agents); -} -agents.push(a); -let counts = this.tileEthnicities.get(i); -if (!counts) { -counts = new Map(); -this.tileEthnicities.set(i, counts); -} -counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1); -} -if (!this.populationFieldInitialized) this.rebuildPopulationFieldsFromAgents(); +for (let i = 0; i < w.count; i++) w.pressure[i] = w.population[i] || 0; } rebuildIndexes() { -this.cityById = new Map(this.cities.map(city => [city.id, city])); -this.polityById = new Map(this.polities.map(polity => [polity.id, polity])); -this.cityBuckets = new Map(); +this.cityById.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); +this.cityBuckets.clear(); for (const city of this.cities) { const key = this.cityBucketKey(city.x, city.y); let bucket = this.cityBuckets.get(key); @@ -644,7 +1242,7 @@ bucket.push(city); } } cityBucketKey(x, y) { -return `${Math.floor(x / 8)},${Math.floor(y / 8)}`; +return Math.floor(x / 8) + Math.floor(y / 8) * 4096; } getCitiesNear(x, y, radius) { const cities = []; @@ -654,7 +1252,7 @@ const minBy = Math.floor((y - radius) / 8); const maxBy = Math.floor((y + radius) / 8); for (let by = minBy; by <= maxBy; by++) { for (let bx = minBx; bx <= maxBx; bx++) { -const bucket = this.cityBuckets.get(`${bx},${by}`); +const bucket = this.cityBuckets.get(bx + by * 4096); if (!bucket) continue; for (const city of bucket) { if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city); @@ -663,52 +1261,127 @@ if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city); } return cities; } -// Phase 2: transitional tile-level population and culture fields. -// Agents and cities still exist, but render/culture/state logic can read the -// aggregate population field and per-tile ethnic composition. -seedInitialPopulationField() { -this.rebuildOccupancy(); -this.rebuildPopulationFieldsFromAgents(true); -this.updatePopulationCapacity(); -this.populationFieldInitialized = true; -} -rebuildPopulationFieldsFromAgents(force = false) { -if (this.populationFieldInitialized && !force) return; +seedInitialPopulationFieldDirectly(initialPopulation) { const w = this.world; w.population.fill(0); w.settledPopulation.fill(0); w.mobilePopulation.fill(0); w.populationPressure.fill(0); this.tileEthnicMix.clear(); -this.tileCultures = this.tileEthnicMix; -for (const a of this.agents) { -if (!a.alive) continue; -const tile = w.idx(a.x, a.y); -const sedentary = clamp(a.traits?.sedentary ?? 0.5, 0, 1); -w.population[tile] += 1; -if (sedentary >= 0.55) w.settledPopulation[tile] += 1; -else w.mobilePopulation[tile] += 1; -let mix = this.tileEthnicMix.get(tile); +this.activePopulationTiles.clear(); +const founders = Math.max(6, Math.min(18, Math.round(initialPopulation / 650))); +const desertFounders = Math.max(2, Math.floor(founders * 0.25)); +for (let e = 0; e < founders; e++) { +const desertFounder = e < desertFounders; +const origin = this.findFounderRegionTile(desertFounder); +const id = this.createEthnicity(0, { +temperature: w.temperature[origin], +humidity: w.humidity[origin] +}); +const traits = desertFounder ? this.desertFounderTraits() : this.randomTraits(); +const ethnicity = this.ethnicities.get(id); +if (ethnicity) ethnicity.averageTraits = traits; +const share = initialPopulation / founders * this.rng.range(0.72, 1.28); +this.seedEthnicPopulationPatch(origin, id, traits, share, desertFounder ? 5 : 4); +} +this.syncTilePopulationCulture(); +this.updatePopulationCapacity(); +this.populationFieldInitialized = true; +} +findFounderRegionTile(desertFounder = false) { +const w = this.world; +let best = 0; +let bestScore = -Infinity; +const tries = Math.max(120, Math.floor(w.size * 1.8)); +for (let n = 0; n < tries; n++) { +const tile = this.rng.int(w.count); +if (w.terrain[tile] === Terrain.WATER) continue; +const water = this.localWaterScore(tile, 5); +const desertFit = desertFounder ? (w.terrain[tile] === Terrain.DESERT ? 1.3 : -0.6) : (w.terrain[tile] === Terrain.DESERT ? -0.4 : 0); +const score = +w.fertility[tile] * 2.2 + +clamp(w.resource[tile] / 24, 0, 1.6) + +w.mineral[tile] * 0.45 + +water * 0.9 - +w.move[tile] * 0.55 + +desertFit + +this.rng.range(0, 0.55); +if (score > bestScore) { +best = tile; +bestScore = score; +} +} +if (bestScore === -Infinity) { +const fallback = this.findHabitableTile(desertFounder ? Terrain.DESERT : null); +return w.idx(fallback.x, fallback.y); +} +return best; +} +localWaterScore(tile, radius = 4) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let score = 0; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const d = Math.abs(dx) + Math.abs(dy); +if (!d || d > radius) continue; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +if (w.terrain[w.idx(tx, ty)] === Terrain.WATER) score += (radius + 1 - d) / radius; +} +} +return score; +} +seedEthnicPopulationPatch(centerTile, ethnicityId, traits, totalPopulation, radius = 4) { +const w = this.world; +const cx = centerTile % w.size; +const cy = Math.floor(centerTile / w.size); +const tiles = []; +let weightTotal = 0; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const d = offset.distance; +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const suitability = 0.35 + w.fertility[tile] + clamp(w.resource[tile] / 30, 0, 1) * 0.6 + w.mineral[tile] * 0.2; +const weight = Math.max(0.05, suitability * (radius - d + 1)); +tiles.push({ tile, weight }); +weightTotal += weight; +} +const settledShare = clamp(traits.sedentary ?? 0.45, 0.08, 0.9); +for (const entry of tiles) { +const amount = totalPopulation * entry.weight / Math.max(0.001, weightTotal); +if (amount <= 0.01) continue; +w.population[entry.tile] += amount; +w.settledPopulation[entry.tile] += amount * settledShare; +w.mobilePopulation[entry.tile] += amount * (1 - settledShare); +this.markPopulationTile(entry.tile); +let mix = this.tileEthnicMix.get(entry.tile); if (!mix) { mix = new Map(); -this.tileEthnicMix.set(tile, mix); +this.tileEthnicMix.set(entry.tile, mix); } -mix.set(a.ethnicity, (mix.get(a.ethnicity) || 0) + 1); +mix.set(ethnicityId, (mix.get(ethnicityId) || 0) + amount); +this.updateCultureTile(entry.tile); } -for (const city of this.cities) this.projectCityPopulationToTiles(city); -this.syncTilePopulationCulture(); } syncTilePopulationCulture() { const w = this.world; this.tileCultures = this.tileEthnicMix; for (const tile of this.tileEthnicMix.keys()) this.updateCultureTile(tile); -for (let i = 0; i < w.count; i++) { -if (!this.tileEthnicMix.has(i) && w.population[i] <= 0) { -w.dominantEthnicity[i] = -1; -w.cultureDiversity[i] *= 0.985; +this.rebuildActivePopulationTiles(); +for (const tile of this.activePopulationTiles) { +if (w.population[tile] <= 0 && !this.tileEthnicMix.has(tile)) { +w.dominantEthnicity[tile] = -1; +w.cultureDiversity[tile] *= 0.985; } } -this.updatePopulationCapacity(); } projectCityPopulationToTiles(city) { if (!city || city.population <= 0) return; @@ -716,10 +1389,10 @@ const w = this.world; const radius = clamp(Math.ceil((city.agriculturalRadius || 2) * 0.75), 1, 8); let totalWeight = 0; const tiles = []; -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { -const distance = Math.abs(dx) + Math.abs(dy); -if (distance > radius) continue; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const distance = offset.distance; const x = city.x + dx; const y = city.y + dy; if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; @@ -729,7 +1402,6 @@ const weight = (radius - distance + 1) * (tile === w.idx(city.x, city.y) ? 1.8 : tiles.push({ tile, weight }); totalWeight += weight; } -} if (!tiles.length || totalWeight <= 0) return; const cityTotal = compositionTotal(city.ethnicityComposition); const fallbackEthnicity = dominantComposition(city.ethnicityComposition); @@ -738,6 +1410,7 @@ const share = entry.weight / totalWeight; const localPopulation = city.population * share; w.population[entry.tile] += localPopulation; w.settledPopulation[entry.tile] += localPopulation; +this.markPopulationTile(entry.tile); let culture = this.tileEthnicMix.get(entry.tile); if (!culture) { culture = new Map(); @@ -754,8 +1427,9 @@ culture.set(fallbackEthnicity, (culture.get(fallbackEthnicity) || 0) + localPopu } } updateCultureTile(tile) { -const counts = this.tileEthnicMix.get(tile) || this.tileEthnicities.get(tile); +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; return; } @@ -774,20 +1448,21 @@ this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0; } diffusePopulation() { const w = this.world; -this.updatePopulationCapacity(); -const moves = []; -for (let tile = 0; tile < w.count; tile++) { +const moves = this.populationMoveScratch; +moves.length = 0; +const tiles = this.activePopulationSnapshot(); +for (const tile of tiles) { const population = w.population[tile]; -if (population < 1 || w.populationPressure[tile] <= 1.05) continue; +const mobile = w.mobilePopulation[tile] || 0; +if (population < 1) continue; +const pressure = w.populationPressure[tile] || 0; +if (pressure <= 1.05 && (population <= 3 || mobile <= 0.5)) continue; const x = tile % w.size; const y = Math.floor(tile / w.size); let bestTile = -1; let bestScore = -Infinity; -const radius = 2; -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { -const distance = Math.abs(dx) + Math.abs(dy); -if (!distance || distance > radius) continue; +const radius = mobile > population * 0.35 || mobile > 3 ? 3 : 2; +for (const { dx, dy } of this.getRadiusOffsets(radius, false)) { const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; @@ -798,14 +1473,14 @@ bestScore = score; bestTile = target; } } -} if (bestTile < 0 || bestScore <= 0) continue; const excess = Math.max(0, population - w.populationCapacity[tile]); -const amount = Math.min(population * 0.035, excess * 0.18); +const frontierPush = mobile > 0.5 ? mobile * 0.012 : 0; +const excessPush = excess * 0.18; +const amount = Math.min(population * 0.055, excessPush + frontierPush); if (amount >= 0.05) moves.push({ from: tile, to: bestTile, amount }); } for (const move of moves) this.moveEthnicPopulation(move.from, move.to, move.amount); -this.updatePopulationCapacity(); } populationDestinationScore(fromTile, toTile) { const w = this.world; @@ -813,15 +1488,17 @@ if (w.terrain[toTile] === Terrain.WATER) return -Infinity; const fromDominant = w.dominantEthnicity[fromTile]; const toDominant = w.dominantEthnicity[toTile]; const sameEthnicity = fromDominant >= 0 && toDominant >= 0 && fromDominant === toDominant ? 1.1 : 0; +const frontier = (w.polity[toTile] < 0 ? 0.65 : 0) + ((w.population[toTile] || 0) < 1 ? 0.25 : 0); const routeScore = (w.tradeRoute[toTile] ? 1.25 : 0) + clamp(w.pheromone[toTile] / 8, 0, 1.4); const overcapacity = Math.max(0, w.populationPressure[toTile] - 0.85); -const foreignPolity = w.polity[toTile] >= 0 && w.polity[fromTile] >= 0 && w.polity[toTile] !== w.polity[fromTile] ? 1.25 : 0; +const foreignPolity = w.polity[toTile] >= 0 && w.polity[fromTile] >= 0 && w.polity[toTile] !== w.polity[fromTile] ? 1.25 + clamp(w.control[toTile], 0, 1) * 1.1 : 0; return w.resource[toTile] * 0.06 + w.fertility[toTile] * 1.4 + w.mineral[toTile] * 0.35 + w.cityPull[toTile] * 1.3 + routeScore + -sameEthnicity - +sameEthnicity + +frontier - w.move[toTile] * 0.62 - overcapacity * 2.4 - foreignPolity; @@ -856,14 +1533,16 @@ w.settledPopulation[fromTile] = Math.max(0, w.settledPopulation[fromTile] - sett w.mobilePopulation[fromTile] = Math.max(0, w.mobilePopulation[fromTile] - mobileMove); w.settledPopulation[toTile] += settledMove; w.mobilePopulation[toTile] += mobileMove; +this.markPopulationTile(toTile); this.updateCultureTile(fromTile); this.updateCultureTile(toTile); +this.cleanupPopulationTile(fromTile); return moved; } growTilePopulation() { const w = this.world; -this.updatePopulationCapacity(); -for (let tile = 0; tile < w.count; tile++) { +const tiles = this.activePopulationSnapshot(); +for (const tile of tiles) { const population = w.population[tile]; if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue; const pressure = w.populationPressure[tile]; @@ -876,7 +1555,6 @@ if (Math.abs(change) < 0.01) continue; if (change > 0) this.addEthnicPopulationProportionally(tile, change); else this.removeEthnicPopulationProportionally(tile, -change); } -this.updatePopulationCapacity(); } addEthnicPopulationProportionally(tile, amount) { const w = this.world; @@ -888,6 +1566,7 @@ mix = new Map([[dominant, amount]]); this.tileEthnicMix.set(tile, mix); w.population[tile] += amount; w.settledPopulation[tile] += amount; +this.markPopulationTile(tile); this.updateCultureTile(tile); return; } @@ -895,12 +1574,13 @@ const total = Math.max(0.001, compositionTotal(mix)); for (const [id, count] of [...mix]) mix.set(id, count + amount * count / total); w.population[tile] += amount; w.settledPopulation[tile] += amount; +this.markPopulationTile(tile); this.updateCultureTile(tile); } removeEthnicPopulationProportionally(tile, amount) { const w = this.world; const mix = this.tileEthnicMix.get(tile); -if (!mix) return; +if (!mix) return 0; const removed = Math.min(amount, w.population[tile]); const total = Math.max(0.001, compositionTotal(mix)); for (const [id, count] of [...mix]) { @@ -915,10 +1595,13 @@ w.population[tile] = Math.max(0, w.population[tile] - removed); w.settledPopulation[tile] = Math.max(0, w.settledPopulation[tile] - removed * settledShare); w.mobilePopulation[tile] = Math.max(0, w.mobilePopulation[tile] - removed * (1 - settledShare)); this.updateCultureTile(tile); +this.cleanupPopulationTile(tile); +return removed; } consumeTileResources() { const w = this.world; -for (let tile = 0; tile < w.count; tile++) { +const tiles = this.activePopulationSnapshot(); +for (const tile of tiles) { const population = w.population[tile]; if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue; const pressure = w.populationPressure[tile] || 0; @@ -926,26 +1609,6 @@ const demand = population * (0.0045 + Math.max(0, pressure - 1) * 0.0035); w.resource[tile] = Math.max(0, w.resource[tile] - demand); } } -blendAgentPresenceIntoPopulationField(weight = 0.01) { -if (!this.populationFieldInitialized || weight <= 0 || !this.agents.length) return; -const w = this.world; -for (const a of this.agents) { -if (!a.alive) continue; -const tile = w.idx(a.x, a.y); -const amount = weight; -w.population[tile] += amount; -if ((a.traits?.sedentary ?? 0.5) >= 0.55) w.settledPopulation[tile] += amount; -else w.mobilePopulation[tile] += amount; -let mix = this.tileEthnicMix.get(tile); -if (!mix) { -mix = new Map(); -this.tileEthnicMix.set(tile, mix); -} -mix.set(a.ethnicity, (mix.get(a.ethnicity) || 0) + amount); -this.updateCultureTile(tile); -} -this.updatePopulationCapacity(); -} updateTileAssimilation() { const w = this.world; for (const [tile, mix] of [...this.tileEthnicMix]) { @@ -990,20 +1653,27 @@ this.updateCultureTile(tile); } updateRegionalCultures() { const w = this.world; -const nextDominant = new Int32Array(w.dominantEthnicity); -const nextDiversity = new Float32Array(w.cultureDiversity); +const nextDominant = this.scratchIntA; +const nextDiversity = this.scratchFloatA; +nextDominant.set(w.dominantEthnicity); +nextDiversity.set(w.cultureDiversity); const radius = SimConfig.culture.spreadRadius; -const cityById = new Map(this.cities.map(city => [city.id, city])); +const offsets = this.getRadiusOffsets(radius); +this.ensureCultureScratch(); +const influence = this.cultureInfluence; +const seen = this.cultureSeen; for (let y = 0; y < w.size; y++) { for (let x = 0; x < w.size; x++) { const tile = w.idx(x, y); if (w.terrain[tile] === Terrain.WATER) continue; -const influence = new Map(); +const stamp = this.nextCultureMarker(); +const seenIds = this.cultureSeenIds; +seenIds.length = 0; let total = 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 > radius) continue; +for (const offset of offsets) { +const distance = offset.distance; +const dx = offset.dx; +const dy = offset.dy; const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; @@ -1012,18 +1682,24 @@ const id = w.dominantEthnicity[source]; if (id < 0) continue; const pressureWeight = Math.sqrt(Math.max(0, w.population[source])); const cityWeight = w.city[source] >= 0 -? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight +? Math.sqrt(Math.max(1, this.getCityById(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight : 0; const routeWeight = w.tradeRoute[source] ? SimConfig.culture.routeWeight : 1; const weight = (pressureWeight + cityWeight) * routeWeight / (1 + distance); if (weight <= 0) continue; -influence.set(id, (influence.get(id) || 0) + weight); -total += weight; +if (seen[id] !== stamp) { +seen[id] = stamp; +influence[id] = weight; +seenIds.push(id); +} else { +influence[id] += weight; } +total += weight; } let bestId = nextDominant[tile]; let bestWeight = 0; -for (const [id, weight] of influence) { +for (const id of seenIds) { +const weight = influence[id]; if (weight > bestWeight) { bestId = id; bestWeight = weight; @@ -1038,192 +1714,6 @@ nextDiversity[tile] = total > 0 ? clamp(1 - bestWeight / total, 0, 1) : 0; w.dominantEthnicity.set(nextDominant); w.cultureDiversity.set(nextDiversity); } -addAgentToOccupancy(a) { -if (!a.alive) return; -const w = this.world; -const i = w.idx(a.x, a.y); -w.pressure[i]++; -if (!this.populationFieldInitialized) w.population[i]++; -let agents = this.tileAgents.get(i); -if (!agents) { -agents = []; -this.tileAgents.set(i, agents); -} -agents.push(a); -let counts = this.tileEthnicities.get(i); -if (!counts) { -counts = new Map(); -this.tileEthnicities.set(i, counts); -} -counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1); -let culture = this.tileEthnicMix.get(i); -if (!this.populationFieldInitialized) { -if (!culture) { -culture = new Map(); -this.tileEthnicMix.set(i, culture); -} -culture.set(a.ethnicity, (culture.get(a.ethnicity) || 0) + 1); -this.updateCultureTile(i); -} -} -removeAgentFromOccupancy(a) { -const w = this.world; -const i = w.idx(a.x, a.y); -w.pressure[i] = Math.max(0, w.pressure[i] - 1); -if (!this.populationFieldInitialized) w.population[i] = Math.max(0, w.population[i] - 1); -const agents = this.tileAgents.get(i); -if (agents) { -const index = agents.indexOf(a); -if (index >= 0) agents.splice(index, 1); -if (!agents.length) this.tileAgents.delete(i); -} -const counts = this.tileEthnicities.get(i); -if (counts) { -const next = (counts.get(a.ethnicity) || 0) - 1; -if (next > 0) counts.set(a.ethnicity, next); -else counts.delete(a.ethnicity); -if (!counts.size) this.tileEthnicities.delete(i); -} -const culture = this.tileEthnicMix.get(i); -if (!this.populationFieldInitialized) { -if (culture) { -const next = (culture.get(a.ethnicity) || 0) - 1; -if (next > 0) culture.set(a.ethnicity, next); -else culture.delete(a.ethnicity); -if (!culture.size) this.tileEthnicMix.delete(i); -} -this.updateCultureTile(i); -} -} -changeAgentEthnicity(a, nextEthnicity) { -if (a.ethnicity === nextEthnicity) return; -const w = this.world; -const i = w.idx(a.x, a.y); -const counts = this.tileEthnicities.get(i); -if (counts) { -const prev = (counts.get(a.ethnicity) || 0) - 1; -if (prev > 0) counts.set(a.ethnicity, prev); -else counts.delete(a.ethnicity); -counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1); -} -const culture = this.tileEthnicMix.get(i); -if (!this.populationFieldInitialized) { -if (culture) { -const prev = (culture.get(a.ethnicity) || 0) - 1; -if (prev > 0) culture.set(a.ethnicity, prev); -else culture.delete(a.ethnicity); -culture.set(nextEthnicity, (culture.get(nextEthnicity) || 0) + 1); -} -} -a.ethnicity = nextEthnicity; -this.updateCultureTile(i); -} -moveAgent(a) { -const w = this.world; -const s = w.size; -const current = w.idx(a.x, a.y); -const localPressure = w.pressure[current]; -let bestX = a.x; -let bestY = a.y; -let bestScore = -Infinity; -const sedentary = a.traits.sedentary; -const ethnocentrism = a.traits.ethnocentrism; -const ethnicClimate = this.ethnicities.get(a.ethnicity); -const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45; -const localCapacity = this.carryingCapacityAt(current); -const localOverCapacity = Math.max(0, localPressure - localCapacity); -if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) { -a.settled++; -a.movedThisStep = false; -return; -} -const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1; -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { -const x = clamp(a.x + dx, 0, s - 1); -const y = clamp(a.y + dy, 0, s - 1); -const i = w.idx(x, y); -const isWater = w.terrain[i] === Terrain.WATER; -const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45; -const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0); -if (isWater && this.rng.next() > waterAccess) continue; -const ethnicDensity = ethnocentrism > 0.04 -? this.ethnicDensityNear(x, y, a.ethnicity) -: { same: 0, foreign: 0 }; -const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i]; -const routeStrength = clamp(w.tradeRoute[i] / 80, 0, 1.8); -const routePull = (w.tradeRoute[i] ? 1.55 + routeStrength * 0.55 : clamp(w.pheromone[i] / 8, 0, 1)) * (0.35 + a.traits.mobility * 0.55 + sedentary * 0.45); -const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0); -const destinationCapacity = this.carryingCapacityAt(i); -const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity); -const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1); -const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85); -const routeBonus = w.tradeRoute[i] ? 1.35 + routeStrength * 0.45 : 0; -const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0; -const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus; -const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0; -const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0; -const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45); -const score = -resourceScore * a.traits.resourceAttraction + -w.pheromone[i] * 0.021 + -ethnicDensity.same * ethnocentrism + -cityPull * sedentary + -routePull + -capacityPull + -inertia - -terrainPenalty - -crowdPenalty + -pressurePush - -ethnicDensity.foreign * ethnocentrism * 0.72 + -this.rng.range(-0.55, 0.55); -if (score > bestScore) { -bestScore = score; -bestX = x; -bestY = y; -} -} -} -const from = w.idx(a.x, a.y); -a.x = bestX; -a.y = bestY; -const to = w.idx(a.x, a.y); -if (from !== to) { -a.movedThisStep = true; -a.farmingWork = 0; -a.lastFarmTile = to; -const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1; -this.addPheromone(from, (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale); -this.addPheromone(to, (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale); -a.settled = Math.max(0, a.settled - 1); -} else { -a.movedThisStep = false; -a.settled++; -} -} -ensureAgentTech(a) { -a.tech ??= { farming: 0, metallurgy: 0 }; -a.tech.farming ??= 0; -a.tech.metallurgy ??= 0; -a.tech.farming = clamp(a.tech.farming, 0, 1); -a.tech.metallurgy = clamp(a.tech.metallurgy, 0, 1); -a.farmingWork ??= 0; -a.lastFarmTile ??= -1; -a.movedThisStep ??= false; -} -ensureAgentTrade(agent) { -agent.tradeOriginCityId ??= null; -agent.lastTradeCityId ??= null; -agent.tradeMemory ??= 0; -agent.tradeCooldown ??= 0; -agent.tradeMemory = clamp(agent.tradeMemory, 0, 1); -agent.tradeCooldown = Math.max(0, Math.floor(agent.tradeCooldown)); -} -agentNomadism(agent) { -const mobility = clamp(agent.traits?.mobility ?? 0.5, 0, 1); -const sedentary = clamp(agent.traits?.sedentary ?? 0.5, 0, 1); -return clamp(mobility * 0.75 + (1 - sedentary) * 0.55, 0, 1); -} farmabilityAt(tile) { const t = this.world.terrain[tile]; if (t === Terrain.PLAINS) return 0.75; @@ -1256,359 +1746,114 @@ cityBonus updatePopulationCapacity() { const w = this.world; for (let i = 0; i < w.count; i++) { -const capacity = this.carryingCapacityAtTile(i); +if (w.terrain[i] === Terrain.WATER) { +w.populationCapacity[i] = 0.35; +w.populationPressure[i] = w.population[i] / 0.35; +continue; +} +const terrainFactor = +w.terrain[i] === Terrain.MOUNTAIN ? 0.62 : +w.terrain[i] === Terrain.DESERT ? 0.55 : +w.terrain[i] === Terrain.FOREST ? 0.92 : +1; +const routeBonus = w.tradeRoute[i] ? 1.6 : clamp(w.pheromone[i] / Math.max(1, SimConfig.route.maxPheromone), 0, 1) * 0.8; +const cityBonus = w.city[i] >= 0 ? 3.5 : w.cityPull[i] * 2.2; +const capacity = Math.max(0.1, ( +SimConfig.population.carryingCapacityBase + +w.fertility[i] * SimConfig.population.carryingCapacityFertility + +w.mineral[i] * SimConfig.population.carryingCapacityMineral + +w.farmland[i] * SimConfig.population.carryingCapacityFarmland + +routeBonus + +cityBonus +) * terrainFactor); w.populationCapacity[i] = capacity; w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0; } } -updateAgentTechnology(agent, payMaintenance = false) { -if (!agent.alive) return; -this.ensureAgentTech(agent); -this.updateFarmingWork(agent); -this.tryInventTechnology(agent); -this.learnTechnologyFromCity(agent); -if (this.shouldRunAgentSocial(agent, 3)) { -this.spreadTechnology(agent); -this.improveTechnologyFromDensity(agent); -} -if (payMaintenance) this.payTechnologyCostOrForget(agent); -} -shouldRunAgentSocial(agent, interval) { -return ((agent.x * 31 + agent.y * 17 + this.year) % interval) === 0; -} -updateFarmingWork(agent) { +updateTileTechnology() { const w = this.world; -const tile = w.idx(agent.x, agent.y); -if (agent.lastFarmTile !== tile) { -agent.farmingWork = 0; -agent.lastFarmTile = tile; -} -if (agent.movedThisStep) return; -const farming = agent.tech?.farming || 0; -if (farming <= 0.02) return; -if (agent.settled > 0) { -agent.farmingWork = clamp((agent.farmingWork || 0) + farming * 0.015, 0, 1); -agent.tech.farming = clamp(agent.tech.farming + farming * this.farmabilityAt(tile) * 0.00018, 0, 1); +const tiles = this.activePopulationSnapshot(); +for (const tile of tiles) { +if (w.population[tile] <= 0 || w.terrain[tile] === Terrain.WATER) 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); +const route = w.tradeRoute[tile] ? 0.35 : clamp(w.pheromone[tile] / SimConfig.route.maxPheromone, 0, 0.25); +const farmingGain = SimConfig.technology.cityInnovation * 0.45 * density * (0.2 + this.farmabilityAt(tile) + w.farmland[tile] * 0.8 + cityPull * 0.45 + route); +const metalTerrain = w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1; +const metallurgyGain = SimConfig.technology.cityInnovation * 0.32 * density * (0.15 + w.mineral[tile] * metalTerrain + cityPull * 0.30 + route); +w.farmingKnowledge[tile] = clamp(w.farmingKnowledge[tile] + farmingGain, 0, 1); +w.metallurgyKnowledge[tile] = clamp(w.metallurgyKnowledge[tile] + metallurgyGain, 0, 1); } } -tryInventTechnology(agent) { +diffuseTileTechnology() { const w = this.world; -const tile = w.idx(agent.x, agent.y); -const settledFactor = clamp((agent.settled || 0) / 12, 0, 1); -if ((agent.tech?.farming || 0) <= 0.02) { -const farmingBase = 0.00016; -const farmability = this.farmabilityAt(tile); -const sedentary = agent.traits.sedentary; -const farmingChance = -farmingBase * -(0.25 + farmability) * -(0.4 + sedentary) * -(0.3 + settledFactor); -if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32); +const nextFarming = this.scratchFloatA; +const nextMetallurgy = this.scratchFloatB; +nextFarming.set(w.farmingKnowledge); +nextMetallurgy.set(w.metallurgyKnowledge); +const sources = this.technologySourceScratch; +sources.length = 0; +const marker = this.nextVisitMarker(); +for (const tile of this.activePopulationTiles) { +this.visitStamp[tile] = marker; +sources.push(tile); } -if ((agent.tech?.metallurgy || 0) <= 0.02) { -const metallurgyBase = 0.00011; -const mineral = clamp(w.mineral[tile], 0, 1); -const mineralTerrainBonus = -w.terrain[tile] === Terrain.MOUNTAIN -? 1.8 -: 1.0; -const metallurgyChance = -metallurgyBase * -(0.2 + mineral) * -mineralTerrainBonus * -(0.5 + settledFactor); -if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.28); +for (const tile of this.activeTradeRouteTiles) { +if (this.visitStamp[tile] === marker) continue; +this.visitStamp[tile] = marker; +sources.push(tile); } -} -grantTechnologyAround(agent, techName, amount) { -this.ensureAgentTech(agent); -this.forEachLocalAgentNear(agent.x, agent.y, 2, other => { -if (!other.alive) return true; -this.ensureAgentTech(other); -const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y); -if (distance > 2) return true; -const gain = amount * (other === agent ? 1 : 0.55); -other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1); -return true; +for (const tile of sources) { +if (w.population[tile] <= 0 && !w.tradeRoute[tile]) continue; +this.forCardinalNeighbors(tile, n => { +if (w.terrain[n] === Terrain.WATER) 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); +nextFarming[n] = clamp(nextFarming[n] + (w.farmingKnowledge[tile] - w.farmingKnowledge[n]) * rate, 0, 1); +nextMetallurgy[n] = clamp(nextMetallurgy[n] + (w.metallurgyKnowledge[tile] - w.metallurgyKnowledge[n]) * rate, 0, 1); }); } -payTechnologyCostOrForget(agent) { -if (!agent.alive) return; -this.ensureAgentTech(agent); -const farming = agent.tech.farming || 0; -const metallurgy = agent.tech.metallurgy || 0; -const cost = farming * 0.004 + metallurgy * 0.010; -if (cost <= 0) return; -if (agent.resources >= cost) { -agent.resources -= cost; -return; +w.farmingKnowledge.set(nextFarming); +w.metallurgyKnowledge.set(nextMetallurgy); } -const shortage = cost - Math.max(0, agent.resources); -agent.resources = Math.max(0, agent.resources - cost); -const decay = clamp(0.006 + shortage * 0.025, 0.006, 0.06); -agent.tech.farming = Math.max(0, farming - decay); -agent.tech.metallurgy = Math.max(0, metallurgy - decay * 1.15); -if (agent.tech.farming < 0.02) agent.tech.farming = 0; -if (agent.tech.metallurgy < 0.02) agent.tech.metallurgy = 0; -if (agent.tech.farming <= 0) agent.farmingWork = 0; -} -spreadTechnology(agent) { +cityTechnologyExchange() { const w = this.world; -const tile = w.idx(agent.x, agent.y); -const assimilation = agent.traits.assimilation || 0; -const ethnocentrism = agent.traits.ethnocentrism || 0; -const nomadism = this.agentNomadism(agent); -let checked = 0; -this.forEachLocalAgentNear(agent.x, agent.y, 1, other => { -if (other === agent || !other.alive) return true; -if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true; -if (++checked > 12) return false; -this.ensureAgentTech(other); -const sameEthnicity = agent.ethnicity === other.ethnicity; -let chance = -0.0035 + -assimilation * 0.006 - -ethnocentrism * 0.0012; -if (sameEthnicity) chance += 0.004; -if (w.tradeRoute[tile]) chance += 0.006; -if (w.city[tile] >= 0) chance += 0.003; -chance += nomadism * 0.004; -chance = clamp(chance, 0.0008, 0.028); -this.learnTechnologyFrom(agent, other, "farming", chance); -this.learnTechnologyFrom(agent, other, "metallurgy", chance); -return true; -}); +for (const city of this.cities) { +city.knowledge ??= { farming: 0, metallurgy: 0 }; +const radius = Math.max(1, city.agriculturalRadius || 2); +let farming = 0; +let metallurgy = 0; +let total = 0; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const d = offset.distance; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +const weight = (radius - d + 1) * Math.max(0.2, w.population[tile]); +farming += w.farmingKnowledge[tile] * weight; +metallurgy += w.metallurgyKnowledge[tile] * weight; +total += weight; } -learnTechnologyFrom(agent, other, techName, chance) { -const current = agent.tech[techName] || 0; -const otherLevel = other.tech?.[techName] || 0; -if (otherLevel > current + 0.04 && this.rng.next() < chance) { -agent.tech[techName] = clamp(current + (otherLevel - current) * 0.14, 0, 1); +if (total > 0) { +city.knowledge.farming = clamp(Math.max(city.knowledge.farming, farming / total * 0.86), 0, 1); +city.knowledge.metallurgy = clamp(Math.max(city.knowledge.metallurgy, metallurgy / total * 0.82), 0, 1); } +const cityTile = w.idx(city.x, city.y); +w.farmingKnowledge[cityTile] = clamp(Math.max(w.farmingKnowledge[cityTile], city.knowledge.farming * 0.72), 0, 1); +w.metallurgyKnowledge[cityTile] = clamp(Math.max(w.metallurgyKnowledge[cityTile], city.knowledge.metallurgy * 0.68), 0, 1); +for (const link of this.tradeLinks || []) { +if (link.from !== city.id && link.to !== city.id) continue; +const other = this.getCityById(link.from === city.id ? link.to : link.from); +if (!other?.knowledge) continue; +const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + (link.strength || 0.2)), 0, 0.012); +city.knowledge.farming = clamp(city.knowledge.farming + (other.knowledge.farming - city.knowledge.farming) * rate, 0, 1); +city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + (other.knowledge.metallurgy - city.knowledge.metallurgy) * rate, 0, 1); } -learnTechnologyFromCity(agent) { -const tile = this.world.idx(agent.x, agent.y); -const city = this.world.city[tile] >= 0 ? this.getCityById(this.world.city[tile]) : null; -if (!city?.knowledge) return; -const routeBonus = this.world.tradeRoute[tile] ? SimConfig.technology.tradeDiffusion : 0; -const chance = SimConfig.technology.cityDiffusion + routeBonus + clamp(city.population / 1200, 0, 0.006); -if (this.rng.next() < chance) { -agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * 0.72), 0, 1); -agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1); -} -} -updateAgentTrade(agent) { -this.ensureAgentTrade(agent); -this.ensureAgentTech(agent); -if (agent.tradeCooldown > 0) { -agent.tradeCooldown--; -return; -} -const city = this.encounteredCityForAgent(agent); -if (!city) return; -const nomadism = this.agentNomadism(agent); -let chance = -0.015 + -nomadism * 0.08 + -(agent.tradeMemory || 0) * 0.04; -if (nomadism < 0.35) chance *= 0.18; -const tile = this.world.idx(agent.x, agent.y); -if (this.world.tradeRoute[tile]) chance += 0.04; -chance += clamp(Math.sqrt(Math.max(0, city.population || 0)) / 80, 0, 0.08); -chance += ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.015; -chance = clamp(chance, 0.005, 0.22); -if (this.rng.next() > chance) return; -if (agent.tradeOriginCityId === null) { -agent.tradeOriginCityId = city.id; -agent.lastTradeCityId = city.id; -agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.01, 0, 1); -agent.tradeCooldown = Math.floor(years(1)); -return; -} -if (agent.tradeOriginCityId === city.id) { -agent.lastTradeCityId = city.id; -agent.tradeCooldown = Math.floor(years(0.5)); -return; -} -const originCity = this.getCityById(agent.tradeOriginCityId); -if (!originCity) { -agent.tradeOriginCityId = city.id; -agent.lastTradeCityId = city.id; -agent.tradeCooldown = Math.floor(years(1)); -return; -} -this.completeAgentTrade(agent, originCity, city); -} -encounteredCityForAgent(agent) { -const w = this.world; -const tile = w.idx(agent.x, agent.y); -if (w.city[tile] >= 0) { -const city = this.getCityById(w.city[tile]); -if (city) return city; -} -const candidates = this.getCitiesNear(agent.x, agent.y, 4); -if (!candidates.length) return null; -candidates.sort((a, b) => { -const da = Math.abs(a.x - agent.x) + Math.abs(a.y - agent.y); -const db = Math.abs(b.x - agent.x) + Math.abs(b.y - agent.y); -if (da !== db) return da - db; -return (b.population || 0) - (a.population || 0); -}); -return candidates[0]; -} -completeAgentTrade(agent, originCity, destinationCity) { -if (!originCity || !destinationCity || originCity.id === destinationCity.id) return; -this.ensureAgentTech(agent); -this.ensureAgentTrade(agent); -const nomadism = this.agentNomadism(agent); -const distance = this.distanceBetweenCities(originCity, destinationCity); -const routeFactor = this.hasDirectTradeConnection(originCity, destinationCity) ? 1.35 : 1.0; -const distanceFactor = clamp(distance / 28, 0.45, 2.1); -const cityScale = Math.sqrt(Math.max(1, originCity.population || 1)) * -Math.sqrt(Math.max(1, destinationCity.population || 1)); -const techFactor = 1 + ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.12; -const memoryFactor = 1 + (agent.tradeMemory || 0) * 0.35; -const rawProfit = -cityScale * -0.010 * -distanceFactor * -routeFactor * -techFactor * -memoryFactor * -clamp(nomadism, 0.25, 1); -const profit = clamp(rawProfit, 0.25, 7.5); -agent.resources += profit * 0.50; -originCity.storedResources += profit * 0.20; -destinationCity.storedResources += profit * 0.30; -this.exchangeAgentTradeKnowledge(agent, originCity, destinationCity); -const currentTile = this.world.idx(agent.x, agent.y); -this.addPheromone(currentTile, profit * 0.45); -this.addPheromone(this.world.idx(originCity.x, originCity.y), profit * 0.30); -this.addPheromone(this.world.idx(destinationCity.x, destinationCity.y), profit * 0.35); -agent.tradeOriginCityId = destinationCity.id; -agent.lastTradeCityId = destinationCity.id; -agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.035, 0, 1); -agent.tradeCooldown = Math.floor(years(1.5)); -} -exchangeAgentTradeKnowledge(agent, originCity, destinationCity) { -if (originCity.knowledge) { -originCity.knowledge.farming = clamp(Math.max(originCity.knowledge.farming, (agent.tech?.farming || 0) * 0.45), 0, 1); -originCity.knowledge.metallurgy = clamp(Math.max(originCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.45), 0, 1); -} -if (destinationCity.knowledge) { -destinationCity.knowledge.farming = clamp(Math.max(destinationCity.knowledge.farming, (agent.tech?.farming || 0) * 0.55), 0, 1); -destinationCity.knowledge.metallurgy = clamp(Math.max(destinationCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.55), 0, 1); -agent.tech.farming = clamp(Math.max(agent.tech.farming || 0, destinationCity.knowledge.farming * 0.25), 0, 1); -agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy || 0, destinationCity.knowledge.metallurgy * 0.25), 0, 1); -} -} -improveTechnologyFromDensity(agent) { -let farmingCount = 0; -let metallurgyCount = 0; -let farmingSum = 0; -let metallurgySum = 0; -this.forEachLocalAgentNear(agent.x, agent.y, 2, other => { -if (!other.alive) return true; -this.ensureAgentTech(other); -const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y); -if (distance > 2) return true; -const farming = other.tech.farming || 0; -const metallurgy = other.tech.metallurgy || 0; -if (farming > 0.1) { -farmingCount++; -farmingSum += farming; -} -if (metallurgy > 0.1) { -metallurgyCount++; -metallurgySum += metallurgy; -} -return true; -}); -if (farmingCount >= 2) { -const avgFarming = farmingSum / farmingCount; -agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1); -} -if (metallurgyCount >= 2) { -const avgMetallurgy = metallurgySum / metallurgyCount; -agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1); -} -} -forEachLocalAgentNear(x, y, radius, callback) { -const w = this.world; -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { -const tx = x + dx; -const ty = y + dy; -if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; -const agents = this.tileAgents.get(w.idx(tx, ty)); -if (!agents) continue; -for (const agent of agents) { -if (callback(agent) === false) return; -} -} -} -} -farmingGatherMultiplier(agent, tile) { -const farmingLevel = agent.tech?.farming || 0; -const farmingWork = agent.farmingWork || 0; -const farmability = this.farmabilityAt(tile); -return clamp(1 + farmingLevel * farmingWork * farmability * 2, 1, 3); -} -metallurgyGatherMultiplier(agent, tile) { -const metallurgyLevel = agent.tech?.metallurgy || 0; -if (metallurgyLevel > 0.02) { -agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1); -} -return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1); -} -gatherConsumeReproduce(a, offspring) { -const w = this.world; -const i = w.idx(a.x, a.y); -this.ensureAgentTech(a); -const ethnicity = this.ethnicities.get(a.ethnicity); -const climateMismatch = this.climateMismatch(a.ethnicity, i); -const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1); -const cityMarket = w.city[i] >= 0 ? 0.28 : 0; -const drylandAdapted = this.isDrylandAdapted(ethnicity); -const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0; -const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage; -const carryingCapacity = this.carryingCapacityAt(i); -const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity); -const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase); -const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2); -const gathered = Math.min( -w.resource[i], -baseGatherAmount * this.farmingGatherMultiplier(a, i) * this.metallurgyGatherMultiplier(a, i) -); -w.resource[i] -= gathered; -a.resources += gathered; -const waterAdaptation = ethnicity?.climateHumidity ?? 0.45; -const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0; -const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4; -const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1; -const sedentary = a.traits.sedentary; -const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08; -a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead; -if (a.resources <= 0) { -this.removeAgentFromOccupancy(a); -a.alive = false; -this.deaths++; -return; -} -const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12); -const mobilityPenalty = (1 - sedentary) * 0.45; -const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty; -const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35); -if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) { -const childShare = 0.36 + sedentary * 0.08; -const childResources = a.resources * childShare; -a.resources -= childResources; -const childTraits = mutateTraits(a.traits, this.rng, 0.035); -const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources); -child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1); -child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1); -offspring.push(child); } } climateMismatch(ethnicityId, tile) { @@ -1618,102 +1863,12 @@ 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; } -isDrylandAdapted(ethnicity) { -return !!ethnicity && ethnicity.climateTemp > 0.5 && ethnicity.climateHumidity < 0.38; -} -ethnicDensityNear(x, y, ethnicity) { -const key = `${x},${y},${ethnicity}`; -const cached = this.ethnicDensityCache.get(key); -if (cached) return cached; -let same = 0; -let foreign = 0; -const w = this.world; -for (let dy = -2; dy <= 2; dy++) { -for (let dx = -2; dx <= 2; dx++) { -if (Math.abs(dx) + Math.abs(dy) > 2) continue; -const tx = x + dx; -const ty = y + dy; -if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; -const counts = this.tileEthnicMix.get(w.idx(tx, ty)) || this.tileEthnicities.get(w.idx(tx, ty)); -if (!counts) continue; -for (const [id, value] of counts) { -if (id === ethnicity) same += value; -else foreign += value; -} -if (same > 6 && foreign > 6) { -const result = { same: same * 0.22, foreign: foreign * 0.16 }; -this.ethnicDensityCache.set(key, result); -return result; -} -} -} -const result = { same: same * 0.22, foreign: foreign * 0.16 }; -this.ethnicDensityCache.set(key, result); -return result; -} -resolveAssimilation(a) { -const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity); -if (!dominant || dominant.id === a.ethnicity) { -a.foreignContact = Math.max(0, a.foreignContact - 1); -return; -} -if (a.contactEthnicity !== dominant.id) { -a.contactEthnicity = dominant.id; -a.foreignContact = 0; -} -a.foreignContact++; -const pressure = dominant.count / Math.max(1, dominant.total); -const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 100); -if (this.rng.next() < chance * 0.0001) { -this.changeAgentEthnicity(a, dominant.id); -a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22); -a.foreignContact = 0; -} -} -dominantEthnicityNear(x, y, self) { -const key = `${x},${y},${self}`; -const cached = this.dominantEthnicityCache.get(key); -if (cached) return cached; -const counts = new Map(); -let total = 0; -const w = this.world; -for (let dy = -3; dy <= 3; dy++) { -for (let dx = -3; dx <= 3; dx++) { -if (Math.abs(dx) + Math.abs(dy) > 3) continue; -const tx = x + dx; -const ty = y + dy; -if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; -const tileCounts = this.tileEthnicMix.get(w.idx(tx, ty)) || this.tileEthnicities.get(w.idx(tx, ty)); -if (!tileCounts) continue; -for (const [id, count] of tileCounts) { -total += count; -counts.set(id, (counts.get(id) || 0) + count); -} -} -} -for (const city of this.getCitiesNear(x, y, 5)) { -const distance = Math.abs(city.x - x) + Math.abs(city.y - y); -if (!city.ethnicityComposition?.size) continue; -const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8); -for (const [id, count] of city.ethnicityComposition) { -const weighted = Math.max(1, Math.round(count * influence * 0.015)); -total += weighted; -counts.set(id, (counts.get(id) || 0) + weighted); -} -} -let best = null; -for (const [id, count] of counts) { -if (id !== self && (!best || count > best.count)) best = { id, count, total }; -} -if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits(); -if (best) this.dominantEthnicityCache.set(key, best); -return best; -} updateWorldFields(scale = 1) { const w = this.world; const pheromoneDecay = Math.pow(SimConfig.route.pheromoneDecay, scale); const diffusion = SimConfig.route.pheromoneDiffusion * scale; -const nextPheromone = diffusion > 0 ? new Float32Array(w.pheromone) : null; +const nextPheromone = diffusion > 0 ? this.scratchFloatC : null; +if (nextPheromone) nextPheromone.set(w.pheromone); for (let i = 0; i < w.count; i++) { w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15)); const decayed = w.pheromone[i] * pheromoneDecay; @@ -1755,14 +1910,15 @@ w.farmland.fill(0); w.cityPull.fill(0); const candidates = new Map(); this.updatePopulationCapacity(); -for (let tile = 0; tile < w.count; tile++) { +const activeTiles = this.activePopulationSnapshot(); +for (const tile of activeTiles) { if (w.terrain[tile] === Terrain.WATER) continue; const population = w.population[tile] || 0; const settled = w.settledPopulation[tile] || 0; if (settled < 5 && population < 9) continue; const x = tile % w.size; const y = Math.floor(tile / w.size); -if (this.getCitiesNear(x, y, 5).length) continue; +if (this.getCitiesNear(x, y, 10).length) continue; const cx = clamp(Math.round(x / 4) * 4, 0, w.size - 1); const cy = clamp(Math.round(y / 4) * 4, 0, w.size - 1); const i = w.idx(cx, cy); @@ -1785,27 +1941,28 @@ for (const [id, count] of mix) group.ethnicities.set(id, (group.ethnicities.get( } } let foundedThisTick = 0; -const canFoundCities = this.year >= years(80) && this.year % years(10) === 0; -const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0; +const canFoundCities = this.year >= years(160) && this.year % years(20) === 0; +const foundingLimit = canFoundCities ? (this.year < years(1000) ? 1 : 1 + Math.floor(this.year / years(1200))) : 0; const candidateEntries = [...candidates].sort((a, b) => { const scoreA = this.cityFoundingScore(a[0], a[1]); const scoreB = this.cityFoundingScore(b[0], b[1]); return scoreB - scoreA; }); for (const [i, group] of candidateEntries) { -if (group.count < 8) continue; +if (group.count < 24) continue; const avgSedentary = clamp(group.sedentary / Math.max(1, group.count), 0, 1); -let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null; +let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 12)[0] || null; if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) { const foundingScore = this.cityFoundingScore(i, group); -const foundingChance = clamp((foundingScore - 6) * 0.055, 0.03, 0.72); -if (avgSedentary < 0.28 || foundingScore < 6 || this.rng.next() > foundingChance) continue; +const foundingChance = clamp((foundingScore - 12) * 0.030, 0.01, 0.38); +if (avgSedentary < 0.38 || foundingScore < 12 || this.rng.next() > foundingChance) continue; city = this.createCity(i % w.size, Math.floor(i / w.size), group); this.cities.push(city); this.cityById.set(city.id, city); const bucketKey = this.cityBucketKey(city.x, city.y); if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []); this.cityBuckets.get(bucketKey).push(city); +this.assignNewCityToTerritoryOwner(city, i); foundedThisTick++; } if (city) { @@ -1824,7 +1981,8 @@ city.strength = city.strength * 0.96 + group.count * 0.05; } } this.absorbUrbanPopulation(); -this.rebuildOccupancy(); +this.rebuildIndexes(); +this.updateFieldPressureFromPopulation(); this.processCityEconomies(); this.cities = this.cities.filter(c => { c.age++; @@ -1837,12 +1995,12 @@ if (c.population < 25 && foodPerCapita < 0.05) c.strength -= 0.018; } if (c.population <= 0 || c.strength <= 0.06) return false; const radius = c.agriculturalRadius; -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; const x = clamp(c.x + dx, 0, w.size - 1); const y = clamp(c.y + dy, 0, w.size - 1); -const d = Math.abs(dx) + Math.abs(dy); -if (d <= radius) { +const d = offset.distance; const tile = w.idx(x, y); w.city[tile] = c.id; if (w.terrain[tile] !== Terrain.WATER) { @@ -1850,8 +2008,6 @@ w.farmland[tile] = Math.max(w.farmland[tile], (radius - d + 1) / (radius + 1)); w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1)); } } -} -} return true; }); this.rebuildIndexes(); @@ -1862,7 +2018,7 @@ const w = this.world; const settledShare = group.count > 0 ? group.sedentary / group.count : 0; const pressure = w.populationPressure[tile] || 0; const route = w.tradeRoute[tile] ? 1.4 : clamp(w.pheromone[tile] / 10, 0, 1.2); -const nearbyPenalty = this.getCitiesNear(tile % w.size, Math.floor(tile / w.size), 10).length * 4; +const nearbyPenalty = this.getCitiesNear(tile % w.size, Math.floor(tile / w.size), 18).length * 11; return group.count * (0.45 + settledShare) + pressure * 2.2 + w.fertility[tile] * 3.4 + @@ -1872,6 +2028,43 @@ w.mineral[tile] * 0.9 - w.move[tile] * 1.1 - nearbyPenalty; } +assignNewCityToTerritoryOwner(city, tile, options = {}) { +if (!options.explicit) return false; +const w = this.world; +const ownerId = w.territoryOwner?.[tile] ?? -1; +if (!city || ownerId < 0) return false; +const polity = this.getPolityById(ownerId); +if (!polity) return false; +if (city.polityId != null && city.polityId !== ownerId) return false; +const control = clamp(w.control?.[tile] || 0, 0, 1); +const contested = !!w.contested?.[tile]; +if (contested && control < 0.35) return false; +const cityEthnicity = this.dominantCityEthnicity(city); +const nearest = this.nearestPolityCityToTile(polity, tile); +const core = nearest || this.getCityById(polity.centerCityId); +const coreEthnicity = this.dominantCityEthnicity(core); +const sameEthnicity = cityEthnicity !== null && coreEthnicity !== null && cityEthnicity === coreEthnicity; +const differentEthnicity = cityEthnicity !== null && coreEthnicity !== null && cityEthnicity !== coreEthnicity; +const distance = nearest ? Math.abs(nearest.x - city.x) + Math.abs(nearest.y - city.y) : 24; +const loyalty = 0.38 + control * 0.34 - (contested ? 0.18 : 0) + (sameEthnicity ? 0.10 : 0) - (differentEthnicity ? 0.10 : 0) - clamp((distance - 18) * 0.004, 0, 0.10); +this.addCityToPolity(city, polity, clamp(loyalty, 0.18, 0.82)); +return true; +} +nearestPolityCityToTile(polity, tile) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let best = null; +let bestDistance = Infinity; +for (const city of this.getPolityCities(polity)) { +const distance = Math.abs(city.x - x) + Math.abs(city.y - y); +if (distance < bestDistance) { +best = city; +bestDistance = distance; +} +} +return best; +} drawPopulationFromTilesForCity(city, group, amount) { const w = this.world; let remaining = Math.max(0, amount); @@ -1936,36 +2129,47 @@ cityAgeCapacityFactor(city) { return clamp(1 - (city?.ageWear || 0), 0, 1); } cityAgePopulationLimit(city) { -const peakPopulation = Math.max(city?.peakPopulation || 0, city?.population || 0, 1); -return Math.max(1, peakPopulation * this.cityAgeCapacityFactor(city)); +return this.cityCarryingCapacity(city); +} +cityCarryingCapacity(city) { +const w = this.world; +if (!city) return 1; +const radius = Math.max(2, city.agriculturalRadius || 2); +let farmlandSum = 0; +let fertilitySum = 0; +let resourceSum = 0; +let weightSum = 0; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const distance = offset.distance; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const weight = (radius - distance + 1) / (radius + 1); +farmlandSum += (w.farmland[tile] || 0) * weight; +fertilitySum += (w.fertility[tile] || 0) * weight; +resourceSum += clamp((w.resource[tile] || 0) / 35, 0, 1) * weight; +weightSum += weight; +} +const hinterlandFertility = weightSum > 0 ? fertilitySum / weightSum : 0; +const resourceSupport = weightSum > 0 ? resourceSum / weightSum : 0; +let capacity = +35 + +hinterlandFertility * 120 + +resourceSupport * 55 + +farmlandSum * 18 + +(city.tradeValue || 0) * 45 + +(city.knowledge?.farming || 0) * 90 + +Math.sqrt(Math.max(0, city.storedResources || 0)) * 3; +capacity *= clamp(1 - (city.ageWear || 0) * 0.45, 0.45, 1.25); +return Math.max(20, capacity); } absorbUrbanPopulation() { -if (!this.cities.length) return; -const absorbed = []; -for (const a of this.agents) { -if (!a.alive || a.settled < 3) { -absorbed.push(a); -continue; -} -const city = this.findCityNear(a.x, a.y, 9); -const sedentary = a.traits.sedentary; -const nomadism = this.agentNomadism(a); -const nomadRetention = 1 - nomadism * 0.85; -const absorbChance = clamp((0.08 + sedentary * 1.05) * clamp(nomadRetention, 0.10, 1), 0.03, 0.92); -if (!city || this.rng.next() > absorbChance) { -absorbed.push(a); -continue; -} -const migrants = 1 + Math.floor(Math.min(10, a.resources / 7)); -const urbanWeight = clamp(0.12 + (sedentary - 0.10) * 1.55, 0.12, 1); -city.population += migrants; -city.storedResources += Math.max(0, a.resources) * 0.65; -const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight); -if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount); -city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015; -city.strength += 0.04; -} -this.agents = absorbed; +// Population is already absorbed into cities when field-founded settlements draw +// from nearby tile population. This hook remains for the city update pipeline. } weightedUrbanContribution(amount, weight) { const value = amount * weight; @@ -1981,14 +2185,15 @@ city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8); city.knowledge ??= { farming: 0, metallurgy: 0 }; let harvested = 0; const radius = city.agriculturalRadius; -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { -if (Math.abs(dx) + Math.abs(dy) > radius) continue; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const distance = offset.distance; const x = clamp(city.x + dx, 0, w.size - 1); const y = clamp(city.y + dy, 0, w.size - 1); const i = w.idx(x, y); if (w.terrain[i] === Terrain.WATER) continue; -const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1); +const pull = (radius - distance + 1) / (radius + 1); const farmingYield = 1 + city.knowledge.farming * 0.85; const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35; const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull); @@ -1997,7 +2202,6 @@ w.farmland[i] = Math.max(w.farmland[i], pull); this.addPheromone(i, city.pheromoneOutput * pull * 0.09); harvested += extraction; } -} city.storedResources += harvested; const trade = this.cityTradeProfile(city); const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018); @@ -2020,17 +2224,17 @@ city.tradeReach = trade.reach; city.tradeValue = 0; city.tradeReach = 0; } -const agePopulationLimit = this.cityAgePopulationLimit(city); -if (city.storedResources > city.population * 0.16 && city.population > 0 && city.population < agePopulationLimit) { +const capacity = this.cityCarryingCapacity(city); +if (city.storedResources > city.population * 0.14 && city.population > 0 && city.population < capacity && city.supplyStress < 0.9) { const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8); -const birthRoom = Math.max(0, Math.floor(agePopulationLimit - city.population)); +const birthRoom = Math.max(0, Math.floor(capacity - city.population)); const births = Math.min(birthRoom, Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015)))); city.population += births; city.storedResources -= births * 0.55; addBirthsToComposition(city.ethnicityComposition, births, this.rng); } -if (city.population > agePopulationLimit) { -const overLimit = city.population - agePopulationLimit; +if (city.population > capacity && city.supplyStress > 0.45) { +const overLimit = city.population - capacity; const ageLoss = Math.min(city.population, Math.max(1, Math.ceil(overLimit * (SimConfig.city.agingOverCapAttrition ?? 0.035)))); city.population -= ageLoss; removeFromComposition(city.ethnicityComposition, ageLoss); @@ -2050,7 +2254,7 @@ city.population -= loss; city.storedResources = 0; removeFromComposition(city.ethnicityComposition, loss); city.strength -= Math.min(0.4, 0.03 + deficit * 0.01); -if (loss > 0 && this.agents.length < this.maxAgents) { +if (loss > 0) { this.spawnUrbanRefugees(city, Math.min(24, Math.max(2, Math.ceil(loss / 8))), dominant); } } @@ -2100,17 +2304,9 @@ city.knowledge.metallurgy *= 0.997; } } spawnUrbanRefugees(city, count, ethnicity = null) { -const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1; -const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits(); -for (let i = 0; i < count; i++) { -this.agents.push(this.makeAgent( -clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1), -clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1), -dominant, -mutateTraits(template, this.rng, 0.05), -this.rng.range(4, 11) -)); -} +const tile = this.world.idx(city.x, city.y); +const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || this.world.dominantEthnicity[tile]; +if (dominant != null && dominant >= 0) this.seedEthnicPopulationPatch(tile, dominant, this.ethnicities.get(dominant)?.averageTraits || this.randomTraits(), count, 2); } findCityNear(x, y, radius) { let best = null; @@ -2129,23 +2325,53 @@ 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); return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor; } -// Phase 1: administrative territory derived from city holdings. -// This does not expand states. Campaign events below change holdings; this -// pass only projects the visible control field from those holdings. +setTileOwner(tile, polityId, options = {}) { +const w = this.world; +if (tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) return false; +const owner = polityId ?? -1; +if (owner >= 0 && !this.getPolityById(owner)) return false; +w.territoryOwner[tile] = owner; +w.polity[tile] = owner; +if (owner < 0) { +w.control[tile] = 0; +w.claim[tile] = 0; +w.contested[tile] = 0; +const stored = this.territorialClaims.get(tile); +if (stored) this.territorialClaims.delete(tile); +return true; +} +if (options.control != null) w.control[tile] = Math.max(w.control[tile] || 0, options.control); +if (options.claim != null) w.claim[tile] = Math.max(w.claim[tile] || 0, options.claim); +if (options.contested != null) w.contested[tile] = options.contested ? 1 : 0; +return true; +} +syncVisibleTerritoryOwners() { +const w = this.world; +w.polity.set(w.territoryOwner); +} +validPolityIds() { +return new Set(this.polities.map(polity => polity.id)); +} +// Territory ownership is persistent. This pass refreshes control/claim/contest +// visualization from soft influence without moving borders unless explicitly +// requested for initialization/debug repair. clearTerritories() { const w = this.world; -w.polity.fill(-1); +this.syncVisibleTerritoryOwners(); w.control.fill(0); w.claim.fill(0); w.contested.fill(0); } -recomputeTerritories() { +recomputeTerritories(options = {}) { const w = this.world; +const allowOwnershipChanges = !!options.allowOwnershipChanges; this.clearTerritories(); if (!this.polities.length || !this.cities.length) return; -const best = new Float32Array(w.count); -const second = new Float32Array(w.count); -const owner = new Int32Array(w.count); +const best = this.scratchFloatA; +const second = this.scratchFloatB; +const owner = this.scratchIntB; +best.fill(0); +second.fill(0); owner.fill(-1); for (const polity of this.polities) { const polityCities = this.getPolityCities(polity); @@ -2161,10 +2387,7 @@ const loyaltyFactor = 0.62 + clamp(city.loyalty ?? 0.5, 0, 1) * 0.58; const tradeFactor = 1 + clamp((city.tradeReach || 0) / 60, 0, 0.42) + clamp((city.tradeValue || 0) * 0.10, 0, 0.22); const baseInfluence = Math.max(1, this.cityInfluence(city)) * capitalFactor * loyaltyFactor * tradeFactor; const radius = clamp(Math.ceil(maxRange * (0.62 + clamp(Math.sqrt(city.population || 1) / 30, 0, 0.45))), 6, maxRange); -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { -const manhattan = Math.abs(dx) + Math.abs(dy); -if (manhattan > radius) continue; +for (const { dx, dy, distance: manhattan } of this.getRadiusOffsets(radius)) { const x = city.x + dx; const y = city.y + dy; if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; @@ -2191,59 +2414,226 @@ second[tile] = score; } } } -} const threshold = 2.4; for (let i = 0; i < w.count; i++) { -if (owner[i] < 0 || best[i] < threshold) continue; -w.polity[i] = owner[i]; +const currentOwner = w.territoryOwner[i]; +if (allowOwnershipChanges && currentOwner < 0 && owner[i] >= 0 && best[i] >= threshold) { +this.setTileOwner(i, owner[i], { +control: clamp((best[i] - threshold) / 18, 0.12, 1), +claim: clamp(best[i] / 22, 0, 1) +}); +continue; +} +if (currentOwner < 0) { +if (owner[i] >= 0 && best[i] >= threshold) { +w.claim[i] = clamp(best[i] / 28, 0, 0.45); +if (second[i] > best[i] * 0.72) w.contested[i] = 1; +} +continue; +} +if (owner[i] === currentOwner && best[i] >= threshold) { w.claim[i] = clamp(best[i] / 22, 0, 1); w.control[i] = clamp((best[i] - threshold) / 18, 0.12, 1); if (second[i] > best[i] * 0.72) w.contested[i] = 1; +} else { +w.claim[i] = Math.max(w.claim[i], 0.18); +w.control[i] = Math.max(w.control[i], 0.08); +if (owner[i] >= 0 && owner[i] !== currentOwner && best[i] >= threshold * 0.8) w.contested[i] = 1; +} } this.applyStoredTerritorialClaims(); +this.enforceCityTerritoryAnchors(options); +this.enforceTerritoryConnectivity(options); } applyStoredTerritorialClaims() { const w = this.world; -const validPolities = new Set(this.polities.map(polity => polity.id)); +const validPolities = this.validPolityIds(); for (const [tile, claim] of [...this.territorialClaims]) { if (!validPolities.has(claim.polityId) || w.terrain[tile] === Terrain.WATER) { this.territorialClaims.delete(tile); continue; } -w.polity[tile] = claim.polityId; +const polity = this.getPolityById(claim.polityId); +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (!polity || (city && city.polityId !== null && city.polityId !== claim.polityId)) { +this.territorialClaims.delete(tile); +continue; +} +if (city && city.polityId === null) { +w.claim[tile] = Math.max(w.claim[tile], claim.claim || 0.35); +w.control[tile] = Math.max(w.control[tile], Math.min(0.34, claim.control || 0.25)); +w.contested[tile] = 1; +continue; +} w.claim[tile] = Math.max(w.claim[tile], claim.claim || 0.35); w.control[tile] = Math.max(w.control[tile], claim.control || 0.25); if (claim.contested) w.contested[tile] = 1; } } -territoryWaterAnchor(tile) { +enforceCityTerritoryAnchors(options = {}) { const w = this.world; -const x = tile % w.size; -const y = Math.floor(tile / w.size); -const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; -for (const [dx, dy] of dirs) { -const tx = x + dx; -const ty = y + dy; -if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; -const n = w.idx(tx, ty); -if (w.city[n] >= 0 || w.tradeRoute[n] > 0) return true; +const allowOwnershipChanges = !!options.allowOwnershipChanges; +for (const city of this.cities) { +if (city.population <= 0) continue; +const tile = w.idx(city.x, city.y); +if (city.polityId === null) { +if (w.territoryOwner[tile] >= 0) { +w.contested[tile] = 1; +} +continue; +} +const polity = this.getPolityById(city.polityId); +if (!polity) continue; +for (let dy = -1; dy <= 1; dy++) { +for (let dx = -1; dx <= 1; dx++) { +const x = city.x + dx; +const y = city.y + 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) continue; +const otherCity = w.city[t] >= 0 ? this.getCityById(w.city[t]) : null; +if (otherCity && otherCity.id !== city.id && otherCity.polityId !== city.polityId) continue; +const core = dx === 0 && dy === 0; +const ownedByCityPolity = w.territoryOwner[t] === polity.id; +if (allowOwnershipChanges && (core || w.territoryOwner[t] < 0)) this.setTileOwner(t, polity.id); +else if (!ownedByCityPolity) w.contested[t] = 1; +w.control[t] = Math.max(w.control[t], core ? 0.55 : 0.35); +w.claim[t] = Math.max(w.claim[t], core ? 0.62 : 0.40); +if (core && (ownedByCityPolity || allowOwnershipChanges)) w.contested[t] = 0; +} +} +} +} +enforceTerritoryConnectivity(options = {}) { +const w = this.world; +if (!options.allowOwnershipChanges) { +this.syncVisibleTerritoryOwners(); +return 0; +} +let removed = 0; +for (const polity of this.polities) { +const seeds = this.getPolityCities(polity).map(city => w.idx(city.x, city.y)); +if (!seeds.length) continue; +const reachable = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = []; +for (const seed of seeds) { +if (w.territoryOwner[seed] !== polity.id) continue; +reachable[seed] = marker; +queue.push(seed); +} +for (let q = 0; q < queue.length; q++) { +const tile = queue[q]; +this.forCardinalNeighbors(tile, n => { +if (reachable[n] === marker || w.territoryOwner[n] !== polity.id) return; +if (w.terrain[n] === Terrain.WATER && (!w.tradeRoute[n] || !this.territoryWaterAnchor(n))) return; +reachable[n] = marker; +queue.push(n); +}); +} +for (let i = 0; i < w.count; i++) { +if (w.territoryOwner[i] !== polity.id || reachable[i] === marker) continue; +this.setTileOwner(i, -1); +const stored = this.territorialClaims.get(i); +if (stored?.polityId === polity.id) this.territorialClaims.delete(i); +removed++; +} +} +this.territoryDebug.disconnectedTerritoryRemoved += removed; +return removed; +} +reconcileCityTerritoryOwnership() { +const w = this.world; +for (const city of this.cities) { +if (city.population <= 0) continue; +const tile = w.idx(city.x, city.y); +const ownerId = w.territoryOwner[tile]; +if (city.polityId !== null) { +if (ownerId !== city.polityId) { +w.contested[tile] = this.activeEnemyCampaignNearCity(city, 4) ? 1 : w.contested[tile]; +this.territoryDebug.cityTerritoryMismatchesFixed++; +} +continue; +} +const territoryOwner = ownerId >= 0 ? this.getPolityById(ownerId) : null; +if (territoryOwner) w.contested[tile] = 1; +} +this.enforceCityTerritoryAnchors({ allowOwnershipChanges: false }); +} +activeEnemyCampaignNearCity(city, radius) { +const tile = this.world.idx(city.x, city.y); +const x = city.x; +const y = city.y; +for (const campaign of this.campaigns || []) { +if (campaign.status !== "active" || campaign.targetTile == null) continue; +if (campaign.attackerPolityId === city.polityId) continue; +const cx = campaign.targetTile % this.world.size; +const cy = Math.floor(campaign.targetTile / this.world.size); +if (Math.abs(cx - x) + Math.abs(cy - y) <= radius || campaign.targetTile === tile) return true; } return false; } +surroundingPolityForCity(city, radius) { +const w = this.world; +const counts = new Map(); +const controls = new Map(); +let total = 0; +for (const { dx, dy } of this.getRadiusOffsets(radius)) { +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +const owner = w.polity[tile]; +if (owner < 0) continue; +total++; +counts.set(owner, (counts.get(owner) || 0) + 1); +controls.set(owner, (controls.get(owner) || 0) + (w.control[tile] || 0)); +} +let best = null; +let count = 0; +for (const [id, value] of counts) { +if (value > count) { +best = id; +count = value; +} +} +const polity = best != null ? this.getPolityById(best) : null; +return polity ? { polity, share: count / Math.max(1, total), control: (controls.get(best) || 0) / Math.max(1, count) } : null; +} +clearIndependentCityEnclave(city, radius) { +const w = this.world; +for (const { dx, dy } of this.getRadiusOffsets(radius)) { +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +w.control[tile] = 0; +w.claim[tile] = 0; +w.contested[tile] = 0; +} +} +territoryWaterAnchor(tile) { +const w = this.world; +let anchored = false; +this.forCardinalNeighbors(tile, n => { +if (w.city[n] < 0 && w.tradeRoute[n] <= 0) return; +anchored = true; +return false; +}); +return anchored; +} isBorderTile(tile) { const w = this.world; const polityId = w.polity[tile]; if (polityId < 0) return false; -const x = tile % w.size; -const y = Math.floor(tile / w.size); -const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; -for (const [dx, dy] of dirs) { -const tx = x + dx; -const ty = y + dy; -if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; -if (w.polity[w.idx(tx, ty)] !== polityId) return true; -} +let border = false; +this.forCardinalNeighbors(tile, n => { +if (w.polity[n] === polityId) return; +border = true; return false; +}); +return border; } territoryStatsForPolity(polity) { const w = this.world; @@ -2273,18 +2663,17 @@ if (!attacker) continue; const cx = campaign.targetTile % w.size; const cy = Math.floor(campaign.targetTile / w.size); const pressure = clamp(0.10 + campaign.progress * 0.28, 0.08, 0.42); -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { -const distance = Math.abs(dx) + Math.abs(dy); -if (distance > radius) continue; +for (const { dx, dy, distance } of this.getRadiusOffsets(radius)) { const x = cx + dx; const y = cy + dy; if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; const tile = w.idx(x, y); if (w.terrain[tile] === Terrain.WATER) continue; const local = pressure * (1 - distance / (radius + 1)); -if (w.polity[tile] < 0 || w.polity[tile] === attacker.id) { -w.polity[tile] = attacker.id; +const connected = w.territoryOwner[tile] === attacker.id || this.expansionConnectionDistance(attacker, tile, 5) <= 5; +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +const ownedCityCore = city?.polityId === attacker.id; +if ((w.territoryOwner[tile] < 0 && connected) || w.territoryOwner[tile] === attacker.id || ownedCityCore) { w.claim[tile] = Math.max(w.claim[tile], local); w.control[tile] = Math.max(w.control[tile], local * 0.65); } else { @@ -2292,7 +2681,7 @@ w.claim[tile] = Math.max(w.claim[tile], local); w.contested[tile] = 1; } } -} +this.enforceTerritoryConnectivity({ allowOwnershipChanges: false }); } } getCityById(id) { @@ -2409,7 +2798,7 @@ const id = this.nextPolity++; const polity = { id, centerCityId: centerCity.id, -cityIds: new Set([centerCity.id]), +cityIds: new Set(), treasury: Math.max(0, centerCity.storedResources * 0.12), color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), founded: this.year, @@ -2421,11 +2810,15 @@ leaderTenureYears: this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, S crisis: 0, lastCrisisYear: this.year }; -centerCity.polityId = id; -centerCity.loyalty = 1; -centerCity.receivedAid = false; this.polities.push(polity); this.polityById.set(polity.id, polity); +this.transferCityToPolity(centerCity, polity, { +loyalty: 1, +control: 0.72, +claim: 0.85, +reason: "founding" +}); +this.claimPolityCoreTerritory(polity, centerCity, 4, "founding"); this.ensurePolityHistory(polity); this.samplePolityHistory(polity); return polity; @@ -2590,26 +2983,6 @@ for (const id of affectedPolityIds) { const polity = this.getPolityById(id); if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity)); } -let affectedAgents = 0; -for (const agent of this.agents) { -if (!agent.alive) continue; -const d = Math.hypot(agent.x - x, agent.y - y); -if (d > radius) continue; -const falloff = 1 - d / radius; -const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); -if (this.rng.next() < damage * 0.32) { -if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent); -agent.alive = false; -this.deaths++; -affectedAgents++; -} else { -agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45)); -if (agent.tech) { -agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035); -agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04); -} -} -} let affectedCities = 0; let totalPopulationLoss = 0; for (const city of this.cities) { @@ -2685,7 +3058,6 @@ y, radius, intensity, expires: this.year + years(config?.visualDurationYears ?? 18), -affectedAgents, affectedCities, totalPopulationLoss, affectedPolities @@ -2760,15 +3132,23 @@ if (!a || !b || a.id === b.id) return null; if (this.getWarBetween(a.id, b.id)) return null; if (this.isPolityAtWar(a.id) || !this.canBeWarTarget(b.id)) return null; const id = this.nextWar++; +const goal = this.chooseWarGoal(a, b); const war = { id, aPolityId: a.id, bPolityId: b.id, +goal, started: this.year, lastActionYear: this.year, intensity: this.rng.range(0.45, 1.05), exhaustionA: 0, exhaustionB: 0, +scoreA: 0, +scoreB: 0, +capturedByA: 0, +capturedByB: 0, +cityPressureById: new Map(), +lastFrontAdvanceYear: new Map(), ended: null }; this.wars.push(war); @@ -2776,8 +3156,24 @@ a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5); b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5); return war; } +chooseWarGoal(a, b) { +const aPower = Math.max(1, this.polityPower(a)); +const bPower = Math.max(1, this.polityPower(b)); +const ratio = aPower / bPower; +const defenderInstability = this.polityInstability ? this.polityInstability(b) : 0; +const defenderOverextension = this.polityOverextension ? this.polityOverextension(b) : 0; +const defenderLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5; +const distance = this.polityDistance(a, b); +const hasReachableCity = this.bestWarPressureTarget(a, b, { goal: "city_conquest", intensity: 0.8, cityPressureById: new Map() }, aPower, bPower, true); +if (ratio > 2.2 && (defenderInstability > 0.6 || defenderOverextension > 0.8 || defenderLoyalty < 0.38)) return "collapse_exploitation"; +if (ratio > 1.75 && defenderInstability > 0.45 && hasReachableCity && this.rng.next() < 0.22) return "capital_pressure"; +if (ratio > 1.25 && hasReachableCity && this.rng.next() < 0.48) return "city_conquest"; +if (distance < 24 && this.rng.next() < 0.62) return "border_claim"; +return "punitive_raid"; +} endWar(war) { if (!war || war.ended !== null) return; +this.applyWarSettlement(war); war.ended = this.year; } totalPolityPopulation(polity) { @@ -2960,6 +3356,7 @@ foundPolities() { let formed = 0; for (const city of this.cities) { if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue; +if (this.world.territoryOwner[this.world.idx(city.x, city.y)] >= 0) continue; if (formed >= 2) break; const localPeers = this.getCitiesNear(city.x, city.y, 18).filter(other => other !== city && other.polityId === null); const localLeader = localPeers.every(other => this.cityInfluence(city) >= this.cityInfluence(other) * 0.92); @@ -2969,6 +3366,7 @@ formed++; } } expandPolities() { +return; for (const polity of this.polities) { const center = this.getCityById(polity.centerCityId); if (!center) continue; @@ -2994,6 +3392,7 @@ absorbed++; } } absorbIndependentCities() { +return; if (this.year % years(5) !== 0) return; for (const polity of this.polities) { const cities = this.getPolityCities(polity); @@ -3085,9 +3484,12 @@ frontierCampaignTargets(polity) { return this.sampleTileCampaignTargets(polity, (tile, connection) => { const w = this.world; if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null; +const expansion = this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 6, requireUnowned: true }); +if (!expansion) return null; const nearbyTrade = (w.tradeRoute[tile] ? 0.5 : 0) + clamp(w.pheromone[tile] / 12, 0, 0.45); const resistance = Math.sqrt(Math.max(0, w.population[tile] || 0)) * 0.10; -const value = w.fertility[tile] + clamp(w.resource[tile] / 45, 0, 1) * 0.8 + nearbyTrade + w.mineral[tile] * 0.25 - resistance - connection.distance * 0.018 - w.move[tile] * 0.22; +const frontierDistance = this.expansionConnectionDistance(polity, tile, 6); +const value = w.fertility[tile] + clamp(w.resource[tile] / 45, 0, 1) * 0.8 + nearbyTrade + w.mineral[tile] * 0.25 - resistance - frontierDistance * 0.10 - connection.distance * 0.010 - w.move[tile] * 0.22; if (value < 0.38) return null; return { targetTile: tile, weight: this.noisyTargetWeight(value) }; }); @@ -3096,6 +3498,8 @@ nonStateCampaignTargets(polity) { return this.sampleTileCampaignTargets(polity, (tile, connection) => { const w = this.world; if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null; +const expansion = this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 7, requireUnowned: true }); +if (!expansion) return null; const population = w.population?.[tile] || w.pressure[tile] || 0; const ethnicity = w.dominantEthnicity[tile]; if (population < 2 && ethnicity < 0) return null; @@ -3103,7 +3507,8 @@ const diversity = w.cultureDiversity[tile] || 0; const sourceEthnicity = this.dominantCityEthnicity(connection.source); const cultureMismatch = sourceEthnicity !== null && ethnicity >= 0 && sourceEthnicity !== ethnicity ? 0.45 : 0; const strategic = (w.tradeRoute[tile] ? 0.45 : 0) + w.mineral[tile] * 0.38 + w.fertility[tile] * 0.25; -const score = Math.sqrt(Math.max(1, population)) * 0.38 + strategic + clamp(w.resource[tile] / 45, 0, 1) * 0.35 - connection.distance * 0.016 - w.move[tile] * 0.22 - cultureMismatch + diversity * 0.12; +const frontierDistance = this.expansionConnectionDistance(polity, tile, 7); +const score = Math.sqrt(Math.max(1, population)) * 0.38 + strategic + clamp(w.resource[tile] / 45, 0, 1) * 0.35 - frontierDistance * 0.09 - connection.distance * 0.008 - w.move[tile] * 0.22 - cultureMismatch + diversity * 0.12; return { targetTile: tile, targetEthnicity: ethnicity >= 0 ? ethnicity : null, @@ -3118,12 +3523,14 @@ for (const city of this.cities) { if (city.polityId !== null || city.population <= 0) continue; const connection = this.polityConnectionToCity(polity, city); if (!connection.connected || connection.distance > (cfg.maxTargetDistance ?? 48)) continue; +const tile = this.world.idx(city.x, city.y); +if (!this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 8, allowIndependentCity: true })) continue; const cityInfluence = Math.max(1, this.cityInfluence(city)); const proximity = 1 / (1 + connection.distance * 0.06); const cityStrength = clamp(city.strength || 1, 0.2, 8) * 0.12; const score = Math.sqrt(city.population || 1) * 0.12 + (city.tradeValue || 0) * 0.55 + proximity * 1.4 - connection.distance * 0.012 - cityStrength + this.polityPower(polity) / Math.max(1, cityInfluence) * 0.18; targets.push({ -targetTile: this.world.idx(city.x, city.y), +targetTile: tile, targetCityId: city.id, connection, weight: this.noisyTargetWeight(score) @@ -3140,6 +3547,8 @@ const distance = this.polityDistance(polity, other); if (!Number.isFinite(distance) || distance > (cfg.maxTargetDistance ?? 48)) continue; const border = this.nearestBorderCampaignTile(polity, other); const city = this.borderWarTargetCity(polity, other); +if (city && !this.isBorderWarCityTarget(polity, other, city, border.tile)) continue; +if (!city && (border.tile == null || !this.isAdjacentToPolity(border.tile, polity.id))) continue; const defenderPower = Math.max(1, this.polityPower(other)); const targetCityValue = city ? Math.sqrt(city.population || 1) * 0.18 + (city.tradeValue || 0) * 0.5 : 0.25; const contested = border.tile != null && this.world.contested[border.tile] ? 0.75 : 0; @@ -3184,6 +3593,69 @@ weight: Math.max(0, base.weight || 0) / (1 + connection.distance * 0.035) } return targets; } +isValidExpansionTarget(polity, tile, options = {}) { +const w = this.world; +if (!polity || tile == null || w.terrain[tile] === Terrain.WATER) return false; +const owner = w.polity[tile]; +if (options.requireUnowned && owner >= 0) return false; +if (owner >= 0 && owner !== polity.id && !options.allowEnemy) return false; +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (city && city.polityId !== null && city.polityId !== polity.id && !options.allowEnemy) return false; +if (city && city.polityId === null && !options.allowIndependentCity && !options.requireUnowned) return false; +if (owner === polity.id && options.allowOwned) return true; +const maxPath = options.maxNeutralPath ?? 6; +if (this.expansionConnectionDistance(polity, tile, maxPath) <= maxPath) return true; +if (w.tradeRoute[tile]) { +const connection = this.nearestPolityTileConnection(polity, tile); +return !!connection.source && connection.distance <= (SimConfig.campaign?.maxTargetDistance ?? 48) * 0.6; +} +this.territoryDebug.invalidCampaignTargetsRejected++; +return false; +} +expansionConnectionDistance(polity, tile, maxPath = 6) { +const w = this.world; +if (w.polity[tile] === polity.id) return 0; +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = [{ tile, distance: 0 }]; +visited[tile] = marker; +for (let q = 0; q < queue.length; q++) { +const current = queue[q]; +if (current.distance >= maxPath) continue; +let found = Infinity; +this.forCardinalNeighbors(current.tile, n => { +if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return; +if (w.polity[n] === polity.id) { +found = current.distance + 1; +return false; +} +if (w.polity[n] >= 0 && w.polity[n] !== polity.id) return; +visited[n] = marker; +queue.push({ tile: n, distance: current.distance + 1 }); +}); +if (found < Infinity) return found; +} +return Infinity; +} +isAdjacentToPolity(tile, polityId) { +const w = this.world; +let adjacent = false; +this.forCardinalNeighbors(tile, n => { +if (w.polity[n] !== polityId) return; +adjacent = true; +return false; +}); +return adjacent; +} +isBorderWarCityTarget(attacker, defender, city, fallbackBorderTile = null) { +const tile = this.world.idx(city.x, city.y); +if (this.isAdjacentToPolity(tile, attacker.id)) return true; +if (fallbackBorderTile != null) { +const distance = Math.abs(city.x - (fallbackBorderTile % this.world.size)) + Math.abs(city.y - Math.floor(fallbackBorderTile / this.world.size)); +if (distance <= 10) return true; +} +return this.expansionConnectionDistance(attacker, tile, 8) <= 8 && this.isValidExpansionTarget(attacker, tile, { allowEnemy: true, maxNeutralPath: 8 }); +} nearestPolityTileConnection(polity, tile) { const w = this.world; const x = tile % w.size; @@ -3325,32 +3797,65 @@ const attacker = this.getPolityById(campaign.attackerPolityId); if (!attacker) return; if (campaign.type === "independent_city_annexation") { const city = this.getCityById(campaign.targetCityId); -if (city) { +const cityTile = city ? this.world.idx(city.x, city.y) : null; +const cityTileOwner = cityTile != null ? this.world.territoryOwner[cityTile] : -1; +if (city && city.polityId === null && (cityTileOwner < 0 || cityTileOwner === attacker.id)) { const similarity = this.campaignEthnicSimilarity(attacker, campaign); -if (city.polityId !== null && city.polityId !== attacker.id) this.removeCityFromPolity(city); -this.addCityToPolity(city, attacker, clamp(0.28 + similarity * 0.34 - campaign.resistance / Math.max(1, campaign.strength + campaign.resistance) * 0.14, 0.16, 0.68)); +this.transferCityToPolity(city, attacker, { +loyalty: clamp(0.28 + similarity * 0.34 - campaign.resistance / Math.max(1, campaign.strength + campaign.resistance) * 0.14, 0.16, 0.68), +control: 0.48, +claim: 0.62, +reason: "annexation" +}); +campaign.affectedTiles = this.applyCampaignTerritory(attacker, cityTile, 5, 0.42, { +allowForeignTakeover: false, +reason: "annexation" +}) || []; } this.addPolityEvent(attacker.id, "annexation", this.year, this.campaignEventData(campaign, "succeeded"), 2); } else if (campaign.type === "border_war") { const defender = this.getPolityById(campaign.defenderPolityId); const city = this.getCityById(campaign.targetCityId); if (city && defender && city.polityId === defender.id) { -this.removeCityFromPolity(city); -this.addCityToPolity(city, attacker, 0.22); +this.applyCityWarDamage(city, clamp(0.04 + campaign.progress * 0.025, 0.03, 0.14), "campaign"); +this.transferCityToPolity(city, attacker, { +loyalty: 0.22, +control: 0.58, +claim: 0.72, +allowForeignTakeover: true, +reason: "borderWar" +}); +campaign.affectedTiles = this.transferWarTerritory(attacker, defender, this.world.idx(city.x, city.y), Math.max(9, SimConfig.campaign?.claimRadius ?? 8), { +strength: 0.62, +reason: "borderWar", +maxTiles: 95 +}) || []; } else { -this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.48); +campaign.affectedTiles = defender +? this.transferFrontierBelt(attacker, defender, campaign.targetTile, 4, 5, { maxTiles: 70, reason: "borderWar" }) || [] +: this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.48, { allowForeignTakeover: false, reason: "campaign" }) || []; +this.applyWarZoneTileDamage(campaign.targetTile, (SimConfig.war.warZoneTileLossRate ?? 0.006) * 1.5); } if (defender) { defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.04, 0, 1); +defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.025, 0, 1); defender.treasury = Math.max(0, (defender.treasury || 0) - campaign.strength * 0.04); +const war = this.getWarBetween(attacker.id, defender.id); +if (war) this.addWarScore(war, attacker.id, city ? 1.5 : 0.75); } this.addPolityEvent(attacker.id, "borderWar", this.year, this.campaignEventData(campaign, "succeeded"), 2); } else if (campaign.type === "nonstate_subjugation") { -this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.56); +this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.56, { +allowForeignTakeover: false, +reason: "subjugation" +}); this.applySubjugationEffects(attacker, campaign); this.addPolityEvent(attacker.id, "subjugation", this.year, this.campaignEventData(campaign, "succeeded"), 1); } else { -this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.46); +this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.46, { +allowForeignTakeover: false, +reason: "frontier" +}); this.seedFrontierPopulation(attacker, campaign.targetTile); this.maybeCreateFrontierSettlement(attacker, campaign.targetTile); this.addPolityEvent(attacker.id, "expansion", this.year, this.campaignEventData(campaign, "succeeded"), 1); @@ -3358,8 +3863,13 @@ this.addPolityEvent(attacker.id, "colonization", this.year, this.campaignEventDa } this.recordCampaign(attacker, campaign, campaign.status); this.syncTilePopulationCulture(); -this.recomputeTerritories(); +this.recomputeTerritories({ allowOwnershipChanges: false, reason: "campaign" }); +this.applyStoredTerritorialClaims(); +this.enforceCityTerritoryAnchors({ allowOwnershipChanges: false }); +this.enforceTerritoryConnectivity({ allowOwnershipChanges: false }); +this.reconcileCityTerritoryOwnership(); this.campaignPressureOnTerritories(); +this.enforceTerritoryConnectivity({ allowOwnershipChanges: false }); } resolveCampaignFailure(campaign, reason = "failed") { campaign.status = reason; @@ -3376,8 +3886,12 @@ const war = campaign.defenderPolityId != null ? this.getWarBetween(attacker.id, if (war) { if (war.aPolityId === attacker.id) war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1); else war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1); +const defender = campaign.defenderPolityId != null ? this.getPolityById(campaign.defenderPolityId) : null; +if (defender) this.addWarScore(war, defender.id, 0.45 + clamp(campaign.resistance / Math.max(1, campaign.strength), 0, 1.2)); } this.addPolityEvent(attacker.id, "failedCampaign", this.year, this.campaignEventData(campaign, reason), 1); +this.applyPolityWarExhaustionPopulationLoss(attacker, 0.45, clamp(campaign.resistance / Math.max(1, campaign.strength), 0.4, 1.4)); +this.applyWarZoneTileDamage(campaign.targetTile, SimConfig.war.warZoneTileLossRate ?? 0.006); this.recordCampaign(attacker, campaign, campaign.status); } campaignEthnicSimilarity(polity, campaign) { @@ -3388,32 +3902,266 @@ const b = targetCity ? this.dominantCityEthnicity(targetCity) : this.world.domin if (a === null || b === null || b < 0) return 0.45; return a === b ? 1 : 0.25; } -applyCampaignTerritory(polity, centerTile, radius, strength) { +transferTerritoryTiles(fromPolityId, toPolityId, tiles, options = {}) { const w = this.world; -const cx = centerTile % w.size; -const cy = Math.floor(centerTile / w.size); -for (let dy = -radius; dy <= radius; dy++) { -for (let dx = -radius; dx <= radius; dx++) { -const distance = Math.abs(dx) + Math.abs(dy); -if (distance > radius) continue; -const x = cx + dx; -const y = cy + dy; +const changed = []; +const toPolity = this.getPolityById(toPolityId); +const clearing = toPolityId == null || toPolityId < 0; +if (!toPolity && !clearing) return changed; +for (const tile of tiles) { +if (tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) continue; +const owner = w.territoryOwner[tile]; +if (fromPolityId != null && owner !== fromPolityId) continue; +if (!clearing && owner >= 0 && owner !== toPolityId && !options.allowForeignTakeover) continue; +if (owner === toPolityId || (clearing && owner < 0)) continue; +this.setTileOwner(tile, toPolityId, { +control: options.control ?? 0.32, +claim: options.claim ?? 0.45, +contested: options.contested ?? false +}); +if (clearing) { +this.territorialClaims.delete(tile); +} else { +this.territorialClaims.set(tile, { +polityId: toPolityId, +control: options.control ?? 0.32, +claim: options.claim ?? 0.45, +contested: !!options.contested, +reason: options.reason ?? "transfer", +year: this.year +}); +} +changed.push(tile); +} +if (changed.length) { +this.markContestedFront(toPolity, fromPolityId != null ? this.getPolityById(fromPolityId) : null, changed, options.intensity ?? 0.45); +if (fromPolityId != null && fromPolityId !== toPolityId) { +const defender = this.getPolityById(fromPolityId); +if (defender) { +defender.cohesion = clamp((defender.cohesion ?? 0.6) - Math.min(0.08, changed.length * 0.0008), 0, 1); +defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - Math.min(0.06, changed.length * 0.0006), 0, 1); +} +} +this.recomputeTerritories({ allowOwnershipChanges: false, reason: options.reason ?? "transfer" }); +} +return changed; +} +claimPolityCoreTerritory(polity, city, radius = 4, reason = "founding") { +if (!polity || !city) return []; +const w = this.world; +const tiles = []; +for (const { dx, dy, distance } of this.getRadiusOffsets(radius)) { +const x = city.x + dx; +const y = city.y + dy; if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; const tile = w.idx(x, y); if (w.terrain[tile] === Terrain.WATER) continue; -const local = strength * (1 - distance / (radius + 1)); -w.polity[tile] = polity.id; -w.control[tile] = Math.max(w.control[tile], local); -w.claim[tile] = Math.max(w.claim[tile], local); -w.contested[tile] = 0; -this.territorialClaims.set(tile, { -polityId: polity.id, -control: Math.max(local, 0.18), -claim: Math.max(local, 0.22), -contested: false +if (w.territoryOwner[tile] >= 0 && w.territoryOwner[tile] !== polity.id) continue; +const otherCity = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (otherCity && otherCity.polityId !== null && otherCity.polityId !== polity.id) continue; +const falloff = 1 - distance / (radius + 1); +if (falloff <= 0) continue; +tiles.push(tile); +} +return this.transferTerritoryTiles(null, polity.id, tiles, { +control: 0.42, +claim: 0.58, +reason +}); +} +transferCityToPolity(city, toPolity, options = {}) { +if (!city || !toPolity) return false; +const fromPolityId = city.polityId; +if (fromPolityId != null && fromPolityId !== toPolity.id) this.removeCityFromPolity(city); +if (city.polityId !== toPolity.id) this.addCityToPolity(city, toPolity, options.loyalty ?? 0.28); +const tile = this.world.idx(city.x, city.y); +this.transferTerritoryTiles(options.fromPolityId ?? fromPolityId, toPolity.id, [tile], { +allowForeignTakeover: !!options.allowForeignTakeover, +control: options.control ?? 0.58, +claim: options.claim ?? 0.70, +reason: options.reason ?? "cityTransfer" +}); +return true; +} +releaseCityTerritory(city, fromPolityId, radius = 4, reason = "fragmentation") { +if (!city || fromPolityId == null) return []; +const w = this.world; +const tiles = []; +for (const { dx, dy, distance } of this.getRadiusOffsets(radius)) { +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER || w.territoryOwner[tile] !== fromPolityId) continue; +const cityHere = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (cityHere && cityHere.id !== city.id && cityHere.polityId === fromPolityId) continue; +const falloff = 1 - distance / (radius + 1); +if (falloff > 0) tiles.push(tile); +} +return this.transferTerritoryTiles(fromPolityId, -1, tiles, { reason, intensity: 0.55 }); +} +releasePolityTerritory(polityId, reason = "collapse") { +if (polityId == null) return []; +const w = this.world; +const tiles = []; +for (let i = 0; i < w.count; i++) { +if (w.territoryOwner[i] === polityId) tiles.push(i); +} +return this.transferTerritoryTiles(polityId, -1, tiles, { reason, intensity: 0.65 }); +} +markContestedFront(attacker, defender, tiles, intensity = 0.45) { +const w = this.world; +const attackerId = typeof attacker === "object" ? attacker?.id : attacker; +const defenderId = typeof defender === "object" ? defender?.id : defender; +for (const tile of tiles) { +this.forCardinalNeighbors(tile, n => { +if (w.terrain[n] === Terrain.WATER) return; +if (defenderId != null && w.territoryOwner[n] === defenderId) { +w.contested[n] = 1; +w.claim[n] = Math.max(w.claim[n], clamp(intensity, 0.18, 0.85)); +} +if (attackerId != null && w.territoryOwner[n] === attackerId) { +w.control[n] = Math.max(w.control[n], clamp(0.24 + intensity * 0.35, 0.24, 0.75)); +} }); } } +transferWarTerritory(attacker, defender, centerTile, radius, options = {}) { +if (!attacker || !defender || centerTile == null) return []; +return this.applyCampaignTerritory(attacker, centerTile, radius, options.strength ?? 0.55, { +allowForeignTakeover: true, +fromPolityId: defender.id, +reason: options.reason ?? "war", +preferFront: true, +maxTiles: options.maxTiles ?? null +}); +} +transferFrontierBelt(attacker, defender, frontTile, width = 3, depth = 3, options = {}) { +const w = this.world; +if (!attacker || !defender || frontTile == null) return []; +const queue = [{ tile: frontTile, distance: 0 }]; +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +visited[frontTile] = marker; +const tiles = []; +const maxTiles = options.maxTiles ?? clamp(width * depth * 7, 10, 140); +for (let q = 0; q < queue.length && tiles.length < maxTiles; q++) { +const { tile, distance } = queue[q]; +if (distance > width + depth) continue; +if (w.terrain[tile] !== Terrain.WATER && w.territoryOwner[tile] === defender.id) tiles.push(tile); +this.forCardinalNeighbors(tile, n => { +if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return; +const owner = w.territoryOwner[n]; +if (owner !== defender.id && owner !== attacker.id) return; +visited[n] = marker; +queue.push({ tile: n, distance: distance + 1 }); +}); +} +return this.transferTerritoryTiles(defender.id, attacker.id, tiles, { +allowForeignTakeover: true, +control: options.control ?? 0.30, +claim: options.claim ?? 0.48, +contested: true, +reason: options.reason ?? "frontAdvance", +intensity: options.intensity ?? 0.55 +}); +} +applyCampaignTerritory(polity, centerTile, radius, strength, options = {}) { +const w = this.world; +const cx = centerTile % w.size; +const cy = Math.floor(centerTile / w.size); +const centerCity = w.city[centerTile] >= 0 ? this.getCityById(w.city[centerTile]) : null; +const capturedCitySeed = centerCity?.polityId === polity.id; +const allowForeignTakeover = !!options.allowForeignTakeover; +const fromPolityId = options.fromPolityId ?? null; +if (!capturedCitySeed && !allowForeignTakeover && !this.isValidExpansionTarget(polity, centerTile, { allowOwned: true, maxNeutralPath: 8 })) { +this.territoryDebug.invalidCampaignTargetsRejected++; +return false; +} +const seeds = capturedCitySeed || allowForeignTakeover ? [centerTile] : this.connectedExpansionSeeds(polity, centerTile, 8); +if (!seeds.length) { +this.territoryDebug.invalidCampaignTargetsRejected++; +return false; +} +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = seeds.map(tile => ({ tile, distance: Math.abs((tile % w.size) - cx) + Math.abs(Math.floor(tile / w.size) - cy) })); +for (const seed of seeds) visited[seed] = marker; +const candidates = []; +const maxTiles = options.maxTiles ?? Infinity; +for (let q = 0; q < queue.length; q++) { +const { tile, distance } = queue[q]; +if (distance > radius) continue; +if (w.terrain[tile] === Terrain.WATER) continue; +const tileCity = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (tileCity && tileCity.polityId !== null && tileCity.polityId !== polity.id && !allowForeignTakeover) continue; +const owner = w.territoryOwner[tile]; +const canTake = +owner === polity.id || +(allowForeignTakeover +? (fromPolityId == null ? owner < 0 || owner !== polity.id : owner === fromPolityId) +: owner < 0); +if (!canTake) { +if (owner >= 0 && owner !== polity.id) w.contested[tile] = 1; +continue; +} +const local = strength * (1 - distance / (radius + 1)); +candidates.push({ tile, distance, local }); +if (candidates.length >= maxTiles) break; +this.forCardinalNeighbors(tile, (n, tx, ty) => { +if (visited[n] === marker) return; +const nd = Math.abs(tx - cx) + Math.abs(ty - cy); +if (nd > radius || w.terrain[n] === Terrain.WATER) return; +const neighborOwner = w.territoryOwner[n]; +if (neighborOwner >= 0 && neighborOwner !== polity.id && !allowForeignTakeover) { +w.contested[n] = 1; +return; +} +if (allowForeignTakeover && fromPolityId != null && neighborOwner >= 0 && neighborOwner !== polity.id && neighborOwner !== fromPolityId) return; +visited[n] = marker; +queue.push({ tile: n, distance: nd }); +}); +} +const ordered = candidates +.sort((a, b) => a.distance - b.distance) +.slice(0, Number.isFinite(maxTiles) ? maxTiles : candidates.length); +return this.transferTerritoryTiles( +fromPolityId, +polity.id, +ordered.map(entry => entry.tile), +{ +allowForeignTakeover, +control: Math.max(0.18, strength * 0.65), +claim: Math.max(0.22, strength), +contested: false, +reason: options.reason ?? "campaign", +intensity: strength +} +); +} +connectedExpansionSeeds(polity, tile, maxPath = 8) { +const w = this.world; +if (w.polity[tile] === polity.id) return [tile]; +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = [{ tile, distance: 0 }]; +const seeds = []; +visited[tile] = marker; +for (let q = 0; q < queue.length; q++) { +const current = queue[q]; +if (current.distance >= maxPath) continue; +this.forCardinalNeighbors(current.tile, n => { +if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return; +if (w.polity[n] === polity.id) { +seeds.push(n); +return; +} +if (w.polity[n] >= 0 && w.polity[n] !== polity.id) return; +visited[n] = marker; +queue.push({ tile: n, distance: current.distance + 1 }); +}); +} +return seeds; } applySubjugationEffects(polity, campaign) { const localEthnicity = campaign.targetEthnicity ?? this.world.dominantEthnicity[campaign.targetTile]; @@ -3434,12 +4182,14 @@ const migrants = Math.max(1, Math.floor(Math.min(source.population * 0.018, 14)) source.population = Math.max(1, source.population - migrants); removeFromComposition(source.ethnicityComposition, migrants); this.world.population[tile] += migrants; +this.markPopulationTile(tile); let culture = this.tileEthnicMix.get(tile); if (!culture) { culture = new Map(); this.tileEthnicMix.set(tile, culture); } culture.set(ethnicity, (culture.get(ethnicity) || 0) + migrants); +this.updateCultureTile(tile); } maybeCreateFrontierSettlement(polity, tile) { const w = this.world; @@ -3529,7 +4279,13 @@ this.cityById.set(city.id, city); const bucketKey = this.cityBucketKey(city.x, city.y); if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []); this.cityBuckets.get(bucketKey).push(city); -this.addCityToPolity(city, polity, city.loyalty); +this.transferCityToPolity(city, polity, { +loyalty: city.loyalty, +control: 0.52, +claim: 0.66, +reason: "frontierSettlement" +}); +this.claimPolityCoreTerritory(polity, city, 3, "frontierSettlement"); w.city[targetTile] = city.id; this.removeEthnicPopulationProportionally(targetTile, Math.min(seedPopulation, (w.population[targetTile] || 0) * 0.65)); this.projectCityPopulationToTiles(city); @@ -3629,21 +4385,10 @@ this.maybeEndWar(war); this.wars = this.wars.filter(w => w.ended === null); } applyWarPressure(war, attacker, defender) { -const attackerCities = this.getPolityCities(attacker); -const defenderCities = this.getPolityCities(defender); -if (!attackerCities.length || !defenderCities.length) return; const attackerPower = this.polityPower(attacker); const defenderPower = this.polityPower(defender); -if (attackerPower <= defenderPower * 1.05) return; -const range = this.polityInfluenceRange(attacker, true); -const candidates = defenderCities -.filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2) -.map(city => ({ city, connection: this.polityConnectionToCity(attacker, city) })) -.filter(x => x.connection.connected && x.connection.distance <= range) -.sort((a, b) => a.connection.distance - b.connection.distance); -if (!candidates.length) return; -const topCandidates = candidates.slice(0, 3); -const selected = topCandidates[this.rng.int(topCandidates.length)]; +const selected = this.bestWarPressureTarget(attacker, defender, war, attackerPower, defenderPower); +if (!selected) return; const pressure = this.warAbsorptionPressure( attacker, defender, @@ -3652,13 +4397,110 @@ selected.connection.distance, attackerPower, defenderPower ); -const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45); -if (pressure > 0.95 && this.rng.next() < chance) { +const key = `${attacker.id}:${selected.city.id}`; +const memory = (war.cityPressureById?.get(key) || 0) * (SimConfig.war.pressureMemoryDecay ?? 0.88) + pressure * (war.intensity || 0.75); +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 + pressure * 0.35) * 0.16, 0.01, 0.58); +const damageRate = clamp( +(SimConfig.war.cityDamageMinRate ?? 0.003) + pressure * 0.010 + memory * 0.006, +SimConfig.war.cityDamageMinRate ?? 0.003, +SimConfig.war.cityDamageMaxRate ?? 0.08 +); +if (memory > captureThreshold && pressure > 0.72 && this.rng.next() < chance) { this.captureCityInWar(selected.city, attacker, defender, war, pressure); } else { -selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1); +this.applyCityWarDamage(selected.city, damageRate, "warPressure"); +this.applyWarZoneTileDamage(this.world.idx(selected.city.x, selected.city.y), SimConfig.war.warZoneTileLossRate ?? 0.006); +selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.018 - memory * 0.004, 0, 1); +this.addWarScore(war, attacker.id, pressure * 0.08 + damageRate * 8); +const lastAdvance = war.lastFrontAdvanceYear instanceof Map ? (war.lastFrontAdvanceYear.get(attacker.id) ?? -Infinity) : -Infinity; +const canAdvanceFront = +memory > 1.05 && +pressure > 0.58 && +this.year - lastAdvance > years(4); +if (canAdvanceFront && this.rng.next() < clamp((memory - 0.9) * 0.10 + (pressure - 0.55) * 0.22, 0.02, 0.34)) { +const border = this.nearestBorderCampaignTile(attacker, defender); +if (border?.tile != null && Number.isFinite(border.distance)) { +const width = pressure > 1.05 ? 5 : 3; +const depth = memory > 1.8 ? 6 : 4; +const maxTiles = pressure > 1.25 || memory > 2.1 ? 120 : pressure > 0.9 ? 62 : 28; +const moved = this.transferFrontierBelt(attacker, defender, border.tile, width, depth, { +maxTiles, +reason: "frontAdvance", +intensity: clamp(0.38 + pressure * 0.18, 0.42, 0.72) +}); +if (moved.length) { +war.lastActionYear = this.year; +war.lastFrontAdvanceYear ??= new Map(); +war.lastFrontAdvanceYear.set(attacker.id, this.year); +this.addWarScore(war, attacker.id, moved.length * 0.018); } } +} +} +} +bestWarPressureTarget(attacker, defender, war, attackerPower = this.polityPower(attacker), defenderPower = this.polityPower(defender), probe = false) { +const attackerCities = this.getPolityCities(attacker); +const defenderCities = this.getPolityCities(defender); +if (!attackerCities.length || !defenderCities.length) return null; +const range = this.warTargetRange(attacker, defender, war, attackerPower, defenderPower); +const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0; +const defenderCohesion = clamp(defender.cohesion ?? 0.6, 0, 1); +const defenderLegitimacy = clamp(defender.legitimacy ?? 0.7, 0, 1); +const pressureMap = war.cityPressureById || new Map(); +let best = null; +let bestScore = -Infinity; +for (const city of defenderCities) { +if (city.population <= 0) continue; +const capital = city.id === defender.centerCityId; +if (capital && defenderCities.length > 2 && war.goal !== "capital_pressure" && war.goal !== "collapse_exploitation") continue; +const connection = this.warConnectionToCity(attacker, city); +if (!connection.source || connection.distance > range) continue; +const memory = pressureMap.get(`${attacker.id}:${city.id}`) || 0; +const lowLoyalty = 1 - clamp(city.loyalty ?? 0.5, 0, 1); +const routeAccess = this.hasDirectTradeConnection(connection.source, city) || this.world.tradeRoute[this.world.idx(city.x, city.y)] ? 0.55 : 0; +const cityValue = Math.sqrt(city.population || 1) * 0.10 + (city.tradeValue || 0) * 0.55 + (capital ? 0.9 : 0); +const powerAdvantage = clamp(attackerPower / Math.max(1, defenderPower) - 1, -0.4, 2.4); +const deepPenalty = Math.max(0, connection.distance - SimConfig.polity.logisticsDistance) * (war.goal === "capital_pressure" ? 0.010 : 0.018); +const weakState = defenderInstability * 0.55 + (1 - defenderCohesion) * 0.35 + (1 - defenderLegitimacy) * 0.28; +const goalBonus = +war.goal === "punitive_raid" ? (city.storedResources || 0) * 0.004 : +war.goal === "border_claim" ? Math.max(0, 18 - connection.distance) * 0.035 : +war.goal === "collapse_exploitation" ? weakState * 0.9 + lowLoyalty * 0.45 : +war.goal === "capital_pressure" && capital ? 1.15 : +0.35; +const score = cityValue + lowLoyalty * 0.85 + routeAccess + memory * 0.45 + powerAdvantage * 0.45 + weakState + goalBonus - connection.distance * 0.018 - deepPenalty + (probe ? 0 : this.rng.range(-0.08, 0.10)); +if (score > bestScore) { +bestScore = score; +best = { city, connection, score }; +} +} +return best; +} +warTargetRange(attacker, defender, war, attackerPower, defenderPower) { +const base = this.polityInfluenceRange(attacker, true); +const ratio = attackerPower / Math.max(1, defenderPower); +const routeReach = this.averagePolityTechnology(attacker) * 12; +const cohesion = clamp(attacker.cohesion ?? 0.6, 0, 1) * 8; +const weakDefender = (this.polityInstability ? this.polityInstability(defender) : 0) * 8; +const goalBoost = war.goal === "capital_pressure" ? 16 : war.goal === "collapse_exploitation" ? 14 : war.goal === "city_conquest" ? 8 : 0; +return clamp(base + clamp((ratio - 1) * 12, 0, 18) + routeReach + cohesion + weakDefender + goalBoost, 24, 82); +} +warConnectionToCity(polity, city) { +let source = null; +let distance = Infinity; +for (const c of this.getPolityCities(polity)) { +if (c.id === city.id) continue; +let d = this.effectiveDistance(c, city); +if (this.hasDirectTradeConnection(c, city)) d *= 0.72; +if (d < distance) { +source = c; +distance = d; +} +} +return { source, distance, connected: !!source }; +} warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) { const powerRatio = attackerPower / Math.max(1, defenderPower); const proximity = 1 / (1 + distance * 0.055); @@ -3687,23 +4529,21 @@ tradeFactor * ); } captureCityInWar(city, attacker, defender, war, pressure) { -const lossRate = clamp( -0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025, -0.05, -0.22 -); -const loss = Math.floor((city.population || 0) * lossRate); -if (loss > 0) { -city.population = Math.max(1, city.population - loss); -if (typeof removeFromComposition === "function" && city.ethnicityComposition) { -removeFromComposition(city.ethnicityComposition, loss); -} -this.deaths += Math.floor(loss * 0.35); -} -this.removeCityFromPolity(city); -this.addCityToPolity(city, attacker, 0.28); +const lossRate = clamp((SimConfig.war.captureLossBase ?? 0.08) + pressure * 0.055 + (war.intensity || 0.75) * 0.035, 0.06, SimConfig.war.captureLossMax ?? 0.32); +const loss = this.applyCityWarDamage(city, lossRate, "capture"); +this.applyWarZoneTileDamage(this.world.idx(city.x, city.y), (SimConfig.war.warZoneTileLossRate ?? 0.006) * 3); +this.transferCityToPolity(city, attacker, { +loyalty: 0.28, +control: 0.62, +claim: 0.78, +allowForeignTakeover: true, +reason: "warCapture" +}); city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36); war.lastActionYear = this.year; +if (war.aPolityId === attacker.id) war.capturedByA = (war.capturedByA || 0) + 1; +else war.capturedByB = (war.capturedByB || 0) + 1; +this.addWarScore(war, attacker.id, 1.8 + pressure * 0.7); if (war.aPolityId === attacker.id) { war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1); war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1); @@ -3715,6 +4555,18 @@ attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015); attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5); defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5); defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1); +defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.045, 0, 1); +const advantage = this.polityPower(attacker) / Math.max(1, this.polityPower(defender)); +const captureRadius = clamp(Math.round(7 + pressure * 5 + clamp(advantage - 1, 0, 1.8) * 4 + (war.intensity || 0.75) * 2), 6, 20); +const maxTiles = clamp(Math.round(28 + captureRadius * captureRadius * 0.38 + pressure * 26), 24, 150); +const transferred = this.transferWarTerritory(attacker, defender, this.world.idx(city.x, city.y), captureRadius, { +strength: clamp(0.52 + pressure * 0.10, 0.52, 0.78), +reason: "warCapture", +maxTiles +}); +if (transferred.length) this.addWarScore(war, attacker.id, transferred.length * 0.012); +this.addPolityEvent(attacker.id, "war", this.year, { outcome: "capturedCity", cityId: city.id, targetPolityId: defender.id }, 2); +this.addPolityEvent(defender.id, "war", this.year, { outcome: "lostCity", cityId: city.id, targetPolityId: attacker.id }, 2); } applyWarExhaustion(war, a, b) { const intensity = war.intensity || 0.75; @@ -3726,6 +4578,8 @@ a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5); b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5); war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1); war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1); +this.applyPolityWarExhaustionPopulationLoss(a, war.exhaustionA || 0, intensity); +this.applyPolityWarExhaustionPopulationLoss(b, war.exhaustionB || 0, intensity); } maybeEndWar(war) { const a = this.getPolityById(war.aPolityId); @@ -3749,6 +4603,112 @@ if (exhaustion > 0.85 && this.rng.next() < 0.35) { this.endWar(war); } } +addWarScore(war, polityId, amount) { +if (!war || amount <= 0) return; +if (war.aPolityId === polityId) war.scoreA = (war.scoreA || 0) + amount; +else if (war.bPolityId === polityId) war.scoreB = (war.scoreB || 0) + amount; +} +applyCityWarDamage(city, rate, reason = "war") { +if (!city || city.population <= 1 || rate <= 0) return 0; +const loss = Math.min(city.population - 1, Math.max(1, Math.floor(city.population * clamp(rate, 0, SimConfig.war.cityDamageMaxRate ?? 0.08)))); +if (loss <= 0) return 0; +city.population = Math.max(1, city.population - loss); +if (city.ethnicityComposition) removeFromComposition(city.ethnicityComposition, loss); +city.storedResources = Math.max(0, (city.storedResources || 0) - loss * (reason === "capture" ? 0.38 : 0.16)); +city.loyalty = clamp((city.loyalty ?? 0.5) - rate * (reason === "capture" ? 1.8 : 0.9), 0, 1); +city.lastWarDamageYear = this.year; +this.recordWarDeaths(loss, reason === "capture" ? 0.45 : 0.65); +return loss; +} +applyPolityWarExhaustionPopulationLoss(polity, exhaustion, intensity) { +if (!polity || exhaustion <= 0.04) return 0; +const cities = this.getPolityCities(polity); +if (!cities.length) return 0; +const shortage = (polity.treasury || 0) <= 0 ? 0.35 : 0; +const crisis = clamp(polity.crisis || 0, 0, 1.5) * 0.15; +const activeWars = this.warCountForPolity(polity.id); +const cohesionStress = clamp(1 - (polity.cohesion ?? 0.6), 0, 1) * 0.22; +const rate = clamp( +(SimConfig.war.exhaustionPopulationLossBase ?? 0.0007) * (1 + exhaustion * 4.8 + intensity + activeWars * 0.35 + shortage + crisis + cohesionStress), +0, +SimConfig.war.exhaustionPopulationLossMax ?? 0.012 +); +let total = 0; +for (const city of cities) { +const localRate = rate * (city.supplyStress > 0.8 ? 1.35 : 1); +if (localRate <= 0) continue; +total += this.applyCityWarDamage(city, localRate, "exhaustion"); +city.loyalty = clamp((city.loyalty ?? 0.5) - exhaustion * 0.004, 0, 1); +} +if (exhaustion > 0.55) { +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - rate * 1.4, 0, 1); +polity.cohesion = clamp((polity.cohesion ?? 0.6) - rate * 1.1, 0, 1); +} +return total; +} +applyWarZoneTileDamage(tile, rate) { +const w = this.world; +if (tile == null || tile < 0 || tile >= w.count || rate <= 0) return 0; +let removed = 0; +for (const { dx, dy, distance } of this.getRadiusOffsets(2)) { +const x = tile % w.size + dx; +const y = Math.floor(tile / w.size) + 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 localRate = rate * (1 - distance / 4); +const amount = w.population[t] * localRate; +if (amount <= 0.03) continue; +removed += this.removeEthnicPopulationProportionally(t, amount) || 0; +w.resource[t] = Math.max(0, w.resource[t] - amount * 0.02); +} +this.recordWarDeaths(removed, 0.8); +return removed; +} +recordWarDeaths(amount, civilianShare = 0.5) { +if (amount <= 0) return; +this.deaths += Math.max(0, Math.floor(amount * clamp(civilianShare, 0, 1))); +} +applyWarSettlement(war) { +const a = this.getPolityById(war.aPolityId); +const b = this.getPolityById(war.bPolityId); +if (!a || !b) return; +const margin = (war.scoreA || 0) - (war.scoreB || 0) + ((war.exhaustionB || 0) - (war.exhaustionA || 0)) * 1.2; +const threshold = SimConfig.war.majorSettlementThreshold ?? 1.25; +if (Math.abs(margin) < threshold) return; +const winner = margin > 0 ? a : b; +const loser = margin > 0 ? b : a; +const transferred = this.applyWarSettlementTransfer(winner, loser, war, Math.abs(margin)); +loser.legitimacy = clamp((loser.legitimacy ?? 0.7) - 0.035 - Math.abs(margin) * 0.006, 0, 1); +loser.cohesion = clamp((loser.cohesion ?? 0.6) - 0.03 - (transferred ? 0.04 : 0), 0, 1); +loser.crisis = clamp((loser.crisis || 0) + 0.08 + Math.abs(margin) * 0.015, 0, 1.5); +for (const city of this.getPolityCities(loser)) city.loyalty = clamp((city.loyalty ?? 0.5) - 0.025, 0, 1); +this.addPolityEvent(winner.id, "war", this.year, { outcome: transferred ? "settlementGain" : "settlementPressure", targetPolityId: loser.id }, 2); +this.addPolityEvent(loser.id, "war", this.year, { outcome: transferred ? "settlementLoss" : "settlementPressure", targetPolityId: winner.id }, 2); +} +applyWarSettlementTransfer(winner, loser, war, margin) { +const loserCities = this.getPolityCities(loser) +.filter(city => city.population > 0 && (city.id !== loser.centerCityId || margin > 2.8 || this.getPolityCities(loser).length <= 2)) +.map(city => ({ city, connection: this.warConnectionToCity(winner, city), memory: war.cityPressureById?.get(`${winner.id}:${city.id}`) || 0 })) +.filter(entry => entry.connection.source && entry.connection.distance <= this.warTargetRange(winner, loser, war, this.polityPower(winner), this.polityPower(loser)) * 0.92) +.sort((a, b) => (b.memory + (1 - (b.city.loyalty ?? 0.5)) - b.connection.distance * 0.018) - (a.memory + (1 - (a.city.loyalty ?? 0.5)) - a.connection.distance * 0.018)); +const entry = loserCities[0]; +if (!entry || margin < 1.8) return false; +this.applyCityWarDamage(entry.city, clamp(0.025 + margin * 0.008, 0.02, 0.10), "settlement"); +this.transferCityToPolity(entry.city, winner, { +loyalty: clamp(0.24 + (1 - (entry.city.loyalty ?? 0.5)) * 0.25, 0.18, 0.48), +control: 0.52, +claim: 0.66, +allowForeignTakeover: true, +reason: "settlement" +}); +this.transferWarTerritory(winner, loser, this.world.idx(entry.city.x, entry.city.y), Math.max(7, SimConfig.campaign?.claimRadius ?? 8), { +strength: 0.50, +reason: "settlement", +maxTiles: 70 +}); +return true; +} collectAndRedistributeResources() { for (const polity of this.polities) { const cities = this.getPolityCities(polity); @@ -3920,7 +4880,9 @@ instability * 0.012 + agePressure * 0.010; if (this.rng.next() < chance) { const rebellionLoyalty = city.loyalty ?? 0; +const oldPolityId = city.polityId; this.removeCityFromPolity(city); +this.releaseCityTerritory(city, oldPolityId, 5, "rebellion"); rebellions.push({ cityId: city.id, population: city.population || 0, @@ -3928,9 +4890,7 @@ loyalty: rebellionLoyalty }); } } -if (rebellions.length === 1) { -this.addPolityEvent(polity.id, "rebellion", this.year, rebellions[0], 1); -} else if (rebellions.length > 1) { +if (rebellions.length >= 3) { const population = rebellions.reduce((sum, rebellion) => sum + (rebellion.population || 0), 0); const avgLoyalty = rebellions.reduce((sum, rebellion) => sum + (rebellion.loyalty || 0), 0) / rebellions.length; this.addPolityEvent(polity.id, "rebellion", this.year, { @@ -3961,7 +4921,9 @@ frontier.push(city); const isolated = cities.filter(city => !reachable.has(city.id)); if (!isolated.length) return cities; for (const city of isolated) { +const oldPolityId = city.polityId; this.removeCityFromPolity(city); +this.releaseCityTerritory(city, oldPolityId, 4, "fragmentation"); city.loyalty = clamp(city.loyalty - 0.28, 0, 1); } polity.cohesion = clamp((polity.cohesion ?? 0.6) - isolated.length * 0.035, 0, 1); @@ -4002,6 +4964,7 @@ for (const polity of this.polities) { const cities = this.getPolityCities(polity); if (!cities.length) { this.markPolityEnded(polity, "collapsed"); +this.releasePolityTerritory(polity.id, "collapse"); continue; } let center = this.getCityById(polity.centerCityId); @@ -4044,6 +5007,7 @@ if (cities.length === 1) { if (weakSingleCityState) { this.markPolityEnded(polity, "collapsed"); + this.releasePolityTerritory(polity.id, "collapse"); city.polityId = null; city.loyalty = 0.45; city.receivedAid = false; @@ -4057,9 +5021,11 @@ this.rebuildIndexes(); const validPolities = new Set(this.polities.map(p => p.id)); for (const city of this.cities) { if (city.polityId !== null && !validPolities.has(city.polityId)) { +const oldPolityId = city.polityId; city.polityId = null; city.loyalty = 0.45; city.receivedAid = false; +this.releaseCityTerritory(city, oldPolityId, 5, "collapse"); } } } @@ -4082,8 +5048,10 @@ this.splitUnloyalCities(); this.cleanupPolities(); if (this.samplePolityHistories) this.samplePolityHistories(); this.syncTilePopulationCulture(); -this.recomputeTerritories(); +this.recomputeTerritories({ allowOwnershipChanges: false, reason: "normal" }); +this.reconcileCityTerritoryOwnership(); this.campaignPressureOnTerritories(); +this.enforceTerritoryConnectivity({ allowOwnershipChanges: false }); } reinforcePolityTradeRoutes() { for (const polity of this.polities) { @@ -4096,11 +5064,12 @@ const candidates = this.getPolityCities(polity) .sort((a, b) => this.effectiveDistance(center, a) - this.effectiveDistance(center, b)); if (!candidates.length) continue; const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))]; -const path = this.findTerrainRoute(center.x, center.y, target.x, target.y); -if (!this.isValidRoutePath(path, target.x, target.y)) continue; -if (this.hasReservedRouteSegment(path, this.activeTradeRouteTiles || new Set())) continue; +const limit = this.effectiveRouteLengthLimit(center, target, polity); +const path = this.findTerrainRoute(center.x, center.y, target.x, target.y, limit, center, target, polity); +if (!this.isValidRoutePath(path, target.x, target.y, limit)) continue; +if (this.hasReservedRouteSegment(path, this.activeTradeRouteTiles)) continue; const cities = this.getPolityCities(polity); -const roadCost = 8 + cities.length * 1.5 + this.polityOverextension(polity) * 3; +const roadCost = 5 + cities.length * 1.2 + this.polityOverextension(polity) * 2.4; if ((polity.treasury || 0) < roadCost) { polity.crisis = clamp((polity.crisis || 0) + 0.025, 0, 1.5); continue; @@ -4110,10 +5079,125 @@ if (!this.payRouteConstructionCost(center, target, path, polity)) continue; this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id); } } -updateTradeRoutes() { +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; +return clamp((city.population > 180 ? 4 : city.population > 90 ? 3 : 2) + capitalBonus, 2, 5); +} +effectiveRouteLengthLimit(cityA = null, cityB = null, ownerPolity = null) { +const cfg = SimConfig.route; +const base = cfg.maxRouteLength ?? 42; +if (!cityA || !cityB) return base; +const avgPopulation = ((cityA.population || 1) + (cityB.population || 1)) * 0.5; +const avgKnowledge = ( +(cityA.knowledge?.farming || 0) + (cityA.knowledge?.metallurgy || 0) + +(cityB.knowledge?.farming || 0) + (cityB.knowledge?.metallurgy || 0) +) * 0.25; +const samePolity = cityA.polityId !== null && cityA.polityId === cityB.polityId; +const capital = ownerPolity && (ownerPolity.centerCityId === cityA.id || ownerPolity.centerCityId === cityB.id) ? 1 : 0; +const org = ownerPolity ? clamp((ownerPolity.cohesion ?? 0.6) * 0.7 + (ownerPolity.legitimacy ?? 0.7) * 0.3, 0, 1.2) : 0; +const treasury = ownerPolity ? clamp(Math.sqrt(Math.max(0, ownerPolity.treasury || 0)) / 18, 0, 1.1) : 0; +return Math.floor(clamp( +base + +clamp(Math.sqrt(avgPopulation) * 0.55, 0, 10) + +avgKnowledge * 12 + +(samePolity ? 7 : 0) + +capital * 6 + +org * 5 + +treasury * 4, +base, +cfg.adaptiveMaxRouteLength ?? 72 +)); +} +cityHasRouteTilesNear(city, radius = SimConfig.route.isolatedRouteRadius ?? 4) { +if (!city) return false; +const w = this.world; +for (const { dx, dy } of this.getRadiusOffsets(radius)) { +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +if (w.tradeRoute[w.idx(x, y)] > 0) return true; +} +return false; +} +bootstrapIsolatedCityRoutes(supportedRoutes) { +const cfg = SimConfig.route; +if (!cfg.isolatedCityBootstrap || this.cities.length < 2) return; +let built = 0; +const maxBootstraps = clamp(Math.ceil(this.cities.length * 0.12), 4, cfg.isolatedRouteSearchLimit ?? 8); +const isolated = this.cities +.filter(city => city.population > 0 && (city.tradeLinks.size === 0 || !this.cityHasRouteTilesNear(city))) +.sort((a, b) => (a.tradeLinks.size - b.tradeLinks.size) || ((a.tradeReach || 0) - (b.tradeReach || 0))); +for (const city of isolated) { +if (built >= maxBootstraps) break; +if (city.tradeLinks.size > 0 && this.cityHasRouteTilesNear(city)) continue; +if (this.tryBootstrapCityRoute(city, supportedRoutes)) built++; +} +} +tryBootstrapCityRoute(city, supportedRoutes) { +const partners = this.bootstrapRoutePartners(city); +for (const partner of partners) { +if (!partner || partner.id === city.id || city.tradeLinks.has(partner.id)) continue; +const ownerPolityId = city.polityId !== null && city.polityId === partner.polityId ? city.polityId : null; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +const limit = this.effectiveRouteLengthLimit(city, partner, ownerPolity); +if (Math.abs(city.x - partner.x) + Math.abs(city.y - partner.y) > limit) continue; +const path = this.findTerrainRoute(city.x, city.y, partner.x, partner.y, limit, city, partner, ownerPolity); +if (!this.isValidRoutePath(path, partner.x, partner.y, limit)) continue; +const strength = Math.max(SimConfig.route.isolatedRouteMinStrength ?? 0.12, this.routeStrengthForPath(path) * 0.75); +if (!this.canPayRouteConstructionCost(city, partner, path, ownerPolity, { bootstrap: true })) continue; +if (!this.payRouteConstructionCost(city, partner, path, ownerPolity, { bootstrap: true })) continue; +this.registerTradeLink( +city, +partner, +strength, +path, +supportedRoutes, +SimConfig.route.newRouteGraceBoost ?? 18, +SimConfig.route.newRoutePheromoneSeed ?? 2.5, +ownerPolityId +); +this.exchangeCityResources(city, partner, strength, path); +this.applyRouteConnectionBenefits(city, partner, strength, SimConfig.route.routePheromoneMaintain ?? 0.12, true); +return true; +} +return false; +} +bootstrapRoutePartners(city) { +const result = []; +const seen = new Set([city.id]); +const add = candidate => { +if (candidate && candidate.id !== city.id && !seen.has(candidate.id)) { +seen.add(candidate.id); +result.push(candidate); +} +}; +if (city.polityId !== null) { +const polity = this.getPolityById(city.polityId); +const capital = this.getCityById(polity?.centerCityId); +add(capital); +const samePolity = this.getPolityCities(polity || { cityIds: new Set() }) +.filter(other => other.id !== city.id) +.sort((a, b) => this.distanceBetweenCities(city, a) - this.distanceBetweenCities(city, b)); +for (const candidate of samePolity.slice(0, 4)) add(candidate); +} +const others = this.cities +.filter(other => other.id !== city.id) +.sort((a, b) => { +const polityBiasA = city.polityId !== null && a.polityId === city.polityId ? -12 : 0; +const polityBiasB = city.polityId !== null && b.polityId === city.polityId ? -12 : 0; +return this.distanceBetweenCities(city, a) + polityBiasA - (this.distanceBetweenCities(city, b) + polityBiasB); +}); +for (const candidate of others.slice(0, 8)) add(candidate); +return result; +} +updateTradeRoutes(fieldBased = false) { const w = this.world; for (let i = 0; i < w.count; i++) { -const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2; +const invalidTerrain = w.terrain[i] === Terrain.WATER; if (invalidTerrain) w.tradeRoute[i] = 0; else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) { const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); @@ -4124,60 +5208,74 @@ if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0; } this.tradeLinks = []; for (const city of this.cities) city.tradeLinks.clear(); -const pairCandidates = []; const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400); -const sqrtPop = new Map(this.cities.map(city => [city.id, Math.sqrt(city.population || 1)])); +const pairHeap = this.pairCandidateScratch; +pairHeap.length = 0; +const sqrtPop = this.cityScoreScratch; +sqrtPop.length = 0; +for (const city of this.cities) sqrtPop[city.id] = Math.sqrt(city.population || 1); for (let a = 0; a < this.cities.length; a++) { for (let b = a + 1; b < this.cities.length; b++) { const c1 = this.cities[a]; const c2 = this.cities[b]; const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y); -if (d > SimConfig.route.maxRouteLength - 1) continue; -const distanceFactor = clamp((d - 14) / 30, 0, 1); -const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1); -const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0; -const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias; -pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore }); -} -} -pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore); -const candidates = []; -for (const pair of pairCandidates.slice(0, maxPairsToRoute)) { -const { c1, c2, distanceFactor, marketScale } = pair; -const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y); -if (!this.isValidRoutePath(path, c2.x, c2.y)) continue; -const strength = this.routeStrengthForPath(path); -const pheromoneSupport = this.routePheromoneSupport(path); const ownerPolityId = c1.polityId !== null && c1.polityId === c2.polityId ? c1.polityId : null; const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +const limit = this.effectiveRouteLengthLimit(c1, c2, ownerPolity); +if (d > limit) continue; +const distanceFactor = clamp((d - 14) / 30, 0, 1); +const marketScale = clamp(((sqrtPop[c1.id] || 1) + (sqrtPop[c2.id] || 1)) / 34, 0.45, 2.1); +const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0; +const isolatedBias = (!this.cityHasRouteTilesNear(c1) ? 0.55 : 0) + (!this.cityHasRouteTilesNear(c2) ? 0.55 : 0); +const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias + isolatedBias + (ownerPolityId !== null ? 0.18 : 0); +this.topHeapPush(pairHeap, { c1, c2, d, limit, distanceFactor, marketScale, preliminaryScore, ownerPolityId }, maxPairsToRoute, "preliminaryScore"); +} +} +const pairCandidates = this.heapToDescending(pairHeap, "preliminaryScore"); +const candidates = this.routeCandidateScratch; +candidates.length = 0; +const maxLinks = Math.max(1, Math.floor(this.cities.length / 2)); +const routeCandidateLimit = clamp(maxLinks * 12, 80, maxPairsToRoute); +const maxPathAttempts = clamp(maxLinks * 16, 120, 520); +let pathAttempts = 0; +for (const pair of pairCandidates) { +if (pathAttempts++ >= maxPathAttempts) break; +const { c1, c2, distanceFactor, marketScale, ownerPolityId } = pair; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +const limit = pair.limit || this.effectiveRouteLengthLimit(c1, c2, ownerPolity); +const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y, limit, c1, c2, ownerPolity); +if (!this.isValidRoutePath(path, c2.x, c2.y, limit)) continue; +const strength = this.routeStrengthForPath(path); +const pheromoneSupport = this.routePheromoneSupport(path); if (strength < 0.08) continue; -if (ownerPolityId === null && pheromoneSupport < SimConfig.route.routePheromoneBuild) continue; +if (!fieldBased && ownerPolityId === null && pheromoneSupport < SimConfig.route.routePheromoneBuild) continue; if (!this.canPayRouteConstructionCost(c1, c2, path, ownerPolity)) continue; const tradeScore = strength * (1 + distanceFactor * 0.7 + pheromoneSupport * 0.75 + (ownerPolityId !== null ? 0.28 : 0)) * marketScale; -candidates.push({ c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId }); +this.topHeapPush(candidates, { c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId }, routeCandidateLimit, "tradeScore"); } -candidates.sort((a, b) => b.tradeScore - a.tradeScore); -const maxLinks = Math.max(1, Math.floor(this.cities.length / 2)); -const supportedRoutes = new Set(); +this.heapToDescending(candidates, "tradeScore"); +const supportedRoutes = this.supportedRouteScratch; +supportedRoutes.clear(); for (const candidate of candidates) { if (this.tradeLinks.length >= maxLinks) break; const { c1, c2, strength, path, pheromoneSupport, ownerPolityId } = candidate; -const c1Limit = c1.population > 90 ? 3 : 2; -const c2Limit = c2.population > 90 ? 3 : 2; +const c1Limit = this.routeCityLinkLimit(c1); +const c2Limit = this.routeCityLinkLimit(c2); if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue; if (this.hasReservedRouteSegment(path, supportedRoutes)) continue; const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; if (!this.payRouteConstructionCost(c1, c2, path, ownerPolity)) continue; const upkeepPaid = this.payRouteUpkeep(c1, c2, path, ownerPolity, pheromoneSupport); -if (!upkeepPaid && pheromoneSupport < SimConfig.route.routePheromoneMaintain) { +if (!fieldBased && !upkeepPaid && pheromoneSupport < SimConfig.route.routePheromoneMaintain) { this.weakenRoutePath(path, SimConfig.route.routeUnsupportedDecay); continue; } const routeBoost = upkeepPaid ? SimConfig.route.routeMaintainedBoost : Math.floor(SimConfig.route.routeMaintainedBoost * 0.45); -this.registerTradeLink(c1, c2, strength, path, supportedRoutes, routeBoost, 0, ownerPolityId); +this.registerTradeLink(c1, c2, strength, path, supportedRoutes, routeBoost, fieldBased ? 0.8 : 0, ownerPolityId); this.exchangeCityResources(c1, c2, strength, path); this.applyRouteConnectionBenefits(c1, c2, strength, pheromoneSupport, upkeepPaid); } +this.bootstrapIsolatedCityRoutes(supportedRoutes); for (let i = 0; i < w.count; i++) { if (w.tradeRoute[i] && !supportedRoutes.has(i)) { const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); @@ -4185,7 +5283,7 @@ w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - (pheromoneSupport >= SimConfig.r if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0; } } -this.activeTradeRouteTiles = new Set(); +this.activeTradeRouteTiles.clear(); for (let i = 0; i < w.count; i++) { if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i); } @@ -4198,6 +5296,7 @@ this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path, ownerPolity for (const tile of path) { if (supportedRoutes) supportedRoutes.add(tile); this.world.tradeRoute[tile] = Math.min(255, this.world.tradeRoute[tile] + routeBoost); +this.activeTradeRouteTiles.add(tile); this.addPheromone(tile, pheromoneBoost); } } @@ -4217,50 +5316,104 @@ a.knowledge.metallurgy = clamp(a.knowledge.metallurgy - metallurgyDelta, 0, 1); b.knowledge.metallurgy = clamp(b.knowledge.metallurgy + metallurgyDelta, 0, 1); } } -findTerrainRoute(x1, y1, x2, y2) { +findTerrainRoute(x1, y1, x2, y2, lengthLimit = SimConfig.route.maxRouteLength, cityA = null, cityB = null, ownerPolity = null) { const w = this.world; +const start = w.idx(x1, y1); +const goal = w.idx(x2, y2); +if (w.terrain[start] === Terrain.WATER || w.terrain[goal] === Terrain.WATER) return []; +const limit = Math.max(1, Math.floor(lengthLimit || this.effectiveRouteLengthLimit(cityA, cityB, ownerPolity))); +const direct = Math.abs(x1 - x2) + Math.abs(y1 - y2); +if (direct > limit) return []; +const padding = clamp(Math.ceil(limit * 0.35), 8, 22); +const minX = Math.max(0, Math.min(x1, x2) - padding); +const maxX = Math.min(w.size - 1, Math.max(x1, x2) + padding); +const minY = Math.max(0, Math.min(y1, y2) - padding); +const maxY = Math.min(w.size - 1, Math.max(y1, y2) + padding); +const costs = this.routeCostScratch; +const prev = this.routePrevScratch; +const seen = this.visitStamp; +const marker = this.nextVisitMarker(); +const heap = this.routeHeapScratch; +heap.length = 0; +costs[start] = 0; +prev[start] = -1; +seen[start] = marker; +this.routeHeapPush(heap, { tile: start, score: direct }); +let reached = false; +let expansions = 0; +const maxExpansions = clamp(limit * limit * 3, 420, 5200); +while (heap.length && expansions++ < maxExpansions) { +const current = this.routeHeapPop(heap); +const tile = current.tile; +if (tile === goal) { +reached = true; +break; +} +const cx = tile % w.size; +const cy = Math.floor(tile / w.size); +this.forCardinalNeighbors(tile, (n, nx, ny) => { +if (nx < minX || nx > maxX || ny < minY || ny > maxY) return; +if (w.terrain[n] === Terrain.WATER) return; +const stepsFromStart = Math.abs(nx - x1) + Math.abs(ny - y1); +const remaining = Math.abs(nx - x2) + Math.abs(ny - y2); +if (stepsFromStart + remaining > limit + 8) return; +const terrainCost = this.routeTileCost(n); +const nextCost = costs[tile] + terrainCost; +if (seen[n] === marker && nextCost >= costs[n]) return; +seen[n] = marker; +costs[n] = nextCost; +prev[n] = tile; +const heuristic = remaining * 1.08; +this.routeHeapPush(heap, { tile: n, score: nextCost + heuristic }); +}); +} +if (!reached) return []; const path = []; -const visited = new Set(); -let x = x1; -let y = y1; -const maxSteps = Math.min(SimConfig.route.maxRouteLength, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 24); -const directions = [ -[1, 0], -[-1, 0], -[0, 1], -[0, -1] -]; -for (let step = 0; step < maxSteps; step++) { -const current = w.idx(x, y); +let current = goal; +while (current >= 0 && path.length <= limit + 8) { path.push(current); -visited.add(current); -if (x === x2 && y === y2) break; -let bestX = x; -let bestY = y; -let bestScore = Infinity; -for (const [dx, dy] of directions) { -const nx = x + dx; -const ny = y + dy; -if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue; -const i = w.idx(nx, ny); -const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0); -const revisitCost = visited.has(i) ? 5 : 0; -const routeEase = w.tradeRoute[i] ? -2 : 0; -const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4); -const distance = Math.abs(nx - x2) + Math.abs(ny - y2); -const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase; -if (score < bestScore) { -bestScore = score; -bestX = nx; -bestY = ny; -} -} -if (bestX === x && bestY === y) break; -x = bestX; -y = bestY; +if (current === start) break; +current = prev[current]; } +if (path[path.length - 1] !== start) return []; +path.reverse(); return path; } +routeHeapPush(heap, node) { +heap.push(node); +let index = heap.length - 1; +while (index > 0) { +const parent = (index - 1) >> 1; +if (heap[parent].score <= node.score) break; +heap[index] = heap[parent]; +index = parent; +} +heap[index] = node; +} +routeHeapPop(heap) { +const root = heap[0]; +const last = heap.pop(); +if (heap.length && last) { +let index = 0; +while (true) { +let child = index * 2 + 1; +if (child >= heap.length) break; +if (child + 1 < heap.length && heap[child + 1].score < heap[child].score) child++; +if (heap[child].score >= last.score) break; +heap[index] = heap[child]; +index = child; +} +heap[index] = last; +} +return root; +} +routeTileCost(tile) { +const w = this.world; +const routeRelief = w.tradeRoute[tile] ? 0.58 : 1; +const pheromoneRelief = 1 - clamp(w.pheromone[tile] / SimConfig.route.maxPheromone, 0, 0.32); +const mountain = w.terrain[tile] === Terrain.MOUNTAIN ? 1.2 : 0; +return Math.max(0.45, (w.move[tile] + mountain) * routeRelief * pheromoneRelief); +} routeStrengthForPath(path) { const w = this.world; if (!path.length) return 0; @@ -4290,23 +5443,20 @@ let support = 0; for (const i of path) support += clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); return support / path.length; } -isValidRoutePath(path, targetX, targetY) { +isValidRoutePath(path, targetX, targetY, lengthLimit = SimConfig.route.maxRouteLength) { const w = this.world; if (!path.length) return false; const last = path[path.length - 1]; if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false; -if (path.length > SimConfig.route.maxRouteLength) return false; -let totalCost = 0; -let hardTiles = 0; +const limit = lengthLimit; +if (path.length - 1 > limit) return false; const seen = new Set(); for (const tile of path) { if (seen.has(tile)) return false; seen.add(tile); if (w.terrain[tile] === Terrain.WATER) return false; -totalCost += w.move[tile]; -if (w.move[tile] > 1.8) hardTiles++; } -return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12; +return true; } exchangeCityResources(a, b, strength, path) { const delta = (a.storedResources - b.storedResources) * 0.018 * strength; @@ -4317,7 +5467,8 @@ this.depositRoutePheromone(path, traffic); } routeUpkeepCost(path, pheromoneSupport) { const pheromoneRelief = clamp(pheromoneSupport / Math.max(0.001, SimConfig.route.routePheromoneMaintain), 0, 1) * 0.55; -return path.length * SimConfig.route.routeUpkeepPerTile * (1 - pheromoneRelief); +const difficulty = this.routePathDifficulty(path); +return path.length * SimConfig.route.routeUpkeepPerTile * (0.65 + difficulty * 0.35) * (1 - pheromoneRelief); } payRouteUpkeep(cityA, cityB, path, polity = null, pheromoneSupport = 0) { const cost = this.routeUpkeepCost(path, pheromoneSupport); @@ -4361,22 +5512,34 @@ cityA.loyalty = clamp((cityA.loyalty ?? 0.5) + reliability * 0.002, 0, 1); cityB.loyalty = clamp((cityB.loyalty ?? 0.5) + reliability * 0.002, 0, 1); } } -routeConstructionCost(path, polityBacked = false) { -let weakTiles = 0; +routePathDifficulty(path) { +if (!path.length) return 1; +let difficulty = 0; for (const tile of path) { -if (this.world.tradeRoute[tile] < 24) weakTiles++; +const move = this.world.move[tile] || 1; +const hard = move > 1.8 ? 0.35 : 0; +const existing = this.world.tradeRoute[tile] ? -0.25 : 0; +difficulty += Math.max(0.45, move + hard + existing); } -return weakTiles * (polityBacked ? 0.22 : 0.16); +return difficulty / path.length; } -canPayRouteConstructionCost(cityA, cityB, path, polity = null) { -const cost = this.routeConstructionCost(path, !!polity); +routeConstructionCost(path, polityBacked = false, options = {}) { +let weakTiles = 0; +for (const tile of path) if (this.world.tradeRoute[tile] < 24) weakTiles++; +const difficulty = this.routePathDifficulty(path); +const base = weakTiles * (polityBacked ? 0.16 : 0.12) * clamp(difficulty / 1.35, 0.75, 2.1); +return options.bootstrap ? base * 0.62 : base; +} +canPayRouteConstructionCost(cityA, cityB, path, polity = null, options = {}) { +const cost = this.routeConstructionCost(path, !!polity, options); if (cost <= 0) return true; -return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost; +const reserve = options.bootstrap ? Math.max(2.5, Math.sqrt((cityA.population || 1) + (cityB.population || 1)) * 0.25) : 0; +return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) + reserve >= cost; } -payRouteConstructionCost(cityA, cityB, path, polity = null) { -const cost = this.routeConstructionCost(path, !!polity); +payRouteConstructionCost(cityA, cityB, path, polity = null, options = {}) { +const cost = this.routeConstructionCost(path, !!polity, options); if (cost <= 0) return true; -if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) return false; +if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity, options)) return false; let remaining = cost; if (polity) { const treasuryPayment = Math.min(polity.treasury, cost * 0.65); @@ -4393,6 +5556,7 @@ const treasuryPayment = Math.min(polity.treasury, remaining); polity.treasury -= treasuryPayment; remaining -= treasuryPayment; } +if (options.bootstrap && remaining <= Math.max(2.5, Math.sqrt((cityA.population || 1) + (cityB.population || 1)) * 0.25)) return true; return remaining <= 0.001; } drainCityResources(city, amount) { @@ -4418,63 +5582,84 @@ e.centroidY = 0; e.activeTraitPopulation = 0; e.traitSums = emptyTraitSums(); } -for (const a of this.agents) { -const e = this.ethnicities.get(a.ethnicity); +for (const band of this.nomadBands || []) { +const e = this.ethnicities.get(band.ethnicityId); if (!e) continue; -e.population++; -e.centroidX += a.x; -e.centroidY += a.y; -e.activeTraitPopulation++; -addTraits(e.traitSums, a.traits); +e.population += band.population; +e.centroidX += band.x * band.population; +e.centroidY += band.y * band.population; +e.diversity += (1 - band.cohesion) * band.population; +} +for (const [tile, mix] of this.tileEthnicMix) { +const x = tile % this.world.size; +const y = Math.floor(tile / this.world.size); +const diversity = this.world.cultureDiversity[tile] || 0; +for (const [id, count] of mix) { +const e = this.ethnicities.get(id); +if (!e || count <= 0) continue; +e.population += count; +e.centroidX += x * count; +e.centroidY += y * count; +e.diversity += diversity * count; +} } for (const city of this.cities) { for (const [id, count] of city.ethnicityComposition) { const e = this.ethnicities.get(id); -if (!e) continue; -e.population += count; -e.centroidX += city.x * count; -e.centroidY += city.y * count; +if (!e || count <= 0) continue; +e.population += count * 0.25; +e.centroidX += city.x * count * 0.25; +e.centroidY += city.y * count * 0.25; } } for (const e of this.ethnicities.values()) { if (!e.population) continue; e.centroidX /= e.population; e.centroidY /= e.population; -const activeTraitCount = Math.max(1, e.activeTraitPopulation); -e.averageTraits = averageTraits(e.traitSums, activeTraitCount); -} -for (const a of this.agents) { -const e = this.ethnicities.get(a.ethnicity); -if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits); -} -for (const e of this.ethnicities.values()) { -if (e.population) e.diversity /= e.population; +e.diversity /= e.population; +e.averageTraits ??= this.randomTraits(); } } splitDivergentEthnicities() { -for (const e of this.ethnicities.values()) { -if (e.activeTraitPopulation < 24 || e.population < 40 || e.diversity < 0.14) continue; +this.splitDivergentFieldEthnicities(); +} +splitDivergentFieldEthnicities() { +for (const e of [...this.ethnicities.values()]) { +if (e.population < 120 || e.diversity < 0.24) continue; const candidates = []; +let population = 0; let tempSum = 0; let humidSum = 0; -for (const a of this.agents) { -if (a.ethnicity !== e.id) continue; -const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 0.95; -const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.09; -const tile = this.world.idx(a.x, a.y); -const climate = this.climateMismatch(e.id, tile) > 0.18; -if ((far || spatial || climate) && this.rng.next() < 0.72) { -candidates.push(a); -tempSum += this.world.temperature[tile]; -humidSum += this.world.humidity[tile]; +for (const [tile, mix] of this.tileEthnicMix) { +const count = mix.get(e.id) || 0; +if (count < 4) continue; +const x = tile % this.world.size; +const y = Math.floor(tile / this.world.size); +const spatial = Math.hypot(x - e.centroidX, y - e.centroidY) > this.world.size * 0.11; +const climate = this.climateMismatch(e.id, tile) > 0.20; +if ((spatial || climate || this.world.cultureDiversity[tile] > 0.38) && this.rng.next() < 0.32) { +candidates.push({ tile, count }); +population += count; +tempSum += this.world.temperature[tile] * count; +humidSum += this.world.humidity[tile] * count; } } -if (candidates.length >= 6) { +if (population < 35 || candidates.length < 3) continue; const newId = this.createEthnicity(e.id, { -temperature: tempSum / candidates.length, -humidity: humidSum / candidates.length +temperature: tempSum / population, +humidity: humidSum / population }); -for (const a of candidates) a.ethnicity = newId; +const parentTraits = e.averageTraits || this.randomTraits(); +const newTraits = mutateTraits(parentTraits, this.rng, 0.12); +const lineage = this.ethnicities.get(newId); +if (lineage) lineage.averageTraits = newTraits; +for (const { tile, count } of candidates) { +const mix = this.tileEthnicMix.get(tile); +if (!mix) continue; +const split = count * this.rng.range(0.35, 0.68); +mix.set(e.id, Math.max(0, count - split)); +mix.set(newId, (mix.get(newId) || 0) + split); +this.updateCultureTile(tile); } } } diff --git a/index.html b/index.html index 0e6abfb..59edc15 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ @@ -57,7 +57,7 @@
Date
0y 1m
-
Active groups
0
+
Field population
0
Urban population
0
Ethnicities
0
Cities
0
@@ -66,7 +66,7 @@
Trade routes
0
Farming knowledge
0.000
Metallurgy knowledge
0.000
-
Collapse deaths
0
+
Deaths
0
Frame cost
0ms
diff --git a/render.js b/render.js index c86688d..105ccbe 100644 --- a/render.js +++ b/render.js @@ -79,7 +79,13 @@ if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix( } } } else if (mode === "technology") { -color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35); +const farming = w.farmingKnowledge?.[i] || 0; +const metallurgy = w.metallurgyKnowledge?.[i] || 0; +const tech = clamp(Math.max(farming, metallurgy), 0, 1); +const techColor = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy)); +color = tech > 0.01 +? mix(terrainInfo[w.terrain[i]].color, techColor, clamp(0.12 + tech * 0.62, 0.12, 0.74)) +: mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35); } else if (mode === "pheromone") { const v = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); color = mix(mix(terrainInfo[w.terrain[i]].color, [18, 20, 18], 0.58), [216, 177, 86], v); @@ -95,8 +101,9 @@ data[p + 3] = 255; ctx.putImageData(image, 0, 0); renderDisasters(); drawTradeLinks(); +drawNomadBands(); drawCampaigns(); -drawAgentsAndCities(mode); +drawPopulationAndCities(mode); drawGraphPolityHighlight(); maybeRenderStateGraph(); els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; @@ -205,7 +212,39 @@ fillSquare(ctx, x, y, 1); } ctx.restore(); } -function drawAgentsAndCities(mode) { +function drawNomadBands() { +if (!sim?.nomadBands?.length) return; +const w = sim.world; +ctx.save(); +for (const band of sim.nomadBands) { +const ethnicityColor = sim.ethnicities.get(band.ethnicityId)?.color || [216, 177, 86]; +ctx.globalAlpha = 0.07; +ctx.fillStyle = rgba(ethnicityColor, 0.45); +for (const tile of band.influenceTiles || []) { +ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1); +} +ctx.globalAlpha = 0.34; +ctx.strokeStyle = band.mode === "raiding" || band.mode === "invading" +? "rgba(232, 101, 76, 0.95)" +: band.mode === "settling" +? "rgba(202, 169, 102, 0.9)" +: rgba(ethnicityColor, 0.9, 18); +ctx.lineWidth = 1; +strokeCircle(ctx, band.x + 0.5, band.y + 0.5, Math.max(2.5, band.radius * 0.45)); +if (band.mode === "confederating") { +ctx.globalAlpha = 0.26; +strokeCircle(ctx, band.x + 0.5, band.y + 0.5, Math.max(4, band.radius * 0.68)); +} +ctx.globalAlpha = 0.22; +ctx.fillStyle = rgba(ethnicityColor, 0.9, 22); +for (const tile of band.trailTiles || []) ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1); +ctx.globalAlpha = 0.82; +ctx.fillStyle = rgba(ethnicityColor, 0.95, 36); +fillSquare(ctx, Math.round(band.x), Math.round(band.y), 1); +} +ctx.restore(); +} +function drawPopulationAndCities(mode) { ctx.save(); if (mode !== "ethnicity") { for (const city of sim.cities) { @@ -218,23 +257,18 @@ fillSquare(ctx, city.x, city.y, radius); } ctx.globalAlpha = 1; if (mode === "technology") { -for (const a of sim.agents) { -sim.ensureAgentTech(a); -const farming = techLevel(a, "farming"); -const metallurgy = techLevel(a, "metallurgy"); -const tech = clamp(Math.max(farming, metallurgy), 0, 1); -if (tech <= 0.01) continue; -const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy)); -ctx.fillStyle = rgba(color, clamp(0.32 + tech * 0.68, 0.32, 1)); -ctx.fillRect(a.x, a.y, 1, 1); -} +// Tile-level technology is rendered in the base pass. } else if (mode === "ethnicity") { -// Phase 2: ethnicity view is driven by tile-level population/culture fields. -// Individual agents remain in the simulation but no longer define this layer. +// Ethnicity view is driven by tile-level population/culture fields. } else { -ctx.fillStyle = "#eeeccf"; -for (const tile of sim.tileAgents.keys()) { -ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1); +const w = sim.world; +ctx.fillStyle = "rgba(238, 236, 207, 0.16)"; +for (let tile = 0; tile < w.count; tile++) { +const pop = w.population?.[tile] || 0; +if (pop < 6) continue; +const alpha = clamp(Math.log1p(pop) / Math.log(160), 0.04, 0.18); +ctx.fillStyle = `rgba(238, 236, 207, ${alpha})`; +ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1); } } for (const city of sim.cities) { @@ -329,23 +363,23 @@ return; } const w = sim.world; const i = w.idx(x, y); -const agent = findAgentAt(x, y); const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null; const terrain = terrainInfo[w.terrain[i]]; -const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null; const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2); const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null; -const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0; const polity = cityPolity(city); const territoryId = w.polity?.[i] ?? -1; const territoryPolity = territoryId >= 0 ? sim.getPolityById(territoryId) : null; const ethnicMix = sim.tileEthnicMix?.get(i); const tileDominantEthnicity = ethnicMix?.size ? dominantComposition(ethnicMix) : w.dominantEthnicity[i]; const regionalEthnicity = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? `E${tileDominantEthnicity}` : "-"; +const regionalLineage = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? sim.ethnicities.get(tileDominantEthnicity) : null; +const mismatch = tileDominantEthnicity != null && tileDominantEthnicity >= 0 ? sim.climateMismatch(tileDominantEthnicity, i) : 0; const tilePopulation = w.population?.[i] || 0; const nearbyCampaigns = activeCampaignsNear(i); +const nearbyNomad = activeNomadNear(i); els.tooltip.innerHTML = ` - ${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name} + ${city ? `City #${city.id}` : terrain.name} ${tooltipSection("Tile", [ tooltipRow("Position", `${x}, ${y}`), tooltipRow("Terrain", terrain.name), @@ -367,6 +401,16 @@ els.tooltip.innerHTML = ` tooltipRow("Dominant ethnicity", regionalEthnicity), tooltipRow("Tile population", tilePopulation >= 10 ? Math.round(tilePopulation).toLocaleString() : tilePopulation.toFixed(1)) ])} + ${tooltipSection("Nomads", [ + tooltipRow("Band", nearbyNomad ? `${nearbyNomad.name} #${nearbyNomad.id}` : "", nearbyNomad), + tooltipRow("Mode", nearbyNomad?.mode, nearbyNomad), + tooltipRow("Population", nearbyNomad ? Math.round(nearbyNomad.population).toLocaleString() : "", nearbyNomad), + tooltipRow("Ethnicity", nearbyNomad ? `E${nearbyNomad.ethnicityId}` : "", nearbyNomad), + tooltipRow("Cohesion", nearbyNomad?.cohesion.toFixed(2), nearbyNomad), + tooltipRow("Prestige", nearbyNomad?.prestige.toFixed(2), nearbyNomad), + tooltipRow("Resources", nearbyNomad?.resources.toFixed(1), nearbyNomad), + tooltipRow("Herds", nearbyNomad?.herds.toFixed(1), nearbyNomad) + ])} ${tooltipSection("City & State", [ tooltipRow("Population", city?.population.toLocaleString(), city), tooltipRow("Food stock", city?.storedResources.toFixed(1), city), @@ -382,17 +426,15 @@ els.tooltip.innerHTML = ` tooltipRow("Treasury", polity?.treasury.toFixed(1), polity), tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity) ])} - ${tooltipSection("Culture & Agent", [ + ${tooltipSection("Culture", [ tooltipRow("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`), - tooltipRow("Agent ethnicity", `E${agent?.ethnicity}`, agent), - tooltipRow("Lineage pop", ethnicity?.population, ethnicity), - tooltipRow("Climate pref", ethnicity ? `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : "", ethnicity), - tooltipRow("Climate mismatch", mismatch.toFixed(2), agent), - tooltipRow("Sedentary", agent ? agent.traits.sedentary.toFixed(2) : "", agent), - tooltipRow("Ethnocentrism", agent ? agent.traits.ethnocentrism.toFixed(2) : "", agent), - tooltipRow("Farming", agent ? `${techLevel(agent, "farming").toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : "", agent), - tooltipRow("Metallurgy", techLevel(agent, "metallurgy").toFixed(3), agent), - tooltipRow("Stored", agent?.resources.toFixed(1), agent) + tooltipRow("Lineage pop", regionalLineage?.population?.toLocaleString(), regionalLineage), + tooltipRow("Climate pref", regionalLineage ? `${regionalLineage.climateTemp.toFixed(2)} / ${regionalLineage.climateHumidity.toFixed(2)}` : "", regionalLineage), + tooltipRow("Climate mismatch", mismatch.toFixed(2), regionalLineage), + tooltipRow("Settled pop", (w.settledPopulation?.[i] || 0).toFixed(1), tilePopulation > 0), + tooltipRow("Mobile pop", (w.mobilePopulation?.[i] || 0).toFixed(1), tilePopulation > 0), + tooltipRow("Farming", (w.farmingKnowledge?.[i] || 0).toFixed(3)), + tooltipRow("Metallurgy", (w.metallurgyKnowledge?.[i] || 0).toFixed(3)) ])} `; els.tooltip.hidden = false; @@ -459,9 +501,23 @@ labels.push(`${campaign.type.replaceAll("_", " ")} ${Math.round(clamp(campaign.p } return labels.slice(0, 2).join("; "); } -function findAgentAt(x, y) { -const directIndex = sim.world.idx(x, y); -return sim.tileAgents.get(directIndex)?.[0] || null; +function activeNomadNear(tile) { +if (!sim?.nomadBands?.length) return null; +const w = sim.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let best = null; +let bestDistance = Infinity; +for (const band of sim.nomadBands) { +const distance = Math.abs(Math.round(band.x) - x) + Math.abs(Math.round(band.y) - y); +const inInfluence = w.nomadBand?.[tile] === band.id; +if (!inInfluence && distance > Math.max(3, Math.floor(band.radius * 0.55))) continue; +if (distance < bestDistance) { +best = band; +bestDistance = distance; +} +} +return best; } function waterInfluenceAt(world, x, y) { let score = 0; @@ -487,25 +543,29 @@ let farmingTotal = 0; let metallurgyTotal = 0; let farmingHolders = 0; let metallurgyHolders = 0; -for (const a of sim.agents) { -sim.ensureAgentTech(a); -farmingTotal += techLevel(a, "farming"); -metallurgyTotal += techLevel(a, "metallurgy"); -if (techLevel(a, "farming") > 0.02) farmingHolders++; -if (techLevel(a, "metallurgy") > 0.02) metallurgyHolders++; +for (let i = 0; i < sim.world.count; i++) { +const pop = sim.world.population?.[i] || 0; +if (pop <= 0) continue; +farmingTotal += (sim.world.farmingKnowledge?.[i] || 0) * pop; +metallurgyTotal += (sim.world.metallurgyKnowledge?.[i] || 0) * pop; +if ((sim.world.farmingKnowledge?.[i] || 0) > 0.02) farmingHolders++; +if ((sim.world.metallurgyKnowledge?.[i] || 0) > 0.02) metallurgyHolders++; } -const agentCount = Math.max(1, sim.agents.length); +const fieldPopulation = sim.world.population +? Array.from(sim.world.population).reduce((sum, value) => sum + value, 0) +: 0; +const knowledgeDenominator = Math.max(1, fieldPopulation); const stats = { year: formatSimDate(sim.year), -activeGroups: sim.agents.length.toLocaleString(), +activeGroups: Math.round(fieldPopulation).toLocaleString(), urbanPopulation: Math.floor(urbanPopulation).toLocaleString(), ethnicities: livingEthnicities.length.toLocaleString(), cities: sim.cities.length.toLocaleString(), polities: sim.polities.length.toLocaleString(), wars: sim.wars.length.toLocaleString(), routes: sim.tradeLinks.length.toLocaleString(), -farmingKnowledge: `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`, -metallurgyKnowledge: `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`, +farmingKnowledge: `${(farmingTotal / knowledgeDenominator).toFixed(4)} (${farmingHolders})`, +metallurgyKnowledge: `${(metallurgyTotal / knowledgeDenominator).toFixed(4)} (${metallurgyHolders})`, deaths: sim.deaths.toLocaleString() }; for (const [id, value] of Object.entries(stats)) { @@ -880,7 +940,7 @@ newEthnicity: { label: "new ethnicity", color: "rgba(105, 181, 120, 0.95)", draw: drawGraphDiamond, -summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier groups` +summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier population` }, campaign: { label: "campaign", @@ -924,6 +984,54 @@ color: "rgba(236, 126, 111, 0.95)", draw: drawGraphTriangleUp, summary: data => `${data.outcome || "war"} vs S${data.targetPolityId ?? "-"}` }, +nomadBand: { +label: "nomad band", +color: "rgba(210, 184, 104, 0.95)", +draw: drawGraphDiamond, +summary: data => `band #${data.bandId ?? "-"}, E${data.ethnicity ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}` +}, +nomadTrade: { +label: "nomad trade", +color: "rgba(216, 177, 86, 0.95)", +draw: drawGraphCircle, +summary: data => `band #${data.bandId ?? "-"} with city #${data.cityId ?? "-"}` +}, +nomadRaid: { +label: "nomad raid", +color: "rgba(232, 101, 76, 0.95)", +draw: drawGraphTriangleDown, +summary: data => `band #${data.bandId ?? "-"} raided city #${data.cityId ?? "-"}` +}, +nomadConquest: { +label: "nomad conquest", +color: "rgba(202, 87, 78, 0.95)", +draw: drawGraphTriangleUp, +summary: data => `band #${data.bandId ?? "-"}, city #${data.cityId ?? "-"}, ${data.outcome || "conquest"}` +}, +nomadConfederation: { +label: "nomad confederation", +color: "rgba(191, 162, 91, 0.95)", +draw: drawGraphDiamond, +summary: data => `band #${data.bandId ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}` +}, +nomadSplit: { +label: "nomad split", +color: "rgba(151, 130, 91, 0.95)", +draw: drawGraphTriangleDown, +summary: data => `band #${data.bandId ?? "-"} split #${data.newBandId ?? "-"}` +}, +nomadMerge: { +label: "nomad merge", +color: "rgba(170, 145, 91, 0.95)", +draw: drawGraphDiamond, +summary: data => `band #${data.mergedBandId ?? "-"} joined #${data.bandId ?? "-"}` +}, +sedentarization: { +label: "sedentarization", +color: "rgba(202, 169, 102, 0.95)", +draw: drawGraphCircle, +summary: data => `band #${data.bandId ?? "-"} settled ${Math.round(data.population || 0).toLocaleString()} in city #${data.cityId ?? "-"}` +}, war: { label: "war", color: "rgba(202, 87, 78, 0.95)", diff --git a/utils.js b/utils.js index 7b1e571..5348bc8 100644 --- a/utils.js +++ b/utils.js @@ -43,7 +43,9 @@ function averageTraits(sum, count) { return Object.fromEntries(traitFields.map(([key, , , , , sumScale]) => [key, sum[key] / count / sumScale])); } function compositionTotal(composition) { -return [...composition.values()].reduce((sum, value) => sum + value, 0); +let sum = 0; +for (const value of composition.values()) sum += value; +return sum; } function addBirthsToComposition(composition, births, rng = null) { const entries = [...composition.entries()];