class Rng { constructor(seed) { this.seed = seed >>> 0; } next() { this.seed = (1664525 * this.seed + 1013904223) >>> 0; return this.seed / 4294967296; } range(min, max) { return min + (max - min) * this.next(); } int(max) { return Math.floor(this.next() * max); } } class World { constructor(size, rng) { this.size = size; this.count = size * size; this.rng = rng; this.terrain = new Uint8Array(this.count); this.resource = new Float32Array(this.count); this.regen = new Float32Array(this.count); this.move = new Float32Array(this.count); this.fertility = new Float32Array(this.count); this.mineral = new Float32Array(this.count); this.temperature = new Float32Array(this.count); this.humidity = new Float32Array(this.count); this.pheromone = new Float32Array(this.count); this.tradeRoute = new Uint8Array(this.count); this.farmland = new Float32Array(this.count); this.cityPull = new Float32Array(this.count); this.city = new Int32Array(this.count); this.population = new Float32Array(this.count); 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 = this.territoryOwner; this.control = new Float32Array(this.count); this.claim = new Float32Array(this.count); 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(); this.buildLandNeighbors(); } buildLandNeighbors() { // Terrain is fixed after generation. Keep the original dy/dx traversal order. const starts = new Int32Array(this.count + 1); const tiles = []; for (let i = 0; i < this.count; i++) { starts[i] = tiles.length; if (this.terrain[i] === Terrain.WATER) continue; const x = i % this.size; const y = Math.floor(i / this.size); for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= this.size || ty >= this.size) continue; const tile = this.idx(tx, ty); if (this.terrain[tile] !== Terrain.WATER) tiles.push(tile); } } } starts[this.count] = tiles.length; this.landNeighborStarts = starts; this.landNeighbors = Int32Array.from(tiles); } idx(x, y) { return y * this.size + x; } generate() { const s = this.size; const centers = Array.from({ length: 18 }, () => ({ x: this.rng.range(0, s), y: this.rng.range(0, s), kind: this.rng.next() })); const height = new Float32Array(this.count); const moistureMap = new Float32Array(this.count); const mineralMap = new Float32Array(this.count); for (let y = 0; y < s; y++) { for (let x = 0; x < s; x++) { const i = this.idx(x, y); const nx = x / s - 0.5; const ny = y / s - 0.5; const latitude = Math.abs(ny) * 0.18; let elevation = 0.48 - Math.hypot(nx, ny) * 0.62 + this.smoothNoise(x, y, 58) * 0.42 + this.smoothNoise(x + 900, y - 300, 31) * 0.12; let moisture = this.smoothNoise(x + 500, y - 330, 54) * 0.68 + this.smoothNoise(x, y, 24) * 0.24 - latitude; let minerals = this.smoothNoise(x - 290, y + 120, 20); for (const c of centers) { const d = Math.hypot(x - c.x, y - c.y) / s; if (c.kind < 0.3) elevation += Math.max(0, 0.24 - d) * 0.85; if (c.kind > 0.72) moisture += Math.max(0, 0.22 - d) * 1.0; } height[i] = elevation; moistureMap[i] = moisture; mineralMap[i] = minerals; } } this.smoothField(height, 2); this.smoothField(moistureMap, 1); const seaLevel = this.percentile(height, 0.34); for (let y = 0; y < s; y++) { for (let x = 0; x < s; x++) { const i = this.idx(x, y); const latitude = Math.abs((y / (s - 1)) * 2 - 1); const altitudeCooling = clamp((height[i] - seaLevel) * 0.25, 0, 0.18); this.temperature[i] = clamp(1 - latitude * 0.92 - altitudeCooling + this.smoothNoise(x + 1700, y - 80, 70) * 0.08, 0, 1); this.terrain[i] = height[i] < seaLevel ? Terrain.WATER : Terrain.PLAINS; } } this.computeHumidityFromWater(moistureMap); for (let i = 0; i < this.count; i++) { const elevation = height[i]; const minerals = mineralMap[i]; const temp = this.temperature[i]; const humid = this.humidity[i]; let t = this.terrain[i]; if (t !== Terrain.WATER) { if (elevation > seaLevel + 0.42) t = Terrain.MOUNTAIN; else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT; else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST; else t = Terrain.PLAINS; } const info = terrainInfo[t]; this.terrain[i] = t; // Water does not participate in fertility simulation. Keep it explicitly at zero // so later systems never spend work interpreting a meaningless fertility value. this.fertility[i] = t === Terrain.WATER ? 0 : clamp(info.fertility + humid * 0.16 - Math.abs(temp - 0.58) * 0.08 + this.rng.range(-0.03, 0.03), 0, 1); this.mineral[i] = clamp(info.mineral + minerals * 0.16, 0, 1); this.regen[i] = info.regen * (0.7 + this.fertility[i]); this.move[i] = info.move; this.resource[i] = this.terrain[i] === Terrain.WATER ? 0 : this.rng.range(4, 18) * (0.5 + this.fertility[i] + this.mineral[i] * 0.35); } this.smoothTerrainTypes(2); this.ensureDesertPatches(); this.enrichWaterMargins(); } enrichWaterMargins() { const s = this.size; const nextFertility = new Float32Array(this.fertility); const offsets = []; for (let dy = -3; dy <= 3; dy++) { for (let dx = -3; dx <= 3; dx++) { const d = Math.abs(dx) + Math.abs(dy); if (!d || d > 3) continue; offsets.push([dx, dy, (4 - d) / 4]); } } for (let y = 0; y < s; y++) { for (let x = 0; x < s; x++) { const i = y * s + x; if (this.terrain[i] === Terrain.WATER) continue; let waterScore = 0; for (const [dx, dy, weight] of offsets) { const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue; if (this.terrain[ty * s + tx] === Terrain.WATER) waterScore += weight; } if (waterScore <= 0) continue; const boost = Math.min(0.32, waterScore * 0.08); nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1); if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS; } } for (let i = 0; i < this.count; i++) { const info = terrainInfo[this.terrain[i]]; if (this.terrain[i] === Terrain.WATER) { this.fertility[i] = 0; this.regen[i] = info.regen * 0.7; this.resource[i] = 0; } else { this.fertility[i] = nextFertility[i]; this.regen[i] = info.regen * (0.7 + this.fertility[i]); this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25)); } this.move[i] = info.move; } } smoothNoise(x, y, scale) { const x0 = Math.floor(x / scale); const y0 = Math.floor(y / scale); const fx = smoothstep((x / scale) - x0); const fy = smoothstep((y / scale) - y0); const a = this.gridNoise(x0, y0); const b = this.gridNoise(x0 + 1, y0); const c = this.gridNoise(x0, y0 + 1); const d = this.gridNoise(x0 + 1, y0 + 1); return lerp(lerp(a, b, fx), lerp(c, d, fx), fy); } gridNoise(x, y) { const v = Math.sin(x * 127.1 + y * 311.7 + this.rng.seed * 0.000001) * 43758.5453123; return v - Math.floor(v); } computeHumidityFromWater(moistureMap) { const s = this.size; const radius = 10; const unreachable = radius + 1; const distance = new Uint8Array(this.count); distance.fill(unreachable); for (let i = 0; i < this.count; i++) { if (this.terrain[i] === Terrain.WATER) distance[i] = 0; } // Exact city-block distance transform. The former implementation scanned the // full radius around every tile; two linear passes produce the same nearest- // water distance with O(tile count) work. for (let y = 0; y < s; y++) { for (let x = 0; x < s; x++) { const i = y * s + x; if (distance[i] === 0) continue; let d = distance[i]; if (x > 0) d = Math.min(d, distance[i - 1] + 1); if (y > 0) d = Math.min(d, distance[i - s] + 1); distance[i] = Math.min(unreachable, d); } } for (let y = s - 1; y >= 0; y--) { for (let x = s - 1; x >= 0; x--) { const i = y * s + x; if (distance[i] === 0) continue; let d = distance[i]; if (x + 1 < s) d = Math.min(d, distance[i + 1] + 1); if (y + 1 < s) d = Math.min(d, distance[i + s] + 1); distance[i] = Math.min(unreachable, d); } } for (let i = 0; i < this.count; i++) { if (this.terrain[i] === Terrain.WATER) { this.humidity[i] = 1; continue; } const d = distance[i]; const waterInfluence = d <= radius ? 1 - d / (radius + 1) : 0; this.humidity[i] = clamp(waterInfluence * 0.78 + moistureMap[i] * 0.22, 0, 1); } this.smoothField(this.humidity, 1); } smoothTerrainTypes(passes) { const s = this.size; let source = new Uint8Array(this.terrain); let target = new Uint8Array(this.terrain.length); for (let pass = 0; pass < passes; pass++) { for (let y = 0; y < s; y++) { for (let x = 0; x < s; x++) { const counts = new Uint8Array(terrainInfo.length); for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue; counts[source[this.idx(tx, ty)]]++; } } let bestTerrain = source[this.idx(x, y)]; let bestCount = counts[bestTerrain]; for (let t = 0; t < counts.length; t++) { if (counts[t] > bestCount) { bestTerrain = t; bestCount = counts[t]; } } target[this.idx(x, y)] = bestCount >= 4 ? bestTerrain : source[this.idx(x, y)]; } } const swap = source; source = target; target = swap; } this.terrain.set(source); } ensureDesertPatches() { const candidates = []; let desertCount = 0; for (let i = 0; i < this.count; i++) { if (this.terrain[i] === Terrain.DESERT) desertCount++; if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.55 && this.humidity[i] < 0.28) { candidates.push(i); } } const target = Math.max(12, Math.floor(this.count * 0.012)); if (desertCount >= target || !candidates.length) return; candidates.sort((a, b) => { const dryA = this.temperature[a] * (1 - this.humidity[a]); const dryB = this.temperature[b] * (1 - this.humidity[b]); return dryB - dryA; }); const needed = Math.min(target - desertCount, candidates.length); for (let n = 0; n < needed; n++) { const center = candidates[n]; const cx = center % this.size; const cy = Math.floor(center / this.size); for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { if (Math.abs(dx) + Math.abs(dy) > 2) continue; const x = cx + dx; const y = cy + dy; if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue; const i = this.idx(x, y); if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.5 && this.humidity[i] < 0.34) { this.terrain[i] = Terrain.DESERT; } } } } } smoothField(field, passes) { const s = this.size; let source = field; let target = new Float32Array(field.length); for (let pass = 0; pass < passes; pass++) { for (let y = 0; y < s; y++) { for (let x = 0; x < s; x++) { let sum = 0; let count = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue; sum += source[this.idx(tx, ty)]; count++; } } target[this.idx(x, y)] = sum / count; } } const nextSource = target; target = source === field ? new Float32Array(field.length) : field; source = nextSource; } if (source !== field) field.set(source); } percentile(field, ratio) { const values = Array.from(field).sort((a, b) => a - b); return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))]; } } class Simulation { constructor(size, initialPopulation = SimConfig.world?.initialPopulation ?? 50000) { this.rng = new Rng(Date.now()); this.world = new World(size, this.rng); this.ethnicities = new Map(); this.cities = []; this.cityById = new Map(); this.cityBuckets = new Map(); this.polityCitiesCache = new Map(); this.polities = []; this.polityById = new Map(); this.wars = []; this.nomadBands = []; this.disasters = []; this.disasterHistory = []; this.graphEvents = []; this.worldHistory = []; this.polityHistory = new Map(); this.deadPolityHistories = []; this.tradeLinks = []; this.activeTradeRouteTiles = new Set(); this.activePheromoneTiles = new Set(); this.nextPheromoneTiles = new Set(); this.pheromoneTouchedScratch = []; this.manualTradeRoutes = []; this.manualRouteProtection = 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; 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.tileEthnicMix = new Map(); this.tileCultures = this.tileEthnicMix; 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.populationCapacityStatic = new Float32Array(this.world.count); this.populationCapacityDynamicScale = new Float32Array(this.world.count); this.initializePopulationCapacityCache(); this.territoryBestSupport = new Float32Array(this.world.count); this.territoryIncumbentSupport = 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.cityScoreScratch = []; this.pairCandidateScratch = []; this.routeCandidateScratch = []; this.supportedRouteScratch = new Set(); this.populationMoveScratch = []; this.activePopulationScratch = []; this.territoryExitCandidates = []; this.suppressTerritoryExitShock = false; this.technologySourceScratch = []; this.routeCostScratch = new Float32Array(this.world.count); this.routePrevScratch = new Int32Array(this.world.count); this.routeHeapScratch = []; this.seedInitialPopulationFieldDirectly(initialPopulation); this.recomputeTerritories({ allowOwnershipChanges: true, reason: "initial" }); this.updateEthnicStats(); this.sampleWorldHistory(); } 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++; } 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++; this.ethnicities.set(id, { id, parent, born: this.year, population: 0, diversity: 0, climateTemp: climate?.temperature ?? 0.55, climateHumidity: climate?.humidity ?? 0.45, color: hslToRgb((id * 0.61803398875) % 1, 0.55, 0.56), centroidX: 0, centroidY: 0, nextDivergenceCheck: this.year + years(this.rng.range(18, 72) / clamp(SimConfig.culture?.divergenceRateMultiplier ?? 0.25, 0.05, 1)) }); return id; } randomTraits() { return { mobility: this.rng.range(0.18, 0.85), resourceAttraction: this.rng.range(0.55, 1), assimilation: this.rng.range(0.03, 0.34), ethnocentrism: this.rng.range(0.12, 0.78), reproductionThreshold: this.rng.range(19, 34), sedentary: this.rng.range(0.08, 0.82) }; } desertFounderTraits() { const traits = this.randomTraits(); traits.mobility = this.rng.range(0.48, 0.9); traits.resourceAttraction = this.rng.range(0.82, 1.12); traits.reproductionThreshold = this.rng.range(24, 38); traits.sedentary = this.rng.range(0.18, 0.58); return traits; } findHabitableTile(preferredTerrain = null) { if (preferredTerrain !== null) { const exact = []; let best = null; let bestScore = -Infinity; for (let i = 0; i < this.world.count; i++) { const x = i % this.world.size; const y = Math.floor(i / this.world.size); if (this.world.terrain[i] === preferredTerrain) { exact.push({ x, y }); continue; } if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) { const score = this.world.temperature[i] * (1 - this.world.humidity[i]); if (score > bestScore) { bestScore = score; best = { x, y }; } } } if (exact.length) return exact[this.rng.int(exact.length)]; if (best) return best; } for (let tries = 0; tries < 5000; tries++) { const x = this.rng.int(this.world.size); const y = this.rng.int(this.world.size); const i = this.world.idx(x, y); if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) { return { x, y }; } } return { x: this.world.size >> 1, y: this.world.size >> 1 }; } frontierSpawnTile(side, edgeBand = 4) { const w = this.world; const band = Math.max(1, Math.floor(edgeBand)); let best = null; let bestScore = -Infinity; for (let tries = 0; tries < 160; tries++) { let x = this.rng.int(w.size); let y = this.rng.int(w.size); 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 i = w.idx(x, y); if (w.terrain[i] === Terrain.WATER) continue; const score = w.resource[i] * 0.04 + w.fertility[i] * 0.85 + w.mineral[i] * 0.25 - w.move[i] * 0.12; if (score > bestScore) { best = { x, y }; bestScore = score; } } return best || this.findHabitableTile(); } maybeSpawnFrontierWave() { const cfg = SimConfig.frontierWave; if (!cfg) return; 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; 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 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 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; 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 (seeded <= 0) { this.ethnicities.delete(ethnicity); return; } this.syncTilePopulationCulture(); this.addGraphEvent("newEthnicity", this.year, { ethnicity, 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: `遊牧集団${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"); this.protectCityFromRebellion(city); 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"); this.protectCityFromRebellion(city); 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.dominantEthnicityCache.clear(); this.rebuildIndexes(); const annualTick = this.year % years(1) === 0; const weekOfYear = this.year % WEEKS_PER_YEAR; const fieldTick = this.year % WEEKS_PER_MONTH === 0; const resourceTick = weekOfYear === WEEKS_PER_YEAR - 1; // Pheromone changes update capacity only on touched cells inside updateWorldFields(). // This avoids a full-map capacity pass every simulated month. if (fieldTick) this.updateWorldFields(WEEKS_PER_MONTH); // Resources are consumed by the annual economy. Batch the same 48-week // regeneration immediately before the next annual tick. if (resourceTick) this.regenerateResources(WEEKS_PER_YEAR); this.maybeSpawnDisaster(); if (annualTick) this.maybeSpawnFrontierWave(); if (annualTick) { this.updateCities(); this.updateNomadBands(); this.consumeTileResources(); this.growTilePopulation(); this.updatePopulationPressure(); this.updateNomadInfluence(); this.clearSparseHumanData(); if (this.year % years(100) === 0 && typeof console !== "undefined" && console.debug) { const d = this.territoryDebug; if (d.disconnectedTerritoryRemoved || d.independentCitiesAbsorbedByTerritory || d.cityTerritoryMismatchesFixed || d.invalidCampaignTargetsRejected) { console.debug("territory topology", { year: Math.floor(this.year / WEEKS_PER_YEAR), ...d }); } } } // Spread expensive annual/periodic field work across the year instead of // stacking it all on the year boundary. Each population cell still migrates // once per year, in one of four stable quarterly cohorts. if (weekOfYear % 12 === 8) { const cohort = Math.floor(weekOfYear / 12) % 4; this.updatePopulationCapacity(); this.diffusePopulation(cohort, 4); this.updatePopulationPressure(); } if (this.year % years(2) === 12) { this.updateTileTechnology(); this.diffuseTileTechnology(); this.cityTechnologyExchange(); this.clearSparseHumanData(); } if (this.year % years(3) === 24) this.updateTileAssimilation(); if (weekOfYear === 36) this.spreadCulturesFromCitiesAndTrade(); if (this.year % years(5) === 20) this.updateTradeRoutes(true); // State accounting follows the annual city economy, but is offset by one // month so the two largest annual workloads do not land on the same frame. if (weekOfYear === 4) this.updatePolities(true); if (this.year % SimConfig.war.tickWeeks === 0) { this.maybeStartWars(); this.updateCampaigns(); this.updateWars(); } const historyTick = this.year % 12 === 0; if (historyTick) this.updateEthnicStats(); // Ethnic maintenance is annual in frequency but offset from the year boundary. if (this.year > 0 && weekOfYear === 40) { this.updateEthnicStats(); this.absorbSmallEthnicities(); this.updateEthnicStats(); this.splitDivergentEthnicities(); this.updateEthnicStats(); } if (historyTick) this.sampleWorldHistory(); this.year++; } rebuildIndexes() { this.cityById.clear(); this.polityCitiesCache?.clear(); for (const city of this.cities) this.cityById.set(city.id, city); this.polityById.clear(); for (const polity of this.polities) this.polityById.set(polity.id, polity); this.cityBuckets.clear(); for (const city of this.cities) { const key = this.cityBucketKey(city.x, city.y); let bucket = this.cityBuckets.get(key); if (!bucket) { bucket = []; this.cityBuckets.set(key, bucket); } bucket.push(city); } } cityBucketKey(x, y) { return Math.floor(x / 8) + Math.floor(y / 8) * 4096; } getCitiesNear(x, y, radius) { const cities = []; const minBx = Math.floor((x - radius) / 8); const maxBx = Math.floor((x + radius) / 8); 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 * 4096); if (!bucket) continue; for (const city of bucket) { if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city); } } } return cities; } 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.activePopulationTiles.clear(); const founders = Math.max(6, Math.min(18, Math.round(initialPopulation / (650 * SimConfig.population.populationScale)))); 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(entry.tile, mix); } mix.set(ethnicityId, (mix.get(ethnicityId) || 0) + amount); this.updateCultureTile(entry.tile); } } syncTilePopulationCulture() { const w = this.world; this.tileCultures = this.tileEthnicMix; for (const tile of this.tileEthnicMix.keys()) this.updateCultureTile(tile); 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; } } } projectCityPopulationToTiles(city) { if (!city || city.population <= 0) return; const w = this.world; const radius = clamp(Math.ceil((city.agriculturalRadius || 2) * 0.75), 1, 8); let totalWeight = 0; const tiles = []; 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) * (tile === w.idx(city.x, city.y) ? 1.8 : 1); tiles.push({ tile, weight }); totalWeight += weight; } if (!tiles.length || totalWeight <= 0) return; const cityTotal = compositionTotal(city.ethnicityComposition); const fallbackEthnicity = dominantComposition(city.ethnicityComposition); for (const entry of tiles) { 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(); this.tileEthnicMix.set(entry.tile, culture); } if (cityTotal > 0) { for (const [id, count] of city.ethnicityComposition) { const amount = localPopulation * count / cityTotal; if (amount > 0) culture.set(id, (culture.get(id) || 0) + amount); } } else if (fallbackEthnicity !== null) { culture.set(fallbackEthnicity, (culture.get(fallbackEthnicity) || 0) + localPopulation); } } } updateCultureTile(tile) { const w = this.world; const sparseThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1); if ((w.population[tile] || 0) < sparseThreshold) { w.dominantEthnicity[tile] = -1; w.cultureDiversity[tile] = 0; w.farmingKnowledge[tile] = 0; w.metallurgyKnowledge[tile] = 0; this.tileEthnicMix.delete(tile); return; } const counts = this.tileEthnicMix.get(tile); if (!counts || !counts.size) { w.dominantEthnicity[tile] = -1; w.cultureDiversity[tile] = 0; return; } let total = 0; let dominant = -1; let dominantCount = 0; for (const [id, count] of counts) { total += count; if (count > dominantCount) { dominant = id; dominantCount = count; } } this.world.dominantEthnicity[tile] = dominant; this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0; } diffusePopulation(cohortIndex = 0, cohortCount = 1) { const w = this.world; const moves = this.populationMoveScratch; moves.length = 0; const tiles = this.activePopulationSnapshot(); for (const tile of tiles) { if (cohortCount > 1 && tile % cohortCount !== cohortIndex) continue; const population = w.population[tile]; const mobile = w.mobilePopulation[tile] || 0; if (population < 1) continue; 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 = 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; const target = w.idx(tx, ty); const score = this.populationDestinationScore(tile, target); if (score > bestScore) { bestScore = score; bestTile = target; } } if (bestTile < 0 || bestScore <= 0) continue; const excess = Math.max(0, population - w.populationCapacity[tile]); 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); } populationDestinationScore(fromTile, toTile) { const w = this.world; 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 + 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 + frontier - w.move[toTile] * 0.62 - overcapacity * 2.4 - foreignPolity; } moveEthnicPopulation(fromTile, toTile, amount) { const w = this.world; const fromMix = this.tileEthnicMix.get(fromTile); if (!fromMix || amount <= 0 || w.population[fromTile] <= 0) return 0; const moved = Math.min(amount, w.population[fromTile]); let toMix = this.tileEthnicMix.get(toTile); if (!toMix) { toMix = new Map(); this.tileEthnicMix.set(toTile, toMix); } const total = compositionTotal(fromMix); if (total <= 0) return 0; for (const [id, count] of [...fromMix]) { const ethnicMove = Math.min(count, moved * count / total); if (ethnicMove <= 0) continue; const remaining = count - ethnicMove; if (remaining > 0.01) fromMix.set(id, remaining); else fromMix.delete(id); toMix.set(id, (toMix.get(id) || 0) + ethnicMove); } if (!fromMix.size) this.tileEthnicMix.delete(fromTile); const settledShare = w.population[fromTile] > 0 ? w.settledPopulation[fromTile] / w.population[fromTile] : 0; const settledMove = moved * clamp(settledShare, 0, 1); const mobileMove = moved - settledMove; w.population[fromTile] = Math.max(0, w.population[fromTile] - moved); w.population[toTile] += moved; w.settledPopulation[fromTile] = Math.max(0, w.settledPopulation[fromTile] - settledMove); 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; 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]; const baseGrowth = 0.004 + w.fertility[tile] * 0.010 + w.farmland[tile] * 0.007 + clamp(w.resource[tile] / 40, 0, 1) * 0.004; const pressurePenalty = pressure > 1 ? (pressure - 1) * 0.020 : 0; const warPenalty = w.contested[tile] ? 0.006 : 0; const rate = clamp(baseGrowth - pressurePenalty - warPenalty, -0.045, 0.035); const change = population * rate; if (Math.abs(change) < 0.01) continue; if (change > 0) this.addEthnicPopulationProportionally(tile, change); else this.removeEthnicPopulationProportionally(tile, -change); } } addEthnicPopulationProportionally(tile, amount) { const w = this.world; let mix = this.tileEthnicMix.get(tile); if (!mix) { const dominant = w.dominantEthnicity[tile]; if (dominant < 0) return; 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; } 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 0; const removed = Math.min(amount, w.population[tile]); const total = Math.max(0.001, compositionTotal(mix)); for (const [id, count] of [...mix]) { const loss = removed * count / total; const remaining = count - loss; if (remaining > 0.01) mix.set(id, remaining); else mix.delete(id); } if (!mix.size) this.tileEthnicMix.delete(tile); const settledShare = w.population[tile] > 0 ? w.settledPopulation[tile] / w.population[tile] : 0; 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; 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; const demand = (population / SimConfig.population.populationScale) * (0.0045 + Math.max(0, pressure - 1) * 0.0035); w.resource[tile] = Math.max(0, w.resource[tile] - demand); } } clearSparseHumanData() { const w = this.world; const threshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1); if (threshold <= 0) return 0; let cleared = 0; for (const tile of this.activePopulationSnapshot()) { if ((w.population[tile] || 0) >= threshold) continue; const hadData = w.farmingKnowledge[tile] !== 0 || w.metallurgyKnowledge[tile] !== 0 || w.dominantEthnicity[tile] >= 0 || w.cultureDiversity[tile] !== 0 || this.tileEthnicMix.has(tile); if (!hadData) continue; w.farmingKnowledge[tile] = 0; w.metallurgyKnowledge[tile] = 0; w.dominantEthnicity[tile] = -1; w.cultureDiversity[tile] = 0; this.tileEthnicMix.delete(tile); cleared++; } return cleared; } updateTileAssimilation() { const w = this.world; for (const [tile, mix] of [...this.tileEthnicMix]) { if (!mix || mix.size <= 1) continue; const total = compositionTotal(mix); if (total <= 0.5) continue; let dominant = null; let dominantCount = 0; for (const [id, count] of mix) { if (count > dominantCount) { dominant = id; dominantCount = count; } } if (dominant == null || dominantCount <= 0) continue; const x = tile % w.size; const y = Math.floor(tile / w.size); const nearCity = this.getCitiesNear(x, y, 5).length ? 1 : 0; const control = clamp(w.control[tile] || 0, 0, 1); const route = w.tradeRoute[tile] ? 1 : 0; const diversity = clamp(w.cultureDiversity[tile] || 0, 0, 1); const dominantTraits = this.ethnicities.get(dominant)?.averageTraits; const assimilation = dominantTraits?.assimilation ?? 0.12; const baseRate = (0.0004 + nearCity * 0.00045 + control * 0.00035 + route * 0.00035 + diversity * 0.00025) * (0.5 + assimilation); let gained = 0; for (const [id, count] of [...mix]) { if (id === dominant || count <= 0) continue; const minorityTraits = this.ethnicities.get(id)?.averageTraits; const resistance = minorityTraits?.ethnocentrism ?? 0.35; const converted = Math.min(count, count * baseRate * clamp(1.15 - resistance * 0.55, 0.2, 1.15)); if (converted <= 0.001) continue; const remaining = count - converted; if (remaining > 0.01) mix.set(id, remaining); else mix.delete(id); gained += converted; } if (gained > 0) { mix.set(dominant, (mix.get(dominant) || 0) + gained); this.updateCultureTile(tile); } } } blendEthnicComposition(target, targetTotal, source, rate) { if (!target || !source?.size || targetTotal <= 0 || rate <= 0) return false; const sourceTotal = compositionTotal(source); if (sourceTotal <= 0) return false; const blendRate = clamp(rate, 0, 0.25); const ids = new Set([...target.keys(), ...source.keys()]); for (const id of ids) { const current = target.get(id) || 0; const desired = targetTotal * (source.get(id) || 0) / sourceTotal; const next = current + (desired - current) * blendRate; if (next > 0.01) target.set(id, next); else target.delete(id); } return true; } blendTileEthnicComposition(tile, source, rate) { const population = this.world.population[tile] || 0; if (population <= 0.01 || this.world.terrain[tile] === Terrain.WATER || !source?.size) return false; let target = this.tileEthnicMix.get(tile); if (!target?.size) { target = new Map(); const sourceTotal = compositionTotal(source); if (sourceTotal <= 0) return false; for (const [id, count] of source) target.set(id, population * count / sourceTotal); this.tileEthnicMix.set(tile, target); this.updateCultureTile(tile); return true; } const targetTotal = compositionTotal(target); const changed = this.blendEthnicComposition(target, targetTotal, source, rate); if (changed) this.updateCultureTile(tile); return changed; } spreadCulturesFromCitiesAndTrade() { const w = this.world; const cfg = SimConfig.culture || {}; const urbanRadius = cfg.urbanSpreadRadius ?? 5; const cityRate = cfg.cityBlendRate ?? 0.045; for (const city of this.cities) { if (!city.ethnicityComposition?.size || city.population <= 0) continue; const populationStrength = clamp(Math.sqrt(city.population / (SimConfig.city.populationScale || 1)) / 18, 0.35, 1.4); for (const { dx, dy, distance } of this.getRadiusOffsets(urbanRadius)) { 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.population[tile] || 0) <= 0.01) continue; const falloff = 1 - distance / (urbanRadius + 1); const routeBoost = w.tradeRoute[tile] ? 1.35 : 1; this.blendTileEthnicComposition(tile, city.ethnicityComposition, cityRate * populationStrength * falloff * routeBoost); } } const routeRate = cfg.routeBlendRate ?? 0.035; const neighborFactor = cfg.routeNeighborFactor ?? 0.35; const cityTradeRate = cfg.cityTradeBlendRate ?? 0.015; for (const link of this.tradeLinks || []) { const a = this.getCityById(link.from); const b = this.getCityById(link.to); if (!a?.ethnicityComposition?.size || !b?.ethnicityComposition?.size) continue; const aSource = new Map(a.ethnicityComposition); const bSource = new Map(b.ethnicityComposition); this.blendEthnicComposition(a.ethnicityComposition, compositionTotal(a.ethnicityComposition), bSource, cityTradeRate * (0.5 + (link.strength || 0))); this.blendEthnicComposition(b.ethnicityComposition, compositionTotal(b.ethnicityComposition), aSource, cityTradeRate * (0.5 + (link.strength || 0))); const routeCulture = new Map(); for (const [id, count] of aSource) routeCulture.set(id, (routeCulture.get(id) || 0) + count / Math.max(1, a.population)); for (const [id, count] of bSource) routeCulture.set(id, (routeCulture.get(id) || 0) + count / Math.max(1, b.population)); const localRate = routeRate * clamp(0.6 + (link.strength || 0), 0.6, 1.8); for (const tile of link.path || []) { if ((w.population[tile] || 0) > 0.01) this.blendTileEthnicComposition(tile, routeCulture, localRate); this.forCardinalNeighbors(tile, neighbor => { if ((w.population[neighbor] || 0) > 0.01) this.blendTileEthnicComposition(neighbor, routeCulture, localRate * neighborFactor); }); } } } farmabilityAt(tile) { const t = this.world.terrain[tile]; if (t === Terrain.PLAINS) return 0.75; if (t === Terrain.FOREST) return 0.55; if (t === Terrain.DESERT) return 0.25; return 0.0; } initializePopulationCapacityCache() { const w = this.world; const { carryingCapacityBase, carryingCapacityFertility, carryingCapacityMineral } = SimConfig.population; const populationScale = SimConfig.population.populationScale; for (let i = 0; i < w.count; i++) { if (w.terrain[i] === Terrain.WATER) { this.populationCapacityStatic[i] = 0.35 * populationScale; this.populationCapacityDynamicScale[i] = 0; 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 scale = terrainFactor * populationScale; this.populationCapacityStatic[i] = ( carryingCapacityBase + w.fertility[i] * carryingCapacityFertility + w.mineral[i] * carryingCapacityMineral ) * scale; this.populationCapacityDynamicScale[i] = scale; } } updatePopulationPressure() { const w = this.world; for (let i = 0; i < w.count; i++) { const capacity = w.populationCapacity[i]; w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0; } } updatePopulationCapacityTile(i) { const w = this.world; const scale = this.populationCapacityDynamicScale[i]; const staticCapacity = this.populationCapacityStatic[i]; if (scale === 0) { w.populationCapacity[i] = staticCapacity; w.populationPressure[i] = staticCapacity > 0 ? w.population[i] / staticCapacity : 0; return; } const maxPheromone = Math.max(1, SimConfig.route.maxPheromone); const routeBonus = w.tradeRoute[i] ? 1.6 : Math.min(1, Math.max(0, w.pheromone[i] / maxPheromone)) * 0.8; const cityBonus = w.city[i] >= 0 ? 3.5 : w.cityPull[i] * 2.2; const capacity = staticCapacity + ( w.farmland[i] * SimConfig.population.carryingCapacityFarmland + routeBonus + cityBonus ) * scale; w.populationCapacity[i] = capacity; w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0; } updatePopulationCapacity() { for (let i = 0; i < this.world.count; i++) this.updatePopulationCapacityTile(i); } updateTileTechnology() { const w = this.world; const tiles = this.activePopulationSnapshot(); const humanThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1); for (const tile of tiles) { if (w.population[tile] < humanThreshold || w.terrain[tile] === Terrain.WATER) { w.farmingKnowledge[tile] = 0; w.metallurgyKnowledge[tile] = 0; continue; } const settled = w.settledPopulation[tile] || 0; const density = clamp(Math.log1p(settled) / Math.log(80), 0, 1.4); const cityPull = w.city[tile] >= 0 ? 1 : clamp(w.cityPull[tile], 0, 1); 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); } } diffuseTileTechnology() { const w = this.world; const nextFarming = this.scratchFloatA; const nextMetallurgy = this.scratchFloatB; const humanThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1); nextFarming.set(w.farmingKnowledge); nextMetallurgy.set(w.metallurgyKnowledge); const sources = this.technologySourceScratch; sources.length = 0; const marker = this.nextVisitMarker(); for (const tile of this.activePopulationTiles) { this.visitStamp[tile] = marker; sources.push(tile); } for (const tile of this.activeTradeRouteTiles) { if (this.visitStamp[tile] === marker) continue; this.visitStamp[tile] = marker; sources.push(tile); } for (const tile of sources) { if ((w.population[tile] || 0) < humanThreshold) { nextFarming[tile] = 0; nextMetallurgy[tile] = 0; continue; } this.forCardinalNeighbors(tile, n => { if (w.terrain[n] === Terrain.WATER || (w.population[n] || 0) < humanThreshold) { nextFarming[n] = 0; nextMetallurgy[n] = 0; return; } const routeBoost = w.tradeRoute[tile] || w.tradeRoute[n] ? 1.9 : 1; const popBoost = clamp(Math.log1p(w.population[n]) / Math.log(60), 0.2, 1.2); const rate = clamp((0.010 + (w.cityPull[tile] + w.cityPull[n]) * 0.004) * routeBoost * popBoost, 0.002, 0.035); 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); }); } w.farmingKnowledge.set(nextFarming); w.metallurgyKnowledge.set(nextMetallurgy); } cityTechnologyExchange() { const w = this.world; 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; } 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); } } } climateMismatch(ethnicityId, tile) { const ethnicity = this.ethnicities.get(ethnicityId); if (!ethnicity) return 0; const tempDiff = Math.abs(this.world.temperature[tile] - ethnicity.climateTemp); const humidDiff = Math.abs(this.world.humidity[tile] - ethnicity.climateHumidity); return tempDiff * 0.58 + humidDiff * 0.42; } updateWorldFields(scale = 1) { // Preserve the former biweekly pheromone diffusion semantics while keeping // pheromone work sparse. Resource regeneration is batched separately by step(). let remaining = Math.max(0, scale); let pheromoneChanged = false; while (remaining > 0) { const chunk = Math.min(2, remaining); if (this.updatePheromoneField(chunk)) pheromoneChanged = true; remaining -= chunk; } return pheromoneChanged; } regenerateResources(scale = 1) { const w = this.world; 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)); } } updatePheromoneField(scale = 1) { const w = this.world; const active = this.activePheromoneTiles; if (!active?.size) return false; const decay = Math.pow(SimConfig.route.pheromoneDecay, scale); const diffusion = SimConfig.route.pheromoneDiffusion * scale; const maxPheromone = SimConfig.route.maxPheromone; const touched = this.pheromoneTouchedScratch; touched.length = 0; const marker = this.nextVisitMarker(); const starts = w.landNeighborStarts; const neighbors = w.landNeighbors; const markTouched = tile => { if (this.visitStamp[tile] === marker) return; this.visitStamp[tile] = marker; touched.push(tile); }; for (const tile of active) { if (tile < 0 || tile >= w.count) continue; markTouched(tile); if (w.terrain[tile] === Terrain.WATER) continue; for (let n = starts[tile]; n < starts[tile + 1]; n++) markTouched(neighbors[n]); } const decayed = this.scratchFloatC; for (const tile of touched) decayed[tile] = w.pheromone[tile] * decay; const nextActive = this.nextPheromoneTiles; nextActive.clear(); for (const tile of touched) { let value; if (w.terrain[tile] === Terrain.WATER) { value = Math.min(maxPheromone, decayed[tile] * 0.85); } else { let neighborSum = 0; const begin = starts[tile]; const end = starts[tile + 1]; for (let n = begin; n < end; n++) neighborSum += decayed[neighbors[n]]; const neighborCount = end - begin; const neighborAverage = neighborCount ? neighborSum / neighborCount : decayed[tile]; value = Math.min(maxPheromone, lerp(decayed[tile], neighborAverage, diffusion)); } if (value < 0.0005) value = 0; w.pheromone[tile] = value; if (value > 0) nextActive.add(tile); } this.activePheromoneTiles = nextActive; this.nextPheromoneTiles = active; return touched.length > 0; } updateCities() { const w = this.world; w.city.fill(-1); w.farmland.fill(0); w.cityPull.fill(0); const candidates = new Map(); // Keep the previous valid capacity while rebuilding farmland/city influence. // Recomputing here would scan the whole map only to discard both bonuses; the // refreshed capacity is calculated once after the rebuilt fields are ready. const activeTiles = this.activePopulationSnapshot(); for (const tile of activeTiles) { if (w.terrain[tile] === Terrain.WATER) continue; 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); const foundingSpacing = SimConfig.city.naturalFoundingMinDistance ?? 6; if (this.getCitiesNear(x, y, Math.max(1, foundingSpacing - 1)).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); let group = candidates.get(i); if (!group) { group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map(), sourceTiles: [] }; candidates.set(i, group); } const localWeight = 0.65 + w.fertility[tile] * 0.35 + w.cityPull[tile] * 0.25 + (w.tradeRoute[tile] ? 0.2 : 0); group.count += population * localWeight; group.resources += Math.max(0, w.resource[tile]) * Math.max(0.25, population * 0.06); group.sedentary += settled; group.farming += w.farmland[tile] * settled; group.metallurgy += w.mineral[tile] * population; group.techCount += Math.max(1, population); group.sourceTiles.push(tile); const mix = this.tileEthnicMix.get(tile); if (mix) { for (const [id, count] of mix) group.ethnicities.set(id, (group.ethnicities.get(id) || 0) + count); } } let foundedThisTick = 0; const foundingStartYears = SimConfig.city.naturalFoundingStartYears ?? 160; const foundingIntervalYears = Math.max(1, SimConfig.city.naturalFoundingIntervalYears ?? 5); const canFoundCities = this.year >= years(foundingStartYears) && this.year % years(foundingIntervalYears) === 0; const foundingLimit = canFoundCities ? this.naturalCityFoundingLimit() : 0; const scoredCandidates = [...candidates].map(([tile, group]) => ({ tile, group, score: this.cityFoundingScore(tile, group) })); const bestFoundingScore = scoredCandidates.reduce((best, entry) => Math.max(best, entry.score), -Infinity); // Near-equivalent sites should not always resolve to the exact same maximum. // Keep strong suitability preference, but randomize within the broad top band. for (const entry of scoredCandidates) { const nearBest = entry.score >= bestFoundingScore - 4.0; entry.priority = nearBest ? bestFoundingScore + this.rng.range(0, 4.0) : entry.score; } scoredCandidates.sort((a, b) => b.priority - a.priority); const candidateEntries = scoredCandidates.map(entry => [entry.tile, entry.group]); for (const [i, group] of candidateEntries) { if (group.count < 24) continue; const avgSedentary = clamp(group.sedentary / Math.max(1, group.count), 0, 1); const foundingSpacing = SimConfig.city.naturalFoundingMinDistance ?? 6; let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), foundingSpacing)[0] || null; if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) { const foundingScore = this.cityFoundingScore(i, group); 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) { city.activeVisitors += group.count; city.storedResources += group.resources * 0.08; city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02; city.knowledge ??= { farming: 0, metallurgy: 0 }; city.knowledge.farming = Math.max(city.knowledge.farming * 0.998, group.farming / Math.max(1, group.techCount)); city.knowledge.metallurgy = Math.max(city.knowledge.metallurgy * 0.998, group.metallurgy / Math.max(1, group.techCount)); const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1); for (const [id, count] of group.ethnicities) { const urbanCount = this.weightedUrbanContribution(count * 0.2 * SimConfig.city.populationScale / SimConfig.population.populationScale, urbanWeight); if (urbanCount > 0) city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + urbanCount); } city.strength = city.strength * 0.96 + group.count * 0.05; } } this.absorbUrbanPopulation(); this.rebuildIndexes(); this.processCityEconomies(); const removedCities = []; this.cities = this.cities.filter(c => { c.age++; c.strength *= 0.992; const foodPerCapita = c.storedResources / Math.max(1, c.population); if (c.age > 20) { if (c.activeVisitors < 2 && foodPerCapita < 0.04) c.strength -= 0.035; else if (c.activeVisitors < 5 && foodPerCapita < 0.02) c.strength -= 0.015; if (c.population < 25 && foodPerCapita < 0.05) c.strength -= 0.018; } if (c.population < (SimConfig.city.abandonmentPopulation ?? 200) || c.strength <= 0.06) { removedCities.push(c); return false; } const radius = c.agriculturalRadius; 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 = offset.distance; const tile = w.idx(x, y); if (w.terrain[tile] !== Terrain.WATER) { 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)); } } w.city[w.idx(c.x, c.y)] = c.id; return true; }); for (const city of removedCities) { const oldPolityId = city.polityId; if (oldPolityId !== null) this.removeCityFromPolity(city); const tile = w.idx(city.x, city.y); if (w.city[tile] === city.id) w.city[tile] = -1; if (oldPolityId !== null) this.releaseCityTerritory(city, oldPolityId, 5, "cityAbandoned"); } this.rebuildIndexes(); this.updatePopulationCapacity(); if (removedCities.length) this.pruneOrphanTradeRoutes(); } naturalCityFoundingLimit() { const referenceArea = Math.max(1, SimConfig.city.naturalFoundingReferenceArea ?? (160 * 160)); const maxPerCycle = Math.max(1, SimConfig.city.naturalFoundingMaxPerCycle ?? 8); return clamp(Math.ceil(this.world.count / referenceArea), 1, maxPerCycle); } cityFoundingScore(tile, group) { 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 spacing = SimConfig.city.naturalFoundingMinDistance ?? 6; const nearbyPenalty = this.getCitiesNear(tile % w.size, Math.floor(tile / w.size), SimConfig.city.foundingCompetitionRadius ?? spacing + 3).length * 11; return group.count * (0.45 + settledShare) + pressure * 2.2 + w.fertility[tile] * 3.4 + clamp(w.resource[tile] / 18, 0, 3) + route + 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); for (const tile of group?.sourceTiles || [w.idx(city.x, city.y)]) { if (remaining <= 0) break; const available = Math.max(0, w.settledPopulation[tile] || w.population[tile] || 0); if (available <= 0) continue; const take = Math.min(available, remaining); this.removeEthnicPopulationProportionally(tile, take); remaining -= take; } } createCity(x, y, seedGroup = null, options = {}) { const scale = SimConfig.city.populationScale; const seedPopulation = Math.max(1, Math.round(options.initialPopulation ?? SimConfig.city.initialPopulation ?? 400)); const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5; const composition = new Map(); if (seedGroup) { const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1); for (const [id, count] of seedGroup.ethnicities) { const urbanCount = this.weightedUrbanContribution(count * 0.42 * scale / SimConfig.population.populationScale, urbanWeight); if (urbanCount > 0) composition.set(id, urbanCount); } } const city = { id: this.nextCity++, x, y, population: seedPopulation, storedResources: options.initialResources ?? (34 + (seedGroup?.resources || 0) * 0.55) * scale, ethnicityComposition: composition, pheromoneOutput: 0, agriculturalRadius: 2, tradeLinks: new Set(), activeVisitors: 0, age: 0, peakPopulation: seedPopulation, strength: 3, sedentaryCulture: seedSedentary, knowledge: { farming: 0, metallurgy: 0 }, supplyStress: 0, polityId: null, loyalty: 0.5, receivedAid: false, tradeValue: 0, tradeReach: 0, rebellionProtectedUntil: 0 }; if (seedGroup) this.drawPopulationFromTilesForCity(city, seedGroup, seedPopulation); this.projectCityPopulationToTiles(city); this.syncTilePopulationCulture(); return 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; return Math.max(20, capacity) * SimConfig.city.populationScale; } absorbUrbanPopulation() { // 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; const whole = Math.floor(value); return whole + (this.rng.next() < value - whole ? 1 : 0); } cityRecoveryGrowthBonus(city) { if (!city || city.population <= 0) return 0; const peak = Math.max(city.population, city.peakPopulation || city.population); if (peak <= 0) return 0; const recoveryGap = clamp((peak - city.population) / peak, 0, 0.75); return recoveryGap * (SimConfig.city.postShockRecoveryGrowth ?? 0.045); } processCityEconomies() { const w = this.world; for (const city of this.cities) { city.peakPopulation = Math.max(city.peakPopulation || 0, city.population || 0); const populationScale = SimConfig.city.populationScale; city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population / populationScale) / 4.5), 2, 14); 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 (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 - 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 * SimConfig.city.foodProductionScale); w.resource[i] -= extraction; 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 / populationScale)) * 0.018) * populationScale; city.storedResources += tradeIncome; const supportRatio = (city.activeVisitors / SimConfig.population.populationScale) / Math.max(1, city.population / populationScale); const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012); const tradeRelief = clamp(trade.value * 0.09, 0, 0.32); const upkeep = (city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09)) * (1 - tradeRelief) + knowledgeMaintenance; city.storedResources -= upkeep; const foodPerCapita = city.storedResources / Math.max(1, city.population); city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8 - trade.value * 0.12, 0, 1.8); this.innovateCityKnowledge(city, harvested); if (trade.value > 0) { city.knowledge.farming = clamp(city.knowledge.farming + trade.value * 0.00018, 0, 1); city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + trade.value * 0.00024, 0, 1); city.strength += clamp(trade.value * 0.018, 0, 0.08); city.tradeValue = trade.value; city.tradeReach = trade.reach; } else { city.tradeValue = 0; city.tradeReach = 0; } const capacity = this.cityCarryingCapacity(city); const recoveryBonus = this.cityRecoveryGrowthBonus(city); const recoveryMode = recoveryBonus > 0.002; const growthReserveRatio = recoveryMode ? 0.06 : 0.11; const growthStressLimit = recoveryMode ? 1.20 : 0.90; if (city.storedResources > city.population * growthReserveRatio && city.population > 0 && city.population < capacity && city.supplyStress < growthStressLimit) { 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(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 + recoveryBonus )))); city.population += births; city.storedResources -= births * 0.55; addBirthsToComposition(city.ethnicityComposition, births, this.rng); } if (city.population > capacity && city.supplyStress > 0.45) { const overLimit = city.population - capacity; const overCapacityLoss = Math.min(city.population, Math.max(1, Math.ceil(overLimit * (SimConfig.city.overCapacityAttrition ?? 0.020)))); city.population -= overCapacityLoss; removeFromComposition(city.ethnicityComposition, overCapacityLoss); city.strength -= Math.min(0.08, overCapacityLoss / Math.max(1, city.population + overCapacityLoss) * 0.18); } if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) { const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010))); city.population -= attrition; removeFromComposition(city.ethnicityComposition, attrition); city.strength -= city.activeVisitors === 0 ? 0.030 : 0.012; } if (city.storedResources < 0) { const deficit = Math.abs(city.storedResources); const dominant = dominantComposition(city.ethnicityComposition); const loss = Math.min(city.population, Math.ceil(deficit * 2.2 + city.population * 0.035)); 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.spawnUrbanRefugees(city, Math.min(24, Math.max(2, Math.ceil(loss / 8))), dominant); } } city.population = Math.max(0, Math.floor(city.population)); city.storedResources = clamp(city.storedResources, 0, Math.max(30, city.population * 1.4)); } } cityTradeProfile(city) { if (!city?.tradeLinks?.size || !this.tradeLinks.length) return { value: 0, reach: 0 }; let value = 0; let reach = 0; 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) continue; const distance = this.distanceBetweenCities(city, other); const distanceFactor = clamp((distance - 14) / 30, 0, 1); if (distanceFactor <= 0) continue; const partnerScale = clamp(Math.sqrt(Math.max(1, other.population || 1)) / 24, 0.35, 2.4); const pathScale = clamp((link.path?.length || distance) / 30, 0.5, 1.6); value += (link.strength || 0.12) * distanceFactor * partnerScale * pathScale; reach = Math.max(reach, distance); } return { value: clamp(value, 0, 3.2), reach }; } innovateCityKnowledge(city, harvested) { city.knowledge ??= { farming: 0, metallurgy: 0 }; const scale = SimConfig.technology.cityInnovation; const density = clamp(Math.sqrt(city.population) / 20, 0, 1.6); const foodSurplus = clamp(city.storedResources / Math.max(1, city.population) - 0.08, 0, 0.5); city.knowledge.farming = clamp( city.knowledge.farming + scale * density * (0.4 + foodSurplus * 4) + harvested * 0.000015, 0, 1 ); city.knowledge.metallurgy = clamp( city.knowledge.metallurgy + scale * density * clamp(city.pheromoneOutput, 0.1, 1.8) * 0.35, 0, 1 ); if (city.supplyStress > 0.8) { city.knowledge.farming *= 0.998; city.knowledge.metallurgy *= 0.997; } } spawnUrbanRefugees(city, count, ethnicity = null) { 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; let bestDistance = Infinity; for (const c of this.getCitiesNear(x, y, radius)) { const d = Math.abs(c.x - x) + Math.abs(c.y - y); if (d <= radius && d < bestDistance) { best = c; bestDistance = d; } } return best; } applyTerritoryExitPopulationShock(tile, rate = SimConfig.population?.territoryExitPopulationLossRate ?? 0.12) { const w = this.world; if (!w?.population || !w?.terrain || tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) return 0; const population = w.population[tile] || 0; if (population <= 0 || rate <= 0) return 0; return this.removeEthnicPopulationProportionally(tile, population * clamp(rate, 0, 0.95)); } finishTrackedTerritoryExits() { const w = this.world; let removed = 0; for (const tile of this.territoryExitCandidates) { if (w.territoryOwner[tile] < 0) removed += this.applyTerritoryExitPopulationShock(tile); } this.territoryExitCandidates.length = 0; return removed; } cityInfluence(city) { if (!city || city.population <= 0 || city.strength <= 0) return 0; const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35); return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor; } setTileOwner(tile, polityId, options = {}) { const w = this.world; if (tile == null || tile < 0 || tile >= w.count || (w.terrain[tile] === Terrain.WATER && (polityId ?? -1) >= 0)) return false; const previousOwner = w.territoryOwner[tile]; const owner = polityId ?? -1; if (owner >= 0 && !this.getPolityById(owner)) return false; w.territoryOwner[tile] = owner; w.polity[tile] = owner; if (previousOwner >= 0 && owner < 0 && !this.suppressTerritoryExitShock && options.populationShock !== false) { this.applyTerritoryExitPopulationShock(tile); } if (owner < 0) { w.control[tile] = 0; w.claim[tile] = 0; 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; if (w.polity !== w.territoryOwner) w.polity.set(w.territoryOwner); } validPolityIds() { return new Set(this.polities.map(polity => polity.id)); } // Territory follows a weighted nearest-city model. Distance is primary, while // city/state power, loyalty, trade, terrain and culture shift the boundary. clearTerritories() { const w = this.world; this.syncVisibleTerritoryOwners(); w.control.fill(0); w.claim.fill(0); w.contested.fill(0); } recomputeTerritories(options = {}) { const w = this.world; const allowOwnershipChanges = !!options.allowOwnershipChanges; const trackTerritoryExits = allowOwnershipChanges && !this.suppressTerritoryExitShock; if (trackTerritoryExits) { this.territoryExitCandidates.length = 0; for (const tile of this.activePopulationTiles) { if (w.territoryOwner[tile] >= 0) this.territoryExitCandidates.push(tile); } this.suppressTerritoryExitShock = true; } this.clearTerritories(); if (!this.polities.length || !this.cities.length) { this.enforceTerritoryConnectivity(); if (trackTerritoryExits) { this.suppressTerritoryExitShock = false; this.finishTrackedTerritoryExits(); } return; } const best = this.scratchFloatA; const second = this.scratchFloatB; const incumbent = this.scratchFloatC; const owner = this.scratchIntB; const bestSupport = this.territoryBestSupport; const incumbentSupport = this.territoryIncumbentSupport; best.fill(0); second.fill(0); incumbent.fill(0); owner.fill(-1); bestSupport.fill(0); incumbentSupport.fill(0); for (const polity of this.polities) { const polityCities = this.getPolityCities(polity); if (!polityCities.length) continue; const center = this.getCityById(polity.centerCityId) || polityCities[0]; const maxRange = clamp(Math.ceil(this.polityInfluenceRange(polity, false)), 8, 36); for (const city of polityCities) { const targetPopulationSupport = clamp( Math.sqrt(Math.max(0, city.population || 0) / (SimConfig.territory?.fullMaintenanceCityPopulation ?? 2400)), SimConfig.territory?.minimumPopulationSupport ?? 0.28, 1 ); if (city.territorySupport == null) city.territorySupport = targetPopulationSupport; else if (city.territorySupportUpdatedAt !== this.year) { city.territorySupport = lerp(city.territorySupport, targetPopulationSupport, SimConfig.territory?.supportSmoothing ?? 0.18); } city.territorySupportUpdatedAt = this.year; const populationSupport = city.territorySupport; const cityTile = w.idx(city.x, city.y); const cityEthnicity = this.dominantCityEthnicity(city); const capitalDistance = center ? this.effectiveDistance(center, city) : 0; const capitalFactor = city.id === polity.centerCityId ? 1.28 : clamp(1 - capitalDistance / 96, 0.58, 1); 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 normalizedInfluence = baseInfluence / Math.sqrt(SimConfig.city.populationScale || 1); const cityPowerReach = clamp(Math.log1p(normalizedInfluence) * (SimConfig.territory?.cityPowerWeight ?? 1.8), 2, 10); const capitalReach = city.id === polity.centerCityId ? (SimConfig.territory?.capitalReachBonus ?? 1.5) : 0; const radiusScale = (SimConfig.territory?.radiusScale ?? 0.5) * populationSupport; const radius = clamp( Math.ceil(maxRange * (0.62 + clamp(Math.sqrt(city.population || 1) / 30, 0, 0.45)) * radiusScale), 3, Math.max(3, Math.ceil(maxRange * radiusScale)) ); for (const { dx, dy } of this.getRadiusOffsets(Math.ceil(radius * Math.SQRT2))) { const distance = Math.hypot(dx, dy); if (distance > radius) continue; 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 isWater = w.terrain[tile] === Terrain.WATER; if (isWater && tile !== cityTile && !w.tradeRoute[tile] && !this.territoryWaterAnchor(tile)) continue; if ((w.population[tile] || 0) <= 0 && tile !== cityTile) continue; const routeBoost = w.tradeRoute[tile] ? 1.28 : 1; const terrainDrag = isWater ? 2.35 : w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.18 : 1); const effectiveDistance = (distance + terrainDrag * 1.4) / routeBoost; const localEthnicity = w.dominantEthnicity[tile]; const ethnicityShift = cityEthnicity !== null && localEthnicity >= 0 && cityEthnicity !== localEthnicity ? -0.65 : 0.35; const populationAnchor = clamp(Math.sqrt(Math.max(0, w.population[tile])) / 40, 0, 0.7); const maintainedReach = (SimConfig.territory?.baseCityReach ?? 9) * (0.45 + populationSupport * 0.55); const score = maintainedReach - effectiveDistance + cityPowerReach * populationSupport + capitalReach + ethnicityShift + populationAnchor; if (score <= 0.8) continue; if (w.territoryOwner[tile] === polity.id && score > incumbent[tile]) { incumbent[tile] = score; incumbentSupport[tile] = populationSupport; } if (score > best[tile]) { if (owner[tile] !== polity.id) second[tile] = best[tile]; best[tile] = score; owner[tile] = polity.id; bestSupport[tile] = populationSupport; } else if (owner[tile] !== polity.id && score > second[tile]) { second[tile] = score; } } } } const threshold = SimConfig.territory?.minimumInfluence ?? 2.4; const rebalanceOwnership = !!options.rebalanceOwnership; const switchMargin = SimConfig.territory?.ownershipSwitchMargin ?? 1.5; const activeWarPairs = new Set((this.wars || []) .filter(war => war.ended === null) .map(war => `${Math.min(war.aPolityId, war.bPolityId)}:${Math.max(war.aPolityId, war.bPolityId)}`)); for (let i = 0; i < w.count; i++) { const currentOwner = w.territoryOwner[i]; const proposedOwner = owner[i]; const cityHere = w.city[i] >= 0 ? this.getCityById(w.city[i]) : null; const protectedCityCore = cityHere?.polityId != null && cityHere.polityId !== proposedOwner; const currentCityCore = cityHere?.polityId === currentOwner; const atWarAcrossBorder = currentOwner >= 0 && proposedOwner >= 0 && currentOwner !== proposedOwner && activeWarPairs.has(`${Math.min(currentOwner, proposedOwner)}:${Math.max(currentOwner, proposedOwner)}`); const currentSupport = incumbentSupport[i] || 0; const proposedSupport = bestSupport[i] || 0; const maintenanceThreshold = threshold + (1 - currentSupport) * (SimConfig.territory?.maintenancePressure ?? 4); const expansionThreshold = threshold + (1 - proposedSupport) * (SimConfig.territory?.maintenancePressure ?? 4); if ( allowOwnershipChanges && currentOwner >= 0 && currentSupport < 1 && !currentCityCore && incumbent[i] < maintenanceThreshold ) { this.setTileOwner(i, -1); this.territorialClaims.delete(i); continue; } const shouldSwitchOwner = rebalanceOwnership && currentOwner >= 0 && proposedOwner >= 0 && proposedOwner !== currentOwner && !protectedCityCore && !atWarAcrossBorder && best[i] >= expansionThreshold && best[i] >= incumbent[i] + switchMargin + (1 - Math.min(currentSupport, proposedSupport)) * (SimConfig.territory?.smallStateSwitchPenalty ?? 2.5); if (allowOwnershipChanges && (currentOwner < 0 || shouldSwitchOwner) && proposedOwner >= 0 && best[i] >= expansionThreshold) { this.setTileOwner(i, proposedOwner, { control: clamp((best[i] - threshold) / 18, 0.12, 1), claim: clamp(best[i] / 22, 0, 1) }); if (this.territorialClaims.get(i)?.polityId !== proposedOwner) this.territorialClaims.delete(i); continue; } if (currentOwner < 0) { if (owner[i] >= 0 && best[i] >= expansionThreshold) { 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); if (allowOwnershipChanges) this.smoothTerritoryBorders(1); this.pruneUnpopulatedTerritory(); this.enforceTerritoryConnectivity(options); if (trackTerritoryExits) { this.suppressTerritoryExitShock = false; this.finishTrackedTerritoryExits(); } } pruneUnpopulatedTerritory() { const w = this.world; for (let tile = 0; tile < w.count; tile++) { if (w.territoryOwner[tile] < 0 || (w.population[tile] || 0) > 0) continue; const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; if (city && city.population > 0) continue; this.setTileOwner(tile, -1); this.territorialClaims.delete(tile); } } smoothTerritoryBorders(passes = 2) { const w = this.world; const source = this.scratchIntA; const protectedTiles = this.scratchUintA; protectedTiles.fill(0); for (const city of this.cities) { const tile = w.idx(city.x, city.y); protectedTiles[tile] = 1; for (let n = w.landNeighborStarts[tile]; n < w.landNeighborStarts[tile + 1]; n++) { protectedTiles[w.landNeighbors[n]] = 1; } } for (let pass = 0; pass < passes; pass++) { source.set(w.territoryOwner); let changed = 0; for (let tile = 0; tile < w.count; tile++) { if (w.terrain[tile] === Terrain.WATER || protectedTiles[tile] || w.tradeRoute[tile]) continue; const start = w.landNeighborStarts[tile]; const end = w.landNeighborStarts[tile + 1]; if (end - start < 6) continue; // Preserve coasts and the edge of the world. const owner = source[tile]; let candidate = -1; let same = 0; let neutral = 0; let foreign = false; for (let n = start; n < end; n++) { const neighborOwner = source[w.landNeighbors[n]]; if (neighborOwner < 0) neutral++; else if (owner >= 0) { if (neighborOwner === owner) same++; else foreign = true; } else if (candidate < 0) { candidate = neighborOwner; same++; } else if (neighborOwner === candidate) same++; else foreign = true; } const x = tile % w.size; let cardinal = 0; const target = owner >= 0 ? owner : candidate; if (x > 0 && source[tile - 1] === target) cardinal++; if (x < w.size - 1 && source[tile + 1] === target) cardinal++; if (tile >= w.size && source[tile - w.size] === target) cardinal++; if (tile < w.count - w.size && source[tile + w.size] === target) cardinal++; if (foreign) { // At a shared border, move only small leaves toward a clear local majority. // Tiles with two connections to their own country can be bridges: keep them. if (owner < 0 || cardinal > 1) continue; let majority = -1; let votes = 0; for (let n = start; n < end; n++) { const id = source[w.landNeighbors[n]]; if (!votes) { majority = id; votes = 1; } else votes += id === majority ? 1 : -1; } if (majority < 0 || majority === owner) continue; let count = 0; let touching = false; for (let n = start; n < end; n++) { const neighbor = w.landNeighbors[n]; if (source[neighbor] !== majority) continue; count++; if (neighbor === tile - w.size || neighbor === tile + w.size || (Math.floor(neighbor / w.size) === Math.floor(tile / w.size) && Math.abs(neighbor - tile) === 1)) touching = true; } if (count < 5 || !touching) continue; this.setTileOwner(tile, majority, { control: 0.22, claim: 0.35, contested: false }); this.territorialClaims.set(tile, { polityId: majority, control: 0.22, claim: 0.35, reason: "borderSmoothing", year: this.year }); changed++; continue; } if (owner < 0 && candidate >= 0 && same >= 5 && cardinal >= 2) { this.setTileOwner(tile, candidate, { control: 0.22, claim: 0.35 }); changed++; } else if (owner >= 0 && neutral >= 5 && cardinal <= 1) { // Trim only leaves; keep narrow bridges and corridors connected. this.setTileOwner(tile, -1); this.territorialClaims.delete(tile); changed++; } } if (!changed) break; } this.syncVisibleTerritoryOwners(); } applyStoredTerritorialClaims() { const w = this.world; 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; } 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; } } enforceCityTerritoryAnchors(options = {}) { const w = this.world; 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) { this.setTileOwner(tile, -1); this.territorialClaims.delete(tile); 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; if (core || (allowOwnershipChanges && w.territoryOwner[t] < 0)) this.setTileOwner(t, polity.id); if (core && this.territorialClaims.get(t)?.polityId !== polity.id) this.territorialClaims.delete(t); const ownedByCityPolity = w.territoryOwner[t] === polity.id; if (!ownedByCityPolity) { if (w.territoryOwner[t] >= 0) w.contested[t] = 1; continue; } 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; this.enforceCityTerritoryAnchors(); let removed = 0; const reachable = this.visitStamp; const marker = this.nextVisitMarker(); const queue = this.scratchIntA; let tail = 0; const validPolities = this.validPolityIds(); for (const city of this.cities) { if (city.population <= 0 || city.polityId == null || !validPolities.has(city.polityId)) continue; const seed = w.idx(city.x, city.y); if (w.terrain[seed] === Terrain.WATER || w.territoryOwner[seed] !== city.polityId || reachable[seed] === marker) continue; reachable[seed] = marker; queue[tail++] = seed; } // All countries share one traversal: each tile is visited at most once. for (let q = 0; q < tail; q++) { const tile = queue[q]; const owner = w.territoryOwner[tile]; this.forCardinalNeighbors(tile, n => { if (reachable[n] === marker || w.territoryOwner[n] !== owner || w.terrain[n] === Terrain.WATER) return; reachable[n] = marker; queue[tail++] = n; }); } for (let i = 0; i < w.count; i++) { if (w.territoryOwner[i] < 0 || reachable[i] === marker) continue; this.setTileOwner(i, -1); this.territorialClaims.delete(i); removed++; } this.territoryDebug.disconnectedTerritoryRemoved += removed; this.syncVisibleTerritoryOwners(); 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; } 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; let border = false; this.forCardinalNeighbors(tile, n => { if (w.polity[n] === polityId) return; border = true; return false; }); return border; } campaignPressureOnTerritories() { const w = this.world; const cfg = SimConfig.campaign || {}; const radius = cfg.claimRadius ?? 8; for (const campaign of this.campaigns || []) { if (campaign.status !== "active" || campaign.targetTile == null) continue; const attacker = this.getPolityById(campaign.attackerPolityId); 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 (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)); 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 { w.claim[tile] = Math.max(w.claim[tile], local); w.contested[tile] = 1; } } } } getCityById(id) { const cached = this.cityById?.get(id); if (cached) return cached; const city = this.cities.find(c => c.id === id) || null; if (city) { this.cityById ??= new Map(); this.cityById.set(id, city); } return city; } getPolityById(id) { const cached = this.polityById?.get(id); if (cached) return cached; const polity = this.polities.find(p => p.id === id) || null; if (polity) { this.polityById ??= new Map(); this.polityById.set(id, polity); } return polity; } getPolityCities(polity) { if (!polity) return []; this.polityCitiesCache ??= new Map(); const cached = this.polityCitiesCache.get(polity.id); if (cached) return cached; const living = []; for (const id of [...polity.cityIds]) { const city = this.getCityById(id); if (!city || city.population <= 0) polity.cityIds.delete(id); else living.push(city); } this.polityCitiesCache.set(polity.id, living); return living; } dominantCityEthnicity(city) { if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null; return dominantComposition(city.ethnicityComposition) || null; } sameDominantEthnicity(cityA, cityB) { const a = this.dominantCityEthnicity(cityA); const b = this.dominantCityEthnicity(cityB); return a !== null && b !== null && a === b; } distanceBetweenCities(cityA, cityB) { return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y); } hasDirectTradeConnection(cityA, cityB) { if (!cityA || !cityB) return false; if (cityA.tradeLinks?.has(cityB.id) || cityB.tradeLinks?.has(cityA.id)) return true; return (this.tradeLinks || []).some(link => { const endpoints = this.tradeLinkEndpointIds(link); return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id); }); } tradeLinkEndpointId(value) { return typeof value === "object" ? value?.id ?? null : value ?? null; } tradeLinkEndpointIds(link) { const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityAId ?? link.aId ?? link.sourceId ?? link.source ?? link.a ?? link.cityA); const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB); return from != null && to != null ? [from, to] : null; } polityTradeNeighbors(city, polityId) { if (!city || polityId == null) return []; const neighbors = []; for (const link of this.tradeLinks || []) { const endpoints = this.tradeLinkEndpointIds(link); if (!endpoints) continue; const [from, to] = endpoints; const otherId = from === city.id ? to : to === city.id ? from : null; if (otherId == null) continue; const other = this.getCityById(otherId); if (other?.polityId === polityId) neighbors.push(other); } return neighbors; } tradeAccessToCenter(city, center, polityId) { if (!city || !center || polityId == null) return { reachable: false, hops: Infinity, transitCities: [] }; if (city.id === center.id) return { reachable: true, hops: 0, transitCities: [] }; if (this.hasDirectTradeConnection(city, center)) return { reachable: true, hops: 1, transitCities: [] }; const maxDepth = SimConfig.polityAccess?.maxSearchDepth ?? 8; const visited = new Set([city.id]); const queue = [{ city, hops: 0, transitCities: [] }]; while (queue.length) { const current = queue.shift(); if (current.hops >= maxDepth) continue; for (const neighbor of this.polityTradeNeighbors(current.city, polityId)) { if (visited.has(neighbor.id)) continue; const hops = current.hops + 1; if (neighbor.id === center.id) return { reachable: true, hops, transitCities: current.transitCities }; visited.add(neighbor.id); queue.push({ city: neighbor, hops, transitCities: [...current.transitCities, neighbor] }); } } return { reachable: false, hops: Infinity, transitCities: [] }; } tradeAccessLoyaltyPenalty(city, center, polity) { const cfg = SimConfig.polityAccess; if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0; const access = this.tradeAccessToCenter(city, center, polity.id); if (!access.reachable) return clamp(cfg.noAccessPenalty, 0, 0.07); let penalty = cfg.indirectPenalty + Math.max(0, access.hops - 1) * cfg.perHopPenalty; const originEthnicity = this.dominantCityEthnicity(city); for (const transitCity of access.transitCities) { if ((transitCity.loyalty ?? 0.5) < 0.35) penalty += cfg.lowLoyaltyTransitPenalty; const transitEthnicity = this.dominantCityEthnicity(transitCity); if (originEthnicity !== null && transitEthnicity !== null && transitEthnicity !== originEthnicity) { penalty += cfg.foreignTransitPenalty; } } return clamp(penalty, 0, 0.07); } effectiveDistance(cityA, cityB) { let distance = this.distanceBetweenCities(cityA, cityB); if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55; return distance; } createPolity(centerCity) { const id = this.nextPolity++; const polity = { id, centerCityId: 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, nextWarAllowedWeek: this.year + years(SimConfig.polity.foundingProtectionYears ?? 12), legitimacy: this.rng.range(0.68, 0.94), cohesion: this.rng.range(0.58, 0.9), charisma: this.rng.range(SimConfig.polity.charismaMin ?? 0.5, SimConfig.polity.charismaMax ?? 1.5), leaderStarted: this.year, leaderTenureYears: this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, SimConfig.polity.leaderTenureMaxYears ?? 68), crisis: 0, lastCrisisYear: this.year, institutionalAging: 0, lastInstitutionalReformYear: this.year }; 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.enforcePolityTreasuryCap(polity); this.claimPolityCoreTerritory(polity, centerCity, 4, "founding"); this.ensurePolityHistory(polity); this.samplePolityHistory(polity); return polity; } ensurePolityHistory(polity) { if (!polity) return null; if (!this.polityHistory.has(polity.id)) { this.polityHistory.set(polity.id, { id: polity.id, color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62), founded: polity.founded ?? this.year, ended: null, active: true, centerCityId: polity.centerCityId ?? null, fate: null, samples: [], events: [], peakPower: 0, peakYear: null, peakEventYear: null }); } const history = this.polityHistory.get(polity.id); history.events ??= []; history.peakPower ??= 0; history.peakYear ??= null; history.peakEventYear ??= null; return history; } averagePolityLoyalty(polity) { const cities = this.getPolityCities(polity); const subordinates = cities.filter(city => city.id !== polity.centerCityId); if (!subordinates.length) return 1; return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length; } samplePolityHistory(polity) { const history = this.ensurePolityHistory(polity); if (!history) return; const cities = this.getPolityCities(polity); const population = cities.reduce((sum, city) => sum + city.population, 0); const treasury = polity.treasury || 0; const avgLoyalty = this.averagePolityLoyalty(polity); const fallbackPower = Math.sqrt(population) * 1.35 + Math.sqrt(Math.max(0, treasury)) * 1.15 + avgLoyalty * 16; history.centerCityId = polity.centerCityId ?? history.centerCityId; const sample = { year: this.year, cities: cities.length, population, treasury, avgLoyalty, institutionalAging: clamp(polity.institutionalAging ?? 0, 0, 1), loyaltyCap: this.polityLoyaltyCap ? this.polityLoyaltyCap(polity) : 1, power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower }; history.samples.push(sample); if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) { history.peakPower = sample.power; history.peakYear = this.year; } const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears; while (history.samples.length > maxSamples) history.samples.shift(); } samplePolityHistories() { for (const polity of this.polities) this.samplePolityHistory(polity); } sampleWorldHistory() { let regionalPopulation = 0; for (const tile of this.activePopulationTiles) regionalPopulation += Math.max(0, this.world.population[tile] || 0); const urbanPopulation = this.cities.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); const sample = { year: this.year, regionalPopulation, urbanPopulation, cities: this.cities.length, polities: this.polities.length, ethnicities: [...this.ethnicities.values()].filter(ethnicity => (ethnicity.population || 0) > 0).length }; const last = this.worldHistory[this.worldHistory.length - 1]; if (last?.year === this.year) this.worldHistory[this.worldHistory.length - 1] = sample; else this.worldHistory.push(sample); const maxSamples = (SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears) * 4; while (this.worldHistory.length > maxSamples) this.worldHistory.shift(); } addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) { let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null; if (!history) { const polity = this.getPolityById(polityId); if (polity) history = this.ensurePolityHistory(polity); } if (!history) return; history.events ??= []; const sameYearDuplicate = history.events.some(event => event.type === type && event.year === year && JSON.stringify(event.data || {}) === JSON.stringify(data || {}) ); if (sameYearDuplicate) return; history.events.push({ year, type, importance, data }); while (history.events.length > 120) history.events.shift(); } addGraphEvent(type, year = this.year, data = {}, importance = 2) { this.graphEvents ??= []; const duplicate = this.graphEvents.some(event => event.type === type && event.year === year && JSON.stringify(event.data || {}) === JSON.stringify(data || {}) ); if (duplicate) return; this.graphEvents.push({ year, type, importance, data }); while (this.graphEvents.length > 160) this.graphEvents.shift(); } selectNewLeader(polity, forced = false) { if (!polity) return; const minCharisma = SimConfig.polity.charismaMin ?? 0.5; const maxCharisma = SimConfig.polity.charismaMax ?? 1.5; const avgCharisma = SimConfig.polity.charismaAverage ?? 1; const previousCharisma = clamp(polity.charisma ?? avgCharisma, minCharisma, maxCharisma); const center = this.getCityById(polity.centerCityId); const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5; const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3; const randomLeader = this.rng.range(minCharisma, maxCharisma); const continuity = forced ? 0.18 : 0.34; const institutionalPull = 0.82 + institutionalBias * 0.36; const nextCharisma = clamp( previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull, minCharisma, maxCharisma ); polity.charisma = nextCharisma; polity.leaderStarted = this.year; polity.leaderTenureYears = this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 22, SimConfig.polity.leaderTenureMaxYears ?? 72); const change = nextCharisma - previousCharisma; polity.legitimacy = clamp((polity.legitimacy ?? 0.7) + change * 0.08 - (forced ? 0.035 : 0), 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) + change * 0.045 - (forced ? 0.020 : 0), 0, 1); if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5); } updatePolityLeaders() { for (const polity of this.polities) { polity.charisma = clamp(polity.charisma ?? (SimConfig.polity.charismaAverage ?? 1), SimConfig.polity.charismaMin ?? 0.5, SimConfig.polity.charismaMax ?? 1.5); polity.leaderStarted ??= polity.founded ?? this.year; polity.leaderTenureYears ??= this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, SimConfig.polity.leaderTenureMaxYears ?? 68); const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR; const oldLeader = Math.max(0, tenureYears - polity.leaderTenureYears); const crisisPressure = clamp((polity.crisis || 0) * 0.045, 0, 0.09); const successionChance = clamp(oldLeader * 0.018 + crisisPressure, 0, 0.36); if (successionChance > 0 && this.rng.next() < successionChance) { this.selectNewLeader(polity, (polity.crisis || 0) > 0.85); } } } maybeSpawnDisaster() { const config = SimConfig.disaster; const interval = years(config?.checkIntervalYears ?? 8); if (!interval || this.year % interval !== 0) return; if (this.rng.next() > (config?.baseChance ?? 0.38)) return; this.spawnDisaster(); } spawnDisaster() { const config = SimConfig.disaster; const large = this.rng.next() < (config?.rareLargeChance ?? 0.10); const radius = large ? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54) : this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34); const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95); this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity); } applyDisaster(x, y, radius, intensity) { const config = SimConfig.disaster; const w = this.world; const scaledIntensity = intensity * (config?.damageScale ?? 1); const majorLossRate = config?.majorLossRate ?? 0.05; const affectedPolityMap = new Map(); const affectedPolityIds = new Set(); for (const city of this.cities) { if (city.polityId === null) continue; if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId); } const polityPopulationBefore = new Map(); for (const id of affectedPolityIds) { const polity = this.getPolityById(id); if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity)); } let affectedCities = 0; let totalPopulationLoss = 0; let regionalPopulationLoss = 0; for (const city of this.cities) { const d = Math.hypot(city.x - x, city.y - y); if (d > radius) continue; const falloff = 1 - d / radius; const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); if (damage <= 0) continue; affectedCities++; const popBefore = city.population || 0; const lossRate = clamp(damage * 0.25, 0, 0.45); const loss = Math.floor(popBefore * lossRate); city.population = Math.max(0, popBefore - loss); if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss); city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55)); city.strength = Math.max(0, city.strength - damage * 0.38); this.deaths += Math.floor(loss * 0.35); totalPopulationLoss += loss; if (city.polityId !== null) { let entry = affectedPolityMap.get(city.polityId); if (!entry) { entry = { polityId: city.polityId, populationBefore: polityPopulationBefore.get(city.polityId) || 0, populationLoss: 0, cityIds: new Set() }; affectedPolityMap.set(city.polityId, entry); } entry.populationLoss += loss; entry.cityIds.add(city.id); const polity = this.getPolityById(city.polityId); if (polity) { polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1); } } } const minX = Math.max(0, Math.floor(x - radius)); const maxX = Math.min(w.size - 1, Math.ceil(x + radius)); const minY = Math.max(0, Math.floor(y - radius)); const maxY = Math.min(w.size - 1, Math.ceil(y + radius)); for (let ty = minY; ty <= maxY; ty++) { for (let tx = minX; tx <= maxX; tx++) { const d = Math.hypot(tx - x, ty - y); if (d > radius) continue; const falloff = 1 - d / radius; const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); const tile = w.idx(tx, ty); if (w.terrain[tile] !== Terrain.WATER && (w.population[tile] || 0) > 0) { const regionalLossRate = clamp( damage * (config?.regionalPopulationLossRate ?? 0.42), 0, config?.regionalPopulationLossMax ?? 0.65 ); const loss = this.removeEthnicPopulationProportionally(tile, w.population[tile] * regionalLossRate); regionalPopulationLoss += loss; totalPopulationLoss += loss; this.deaths += Math.floor(loss * 0.40); } w.resource[tile] *= (1 - damage * 0.45); w.farmland[tile] *= (1 - damage * 0.35); w.pheromone[tile] *= (1 - damage * 0.25); } } const affectedPolities = [...affectedPolityMap.values()].map(entry => { const populationBefore = Math.max(1, entry.populationBefore || 0); const lossRate = entry.populationLoss / populationBefore; return { polityId: entry.polityId, populationBefore: entry.populationBefore, populationLoss: entry.populationLoss, lossRate, cityIds: [...entry.cityIds] }; }); const id = this.nextDisaster++; const record = { id, year: this.year, x, y, radius, intensity, expires: this.year + years(config?.visualDurationYears ?? 18), affectedCities, totalPopulationLoss, regionalPopulationLoss, affectedPolities }; this.disasters.push(record); this.disasterHistory.push(record); for (const entry of affectedPolities) { if (entry.lossRate >= majorLossRate) { this.addPolityEvent(entry.polityId, "disaster", this.year, { disasterId: id, populationLoss: entry.populationLoss, lossRate: entry.lossRate, affectedCities: entry.cityIds.length }, entry.lossRate >= majorLossRate * 2 ? 3 : 2); } } this.disasters = this.disasters .filter(disaster => this.year <= disaster.expires) .slice(-(config?.maxActiveVisuals ?? 12)); while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift(); } triggerManualDisasterAt(x, y) { const w = this.world; const tx = clamp(Math.floor(x), 0, w.size - 1); const ty = clamp(Math.floor(y), 0, w.size - 1); const cfg = SimConfig.disaster || {}; const radius = this.rng.range(Math.max(5, cfg.minRadius ?? 6), Math.min(24, cfg.maxRadius ?? 34)); const intensity = this.rng.range(Math.max(0.48, cfg.minIntensity ?? 0.25), Math.min(0.82, cfg.maxIntensity ?? 0.95)); const beforeId = this.nextDisaster; this.applyDisaster(tx, ty, radius, intensity); const record = this.disasterHistory.find(item => item.id === beforeId) || this.disasterHistory[this.disasterHistory.length - 1]; return { ok: true, record, x: tx, y: ty, radius, intensity }; } manualCityEthnicitySeed(tile) { const w = this.world; const local = this.tileEthnicMix.get(tile); if (local?.size) return new Map(local); const dominant = w.dominantEthnicity[tile]; if (dominant >= 0) return new Map([[dominant, 1]]); const tx = tile % w.size; const ty = Math.floor(tile / w.size); let nearest = null; let nearestDistance = Infinity; for (const [cultureTile, mix] of this.tileEthnicMix) { if (!mix?.size) continue; const distance = Math.abs(cultureTile % w.size - tx) + Math.abs(Math.floor(cultureTile / w.size) - ty); if (distance < nearestDistance) { nearest = mix; nearestDistance = distance; if (distance <= 2) break; } } if (nearest?.size) return new Map(nearest); let nearestCity = null; let cityDistance = Infinity; for (const city of this.cities) { if (!city.ethnicityComposition?.size) continue; const distance = Math.abs(city.x - tx) + Math.abs(city.y - ty); if (distance < cityDistance) { nearestCity = city; cityDistance = distance; } } if (nearestCity) return new Map(nearestCity.ethnicityComposition); const ethnicityId = this.createEthnicity(0, { temperature: w.temperature[tile], humidity: w.humidity[tile] }); const ethnicity = this.ethnicities.get(ethnicityId); if (ethnicity) ethnicity.averageTraits = this.randomTraits(); return new Map([[ethnicityId, 1]]); } applyCityEthnicitySeed(city, seed, population) { city.ethnicityComposition ??= new Map(); city.ethnicityComposition.clear(); let total = 0; for (const count of seed?.values?.() || []) total += Math.max(0, count || 0); if (total <= 0) return; let assigned = 0; const entries = [...seed.entries()]; for (let index = 0; index < entries.length; index++) { const [id, count] = entries[index]; const amount = index === entries.length - 1 ? Math.max(0, population - assigned) : Math.max(0, Math.round(population * Math.max(0, count || 0) / total)); if (amount <= 0) continue; city.ethnicityComposition.set(id, amount); assigned += amount; } } buildCityAt(x, y) { const w = this.world; const tx = clamp(Math.floor(x), 0, w.size - 1); const ty = clamp(Math.floor(y), 0, w.size - 1); const tile = w.idx(tx, ty); // Manual intervention bypasses natural spacing and the automatic-city cap, // but cities still require land. if (w.terrain[tile] === Terrain.WATER) return { ok: false, reason: "water", x: tx, y: ty }; const ethnicitySeed = this.manualCityEthnicitySeed(tile); const city = this.createCity(tx, ty); const basePopulation = city.population; const targetPopulation = Math.max(1, SimConfig.city.initialPopulation ?? 400); this.applyCityEthnicitySeed(city, ethnicitySeed, targetPopulation); city.population = targetPopulation; city.peakPopulation = targetPopulation; city.storedResources = Math.max(city.storedResources, targetPopulation * 0.20); city.strength = Math.max(city.strength, 6); city.activeVisitors = Math.max(city.activeVisitors, 12); if (targetPopulation > basePopulation) { city.population = targetPopulation - basePopulation; this.projectCityPopulationToTiles(city); city.population = targetPopulation; } this.cities.push(city); this.rebuildIndexes(); w.city[tile] = city.id; this.assignNewCityToTerritoryOwner(city, tile, { explicit: true }); if (city.polityId !== null) { const polity = this.getPolityById(city.polityId); if (polity) this.claimPolityCoreTerritory(polity, city, 3, "manualCity"); } this.updatePopulationCapacity(); this.syncTilePopulationCulture(); return { ok: true, city }; } markPolityEnded(polity, reason = "dissolved") { if (!polity) return; const history = this.ensurePolityHistory(polity); if (!history) return; this.samplePolityHistory(polity); history.ended = this.year; history.active = false; history.fate = reason; history.centerCityId = polity.centerCityId ?? history.centerCityId; this.polityHistory.delete(polity.id); this.deadPolityHistories.push(history); while (this.deadPolityHistories.length > 20) this.deadPolityHistories.shift(); } getAllPolityHistories() { return [...this.deadPolityHistories, ...this.polityHistory.values()] .sort((a, b) => (a.founded - b.founded) || (a.id - b.id)); } addCityToPolity(city, polity, initialLoyalty = 0.5) { if (!city || !polity) return; if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city); city.polityId = polity.id; city.loyalty = clamp(initialLoyalty, 0, 1); city.receivedAid = false; polity.cityIds.add(city.id); this.polityCitiesCache?.delete(polity.id); } removeCityFromPolity(city) { if (!city || city.polityId === null) return; const oldPolity = this.getPolityById(city.polityId); if (oldPolity) { oldPolity.cityIds.delete(city.id); this.polityCitiesCache?.delete(oldPolity.id); } city.polityId = null; city.loyalty = 0.45; city.receivedAid = false; } isPolityAtWar(polityId) { return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)); } warCountForPolity(polityId) { return this.wars.filter(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)).length; } canBeWarTarget(polityId) { return this.warCountForPolity(polityId) < 3; } getWarBetween(aId, bId) { return this.wars.find(war => war.ended === null && ((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId)) ) || null; } startWar(a, b) { if (!a || !b || a.id === b.id) return null; if (this.year < (a.nextWarAllowedWeek ?? 0) || this.year < (b.nextWarAllowedWeek ?? 0)) return null; if (this.wars.filter(war => war.ended === null).length >= SimConfig.war.maxActiveWars) 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.9, 1.4), exhaustionA: 0, exhaustionB: 0, scoreA: 0, scoreB: 0, capturedByA: 0, capturedByB: 0, cityPressureById: new Map(), lastFrontAdvanceYear: new Map(), ended: null }; this.wars.push(war); this.addPolityEvent(a.id, "war", this.year, { outcome: "started", targetPolityId: b.id }, 2); this.addPolityEvent(b.id, "war", this.year, { outcome: "started", targetPolityId: a.id }, 2); 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; for (const [id, other] of [[war.aPolityId, war.bPolityId], [war.bPolityId, war.aPolityId]]) { const polity = this.getPolityById(id); if (polity) polity.nextWarAllowedWeek = Math.max(polity.nextWarAllowedWeek || 0, this.year + SimConfig.war.cooldownWeeks); this.addPolityEvent(id, "war", this.year, { outcome: "ended", targetPolityId: other }, 2); } for (const campaign of this.campaigns) { if (campaign.status === "active" && campaign.type === "border_war" && ((campaign.attackerPolityId === war.aPolityId && campaign.defenderPolityId === war.bPolityId) || (campaign.attackerPolityId === war.bPolityId && campaign.defenderPolityId === war.aPolityId))) campaign.status = "ceased"; } } totalPolityPopulation(polity) { return this.getPolityCities(polity) .reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); } averagePolityTechnology(polity) { const cities = this.getPolityCities(polity); if (!cities.length) return 0; let total = 0; let count = 0; for (const city of cities) { if (!city?.knowledge) continue; total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5; count++; } return count ? total / count : 0; } polityPower(polity) { const cities = this.getPolityCities(polity); if (!cities.length) return 0; const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0); const treasury = Math.max(0, polity.treasury || 0); const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5; const tech = this.averagePolityTechnology(polity); const instability = this.polityInstability ? this.polityInstability(polity) : 0; const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0; const basePower = Math.max(0, Math.sqrt(population) * 1.35 + Math.sqrt(treasury) * 1.15 + avgLoyalty * 16 + tech * 24 - instability * 12 - agePressure * 6 ); return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5); } polityInfluenceRange(polity, wartime = false) { const power = this.polityPower(polity); const base = 18 + Math.sqrt(power) * 2.2; const range = wartime ? base * 1.15 : base * 0.75; return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42); } nearestPolityCity(polity, city) { if (!polity || !city) return null; let best = null; let bestDistance = Infinity; for (const source of this.getPolityCities(polity)) { const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city); if (distance < bestDistance) { best = source; bestDistance = distance; } } return best; } polityConnectionToCity(polity, city) { if (!polity || !city) return { source: null, distance: Infinity, connected: false }; let source = null; let distance = Infinity; for (const c of this.getPolityCities(polity)) { if (c.id === city.id) continue; const d = this.effectiveDistance(c, city); if (d < distance) { source = c; distance = d; } } const limit = SimConfig.polity.logisticsDistance; return { source, distance, connected: !!source && distance <= limit }; } polityDistance(a, b) { const aCities = this.getPolityCities(a); const bCities = this.getPolityCities(b); if (!aCities.length || !bCities.length) return Infinity; let best = Infinity; for (const aCity of aCities) { for (const bCity of bCities) { const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity); if (distance < best) best = distance; } } return best; } polityTreasuryCap(polity) { if (!polity) return 0; const perPopulation = Math.max(0, SimConfig.polity?.treasuryCapPerPopulation ?? 0.30); return Math.max(0, this.totalPolityPopulation(polity) * perPopulation); } enforcePolityTreasuryCap(polity) { if (!polity) return 0; const cap = this.polityTreasuryCap(polity); polity.treasury = clamp(polity.treasury || 0, 0, cap); return cap; } enforcePolityTreasuryCaps() { for (const polity of this.polities) this.enforcePolityTreasuryCap(polity); } treasuryCityInvestmentProfile(cost) { const cfg = SimConfig.polity || {}; const safeCost = Math.max(0, cost || 0); const basePopulation = Math.max(1, SimConfig.city.initialPopulation ?? 400); const populationPerCost = Math.max(0, cfg.treasuryCityPopulationPerCost ?? 0.18); const reservePerCost = Math.max(0, cfg.treasuryCityReservePerCost ?? 0.45); const population = Math.max(basePopulation, Math.round(basePopulation + safeCost * populationPerCost)); const resources = 34 * SimConfig.city.populationScale + safeCost * reservePerCost + Math.max(0, population - basePopulation) * 0.22; return { population, resources }; } findTreasuryCityFoundingSite(polity) { if (!polity) return -1; const w = this.world; const cities = this.getPolityCities(polity); if (!cities.length) return -1; const spacing = Math.max(1, SimConfig.city.naturalFoundingMinDistance ?? 6); const radius = Math.max(spacing + 1, SimConfig.polity?.treasuryCityFoundingSearchRadius ?? 20); const candidates = []; const visited = new Set(); for (const source of cities) { for (const { dx, dy, distance } of this.getRadiusOffsets(radius, false)) { if (distance < spacing) continue; const x = source.x + dx; const y = source.y + dy; if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; const tile = w.idx(x, y); if (visited.has(tile)) continue; visited.add(tile); if (w.terrain[tile] === Terrain.WATER || w.city[tile] >= 0) continue; if (w.territoryOwner[tile] !== polity.id) continue; if (this.getCitiesNear(x, y, spacing - 1).length) continue; const route = w.tradeRoute[tile] ? 1.25 : clamp((w.pheromone[tile] || 0) / 12, 0, 0.8); const score = (w.fertility[tile] || 0) * 3.2 + clamp((w.resource[tile] || 0) / 20, 0, 2.5) + (w.populationPressure[tile] || 0) * 1.6 + (w.farmland[tile] || 0) * 1.4 + (w.control[tile] || 0) * 1.2 + route - (w.move[tile] || 1) * 0.75; candidates.push({ tile, score }); } } if (!candidates.length) return -1; let bestScore = -Infinity; for (const candidate of candidates) if (candidate.score > bestScore) bestScore = candidate.score; const scoreWindow = Math.max(0.2, SimConfig.polity?.treasuryCitySiteScoreWindow ?? 6.0); const floor = bestScore - scoreWindow; let totalWeight = 0; for (const candidate of candidates) { if (candidate.score < floor) continue; candidate.weight = Math.pow(Math.max(0.20, candidate.score - floor + 0.20), 1.10); totalWeight += candidate.weight; } let roll = this.rng.next() * totalWeight; let fallback = candidates[0].tile; for (const candidate of candidates) { if (candidate.score < floor) continue; fallback = candidate.tile; roll -= candidate.weight; if (roll <= 0) return candidate.tile; } return fallback; } maybeBuildTreasuryCities() { const cfg = SimConfig.polity || {}; const chance = clamp(cfg.treasuryCityFoundingChance ?? 0.14, 0, 1); const costRatio = clamp(cfg.treasuryCityFoundingCostRatio ?? 0.35, 0, 1); if (chance <= 0 || costRatio <= 0 || this.cities.length >= SimConfig.city.maxCities) return 0; let founded = 0; for (const polity of [...this.polities]) { if (this.cities.length >= SimConfig.city.maxCities) break; const cap = this.polityTreasuryCap(polity); if (cap <= 0 || (polity.treasury || 0) + 1e-6 < cap) continue; if (this.rng.next() >= chance) continue; const tile = this.findTreasuryCityFoundingSite(polity); if (tile < 0) continue; const cost = Math.min(polity.treasury || 0, cap * costRatio); if (cost <= 0) continue; const x = tile % this.world.size; const y = Math.floor(tile / this.world.size); const ethnicitySeed = this.manualCityEthnicitySeed(tile); const investment = this.treasuryCityInvestmentProfile(cost); const city = this.createCity(x, y, null, { initialPopulation: investment.population, initialResources: investment.resources }); this.applyCityEthnicitySeed(city, ethnicitySeed, city.population); city.strength = Math.max(city.strength, 4.5); city.activeVisitors = Math.max(city.activeVisitors, 6); 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.world.city[tile] = city.id; this.addCityToPolity(city, polity, 0.72); this.claimPolityCoreTerritory(polity, city, 3, "treasuryCity"); polity.treasury = Math.max(0, (polity.treasury || 0) - cost); founded++; } if (founded) { this.rebuildIndexes(); this.updatePopulationCapacity(); this.syncTilePopulationCulture(); } return founded; } polityAge(polity) { return (this.year - (polity?.founded ?? this.year)) / WEEKS_PER_YEAR; } polityAgePressure(polity) { const age = this.polityAge(polity); if (age < 80) return 0; const x = (age - 80) / 420; return clamp(1 - Math.exp(-x), 0, 1.35); } polityInstitutionalAging(polity) { return clamp(polity?.institutionalAging ?? 0, 0, 1); } polityLoyaltyCap(polity) { return clamp(1 - this.polityInstitutionalAging(polity), 0, 1); } updateInstitutionalAging() { const cfg = SimConfig.polity || {}; const annualIncrease = Math.max(0, cfg.institutionalAgingAnnualIncrease ?? 0.0015); for (const polity of this.polities) { polity.institutionalAging = this.polityInstitutionalAging(polity); if (this.year <= (polity.founded ?? this.year)) continue; polity.institutionalAging = clamp(polity.institutionalAging + annualIncrease, 0, 1); } } maybeTriggerInstitutionalReforms() { const cfg = SimConfig.polity || {}; const threshold = clamp(cfg.institutionalReformThreshold ?? 0.30, 0, 1); const cooldown = years(Math.max(0, cfg.institutionalReformCooldownYears ?? 50)); for (const polity of this.polities) { const before = this.polityInstitutionalAging(polity); if (before <= threshold) continue; if (this.year - (polity.lastInstitutionalReformYear ?? polity.founded ?? 0) < cooldown) continue; const chance = clamp( (cfg.institutionalReformBaseChance ?? 0.008) + before * (cfg.institutionalReformChanceScale ?? 0.10), 0, cfg.institutionalReformMaxChance ?? 0.12 ); if (this.rng.next() >= chance) continue; const changeRate = this.rng.range( cfg.institutionalReformMinChange ?? -0.50, cfg.institutionalReformMaxChange ?? 0.30 ); const after = clamp(before * (1 + changeRate), 0, 1); polity.institutionalAging = after; polity.lastInstitutionalReformYear = this.year; this.addPolityEvent(polity.id, "institutionalReform", this.year, { before, after, changeRate, loyaltyCapBefore: clamp(1 - before, 0, 1), loyaltyCapAfter: clamp(1 - after, 0, 1) }, 3); } } polityOverextension(polity) { const cities = this.getPolityCities(polity); const center = this.getCityById(polity.centerCityId); if (!center || cities.length <= 1) return 0; const cityCountPressure = Math.max(0, cities.length - 5) * 0.08; let distanceSum = 0; let count = 0; for (const city of cities) { if (city.id === center.id) continue; distanceSum += this.effectiveDistance(center, city); count++; } const avgDistance = count ? distanceSum / count : 0; const distancePressure = Math.max(0, avgDistance - 28) * 0.008; return clamp(cityCountPressure + distancePressure, 0, 2); } polityResourceStress(polity) { const cities = this.getPolityCities(polity); if (!cities.length) return 1; let poor = 0; let supplyStress = 0; for (const city of cities) { const perCapita = city.storedResources / Math.max(1, city.population); if (perCapita < 0.06) poor++; supplyStress += city.supplyStress || 0; } const povertyRate = poor / cities.length; const avgSupplyStress = supplyStress / cities.length; const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length); const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0; return clamp(povertyRate * 0.55 + avgSupplyStress * 0.35 + treasuryStress * 0.28, 0, 1.5); } polityLogisticsStress(polity) { const cities = this.getPolityCities(polity); const center = this.getCityById(polity.centerCityId); if (!center || cities.length <= 1) return 0; let stress = 0; let count = 0; for (const city of cities) { if (city.id === center.id) continue; const distance = this.effectiveDistance(center, city); const food = city.storedResources / Math.max(1, city.population); const distanceStress = Math.max(0, distance - SimConfig.polity.logisticsDistance) * 0.012; const foodStress = Math.max(0, SimConfig.polity.minimumPerCapitaFood - food) * 4.5; const routeRelief = this.hasDirectTradeConnection(center, city) ? 0.72 : 1; stress += (distanceStress + foodStress + (city.supplyStress || 0) * 0.35) * routeRelief; count++; } return clamp(stress / Math.max(1, count), 0, 1.8); } polityEthnicFragmentation(polity) { const cities = this.getPolityCities(polity); if (cities.length <= 1) return 0; const counts = new Map(); for (const city of cities) { const e = this.dominantCityEthnicity(city); if (e !== null) counts.set(e, (counts.get(e) || 0) + 1); } if (!counts.size) return 0; let max = 0; for (const v of counts.values()) max = Math.max(max, v); const dominantShare = max / cities.length; return clamp(1 - dominantShare, 0, 1); } polityInstability(polity) { const agePressure = this.polityAgePressure(polity); const overextension = this.polityOverextension(polity); const resourceStress = this.polityResourceStress(polity); const logisticsStress = this.polityLogisticsStress(polity); const fragmentation = this.polityEthnicFragmentation(polity); const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85; const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.62; return clamp( agePressure * 0.35 + overextension * 0.24 + resourceStress * 0.38 + logisticsStress * 0.30 + fragmentation * 0.22 + (polity.crisis || 0) * 0.42 - legitimacyBuffer - cohesionBuffer, 0, 2.5 ); } 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); if (!localLeader || this.rng.next() > 0.22) continue; this.createPolity(city); formed++; } } // Phase 1B: campaigns are discrete targeted events. Influence/control can show // continuous territory, but new holdings come from these active campaign objects. maybeStartCampaigns() { const cfg = SimConfig.campaign || {}; const interval = years(cfg.checkIntervalYears ?? 3); if (!interval || this.year % interval !== 0) return; const active = (this.campaigns || []).filter(c => c.status === "active").length; if (active >= (cfg.maxActiveCampaigns ?? 12)) return; for (const polity of this.polities) { if ((this.campaigns || []).filter(c => c.status === "active").length >= (cfg.maxActiveCampaigns ?? 12)) break; if (!this.getPolityCities(polity).length) continue; const cooldown = this.year - (polity.lastCampaignYear ?? -Infinity); if (cooldown < years(cfg.minCooldownYears ?? 12)) continue; if (polity.nextCampaignAllowedYear != null && this.year < polity.nextCampaignAllowedYear) continue; const chance = this.campaignStartChance(polity); if (this.rng.next() > chance) continue; const target = this.chooseCampaignTarget(polity); if (target) this.startCampaign(polity, target); } } campaignStartChance(polity) { const cfg = SimConfig.campaign || {}; const power = this.polityPower(polity); const powerFactor = clamp(power / 80, 0.5, 2.0); const cohesion = clamp(polity.cohesion ?? 0.6, 0.25, 1.2); const legitimacy = clamp(polity.legitimacy ?? 0.7, 0.25, 1.2); const charisma = clamp(polity.charisma ?? 1, 0.5, 1.8); const crisis = clamp(polity.crisis || 0, 0, 1.5); const overextension = this.polityOverextension ? this.polityOverextension(polity) : 0; const overextensionFactor = clamp(1.2 - overextension, 0.1, 1.2); const crisisFactor = clamp(1.1 - crisis, 0.05, 1.1); const treasuryFactor = clamp((polity.treasury || 0) / Math.max(8, this.getPolityCities(polity).length * 7), 0.45, 1.35); const noise = this.rng.range(0.72, 1.24); return clamp((cfg.baseChance ?? 0.18) * powerFactor * cohesion * legitimacy * charisma * overextensionFactor * crisisFactor * treasuryFactor * noise, 0, 0.68); } weightedPick(entries) { const total = entries.reduce((sum, entry) => sum + Math.max(0, entry.weight || 0), 0); if (total <= 0) return null; let roll = this.rng.next() * total; for (const entry of entries) { roll -= Math.max(0, entry.weight || 0); if (roll <= 0) return entry; } return entries[entries.length - 1] || null; } chooseCampaignTarget(polity) { const cfg = SimConfig.campaign || {}; const groups = [ { kind: "frontier_colonization", weight: cfg.frontierTargetChance ?? 0.35, items: this.frontierCampaignTargets(polity) }, { kind: "nonstate_subjugation", weight: cfg.nonStateTargetChance ?? 0.30, items: this.nonStateCampaignTargets(polity) }, { kind: "independent_city_annexation", weight: cfg.independentCityTargetChance ?? 0.20, items: this.independentCityCampaignTargets(polity) }, { kind: "border_war", weight: cfg.polityTargetChance ?? 0.15, items: this.borderWarCampaignTargets(polity) } ].filter(group => group.items.length && group.weight > 0); const group = this.weightedPick(groups); if (!group) return null; const target = this.weightedPick(group.items); return target ? { ...target, type: group.kind } : null; } noisyTargetWeight(score) { return Math.max(0, score * this.rng.range(0.45, 1.65) + this.rng.range(-0.08, 0.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 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) }; }); } 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] || 0; const ethnicity = w.dominantEthnicity[tile]; if (population < 2 && ethnicity < 0) return null; 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 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, weight: this.noisyTargetWeight(score) }; }); } independentCityCampaignTargets(polity) { const cfg = SimConfig.campaign || {}; const targets = []; 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: tile, targetCityId: city.id, connection, weight: this.noisyTargetWeight(score) }); } return targets; } borderWarCampaignTargets(polity) { const cfg = SimConfig.campaign || {}; const targets = []; for (const other of this.polities) { if (other.id === polity.id || !this.canBeWarTarget(other.id)) continue; 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; const borderValue = this.borderFriction(polity, other, distance) + contested + targetCityValue; const riskAppetite = this.rng.next() < 0.10 ? this.rng.range(0.4, 1.1) : 0; const score = borderValue - defenderPower / 90 - distance * 0.018 + this.polityPower(polity) / 120 + riskAppetite; targets.push({ targetTile: city ? this.world.idx(city.x, city.y) : border.tile, targetCityId: city?.id ?? null, defenderPolityId: other.id, weight: this.noisyTargetWeight(score) }); } return targets; } sampleTileCampaignTargets(polity, scorer) { const w = this.world; const cfg = SimConfig.campaign || {}; const targets = []; const seen = new Set(); const cities = this.getPolityCities(polity); const maxDistance = cfg.maxTargetDistance ?? 48; const tries = clamp(80 + cities.length * 22, 90, 260); for (let n = 0; n < tries; n++) { const source = cities[this.rng.int(cities.length)]; if (!source) break; const radius = Math.ceil(maxDistance); const x = clamp(source.x + this.rng.int(radius * 2 + 1) - radius, 0, w.size - 1); const y = clamp(source.y + this.rng.int(radius * 2 + 1) - radius, 0, w.size - 1); const tile = w.idx(x, y); if (seen.has(tile)) continue; seen.add(tile); const connection = this.nearestPolityTileConnection(polity, tile); if (!connection.source || connection.distance > maxDistance) continue; const base = scorer(tile, connection); if (!base) continue; targets.push({ ...base, connection, 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; const y = Math.floor(tile / w.size); let source = null; let distance = Infinity; for (const city of this.getPolityCities(polity)) { let d = Math.abs(city.x - x) + Math.abs(city.y - y); if (w.tradeRoute[tile]) d *= 0.72; if (d < distance) { source = city; distance = d; } } return { source, distance }; } nearestBorderCampaignTile(attacker, defender) { const w = this.world; let best = { tile: this.world.idx(this.getPolityCities(defender)[0]?.x || 0, this.getPolityCities(defender)[0]?.y || 0), distance: Infinity }; for (let i = 0; i < w.count; i++) { if (w.polity[i] !== defender.id || !this.isBorderTile(i)) continue; const connection = this.nearestPolityTileConnection(attacker, i); if (connection.distance < best.distance) best = { tile: i, distance: connection.distance }; } return best; } borderWarTargetCity(attacker, defender) { const maxDistance = SimConfig.campaign?.maxTargetDistance ?? 48; let best = null; let bestDistance = Infinity; for (const city of this.getPolityCities(defender)) { const connection = this.polityConnectionToCity(attacker, city); if (connection.connected && connection.distance < bestDistance && connection.distance <= maxDistance) { best = city; bestDistance = connection.distance; } } return best; } startCampaign(polity, target) { const cfg = SimConfig.campaign || {}; if (target.type === "border_war") { const defender = this.getPolityById(target.defenderPolityId); if (!defender || (!this.getWarBetween(polity.id, defender.id) && !this.startWar(polity, defender))) return null; } const duration = years(Math.min(cfg.maxDurationYears ?? 25, (cfg.baseDurationYears ?? 8) + this.rng.int(Math.max(1, (cfg.maxDurationYears ?? 25) - (cfg.baseDurationYears ?? 8) + 1)))); const targetTile = target.targetTile ?? (target.targetCityId ? this.cityTile(this.getCityById(target.targetCityId)) : null); if (targetTile == null) return null; const campaign = { id: this.nextCampaign++, type: target.type, annexationMode: target.type === "independent_city_annexation" ? this.independentAnnexationMode() : null, attackerPolityId: polity.id, defenderPolityId: target.defenderPolityId ?? null, targetCityId: target.targetCityId ?? null, targetTile, targetEthnicity: target.targetEthnicity ?? null, started: this.year, expires: target.type === "border_war" ? Infinity : this.year + duration, progress: 0, strength: 0, resistance: 0, status: "active" }; this.campaigns.push(campaign); polity.lastCampaignYear = this.year; polity.nextCampaignAllowedYear = this.year + years(this.rng.range(cfg.minCooldownYears ?? 12, cfg.maxCooldownYears ?? 45)); if (campaign.type === "border_war") { const defender = this.getPolityById(campaign.defenderPolityId); if (defender && !this.getWarBetween(polity.id, defender.id)) this.startWar(polity, defender); this.addPolityEvent(polity.id, "borderWar", this.year, this.campaignEventData(campaign, "started"), 2); if (defender) this.addPolityEvent(defender.id, "borderWar", this.year, this.campaignEventData(campaign, "defending"), 2); } else if (campaign.type === "frontier_colonization") { this.addPolityEvent(polity.id, "expansion", this.year, this.campaignEventData(campaign, "started"), 1); } this.addPolityEvent(polity.id, "campaign", this.year, this.campaignEventData(campaign, "started"), 1); return campaign; } independentAnnexationMode() { const peacefulShare = SimConfig.campaign?.independentCityPeacefulShare ?? 0.4; return this.rng.next() < peacefulShare ? "peaceful" : "military"; } cityTile(city) { return city ? this.world.idx(city.x, city.y) : null; } updateCampaigns() { for (const campaign of this.campaigns) { if (campaign.status !== "active") continue; const borderWar = campaign.type === "border_war"; if (this.year % (borderWar ? SimConfig.war.tickWeeks : years(1)) !== 0) continue; const attacker = this.getPolityById(campaign.attackerPolityId); if (!attacker) { campaign.status = "failed"; continue; } const force = this.campaignAttackerForce(campaign, attacker); const resistance = this.campaignResistance(campaign, attacker); campaign.strength = force; campaign.resistance = resistance; const momentum = (force - resistance) / Math.max(1, force + resistance); campaign.progress = clamp(campaign.progress + (borderWar ? 0.25 + momentum * 0.4 : 0.09 + momentum * 0.18) + this.rng.range(-0.08, 0.08), -0.35, 1.25); attacker.treasury = Math.max(0, (attacker.treasury || 0) - Math.max(0.05, force * 0.018)); if (campaign.progress >= 1) this.resolveCampaignSuccess(campaign); else if (campaign.progress <= -0.28) this.resolveCampaignFailure(campaign); else if (this.year >= campaign.expires) { if (campaign.progress > 0.72 && this.rng.next() < 0.45) this.resolveCampaignSuccess(campaign); else this.resolveCampaignFailure(campaign, "expired"); } } this.campaigns = this.campaigns.filter(campaign => campaign.status === "active" || this.year - campaign.started < years(8)); } campaignAttackerForce(campaign, attacker) { const connection = this.nearestPolityTileConnection(attacker, campaign.targetTile); const source = connection.source; const nearbyStrength = source ? this.cityInfluence(source) * 0.32 : 1; const routeAccess = this.world.tradeRoute[campaign.targetTile] || (source && this.hasDirectTradeConnection(source, this.getCityById(campaign.targetCityId))) ? 1.18 : 1; return Math.max(0.1, this.polityPower(attacker) * 0.42 * clamp(attacker.cohesion ?? 0.6, 0.2, 1.2) * clamp(attacker.charisma ?? 1, 0.5, 1.5) * clamp((attacker.treasury || 0) / Math.max(8, this.getPolityCities(attacker).length * 5), 0.25, 1.6) * routeAccess + nearbyStrength ); } campaignResistance(campaign, attacker) { const w = this.world; const tile = campaign.targetTile; const targetCity = this.getCityById(campaign.targetCityId); const defender = campaign.defenderPolityId != null ? this.getPolityById(campaign.defenderPolityId) : null; const connection = this.nearestPolityTileConnection(attacker, tile); const terrain = w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1); const population = targetCity?.population || w.population[tile] || 0; const defenderPower = defender ? this.polityPower(defender) * 0.55 : 0; const attackerEthnicity = this.dominantCityEthnicity(connection.source); const localEthnicity = targetCity ? this.dominantCityEthnicity(targetCity) : w.dominantEthnicity[tile]; const mismatch = attackerEthnicity !== null && localEthnicity !== null && localEthnicity >= 0 && attackerEthnicity !== localEthnicity ? 1.22 : 0.92; const foreignControl = w.polity[tile] >= 0 && w.polity[tile] !== attacker.id ? 1.35 : 1; return Math.max(0.2, (Math.sqrt(Math.max(1, population)) * 3.2 + terrain * 8 + connection.distance * 0.32 + defenderPower) * mismatch * foreignControl * (1 + (w.cultureDiversity[tile] || 0) * 0.35) ); } resolveCampaignSuccess(campaign) { campaign.status = "succeeded"; const attacker = this.getPolityById(campaign.attackerPolityId); if (!attacker) return; if (campaign.type === "independent_city_annexation") { const city = this.getCityById(campaign.targetCityId); 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); const peaceful = campaign.annexationMode === "peaceful"; const resistanceRatio = campaign.resistance / Math.max(1, campaign.strength + campaign.resistance); if (!peaceful) { this.applyCityWarDamage(city, clamp(0.025 + resistanceRatio * 0.06, 0.025, 0.10), "independentAnnexation"); } this.transferCityToPolity(city, attacker, { loyalty: peaceful ? clamp(0.58 + similarity * 0.22, 0.58, 0.82) : clamp(0.20 + similarity * 0.22 - resistanceRatio * 0.12, 0.12, 0.48), control: peaceful ? 0.58 : 0.46, claim: peaceful ? 0.68 : 0.62, reason: peaceful ? "peacefulAnnexation" : "militaryAnnexation" }); campaign.affectedTiles = this.applyCampaignTerritory(attacker, cityTile, 5, peaceful ? 0.48 : 0.42, { allowForeignTakeover: false, reason: peaceful ? "peacefulAnnexation" : "militaryAnnexation" }) || []; } this.addPolityEvent(attacker.id, "annexation", this.year, this.campaignEventData(campaign, campaign.annexationMode || "military"), 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.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 { 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, { 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, { 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); this.addPolityEvent(attacker.id, "colonization", this.year, this.campaignEventData(campaign, "succeeded"), 1); } this.recordCampaign(attacker, campaign, campaign.status); this.syncTilePopulationCulture(); this.recomputeTerritories({ allowOwnershipChanges: false, reason: "campaign" }); this.reconcileCityTerritoryOwnership(); this.campaignPressureOnTerritories(); } resolveCampaignFailure(campaign, reason = "failed") { campaign.status = reason; const attacker = this.getPolityById(campaign.attackerPolityId); if (!attacker) return; attacker.treasury = Math.max(0, (attacker.treasury || 0) - Math.max(1, campaign.strength * 0.04)); attacker.cohesion = clamp((attacker.cohesion ?? 0.6) - 0.025, 0, 1); attacker.legitimacy = clamp((attacker.legitimacy ?? 0.7) - 0.018, 0, 1); if (campaign.type === "nonstate_subjugation") { const w = this.world; w.cultureDiversity[campaign.targetTile] = clamp((w.cultureDiversity[campaign.targetTile] || 0) + 0.10, 0, 1); } const war = campaign.defenderPolityId != null ? this.getWarBetween(attacker.id, campaign.defenderPolityId) : null; 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); const peacefulAnnexation = campaign.type === "independent_city_annexation" && campaign.annexationMode === "peaceful"; if (!peacefulAnnexation) { 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) { const source = this.nearestPolityTileConnection(polity, campaign.targetTile).source; const targetCity = this.getCityById(campaign.targetCityId); const a = this.dominantCityEthnicity(source); const b = targetCity ? this.dominantCityEthnicity(targetCity) : this.world.dominantEthnicity[campaign.targetTile]; if (a === null || b === null || b < 0) return 0.45; return a === b ? 1 : 0.25; } transferTerritoryTiles(fromPolityId, toPolityId, tiles, options = {}) { const w = this.world; 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 && owner !== toPolityId && !(options.allowNeutralWithFrom && owner < 0) ) 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 } of this.getRadiusOffsets(Math.ceil(radius * Math.SQRT2))) { const distance = Math.hypot(dx, dy); if (distance > radius) continue; 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; 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 }); } isConquestTransferReason(reason) { return reason === "warCapture" || reason === "borderWar" || reason === "militaryAnnexation" || reason === "settlement" || reason === "nomadConquest"; } protectCityFromRebellion(city, graceYears = SimConfig.rebellion?.conquestGraceYears ?? 20) { if (!city) return; city.rebellionProtectedUntil = Math.max(city.rebellionProtectedUntil || 0, this.year + years(Math.max(0, graceYears))); } isCityRebellionProtected(city) { return !!city && (city.rebellionProtectedUntil || 0) > this.year; } 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" }); if (this.isConquestTransferReason(options.reason)) { this.protectCityFromRebellion(city, options.rebellionProtectionYears); // City occupation also disrupts the surrounding rural population. Keep this // separate from ordinary battle-zone damage so every conquest-style transfer // receives the same occupation shock exactly once. if (fromPolityId !== toPolity.id) this.applyOccupationPopulationShock(tile); } return true; } releaseCityTerritory(city, fromPolityId, radius = 4, reason = "fragmentation") { 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.hypot((tile % w.size) - cx, 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 < 0 || 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.hypot(tx - cx, 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, allowNeutralWithFrom: allowForeignTakeover && fromPolityId != null, 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]; if (localEthnicity == null || localEthnicity < 0) return; const sourceEthnicity = this.dominantCityEthnicity(this.nearestPolityTileConnection(polity, campaign.targetTile).source); if (sourceEthnicity === localEthnicity) return; const x = campaign.targetTile % this.world.size; const y = Math.floor(campaign.targetTile / this.world.size); for (const city of this.getCitiesNear(x, y, 12)) { if (city.polityId === polity.id) city.loyalty = clamp((city.loyalty ?? 0.5) - 0.05, 0, 1); } } seedFrontierPopulation(polity, tile) { const source = this.nearestPolityTileConnection(polity, tile).source; const ethnicity = this.dominantCityEthnicity(source) || this.world.dominantEthnicity[tile]; if (!source || ethnicity == null || ethnicity < 0) return; 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; if (this.cities.length >= SimConfig.city.maxCities || w.city[tile] >= 0) return null; if ((w.population[tile] || 0) < 8 || w.fertility[tile] + w.resource[tile] * 0.025 < 0.8) return null; const target = { type: "frontier_colonization", tile, x: tile % w.size, y: Math.floor(tile / w.size), population: w.population[tile] || 0, connection: this.nearestPolityTileConnection(polity, tile) }; return this.foundCampaignOutpost(polity, target); } campaignEventData(campaign, outcome) { return { campaignId: campaign.id, campaignType: campaign.type, annexationMode: campaign.annexationMode ?? null, outcome, targetCityId: campaign.targetCityId ?? null, targetPolityId: campaign.defenderPolityId ?? null, targetTile: campaign.targetTile, progress: Number((campaign.progress || 0).toFixed(2)) }; } recordCampaign(polity, campaign, outcome) { const data = this.campaignEventData(campaign, outcome); this.campaignHistory.push({ year: this.year, polityId: polity.id, ...data }); while (this.campaignHistory.length > 160) this.campaignHistory.shift(); } foundCampaignOutpost(polity, target) { const w = this.world; const targetTile = target?.targetTile ?? target?.tile; if (this.cities.length >= SimConfig.city.maxCities || !target || targetTile == null) return null; const source = target.connection?.source || this.getCityById(polity.centerCityId); if (!source) return null; const isFrontier = target.type === "frontier_colonization" || target.type === "frontier"; const cost = isFrontier ? 5.5 : 8.5; if ((polity.treasury || 0) < cost && source.storedResources < cost * 1.4) return null; const treasuryPaid = Math.min(polity.treasury || 0, cost); polity.treasury = Math.max(0, (polity.treasury || 0) - treasuryPaid); source.storedResources = Math.max(0, source.storedResources - Math.max(0, cost - treasuryPaid) * 0.5); const culture = this.tileCultures.get(targetTile); const composition = new Map(); let regionalTotal = 0; if (culture?.size) { for (const [id, count] of culture) regionalTotal += count; const basePopulation = clamp(Math.round((target.population || 0) * 0.55), 6, 58); for (const [id, count] of culture) { const share = regionalTotal > 0 ? count / regionalTotal : 0; if (share > 0) composition.set(id, Math.max(1, Math.round(basePopulation * share))); } } if (!composition.size) { const regionalEthnicity = this.world.dominantEthnicity[targetTile]; const sourceEthnicity = this.dominantCityEthnicity(source) || (regionalEthnicity >= 0 ? regionalEthnicity : 1); composition.set(sourceEthnicity, isFrontier ? 12 : 18); } const populationScale = SimConfig.city.populationScale; const seedPopulation = Math.max(12, Math.round(compositionTotal(composition) * (isFrontier ? 1.2 : 1.6))) * populationScale; for (const [id, count] of composition) composition.set(id, count * populationScale); const city = { id: this.nextCity++, x: target.x ?? targetTile % w.size, y: target.y ?? Math.floor(targetTile / w.size), population: seedPopulation, storedResources: (18 + (w.resource[targetTile] || 0) * 0.25) * populationScale, ethnicityComposition: composition, pheromoneOutput: 0, agriculturalRadius: 2, tradeLinks: new Set(), activeVisitors: 0, age: 0, peakPopulation: seedPopulation, strength: isFrontier ? 1.8 : 2.4, sedentaryCulture: isFrontier ? 0.42 : 0.55, knowledge: { ...source.knowledge }, supplyStress: 0, polityId: null, loyalty: isFrontier ? 0.52 : 0.32, receivedAid: false, tradeValue: 0, tradeReach: 0, rebellionProtectedUntil: 0 }; 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.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); this.syncTilePopulationCulture(); return city; } maybeStartWars() { if (this.year % SimConfig.war.tickWeeks !== 0) return; let actions = 0; let warSlots = SimConfig.war.maxActiveWars - this.wars.filter(war => war.ended === null).length; const maxActions = SimConfig.war.maxStartsPerTick; for (const a of this.polities) { if (actions >= maxActions) break; if (this.year < (a.nextWarAllowedWeek ?? 0)) continue; if (this.isPolityAtWar(a.id)) continue; const aPower = this.polityPower(a); if (aPower <= 0) continue; let started = false; for (const b of this.polities) { if (started) break; if (a.id === b.id) continue; if (this.year < (b.nextWarAllowedWeek ?? 0)) continue; if (!this.canBeWarTarget(b.id)) continue; if (this.getWarBetween(a.id, b.id)) continue; const distance = this.polityDistance(a, b); if (distance > 46) continue; const bPower = this.polityPower(b); if (bPower <= 0) continue; const larger = Math.max(aPower, bPower); const smaller = Math.min(aPower, bPower); const advantage = larger / Math.max(1, smaller); const proximity = clamp((46 - distance) / 46, 0, 1); const aInstability = this.polityInstability ? this.polityInstability(a) : 0; const bInstability = this.polityInstability ? this.polityInstability(b) : 0; const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0; const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0; const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0; const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0; const aLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(a) : 0.5; const bLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5; const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1); const weakSideInstability = aPower > bPower ? bInstability : aInstability; const weakSideAge = aPower > bPower ? bAge : aAge; const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension; const weakSideLoyalty = aPower > bPower ? bLoyalty : aLoyalty; const lowLoyaltyPressure = clamp(1 - Math.min(aLoyalty, bLoyalty), 0, 1); const weakSideLowLoyalty = clamp(1 - weakSideLoyalty, 0, 1); const generalInstability = Math.max(aInstability, bInstability) * 0.035; const targetWarCount = this.warCountForPolity(b.id); const targetLowLoyalty = clamp(1 - bLoyalty, 0, 1); const attackerAdvantage = clamp((aPower / Math.max(1, bPower) - 1) / 2.25, 0, 1); const opportunisticPressure = targetWarCount > 0 ? clamp(targetWarCount / 3, 0, 1) * ( 0.010 + bInstability * 0.018 + targetLowLoyalty * 0.018 + attackerAdvantage * 0.020 ) : 0; const borderFriction = this.borderFriction(a, b, distance); const chance = 0.001 + proximity * 0.007 + asymmetryPressure * 0.008 + weakSideInstability * 0.018 + weakSideAge * 0.010 + weakSideOverextension * 0.010 + borderFriction * 0.008 + generalInstability + lowLoyaltyPressure * 0.018 + weakSideLowLoyalty * 0.028 + opportunisticPressure; if (this.rng.next() < clamp(chance * SimConfig.war.startChanceMultiplier, 0, 0.35)) { if (warSlots > 0) { started = !!this.startWar(a, b); if (started) warSlots--; } if (started) actions++; } } } } borderFriction(a, b, distance) { if (!Number.isFinite(distance)) return 0; const proximity = clamp((34 - distance) / 34, 0, 1); const aCities = this.getPolityCities(a).length; const bCities = this.getPolityCities(b).length; const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1); return proximity * (0.35 + sizePressure * 0.65); } updateWars() { if (this.year % SimConfig.war.tickWeeks !== 0) return; this.warLogisticsCache = new Map(); for (const war of this.wars) { if (war.ended !== null) continue; const a = this.getPolityById(war.aPolityId); const b = this.getPolityById(war.bPolityId); if (!a || !b) { this.endWar(war); continue; } this.applyWarPressure(war, a, b); this.applyWarPressure(war, b, a); this.applyWarExhaustion(war, a, b); this.maybeEndWar(war); } this.wars = this.wars.filter(w => w.ended === null); } applyWarPressure(war, attacker, defender) { const attackerPower = this.polityPower(attacker); const defenderPower = this.polityPower(defender); const selected = this.bestWarPressureTarget(attacker, defender, war, attackerPower, defenderPower); if (!selected) return; const pressure = this.warAbsorptionPressure( attacker, defender, selected.city, selected.connection.distance, attackerPower, defenderPower, selected.connection.logisticsFactor ); const key = `${attacker.id}:${selected.city.id}`; const memory = (war.cityPressureById?.get(key) || 0) * (SimConfig.war.pressureMemoryDecay ?? 0.88) + pressure * (war.intensity || 0.75) * 1.6; war.cityPressureById?.set(key, memory); const captureThreshold = war.goal === "collapse_exploitation" ? 1.55 : war.goal === "city_conquest" ? 1.75 : war.goal === "capital_pressure" ? 1.85 : 2.15; const baseCaptureChance = clamp((memory - captureThreshold * 0.65 + pressure * 0.35) * 0.45, 0.05, 0.85); const chance = clamp(baseCaptureChance * (SimConfig.war.captureChanceMultiplier ?? 1), 0, 0.85); const damageRate = clamp( (SimConfig.war.cityDamageMinRate ?? 0.003) + pressure * 0.030 + memory * 0.018, SimConfig.war.cityDamageMinRate ?? 0.003, SimConfig.war.cityDamageMaxRate ?? 0.08 ); if (memory > captureThreshold * 0.65 && pressure > 0.55 && this.rng.next() < chance) { this.captureCityInWar(selected.city, attacker, defender, war, pressure); } else { 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 >= SimConfig.war.tickWeeks; 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 logisticsPenalty = (1 - connection.logisticsFactor) * 2.2; 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 - logisticsPenalty + (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; let logisticsFactor = 0; let campaignCost = 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; const logistics = this.warLogisticsFactor(polity, c, city); const cost = d / Math.max(0.28, logistics); if (cost < campaignCost) { source = c; distance = d; logisticsFactor = logistics; campaignCost = cost; } } return { source, distance, connected: !!source, logisticsFactor, campaignCost }; } warLogisticsFactor(polity, sourceCity, targetCity) { if (!polity || !sourceCity || !targetCity) return 0.25; const capital = this.getCityById(polity.centerCityId) || sourceCity; const capitalDistance = this.effectiveDistance(capital, targetCity); const distanceStart = SimConfig.polity.warCapitalDistanceStart ?? 18; const distanceScale = SimConfig.polity.warCapitalDistanceScale ?? 42; let factor = 1 / (1 + Math.max(0, capitalDistance - distanceStart) / Math.max(1, distanceScale)); const connected = sourceCity.id === capital.id || this.territoryRouteConnected(polity.id, capital, sourceCity); if (!connected) factor *= SimConfig.polity.warDisconnectedTerritoryFactor ?? 0.42; if (sourceCity.id !== capital.id && this.hasDirectTradeConnection(capital, sourceCity)) factor *= 1.08; return clamp(factor, 0.18, 1); } territoryRouteConnected(polityId, fromCity, toCity) { if (!fromCity || !toCity) return false; if (fromCity.id === toCity.id) return true; const key = `${polityId}:${fromCity.id}:${toCity.id}`; if (this.warLogisticsCache?.has(key)) return this.warLogisticsCache.get(key); const w = this.world; const start = w.idx(fromCity.x, fromCity.y); const goal = w.idx(toCity.x, toCity.y); if (w.territoryOwner[start] !== polityId || w.territoryOwner[goal] !== polityId) { this.warLogisticsCache?.set(key, false); return false; } const visited = this.visitStamp; const marker = this.nextVisitMarker(); const queue = this.scratchIntA; let head = 0; let tail = 0; queue[tail++] = start; visited[start] = marker; let connected = false; while (head < tail) { const tile = queue[head++]; if (tile === goal) { connected = true; break; } this.forCardinalNeighbors(tile, neighbor => { if (visited[neighbor] === marker || w.territoryOwner[neighbor] !== polityId || w.terrain[neighbor] === Terrain.WATER) return; visited[neighbor] = marker; queue[tail++] = neighbor; }); } this.warLogisticsCache?.set(key, connected); return connected; } warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower, logisticsFactor = 1) { const powerRatio = attackerPower / Math.max(1, defenderPower); const proximity = 1 / (1 + distance * 0.055); const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0; const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0; const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0; const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1); const techAdvantage = 1 + clamp( this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender), -0.25, 0.45 ); const nearest = this.nearestPolityCity(attacker, city); const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92; const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0; return ( powerRatio * proximity * techAdvantage * ethnicityFactor * tradeFactor * clamp(logisticsFactor, 0.18, 1) * (1 + defenderInstability * 0.32) * (1 + defenderAge * 0.18) * (1 + defenderOverextension * 0.14) * (0.65 + loyaltyWeakness * 0.72) ); } captureCityInWar(city, attacker, defender, war, pressure) { 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); } else { war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1); war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1); } 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; const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035; const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035; a.treasury = Math.max(0, (a.treasury || 0) - costA); b.treasury = Math.max(0, (b.treasury || 0) - costB); 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.08 * intensity, 0, 1); war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08 * 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); const b = this.getPolityById(war.bPolityId); if (!a || !b) { this.endWar(war); return; } const age = this.year - war.started; const noActionFor = this.year - war.lastActionYear; const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0); if (!this.getPolityCities(a).length || !this.getPolityCities(b).length) { this.endWar(war); return; } if (age >= SimConfig.war.minDurationWeeks && noActionFor >= SimConfig.war.idleEndWeeks) { this.endWar(war); return; } if (age >= SimConfig.war.minDurationWeeks && (exhaustion >= 0.8 || Math.abs((war.scoreA || 0) - (war.scoreB || 0)) >= SimConfig.war.decisiveScore)) { 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 damageMultiplier = SimConfig.war.damageMultiplier ?? 1; const maxRate = reason === "capture" ? SimConfig.war.captureLossMax : SimConfig.war.cityDamageMaxRate; const appliedRate = clamp(rate, 0, maxRate) * damageMultiplier; const loss = Math.min(city.population - 1, Math.max(1, Math.floor(city.population * appliedRate))); 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) - appliedRate * (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; } applyOccupationPopulationShock(tile, radius = SimConfig.war.occupationPopulationLossRadius ?? 4, rate = SimConfig.war.occupationPopulationLossRate ?? 0.08) { const w = this.world; if (tile == null || tile < 0 || tile >= w.count || radius <= 0 || rate <= 0) return 0; const cx = tile % w.size; const cy = Math.floor(tile / w.size); const damageMultiplier = SimConfig.war.damageMultiplier ?? 1; let removed = 0; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const distance = Math.abs(dx) + Math.abs(dy); if (distance === 0 || distance > radius) continue; const x = cx + dx; const y = cy + dy; if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; const t = w.idx(x, y); if (w.terrain[t] === Terrain.WATER || w.population[t] <= 0) continue; const falloff = 1 - distance / (radius + 1); const amount = w.population[t] * rate * damageMultiplier * falloff; if (amount <= 0.03) continue; removed += this.removeEthnicPopulationProportionally(t, amount) || 0; w.resource[t] = Math.max(0, w.resource[t] - amount * 0.015); } } this.recordWarDeaths(removed, 0.72); return removed; } applyWarZoneTileDamage(tile, rate) { const w = this.world; if (tile == null || tile < 0 || tile >= w.count || rate <= 0) return 0; const damageMultiplier = SimConfig.war.damageMultiplier ?? 1; 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 * damageMultiplier * (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); const centerId = polity.centerCityId; for (const city of cities) city.receivedAid = false; for (const city of cities) { if (city.id === centerId) continue; const tax = city.storedResources * 0.035; city.storedResources -= tax; polity.treasury += tax; } const agePressure = this.polityAgePressure(polity); const overextension = this.polityOverextension(polity); const adminCost = cities.length * 0.24 + overextension * 1.1 + agePressure * cities.length * 0.18; polity.treasury -= adminCost; if (polity.treasury < 0) { const deficit = Math.abs(polity.treasury); polity.treasury = 0; polity.crisis = clamp((polity.crisis || 0) + deficit * 0.012, 0, 1.5); } const maintenance = Math.pow(cities.length, 1.12) * 0.10; if (maintenance > 0) { const center = this.getCityById(centerId); const treasuryPayment = Math.min(polity.treasury, maintenance); polity.treasury -= treasuryPayment; const unpaid = maintenance - treasuryPayment; if (unpaid > 0 && center) { const paidByCenter = this.drainCityResources(center, unpaid); if (paidByCenter < unpaid) { for (const city of cities) { if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.012, 0, 1); } } } } const poorCount = Math.ceil(cities.length * 0.1); const poorest = [...cities] .sort((a, b) => (a.storedResources / Math.max(1, a.population)) - (b.storedResources / Math.max(1, b.population))) .slice(0, poorCount); for (const city of poorest) { const target = city.population * 0.08; const need = target - city.storedResources; if (need > 0 && polity.treasury > 0) { const aid = Math.min(need, polity.treasury); city.storedResources += aid; polity.treasury -= aid; city.receivedAid = true; city.loyalty = clamp(city.loyalty + 0.05, 0, 1); } } this.enforcePolityTreasuryCap(polity); } } erodePolityLegitimacy() { for (const polity of this.polities) { const agePressure = this.polityAgePressure(polity); const resourceStress = this.polityResourceStress(polity); const overextension = this.polityOverextension(polity); const erosion = 0.0015 + agePressure * 0.003 + resourceStress * 0.003 + overextension * 0.0015; polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - erosion, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - erosion * 0.45, 0, 1); const cities = this.getPolityCities(polity); if (this.polityAge(polity) < 80 && polity.treasury > cities.length * 10) { polity.legitimacy = clamp(polity.legitimacy + 0.008, 0, 1); polity.cohesion = clamp(polity.cohesion + 0.005, 0, 1); } polity.crisis = clamp((polity.crisis || 0) * 0.88, 0, 1.5); } } triggerPolityCrises() { for (const polity of this.polities) { const age = this.polityAge(polity); if (age < 140) continue; if ((this.year - (polity.lastCrisisYear ?? 0)) / WEEKS_PER_YEAR < 160) continue; const instability = this.polityInstability(polity); const agePressure = this.polityAgePressure(polity); const chance = 0.006 + agePressure * 0.018 + instability * 0.014; if (this.rng.next() >= chance) continue; polity.lastCrisisYear = this.year; const severity = clamp( 0.08 + agePressure * this.rng.range(0.08, 0.20) + instability * this.rng.range(0.05, 0.16), 0.05, 0.36 ); polity.crisis = clamp((polity.crisis || 0) + severity, 0, 1.5); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - severity * 0.30, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - severity * 0.18, 0, 1); const center = this.getCityById(polity.centerCityId); for (const city of this.getPolityCities(polity)) { if (city.id === polity.centerCityId) continue; const distance = center ? this.effectiveDistance(center, city) : 40; const distanceFactor = clamp(distance / 40, 0.25, 1); city.loyalty = clamp(city.loyalty - severity * distanceFactor, 0, 1); } } } applyOldStateStress() { for (const polity of this.polities) { const age = this.polityAge(polity); if (age < 1000) continue; const cities = this.getPolityCities(polity); if (cities.length <= 1) continue; const oldAge = clamp((age - 1000) / 1000, 0, 1); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - oldAge * 0.018, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - oldAge * 0.012, 0, 1); polity.crisis = clamp((polity.crisis || 0) + oldAge * 0.035, 0, 1.5); const center = this.getCityById(polity.centerCityId); for (const city of cities) { if (city.id === polity.centerCityId) continue; const distance = center ? this.effectiveDistance(center, city) : 40; const distanceFactor = clamp(distance / 35, 0.2, 1); city.loyalty = clamp(city.loyalty - oldAge * distanceFactor * 0.045, 0, 1); } } } updateCityLoyalty() { for (const polity of this.polities) { const center = this.getCityById(polity.centerCityId); if (!center) continue; const loyaltyCap = this.polityLoyaltyCap(polity); center.loyalty = loyaltyCap; const instability = this.polityInstability(polity); const agePressure = this.polityAgePressure(polity); const overextension = this.polityOverextension(polity); const logisticsStress = this.polityLogisticsStress(polity); for (const city of this.getPolityCities(polity)) { if (city.id === center.id) continue; const perCapita = city.storedResources / Math.max(1, city.population); const distance = this.effectiveDistance(city, center); let delta = 0; delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045); delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012; delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045); const directCenterTrade = this.hasDirectTradeConnection(city, center); if (directCenterTrade) delta += 0.025; else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity); if (city.receivedAid) delta += 0.04; delta -= 0.004; if (city.storedResources < city.population * 0.05) delta -= 0.035; delta -= (city.supplyStress || 0) * 0.018; delta -= instability * 0.020; delta -= agePressure * 0.010; delta -= overextension * clamp(distance / 48, 0, 1) * 0.014; delta -= logisticsStress * clamp(distance / 42, 0.25, 1) * 0.018; if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.020; if ((polity.crisis || 0) > 0.6) delta -= 0.014; city.loyalty = clamp(city.loyalty + delta, 0, loyaltyCap); } } } splitUnloyalCities() { const cfg = SimConfig.rebellion || {}; const participationLoyalty = cfg.participationLoyalty ?? 0.50; const automaticLoyalty = cfg.automaticLoyalty ?? 0.30; for (const polity of [...this.polities]) { const originalCities = this.getPolityCities(polity); const preRebellionPopulation = originalCities.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); const members = originalCities .filter(city => city.id !== polity.centerCityId && city.polityId === polity.id) .filter(city => !this.isCityRebellionProtected(city)) .filter(city => (city.loyalty ?? 0.5) < participationLoyalty); if (!members.length) continue; const lowestLoyalty = Math.min(...members.map(city => city.loyalty ?? 0.5)); const automatic = lowestLoyalty < automaticLoyalty; if (!automatic) { const instability = this.polityInstability(polity); const agePressure = this.polityAgePressure(polity); const averageLoyalty = members.reduce((sum, city) => sum + (city.loyalty ?? 0.5), 0) / members.length; const chance = clamp( (participationLoyalty - averageLoyalty) * (cfg.chanceScale ?? 0.20) + instability * 0.018 + agePressure * 0.012, 0, cfg.maxAnnualChance ?? 0.16 ); if (this.rng.next() >= chance) continue; } // Once a rebellion begins, every non-capital city below 50% loyalty joins // simultaneously, regardless of distance from the first rebel city. const trigger = members.reduce((lowest, city) => (city.loyalty ?? 0.5) < (lowest.loyalty ?? 0.5) ? city : lowest, members[0]); const rebellionData = members.map(city => ({ cityId: city.id, population: city.population || 0, loyalty: city.loyalty ?? 0 })); const oldPolityId = polity.id; this.removeCityFromPolity(trigger); this.releaseCityTerritory(trigger, oldPolityId, 5, "rebellion"); const rebelPolity = this.createPolity(trigger); trigger.loyalty = 1; for (const city of members) { if (city.id === trigger.id) continue; const loyalty = city.loyalty ?? 0.3; this.removeCityFromPolity(city); this.releaseCityTerritory(city, oldPolityId, 5, "rebellion"); this.transferCityToPolity(city, rebelPolity, { loyalty: clamp(0.52 + (participationLoyalty - loyalty) * 0.38, 0.46, 0.72), control: 0.48, claim: 0.62, reason: "rebellion" }); this.claimPolityCoreTerritory(rebelPolity, city, 3, "rebellion"); } const population = rebellionData.reduce((sum, rebellion) => sum + rebellion.population, 0); const avgLoyalty = rebellionData.reduce((sum, rebellion) => sum + rebellion.loyalty, 0) / rebellionData.length; const rebelPopulationShare = preRebellionPopulation > 0 ? clamp(population / preRebellionPopulation, 0, 1) : 0; const transferRate = clamp(cfg.treasuryTransferRate ?? 0.65, 0, 1); const treasuryTransferred = Math.min( Math.max(0, polity.treasury || 0), Math.max(0, polity.treasury || 0) * rebelPopulationShare * transferRate ); polity.treasury = Math.max(0, (polity.treasury || 0) - treasuryTransferred); rebelPolity.treasury = Math.max(0, (rebelPolity.treasury || 0) + treasuryTransferred); this.enforcePolityTreasuryCap(polity); this.enforcePolityTreasuryCap(rebelPolity); const eventData = { cityIds: rebellionData.map(rebellion => rebellion.cityId), cityCount: rebellionData.length, population, loyalty: avgLoyalty, automatic, treasuryTransferred, newPolityId: rebelPolity.id }; this.addPolityEvent(polity.id, "rebellion", this.year, eventData, 3); this.addPolityEvent(rebelPolity.id, "rebellion", this.year, { cityIds: eventData.cityIds, cityCount: eventData.cityCount, population, automatic, treasuryTransferred, formerPolityId: polity.id }, 3); } } enforcePolityContinuity(polity) { const center = this.getCityById(polity.centerCityId); const cities = this.getPolityCities(polity); if (!center || cities.length <= 1) return cities; const limit = SimConfig.polity.logisticsDistance; const reachable = new Set([center.id]); const frontier = [center]; while (frontier.length) { const source = frontier.pop(); for (const city of cities) { if (reachable.has(city.id)) continue; if (this.effectiveDistance(source, city) > limit) continue; reachable.add(city.id); 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); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - isolated.length * 0.02, 0, 1); polity.crisis = clamp((polity.crisis || 0) + isolated.length * 0.025, 0, 1.5); return this.getPolityCities(polity); } detectPolityFamines() { const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06; for (const polity of this.polities) { const cities = this.getPolityCities(polity); if (!cities.length) continue; let poorCities = 0; let totalFood = 0; let totalPopulation = 0; for (const city of cities) { const population = Math.max(1, city.population || 0); const perCapita = (city.storedResources || 0) / population; if (perCapita < minimumFood) poorCities++; totalFood += city.storedResources || 0; totalPopulation += population; } const poorRate = poorCities / cities.length; const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation); const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035; if (!famine) continue; if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue; this.addPolityEvent(polity.id, "famine", this.year, { poorRate, avgPerCapitaFood }, poorRate > 0.65 ? 3 : 2); polity.lastFamineYear = this.year; } } cleanupPolities() { const survivors = []; 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); if (!center || center.population <= 0) { const oldCenterCityId = polity.centerCityId; center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]); polity.centerCityId = center.id; center.loyalty = this.polityLoyaltyCap ? this.polityLoyaltyCap(polity) : 1; if (oldCenterCityId !== center.id) { this.addPolityEvent(polity.id, "capitalShift", this.year, { oldCenterCityId, newCenterCityId: center.id }, 2); } polity.treasury *= 0.5; polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1); polity.crisis = clamp((polity.crisis || 0) + 0.35, 0, 1.5); polity.lastCrisisYear = this.year; for (const city of cities) { if (city.id !== center.id) city.loyalty = clamp(city.loyalty - 0.18, 0, 1); } const history = this.ensurePolityHistory(polity); if (history) history.centerCityId = center.id; } const connectedCities = this.enforcePolityContinuity(polity); if (connectedCities.length !== cities.length) { cities.length = 0; cities.push(...connectedCities); } if (cities.length === 1) { const city = cities[0]; const ageYears = (this.year - (polity.founded ?? this.year)) / WEEKS_PER_YEAR; const weakSingleCityState = ageYears > 80 && city.population < 45 && (polity.legitimacy ?? 0.7) < 0.25 && (polity.cohesion ?? 0.6) < 0.25 && (polity.crisis || 0) > 0.9; if (weakSingleCityState) { this.markPolityEnded(polity, "collapsed"); this.releasePolityTerritory(polity.id, "collapse"); city.polityId = null; city.loyalty = 0.45; city.receivedAid = false; continue; } } survivors.push(polity); } this.polities = survivors; 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"); } } } updatePolities(force = false) { if (!force && this.year % years(1) !== 0) return; this.cleanupPolities(); this.foundPolities(); this.reinforcePolityTradeRoutes(); this.collectAndRedistributeResources(); this.maybeBuildTreasuryCities(); this.detectPolityFamines(); this.erodePolityLegitimacy(); this.updateInstitutionalAging(); this.maybeTriggerInstitutionalReforms(); this.triggerPolityCrises(); this.applyOldStateStress(); this.updatePolityLeaders(); this.updateCityLoyalty(); this.maybeStartCampaigns(); this.splitUnloyalCities(); this.enforcePolityTreasuryCaps(); this.cleanupPolities(); this.samplePolityHistories(); this.recomputeTerritories({ allowOwnershipChanges: true, rebalanceOwnership: true, reason: "normal" }); this.reconcileCityTerritoryOwnership(); this.campaignPressureOnTerritories(); } reinforcePolityTradeRoutes() { for (const polity of this.polities) { if (this.rng.next() > 0.48) continue; const center = this.getCityById(polity.centerCityId); if (!center) continue; const candidates = this.getPolityCities(polity) .filter(city => city.id !== center.id && !this.hasDirectTradeConnection(center, city)) .filter(city => this.distanceBetweenCities(center, city) <= 40) .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 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 = 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; } polity.treasury -= roadCost; if (!this.payRouteConstructionCost(center, target, path, polity)) continue; this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id); } } 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); } desertCorridorShare(cityA, cityB, samples = 12) { if (!cityA || !cityB) return 0; const w = this.world; let desert = 0; let land = 0; for (let step = 0; step <= samples; step++) { const t = step / samples; const x = clamp(Math.round(lerp(cityA.x, cityB.x, t)), 0, w.size - 1); const y = clamp(Math.round(lerp(cityA.y, cityB.y, t)), 0, w.size - 1); const terrain = w.terrain[w.idx(x, y)]; if (terrain === Terrain.WATER) continue; land++; if (terrain === Terrain.DESERT) desert++; } return land > 0 ? desert / land : 0; } routeDesertShare(path) { if (!path?.length) return 0; let desert = 0; for (const tile of path) if (this.world.terrain[tile] === Terrain.DESERT) desert++; return desert / path.length; } 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; const desertShare = this.desertCorridorShare(cityA, cityB); const desertBonus = desertShare * (cfg.desertRouteLengthBonus ?? 30); const normalMax = cfg.adaptiveMaxRouteLength ?? 72; const desertMax = cfg.desertAdaptiveMaxRouteLength ?? normalMax; const upperLimit = lerp(normalMax, desertMax, desertShare); return Math.floor(clamp( base + clamp(Math.sqrt(avgPopulation) * 0.55, 0, 10) + avgKnowledge * 12 + (samePolity ? 7 : 0) + capital * 6 + org * 5 + treasury * 4 + desertBonus, base, upperLimit )); } 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; } createManualTradeRoute(cityAId, cityBId) { const cityA = this.getCityById(cityAId); const cityB = this.getCityById(cityBId); if (!cityA || !cityB) return { ok: false, reason: "missingCity" }; if (cityA.id === cityB.id) return { ok: false, reason: "sameCity" }; const cfg = SimConfig.route || {}; const direct = this.distanceBetweenCities(cityA, cityB); const maxLength = cfg.manualRouteMaxLength ?? 120; if (direct > maxLength) return { ok: false, reason: "tooFar", maxLength }; const ownerPolityId = cityA.polityId !== null && cityA.polityId === cityB.polityId ? cityA.polityId : null; const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; const naturalLimit = this.effectiveRouteLengthLimit(cityA, cityB, ownerPolity); const limit = Math.min(maxLength, Math.max(naturalLimit, Math.ceil(direct + 24))); const path = this.findTerrainRoute(cityA.x, cityA.y, cityB.x, cityB.y, limit, cityA, cityB, ownerPolity); if (!this.isValidRoutePath(path, cityB.x, cityB.y, limit)) return { ok: false, reason: "noPath" }; const protectionYears = cfg.manualProtectionYears ?? 25; const protectedUntil = this.year + years(protectionYears); const strength = Math.max(0.35, this.routeStrengthForPath(path)); this.manualTradeRoutes ??= []; this.manualTradeRoutes = this.manualTradeRoutes.filter(route => !((route.from === cityA.id && route.to === cityB.id) || (route.from === cityB.id && route.to === cityA.id))); const record = { from: cityA.id, to: cityB.id, path: [...path], strength, created: this.year, protectedUntil }; this.manualTradeRoutes.push(record); this.manualRouteProtection ??= new Map(); for (const tile of path) { if (this.world.terrain[tile] === Terrain.WATER) continue; this.manualRouteProtection.set(tile, Math.max(this.manualRouteProtection.get(tile) || 0, protectedUntil)); this.world.tradeRoute[tile] = Math.max(this.world.tradeRoute[tile], cfg.manualProtectedStrength ?? 32); this.activeTradeRouteTiles.add(tile); this.addPheromone(tile, cfg.newRoutePheromoneSeed ?? 2.5); } const existingLink = this.tradeLinks.some(link => { const endpoints = this.tradeLinkEndpointIds(link); return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id); }); cityA.tradeLinks.add(cityB.id); cityB.tradeLinks.add(cityA.id); if (!existingLink) { this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path: [...path], ownerPolityId, manual: true, protectedUntil }); } return { ok: true, path, protectionYears, protectedUntil }; } cleanupManualTradeRouteProtection() { this.manualTradeRoutes ??= []; this.manualRouteProtection ??= new Map(); const active = []; this.manualRouteProtection.clear(); for (const route of this.manualTradeRoutes) { if (!route || route.protectedUntil <= this.year) continue; const a = this.getCityById(route.from); const b = this.getCityById(route.to); if (!a || !b || !route.path?.length) continue; active.push(route); for (const tile of route.path) { if (tile < 0 || tile >= this.world.count || this.world.terrain[tile] === Terrain.WATER) continue; this.manualRouteProtection.set(tile, Math.max(this.manualRouteProtection.get(tile) || 0, route.protectedUntil)); } } this.manualTradeRoutes = active; } isManualRouteTileProtected(tile) { return (this.manualRouteProtection?.get(tile) || 0) > this.year; } restoreProtectedManualRoutes(supportedRoutes) { const cfg = SimConfig.route || {}; for (const route of this.manualTradeRoutes || []) { if (route.protectedUntil <= this.year) continue; const cityA = this.getCityById(route.from); const cityB = this.getCityById(route.to); if (!cityA || !cityB) continue; cityA.tradeLinks.add(cityB.id); cityB.tradeLinks.add(cityA.id); const ownerPolityId = cityA.polityId !== null && cityA.polityId === cityB.polityId ? cityA.polityId : null; const exists = this.tradeLinks.some(link => (link.from === cityA.id && link.to === cityB.id) || (link.from === cityB.id && link.to === cityA.id)); if (!exists) this.tradeLinks.push({ ...route, ownerPolityId, manual: true }); for (const tile of route.path) { if (tile < 0 || tile >= this.world.count || this.world.terrain[tile] === Terrain.WATER) continue; if (supportedRoutes) supportedRoutes.add(tile); this.world.tradeRoute[tile] = Math.max(this.world.tradeRoute[tile], cfg.manualProtectedStrength ?? 32); this.activeTradeRouteTiles.add(tile); } } } updateTradeRoutes(fieldBased = false) { const w = this.world; this.cleanupManualTradeRouteProtection(); for (let i = 0; i < w.count; i++) { const invalidTerrain = w.terrain[i] === Terrain.WATER; if (invalidTerrain) w.tradeRoute[i] = 0; else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0 && !this.isManualRouteTileProtected(i)) { const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); const decay = pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 1 : SimConfig.route.routeUnsupportedDecay; w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - decay); if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0; } } this.tradeLinks = []; for (const city of this.cities) city.tradeLinks.clear(); const supportedRoutes = this.supportedRouteScratch; supportedRoutes.clear(); this.restoreProtectedManualRoutes(supportedRoutes); const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400); 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); 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; const desertShare = this.routeDesertShare(path); const desertThresholdFactor = SimConfig.route.desertPheromoneThresholdFactor ?? 0.45; const buildThreshold = SimConfig.route.routePheromoneBuild * (1 - desertShare * (1 - desertThresholdFactor)); if (!fieldBased && ownerPolityId === null && pheromoneSupport < buildThreshold) 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; this.topHeapPush(candidates, { c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId }, routeCandidateLimit, "tradeScore"); } this.heapToDescending(candidates, "tradeScore"); for (const candidate of candidates) { if (this.tradeLinks.length >= maxLinks) break; const { c1, c2, strength, path, pheromoneSupport, ownerPolityId } = candidate; 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 (!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, 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)) { w.tradeRoute[i] = 0; } } this.activeTradeRouteTiles.clear(); for (let i = 0; i < w.count; i++) { if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i); } this.diffuseCityKnowledgeThroughTrade(); } pruneOrphanTradeRoutes() { const w = this.world; this.cleanupManualTradeRouteProtection(); const validLinks = []; const validTiles = new Set(); for (const link of this.tradeLinks || []) { if (link.manual && link.protectedUntil != null && link.protectedUntil <= this.year) continue; const a = this.getCityById(link.from); const b = this.getCityById(link.to); const path = link.path || []; if (!a || !b || !path.length) continue; const first = path[0]; const last = path[path.length - 1]; const endpointDistance = (tile, city) => Math.abs(tile % w.size - city.x) + Math.abs(Math.floor(tile / w.size) - city.y); const aligned = (endpointDistance(first, a) <= 1 && endpointDistance(last, b) <= 1) || (endpointDistance(first, b) <= 1 && endpointDistance(last, a) <= 1); if (!aligned) continue; validLinks.push(link); for (const tile of path) validTiles.add(tile); } // Protected user-created routes remain valid even if a normal route refresh has // not run since they were created. Once protection expires they are omitted and // can disappear under the ordinary route rules. for (const route of this.manualTradeRoutes || []) { if (route.protectedUntil <= this.year) continue; const a = this.getCityById(route.from); const b = this.getCityById(route.to); if (!a || !b || !route.path?.length) continue; const already = validLinks.some(link => { const endpoints = this.tradeLinkEndpointIds(link); return endpoints && endpoints.includes(a.id) && endpoints.includes(b.id); }); if (!already) validLinks.push({ ...route, manual: true }); for (const tile of route.path) if (w.terrain[tile] !== Terrain.WATER) validTiles.add(tile); } this.tradeLinks = validLinks; for (const city of this.cities) { city.tradeLinks ??= new Set(); city.tradeLinks.clear(); } for (const link of validLinks) { const a = this.getCityById(link.from); const b = this.getCityById(link.to); if (!a || !b) continue; a.tradeLinks.add(b.id); b.tradeLinks.add(a.id); } for (let tile = 0; tile < w.count; tile++) if (w.tradeRoute[tile] && !validTiles.has(tile)) w.tradeRoute[tile] = 0; this.activeTradeRouteTiles.clear(); for (const tile of validTiles) if (w.tradeRoute[tile]) this.activeTradeRouteTiles.add(tile); } registerTradeLink(cityA, cityB, strength, path, supportedRoutes = null, routeBoost = 18, pheromoneBoost = 0, ownerPolityId = null) { cityA.tradeLinks.add(cityB.id); cityB.tradeLinks.add(cityA.id); this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path, ownerPolityId }); 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); } } diffuseCityKnowledgeThroughTrade() { for (const link of this.tradeLinks) { const a = this.getCityById(link.from); const b = this.getCityById(link.to); if (!a || !b) continue; a.knowledge ??= { farming: 0, metallurgy: 0 }; b.knowledge ??= { farming: 0, metallurgy: 0 }; const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + link.strength), 0, 0.012); const farmingDelta = (a.knowledge.farming - b.knowledge.farming) * rate; const metallurgyDelta = (a.knowledge.metallurgy - b.knowledge.metallurgy) * rate; a.knowledge.farming = clamp(a.knowledge.farming - farmingDelta, 0, 1); b.knowledge.farming = clamp(b.knowledge.farming + farmingDelta, 0, 1); 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, 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 = []; let current = goal; while (current >= 0 && path.length <= limit + 8) { path.push(current); 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; const desertFactor = w.terrain[tile] === Terrain.DESERT ? (SimConfig.route.desertTraversalCostFactor ?? 0.68) : 1; return Math.max(0.45, (w.move[tile] * desertFactor + mountain) * routeRelief * pheromoneRelief); } routeStrengthForPath(path) { const w = this.world; if (!path.length) return 0; let route = 0; let pheromone = 0; let terrainEase = 0; for (const i of path) { route += w.tradeRoute[i] > 0 ? 1 : 0; pheromone += clamp(w.pheromone[i] / 9, 0, 1); terrainEase += 1 / Math.max(0.75, this.routeTileCost(i)); } return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2; } hasReservedRouteSegment(path, reservedTiles) { let previousReserved = false; for (let p = 1; p < path.length - 1; p++) { const reserved = reservedTiles.has(path[p]); if (reserved && previousReserved) return true; previousReserved = reserved; } return false; } routePheromoneSupport(path) { const w = this.world; if (!path.length) return 0; 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, 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; 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; } return true; } exchangeCityResources(a, b, strength, path) { const delta = (a.storedResources - b.storedResources) * 0.018 * strength; a.storedResources -= delta; b.storedResources += delta; const traffic = Math.min(0.8, strength * 0.26); this.depositRoutePheromone(path, traffic); } routeUpkeepCost(path, pheromoneSupport) { const pheromoneRelief = clamp(pheromoneSupport / Math.max(0.001, SimConfig.route.routePheromoneMaintain), 0, 1) * 0.55; 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); if (cost <= 0.001) return true; let remaining = cost; if (polity) { const paid = Math.min(polity.treasury || 0, remaining * 0.72); polity.treasury = Math.max(0, (polity.treasury || 0) - paid); remaining -= paid; } const cityShare = remaining * 0.5; remaining -= this.drainCityResources(cityA, cityShare); remaining -= this.drainCityResources(cityB, cityShare); if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining); if (remaining > 0 && polity) { const paid = Math.min(polity.treasury || 0, remaining); polity.treasury = Math.max(0, (polity.treasury || 0) - paid); remaining -= paid; } return remaining <= cost * 0.25; } weakenRoutePath(path, amount) { for (const tile of path) { this.world.tradeRoute[tile] = Math.max(0, this.world.tradeRoute[tile] - amount); if (this.world.tradeRoute[tile] < SimConfig.route.routeWeakThreshold) this.world.tradeRoute[tile] = 0; } } applyRouteConnectionBenefits(cityA, cityB, strength, pheromoneSupport, upkeepPaid) { const reliability = clamp(strength * 0.65 + pheromoneSupport * 0.35, 0, 1) * (upkeepPaid ? 1 : 0.55); if (reliability <= 0) return; const tradeValue = reliability * 0.18; cityA.tradeValue = (cityA.tradeValue || 0) + tradeValue; cityB.tradeValue = (cityB.tradeValue || 0) + tradeValue; cityA.tradeReach = Math.max(cityA.tradeReach || 0, reliability); cityB.tradeReach = Math.max(cityB.tradeReach || 0, reliability); const resourceBonus = reliability * 0.12; cityA.storedResources += resourceBonus; cityB.storedResources += resourceBonus; if (cityA.polityId !== null && cityA.polityId === cityB.polityId) { cityA.loyalty = clamp((cityA.loyalty ?? 0.5) + reliability * 0.002, 0, 1); cityB.loyalty = clamp((cityB.loyalty ?? 0.5) + reliability * 0.002, 0, 1); } } routePathDifficulty(path) { if (!path.length) return 1; let difficulty = 0; for (const tile of path) { let move = this.world.move[tile] || 1; if (this.world.terrain[tile] === Terrain.DESERT) move *= SimConfig.route.desertTraversalCostFactor ?? 0.68; 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 difficulty / path.length; } 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 desertShare = this.routeDesertShare(path); const desertFactor = SimConfig.route.desertConstructionCostFactor ?? 0.72; const terrainConstructionFactor = 1 - desertShare * (1 - desertFactor); const base = weakTiles * (polityBacked ? 0.16 : 0.12) * clamp(difficulty / 1.35, 0.75, 2.1) * terrainConstructionFactor; 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; 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, options = {}) { const cost = this.routeConstructionCost(path, !!polity, options); if (cost <= 0) return true; if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity, options)) return false; let remaining = cost; if (polity) { const treasuryPayment = Math.min(polity.treasury, cost * 0.65); polity.treasury -= treasuryPayment; remaining -= treasuryPayment; } const half = remaining * 0.5; const paidA = this.drainCityResources(cityA, half); const paidB = this.drainCityResources(cityB, half); remaining -= paidA + paidB; if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining); if (remaining > 0 && polity) { 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) { const paid = Math.min(city.storedResources, amount); city.storedResources -= paid; return paid; } addPheromone(tile, amount) { if (tile < 0 || tile >= this.world.count || amount <= 0) return; this.world.pheromone[tile] = Math.min(SimConfig.route.maxPheromone, this.world.pheromone[tile] + amount); if (this.world.pheromone[tile] > 0) this.activePheromoneTiles?.add(tile); } depositRoutePheromone(path, amount) { for (const i of path) { this.addPheromone(i, amount); } } updateEthnicStats() { for (const e of this.ethnicities.values()) { e.population = 0; e.diversity = 0; e.centroidX = 0; e.centroidY = 0; } for (const band of this.nomadBands || []) { const e = this.ethnicities.get(band.ethnicityId); if (!e) continue; 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 || 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; e.diversity /= e.population; e.averageTraits ??= this.randomTraits(); } } absorptionTargetForEthnicity(ethnicityId) { const ethnicity = this.ethnicities.get(ethnicityId); if (!ethnicity) return null; const scores = new Map(); const addScore = (id, amount) => { if (id == null || id === ethnicityId || amount <= 0 || !this.ethnicities.has(id)) return; scores.set(id, (scores.get(id) || 0) + amount); }; for (const [tile, mix] of this.tileEthnicMix) { const local = mix.get(ethnicityId) || 0; if (local <= 0) continue; for (const [id, count] of mix) addScore(id, count * 1.5); const begin = this.world.landNeighborStarts?.[tile] ?? 0; const end = this.world.landNeighborStarts?.[tile + 1] ?? begin; for (let index = begin; index < end; index++) { const neighborMix = this.tileEthnicMix.get(this.world.landNeighbors[index]); if (!neighborMix) continue; for (const [id, count] of neighborMix) addScore(id, count); } } for (const city of this.cities) { const local = city.ethnicityComposition?.get(ethnicityId) || 0; if (local <= 0) continue; for (const [id, count] of city.ethnicityComposition) addScore(id, count * 1.25); } let bestId = null; let bestScore = -Infinity; for (const [id, score] of scores) { if (score > bestScore) { bestId = id; bestScore = score; } } if (bestId !== null) return bestId; const parent = ethnicity.parent; if (parent != null && parent !== ethnicityId && this.ethnicities.has(parent)) return parent; let nearest = null; let nearestDistance = Infinity; for (const candidate of this.ethnicities.values()) { if (candidate.id === ethnicityId || (candidate.population || 0) <= 0) continue; const distance = Math.hypot((candidate.centroidX || 0) - (ethnicity.centroidX || 0), (candidate.centroidY || 0) - (ethnicity.centroidY || 0)); if (distance < nearestDistance) { nearest = candidate.id; nearestDistance = distance; } } return nearest; } absorbEthnicityInto(ethnicityId, targetId) { if (ethnicityId === targetId || !this.ethnicities.has(ethnicityId) || !this.ethnicities.has(targetId)) return false; for (const [tile, mix] of this.tileEthnicMix) { const amount = mix.get(ethnicityId) || 0; if (amount <= 0) continue; mix.delete(ethnicityId); mix.set(targetId, (mix.get(targetId) || 0) + amount); this.updateCultureTile(tile); } for (const city of this.cities) { const amount = city.ethnicityComposition?.get(ethnicityId) || 0; if (amount <= 0) continue; city.ethnicityComposition.delete(ethnicityId); city.ethnicityComposition.set(targetId, (city.ethnicityComposition.get(targetId) || 0) + amount); } for (const band of this.nomadBands || []) if (band.ethnicityId === ethnicityId) band.ethnicityId = targetId; for (const ethnicity of this.ethnicities.values()) if (ethnicity.parent === ethnicityId) ethnicity.parent = targetId; this.ethnicities.delete(ethnicityId); return true; } absorbSmallEthnicities() { const threshold = Math.max(0, SimConfig.culture?.absorptionPopulationThreshold ?? 30); if (threshold <= 0 || this.ethnicities.size <= 1) return 0; let absorbed = 0; const candidates = [...this.ethnicities.values()] .filter(ethnicity => (ethnicity.population || 0) > 0 && (ethnicity.population || 0) < threshold) .sort((a, b) => (a.population || 0) - (b.population || 0)); for (const ethnicity of candidates) { if (!this.ethnicities.has(ethnicity.id)) continue; const target = this.absorptionTargetForEthnicity(ethnicity.id); if (target == null) continue; if (this.absorbEthnicityInto(ethnicity.id, target)) absorbed++; } return absorbed; } splitDivergentEthnicities() { this.splitDivergentFieldEthnicities(); } splitDivergentFieldEthnicities() { let formed = 0; const divergenceSpeed = clamp(SimConfig.culture?.divergenceRateMultiplier ?? 0.25, 0.05, 1); const divergenceChance = 0.32; for (const e of [...this.ethnicities.values()]) { if (formed >= 1) break; if (this.year < (e.nextDivergenceCheck ?? 0)) continue; e.nextDivergenceCheck = this.year + years(this.rng.range(24, 90) / divergenceSpeed); if (e.population < 120 || e.diversity < 0.24) continue; const candidates = []; let population = 0; let tempSum = 0; let humidSum = 0; 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() < divergenceChance) { candidates.push({ tile, count }); population += count; tempSum += this.world.temperature[tile] * count; humidSum += this.world.humidity[tile] * count; } } if (population < 35 || candidates.length < 3) continue; const newId = this.createEthnicity(e.id, { temperature: tempSum / population, humidity: humidSum / population }); 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); } formed++; } } }