From 8ff27ca2bcb6d905dd9ea53a102ecf342e620776 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Wed, 13 May 2026 20:21:58 +0900 Subject: [PATCH] tweak --- script.js | 8666 ++++++++++++++++++++++++++--------------------------- 1 file changed, 4188 insertions(+), 4478 deletions(-) diff --git a/script.js b/script.js index f502b26..36d8474 100644 --- a/script.js +++ b/script.js @@ -1,3790 +1,3520 @@ const Terrain = Object.freeze({ - PLAINS: 0, - FOREST: 1, - MOUNTAIN: 2, - WATER: 3, - DESERT: 4, - FERTILE: 5, - MINERAL: 6 +PLAINS: 0, +FOREST: 1, +MOUNTAIN: 2, +WATER: 3, +DESERT: 4, +FERTILE: 5, +MINERAL: 6 }); - const terrainInfo = [ - { name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 }, - { name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 }, - { name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 }, - { name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 }, - { name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 }, - { name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 }, - { name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 } +{ name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 }, +{ name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 }, +{ name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 }, +{ name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 }, +{ name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 }, +{ name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 }, +{ name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 } ]; - const MONTHS_PER_YEAR = 12; const SAVE_KEY = "civil-emergence-save"; const SAVE_VERSION = 3; const MAX_SAVE_BYTES = 4_500_000; - const SimConfig = Object.freeze({ - population: Object.freeze({ - maxAgentsFloor: 8000, - maxAgentsScale: 1.15, - maxOffspringPerStep: 360, - carryingCapacityBase: 2.4, - carryingCapacityFertility: 7.8, - carryingCapacityMineral: 1.8, - carryingCapacityFarmland: 5.5, - pressurePenaltyBase: 0.2, - pressureReproductionPenalty: 0.16 - }), - city: Object.freeze({ - maxCities: 300 - }), - render: Object.freeze({ - graphThrottleMs: 700, - statsThrottleMs: 350, - historyWindowYears: 2000, - historySamplePaddingYears: 240 - }), - save: Object.freeze({ - key: SAVE_KEY, - version: SAVE_VERSION, - maxBytes: MAX_SAVE_BYTES - }), - technology: Object.freeze({ - cityDiffusion: 0.0022, - tradeDiffusion: 0.0032, - cityInnovation: 0.00045 - }), - polity: Object.freeze({ - minimumPerCapitaFood: 0.06, - logisticsDistance: 34 - }), - culture: Object.freeze({ - spreadRadius: 3, - cityWeight: 0.035, - routeWeight: 1.35, - minimumInfluence: 0.18 - }), - disaster: Object.freeze({ - checkIntervalYears: 8, - baseChance: 0.38, - minRadius: 6, - maxRadius: 34, - rareLargeChance: 0.10, - largeRadiusMin: 28, - largeRadiusMax: 54, - minIntensity: 0.25, - maxIntensity: 0.95, - visualDurationYears: 18, - maxActiveVisuals: 12, - maxHistory: 160 - }) +population: Object.freeze({ +maxAgentsFloor: 8000, +maxAgentsScale: 1.15, +maxOffspringPerStep: 360, +carryingCapacityBase: 2.4, +carryingCapacityFertility: 7.8, +carryingCapacityMineral: 1.8, +carryingCapacityFarmland: 5.5, +pressurePenaltyBase: 0.2, +pressureReproductionPenalty: 0.16 +}), +city: Object.freeze({ +maxCities: 300 +}), +render: Object.freeze({ +graphThrottleMs: 1400, +statsThrottleMs: 900, +historyWindowYears: 2000, +historySamplePaddingYears: 240 +}), +save: Object.freeze({ +key: SAVE_KEY, +version: SAVE_VERSION, +maxBytes: MAX_SAVE_BYTES +}), +technology: Object.freeze({ +cityDiffusion: 0.0022, +tradeDiffusion: 0.0032, +cityInnovation: 0.00045 +}), +polity: Object.freeze({ +minimumPerCapitaFood: 0.06, +logisticsDistance: 34 +}), +culture: Object.freeze({ +spreadRadius: 3, +cityWeight: 0.035, +routeWeight: 1.35, +minimumInfluence: 0.18 +}), +disaster: Object.freeze({ +damageScale: 1.5, +majorLossRate: 0.05, +checkIntervalYears: 8, +baseChance: 0.38, +minRadius: 6, +maxRadius: 34, +rareLargeChance: 0.10, +largeRadiusMin: 28, +largeRadiusMax: 54, +minIntensity: 0.25, +maxIntensity: 0.95, +visualDurationYears: 18, +maxActiveVisuals: 12, +maxHistory: 160 +}) }); - function years(value) { - return value * MONTHS_PER_YEAR; +return value * MONTHS_PER_YEAR; } - const els = { - canvas: document.getElementById("world"), - sim: document.querySelector(".sim"), - toggleRun: document.getElementById("toggleRun"), - stepOnce: document.getElementById("stepOnce"), - resetWorld: document.getElementById("resetWorld"), - saveWorld: document.getElementById("saveWorld"), - loadWorld: document.getElementById("loadWorld"), - clearSave: document.getElementById("clearSave"), - speed: document.getElementById("speed"), - agentCount: document.getElementById("agentCount"), - worldSize: document.getElementById("worldSize"), - viewMode: document.getElementById("viewMode"), - year: document.getElementById("year"), - activeGroups: document.getElementById("activeGroups"), - urbanPopulation: document.getElementById("urbanPopulation"), - ethnicities: document.getElementById("ethnicities"), - cities: document.getElementById("cities"), - polities: document.getElementById("polities"), - wars: document.getElementById("wars"), - routes: document.getElementById("routes"), - farmingKnowledge: document.getElementById("farmingKnowledge"), - metallurgyKnowledge: document.getElementById("metallurgyKnowledge"), - deaths: document.getElementById("deaths"), - frameCost: document.getElementById("frameCost"), - ethnicityList: document.getElementById("ethnicityList"), - legend: document.getElementById("legend"), - tooltip: document.getElementById("tooltip"), - stateGraph: document.getElementById("stateGraph"), - historyRange: document.getElementById("historyRange"), - historyFilter: document.getElementById("historyFilter"), - historySort: document.getElementById("historySort"), - historyMetric: document.getElementById("historyMetric"), - toggleActiveStates: document.getElementById("toggleActiveStates"), - togglePastStates: document.getElementById("togglePastStates"), - stateGraphInfo: document.getElementById("stateGraphInfo"), - stateGraphTooltip: document.getElementById("stateGraphTooltip") +canvas: document.getElementById("world"), +sim: document.querySelector(".sim"), +toggleRun: document.getElementById("toggleRun"), +stepOnce: document.getElementById("stepOnce"), +resetWorld: document.getElementById("resetWorld"), +saveWorld: document.getElementById("saveWorld"), +loadWorld: document.getElementById("loadWorld"), +clearSave: document.getElementById("clearSave"), +speed: document.getElementById("speed"), +agentCount: document.getElementById("agentCount"), +worldSize: document.getElementById("worldSize"), +viewMode: document.getElementById("viewMode"), +year: document.getElementById("year"), +activeGroups: document.getElementById("activeGroups"), +urbanPopulation: document.getElementById("urbanPopulation"), +ethnicities: document.getElementById("ethnicities"), +cities: document.getElementById("cities"), +polities: document.getElementById("polities"), +wars: document.getElementById("wars"), +routes: document.getElementById("routes"), +farmingKnowledge: document.getElementById("farmingKnowledge"), +metallurgyKnowledge: document.getElementById("metallurgyKnowledge"), +deaths: document.getElementById("deaths"), +frameCost: document.getElementById("frameCost"), +ethnicityList: document.getElementById("ethnicityList"), +legend: document.getElementById("legend"), +tooltip: document.getElementById("tooltip"), +stateGraph: document.getElementById("stateGraph"), +historyRange: document.getElementById("historyRange"), +historyFilter: document.getElementById("historyFilter"), +historySort: document.getElementById("historySort"), +historyMetric: document.getElementById("historyMetric"), +toggleActiveStates: document.getElementById("toggleActiveStates"), +togglePastStates: document.getElementById("togglePastStates"), +stateGraphInfo: document.getElementById("stateGraphInfo"), +stateGraphTooltip: document.getElementById("stateGraphTooltip") }; - const ctx = els.canvas.getContext("2d", { alpha: false }); let sim; let running = true; let frame = 0; let lastStatsAt = 0; let lastGraphRenderAt = 0; +let lastRenderAt = 0; +let lastSimTickAt = 0; +let renderDirty = true; +let loopTimer = null; +let loopScheduled = false; let hoverState = null; let renderImage = null; let renderImageSize = 0; +const LoopConfig = Object.freeze({ +simTickMs: 33, +renderMs: 66, +stepBudgetMs: 4.5, +idlePollMs: 180, +hiddenPollMs: 1000 +}); const graphState = { - filter: "all", - sort: "important", - metric: "power", - showActive: true, - showPast: true, - hoverPolityId: null, - rows: [], - markers: [], - scale: null +filter: "all", +sort: "important", +metric: "power", +showActive: true, +showPast: true, +hoverPolityId: null, +rows: [], +markers: [], +scale: null }; - 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); - } +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, generate = true) { - 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.pressure = new Float32Array(this.count); - this.dominantEthnicity = new Int32Array(this.count); - this.cultureDiversity = new Float32Array(this.count); - this.city.fill(-1); - this.dominantEthnicity.fill(-1); - if (generate) this.generate(); - } - - static blank(size, rng) { - return new World(size, rng, false); - } - - 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 = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN; - else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT; - else if (humid > 0.72 && temp > 0.25 && temp < 0.88) t = Terrain.FERTILE; - else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST; - else t = Terrain.PLAINS; - } - - const info = terrainInfo[t]; - this.terrain[i] = t; - this.fertility[i] = 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); - for (let y = 0; y < s; y++) { - for (let x = 0; x < s; x++) { - const i = this.idx(x, y); - if (this.terrain[i] === Terrain.WATER) continue; - let waterScore = 0; - 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; - const tx = x + dx; - const ty = y + dy; - if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue; - if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) waterScore += (4 - d) / 4; - } - } - 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; - if ((this.terrain[i] === Terrain.PLAINS || this.terrain[i] === Terrain.FOREST) && nextFertility[i] > 0.82) this.terrain[i] = Terrain.FERTILE; - } - } - - for (let i = 0; i < this.count; i++) { - const info = terrainInfo[this.terrain[i]]; - this.fertility[i] = nextFertility[i]; - this.regen[i] = info.regen * (0.7 + this.fertility[i]); - this.move[i] = info.move; - if (this.terrain[i] === Terrain.WATER) { - this.resource[i] = 0; - } else { - this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25)); - } - } - } - - 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; - for (let y = 0; y < s; y++) { - for (let x = 0; x < s; x++) { - const i = this.idx(x, y); - if (this.terrain[i] === Terrain.WATER) { - this.humidity[i] = 1; - continue; - } - let best = 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 >= s || ty >= s) continue; - if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) best = Math.max(best, 1 - d / (radius + 1)); - } - } - this.humidity[i] = clamp(best * 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))]; - } +constructor(size, rng, generate = true) { +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.pressure = new Float32Array(this.count); +this.dominantEthnicity = new Int32Array(this.count); +this.cultureDiversity = new Float32Array(this.count); +this.city.fill(-1); +this.dominantEthnicity.fill(-1); +if (generate) this.generate(); +} +static blank(size, rng) { +return new World(size, rng, false); +} +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 = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN; +else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT; +else if (humid > 0.72 && temp > 0.25 && temp < 0.88) t = Terrain.FERTILE; +else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST; +else t = Terrain.PLAINS; +} +const info = terrainInfo[t]; +this.terrain[i] = t; +this.fertility[i] = 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); +for (let y = 0; y < s; y++) { +for (let x = 0; x < s; x++) { +const i = this.idx(x, y); +if (this.terrain[i] === Terrain.WATER) continue; +let waterScore = 0; +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; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue; +if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) waterScore += (4 - d) / 4; +} +} +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; +if ((this.terrain[i] === Terrain.PLAINS || this.terrain[i] === Terrain.FOREST) && nextFertility[i] > 0.82) this.terrain[i] = Terrain.FERTILE; +} +} +for (let i = 0; i < this.count; i++) { +const info = terrainInfo[this.terrain[i]]; +this.fertility[i] = nextFertility[i]; +this.regen[i] = info.regen * (0.7 + this.fertility[i]); +this.move[i] = info.move; +if (this.terrain[i] === Terrain.WATER) { +this.resource[i] = 0; +} else { +this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25)); +} +} +} +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; +for (let y = 0; y < s; y++) { +for (let x = 0; x < s; x++) { +const i = this.idx(x, y); +if (this.terrain[i] === Terrain.WATER) { +this.humidity[i] = 1; +continue; +} +let best = 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 >= s || ty >= s) continue; +if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) best = Math.max(best, 1 - d / (radius + 1)); +} +} +this.humidity[i] = clamp(best * 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, initialAgents, options = {}) { - this.rng = new Rng(Date.now()); - this.world = options.blankWorld ? World.blank(size, this.rng) : new World(size, this.rng); - this.agents = []; - this.ethnicities = new Map(); - this.cities = []; - this.cityById = new Map(); - this.cityBuckets = new Map(); - this.polities = []; - this.polityById = new Map(); - this.wars = []; - this.disasters = []; - this.disasterHistory = []; - this.polityHistory = new Map(); - this.deadPolityHistories = []; - this.tradeLinks = []; - this.activeTradeRouteTiles = new Set(); - this.ethnicDensityCache = new Map(); - this.dominantEthnicityCache = new Map(); - this.nextEthnicity = 1; - this.nextCity = 1; - this.nextPolity = 1; - this.nextWar = 1; - this.nextDisaster = 1; - this.year = 0; - this.deaths = 0; - this.maxAgents = Math.max( - Math.floor(initialAgents * SimConfig.population.maxAgentsScale), - SimConfig.population.maxAgentsFloor - ); - this.tileEthnicities = new Map(); - this.tileAgents = new Map(); - if (!options.skipSpawn) { - this.spawnInitialAgents(initialAgents); - this.rebuildOccupancy(); - this.updateEthnicStats(); - } - } - - spawnInitialAgents(count) { - const founders = Math.max(5, Math.min(16, Math.round(count / 180))); - const desertFounders = Math.max(2, Math.floor(founders * 0.25)); - for (let e = 0; e < founders; e++) { - const desertFounder = e < desertFounders; - const origin = this.findHabitableTile(desertFounder ? Terrain.DESERT : null); - const originTile = this.world.idx(origin.x, origin.y); - const id = this.createEthnicity(0, { - temperature: this.world.temperature[originTile], - humidity: this.world.humidity[originTile] - }); - const baseTraits = desertFounder ? this.desertFounderTraits() : this.randomTraits(); - for (let n = 0; n < Math.floor(count / founders); n++) { - const spawn = this.findNearbySpawn(origin.x, origin.y, desertFounder ? Terrain.DESERT : null); - this.agents.push(this.makeAgent( - spawn.x, - spawn.y, - id, - mutateTraits(baseTraits, this.rng, 0.07), - desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15) - )); - } - } - } - - 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 - }); - 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; - } - - makeAgent(x, y, ethnicity, traits, resources) { - return { - x, - y, - resources, - ethnicity, - traits, - alive: true, - foreignContact: 0, - contactEthnicity: ethnicity, - settled: 0, - movedThisStep: false, - tech: { - farming: 0, - metallurgy: 0 - }, - farmingWork: 0, - lastFarmTile: -1 - }; - } - - findNearbySpawn(originX, originY, preferredTerrain = null) { - const w = this.world; - let best = { x: originX, y: originY }; - let bestScore = -Infinity; - for (let tries = 0; tries < 24; tries++) { - const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1); - const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1); - const i = w.idx(x, y); - if (w.terrain[i] === Terrain.WATER) continue; - const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0; - const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16; - if (score > bestScore) { - bestScore = score; - best = { x, y }; - } - } - return best; - } - - findHabitableTile(preferredTerrain = null) { - if (preferredTerrain !== null) { - const exact = []; - 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 }; - } - - step() { - for (const city of this.cities) city.activeVisitors = 0; - this.rebuildOccupancy(); - this.ethnicDensityCache.clear(); - - const offspring = []; - for (const a of this.agents) { - if (!a.alive) continue; - this.moveAgent(a); - } - this.rebuildOccupancy(); - this.dominantEthnicityCache.clear(); - - for (const a of this.agents) { - if (!a.alive) continue; - this.updateAgentTechnology(a); - this.gatherConsumeReproduce(a, offspring); - if (a.alive) this.payTechnologyCostOrForget(a); - if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a); - } - - this.agents = this.agents.filter(a => a.alive); - const acceptedOffspring = this.agents.length + offspring.length < this.maxAgents - ? offspring - : offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length)); - this.agents.push(...acceptedOffspring); - for (const child of acceptedOffspring) this.addAgentToOccupancy(child); - - if (this.year % 2 === 0) this.updateWorldFields(2); - this.maybeSpawnDisaster(); - if (this.year % years(1) === 0) { - this.updateCities(); - if (this.year % years(2) === 0) this.updateRegionalCultures(); - } - if (this.year % years(5) === 0) { - this.updateTradeRoutes(); - } - this.updatePolities(); - this.updateEthnicStats(); - if (this.year % years(45) === 0) { - this.splitDivergentEthnicities(); - this.updateEthnicStats(); - } - this.year++; - } - - rebuildOccupancy() { - const w = this.world; - w.pressure.fill(0); - this.tileEthnicities.clear(); - this.tileAgents.clear(); - this.rebuildIndexes(); - for (const a of this.agents) { - if (!a.alive) continue; - const i = w.idx(a.x, a.y); - w.pressure[i]++; - let agents = this.tileAgents.get(i); - if (!agents) { - agents = []; - this.tileAgents.set(i, agents); - } - agents.push(a); - let counts = this.tileEthnicities.get(i); - if (!counts) { - counts = new Map(); - this.tileEthnicities.set(i, counts); - } - counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1); - } - for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile); - } - - rebuildIndexes() { - this.cityById = new Map(this.cities.map(city => [city.id, city])); - this.polityById = new Map(this.polities.map(polity => [polity.id, polity])); - this.cityBuckets = new Map(); - 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)}`; - } - - 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}`); - 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; - } - - updateCultureTile(tile) { - const counts = this.tileEthnicities.get(tile); - if (!counts || !counts.size) { - this.world.cultureDiversity[tile] *= 0.98; - 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; - } - - updateRegionalCultures() { - const w = this.world; - const nextDominant = new Int32Array(w.dominantEthnicity); - const nextDiversity = new Float32Array(w.cultureDiversity); - const radius = SimConfig.culture.spreadRadius; - const cityById = new Map(this.cities.map(city => [city.id, city])); - - for (let y = 0; y < w.size; y++) { - for (let x = 0; x < w.size; x++) { - const tile = w.idx(x, y); - if (w.terrain[tile] === Terrain.WATER) continue; - const influence = new Map(); - let total = 0; - - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const distance = Math.abs(dx) + Math.abs(dy); - if (distance > radius) continue; - const tx = x + dx; - const ty = y + dy; - if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; - const source = w.idx(tx, ty); - const id = w.dominantEthnicity[source]; - if (id < 0) continue; - const pressureWeight = Math.sqrt(Math.max(0, w.pressure[source])); - const cityWeight = w.city[source] >= 0 - ? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight - : 0; - const routeWeight = w.tradeRoute[source] ? SimConfig.culture.routeWeight : 1; - const weight = (pressureWeight + cityWeight) * routeWeight / (1 + distance); - if (weight <= 0) continue; - influence.set(id, (influence.get(id) || 0) + weight); - total += weight; - } - } - - let bestId = nextDominant[tile]; - let bestWeight = 0; - for (const [id, weight] of influence) { - if (weight > bestWeight) { - bestId = id; - bestWeight = weight; - } - } - if (bestWeight >= SimConfig.culture.minimumInfluence) { - nextDominant[tile] = bestId; - nextDiversity[tile] = total > 0 ? clamp(1 - bestWeight / total, 0, 1) : 0; - } - } - } - - w.dominantEthnicity.set(nextDominant); - w.cultureDiversity.set(nextDiversity); - } - - addAgentToOccupancy(a) { - if (!a.alive) return; - const w = this.world; - const i = w.idx(a.x, a.y); - w.pressure[i]++; - let agents = this.tileAgents.get(i); - if (!agents) { - agents = []; - this.tileAgents.set(i, agents); - } - agents.push(a); - let counts = this.tileEthnicities.get(i); - if (!counts) { - counts = new Map(); - this.tileEthnicities.set(i, counts); - } - counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1); - this.updateCultureTile(i); - } - - removeAgentFromOccupancy(a) { - const w = this.world; - const i = w.idx(a.x, a.y); - w.pressure[i] = Math.max(0, w.pressure[i] - 1); - const agents = this.tileAgents.get(i); - if (agents) { - const index = agents.indexOf(a); - if (index >= 0) agents.splice(index, 1); - if (!agents.length) this.tileAgents.delete(i); - } - const counts = this.tileEthnicities.get(i); - if (counts) { - const next = (counts.get(a.ethnicity) || 0) - 1; - if (next > 0) counts.set(a.ethnicity, next); - else counts.delete(a.ethnicity); - if (!counts.size) this.tileEthnicities.delete(i); - } - this.updateCultureTile(i); - } - - changeAgentEthnicity(a, nextEthnicity) { - if (a.ethnicity === nextEthnicity) return; - const w = this.world; - const i = w.idx(a.x, a.y); - const counts = this.tileEthnicities.get(i); - if (counts) { - const prev = (counts.get(a.ethnicity) || 0) - 1; - if (prev > 0) counts.set(a.ethnicity, prev); - else counts.delete(a.ethnicity); - counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1); - } - a.ethnicity = nextEthnicity; - this.updateCultureTile(i); - } - - moveAgent(a) { - const w = this.world; - const s = w.size; - const current = w.idx(a.x, a.y); - const localPressure = w.pressure[current]; - let bestX = a.x; - let bestY = a.y; - let bestScore = -Infinity; - const sedentary = getSedentary(a.traits); - const ethnocentrism = getEthnocentrism(a.traits); - const ethnicClimate = this.ethnicities.get(a.ethnicity); - const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45; - const localCapacity = this.carryingCapacityAt(current); - const localOverCapacity = Math.max(0, localPressure - localCapacity); - if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) { - a.settled++; - a.movedThisStep = false; - return; - } - const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1; - - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const x = clamp(a.x + dx, 0, s - 1); - const y = clamp(a.y + dy, 0, s - 1); - const i = w.idx(x, y); - const isWater = w.terrain[i] === Terrain.WATER; - const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45; - const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0); - if (isWater && this.rng.next() > waterAccess) continue; - - const ethnicDensity = ethnocentrism > 0.04 - ? this.ethnicDensityNear(x, y, a.ethnicity) - : { same: 0, foreign: 0 }; - const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i]; - const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary; - const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0); - const destinationCapacity = this.carryingCapacityAt(i); - const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity); - const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1); - const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85); - const routeBonus = w.tradeRoute[i] ? 1.25 : 0; - const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0; - const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus; - const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0; - const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0; - const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45); - - const score = - resourceScore * a.traits.resourceAttraction + - w.pheromone[i] * 0.021 + - ethnicDensity.same * ethnocentrism + - cityPull * sedentary + - routePull + - capacityPull + - inertia - - terrainPenalty - - crowdPenalty + - pressurePush - - ethnicDensity.foreign * ethnocentrism * 0.72 + - this.rng.range(-0.55, 0.55); - - if (score > bestScore) { - bestScore = score; - bestX = x; - bestY = y; - } - } - } - - const from = w.idx(a.x, a.y); - a.x = bestX; - a.y = bestY; - const to = w.idx(a.x, a.y); - if (from !== to) { - a.movedThisStep = true; - a.farmingWork = 0; - a.lastFarmTile = to; - const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1; - w.pheromone[from] += (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale; - w.pheromone[to] += (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale; - a.settled = Math.max(0, a.settled - 1); - } else { - a.movedThisStep = false; - a.settled++; - } - } - - ensureAgentTech(a) { - a.tech ??= { farming: 0, metallurgy: 0 }; - a.tech.farming ??= 0; - a.tech.metallurgy ??= 0; - a.tech.farming = clamp(a.tech.farming, 0, 1); - a.tech.metallurgy = clamp(a.tech.metallurgy, 0, 1); - a.farmingWork ??= 0; - a.lastFarmTile ??= -1; - a.movedThisStep ??= false; - } - - farmabilityAt(tile) { - const t = this.world.terrain[tile]; - if (t === Terrain.FERTILE) return 1.0; - if (t === Terrain.PLAINS) return 0.75; - if (t === Terrain.FOREST) return 0.55; - if (t === Terrain.DESERT) return 0.25; - return 0.0; - } - - carryingCapacityAt(tile) { - const w = this.world; - if (w.terrain[tile] === Terrain.WATER) return 1.2; - return SimConfig.population.carryingCapacityBase + - w.fertility[tile] * SimConfig.population.carryingCapacityFertility + - w.mineral[tile] * SimConfig.population.carryingCapacityMineral + - w.farmland[tile] * SimConfig.population.carryingCapacityFarmland + - (w.city[tile] >= 0 ? 3.5 : 0); - } - - updateAgentTechnology(agent, payMaintenance = false) { - if (!agent.alive) return; - this.ensureAgentTech(agent); - this.updateFarmingWork(agent); - this.tryInventTechnology(agent); - this.learnTechnologyFromCity(agent); - if (this.shouldRunAgentSocial(agent, 3)) { - this.spreadTechnology(agent); - this.improveTechnologyFromDensity(agent); - } - if (payMaintenance) this.payTechnologyCostOrForget(agent); - } - - shouldRunAgentSocial(agent, interval) { - return ((agent.x * 31 + agent.y * 17 + this.year) % interval) === 0; - } - - updateFarmingWork(agent) { - const w = this.world; - const tile = w.idx(agent.x, agent.y); - if (agent.lastFarmTile !== tile) { - agent.farmingWork = 0; - agent.lastFarmTile = tile; - } - if (agent.movedThisStep) return; - - const farming = agent.tech?.farming || 0; - if (farming <= 0.02) return; - if (agent.settled > 0) { - agent.farmingWork = clamp((agent.farmingWork || 0) + farming * 0.015, 0, 1); - agent.tech.farming = clamp(agent.tech.farming + farming * this.farmabilityAt(tile) * 0.00018, 0, 1); - } - } - - tryInventTechnology(agent) { - const w = this.world; - const tile = w.idx(agent.x, agent.y); - const settledFactor = clamp((agent.settled || 0) / 12, 0, 1); - - if ((agent.tech?.farming || 0) <= 0.02) { - const farmingBase = 0.00016; - const farmability = this.farmabilityAt(tile); - const sedentary = getSedentary(agent.traits); - const farmingChance = - farmingBase * - (0.25 + farmability) * - (0.4 + sedentary) * - (0.3 + settledFactor); - if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32); - } - - if ((agent.tech?.metallurgy || 0) <= 0.02) { - const metallurgyBase = 0.00011; - const mineral = clamp(w.mineral[tile], 0, 1); - const mineralTerrainBonus = - w.terrain[tile] === Terrain.MINERAL || w.terrain[tile] === Terrain.MOUNTAIN - ? 1.8 - : 1.0; - const metallurgyChance = - metallurgyBase * - (0.2 + mineral) * - mineralTerrainBonus * - (0.5 + settledFactor); - if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.28); - } - } - - grantTechnologyAround(agent, techName, amount) { - this.ensureAgentTech(agent); - this.forEachLocalAgentNear(agent.x, agent.y, 2, other => { - if (!other.alive) return true; - this.ensureAgentTech(other); - const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y); - if (distance > 2) return true; - const gain = amount * (other === agent ? 1 : 0.55); - other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1); - return true; - }); - } - - payTechnologyCostOrForget(agent) { - if (!agent.alive) return; - this.ensureAgentTech(agent); - const farming = agent.tech.farming || 0; - const metallurgy = agent.tech.metallurgy || 0; - const cost = farming * 0.004 + metallurgy * 0.010; - if (cost <= 0) return; - - if (agent.resources >= cost) { - agent.resources -= cost; - return; - } - - const shortage = cost - Math.max(0, agent.resources); - agent.resources = Math.max(0, agent.resources - cost); - const decay = clamp(0.006 + shortage * 0.025, 0.006, 0.06); - agent.tech.farming = Math.max(0, farming - decay); - agent.tech.metallurgy = Math.max(0, metallurgy - decay * 1.15); - if (agent.tech.farming < 0.02) agent.tech.farming = 0; - if (agent.tech.metallurgy < 0.02) agent.tech.metallurgy = 0; - if (agent.tech.farming <= 0) agent.farmingWork = 0; - } - - spreadTechnology(agent) { - const w = this.world; - const tile = w.idx(agent.x, agent.y); - const assimilation = agent.traits.assimilation || 0; - const ethnocentrism = agent.traits.ethnocentrism || 0; - let checked = 0; - - this.forEachLocalAgentNear(agent.x, agent.y, 1, other => { - if (other === agent || !other.alive) return true; - if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true; - if (++checked > 12) return false; - this.ensureAgentTech(other); - const sameEthnicity = agent.ethnicity === other.ethnicity; - let chance = - 0.0035 + - assimilation * 0.006 - - ethnocentrism * 0.0012; - - if (sameEthnicity) chance += 0.004; - if (w.tradeRoute[tile]) chance += 0.006; - if (w.city[tile] >= 0) chance += 0.003; - chance = clamp(chance, 0.0008, 0.028); - - this.learnTechnologyFrom(agent, other, "farming", chance); - this.learnTechnologyFrom(agent, other, "metallurgy", chance); - return true; - }); - } - - learnTechnologyFrom(agent, other, techName, chance) { - const current = agent.tech[techName] || 0; - const otherLevel = other.tech?.[techName] || 0; - if (otherLevel > current + 0.04 && this.rng.next() < chance) { - agent.tech[techName] = clamp(current + (otherLevel - current) * 0.14, 0, 1); - } - } - - learnTechnologyFromCity(agent) { - const tile = this.world.idx(agent.x, agent.y); - const city = this.world.city[tile] >= 0 ? this.getCityById(this.world.city[tile]) : null; - if (!city?.knowledge) return; - const routeBonus = this.world.tradeRoute[tile] ? SimConfig.technology.tradeDiffusion : 0; - const chance = SimConfig.technology.cityDiffusion + routeBonus + clamp(city.population / 1200, 0, 0.006); - if (this.rng.next() < chance) { - agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * 0.72), 0, 1); - agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1); - } - } - - improveTechnologyFromDensity(agent) { - let farmingCount = 0; - let metallurgyCount = 0; - let farmingSum = 0; - let metallurgySum = 0; - - this.forEachLocalAgentNear(agent.x, agent.y, 2, other => { - if (!other.alive) return true; - this.ensureAgentTech(other); - const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y); - if (distance > 2) return true; - const farming = other.tech.farming || 0; - const metallurgy = other.tech.metallurgy || 0; - if (farming > 0.1) { - farmingCount++; - farmingSum += farming; - } - if (metallurgy > 0.1) { - metallurgyCount++; - metallurgySum += metallurgy; - } - return true; - }); - - if (farmingCount >= 2) { - const avgFarming = farmingSum / farmingCount; - agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1); - } - - if (metallurgyCount >= 2) { - const avgMetallurgy = metallurgySum / metallurgyCount; - agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1); - } - } - - forEachLocalAgentNear(x, y, radius, callback) { - const w = this.world; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const tx = x + dx; - const ty = y + dy; - if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; - const agents = this.tileAgents.get(w.idx(tx, ty)); - if (!agents) continue; - for (const agent of agents) { - if (callback(agent) === false) return; - } - } - } - } - - farmingGatherMultiplier(agent, tile) { - const farmingLevel = agent.tech?.farming || 0; - const farmingWork = agent.farmingWork || 0; - const farmability = this.farmabilityAt(tile); - return clamp(1 + farmingLevel * farmingWork * farmability * 2, 1, 3); - } - - metallurgyGatherMultiplier(agent, tile) { - const metallurgyLevel = agent.tech?.metallurgy || 0; - if (metallurgyLevel > 0.02) { - agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1); - } - return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1); - } - - gatherConsumeReproduce(a, offspring) { - const w = this.world; - const i = w.idx(a.x, a.y); - this.ensureAgentTech(a); - const ethnicity = this.ethnicities.get(a.ethnicity); - const climateMismatch = this.climateMismatch(a.ethnicity, i); - const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1); - const cityMarket = w.city[i] >= 0 ? 0.28 : 0; - const drylandAdapted = this.isDrylandAdapted(ethnicity); - const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0; - const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage; - const carryingCapacity = this.carryingCapacityAt(i); - const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity); - const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase); - const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2); - const gathered = Math.min( - w.resource[i], - baseGatherAmount * this.farmingGatherMultiplier(a, i) * this.metallurgyGatherMultiplier(a, i) - ); - w.resource[i] -= gathered; - a.resources += gathered; - const waterAdaptation = ethnicity?.climateHumidity ?? 0.45; - const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0; - const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4; - const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1; - const sedentary = getSedentary(a.traits); - const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08; - a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead; - - if (a.resources <= 0) { - this.removeAgentFromOccupancy(a); - a.alive = false; - this.deaths++; - return; - } - - const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12); - const mobilityPenalty = (1 - sedentary) * 0.45; - const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty; - const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35); - if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) { - const childShare = 0.36 + sedentary * 0.08; - const childResources = a.resources * childShare; - a.resources -= childResources; - const childTraits = mutateTraits(a.traits, this.rng, 0.035); - const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources); - child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1); - child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1); - offspring.push(child); - } - } - - climateMismatch(ethnicityId, tile) { - 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; - } - - isDrylandAdapted(ethnicity) { - return !!ethnicity && ethnicity.climateTemp > 0.5 && ethnicity.climateHumidity < 0.38; - } - - ethnicDensityNear(x, y, ethnicity) { - const key = `${x},${y},${ethnicity}`; - const cached = this.ethnicDensityCache.get(key); - if (cached) return cached; - let same = 0; - let foreign = 0; - const w = this.world; - for (let dy = -2; dy <= 2; dy++) { - for (let dx = -2; dx <= 2; dx++) { - if (Math.abs(dx) + Math.abs(dy) > 2) continue; - const tx = x + dx; - const ty = y + dy; - if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; - const counts = this.tileEthnicities.get(w.idx(tx, ty)); - if (!counts) continue; - for (const [id, value] of counts) { - if (id === ethnicity) same += value; - else foreign += value; - } - if (same > 6 && foreign > 6) { - const result = { same: same * 0.22, foreign: foreign * 0.16 }; - this.ethnicDensityCache.set(key, result); - return result; - } - } - } - const result = { same: same * 0.22, foreign: foreign * 0.16 }; - this.ethnicDensityCache.set(key, result); - return result; - } - - resolveAssimilation(a) { - const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity); - if (!dominant || dominant.id === a.ethnicity) { - a.foreignContact = Math.max(0, a.foreignContact - 1); - return; - } - - if (a.contactEthnicity !== dominant.id) { - a.contactEthnicity = dominant.id; - a.foreignContact = 0; - } - a.foreignContact++; - - const pressure = dominant.count / Math.max(1, dominant.total); - const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30); - if (this.rng.next() < chance * 0.14) { - this.changeAgentEthnicity(a, dominant.id); - a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22); - a.foreignContact = 0; - } - } - - dominantEthnicityNear(x, y, self) { - const key = `${x},${y},${self}`; - const cached = this.dominantEthnicityCache.get(key); - if (cached) return cached; - const counts = new Map(); - let total = 0; - const w = this.world; - for (let dy = -3; dy <= 3; dy++) { - for (let dx = -3; dx <= 3; dx++) { - if (Math.abs(dx) + Math.abs(dy) > 3) continue; - const tx = x + dx; - const ty = y + dy; - if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; - const tileCounts = this.tileEthnicities.get(w.idx(tx, ty)); - if (!tileCounts) continue; - for (const [id, count] of tileCounts) { - total += count; - counts.set(id, (counts.get(id) || 0) + count); - } - } - } - for (const city of this.getCitiesNear(x, y, 5)) { - const distance = Math.abs(city.x - x) + Math.abs(city.y - y); - if (!city.ethnicityComposition?.size) continue; - const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8); - for (const [id, count] of city.ethnicityComposition) { - const weighted = Math.max(1, Math.round(count * influence * 0.08)); - total += weighted; - counts.set(id, (counts.get(id) || 0) + weighted); - } - } - let best = null; - for (const [id, count] of counts) { - if (id !== self && (!best || count > best.count)) best = { id, count, total }; - } - if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits(); - if (best) this.dominantEthnicityCache.set(key, best); - return best; - } - - updateWorldFields(scale = 1) { - const w = this.world; - const pheromoneDecay = Math.pow(0.996, scale); - 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)); - w.pheromone[i] *= pheromoneDecay; - } - } - - updateCities() { - const w = this.world; - w.city.fill(-1); - w.farmland.fill(0); - w.cityPull.fill(0); - - const candidates = new Map(); - for (const a of this.agents) { - if (!a.alive) continue; - const local = w.idx(a.x, a.y); - if (a.settled < 3 && w.pressure[local] < 2) continue; - if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.24) continue; - const cx = clamp(Math.round(a.x / 4) * 4, 0, w.size - 1); - const cy = clamp(Math.round(a.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() }; - candidates.set(i, group); - } - this.ensureAgentTech(a); - group.count++; - group.resources += Math.max(0, a.resources); - group.sedentary += getSedentary(a.traits); - group.farming += a.tech.farming || 0; - group.metallurgy += a.tech.metallurgy || 0; - group.techCount++; - group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1); - } - - let foundedThisTick = 0; - const canFoundCities = this.year >= years(80) && this.year % years(10) === 0; - const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0; - const candidateEntries = [...candidates].sort((a, b) => { - const sedentaryA = a[1].sedentary / Math.max(1, a[1].count); - const sedentaryB = b[1].sedentary / Math.max(1, b[1].count); - return (b[1].count * (0.7 + sedentaryB) + b[1].resources * 0.02) - (a[1].count * (0.7 + sedentaryA) + a[1].resources * 0.02); - }); - for (const [i, group] of candidateEntries) { - if (group.count < 3) continue; - const avgSedentary = group.sedentary / group.count; - let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null; - if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) { - const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98); - if (avgSedentary < 0.22 || 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); - 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, 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.rebuildOccupancy(); - this.processCityEconomies(); - - 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 <= 0 || c.strength <= 0.06) return false; - const radius = c.agriculturalRadius; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const x = clamp(c.x + dx, 0, w.size - 1); - const y = clamp(c.y + dy, 0, w.size - 1); - const d = Math.abs(dx) + Math.abs(dy); - if (d <= radius) { - const tile = w.idx(x, y); - w.city[tile] = c.id; - 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)); - } - } - } - } - return true; - }); - this.rebuildIndexes(); - } - - createCity(x, y, seedGroup = null) { - const seedPopulation = seedGroup ? Math.max(14, seedGroup.count * 5) : 10; - 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 * 3, urbanWeight); - if (urbanCount > 0) composition.set(id, urbanCount); - } - } - return { - id: this.nextCity++, - x, - y, - population: seedPopulation, - storedResources: 34 + (seedGroup?.resources || 0) * 0.55, - ethnicityComposition: composition, - pheromoneOutput: 0, - agriculturalRadius: 2, - tradeLinks: new Set(), - activeVisitors: 0, - age: 0, - strength: 3, - sedentaryCulture: seedSedentary, - knowledge: { farming: 0, metallurgy: 0 }, - supplyStress: 0, - polityId: null, - loyalty: 0.5, - receivedAid: false, - tradeValue: 0, - tradeReach: 0 - }; - } - - absorbUrbanPopulation() { - if (!this.cities.length) return; - const absorbed = []; - for (const a of this.agents) { - if (!a.alive || a.settled < 5) { - absorbed.push(a); - continue; - } - const city = this.findCityNear(a.x, a.y, 7); - const sedentary = getSedentary(a.traits); - if (!city || this.rng.next() > sedentary * 0.85) { - absorbed.push(a); - continue; - } - const migrants = 1 + Math.floor(Math.min(8, a.resources / 8)); - const urbanWeight = clamp((sedentary - 0.14) * 1.5, 0.08, 1); - city.population += migrants; - city.storedResources += Math.max(0, a.resources) * 0.65; - const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight); - if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount); - city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015; - city.strength += 0.04; - } - this.agents = absorbed; - } - - weightedUrbanContribution(amount, weight) { - const value = amount * weight; - const whole = Math.floor(value); - return whole + (this.rng.next() < value - whole ? 1 : 0); - } - - processCityEconomies() { - const w = this.world; - for (const city of this.cities) { - city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 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 (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - if (Math.abs(dx) + Math.abs(dy) > radius) continue; - const x = clamp(city.x + dx, 0, w.size - 1); - const y = clamp(city.y + dy, 0, w.size - 1); - const i = w.idx(x, y); - if (w.terrain[i] === Terrain.WATER) continue; - const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1); - const 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); - w.resource[i] -= extraction; - w.farmland[i] = Math.max(w.farmland[i], pull); - w.pheromone[i] += city.pheromoneOutput * pull * 0.09; - harvested += extraction; - } - } - - city.storedResources += harvested; - const trade = this.cityTradeProfile(city); - const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018); - city.storedResources += tradeIncome; - const supportRatio = city.activeVisitors / Math.max(1, city.population); - 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; - } - if (city.storedResources > city.population * 0.16 && city.population > 0) { - const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8); - const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015))); - city.population += births; - city.storedResources -= births * 0.55; - addBirthsToComposition(city.ethnicityComposition, births); - } - 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.agents.length < this.maxAgents) { - 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 dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1; - const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits(); - for (let i = 0; i < count; i++) { - this.agents.push(this.makeAgent( - clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1), - clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1), - dominant, - mutateTraits(template, this.rng, 0.05), - this.rng.range(4, 11) - )); - } - } - - 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; - } - - 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; - } - - 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.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.set(id, polity); - return polity; - } - - getPolityCities(polity) { - 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); - } - } - 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) { - return !!cityA?.tradeLinks?.has(cityB?.id) || !!cityB?.tradeLinks?.has(cityA?.id); - } - - 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([centerCity.id]), - treasury: Math.max(0, centerCity.storedResources * 0.12), - color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), - founded: this.year, - legitimacy: this.rng.range(0.68, 0.94), - cohesion: this.rng.range(0.58, 0.9), - charisma: this.rng.range(0.5, 1.5), - leaderStarted: this.year, - leaderTenureYears: this.rng.range(24, 68), - crisis: 0, - lastCrisisYear: this.year - }; - centerCity.polityId = id; - centerCity.loyalty = 1; - centerCity.receivedAid = false; - this.polities.push(polity); - this.polityById.set(polity.id, polity); - this.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, - 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 oldEnoughForPeakMarker = this.year - (history.founded ?? this.year) > years(40); - if (oldEnoughForPeakMarker && (!history.peakEventYear || this.year - history.peakEventYear > years(120))) { - this.addPolityEvent(polity.id, "peak", this.year, { - power: sample.power, - cities: sample.cities, - population: sample.population - }, 2); - history.peakEventYear = 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); - } - - 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(); - } - - selectNewLeader(polity, forced = false) { - if (!polity) return; - const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5); - 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(0.5, 1.5); - const continuity = forced ? 0.18 : 0.34; - const institutionalPull = 0.82 + institutionalBias * 0.36; - const nextCharisma = clamp( - previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull, - 0.5, - 1.5 - ); - polity.charisma = nextCharisma; - polity.leaderStarted = this.year; - polity.leaderTenureYears = this.rng.range(22, 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 ?? 1, 0.5, 1.5); - polity.leaderStarted ??= polity.founded ?? this.year; - polity.leaderTenureYears ??= this.rng.range(24, 68); - const tenureYears = (this.year - polity.leaderStarted) / MONTHS_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 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 affectedAgents = 0; - for (const agent of this.agents) { - if (!agent.alive) continue; - const d = Math.hypot(agent.x - x, agent.y - y); - if (d > radius) continue; - const falloff = 1 - d / radius; - const damage = clamp(intensity * falloff * falloff, 0, 1); - if (this.rng.next() < damage * 0.32) { - if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent); - agent.alive = false; - this.deaths++; - affectedAgents++; - } else { - agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45)); - if (agent.tech) { - agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035); - agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04); - } - } - } - - let affectedCities = 0; - let totalPopulationLoss = 0; - for (const city of this.cities) { - const d = Math.hypot(city.x - x, city.y - y); - if (d > radius) continue; - const falloff = 1 - d / radius; - const damage = clamp(intensity * 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(intensity * falloff * falloff, 0, 1); - const tile = w.idx(tx, ty); - 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), - affectedAgents, - affectedCities, - totalPopulationLoss, - affectedPolities - }; - this.disasters.push(record); - this.disasterHistory.push(record); - for (const entry of affectedPolities) { - if (entry.lossRate >= 0.10) { - this.addPolityEvent(entry.polityId, "disaster", this.year, { - disasterId: id, - populationLoss: entry.populationLoss, - lossRate: entry.lossRate, - affectedCities: entry.cityIds.length - }, entry.lossRate >= 0.20 ? 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(); - } - - 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 > 80) 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); - } - - removeCityFromPolity(city) { - if (!city || city.polityId === null) return; - const oldPolity = this.getPolityById(city.polityId); - if (oldPolity) oldPolity.cityIds.delete(city.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)); - } - - 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.getWarBetween(a.id, b.id)) return null; - if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null; - - const id = this.nextWar++; - const war = { - id, - aPolityId: a.id, - bPolityId: b.id, - started: this.year, - lastActionYear: this.year, - intensity: this.rng.range(0.45, 1.05), - exhaustionA: 0, - exhaustionB: 0, - ended: null - }; - this.wars.push(war); - a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5); - b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5); - return war; - } - - endWar(war) { - if (!war || war.ended !== null) return; - war.ended = this.year; - } - - 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); - } - - distanceToNearestPolityCity(polity, city) { - if (!polity || !city) return Infinity; - let best = Infinity; - for (const source of this.getPolityCities(polity)) { - const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city); - if (distance < best) best = distance; - } - return best; - } - - 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; - } - - 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; - } - - polityAge(polity) { - return (this.year - (polity?.founded ?? this.year)) / MONTHS_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); - } - - 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() { - for (const city of this.cities) { - if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue; - const centerInfluence = this.cityInfluence(city); - let absorbed = 0; - for (const other of this.cities) { - if (absorbed >= 3) break; - if (other === city || other.polityId !== null) continue; - if (this.distanceBetweenCities(city, other) > 34) continue; - const targetInfluence = this.cityInfluence(other); - if (centerInfluence <= targetInfluence * 1.08) continue; - - const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08); - const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0; - const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus; - if (dominanceScore > 0.68 && this.rng.next() < 0.32) { - const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId); - this.addCityToPolity(other, polity, 0.68); - absorbed++; - } - } - } - } - - expandPolities() { - for (const polity of this.polities) { - const center = this.getCityById(polity.centerCityId); - if (!center) continue; - const centerInfluence = this.cityInfluence(center); - let absorbed = 0; - for (const city of this.cities) { - if (absorbed >= 2) break; - if (city.polityId !== null || city.id === center.id) continue; - const distance = this.effectiveDistance(center, city); - if (distance > 44) continue; - const targetInfluence = this.cityInfluence(city); - if (centerInfluence <= targetInfluence * 0.9) continue; - const dominanceScore = - (centerInfluence / (targetInfluence + 1)) * - (1 / (1 + distance * 0.08)) * - (this.hasDirectTradeConnection(center, city) ? 1.5 : 1.0); - if (dominanceScore > 0.62 && this.rng.next() < 0.24) { - this.addCityToPolity(city, polity, 0.62); - absorbed++; - } - } - } - } - - absorbIndependentCities() { - if (this.year % years(5) !== 0) return; - - for (const polity of this.polities) { - const cities = this.getPolityCities(polity); - if (!cities.length) continue; - - const power = this.polityPower(polity); - const range = this.polityInfluenceRange(polity, false); - let absorbed = 0; - - const candidates = this.cities - .filter(city => city.polityId === null && city.population > 0) - .map(city => ({ - city, - distance: this.distanceToNearestPolityCity(polity, city) - })) - .filter(x => x.distance <= range) - .sort((a, b) => a.distance - b.distance); - - for (const { city, distance } of candidates) { - if (absorbed >= 1) break; - - const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1); - const proximity = 1 / (1 + distance * 0.07); - const pressure = (power / Math.max(1, cityInfluence)) * proximity; - - if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) { - this.addCityToPolity(city, polity, 0.42); - city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99))); - absorbed++; - } - } - } - } - - maybeStartWars() { - if (this.year % years(10) !== 0) return; - - for (const a of this.polities) { - 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.isPolityAtWar(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 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 generalInstability = Math.max(aInstability, bInstability) * 0.04; - const borderFriction = this.borderFriction(a, b, distance); - - const chance = - 0.002 + - proximity * 0.010 + - asymmetryPressure * 0.010 + - weakSideInstability * 0.020 + - weakSideAge * 0.012 + - weakSideOverextension * 0.012 + - borderFriction * 0.010 + - generalInstability; - - if (this.rng.next() < clamp(chance, 0, 0.08)) { - this.startWar(a, b); - started = true; - } - } - } - } - - 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 % years(2) !== 0) return; - - 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 attackerCities = this.getPolityCities(attacker); - const defenderCities = this.getPolityCities(defender); - if (!attackerCities.length || !defenderCities.length) return; - - const attackerPower = this.polityPower(attacker); - const defenderPower = this.polityPower(defender); - if (attackerPower <= defenderPower * 1.05) return; - - const range = this.polityInfluenceRange(attacker, true); - - const candidates = defenderCities - .filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2) - .map(city => ({ - city, - distance: this.distanceToNearestPolityCity(attacker, city) - })) - .filter(x => x.distance <= range) - .sort((a, b) => a.distance - b.distance); - - if (!candidates.length) return; - - const topCandidates = candidates.slice(0, 3); - const selected = topCandidates[this.rng.int(topCandidates.length)]; - const pressure = this.warAbsorptionPressure( - attacker, - defender, - selected.city, - selected.distance, - attackerPower, - defenderPower - ); - - const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45); - - if (pressure > 0.95 && this.rng.next() < chance) { - this.captureCityInWar(selected.city, attacker, defender, war, pressure); - } else { - selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1); - } - } - - warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) { - 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 * - (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( - 0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025, - 0.05, - 0.22 - ); - - const loss = Math.floor((city.population || 0) * lossRate); - if (loss > 0) { - city.population = Math.max(1, city.population - loss); - if (typeof removeFromComposition === "function" && city.ethnicityComposition) { - removeFromComposition(city.ethnicityComposition, loss); - } - this.deaths += Math.floor(loss * 0.35); - } - - this.removeCityFromPolity(city); - this.addCityToPolity(city, attacker, 0.28); - - city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36); - - war.lastActionYear = this.year; - - 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); - } - - 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.006 * intensity, 0, 1); - war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1); - } - - 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 (age > years(90)) { - this.endWar(war); - return; - } - - if (age > years(10) && noActionFor > years(18)) { - this.endWar(war); - return; - } - - if (exhaustion > 0.85 && this.rng.next() < 0.35) { - this.endWar(war); - } - } - - 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); - } - } - } - } - - 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)) / MONTHS_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; - center.loyalty = 1; - 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); - if (this.hasDirectTradeConnection(city, center)) delta += 0.025; - 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, 1); - } - } - } - - splitUnloyalCities() { - for (const polity of this.polities) { - const instability = this.polityInstability(polity); - const agePressure = this.polityAgePressure(polity); - const threshold = clamp(0.14 + instability * 0.05 + agePressure * 0.03, 0.14, 0.28); - for (const city of this.getPolityCities(polity)) { - if (city.id === polity.centerCityId || city.loyalty >= threshold) continue; - const chance = - (threshold - city.loyalty) * 0.28 + - instability * 0.012 + - agePressure * 0.010; - if (this.rng.next() < chance) this.removeCityFromPolity(city); - } - } - } - - 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"); - 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 = 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; - } - if (cities.length === 1) { - this.markPolityEnded(polity, "fragmented"); - cities[0].polityId = null; - cities[0].loyalty = 0.45; - cities[0].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)) { - city.polityId = null; - city.loyalty = 0.45; - city.receivedAid = false; - } - } - } - - updatePolities() { - if (this.year % years(1) !== 0) return; - this.cleanupPolities(); - this.foundPolities(); - this.expandPolities(); - - if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes(); - - this.collectAndRedistributeResources(); - this.detectPolityFamines(); - - if (this.erodePolityLegitimacy) this.erodePolityLegitimacy(); - if (this.triggerPolityCrises) this.triggerPolityCrises(); - if (this.applyOldStateStress) this.applyOldStateStress(); - if (this.updatePolityLeaders) this.updatePolityLeaders(); - - this.updateCityLoyalty(); - this.absorbIndependentCities(); - this.maybeStartWars(); - this.updateWars(); - this.splitUnloyalCities(); - - this.cleanupPolities(); - if (this.samplePolityHistories) this.samplePolityHistories(); - } - - reinforcePolityTradeRoutes() { - const w = this.world; - 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 path = this.findTerrainRoute(center.x, center.y, target.x, target.y); - if (!this.isValidRoutePath(path, target.x, target.y)) continue; - const cities = this.getPolityCities(polity); - const roadCost = 8 + cities.length * 1.5 + this.polityOverextension(polity) * 3; - 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; - - center.tradeLinks.add(target.id); - target.tradeLinks.add(center.id); - this.tradeLinks.push({ from: center.id, to: target.id, strength: 0.22, path }); - for (const tile of path) { - w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 34); - w.pheromone[tile] += 2.4; - } - } - } - - updateTradeRoutes() { - const w = this.world; - for (let i = 0; i < w.count; i++) { - const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2; - if (invalidTerrain) w.tradeRoute[i] = 0; - else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2); - } - - this.tradeLinks = []; - for (const city of this.cities) city.tradeLinks.clear(); - const pairCandidates = []; - const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400); - const sqrtPop = new Map(this.cities.map(city => [city.id, Math.sqrt(city.population || 1)])); - for (let a = 0; a < this.cities.length; a++) { - for (let b = a + 1; b < this.cities.length; b++) { - const c1 = this.cities[a]; - const c2 = this.cities[b]; - const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y); - if (d > 44) continue; - const distanceFactor = clamp((d - 14) / 30, 0, 1); - const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1); - const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0; - const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias; - pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore }); - } - } - pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore); - - const candidates = []; - for (const pair of pairCandidates.slice(0, maxPairsToRoute)) { - const { c1, c2, distanceFactor, marketScale } = pair; - const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y); - if (!this.isValidRoutePath(path, c2.x, c2.y)) continue; - const strength = this.routeStrengthForPath(path); - if (strength < 0.08) continue; - if (!this.canPayRouteConstructionCost(c1, c2, path)) continue; - const tradeScore = strength * (1 + distanceFactor * 0.7) * marketScale; - candidates.push({ c1, c2, strength, path, tradeScore }); - } - - candidates.sort((a, b) => b.tradeScore - a.tradeScore); - const maxLinks = Math.max(1, Math.floor(this.cities.length / 2)); - const supportedRoutes = new Set(); - for (const candidate of candidates) { - if (this.tradeLinks.length >= maxLinks) break; - const { c1, c2, strength, path } = candidate; - const c1Limit = c1.population > 90 ? 3 : 2; - const c2Limit = c2.population > 90 ? 3 : 2; - if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue; - if (!this.payRouteConstructionCost(c1, c2, path)) continue; - c1.tradeLinks.add(c2.id); - c2.tradeLinks.add(c1.id); - this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path }); - this.exchangeCityResources(c1, c2, strength, path); - for (const tile of path) { - supportedRoutes.add(tile); - w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 18); - } - } - - for (let i = 0; i < w.count; i++) { - if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4); - } - this.activeTradeRouteTiles = new Set(); - for (let i = 0; i < w.count; i++) { - if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i); - } - this.diffuseCityKnowledgeThroughTrade(); - } - - 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) { - const w = this.world; - const path = []; - const visited = new Set(); - let x = x1; - let y = y1; - const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60); - const directions = [ - [1, 0], - [-1, 0], - [0, 1], - [0, -1] - ]; - - for (let step = 0; step < maxSteps; step++) { - const current = w.idx(x, y); - path.push(current); - visited.add(current); - if (x === x2 && y === y2) break; - - let bestX = x; - let bestY = y; - let bestScore = Infinity; - for (const [dx, dy] of directions) { - const nx = x + dx; - const ny = y + dy; - if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue; - const i = w.idx(nx, ny); - const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0); - const revisitCost = visited.has(i) ? 5 : 0; - const routeEase = w.tradeRoute[i] ? -2 : 0; - const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4); - const distance = Math.abs(nx - x2) + Math.abs(ny - y2); - const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase; - if (score < bestScore) { - bestScore = score; - bestX = nx; - bestY = ny; - } - } - - if (bestX === x && bestY === y) break; - x = bestX; - y = bestY; - } - return path; - } - - 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(1, w.move[i]); - } - return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2; - } - - isValidRoutePath(path, targetX, targetY) { - const w = this.world; - if (!path.length) return false; - const last = path[path.length - 1]; - if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false; - if (path.length > 42) return false; - - let totalCost = 0; - let hardTiles = 0; - const seen = new Set(); - for (const tile of path) { - if (seen.has(tile)) return false; - seen.add(tile); - if (w.terrain[tile] === Terrain.WATER) return false; - totalCost += w.move[tile]; - if (w.move[tile] > 1.8) hardTiles++; - } - return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12; - } - - 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); - } - - routeConstructionCost(path, polityBacked = false) { - let weakTiles = 0; - for (const tile of path) { - if (this.world.tradeRoute[tile] < 24) weakTiles++; - } - return weakTiles * (polityBacked ? 0.22 : 0.16); - } - - canPayRouteConstructionCost(cityA, cityB, path, polity = null) { - const cost = this.routeConstructionCost(path, !!polity); - if (cost <= 0) return true; - return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost; - } - - payRouteConstructionCost(cityA, cityB, path, polity = null) { - const cost = this.routeConstructionCost(path, !!polity); - if (cost <= 0) return true; - if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) 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; - } - return remaining <= 0.001; - } - - drainCityResources(city, amount) { - const paid = Math.min(city.storedResources, amount); - city.storedResources -= paid; - return paid; - } - - depositRoutePheromone(path, amount) { - const w = this.world; - for (const i of path) { - w.pheromone[i] += amount; - } - } - - updateEthnicStats() { - for (const e of this.ethnicities.values()) { - e.population = 0; - e.diversity = 0; - e.centroidX = 0; - e.centroidY = 0; - e.activeTraitPopulation = 0; - e.traitSums = emptyTraitSums(); - } - - for (const a of this.agents) { - const e = this.ethnicities.get(a.ethnicity); - if (!e) continue; - e.population++; - e.centroidX += a.x; - e.centroidY += a.y; - e.activeTraitPopulation++; - addTraits(e.traitSums, a.traits); - } - - for (const city of this.cities) { - for (const [id, count] of city.ethnicityComposition) { - const e = this.ethnicities.get(id); - if (!e) continue; - e.population += count; - e.centroidX += city.x * count; - e.centroidY += city.y * count; - } - } - - for (const e of this.ethnicities.values()) { - if (!e.population) continue; - e.centroidX /= e.population; - e.centroidY /= e.population; - const activeTraitCount = Math.max(1, e.activeTraitPopulation); - e.averageTraits = averageTraits(e.traitSums, activeTraitCount); - } - - for (const a of this.agents) { - const e = this.ethnicities.get(a.ethnicity); - if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits); - } - - for (const e of this.ethnicities.values()) { - if (e.population) e.diversity /= e.population; - } - } - - splitDivergentEthnicities() { - for (const e of this.ethnicities.values()) { - if (e.activeTraitPopulation < 24 || e.population < 40 || e.diversity < 0.14) continue; - const candidates = []; - let tempSum = 0; - let humidSum = 0; - for (const a of this.agents) { - if (a.ethnicity !== e.id) continue; - const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 0.95; - const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.09; - const tile = this.world.idx(a.x, a.y); - const climate = this.climateMismatch(e.id, tile) > 0.18; - if ((far || spatial || climate) && this.rng.next() < 0.72) { - candidates.push(a); - tempSum += this.world.temperature[tile]; - humidSum += this.world.humidity[tile]; - } - } - if (candidates.length >= 6) { - const newId = this.createEthnicity(e.id, { - temperature: tempSum / candidates.length, - humidity: humidSum / candidates.length - }); - for (const a of candidates) a.ethnicity = newId; - } - } - } - - toJSON() { - return { - version: SimConfig.save.version, - rngSeed: this.rng.seed, - year: this.year, - deaths: this.deaths, - nextEthnicity: this.nextEthnicity, - nextCity: this.nextCity, - nextPolity: this.nextPolity, - nextWar: this.nextWar, - nextDisaster: this.nextDisaster, - maxAgents: this.maxAgents, - world: { - size: this.world.size, - terrain: packArray(this.world.terrain), - resource: packArray(this.world.resource), - regen: packArray(this.world.regen), - move: packArray(this.world.move), - fertility: packArray(this.world.fertility), - mineral: packArray(this.world.mineral), - temperature: packArray(this.world.temperature), - humidity: packArray(this.world.humidity), - pheromone: packArray(this.world.pheromone), - tradeRoute: packArray(this.world.tradeRoute), - farmland: packArray(this.world.farmland), - cityPull: packArray(this.world.cityPull), - city: packArray(this.world.city), - pressure: packArray(this.world.pressure), - dominantEthnicity: packArray(this.world.dominantEthnicity), - cultureDiversity: packArray(this.world.cultureDiversity) - }, - agents: this.agents, - ethnicities: [...this.ethnicities.values()].map(e => ({ - id: e.id, - parent: e.parent, - born: e.born, - climateTemp: e.climateTemp, - climateHumidity: e.climateHumidity, - color: e.color - })), - cities: this.cities.map(c => ({ - ...c, - ethnicityComposition: [...c.ethnicityComposition], - tradeLinks: [...c.tradeLinks] - })), - polities: this.polities.map(p => ({ - id: p.id, - centerCityId: p.centerCityId, - cityIds: [...p.cityIds], - treasury: p.treasury, - color: p.color, - founded: p.founded, - legitimacy: p.legitimacy, - cohesion: p.cohesion, - charisma: p.charisma, - leaderStarted: p.leaderStarted, - leaderTenureYears: p.leaderTenureYears, - crisis: p.crisis, - lastCrisisYear: p.lastCrisisYear, - lastFamineYear: p.lastFamineYear ?? null - })), - wars: this.wars, - disasters: this.disasters, - disasterHistory: this.disasterHistory, - polityHistory: [...this.polityHistory.values()], - deadPolityHistories: this.deadPolityHistories - }; - } - - static fromJSON(state) { - if (!state?.world?.size) throw new Error("Invalid save payload"); - const sim = new Simulation(state.world.size, 0, { blankWorld: true, skipSpawn: true }); - sim.rng.seed = state.rngSeed >>> 0; - sim.year = state.year || 0; - sim.deaths = state.deaths || 0; - sim.nextEthnicity = state.nextEthnicity || 1; - sim.nextCity = state.nextCity || 1; - sim.nextPolity = state.nextPolity || 1; - sim.nextWar = state.nextWar || 1; - sim.nextDisaster = state.nextDisaster || 1; - sim.maxAgents = state.maxAgents || 30000; - - sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array)); - sim.world.resource.set(unpackArray(state.world.resource, Float32Array)); - sim.world.regen.set(unpackArray(state.world.regen, Float32Array)); - sim.world.move.set(unpackArray(state.world.move, Float32Array)); - sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array)); - sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array)); - sim.world.temperature.set(unpackArray(state.world.temperature, Float32Array)); - sim.world.humidity.set(unpackArray(state.world.humidity, Float32Array)); - sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array)); - sim.world.tradeRoute.set(unpackArray(state.world.tradeRoute, Uint8Array)); - sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array)); - sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array)); - sim.world.city.set(unpackArray(state.world.city, Int32Array)); - if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array)); - if (state.world.dominantEthnicity) sim.world.dominantEthnicity.set(unpackArray(state.world.dominantEthnicity, Int32Array)); - if (state.world.cultureDiversity) sim.world.cultureDiversity.set(unpackArray(state.world.cultureDiversity, Float32Array)); - - sim.agents = state.agents || []; - for (const a of sim.agents) sim.ensureAgentTech(a); - sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, { - id: e.id, - parent: e.parent, - born: e.born, - population: 0, - diversity: 0, - climateTemp: e.climateTemp, - climateHumidity: e.climateHumidity, - color: e.color, - centroidX: 0, - centroidY: 0 - }])); - sim.cities = (state.cities || []).map(c => ({ - id: c.id, - x: c.x, - y: c.y, - population: c.population || 0, - storedResources: c.storedResources || 0, - ethnicityComposition: new Map(c.ethnicityComposition || []), - pheromoneOutput: c.pheromoneOutput || 0, - agriculturalRadius: c.agriculturalRadius || 2, - tradeLinks: new Set(c.tradeLinks || []), - activeVisitors: 0, - age: c.age || 0, - strength: c.strength || 1, - sedentaryCulture: c.sedentaryCulture ?? 0.5, - knowledge: { - farming: clamp(c.knowledge?.farming ?? 0, 0, 1), - metallurgy: clamp(c.knowledge?.metallurgy ?? 0, 0, 1) - }, - supplyStress: c.supplyStress ?? 0, - polityId: c.polityId ?? null, - loyalty: c.loyalty ?? 0.5, - receivedAid: c.receivedAid ?? false, - tradeValue: c.tradeValue ?? 0, - tradeReach: c.tradeReach ?? 0 - })); - sim.polities = (state.polities || []).map(p => ({ - id: p.id, - centerCityId: p.centerCityId, - cityIds: new Set(p.cityIds || []), - treasury: p.treasury || 0, - color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62), - founded: p.founded || 0, - legitimacy: p.legitimacy ?? 0.7, - cohesion: p.cohesion ?? 0.6, - charisma: clamp(p.charisma ?? 1, 0.5, 1.5), - leaderStarted: p.leaderStarted ?? p.founded ?? sim.year, - leaderTenureYears: clamp(p.leaderTenureYears ?? 48, 12, 96), - crisis: p.crisis ?? 0, - lastCrisisYear: p.lastCrisisYear ?? sim.year, - lastFamineYear: p.lastFamineYear ?? null - })); - sim.rebuildIndexes(); - sim.wars = (state.wars || []).map(w => ({ - id: w.id, - aPolityId: w.aPolityId, - bPolityId: w.bPolityId, - started: w.started || sim.year, - lastActionYear: w.lastActionYear || w.started || sim.year, - intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25), - exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1), - exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1), - ended: w.ended ?? null - })).filter(w => w.ended === null); - const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0); - sim.nextWar = Math.max(sim.nextWar, maxWarId + 1); - const restoreDisaster = d => ({ - id: d.id, - year: d.year || 0, - x: d.x || 0, - y: d.y || 0, - radius: d.radius || 8, - intensity: d.intensity || 0.5, - expires: d.expires ?? ((d.year || 0) + years(18)), - affectedAgents: d.affectedAgents || 0, - affectedCities: d.affectedCities || 0, - totalPopulationLoss: d.totalPopulationLoss || 0, - affectedPolities: d.affectedPolities || [] - }); - sim.disasters = (state.disasters || []).map(restoreDisaster); - sim.disasterHistory = (state.disasterHistory || []).map(restoreDisaster); - const maxDisasterId = [...sim.disasters, ...sim.disasterHistory].reduce((max, disaster) => Math.max(max, disaster.id || 0), 0); - sim.nextDisaster = Math.max(sim.nextDisaster, maxDisasterId + 1); - sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, { - id: h.id, - color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), - founded: h.founded || 0, - ended: h.ended ?? null, - active: h.active !== false, - centerCityId: h.centerCityId ?? null, - fate: h.fate || null, - samples: h.samples || [], - events: h.events || [], - peakPower: h.peakPower || 0, - peakYear: h.peakYear ?? null, - peakEventYear: h.peakEventYear ?? null - }])); - sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({ - id: h.id, - color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), - founded: h.founded || 0, - ended: h.ended ?? null, - active: false, - centerCityId: h.centerCityId ?? null, - fate: h.fate || null, - samples: h.samples || [], - events: h.events || [], - peakPower: h.peakPower || 0, - peakYear: h.peakYear ?? null, - peakEventYear: h.peakEventYear ?? null - })); - for (const polity of sim.polities) sim.ensurePolityHistory(polity); - sim.tradeLinks = []; - sim.activeTradeRouteTiles = new Set(); - sim.rebuildOccupancy(); - sim.updateTradeRoutes(); - sim.cleanupPolities(); - sim.updateEthnicStats(); - return sim; - } +constructor(size, initialAgents, options = {}) { +this.rng = new Rng(Date.now()); +this.world = options.blankWorld ? World.blank(size, this.rng) : new World(size, this.rng); +this.agents = []; +this.ethnicities = new Map(); +this.cities = []; +this.cityById = new Map(); +this.cityBuckets = new Map(); +this.polities = []; +this.polityById = new Map(); +this.wars = []; +this.disasters = []; +this.disasterHistory = []; +this.polityHistory = new Map(); +this.deadPolityHistories = []; +this.tradeLinks = []; +this.activeTradeRouteTiles = new Set(); +this.ethnicDensityCache = new Map(); +this.dominantEthnicityCache = new Map(); +this.nextEthnicity = 1; +this.nextCity = 1; +this.nextPolity = 1; +this.nextWar = 1; +this.nextDisaster = 1; +this.year = 0; +this.deaths = 0; +this.maxAgents = Math.max( +Math.floor(initialAgents * SimConfig.population.maxAgentsScale), +SimConfig.population.maxAgentsFloor +); +this.tileEthnicities = new Map(); +this.tileAgents = new Map(); +if (!options.skipSpawn) { +this.spawnInitialAgents(initialAgents); +this.rebuildOccupancy(); +this.updateEthnicStats(); +} +} +spawnInitialAgents(count) { +const founders = Math.max(5, Math.min(16, Math.round(count / 180))); +const desertFounders = Math.max(2, Math.floor(founders * 0.25)); +for (let e = 0; e < founders; e++) { +const desertFounder = e < desertFounders; +const origin = this.findHabitableTile(desertFounder ? Terrain.DESERT : null); +const originTile = this.world.idx(origin.x, origin.y); +const id = this.createEthnicity(0, { +temperature: this.world.temperature[originTile], +humidity: this.world.humidity[originTile] +}); +const baseTraits = desertFounder ? this.desertFounderTraits() : this.randomTraits(); +for (let n = 0; n < Math.floor(count / founders); n++) { +const spawn = this.findNearbySpawn(origin.x, origin.y, desertFounder ? Terrain.DESERT : null); +this.agents.push(this.makeAgent( +spawn.x, +spawn.y, +id, +mutateTraits(baseTraits, this.rng, 0.07), +desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15) +)); +} +} +} +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 +}); +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; +} +makeAgent(x, y, ethnicity, traits, resources) { +return { +x, +y, +resources, +ethnicity, +traits, +alive: true, +foreignContact: 0, +contactEthnicity: ethnicity, +settled: 0, +movedThisStep: false, +tech: { +farming: 0, +metallurgy: 0 +}, +farmingWork: 0, +lastFarmTile: -1 +}; +} +findNearbySpawn(originX, originY, preferredTerrain = null) { +const w = this.world; +let best = { x: originX, y: originY }; +let bestScore = -Infinity; +for (let tries = 0; tries < 24; tries++) { +const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1); +const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1); +const i = w.idx(x, y); +if (w.terrain[i] === Terrain.WATER) continue; +const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0; +const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16; +if (score > bestScore) { +bestScore = score; +best = { x, y }; +} +} +return best; +} +findHabitableTile(preferredTerrain = null) { +if (preferredTerrain !== null) { +const exact = []; +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 }; +} +step() { +for (const city of this.cities) city.activeVisitors = 0; +this.rebuildOccupancy(); +this.ethnicDensityCache.clear(); +const offspring = []; +for (const a of this.agents) { +if (!a.alive) continue; +this.moveAgent(a); +} +this.rebuildOccupancy(); +this.dominantEthnicityCache.clear(); +for (const a of this.agents) { +if (!a.alive) continue; +this.updateAgentTechnology(a); +this.gatherConsumeReproduce(a, offspring); +if (a.alive) this.payTechnologyCostOrForget(a); +if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a); +} +this.agents = this.agents.filter(a => a.alive); +const acceptedOffspring = this.agents.length + offspring.length < this.maxAgents +? offspring +: offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length)); +this.agents.push(...acceptedOffspring); +for (const child of acceptedOffspring) this.addAgentToOccupancy(child); +if (this.year % 2 === 0) this.updateWorldFields(2); +this.maybeSpawnDisaster(); +if (this.year % years(1) === 0) { +this.updateCities(); +if (this.year % years(2) === 0) this.updateRegionalCultures(); +} +if (this.year % years(5) === 0) { +this.updateTradeRoutes(); +} +this.updatePolities(); +this.updateEthnicStats(); +if (this.year % years(45) === 0) { +this.splitDivergentEthnicities(); +this.updateEthnicStats(); +} +this.year++; +} +rebuildOccupancy() { +const w = this.world; +w.pressure.fill(0); +this.tileEthnicities.clear(); +this.tileAgents.clear(); +this.rebuildIndexes(); +for (const a of this.agents) { +if (!a.alive) continue; +const i = w.idx(a.x, a.y); +w.pressure[i]++; +let agents = this.tileAgents.get(i); +if (!agents) { +agents = []; +this.tileAgents.set(i, agents); +} +agents.push(a); +let counts = this.tileEthnicities.get(i); +if (!counts) { +counts = new Map(); +this.tileEthnicities.set(i, counts); +} +counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1); +} +for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile); +} +rebuildIndexes() { +this.cityById = new Map(this.cities.map(city => [city.id, city])); +this.polityById = new Map(this.polities.map(polity => [polity.id, polity])); +this.cityBuckets = new Map(); +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)}`; +} +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}`); +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; +} +updateCultureTile(tile) { +const counts = this.tileEthnicities.get(tile); +if (!counts || !counts.size) { +this.world.cultureDiversity[tile] *= 0.98; +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; +} +updateRegionalCultures() { +const w = this.world; +const nextDominant = new Int32Array(w.dominantEthnicity); +const nextDiversity = new Float32Array(w.cultureDiversity); +const radius = SimConfig.culture.spreadRadius; +const cityById = new Map(this.cities.map(city => [city.id, city])); +for (let y = 0; y < w.size; y++) { +for (let x = 0; x < w.size; x++) { +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const influence = new Map(); +let total = 0; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const distance = Math.abs(dx) + Math.abs(dy); +if (distance > radius) continue; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +const source = w.idx(tx, ty); +const id = w.dominantEthnicity[source]; +if (id < 0) continue; +const pressureWeight = Math.sqrt(Math.max(0, w.pressure[source])); +const cityWeight = w.city[source] >= 0 +? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight +: 0; +const routeWeight = w.tradeRoute[source] ? SimConfig.culture.routeWeight : 1; +const weight = (pressureWeight + cityWeight) * routeWeight / (1 + distance); +if (weight <= 0) continue; +influence.set(id, (influence.get(id) || 0) + weight); +total += weight; +} +} +let bestId = nextDominant[tile]; +let bestWeight = 0; +for (const [id, weight] of influence) { +if (weight > bestWeight) { +bestId = id; +bestWeight = weight; +} +} +if (bestWeight >= SimConfig.culture.minimumInfluence) { +nextDominant[tile] = bestId; +nextDiversity[tile] = total > 0 ? clamp(1 - bestWeight / total, 0, 1) : 0; +} +} +} +w.dominantEthnicity.set(nextDominant); +w.cultureDiversity.set(nextDiversity); +} +addAgentToOccupancy(a) { +if (!a.alive) return; +const w = this.world; +const i = w.idx(a.x, a.y); +w.pressure[i]++; +let agents = this.tileAgents.get(i); +if (!agents) { +agents = []; +this.tileAgents.set(i, agents); +} +agents.push(a); +let counts = this.tileEthnicities.get(i); +if (!counts) { +counts = new Map(); +this.tileEthnicities.set(i, counts); +} +counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1); +this.updateCultureTile(i); +} +removeAgentFromOccupancy(a) { +const w = this.world; +const i = w.idx(a.x, a.y); +w.pressure[i] = Math.max(0, w.pressure[i] - 1); +const agents = this.tileAgents.get(i); +if (agents) { +const index = agents.indexOf(a); +if (index >= 0) agents.splice(index, 1); +if (!agents.length) this.tileAgents.delete(i); +} +const counts = this.tileEthnicities.get(i); +if (counts) { +const next = (counts.get(a.ethnicity) || 0) - 1; +if (next > 0) counts.set(a.ethnicity, next); +else counts.delete(a.ethnicity); +if (!counts.size) this.tileEthnicities.delete(i); +} +this.updateCultureTile(i); +} +changeAgentEthnicity(a, nextEthnicity) { +if (a.ethnicity === nextEthnicity) return; +const w = this.world; +const i = w.idx(a.x, a.y); +const counts = this.tileEthnicities.get(i); +if (counts) { +const prev = (counts.get(a.ethnicity) || 0) - 1; +if (prev > 0) counts.set(a.ethnicity, prev); +else counts.delete(a.ethnicity); +counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1); +} +a.ethnicity = nextEthnicity; +this.updateCultureTile(i); +} +moveAgent(a) { +const w = this.world; +const s = w.size; +const current = w.idx(a.x, a.y); +const localPressure = w.pressure[current]; +let bestX = a.x; +let bestY = a.y; +let bestScore = -Infinity; +const sedentary = getSedentary(a.traits); +const ethnocentrism = getEthnocentrism(a.traits); +const ethnicClimate = this.ethnicities.get(a.ethnicity); +const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45; +const localCapacity = this.carryingCapacityAt(current); +const localOverCapacity = Math.max(0, localPressure - localCapacity); +if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) { +a.settled++; +a.movedThisStep = false; +return; +} +const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const x = clamp(a.x + dx, 0, s - 1); +const y = clamp(a.y + dy, 0, s - 1); +const i = w.idx(x, y); +const isWater = w.terrain[i] === Terrain.WATER; +const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45; +const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0); +if (isWater && this.rng.next() > waterAccess) continue; +const ethnicDensity = ethnocentrism > 0.04 +? this.ethnicDensityNear(x, y, a.ethnicity) +: { same: 0, foreign: 0 }; +const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i]; +const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary; +const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0); +const destinationCapacity = this.carryingCapacityAt(i); +const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity); +const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1); +const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85); +const routeBonus = w.tradeRoute[i] ? 1.25 : 0; +const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0; +const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus; +const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0; +const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0; +const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45); +const score = +resourceScore * a.traits.resourceAttraction + +w.pheromone[i] * 0.021 + +ethnicDensity.same * ethnocentrism + +cityPull * sedentary + +routePull + +capacityPull + +inertia - +terrainPenalty - +crowdPenalty + +pressurePush - +ethnicDensity.foreign * ethnocentrism * 0.72 + +this.rng.range(-0.55, 0.55); +if (score > bestScore) { +bestScore = score; +bestX = x; +bestY = y; +} +} +} +const from = w.idx(a.x, a.y); +a.x = bestX; +a.y = bestY; +const to = w.idx(a.x, a.y); +if (from !== to) { +a.movedThisStep = true; +a.farmingWork = 0; +a.lastFarmTile = to; +const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1; +w.pheromone[from] += (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale; +w.pheromone[to] += (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale; +a.settled = Math.max(0, a.settled - 1); +} else { +a.movedThisStep = false; +a.settled++; +} +} +ensureAgentTech(a) { +a.tech ??= { farming: 0, metallurgy: 0 }; +a.tech.farming ??= 0; +a.tech.metallurgy ??= 0; +a.tech.farming = clamp(a.tech.farming, 0, 1); +a.tech.metallurgy = clamp(a.tech.metallurgy, 0, 1); +a.farmingWork ??= 0; +a.lastFarmTile ??= -1; +a.movedThisStep ??= false; +} +farmabilityAt(tile) { +const t = this.world.terrain[tile]; +if (t === Terrain.FERTILE) return 1.0; +if (t === Terrain.PLAINS) return 0.75; +if (t === Terrain.FOREST) return 0.55; +if (t === Terrain.DESERT) return 0.25; +return 0.0; +} +carryingCapacityAt(tile) { +const w = this.world; +if (w.terrain[tile] === Terrain.WATER) return 1.2; +return SimConfig.population.carryingCapacityBase + +w.fertility[tile] * SimConfig.population.carryingCapacityFertility + +w.mineral[tile] * SimConfig.population.carryingCapacityMineral + +w.farmland[tile] * SimConfig.population.carryingCapacityFarmland + +(w.city[tile] >= 0 ? 3.5 : 0); +} +updateAgentTechnology(agent, payMaintenance = false) { +if (!agent.alive) return; +this.ensureAgentTech(agent); +this.updateFarmingWork(agent); +this.tryInventTechnology(agent); +this.learnTechnologyFromCity(agent); +if (this.shouldRunAgentSocial(agent, 3)) { +this.spreadTechnology(agent); +this.improveTechnologyFromDensity(agent); +} +if (payMaintenance) this.payTechnologyCostOrForget(agent); +} +shouldRunAgentSocial(agent, interval) { +return ((agent.x * 31 + agent.y * 17 + this.year) % interval) === 0; +} +updateFarmingWork(agent) { +const w = this.world; +const tile = w.idx(agent.x, agent.y); +if (agent.lastFarmTile !== tile) { +agent.farmingWork = 0; +agent.lastFarmTile = tile; +} +if (agent.movedThisStep) return; +const farming = agent.tech?.farming || 0; +if (farming <= 0.02) return; +if (agent.settled > 0) { +agent.farmingWork = clamp((agent.farmingWork || 0) + farming * 0.015, 0, 1); +agent.tech.farming = clamp(agent.tech.farming + farming * this.farmabilityAt(tile) * 0.00018, 0, 1); +} +} +tryInventTechnology(agent) { +const w = this.world; +const tile = w.idx(agent.x, agent.y); +const settledFactor = clamp((agent.settled || 0) / 12, 0, 1); +if ((agent.tech?.farming || 0) <= 0.02) { +const farmingBase = 0.00016; +const farmability = this.farmabilityAt(tile); +const sedentary = getSedentary(agent.traits); +const farmingChance = +farmingBase * +(0.25 + farmability) * +(0.4 + sedentary) * +(0.3 + settledFactor); +if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32); +} +if ((agent.tech?.metallurgy || 0) <= 0.02) { +const metallurgyBase = 0.00011; +const mineral = clamp(w.mineral[tile], 0, 1); +const mineralTerrainBonus = +w.terrain[tile] === Terrain.MINERAL || w.terrain[tile] === Terrain.MOUNTAIN +? 1.8 +: 1.0; +const metallurgyChance = +metallurgyBase * +(0.2 + mineral) * +mineralTerrainBonus * +(0.5 + settledFactor); +if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.28); +} +} +grantTechnologyAround(agent, techName, amount) { +this.ensureAgentTech(agent); +this.forEachLocalAgentNear(agent.x, agent.y, 2, other => { +if (!other.alive) return true; +this.ensureAgentTech(other); +const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y); +if (distance > 2) return true; +const gain = amount * (other === agent ? 1 : 0.55); +other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1); +return true; +}); +} +payTechnologyCostOrForget(agent) { +if (!agent.alive) return; +this.ensureAgentTech(agent); +const farming = agent.tech.farming || 0; +const metallurgy = agent.tech.metallurgy || 0; +const cost = farming * 0.004 + metallurgy * 0.010; +if (cost <= 0) return; +if (agent.resources >= cost) { +agent.resources -= cost; +return; +} +const shortage = cost - Math.max(0, agent.resources); +agent.resources = Math.max(0, agent.resources - cost); +const decay = clamp(0.006 + shortage * 0.025, 0.006, 0.06); +agent.tech.farming = Math.max(0, farming - decay); +agent.tech.metallurgy = Math.max(0, metallurgy - decay * 1.15); +if (agent.tech.farming < 0.02) agent.tech.farming = 0; +if (agent.tech.metallurgy < 0.02) agent.tech.metallurgy = 0; +if (agent.tech.farming <= 0) agent.farmingWork = 0; +} +spreadTechnology(agent) { +const w = this.world; +const tile = w.idx(agent.x, agent.y); +const assimilation = agent.traits.assimilation || 0; +const ethnocentrism = agent.traits.ethnocentrism || 0; +let checked = 0; +this.forEachLocalAgentNear(agent.x, agent.y, 1, other => { +if (other === agent || !other.alive) return true; +if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true; +if (++checked > 12) return false; +this.ensureAgentTech(other); +const sameEthnicity = agent.ethnicity === other.ethnicity; +let chance = +0.0035 + +assimilation * 0.006 - +ethnocentrism * 0.0012; +if (sameEthnicity) chance += 0.004; +if (w.tradeRoute[tile]) chance += 0.006; +if (w.city[tile] >= 0) chance += 0.003; +chance = clamp(chance, 0.0008, 0.028); +this.learnTechnologyFrom(agent, other, "farming", chance); +this.learnTechnologyFrom(agent, other, "metallurgy", chance); +return true; +}); +} +learnTechnologyFrom(agent, other, techName, chance) { +const current = agent.tech[techName] || 0; +const otherLevel = other.tech?.[techName] || 0; +if (otherLevel > current + 0.04 && this.rng.next() < chance) { +agent.tech[techName] = clamp(current + (otherLevel - current) * 0.14, 0, 1); +} +} +learnTechnologyFromCity(agent) { +const tile = this.world.idx(agent.x, agent.y); +const city = this.world.city[tile] >= 0 ? this.getCityById(this.world.city[tile]) : null; +if (!city?.knowledge) return; +const routeBonus = this.world.tradeRoute[tile] ? SimConfig.technology.tradeDiffusion : 0; +const chance = SimConfig.technology.cityDiffusion + routeBonus + clamp(city.population / 1200, 0, 0.006); +if (this.rng.next() < chance) { +agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * 0.72), 0, 1); +agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1); +} +} +improveTechnologyFromDensity(agent) { +let farmingCount = 0; +let metallurgyCount = 0; +let farmingSum = 0; +let metallurgySum = 0; +this.forEachLocalAgentNear(agent.x, agent.y, 2, other => { +if (!other.alive) return true; +this.ensureAgentTech(other); +const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y); +if (distance > 2) return true; +const farming = other.tech.farming || 0; +const metallurgy = other.tech.metallurgy || 0; +if (farming > 0.1) { +farmingCount++; +farmingSum += farming; +} +if (metallurgy > 0.1) { +metallurgyCount++; +metallurgySum += metallurgy; +} +return true; +}); +if (farmingCount >= 2) { +const avgFarming = farmingSum / farmingCount; +agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1); +} +if (metallurgyCount >= 2) { +const avgMetallurgy = metallurgySum / metallurgyCount; +agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1); +} +} +forEachLocalAgentNear(x, y, radius, callback) { +const w = this.world; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +const agents = this.tileAgents.get(w.idx(tx, ty)); +if (!agents) continue; +for (const agent of agents) { +if (callback(agent) === false) return; +} +} +} +} +farmingGatherMultiplier(agent, tile) { +const farmingLevel = agent.tech?.farming || 0; +const farmingWork = agent.farmingWork || 0; +const farmability = this.farmabilityAt(tile); +return clamp(1 + farmingLevel * farmingWork * farmability * 2, 1, 3); +} +metallurgyGatherMultiplier(agent, tile) { +const metallurgyLevel = agent.tech?.metallurgy || 0; +if (metallurgyLevel > 0.02) { +agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1); +} +return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1); +} +gatherConsumeReproduce(a, offspring) { +const w = this.world; +const i = w.idx(a.x, a.y); +this.ensureAgentTech(a); +const ethnicity = this.ethnicities.get(a.ethnicity); +const climateMismatch = this.climateMismatch(a.ethnicity, i); +const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1); +const cityMarket = w.city[i] >= 0 ? 0.28 : 0; +const drylandAdapted = this.isDrylandAdapted(ethnicity); +const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0; +const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage; +const carryingCapacity = this.carryingCapacityAt(i); +const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity); +const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase); +const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2); +const gathered = Math.min( +w.resource[i], +baseGatherAmount * this.farmingGatherMultiplier(a, i) * this.metallurgyGatherMultiplier(a, i) +); +w.resource[i] -= gathered; +a.resources += gathered; +const waterAdaptation = ethnicity?.climateHumidity ?? 0.45; +const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0; +const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4; +const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1; +const sedentary = getSedentary(a.traits); +const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08; +a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead; +if (a.resources <= 0) { +this.removeAgentFromOccupancy(a); +a.alive = false; +this.deaths++; +return; +} +const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12); +const mobilityPenalty = (1 - sedentary) * 0.45; +const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty; +const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35); +if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) { +const childShare = 0.36 + sedentary * 0.08; +const childResources = a.resources * childShare; +a.resources -= childResources; +const childTraits = mutateTraits(a.traits, this.rng, 0.035); +const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources); +child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1); +child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1); +offspring.push(child); +} +} +climateMismatch(ethnicityId, tile) { +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; +} +isDrylandAdapted(ethnicity) { +return !!ethnicity && ethnicity.climateTemp > 0.5 && ethnicity.climateHumidity < 0.38; +} +ethnicDensityNear(x, y, ethnicity) { +const key = `${x},${y},${ethnicity}`; +const cached = this.ethnicDensityCache.get(key); +if (cached) return cached; +let same = 0; +let foreign = 0; +const w = this.world; +for (let dy = -2; dy <= 2; dy++) { +for (let dx = -2; dx <= 2; dx++) { +if (Math.abs(dx) + Math.abs(dy) > 2) continue; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +const counts = this.tileEthnicities.get(w.idx(tx, ty)); +if (!counts) continue; +for (const [id, value] of counts) { +if (id === ethnicity) same += value; +else foreign += value; +} +if (same > 6 && foreign > 6) { +const result = { same: same * 0.22, foreign: foreign * 0.16 }; +this.ethnicDensityCache.set(key, result); +return result; +} +} +} +const result = { same: same * 0.22, foreign: foreign * 0.16 }; +this.ethnicDensityCache.set(key, result); +return result; +} +resolveAssimilation(a) { +const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity); +if (!dominant || dominant.id === a.ethnicity) { +a.foreignContact = Math.max(0, a.foreignContact - 1); +return; +} +if (a.contactEthnicity !== dominant.id) { +a.contactEthnicity = dominant.id; +a.foreignContact = 0; +} +a.foreignContact++; +const pressure = dominant.count / Math.max(1, dominant.total); +const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30); +if (this.rng.next() < chance * 0.14) { +this.changeAgentEthnicity(a, dominant.id); +a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22); +a.foreignContact = 0; +} +} +dominantEthnicityNear(x, y, self) { +const key = `${x},${y},${self}`; +const cached = this.dominantEthnicityCache.get(key); +if (cached) return cached; +const counts = new Map(); +let total = 0; +const w = this.world; +for (let dy = -3; dy <= 3; dy++) { +for (let dx = -3; dx <= 3; dx++) { +if (Math.abs(dx) + Math.abs(dy) > 3) continue; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +const tileCounts = this.tileEthnicities.get(w.idx(tx, ty)); +if (!tileCounts) continue; +for (const [id, count] of tileCounts) { +total += count; +counts.set(id, (counts.get(id) || 0) + count); +} +} +} +for (const city of this.getCitiesNear(x, y, 5)) { +const distance = Math.abs(city.x - x) + Math.abs(city.y - y); +if (!city.ethnicityComposition?.size) continue; +const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8); +for (const [id, count] of city.ethnicityComposition) { +const weighted = Math.max(1, Math.round(count * influence * 0.08)); +total += weighted; +counts.set(id, (counts.get(id) || 0) + weighted); +} +} +let best = null; +for (const [id, count] of counts) { +if (id !== self && (!best || count > best.count)) best = { id, count, total }; +} +if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits(); +if (best) this.dominantEthnicityCache.set(key, best); +return best; +} +updateWorldFields(scale = 1) { +const w = this.world; +const pheromoneDecay = Math.pow(0.996, scale); +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)); +w.pheromone[i] *= pheromoneDecay; +} +} +updateCities() { +const w = this.world; +w.city.fill(-1); +w.farmland.fill(0); +w.cityPull.fill(0); +const candidates = new Map(); +for (const a of this.agents) { +if (!a.alive) continue; +const local = w.idx(a.x, a.y); +if (a.settled < 3 && w.pressure[local] < 2) continue; +if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.24) continue; +const cx = clamp(Math.round(a.x / 4) * 4, 0, w.size - 1); +const cy = clamp(Math.round(a.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() }; +candidates.set(i, group); +} +this.ensureAgentTech(a); +group.count++; +group.resources += Math.max(0, a.resources); +group.sedentary += getSedentary(a.traits); +group.farming += a.tech.farming || 0; +group.metallurgy += a.tech.metallurgy || 0; +group.techCount++; +group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1); +} +let foundedThisTick = 0; +const canFoundCities = this.year >= years(80) && this.year % years(10) === 0; +const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0; +const candidateEntries = [...candidates].sort((a, b) => { +const sedentaryA = a[1].sedentary / Math.max(1, a[1].count); +const sedentaryB = b[1].sedentary / Math.max(1, b[1].count); +return (b[1].count * (0.7 + sedentaryB) + b[1].resources * 0.02) - (a[1].count * (0.7 + sedentaryA) + a[1].resources * 0.02); +}); +for (const [i, group] of candidateEntries) { +if (group.count < 3) continue; +const avgSedentary = group.sedentary / group.count; +let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null; +if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) { +const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98); +if (avgSedentary < 0.22 || 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); +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, 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.rebuildOccupancy(); +this.processCityEconomies(); +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 <= 0 || c.strength <= 0.06) return false; +const radius = c.agriculturalRadius; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const x = clamp(c.x + dx, 0, w.size - 1); +const y = clamp(c.y + dy, 0, w.size - 1); +const d = Math.abs(dx) + Math.abs(dy); +if (d <= radius) { +const tile = w.idx(x, y); +w.city[tile] = c.id; +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)); +} +} +} +} +return true; +}); +this.rebuildIndexes(); +} +createCity(x, y, seedGroup = null) { +const seedPopulation = seedGroup ? Math.max(14, seedGroup.count * 5) : 10; +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 * 3, urbanWeight); +if (urbanCount > 0) composition.set(id, urbanCount); +} +} +return { +id: this.nextCity++, +x, +y, +population: seedPopulation, +storedResources: 34 + (seedGroup?.resources || 0) * 0.55, +ethnicityComposition: composition, +pheromoneOutput: 0, +agriculturalRadius: 2, +tradeLinks: new Set(), +activeVisitors: 0, +age: 0, +strength: 3, +sedentaryCulture: seedSedentary, +knowledge: { farming: 0, metallurgy: 0 }, +supplyStress: 0, +polityId: null, +loyalty: 0.5, +receivedAid: false, +tradeValue: 0, +tradeReach: 0 +}; +} +absorbUrbanPopulation() { +if (!this.cities.length) return; +const absorbed = []; +for (const a of this.agents) { +if (!a.alive || a.settled < 5) { +absorbed.push(a); +continue; +} +const city = this.findCityNear(a.x, a.y, 7); +const sedentary = getSedentary(a.traits); +if (!city || this.rng.next() > sedentary * 0.85) { +absorbed.push(a); +continue; +} +const migrants = 1 + Math.floor(Math.min(8, a.resources / 8)); +const urbanWeight = clamp((sedentary - 0.14) * 1.5, 0.08, 1); +city.population += migrants; +city.storedResources += Math.max(0, a.resources) * 0.65; +const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight); +if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount); +city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015; +city.strength += 0.04; +} +this.agents = absorbed; +} +weightedUrbanContribution(amount, weight) { +const value = amount * weight; +const whole = Math.floor(value); +return whole + (this.rng.next() < value - whole ? 1 : 0); +} +processCityEconomies() { +const w = this.world; +for (const city of this.cities) { +city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 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 (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +if (Math.abs(dx) + Math.abs(dy) > radius) continue; +const x = clamp(city.x + dx, 0, w.size - 1); +const y = clamp(city.y + dy, 0, w.size - 1); +const i = w.idx(x, y); +if (w.terrain[i] === Terrain.WATER) continue; +const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1); +const 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); +w.resource[i] -= extraction; +w.farmland[i] = Math.max(w.farmland[i], pull); +w.pheromone[i] += city.pheromoneOutput * pull * 0.09; +harvested += extraction; +} +} +city.storedResources += harvested; +const trade = this.cityTradeProfile(city); +const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018); +city.storedResources += tradeIncome; +const supportRatio = city.activeVisitors / Math.max(1, city.population); +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; +} +if (city.storedResources > city.population * 0.16 && city.population > 0) { +const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8); +const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015))); +city.population += births; +city.storedResources -= births * 0.55; +addBirthsToComposition(city.ethnicityComposition, births); +} +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.agents.length < this.maxAgents) { +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 dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1; +const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits(); +for (let i = 0; i < count; i++) { +this.agents.push(this.makeAgent( +clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1), +clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1), +dominant, +mutateTraits(template, this.rng, 0.05), +this.rng.range(4, 11) +)); +} +} +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; +} +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; +} +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.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.set(id, polity); +return polity; +} +getPolityCities(polity) { +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); +} +} +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) { +return !!cityA?.tradeLinks?.has(cityB?.id) || !!cityB?.tradeLinks?.has(cityA?.id); +} +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([centerCity.id]), +treasury: Math.max(0, centerCity.storedResources * 0.12), +color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), +founded: this.year, +legitimacy: this.rng.range(0.68, 0.94), +cohesion: this.rng.range(0.58, 0.9), +charisma: this.rng.range(0.5, 1.5), +leaderStarted: this.year, +leaderTenureYears: this.rng.range(24, 68), +crisis: 0, +lastCrisisYear: this.year +}; +centerCity.polityId = id; +centerCity.loyalty = 1; +centerCity.receivedAid = false; +this.polities.push(polity); +this.polityById.set(polity.id, polity); +this.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, +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 oldEnoughForPeakMarker = this.year - (history.founded ?? this.year) > years(40); +if (oldEnoughForPeakMarker && (!history.peakEventYear || this.year - history.peakEventYear > years(120))) { +this.addPolityEvent(polity.id, "peak", this.year, { +power: sample.power, +cities: sample.cities, +population: sample.population +}, 2); +history.peakEventYear = 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); +} +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(); +} +selectNewLeader(polity, forced = false) { +if (!polity) return; +const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5); +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(0.5, 1.5); +const continuity = forced ? 0.18 : 0.34; +const institutionalPull = 0.82 + institutionalBias * 0.36; +const nextCharisma = clamp( +previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull, +0.5, +1.5 +); +polity.charisma = nextCharisma; +polity.leaderStarted = this.year; +polity.leaderTenureYears = this.rng.range(22, 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 ?? 1, 0.5, 1.5); +polity.leaderStarted ??= polity.founded ?? this.year; +polity.leaderTenureYears ??= this.rng.range(24, 68); +const tenureYears = (this.year - polity.leaderStarted) / MONTHS_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 affectedAgents = 0; +for (const agent of this.agents) { +if (!agent.alive) continue; +const d = Math.hypot(agent.x - x, agent.y - y); +if (d > radius) continue; +const falloff = 1 - d / radius; +const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); +if (this.rng.next() < damage * 0.32) { +if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent); +agent.alive = false; +this.deaths++; +affectedAgents++; +} else { +agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45)); +if (agent.tech) { +agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035); +agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04); +} +} +} +let affectedCities = 0; +let totalPopulationLoss = 0; +for (const city of this.cities) { +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); +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), +affectedAgents, +affectedCities, +totalPopulationLoss, +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(); +} +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 > 80) 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); +} +removeCityFromPolity(city) { +if (!city || city.polityId === null) return; +const oldPolity = this.getPolityById(city.polityId); +if (oldPolity) oldPolity.cityIds.delete(city.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)); +} +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.getWarBetween(a.id, b.id)) return null; +if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null; +const id = this.nextWar++; +const war = { +id, +aPolityId: a.id, +bPolityId: b.id, +started: this.year, +lastActionYear: this.year, +intensity: this.rng.range(0.45, 1.05), +exhaustionA: 0, +exhaustionB: 0, +ended: null +}; +this.wars.push(war); +a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5); +b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5); +return war; +} +endWar(war) { +if (!war || war.ended !== null) return; +war.ended = this.year; +} +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; +} +polityAge(polity) { +return (this.year - (polity?.founded ?? this.year)) / MONTHS_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); +} +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() { +for (const city of this.cities) { +if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue; +const centerInfluence = this.cityInfluence(city); +let absorbed = 0; +for (const other of this.cities) { +if (absorbed >= 3) break; +if (other === city || other.polityId !== null) continue; +if (this.distanceBetweenCities(city, other) > 34) continue; +const targetInfluence = this.cityInfluence(other); +if (centerInfluence <= targetInfluence * 1.08) continue; +const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08); +const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0; +const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus; +if (dominanceScore > 0.68 && this.rng.next() < 0.32) { +const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId); +const connection = this.polityConnectionToCity(polity, other); +if (!connection.connected) continue; +this.addCityToPolity(other, polity, 0.68); +absorbed++; +} +} +} +} +expandPolities() { +for (const polity of this.polities) { +const center = this.getCityById(polity.centerCityId); +if (!center) continue; +const centerInfluence = this.cityInfluence(center); +let absorbed = 0; +for (const city of this.cities) { +if (absorbed >= 2) break; +if (city.polityId !== null || city.id === center.id) continue; +const connection = this.polityConnectionToCity(polity, city); +if (!connection.connected) continue; +const distance = connection.distance; +const targetInfluence = this.cityInfluence(city); +if (centerInfluence <= targetInfluence * 0.9) continue; +const dominanceScore = +(centerInfluence / (targetInfluence + 1)) * +(1 / (1 + distance * 0.08)) * +(this.hasDirectTradeConnection(connection.source, city) ? 1.5 : 1.0); +if (dominanceScore > 0.62 && this.rng.next() < 0.24) { +this.addCityToPolity(city, polity, 0.62); +absorbed++; +} +} +} +} +absorbIndependentCities() { +if (this.year % years(5) !== 0) return; +for (const polity of this.polities) { +const cities = this.getPolityCities(polity); +if (!cities.length) continue; +const power = this.polityPower(polity); +const range = this.polityInfluenceRange(polity, false); +let absorbed = 0; +const candidates = this.cities +.filter(city => city.polityId === null && city.population > 0) +.map(city => ({ city, connection: this.polityConnectionToCity(polity, city) })) +.filter(x => x.connection.connected && x.connection.distance <= range) +.sort((a, b) => a.connection.distance - b.connection.distance); +for (const { city, connection } of candidates) { +if (absorbed >= 1) break; +const distance = connection.distance; +const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1); +const proximity = 1 / (1 + distance * 0.07); +const pressure = (power / Math.max(1, cityInfluence)) * proximity; +if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) { +this.addCityToPolity(city, polity, 0.42); +city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99))); +absorbed++; +} +} +} +} +maybeStartWars() { +if (this.year % years(10) !== 0) return; +for (const a of this.polities) { +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.isPolityAtWar(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 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 generalInstability = Math.max(aInstability, bInstability) * 0.04; +const borderFriction = this.borderFriction(a, b, distance); +const chance = +0.002 + +proximity * 0.010 + +asymmetryPressure * 0.010 + +weakSideInstability * 0.020 + +weakSideAge * 0.012 + +weakSideOverextension * 0.012 + +borderFriction * 0.010 + +generalInstability; +if (this.rng.next() < clamp(chance, 0, 0.08)) { +this.startWar(a, b); +started = true; +} +} +} +} +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 % years(2) !== 0) return; +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 attackerCities = this.getPolityCities(attacker); +const defenderCities = this.getPolityCities(defender); +if (!attackerCities.length || !defenderCities.length) return; +const attackerPower = this.polityPower(attacker); +const defenderPower = this.polityPower(defender); +if (attackerPower <= defenderPower * 1.05) return; +const range = this.polityInfluenceRange(attacker, true); +const candidates = defenderCities +.filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2) +.map(city => ({ city, connection: this.polityConnectionToCity(attacker, city) })) +.filter(x => x.connection.connected && x.connection.distance <= range) +.sort((a, b) => a.connection.distance - b.connection.distance); +if (!candidates.length) return; +const topCandidates = candidates.slice(0, 3); +const selected = topCandidates[this.rng.int(topCandidates.length)]; +const pressure = this.warAbsorptionPressure( +attacker, +defender, +selected.city, +selected.connection.distance, +attackerPower, +defenderPower +); +const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45); +if (pressure > 0.95 && this.rng.next() < chance) { +this.captureCityInWar(selected.city, attacker, defender, war, pressure); +} else { +selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1); +} +} +warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) { +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 * +(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( +0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025, +0.05, +0.22 +); +const loss = Math.floor((city.population || 0) * lossRate); +if (loss > 0) { +city.population = Math.max(1, city.population - loss); +if (typeof removeFromComposition === "function" && city.ethnicityComposition) { +removeFromComposition(city.ethnicityComposition, loss); +} +this.deaths += Math.floor(loss * 0.35); +} +this.removeCityFromPolity(city); +this.addCityToPolity(city, attacker, 0.28); +city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36); +war.lastActionYear = this.year; +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); +} +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.006 * intensity, 0, 1); +war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1); +} +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 (age > years(90)) { +this.endWar(war); +return; +} +if (age > years(10) && noActionFor > years(18)) { +this.endWar(war); +return; +} +if (exhaustion > 0.85 && this.rng.next() < 0.35) { +this.endWar(war); +} +} +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); +} +} +} +} +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)) / MONTHS_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; +center.loyalty = 1; +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); +if (this.hasDirectTradeConnection(city, center)) delta += 0.025; +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, 1); +} +} +} +splitUnloyalCities() { +for (const polity of this.polities) { +const instability = this.polityInstability(polity); +const agePressure = this.polityAgePressure(polity); +const threshold = clamp(0.14 + instability * 0.05 + agePressure * 0.03, 0.14, 0.28); +for (const city of this.getPolityCities(polity)) { +if (city.id === polity.centerCityId || city.loyalty >= threshold) continue; +const chance = +(threshold - city.loyalty) * 0.28 + +instability * 0.012 + +agePressure * 0.010; +if (this.rng.next() < chance) this.removeCityFromPolity(city); +} +} +} +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) { +this.removeCityFromPolity(city); +city.loyalty = clamp(city.loyalty - 0.12, 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"); +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 = 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) { +this.markPolityEnded(polity, "fragmented"); +cities[0].polityId = null; +cities[0].loyalty = 0.45; +cities[0].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)) { +city.polityId = null; +city.loyalty = 0.45; +city.receivedAid = false; +} +} +} +updatePolities() { +if (this.year % years(1) !== 0) return; +this.cleanupPolities(); +this.foundPolities(); +this.expandPolities(); +if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes(); +this.collectAndRedistributeResources(); +this.detectPolityFamines(); +if (this.erodePolityLegitimacy) this.erodePolityLegitimacy(); +if (this.triggerPolityCrises) this.triggerPolityCrises(); +if (this.applyOldStateStress) this.applyOldStateStress(); +if (this.updatePolityLeaders) this.updatePolityLeaders(); +this.updateCityLoyalty(); +this.absorbIndependentCities(); +this.maybeStartWars(); +this.updateWars(); +this.splitUnloyalCities(); +this.cleanupPolities(); +if (this.samplePolityHistories) this.samplePolityHistories(); +} +reinforcePolityTradeRoutes() { +const w = this.world; +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 path = this.findTerrainRoute(center.x, center.y, target.x, target.y); +if (!this.isValidRoutePath(path, target.x, target.y)) continue; +const cities = this.getPolityCities(polity); +const roadCost = 8 + cities.length * 1.5 + this.polityOverextension(polity) * 3; +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; +center.tradeLinks.add(target.id); +target.tradeLinks.add(center.id); +this.tradeLinks.push({ from: center.id, to: target.id, strength: 0.22, path }); +for (const tile of path) { +w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 34); +w.pheromone[tile] += 2.4; +} +} +} +updateTradeRoutes() { +const w = this.world; +for (let i = 0; i < w.count; i++) { +const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2; +if (invalidTerrain) w.tradeRoute[i] = 0; +else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2); +} +this.tradeLinks = []; +for (const city of this.cities) city.tradeLinks.clear(); +const pairCandidates = []; +const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400); +const sqrtPop = new Map(this.cities.map(city => [city.id, Math.sqrt(city.population || 1)])); +for (let a = 0; a < this.cities.length; a++) { +for (let b = a + 1; b < this.cities.length; b++) { +const c1 = this.cities[a]; +const c2 = this.cities[b]; +const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y); +if (d > 44) continue; +const distanceFactor = clamp((d - 14) / 30, 0, 1); +const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1); +const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0; +const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias; +pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore }); +} +} +pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore); +const candidates = []; +for (const pair of pairCandidates.slice(0, maxPairsToRoute)) { +const { c1, c2, distanceFactor, marketScale } = pair; +const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y); +if (!this.isValidRoutePath(path, c2.x, c2.y)) continue; +const strength = this.routeStrengthForPath(path); +if (strength < 0.08) continue; +if (!this.canPayRouteConstructionCost(c1, c2, path)) continue; +const tradeScore = strength * (1 + distanceFactor * 0.7) * marketScale; +candidates.push({ c1, c2, strength, path, tradeScore }); +} +candidates.sort((a, b) => b.tradeScore - a.tradeScore); +const maxLinks = Math.max(1, Math.floor(this.cities.length / 2)); +const supportedRoutes = new Set(); +for (const candidate of candidates) { +if (this.tradeLinks.length >= maxLinks) break; +const { c1, c2, strength, path } = candidate; +const c1Limit = c1.population > 90 ? 3 : 2; +const c2Limit = c2.population > 90 ? 3 : 2; +if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue; +if (!this.payRouteConstructionCost(c1, c2, path)) continue; +c1.tradeLinks.add(c2.id); +c2.tradeLinks.add(c1.id); +this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path }); +this.exchangeCityResources(c1, c2, strength, path); +for (const tile of path) { +supportedRoutes.add(tile); +w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 18); +} +} +for (let i = 0; i < w.count; i++) { +if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4); +} +this.activeTradeRouteTiles = new Set(); +for (let i = 0; i < w.count; i++) { +if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i); +} +this.diffuseCityKnowledgeThroughTrade(); +} +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) { +const w = this.world; +const path = []; +const visited = new Set(); +let x = x1; +let y = y1; +const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60); +const directions = [ +[1, 0], +[-1, 0], +[0, 1], +[0, -1] +]; +for (let step = 0; step < maxSteps; step++) { +const current = w.idx(x, y); +path.push(current); +visited.add(current); +if (x === x2 && y === y2) break; +let bestX = x; +let bestY = y; +let bestScore = Infinity; +for (const [dx, dy] of directions) { +const nx = x + dx; +const ny = y + dy; +if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue; +const i = w.idx(nx, ny); +const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0); +const revisitCost = visited.has(i) ? 5 : 0; +const routeEase = w.tradeRoute[i] ? -2 : 0; +const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4); +const distance = Math.abs(nx - x2) + Math.abs(ny - y2); +const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase; +if (score < bestScore) { +bestScore = score; +bestX = nx; +bestY = ny; +} +} +if (bestX === x && bestY === y) break; +x = bestX; +y = bestY; +} +return path; +} +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(1, w.move[i]); +} +return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2; +} +isValidRoutePath(path, targetX, targetY) { +const w = this.world; +if (!path.length) return false; +const last = path[path.length - 1]; +if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false; +if (path.length > 42) return false; +let totalCost = 0; +let hardTiles = 0; +const seen = new Set(); +for (const tile of path) { +if (seen.has(tile)) return false; +seen.add(tile); +if (w.terrain[tile] === Terrain.WATER) return false; +totalCost += w.move[tile]; +if (w.move[tile] > 1.8) hardTiles++; +} +return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12; +} +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); +} +routeConstructionCost(path, polityBacked = false) { +let weakTiles = 0; +for (const tile of path) { +if (this.world.tradeRoute[tile] < 24) weakTiles++; +} +return weakTiles * (polityBacked ? 0.22 : 0.16); +} +canPayRouteConstructionCost(cityA, cityB, path, polity = null) { +const cost = this.routeConstructionCost(path, !!polity); +if (cost <= 0) return true; +return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost; +} +payRouteConstructionCost(cityA, cityB, path, polity = null) { +const cost = this.routeConstructionCost(path, !!polity); +if (cost <= 0) return true; +if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) 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; +} +return remaining <= 0.001; +} +drainCityResources(city, amount) { +const paid = Math.min(city.storedResources, amount); +city.storedResources -= paid; +return paid; +} +depositRoutePheromone(path, amount) { +const w = this.world; +for (const i of path) { +w.pheromone[i] += amount; +} +} +updateEthnicStats() { +for (const e of this.ethnicities.values()) { +e.population = 0; +e.diversity = 0; +e.centroidX = 0; +e.centroidY = 0; +e.activeTraitPopulation = 0; +e.traitSums = emptyTraitSums(); +} +for (const a of this.agents) { +const e = this.ethnicities.get(a.ethnicity); +if (!e) continue; +e.population++; +e.centroidX += a.x; +e.centroidY += a.y; +e.activeTraitPopulation++; +addTraits(e.traitSums, a.traits); +} +for (const city of this.cities) { +for (const [id, count] of city.ethnicityComposition) { +const e = this.ethnicities.get(id); +if (!e) continue; +e.population += count; +e.centroidX += city.x * count; +e.centroidY += city.y * count; +} +} +for (const e of this.ethnicities.values()) { +if (!e.population) continue; +e.centroidX /= e.population; +e.centroidY /= e.population; +const activeTraitCount = Math.max(1, e.activeTraitPopulation); +e.averageTraits = averageTraits(e.traitSums, activeTraitCount); +} +for (const a of this.agents) { +const e = this.ethnicities.get(a.ethnicity); +if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits); +} +for (const e of this.ethnicities.values()) { +if (e.population) e.diversity /= e.population; +} +} +splitDivergentEthnicities() { +for (const e of this.ethnicities.values()) { +if (e.activeTraitPopulation < 24 || e.population < 40 || e.diversity < 0.14) continue; +const candidates = []; +let tempSum = 0; +let humidSum = 0; +for (const a of this.agents) { +if (a.ethnicity !== e.id) continue; +const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 0.95; +const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.09; +const tile = this.world.idx(a.x, a.y); +const climate = this.climateMismatch(e.id, tile) > 0.18; +if ((far || spatial || climate) && this.rng.next() < 0.72) { +candidates.push(a); +tempSum += this.world.temperature[tile]; +humidSum += this.world.humidity[tile]; +} +} +if (candidates.length >= 6) { +const newId = this.createEthnicity(e.id, { +temperature: tempSum / candidates.length, +humidity: humidSum / candidates.length +}); +for (const a of candidates) a.ethnicity = newId; +} +} +} +toJSON() { +return { +version: SimConfig.save.version, +rngSeed: this.rng.seed, +year: this.year, +deaths: this.deaths, +nextEthnicity: this.nextEthnicity, +nextCity: this.nextCity, +nextPolity: this.nextPolity, +nextWar: this.nextWar, +nextDisaster: this.nextDisaster, +maxAgents: this.maxAgents, +world: { +size: this.world.size, +terrain: packArray(this.world.terrain), +resource: packArray(this.world.resource), +regen: packArray(this.world.regen), +move: packArray(this.world.move), +fertility: packArray(this.world.fertility), +mineral: packArray(this.world.mineral), +temperature: packArray(this.world.temperature), +humidity: packArray(this.world.humidity), +pheromone: packArray(this.world.pheromone), +tradeRoute: packArray(this.world.tradeRoute), +farmland: packArray(this.world.farmland), +cityPull: packArray(this.world.cityPull), +city: packArray(this.world.city), +pressure: packArray(this.world.pressure), +dominantEthnicity: packArray(this.world.dominantEthnicity), +cultureDiversity: packArray(this.world.cultureDiversity) +}, +agents: this.agents, +ethnicities: [...this.ethnicities.values()].map(e => ({ +id: e.id, +parent: e.parent, +born: e.born, +climateTemp: e.climateTemp, +climateHumidity: e.climateHumidity, +color: e.color +})), +cities: this.cities.map(c => ({ +...c, +ethnicityComposition: [...c.ethnicityComposition], +tradeLinks: [...c.tradeLinks] +})), +polities: this.polities.map(p => ({ +id: p.id, +centerCityId: p.centerCityId, +cityIds: [...p.cityIds], +treasury: p.treasury, +color: p.color, +founded: p.founded, +legitimacy: p.legitimacy, +cohesion: p.cohesion, +charisma: p.charisma, +leaderStarted: p.leaderStarted, +leaderTenureYears: p.leaderTenureYears, +crisis: p.crisis, +lastCrisisYear: p.lastCrisisYear, +lastFamineYear: p.lastFamineYear ?? null +})), +wars: this.wars, +disasters: this.disasters, +disasterHistory: this.disasterHistory, +polityHistory: [...this.polityHistory.values()], +deadPolityHistories: this.deadPolityHistories +}; +} +static fromJSON(state) { +if (!state?.world?.size) throw new Error("Invalid save payload"); +const sim = new Simulation(state.world.size, 0, { blankWorld: true, skipSpawn: true }); +sim.rng.seed = state.rngSeed >>> 0; +sim.year = state.year || 0; +sim.deaths = state.deaths || 0; +sim.nextEthnicity = state.nextEthnicity || 1; +sim.nextCity = state.nextCity || 1; +sim.nextPolity = state.nextPolity || 1; +sim.nextWar = state.nextWar || 1; +sim.nextDisaster = state.nextDisaster || 1; +sim.maxAgents = state.maxAgents || 30000; +sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array)); +sim.world.resource.set(unpackArray(state.world.resource, Float32Array)); +sim.world.regen.set(unpackArray(state.world.regen, Float32Array)); +sim.world.move.set(unpackArray(state.world.move, Float32Array)); +sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array)); +sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array)); +sim.world.temperature.set(unpackArray(state.world.temperature, Float32Array)); +sim.world.humidity.set(unpackArray(state.world.humidity, Float32Array)); +sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array)); +sim.world.tradeRoute.set(unpackArray(state.world.tradeRoute, Uint8Array)); +sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array)); +sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array)); +sim.world.city.set(unpackArray(state.world.city, Int32Array)); +if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array)); +if (state.world.dominantEthnicity) sim.world.dominantEthnicity.set(unpackArray(state.world.dominantEthnicity, Int32Array)); +if (state.world.cultureDiversity) sim.world.cultureDiversity.set(unpackArray(state.world.cultureDiversity, Float32Array)); +sim.agents = state.agents || []; +for (const a of sim.agents) sim.ensureAgentTech(a); +sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, { +id: e.id, +parent: e.parent, +born: e.born, +population: 0, +diversity: 0, +climateTemp: e.climateTemp, +climateHumidity: e.climateHumidity, +color: e.color, +centroidX: 0, +centroidY: 0 +}])); +sim.cities = (state.cities || []).map(c => ({ +id: c.id, +x: c.x, +y: c.y, +population: c.population || 0, +storedResources: c.storedResources || 0, +ethnicityComposition: new Map(c.ethnicityComposition || []), +pheromoneOutput: c.pheromoneOutput || 0, +agriculturalRadius: c.agriculturalRadius || 2, +tradeLinks: new Set(c.tradeLinks || []), +activeVisitors: 0, +age: c.age || 0, +strength: c.strength || 1, +sedentaryCulture: c.sedentaryCulture ?? 0.5, +knowledge: { +farming: clamp(c.knowledge?.farming ?? 0, 0, 1), +metallurgy: clamp(c.knowledge?.metallurgy ?? 0, 0, 1) +}, +supplyStress: c.supplyStress ?? 0, +polityId: c.polityId ?? null, +loyalty: c.loyalty ?? 0.5, +receivedAid: c.receivedAid ?? false, +tradeValue: c.tradeValue ?? 0, +tradeReach: c.tradeReach ?? 0 +})); +sim.polities = (state.polities || []).map(p => ({ +id: p.id, +centerCityId: p.centerCityId, +cityIds: new Set(p.cityIds || []), +treasury: p.treasury || 0, +color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62), +founded: p.founded || 0, +legitimacy: p.legitimacy ?? 0.7, +cohesion: p.cohesion ?? 0.6, +charisma: clamp(p.charisma ?? 1, 0.5, 1.5), +leaderStarted: p.leaderStarted ?? p.founded ?? sim.year, +leaderTenureYears: clamp(p.leaderTenureYears ?? 48, 12, 96), +crisis: p.crisis ?? 0, +lastCrisisYear: p.lastCrisisYear ?? sim.year, +lastFamineYear: p.lastFamineYear ?? null +})); +sim.rebuildIndexes(); +sim.wars = (state.wars || []).map(w => ({ +id: w.id, +aPolityId: w.aPolityId, +bPolityId: w.bPolityId, +started: w.started || sim.year, +lastActionYear: w.lastActionYear || w.started || sim.year, +intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25), +exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1), +exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1), +ended: w.ended ?? null +})).filter(w => w.ended === null); +const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0); +sim.nextWar = Math.max(sim.nextWar, maxWarId + 1); +const restoreDisaster = d => ({ +id: d.id, +year: d.year || 0, +x: d.x || 0, +y: d.y || 0, +radius: d.radius || 8, +intensity: d.intensity || 0.5, +expires: d.expires ?? ((d.year || 0) + years(18)), +affectedAgents: d.affectedAgents || 0, +affectedCities: d.affectedCities || 0, +totalPopulationLoss: d.totalPopulationLoss || 0, +affectedPolities: d.affectedPolities || [] +}); +sim.disasters = (state.disasters || []).map(restoreDisaster); +sim.disasterHistory = (state.disasterHistory || []).map(restoreDisaster); +const maxDisasterId = [...sim.disasters, ...sim.disasterHistory].reduce((max, disaster) => Math.max(max, disaster.id || 0), 0); +sim.nextDisaster = Math.max(sim.nextDisaster, maxDisasterId + 1); +sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, { +id: h.id, +color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), +founded: h.founded || 0, +ended: h.ended ?? null, +active: h.active !== false, +centerCityId: h.centerCityId ?? null, +fate: h.fate || null, +samples: h.samples || [], +events: h.events || [], +peakPower: h.peakPower || 0, +peakYear: h.peakYear ?? null, +peakEventYear: h.peakEventYear ?? null +}])); +sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({ +id: h.id, +color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62), +founded: h.founded || 0, +ended: h.ended ?? null, +active: false, +centerCityId: h.centerCityId ?? null, +fate: h.fate || null, +samples: h.samples || [], +events: h.events || [], +peakPower: h.peakPower || 0, +peakYear: h.peakYear ?? null, +peakEventYear: h.peakEventYear ?? null +})); +for (const polity of sim.polities) sim.ensurePolityHistory(polity); +sim.tradeLinks = []; +sim.activeTradeRouteTiles = new Set(); +sim.rebuildOccupancy(); +sim.updateTradeRoutes(); +sim.cleanupPolities(); +sim.updateEthnicStats(); +return sim; +} } - function render() { - const start = performance.now(); - const w = sim.world; - const size = w.size; - if (!renderImage || renderImageSize !== size) { - renderImage = ctx.createImageData(size, size); - renderImageSize = size; - } - const image = renderImage; - const data = image.data; - const mode = els.viewMode.value; - const cityPolityColor = new Map(); - if (mode === "polities") { - for (const city of sim.cities) { - if (city.polityId === null) continue; - const polity = sim.getPolityById(city.polityId); - if (!polity) continue; - const isGraphHover = graphState.hoverPolityId === polity.id; - cityPolityColor.set(city.id, { - color: polity.color, - isGraphHover - }); - } - } - - for (let i = 0; i < w.count; i++) { - let color; - if (mode === "resources") { - const v = clamp(w.resource[i] / 30, 0, 1); - color = mix([28, 36, 40], [107, 188, 85], v); - } else if (mode === "ethnicity") { - const id = w.dominantEthnicity[i]; - const ethnicity = id >= 0 ? sim.ethnicities.get(id) : null; - color = ethnicity - ? mix(ethnicity.color, [178, 184, 177], clamp(w.cultureDiversity[i] * 0.85, 0, 0.65)) - : mix(terrainInfo[w.terrain[i]].color, [18, 21, 23], 0.55); - } else if (mode === "pressure") { - const v = clamp(w.pressure[i] / 12, 0, 1); - color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v); - } else if (mode === "polities") { - color = terrainInfo[w.terrain[i]].color; - if (w.city[i] >= 0) { - const polityColor = cityPolityColor.get(w.city[i]); - if (polityColor) { - color = mix(color, polityColor.color, polityColor.isGraphHover ? 0.82 : 0.5); - if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.28); - } - } - } else if (mode === "technology") { - color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35); - } else { - color = terrainInfo[w.terrain[i]].color; - } - const p = i * 4; - data[p] = color[0]; - data[p + 1] = color[1]; - data[p + 2] = color[2]; - data[p + 3] = 255; - } - - ctx.putImageData(image, 0, 0); - renderDisasters(); - drawTradeLinks(); - drawAgentsAndCities(mode); - drawGraphPolityHighlight(); - maybeRenderStateGraph(); - els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; +const start = performance.now(); +const w = sim.world; +const size = w.size; +if (!renderImage || renderImageSize !== size) { +renderImage = ctx.createImageData(size, size); +renderImageSize = size; +} +const image = renderImage; +const data = image.data; +const mode = els.viewMode.value; +const cityPolityColor = new Map(); +if (mode === "polities") { +for (const city of sim.cities) { +if (city.polityId === null) continue; +const polity = sim.getPolityById(city.polityId); +if (!polity) continue; +const isGraphHover = graphState.hoverPolityId === polity.id; +cityPolityColor.set(city.id, { +color: polity.color, +isGraphHover +}); +} +} +for (let i = 0; i < w.count; i++) { +let color; +if (mode === "resources") { +const v = clamp(w.resource[i] / 30, 0, 1); +color = mix([28, 36, 40], [107, 188, 85], v); +} else if (mode === "ethnicity") { +color = terrainInfo[w.terrain[i]].color; +} else if (mode === "pressure") { +const v = clamp(w.pressure[i] / 12, 0, 1); +color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v); +} else if (mode === "polities") { +color = terrainInfo[w.terrain[i]].color; +if (w.city[i] >= 0) { +const polityColor = cityPolityColor.get(w.city[i]); +if (polityColor) { +color = mix(color, polityColor.color, polityColor.isGraphHover ? 0.82 : 0.5); +if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.28); +} +} +} else if (mode === "technology") { +color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35); +} else { +color = terrainInfo[w.terrain[i]].color; +} +const p = i * 4; +data[p] = color[0]; +data[p + 1] = color[1]; +data[p + 2] = color[2]; +data[p + 3] = 255; +} +ctx.putImageData(image, 0, 0); +renderDisasters(); +drawTradeLinks(); +drawAgentsAndCities(mode); +drawGraphPolityHighlight(); +maybeRenderStateGraph(); +els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; } - function renderDisasters() { - if (!sim?.disasters?.length) return; - sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires); - if (!sim.disasters.length) return; - ctx.save(); - ctx.globalCompositeOperation = "source-over"; - for (const disaster of sim.disasters) { - const duration = Math.max(1, disaster.expires - disaster.year); - const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1); - const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22); - if (alpha <= 0.005) continue; - const innerRadius = Math.max(1, disaster.radius * 0.32); - const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius); - gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`); - gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`); - gradient.addColorStop(1, "rgba(211, 95, 84, 0)"); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2); - ctx.stroke(); - } - ctx.restore(); +if (!sim?.disasters?.length) return; +sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires); +if (!sim.disasters.length) return; +ctx.save(); +ctx.globalCompositeOperation = "source-over"; +for (const disaster of sim.disasters) { +const duration = Math.max(1, disaster.expires - disaster.year); +const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1); +const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22); +if (alpha <= 0.005) continue; +const innerRadius = Math.max(1, disaster.radius * 0.32); +const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius); +gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`); +gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`); +gradient.addColorStop(1, "rgba(211, 95, 84, 0)"); +ctx.fillStyle = gradient; +ctx.beginPath(); +ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2); +ctx.fill(); +ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`; +ctx.lineWidth = 1; +ctx.beginPath(); +ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2); +ctx.stroke(); +} +ctx.restore(); } - function drawTradeLinks() { - const w = sim.world; - ctx.save(); - ctx.globalAlpha = 0.24; - ctx.fillStyle = "#d9b650"; - for (const i of sim.activeTradeRouteTiles || []) { - ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1); - } - if (!sim.tradeLinks.length) { - ctx.restore(); - return; - } - for (const link of sim.tradeLinks) { - ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42); - ctx.fillStyle = "#ffd75a"; - for (const tile of link.path || []) { - ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1); - } - } - ctx.restore(); +const w = sim.world; +ctx.save(); +ctx.globalAlpha = 0.24; +ctx.fillStyle = "#d9b650"; +for (const i of sim.activeTradeRouteTiles || []) { +ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1); +} +if (!sim.tradeLinks.length) { +ctx.restore(); +return; +} +for (const link of sim.tradeLinks) { +ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42); +ctx.fillStyle = "#ffd75a"; +for (const tile of link.path || []) { +ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1); +} +} +ctx.restore(); } - function drawAgentsAndCities(mode) { - ctx.save(); - for (const city of sim.cities) { - const radius = cityRenderRadius(city); - const color = cityDisplayColor(city, mode); - ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.72)`; - ctx.globalAlpha = 0.88; - ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); - } - ctx.globalAlpha = 1; - - if (mode === "technology") { - for (const a of sim.agents) { - sim.ensureAgentTech(a); - const farming = a.tech.farming || 0; - const metallurgy = a.tech.metallurgy || 0; - const tech = clamp(Math.max(farming, metallurgy), 0, 1); - if (tech <= 0.01) continue; - const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy)); - ctx.fillStyle = `rgba(${color[0]},${color[1]},${color[2]},${clamp(0.32 + tech * 0.68, 0.32, 1)})`; - ctx.fillRect(a.x, a.y, 1, 1); - } - } else if (mode === "ethnicity") { - for (const [tile, counts] of sim.tileEthnicities) { - let bestId = null; - let bestCount = 0; - for (const [id, count] of counts) { - if (count > bestCount) { - bestId = id; - bestCount = count; - } - } - const e = bestId !== null ? sim.ethnicities.get(bestId) : null; - if (!e) continue; - ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`; - ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1); - } - } else { - ctx.fillStyle = "#eeeccf"; - for (const tile of sim.tileAgents.keys()) { - ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1); - } - } - - for (const city of sim.cities) { - const color = cityDisplayColor(city, mode); - ctx.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`; - ctx.fillRect(city.x, city.y, 1, 1); - } - ctx.restore(); +ctx.save(); +for (const city of sim.cities) { +const radius = cityRenderRadius(city); +const color = cityDisplayColor(city, mode); +ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.72)`; +ctx.globalAlpha = 0.88; +ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); +} +ctx.globalAlpha = 1; +if (mode === "technology") { +for (const a of sim.agents) { +sim.ensureAgentTech(a); +const farming = a.tech.farming || 0; +const metallurgy = a.tech.metallurgy || 0; +const tech = clamp(Math.max(farming, metallurgy), 0, 1); +if (tech <= 0.01) continue; +const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy)); +ctx.fillStyle = `rgba(${color[0]},${color[1]},${color[2]},${clamp(0.32 + tech * 0.68, 0.32, 1)})`; +ctx.fillRect(a.x, a.y, 1, 1); +} +} else if (mode === "ethnicity") { +for (const a of sim.agents) { +const e = sim.ethnicities.get(a.ethnicity); +if (!e) continue; +ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`; +ctx.fillRect(a.x, a.y, 1, 1); +} +} else { +ctx.fillStyle = "#eeeccf"; +for (const tile of sim.tileAgents.keys()) { +ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1); +} +} +for (const city of sim.cities) { +const color = cityDisplayColor(city, mode); +ctx.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`; +ctx.fillRect(city.x, city.y, 1, 1); +} +ctx.restore(); } - function drawGraphPolityHighlight() { - const polityId = graphState.hoverPolityId; - if (polityId === null) return; - const polity = sim.getPolityById(polityId); - if (!polity) return; - const cities = sim.getPolityCities(polity); - if (!cities.length) return; - - ctx.save(); - ctx.lineWidth = 1; - for (const city of cities) { - const radius = Math.max(3, cityRenderRadius(city) + 2); - const color = polity.color || [238, 232, 188]; - ctx.globalAlpha = city.id === polity.centerCityId ? 0.95 : 0.72; - ctx.strokeStyle = `rgb(${Math.min(255, color[0] + 72)}, ${Math.min(255, color[1] + 72)}, ${Math.min(255, color[2] + 72)})`; - ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); - ctx.globalAlpha = 0.32; - ctx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; - ctx.fillRect(city.x - radius + 1, city.y - radius + 1, Math.max(1, radius * 2 - 1), Math.max(1, radius * 2 - 1)); - } - ctx.restore(); +const polityId = graphState.hoverPolityId; +if (polityId === null) return; +const polity = sim.getPolityById(polityId); +if (!polity) return; +const cities = sim.getPolityCities(polity); +if (!cities.length) return; +ctx.save(); +ctx.lineWidth = 1; +for (const city of cities) { +const radius = Math.max(3, cityRenderRadius(city) + 2); +const color = polity.color || [238, 232, 188]; +ctx.globalAlpha = city.id === polity.centerCityId ? 0.95 : 0.72; +ctx.strokeStyle = `rgb(${Math.min(255, color[0] + 72)}, ${Math.min(255, color[1] + 72)}, ${Math.min(255, color[2] + 72)})`; +ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); +ctx.globalAlpha = 0.32; +ctx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; +ctx.fillRect(city.x - radius + 1, city.y - radius + 1, Math.max(1, radius * 2 - 1), Math.max(1, radius * 2 - 1)); +} +ctx.restore(); } - function cityRenderRadius(city) { - return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7); +return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7); } - function cityMajorityColor(city) { - const id = dominantComposition(city.ethnicityComposition); - return sim.ethnicities.get(id)?.color || [242, 215, 134]; +const id = dominantComposition(city.ethnicityComposition); +return sim.ethnicities.get(id)?.color || [242, 215, 134]; } - function cityDisplayColor(city, mode) { - if (mode === "polities") { - if (city.polityId !== null) { - const polity = sim.getPolityById(city.polityId); - if (polity) return polity.color; - } - return [218, 205, 154]; - } - return cityMajorityColor(city); +if (mode === "polities") { +if (city.polityId !== null) { +const polity = sim.getPolityById(city.polityId); +if (polity) return polity.color; +} +return [218, 205, 154]; +} +return cityMajorityColor(city); } - function showTooltip(event) { - const rect = els.canvas.getBoundingClientRect(); - const simRect = els.sim.getBoundingClientRect(); - const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size); - const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size); - hoverState = { - x, - y, - left: event.clientX - simRect.left + 16, - top: event.clientY - simRect.top + 16, - width: simRect.width, - height: simRect.height - }; - renderTooltip(); +const rect = els.canvas.getBoundingClientRect(); +const simRect = els.sim.getBoundingClientRect(); +const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size); +const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size); +hoverState = { +x, +y, +left: event.clientX - simRect.left + 16, +top: event.clientY - simRect.top + 16, +width: simRect.width, +height: simRect.height +}; +renderTooltip(); } - function renderTooltip() { - if (!hoverState) return; - const x = hoverState.x; - const y = hoverState.y; - if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) { - hideTooltip(); - return; - } - - const w = sim.world; - const i = w.idx(x, y); - const agent = findAgentAt(x, y); - const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null; - const terrain = terrainInfo[w.terrain[i]]; - const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null; - const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2); - const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null; - const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0; - const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null; - const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-"; - const rows = items => items.filter(Boolean).join(""); - const row = (label, value) => `${label}${value}`; - const section = (title, items) => { - const body = rows(items); - return body ? `

${title}

${body}
` : ""; - }; - - els.tooltip.innerHTML = ` +if (!hoverState) return; +const x = hoverState.x; +const y = hoverState.y; +if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) { +hideTooltip(); +return; +} +const w = sim.world; +const i = w.idx(x, y); +const agent = findAgentAt(x, y); +const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null; +const terrain = terrainInfo[w.terrain[i]]; +const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null; +const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2); +const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null; +const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0; +const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null; +const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-"; +const rows = items => items.filter(Boolean).join(""); +const row = (label, value) => `${label}${value}`; +const section = (title, items) => { +const body = rows(items); +return body ? `

${title}

${body}
` : ""; +}; +els.tooltip.innerHTML = ` ${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name} ${section("Tile", [ row("Position", `${x}, ${y}`), @@ -3825,829 +3555,799 @@ function renderTooltip() { agent ? row("Stored", agent.resources.toFixed(1)) : "" ])} `; - els.tooltip.hidden = false; - const margin = 8; - const width = els.tooltip.offsetWidth; - const height = els.tooltip.offsetHeight; - let left = hoverState.left; - let top = hoverState.top; - if (left + width + margin > hoverState.width) left = hoverState.left - width - 32; - if (top + height + margin > hoverState.height) top = hoverState.height - height - margin; - els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.width - width - margin))}px`; - els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`; +els.tooltip.hidden = false; +const margin = 8; +const width = els.tooltip.offsetWidth; +const height = els.tooltip.offsetHeight; +let left = hoverState.left; +let top = hoverState.top; +if (left + width + margin > hoverState.width) left = hoverState.left - width - 32; +if (top + height + margin > hoverState.height) top = hoverState.height - height - margin; +els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.width - width - margin))}px`; +els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`; } - function hideTooltip() { - hoverState = null; - els.tooltip.hidden = true; +hoverState = null; +els.tooltip.hidden = true; } - function findAgentAt(x, y) { - const directIndex = sim.world.idx(x, y); - return sim.tileAgents.get(directIndex)?.[0] || null; +const directIndex = sim.world.idx(x, y); +return sim.tileAgents.get(directIndex)?.[0] || null; } - function waterInfluenceAt(world, x, y) { - let score = 0; - 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; - const tx = x + dx; - const ty = y + dy; - if (tx < 0 || ty < 0 || tx >= world.size || ty >= world.size) continue; - if (world.terrain[world.idx(tx, ty)] === Terrain.WATER) score += (4 - d) / 4; - } - } - return score; +let score = 0; +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; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= world.size || ty >= world.size) continue; +if (world.terrain[world.idx(tx, ty)] === Terrain.WATER) score += (4 - d) / 4; +} +} +return score; } - function updateStats(force = false) { - const now = performance.now(); - if (!force && now - lastStatsAt < SimConfig.render.statsThrottleMs) return; - lastStatsAt = now; - const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0); - const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0); - let farmingTotal = 0; - let metallurgyTotal = 0; - let farmingHolders = 0; - let metallurgyHolders = 0; - for (const a of sim.agents) { - sim.ensureAgentTech(a); - farmingTotal += a.tech.farming || 0; - metallurgyTotal += a.tech.metallurgy || 0; - if ((a.tech.farming || 0) > 0.02) farmingHolders++; - if ((a.tech.metallurgy || 0) > 0.02) metallurgyHolders++; - } - const agentCount = Math.max(1, sim.agents.length); - els.year.textContent = formatSimDate(sim.year); - els.activeGroups.textContent = sim.agents.length.toLocaleString(); - els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString(); - els.ethnicities.textContent = livingEthnicities.length.toLocaleString(); - els.cities.textContent = sim.cities.length.toLocaleString(); - els.polities.textContent = sim.polities.length.toLocaleString(); - if (els.wars) els.wars.textContent = sim.wars.length.toLocaleString(); - els.routes.textContent = sim.tradeLinks.length.toLocaleString(); - els.farmingKnowledge.textContent = `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`; - els.metallurgyKnowledge.textContent = `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`; - els.deaths.textContent = sim.deaths.toLocaleString(); - - const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9); - els.ethnicityList.innerHTML = top.map(e => ( - `
  • ` + - `E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}
  • ` - )).join(""); - maybeRenderStateGraph(); +const now = performance.now(); +if (!force && now - lastStatsAt < SimConfig.render.statsThrottleMs) return; +lastStatsAt = now; +const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0); +const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0); +let farmingTotal = 0; +let metallurgyTotal = 0; +let farmingHolders = 0; +let metallurgyHolders = 0; +for (const a of sim.agents) { +sim.ensureAgentTech(a); +farmingTotal += a.tech.farming || 0; +metallurgyTotal += a.tech.metallurgy || 0; +if ((a.tech.farming || 0) > 0.02) farmingHolders++; +if ((a.tech.metallurgy || 0) > 0.02) metallurgyHolders++; +} +const agentCount = Math.max(1, sim.agents.length); +els.year.textContent = formatSimDate(sim.year); +els.activeGroups.textContent = sim.agents.length.toLocaleString(); +els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString(); +els.ethnicities.textContent = livingEthnicities.length.toLocaleString(); +els.cities.textContent = sim.cities.length.toLocaleString(); +els.polities.textContent = sim.polities.length.toLocaleString(); +if (els.wars) els.wars.textContent = sim.wars.length.toLocaleString(); +els.routes.textContent = sim.tradeLinks.length.toLocaleString(); +els.farmingKnowledge.textContent = `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`; +els.metallurgyKnowledge.textContent = `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`; +els.deaths.textContent = sim.deaths.toLocaleString(); +const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9); +els.ethnicityList.innerHTML = top.map(e => ( +`
  • ` + +`E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}
  • ` +)).join(""); +maybeRenderStateGraph(); } - function setLegend() { - const mode = els.viewMode.value; - if (mode === "terrain") { - els.legend.innerHTML = terrainInfo.map(t => `${t.name}`).join(""); - } else if (mode === "ethnicity") { - els.legend.innerHTML = "Color = dominant regional lineage. Mixed tiles brighten toward gray."; - } else if (mode === "pressure") { - els.legend.innerHTML = "High local population pressure"; - } else if (mode === "polities") { - els.legend.innerHTML = "Color = city-centered state. Uncolored cities are independent."; - } else if (mode === "technology") { - els.legend.innerHTML = "Farming knowledgeMetallurgy knowledge"; - } else { - els.legend.innerHTML = "Regenerating local resource stock"; - } +const mode = els.viewMode.value; +if (mode === "terrain") { +els.legend.innerHTML = terrainInfo.map(t => `${t.name}`).join(""); +} else if (mode === "ethnicity") { +els.legend.innerHTML = "Agent and city colors show lineage. Land remains terrain-colored."; +} else if (mode === "pressure") { +els.legend.innerHTML = "High local population pressure"; +} else if (mode === "polities") { +els.legend.innerHTML = "Color = city-centered state. Uncolored cities are independent."; +} else if (mode === "technology") { +els.legend.innerHTML = "Farming knowledgeMetallurgy knowledge"; +} else { +els.legend.innerHTML = "Regenerating local resource stock"; +} } - function maybeRenderStateGraph() { - const now = performance.now(); - if (now - lastGraphRenderAt < SimConfig.render.graphThrottleMs) return; - lastGraphRenderAt = now; - renderStateGraph(); +const now = performance.now(); +if (now - lastGraphRenderAt < SimConfig.render.graphThrottleMs) return; +lastGraphRenderAt = now; +renderStateGraph(); } - function sampleGraphPower(sample) { - if (Number.isFinite(sample?.power)) return sample.power; - return Math.sqrt(sample?.population || 0) * 1.35 + - Math.sqrt(Math.max(0, sample?.treasury || 0)) * 1.15 + - (sample?.avgLoyalty ?? 0.5) * 16; +if (Number.isFinite(sample?.power)) return sample.power; +return Math.sqrt(sample?.population || 0) * 1.35 + +Math.sqrt(Math.max(0, sample?.treasury || 0)) * 1.15 + +(sample?.avgLoyalty ?? 0.5) * 16; } - function graphSampleValue(sample, metric) { - if (metric === "population") return sample?.population || 0; - if (metric === "cities") return sample?.cities || 0; - if (metric === "loyalty") return sample?.avgLoyalty ?? 0.5; - return sampleGraphPower(sample); +if (metric === "population") return sample?.population || 0; +if (metric === "cities") return sample?.cities || 0; +if (metric === "loyalty") return sample?.avgLoyalty ?? 0.5; +return sampleGraphPower(sample); } - function formatGraphValue(value, metric) { - if (metric === "loyalty") return `${Math.round(clamp(value, 0, 1) * 100)}%`; - if (metric === "cities") return Math.round(value).toLocaleString(); - if (metric === "population") return Math.round(value).toLocaleString(); - return value.toFixed(1); +if (metric === "loyalty") return `${Math.round(clamp(value, 0, 1) * 100)}%`; +if (metric === "cities") return Math.round(value).toLocaleString(); +if (metric === "population") return Math.round(value).toLocaleString(); +return value.toFixed(1); } - function renderStateGraph() { - const canvas = els.stateGraph; - if (!canvas || !sim) return; - const rect = canvas.getBoundingClientRect(); - if (!rect.width) return; - - const historyWindowSpan = years(SimConfig.render.historyWindowYears); - const historyWindowEnd = sim.year; - const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan); - graphState.filter = els.historyFilter?.value || graphState.filter; - graphState.sort = els.historySort?.value || graphState.sort; - graphState.metric = els.historyMetric?.value || graphState.metric; - const histories = sim.getAllPolityHistories() - .filter(history => (history.ended ?? sim.year) >= historyWindowStart) - .map(history => ({ - ...history, - visibleFounded: Math.max(history.founded ?? 0, historyWindowStart), - visibleSamples: (history.samples || []).filter(sample => sample.year >= historyWindowStart) - })) - .filter(history => history.visibleSamples.length > 0 || (history.ended ?? sim.year) >= historyWindowStart) - .filter(history => { - const isActive = history.active && history.ended === null; - if (graphState.filter === "active" && !isActive) return false; - if (graphState.filter === "ended" && isActive) return false; - if (isActive && !graphState.showActive) return false; - if (!isActive && !graphState.showPast) return false; - return true; - }); - const paddingLeft = 34; - const paddingRight = 8; - const paddingTop = 18; - const paddingBottom = 22; - const rowHeight = 24; - const groupHeight = 18; - const scored = histories.map(history => { - const samples = history.visibleSamples.length ? history.visibleSamples : [{ population: 0, cities: 0, avgLoyalty: 0.5 }]; - const peakPower = Math.max(...samples.map(sample => sampleGraphPower(sample))); - const peakPopulation = Math.max(...samples.map(sample => sample.population || 0)); - const peakCities = Math.max(...samples.map(sample => sample.cities || 0)); - const peakLoyalty = Math.max(...samples.map(sample => sample.avgLoyalty ?? 0.5)); - const peakMetric = Math.max(...samples.map(sample => graphSampleValue(sample, graphState.metric))); - const lifespan = (history.ended ?? sim.year) - history.visibleFounded; - const importance = peakPower * 1.6 + - peakCities * 130 + - Math.sqrt(Math.max(0, peakPopulation)) * 6 + - lifespan * 0.35; - return { history, peakPopulation, peakCities, peakLoyalty, peakPower, peakMetric, importance }; - }); - const compareRows = (a, b) => { - if (graphState.sort === "oldest") { - return ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id); - } - if (graphState.sort === "strongest") { - return (b.peakPower - a.peakPower) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)); - } - if (graphState.sort === "largest") { - return (b.peakPopulation - a.peakPopulation) || (b.peakCities - a.peakCities); - } - const activeDelta = (b.history.active ? 1 : 0) - (a.history.active ? 1 : 0); - if (graphState.sort === "active") { - return activeDelta || (b.importance - a.importance) || (a.history.id - b.history.id); - } - return activeDelta || (b.importance - a.importance) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id); - }; - const selected = scored.sort(compareRows).slice(0, 90).sort(compareRows); - const activeRows = selected.filter(item => item.history.active && item.history.ended === null); - const pastRows = selected.filter(item => !(item.history.active && item.history.ended === null)); - const displayRows = []; - if (graphState.showActive && graphState.filter !== "ended" && activeRows.length) { - displayRows.push({ type: "group", label: `Living ${activeRows.length}` }); - for (const item of activeRows) displayRows.push({ type: "state", item }); - } - if (graphState.showPast && graphState.filter !== "active" && pastRows.length) { - displayRows.push({ type: "group", label: `Past ${pastRows.length}` }); - for (const item of pastRows) displayRows.push({ type: "state", item }); - } - const logicalHeight = paddingTop + - displayRows.reduce((sum, row) => sum + (row.type === "group" ? groupHeight : rowHeight), 0) + - paddingBottom; - canvas.style.height = `${logicalHeight}px`; - const updatedRect = canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - const width = Math.max(1, Math.floor(updatedRect.width * dpr)); - const height = Math.max(1, Math.floor(logicalHeight * dpr)); - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width; - canvas.height = height; - } - - const g = canvas.getContext("2d"); - g.setTransform(dpr, 0, 0, dpr, 0, 0); - const w = updatedRect.width; - const h = logicalHeight; - g.clearRect(0, 0, w, h); - g.fillStyle = "#101315"; - g.fillRect(0, 0, w, h); - - if (!histories.length || !selected.length || !displayRows.length) { - if (els.historyRange) els.historyRange.textContent = "-"; - graphState.rows = []; - graphState.markers = []; - graphState.scale = null; - g.fillStyle = "#7f8984"; - g.font = "12px ui-sans-serif, system-ui, sans-serif"; - g.fillText("No state history yet", 16, 28); - return; - } - - let minYear = historyWindowStart; - let maxYear = historyWindowEnd; - for (const item of selected) { - const history = item.history; - minYear = Math.min(minYear, history.visibleFounded); - maxYear = Math.max(maxYear, history.ended ?? sim.year); - } - if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`; - - const plotWidth = Math.max(1, w - paddingLeft - paddingRight); - const yearToX = year => paddingLeft + ((clamp(year, minYear, maxYear) - minYear) / Math.max(1, maxYear - minYear)) * plotWidth; - const xToYear = x => minYear + ((clamp(x, paddingLeft, w - paddingRight) - paddingLeft) / plotWidth) * Math.max(1, maxYear - minYear); - const rows = []; - let cursorY = paddingTop; - for (const displayRow of displayRows) { - if (displayRow.type === "group") { - rows.push({ ...displayRow, y: cursorY + groupHeight * 0.5, height: groupHeight }); - cursorY += groupHeight; - continue; - } - const item = displayRow.item; - rows.push({ - ...item, - type: "state", - y: cursorY + rowHeight * 0.5, - height: rowHeight, - color: item.history.color || hslToRgb((item.history.id * 0.38196601125) % 1, 0.58, 0.62), - samples: (item.history.visibleSamples || []) - .filter(sample => Number.isFinite(sample.year)) - .sort((a, b) => a.year - b.year) - }); - cursorY += rowHeight; - } - let minMetric = Infinity; - let maxMetric = -Infinity; - let minCities = Infinity; - let maxCities = -Infinity; - for (const row of rows.filter(row => row.type === "state")) { - for (const sample of row.samples) { - const value = graphSampleValue(sample, graphState.metric); - minMetric = Math.min(minMetric, value); - maxMetric = Math.max(maxMetric, value); - minCities = Math.min(minCities, sample.cities || 0); - maxCities = Math.max(maxCities, sample.cities || 0); - } - } - if (!Number.isFinite(minMetric) || !Number.isFinite(maxMetric)) { - minMetric = 0; - maxMetric = 1; - } - if (!Number.isFinite(minCities) || !Number.isFinite(maxCities)) { - minCities = 0; - maxCities = 1; - } - graphState.rows = rows.filter(row => row.type === "state"); - graphState.markers = []; - graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear }; - - const axisY = h - paddingBottom + 4; - g.strokeStyle = "rgba(168, 177, 170, 0.22)"; - g.lineWidth = 1; - g.beginPath(); - g.moveTo(paddingLeft, axisY); - g.lineTo(w - paddingRight, axisY); - g.stroke(); - g.fillStyle = "#8c9690"; - g.font = "10px ui-sans-serif, system-ui, sans-serif"; - g.textAlign = "left"; - g.fillText(formatGraphYear(minYear), paddingLeft, h - 5); - g.textAlign = "center"; - g.fillText(formatGraphYear((minYear + maxYear) / 2), paddingLeft + plotWidth / 2, h - 5); - g.textAlign = "right"; - g.fillText(formatGraphYear(maxYear), w - paddingRight, h - 5); - - rows.forEach(row => { - if (row.type === "group") { - g.fillStyle = "#7f8984"; - g.font = "10px ui-sans-serif, system-ui, sans-serif"; - g.textAlign = "left"; - g.fillText(row.label, 4, row.y + 3); - g.strokeStyle = "rgba(168, 177, 170, 0.16)"; - g.lineWidth = 1; - g.beginPath(); - g.moveTo(paddingLeft, row.y); - g.lineTo(w - paddingRight, row.y); - g.stroke(); - return; - } - g.strokeStyle = "rgba(168, 177, 170, 0.12)"; - g.lineWidth = 1; - g.beginPath(); - g.moveTo(paddingLeft, row.y); - g.lineTo(w - paddingRight, row.y); - g.stroke(); - }); - - rows.forEach(row => { - if (row.type !== "state") return; - const history = row.history; - - g.textAlign = "left"; - g.fillStyle = "#a8b1aa"; - g.font = "9px ui-sans-serif, system-ui, sans-serif"; - g.fillText(`S${history.id}`, 4, row.y + 3); - - const inactiveMultiplier = history.active ? 1 : 0.65; - const strokeSegment = (fromYear, toYear, metricValue, cities, alphaScale = 1) => { - if (toYear <= fromYear) return; - const normalizedMetric = (metricValue - minMetric) / Math.max(1, maxMetric - minMetric); - const metricT = clamp(normalizedMetric, 0, 1); - const cityT = clamp((cities - minCities) / Math.max(1, maxCities - minCities), 0, 1); - const lowColor = mix([56, 62, 60], row.color, 0.34); - const highColor = mix(row.color, [244, 246, 238], 0.18); - const segmentColor = mix(lowColor, highColor, metricT); - let alpha = (0.22 + metricT * 0.76) * inactiveMultiplier * alphaScale; - alpha = clamp(alpha, 0.10, 0.96); - const thickness = clamp(3.5 + cityT * 5.5 + metricT * 4.5, 3.5, 13); - g.lineWidth = thickness; - g.lineCap = "round"; - g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${clamp(alpha * 0.28, 0.08, 0.28)})`; - g.beginPath(); - g.moveTo(yearToX(fromYear), row.y); - g.lineTo(yearToX(toYear), row.y); - g.stroke(); - g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${alpha})`; - g.lineWidth = Math.max(2, thickness * 0.62); - g.beginPath(); - g.moveTo(yearToX(fromYear), row.y); - g.lineTo(yearToX(toYear), row.y); - g.stroke(); - g.lineCap = "butt"; - }; - - if (row.samples.length > 1) { - if (history.visibleFounded < row.samples[0].year) { - strokeSegment(history.visibleFounded, row.samples[0].year, graphSampleValue(row.samples[0], graphState.metric), row.samples[0].cities, 0.42); - } - for (let i = 0; i < row.samples.length - 1; i++) { - const sampleA = row.samples[i]; - const sampleB = row.samples[i + 1]; - strokeSegment( - sampleA.year, - sampleB.year, - (graphSampleValue(sampleA, graphState.metric) + graphSampleValue(sampleB, graphState.metric)) * 0.5, - ((sampleA.cities || 0) + (sampleB.cities || 0)) * 0.5 - ); - } - const lastSample = row.samples[row.samples.length - 1]; - const endYear = history.ended ?? sim.year; - if (lastSample.year < endYear) { - strokeSegment(lastSample.year, endYear, graphSampleValue(lastSample, graphState.metric), lastSample.cities, 0.55); - } - } else { - const sample = row.samples[0] || { population: 0, cities: row.peakCities, avgLoyalty: 0.5 }; - strokeSegment(history.visibleFounded, history.ended ?? sim.year, graphSampleValue(sample, graphState.metric), sample.cities || row.peakCities); - } - - }); - - rows.forEach(row => { - if (row.type !== "state") return; - const events = (row.history.events || []) - .filter(event => event.importance >= 2) - .filter(event => event.year >= minYear && event.year <= maxYear) - .filter(event => ["capitalShift", "disaster", "peak", "famine"].includes(event.type)); - const collisions = new Map(); - for (const event of events) { - const x = yearToX(event.year); - const bucket = Math.round(x / 7); - const count = collisions.get(bucket) || 0; - collisions.set(bucket, count + 1); - const y = row.y + ([-4, 0, 4][count % 3]); - const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3; - if (event.type === "capitalShift") { - g.fillStyle = "rgba(227, 179, 65, 0.95)"; - drawGraphDiamond(g, x, y, r); - } else if (event.type === "disaster") { - g.fillStyle = "rgba(211, 95, 84, 0.95)"; - drawGraphCircle(g, x, y, r); - } else if (event.type === "peak") { - g.fillStyle = "rgba(238, 241, 237, 0.90)"; - drawGraphTriangleUp(g, x, y, r); - } else if (event.type === "famine") { - g.fillStyle = "rgba(227, 128, 65, 0.90)"; - drawGraphTriangleDown(g, x, y, r); - } - graphState.markers.push({ row, event, x, y, radius: r + 3 }); - } - }); +const canvas = els.stateGraph; +if (!canvas || !sim) return; +const rect = canvas.getBoundingClientRect(); +if (!rect.width) return; +const historyWindowSpan = years(SimConfig.render.historyWindowYears); +const historyWindowEnd = sim.year; +const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan); +graphState.filter = els.historyFilter?.value || graphState.filter; +graphState.sort = els.historySort?.value || graphState.sort; +graphState.metric = els.historyMetric?.value || graphState.metric; +const histories = sim.getAllPolityHistories() +.filter(history => (history.ended ?? sim.year) >= historyWindowStart) +.map(history => ({ +...history, +visibleFounded: Math.max(history.founded ?? 0, historyWindowStart), +visibleSamples: (history.samples || []).filter(sample => sample.year >= historyWindowStart) +})) +.filter(history => history.visibleSamples.length > 0 || (history.ended ?? sim.year) >= historyWindowStart) +.filter(history => { +const isActive = history.active && history.ended === null; +if (graphState.filter === "active" && !isActive) return false; +if (graphState.filter === "ended" && isActive) return false; +if (isActive && !graphState.showActive) return false; +if (!isActive && !graphState.showPast) return false; +return true; +}); +const paddingLeft = 34; +const paddingRight = 8; +const paddingTop = 18; +const paddingBottom = 22; +const rowHeight = 24; +const groupHeight = 18; +const scored = histories.map(history => { +const samples = history.visibleSamples.length ? history.visibleSamples : [{ population: 0, cities: 0, avgLoyalty: 0.5 }]; +const peakPower = Math.max(...samples.map(sample => sampleGraphPower(sample))); +const peakPopulation = Math.max(...samples.map(sample => sample.population || 0)); +const peakCities = Math.max(...samples.map(sample => sample.cities || 0)); +const peakLoyalty = Math.max(...samples.map(sample => sample.avgLoyalty ?? 0.5)); +const peakMetric = Math.max(...samples.map(sample => graphSampleValue(sample, graphState.metric))); +const lifespan = (history.ended ?? sim.year) - history.visibleFounded; +const importance = peakPower * 1.6 + +peakCities * 130 + +Math.sqrt(Math.max(0, peakPopulation)) * 6 + +lifespan * 0.35; +return { history, peakPopulation, peakCities, peakLoyalty, peakPower, peakMetric, importance }; +}); +const compareRows = (a, b) => { +if (graphState.sort === "oldest") { +return ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id); +} +if (graphState.sort === "strongest") { +return (b.peakPower - a.peakPower) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)); +} +if (graphState.sort === "largest") { +return (b.peakPopulation - a.peakPopulation) || (b.peakCities - a.peakCities); +} +const activeDelta = (b.history.active ? 1 : 0) - (a.history.active ? 1 : 0); +if (graphState.sort === "active") { +return activeDelta || (b.importance - a.importance) || (a.history.id - b.history.id); +} +return activeDelta || (b.importance - a.importance) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id); +}; +const selected = scored.sort(compareRows).slice(0, 90).sort(compareRows); +const activeRows = selected.filter(item => item.history.active && item.history.ended === null); +const pastRows = selected.filter(item => !(item.history.active && item.history.ended === null)); +const displayRows = []; +if (graphState.showActive && graphState.filter !== "ended" && activeRows.length) { +displayRows.push({ type: "group", label: `Living ${activeRows.length}` }); +for (const item of activeRows) displayRows.push({ type: "state", item }); +} +if (graphState.showPast && graphState.filter !== "active" && pastRows.length) { +displayRows.push({ type: "group", label: `Past ${pastRows.length}` }); +for (const item of pastRows) displayRows.push({ type: "state", item }); +} +const logicalHeight = paddingTop + +displayRows.reduce((sum, row) => sum + (row.type === "group" ? groupHeight : rowHeight), 0) + +paddingBottom; +canvas.style.height = `${logicalHeight}px`; +const updatedRect = canvas.getBoundingClientRect(); +const dpr = window.devicePixelRatio || 1; +const width = Math.max(1, Math.floor(updatedRect.width * dpr)); +const height = Math.max(1, Math.floor(logicalHeight * dpr)); +if (canvas.width !== width || canvas.height !== height) { +canvas.width = width; +canvas.height = height; +} +const g = canvas.getContext("2d"); +g.setTransform(dpr, 0, 0, dpr, 0, 0); +const w = updatedRect.width; +const h = logicalHeight; +g.clearRect(0, 0, w, h); +g.fillStyle = "#101315"; +g.fillRect(0, 0, w, h); +if (!histories.length || !selected.length || !displayRows.length) { +if (els.historyRange) els.historyRange.textContent = "-"; +graphState.rows = []; +graphState.markers = []; +graphState.scale = null; +g.fillStyle = "#7f8984"; +g.font = "12px ui-sans-serif, system-ui, sans-serif"; +g.fillText("No state history yet", 16, 28); +return; +} +let minYear = historyWindowStart; +let maxYear = historyWindowEnd; +for (const item of selected) { +const history = item.history; +minYear = Math.min(minYear, history.visibleFounded); +maxYear = Math.max(maxYear, history.ended ?? sim.year); +} +if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`; +const plotWidth = Math.max(1, w - paddingLeft - paddingRight); +const yearToX = year => paddingLeft + ((clamp(year, minYear, maxYear) - minYear) / Math.max(1, maxYear - minYear)) * plotWidth; +const xToYear = x => minYear + ((clamp(x, paddingLeft, w - paddingRight) - paddingLeft) / plotWidth) * Math.max(1, maxYear - minYear); +const rows = []; +let cursorY = paddingTop; +for (const displayRow of displayRows) { +if (displayRow.type === "group") { +rows.push({ ...displayRow, y: cursorY + groupHeight * 0.5, height: groupHeight }); +cursorY += groupHeight; +continue; +} +const item = displayRow.item; +rows.push({ +...item, +type: "state", +y: cursorY + rowHeight * 0.5, +height: rowHeight, +color: item.history.color || hslToRgb((item.history.id * 0.38196601125) % 1, 0.58, 0.62), +samples: (item.history.visibleSamples || []) +.filter(sample => Number.isFinite(sample.year)) +.sort((a, b) => a.year - b.year) +}); +cursorY += rowHeight; +} +let minMetric = Infinity; +let maxMetric = -Infinity; +let minCities = Infinity; +let maxCities = -Infinity; +for (const row of rows.filter(row => row.type === "state")) { +for (const sample of row.samples) { +const value = graphSampleValue(sample, graphState.metric); +minMetric = Math.min(minMetric, value); +maxMetric = Math.max(maxMetric, value); +minCities = Math.min(minCities, sample.cities || 0); +maxCities = Math.max(maxCities, sample.cities || 0); +} +} +if (!Number.isFinite(minMetric) || !Number.isFinite(maxMetric)) { +minMetric = 0; +maxMetric = 1; +} +if (!Number.isFinite(minCities) || !Number.isFinite(maxCities)) { +minCities = 0; +maxCities = 1; +} +graphState.rows = rows.filter(row => row.type === "state"); +graphState.markers = []; +graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear }; +const axisY = h - paddingBottom + 4; +g.strokeStyle = "rgba(168, 177, 170, 0.22)"; +g.lineWidth = 1; +g.beginPath(); +g.moveTo(paddingLeft, axisY); +g.lineTo(w - paddingRight, axisY); +g.stroke(); +g.fillStyle = "#8c9690"; +g.font = "10px ui-sans-serif, system-ui, sans-serif"; +g.textAlign = "left"; +g.fillText(formatGraphYear(minYear), paddingLeft, h - 5); +g.textAlign = "center"; +g.fillText(formatGraphYear((minYear + maxYear) / 2), paddingLeft + plotWidth / 2, h - 5); +g.textAlign = "right"; +g.fillText(formatGraphYear(maxYear), w - paddingRight, h - 5); +rows.forEach(row => { +if (row.type === "group") { +g.fillStyle = "#7f8984"; +g.font = "10px ui-sans-serif, system-ui, sans-serif"; +g.textAlign = "left"; +g.fillText(row.label, 4, row.y + 3); +g.strokeStyle = "rgba(168, 177, 170, 0.16)"; +g.lineWidth = 1; +g.beginPath(); +g.moveTo(paddingLeft, row.y); +g.lineTo(w - paddingRight, row.y); +g.stroke(); +return; +} +g.strokeStyle = "rgba(168, 177, 170, 0.12)"; +g.lineWidth = 1; +g.beginPath(); +g.moveTo(paddingLeft, row.y); +g.lineTo(w - paddingRight, row.y); +g.stroke(); +}); +rows.forEach(row => { +if (row.type !== "state") return; +const history = row.history; +g.textAlign = "left"; +g.fillStyle = "#a8b1aa"; +g.font = "9px ui-sans-serif, system-ui, sans-serif"; +g.fillText(`S${history.id}`, 4, row.y + 3); +const inactiveMultiplier = history.active ? 1 : 0.65; +const strokeSegment = (fromYear, toYear, metricValue, cities, alphaScale = 1) => { +if (toYear <= fromYear) return; +const normalizedMetric = (metricValue - minMetric) / Math.max(1, maxMetric - minMetric); +const metricT = clamp(normalizedMetric, 0, 1); +const cityT = clamp((cities - minCities) / Math.max(1, maxCities - minCities), 0, 1); +const lowColor = mix([56, 62, 60], row.color, 0.34); +const highColor = mix(row.color, [244, 246, 238], 0.18); +const segmentColor = mix(lowColor, highColor, metricT); +let alpha = (0.22 + metricT * 0.76) * inactiveMultiplier * alphaScale; +alpha = clamp(alpha, 0.10, 0.96); +const thickness = clamp(3.5 + cityT * 5.5 + metricT * 4.5, 3.5, 13); +g.lineWidth = thickness; +g.lineCap = "round"; +g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${clamp(alpha * 0.28, 0.08, 0.28)})`; +g.beginPath(); +g.moveTo(yearToX(fromYear), row.y); +g.lineTo(yearToX(toYear), row.y); +g.stroke(); +g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${alpha})`; +g.lineWidth = Math.max(2, thickness * 0.62); +g.beginPath(); +g.moveTo(yearToX(fromYear), row.y); +g.lineTo(yearToX(toYear), row.y); +g.stroke(); +g.lineCap = "butt"; +}; +if (row.samples.length > 1) { +if (history.visibleFounded < row.samples[0].year) { +strokeSegment(history.visibleFounded, row.samples[0].year, graphSampleValue(row.samples[0], graphState.metric), row.samples[0].cities, 0.42); +} +for (let i = 0; i < row.samples.length - 1; i++) { +const sampleA = row.samples[i]; +const sampleB = row.samples[i + 1]; +strokeSegment( +sampleA.year, +sampleB.year, +(graphSampleValue(sampleA, graphState.metric) + graphSampleValue(sampleB, graphState.metric)) * 0.5, +((sampleA.cities || 0) + (sampleB.cities || 0)) * 0.5 +); +} +const lastSample = row.samples[row.samples.length - 1]; +const endYear = history.ended ?? sim.year; +if (lastSample.year < endYear) { +strokeSegment(lastSample.year, endYear, graphSampleValue(lastSample, graphState.metric), lastSample.cities, 0.55); +} +} else { +const sample = row.samples[0] || { population: 0, cities: row.peakCities, avgLoyalty: 0.5 }; +strokeSegment(history.visibleFounded, history.ended ?? sim.year, graphSampleValue(sample, graphState.metric), sample.cities || row.peakCities); +} +}); +rows.forEach(row => { +if (row.type !== "state") return; +const events = (row.history.events || []) +.filter(event => event.importance >= 2) +.filter(event => event.year >= minYear && event.year <= maxYear) +.filter(event => ["capitalShift", "disaster", "peak", "famine"].includes(event.type)); +const collisions = new Map(); +for (const event of events) { +const x = yearToX(event.year); +const bucket = Math.round(x / 7); +const count = collisions.get(bucket) || 0; +collisions.set(bucket, count + 1); +const y = row.y + ([-4, 0, 4][count % 3]); +const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3; +if (event.type === "capitalShift") { +g.fillStyle = "rgba(227, 179, 65, 0.95)"; +drawGraphDiamond(g, x, y, r); +} else if (event.type === "disaster") { +g.fillStyle = "rgba(211, 95, 84, 0.95)"; +drawGraphCircle(g, x, y, r); +} else if (event.type === "peak") { +g.fillStyle = "rgba(238, 241, 237, 0.90)"; +drawGraphTriangleUp(g, x, y, r); +} else if (event.type === "famine") { +g.fillStyle = "rgba(227, 128, 65, 0.90)"; +drawGraphTriangleDown(g, x, y, r); +} +graphState.markers.push({ row, event, x, y, radius: r + 3 }); +} +}); } - function drawGraphCircle(g, x, y, r) { - g.beginPath(); - g.arc(x, y, r, 0, Math.PI * 2); - g.fill(); +g.beginPath(); +g.arc(x, y, r, 0, Math.PI * 2); +g.fill(); } - function drawGraphDiamond(g, x, y, r) { - g.beginPath(); - g.moveTo(x, y - r); - g.lineTo(x + r, y); - g.lineTo(x, y + r); - g.lineTo(x - r, y); - g.closePath(); - g.fill(); +g.beginPath(); +g.moveTo(x, y - r); +g.lineTo(x + r, y); +g.lineTo(x, y + r); +g.lineTo(x - r, y); +g.closePath(); +g.fill(); } - function drawGraphTriangleUp(g, x, y, r) { - g.beginPath(); - g.moveTo(x, y - r); - g.lineTo(x + r, y + r); - g.lineTo(x - r, y + r); - g.closePath(); - g.fill(); +g.beginPath(); +g.moveTo(x, y - r); +g.lineTo(x + r, y + r); +g.lineTo(x - r, y + r); +g.closePath(); +g.fill(); } - function drawGraphTriangleDown(g, x, y, r) { - g.beginPath(); - g.moveTo(x, y + r); - g.lineTo(x + r, y - r); - g.lineTo(x - r, y - r); - g.closePath(); - g.fill(); +g.beginPath(); +g.moveTo(x, y + r); +g.lineTo(x + r, y - r); +g.lineTo(x - r, y - r); +g.closePath(); +g.fill(); } - function graphEventLabel(event) { - if (event.type === "capitalShift") return "capital shift"; - if (event.type === "disaster") return "major disaster"; - if (event.type === "peak") return "peak power"; - if (event.type === "famine") return "famine"; - return event.type; +if (event.type === "capitalShift") return "capital shift"; +if (event.type === "disaster") return "major disaster"; +if (event.type === "peak") return "peak power"; +if (event.type === "famine") return "famine"; +return event.type; } - function graphEventSummary(event) { - const data = event.data || {}; - if (event.type === "capitalShift") { - return `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}`; - } - if (event.type === "disaster") { - return `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities`; - } - if (event.type === "peak") { - return `power ${Number(data.power || 0).toFixed(1)}, pop ${Math.round(data.population || 0).toLocaleString()}, cities ${data.cities || 0}`; - } - if (event.type === "famine") { - return `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap`; - } - return ""; +const data = event.data || {}; +if (event.type === "capitalShift") { +return `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}`; +} +if (event.type === "disaster") { +return `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities`; +} +if (event.type === "peak") { +return `power ${Number(data.power || 0).toFixed(1)}, pop ${Math.round(data.population || 0).toLocaleString()}, cities ${data.cities || 0}`; +} +if (event.type === "famine") { +return `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap`; +} +return ""; } - function showStateGraphMarkerTooltip(marker, pointerX, pointerY) { - if (!els.stateGraphTooltip) return; - els.stateGraphTooltip.innerHTML = ` +if (!els.stateGraphTooltip) return; +els.stateGraphTooltip.innerHTML = ` S${marker.row.history.id} ${graphEventLabel(marker.event)} ${formatGraphYear(marker.event.year)} ${graphEventSummary(marker.event)} `; - els.stateGraphTooltip.hidden = false; - const scroll = els.stateGraph.parentElement; - const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8); - const left = clamp(pointerX + 10, 8, maxLeft); - const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10); - els.stateGraphTooltip.style.left = `${left}px`; - els.stateGraphTooltip.style.top = `${top}px`; +els.stateGraphTooltip.hidden = false; +const scroll = els.stateGraph.parentElement; +const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8); +const left = clamp(pointerX + 10, 8, maxLeft); +const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10); +els.stateGraphTooltip.style.left = `${left}px`; +els.stateGraphTooltip.style.top = `${top}px`; } - function hideStateGraphMarkerTooltip() { - if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true; +if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true; } - function updateStateGraphInfo(event) { - if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return; - const y = event.offsetY; - const x = event.offsetX; - const marker = (graphState.markers || []) - .map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) })) - .filter(item => item.distance <= item.candidate.radius) - .sort((a, b) => a.distance - b.distance)[0]?.candidate; - if (marker) { - const status = marker.row.history.active && marker.row.history.ended === null ? "living" : "past"; - els.stateGraphInfo.textContent = `S${marker.row.history.id} ${status} ${formatGraphYear(marker.event.year)} ${graphEventLabel(marker.event)}`; - showStateGraphMarkerTooltip(marker, x, y); - if (graphState.hoverPolityId !== marker.row.history.id) { - graphState.hoverPolityId = marker.row.history.id; - if (!running) render(); - } - return; - } - const row = graphState.rows - .map(candidate => ({ candidate, distance: Math.abs(candidate.y - y) })) - .filter(item => item.distance <= item.candidate.height * 0.5) - .sort((a, b) => a.distance - b.distance)[0]?.candidate; - if (!row) { - els.stateGraphInfo.textContent = "Hover a row for details"; - hideStateGraphMarkerTooltip(); - if (graphState.hoverPolityId !== null) { - graphState.hoverPolityId = null; - if (!running) render(); - } - return; - } - if (graphState.hoverPolityId !== row.history.id) { - graphState.hoverPolityId = row.history.id; - if (!running) render(); - } - hideStateGraphMarkerTooltip(); - - const year = graphState.scale.xToYear(x); - const samples = row.samples.length ? row.samples : [{ - year, - population: 0, - cities: row.peakCities, - avgLoyalty: 0.5, - power: row.peakPower - }]; - const sample = samples - .map(candidate => ({ candidate, distance: Math.abs((candidate.year ?? year) - year) })) - .sort((a, b) => a.distance - b.distance)[0].candidate; - const metricValue = graphSampleValue(sample, graphState.metric); - const status = row.history.active && row.history.ended === null ? "living" : "past"; - els.stateGraphInfo.textContent = - `S${row.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` + - `${graphState.metric} ${formatGraphValue(metricValue, graphState.metric)} ` + - `pop ${Math.round(sample.population || 0).toLocaleString()} ` + - `cities ${Math.round(sample.cities || 0).toLocaleString()} ` + - `loyalty ${formatGraphValue(sample.avgLoyalty ?? 0.5, "loyalty")}`; +if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return; +const y = event.offsetY; +const x = event.offsetX; +const marker = (graphState.markers || []) +.map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) })) +.filter(item => item.distance <= item.candidate.radius) +.sort((a, b) => a.distance - b.distance)[0]?.candidate; +if (marker) { +const status = marker.row.history.active && marker.row.history.ended === null ? "living" : "past"; +els.stateGraphInfo.textContent = `S${marker.row.history.id} ${status} ${formatGraphYear(marker.event.year)} ${graphEventLabel(marker.event)}`; +showStateGraphMarkerTooltip(marker, x, y); +if (graphState.hoverPolityId !== marker.row.history.id) { +graphState.hoverPolityId = marker.row.history.id; +if (!running) render(); +} +return; +} +const row = graphState.rows +.map(candidate => ({ candidate, distance: Math.abs(candidate.y - y) })) +.filter(item => item.distance <= item.candidate.height * 0.5) +.sort((a, b) => a.distance - b.distance)[0]?.candidate; +if (!row) { +els.stateGraphInfo.textContent = "Hover a row for details"; +hideStateGraphMarkerTooltip(); +if (graphState.hoverPolityId !== null) { +graphState.hoverPolityId = null; +if (!running) render(); +} +return; +} +if (graphState.hoverPolityId !== row.history.id) { +graphState.hoverPolityId = row.history.id; +if (!running) render(); +} +hideStateGraphMarkerTooltip(); +const year = graphState.scale.xToYear(x); +const samples = row.samples.length ? row.samples : [{ +year, +population: 0, +cities: row.peakCities, +avgLoyalty: 0.5, +power: row.peakPower +}]; +const sample = samples +.map(candidate => ({ candidate, distance: Math.abs((candidate.year ?? year) - year) })) +.sort((a, b) => a.distance - b.distance)[0].candidate; +const metricValue = graphSampleValue(sample, graphState.metric); +const status = row.history.active && row.history.ended === null ? "living" : "past"; +els.stateGraphInfo.textContent = +`S${row.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` + +`${graphState.metric} ${formatGraphValue(metricValue, graphState.metric)} ` + +`pop ${Math.round(sample.population || 0).toLocaleString()} ` + +`cities ${Math.round(sample.cities || 0).toLocaleString()} ` + +`loyalty ${formatGraphValue(sample.avgLoyalty ?? 0.5, "loyalty")}`; } - function clearStateGraphInfo() { - if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details"; - hideStateGraphMarkerTooltip(); - if (graphState.hoverPolityId !== null) { - graphState.hoverPolityId = null; - if (!running) render(); - } +if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details"; +hideStateGraphMarkerTooltip(); +if (graphState.hoverPolityId !== null) { +graphState.hoverPolityId = null; +if (!running) render(); +} } - function formatGraphYear(month) { - return `${Math.floor(month / MONTHS_PER_YEAR).toLocaleString()}y`; +return `${Math.floor(month / MONTHS_PER_YEAR).toLocaleString()}y`; } - function formatSimDate(month) { - const year = Math.floor(month / MONTHS_PER_YEAR); - const monthOfYear = month % MONTHS_PER_YEAR + 1; - return `${year.toLocaleString()}y ${monthOfYear}m`; +const year = Math.floor(month / MONTHS_PER_YEAR); +const monthOfYear = month % MONTHS_PER_YEAR + 1; +return `${year.toLocaleString()}y ${monthOfYear}m`; } - -function loop() { - const steps = running ? Number(els.speed.value) : 0; - const loopStart = performance.now(); - let completedSteps = 0; - const stepBudgetMs = 10.5; - for (let i = 0; i < steps; i++) { - sim.step(); - completedSteps++; - if (running && completedSteps > 0 && performance.now() - loopStart > stepBudgetMs) break; - } - if (running || frame % 8 === 0) { - render(); - updateStats(); - renderTooltip(); - } - frame++; - requestAnimationFrame(loop); +function requestRender() { +renderDirty = true; +} +function drawFrame(forceStats = false) { +render(); +updateStats(forceStats); +renderTooltip(); +renderDirty = false; +lastRenderAt = performance.now(); +} +function scheduleLoop() { +if (loopScheduled) return; +loopScheduled = true; +const delay = document.hidden +? LoopConfig.hiddenPollMs +: running ? 0 : LoopConfig.idlePollMs; +if (delay > 0) { +loopTimer = setTimeout(() => { +loopTimer = null; +requestAnimationFrame(loop); +}, delay); +} else { +requestAnimationFrame(loop); +} +} +function loop(now = performance.now()) { +loopScheduled = false; +if (running && !document.hidden && now - lastSimTickAt >= LoopConfig.simTickMs) { +lastSimTickAt = now; +const steps = Number(els.speed.value); +const loopStart = performance.now(); +let completedSteps = 0; +for (let i = 0; i < steps; i++) { +sim.step(); +completedSteps++; +if (completedSteps > 0 && performance.now() - loopStart > LoopConfig.stepBudgetMs) break; +} +if (completedSteps > 0) requestRender(); +} +const shouldRender = !document.hidden && +(renderDirty || (running && now - lastRenderAt >= LoopConfig.renderMs)); +if (shouldRender) drawFrame(); +frame++; +scheduleLoop(); } - function reset() { - const size = Number(els.worldSize.value); - const count = Number(els.agentCount.value); - els.canvas.width = size; - els.canvas.height = size; - renderImage = null; - renderImageSize = 0; - sim = new Simulation(size, count); - setLegend(); - render(); - updateStats(true); - renderStateGraph(); +const size = Number(els.worldSize.value); +const count = Number(els.agentCount.value); +els.canvas.width = size; +els.canvas.height = size; +renderImage = null; +renderImageSize = 0; +sim = new Simulation(size, count); +setLegend(); +drawFrame(true); +renderStateGraph(); } - function clamp(v, min, max) { - return Math.max(min, Math.min(max, v)); +return Math.max(min, Math.min(max, v)); } - function smoothstep(t) { - return t * t * (3 - 2 * t); +return t * t * (3 - 2 * t); } - function getSedentary(traits) { - return traits.sedentary; +return traits.sedentary; } - function getEthnocentrism(traits) { - return traits.ethnocentrism; +return traits.ethnocentrism; } - function mutateTraits(traits, rng, amount) { - return { - mobility: clamp(traits.mobility + rng.range(-amount, amount), 0.02, 1), - resourceAttraction: clamp(traits.resourceAttraction + rng.range(-amount, amount), 0.05, 1.2), - assimilation: clamp(traits.assimilation + rng.range(-amount, amount), 0, 0.75), - ethnocentrism: clamp(getEthnocentrism(traits) + rng.range(-amount, amount), 0, 1.2), - reproductionThreshold: clamp(traits.reproductionThreshold + rng.range(-amount * 16, amount * 16), 12, 48), - sedentary: clamp(getSedentary(traits) + rng.range(-amount, amount), 0, 1) - }; +return { +mobility: clamp(traits.mobility + rng.range(-amount, amount), 0.02, 1), +resourceAttraction: clamp(traits.resourceAttraction + rng.range(-amount, amount), 0.05, 1.2), +assimilation: clamp(traits.assimilation + rng.range(-amount, amount), 0, 0.75), +ethnocentrism: clamp(getEthnocentrism(traits) + rng.range(-amount, amount), 0, 1.2), +reproductionThreshold: clamp(traits.reproductionThreshold + rng.range(-amount * 16, amount * 16), 12, 48), +sedentary: clamp(getSedentary(traits) + rng.range(-amount, amount), 0, 1) +}; } - function blendTraits(a, b, t) { - return { - mobility: lerp(a.mobility, b.mobility, t), - resourceAttraction: lerp(a.resourceAttraction, b.resourceAttraction, t), - assimilation: lerp(a.assimilation, b.assimilation, t), - ethnocentrism: lerp(getEthnocentrism(a), getEthnocentrism(b), t), - reproductionThreshold: lerp(a.reproductionThreshold, b.reproductionThreshold, t), - sedentary: lerp(getSedentary(a), getSedentary(b), t) - }; +return { +mobility: lerp(a.mobility, b.mobility, t), +resourceAttraction: lerp(a.resourceAttraction, b.resourceAttraction, t), +assimilation: lerp(a.assimilation, b.assimilation, t), +ethnocentrism: lerp(getEthnocentrism(a), getEthnocentrism(b), t), +reproductionThreshold: lerp(a.reproductionThreshold, b.reproductionThreshold, t), +sedentary: lerp(getSedentary(a), getSedentary(b), t) +}; } - function emptyTraitSums() { - return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, sedentary: 0 }; +return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, sedentary: 0 }; } - function addTraits(sum, traits) { - sum.mobility += traits.mobility; - sum.resourceAttraction += traits.resourceAttraction; - sum.assimilation += traits.assimilation; - sum.ethnocentrism += getEthnocentrism(traits); - sum.reproductionThreshold += traits.reproductionThreshold / 48; - sum.sedentary += getSedentary(traits); +sum.mobility += traits.mobility; +sum.resourceAttraction += traits.resourceAttraction; +sum.assimilation += traits.assimilation; +sum.ethnocentrism += getEthnocentrism(traits); +sum.reproductionThreshold += traits.reproductionThreshold / 48; +sum.sedentary += getSedentary(traits); } - function averageTraits(sum, count) { - return { - mobility: sum.mobility / count, - resourceAttraction: sum.resourceAttraction / count, - assimilation: sum.assimilation / count, - ethnocentrism: sum.ethnocentrism / count, - reproductionThreshold: (sum.reproductionThreshold / count) * 48, - sedentary: sum.sedentary / count - }; +return { +mobility: sum.mobility / count, +resourceAttraction: sum.resourceAttraction / count, +assimilation: sum.assimilation / count, +ethnocentrism: sum.ethnocentrism / count, +reproductionThreshold: (sum.reproductionThreshold / count) * 48, +sedentary: sum.sedentary / count +}; } - function addBirthsToComposition(composition, births) { - const dominant = dominantComposition(composition); - if (!dominant) return; - composition.set(dominant, (composition.get(dominant) || 0) + births); +const dominant = dominantComposition(composition); +if (!dominant) return; +composition.set(dominant, (composition.get(dominant) || 0) + births); } - function removeFromComposition(composition, loss) { - let remaining = loss; - const total = [...composition.values()].reduce((sum, value) => sum + value, 0); - if (!total) return; - for (const [id, count] of [...composition]) { - const removed = Math.min(count, Math.ceil(loss * (count / total))); - composition.set(id, Math.max(0, count - removed)); - remaining -= removed; - if (composition.get(id) <= 0) composition.delete(id); - if (remaining <= 0) break; - } +let remaining = loss; +const total = [...composition.values()].reduce((sum, value) => sum + value, 0); +if (!total) return; +for (const [id, count] of [...composition]) { +const removed = Math.min(count, Math.ceil(loss * (count / total))); +composition.set(id, Math.max(0, count - removed)); +remaining -= removed; +if (composition.get(id) <= 0) composition.delete(id); +if (remaining <= 0) break; +} } - function dominantComposition(composition) { - let bestId = null; - let bestCount = 0; - for (const [id, count] of composition) { - if (count > bestCount) { - bestId = id; - bestCount = count; - } - } - return bestId; +let bestId = null; +let bestCount = 0; +for (const [id, count] of composition) { +if (count > bestCount) { +bestId = id; +bestCount = count; +} +} +return bestId; } - function traitDistance(a, b) { - return Math.abs(a.mobility - b.mobility) + - Math.abs(a.resourceAttraction - b.resourceAttraction) + - Math.abs(a.assimilation - b.assimilation) + - Math.abs(getEthnocentrism(a) - getEthnocentrism(b)) + - Math.abs(a.reproductionThreshold - b.reproductionThreshold) / 48 + - Math.abs(getSedentary(a) - getSedentary(b)); +return Math.abs(a.mobility - b.mobility) + +Math.abs(a.resourceAttraction - b.resourceAttraction) + +Math.abs(a.assimilation - b.assimilation) + +Math.abs(getEthnocentrism(a) - getEthnocentrism(b)) + +Math.abs(a.reproductionThreshold - b.reproductionThreshold) / 48 + +Math.abs(getSedentary(a) - getSedentary(b)); } - function hslToRgb(h, s, l) { - const hue = (p, q, t) => { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; - return p; - }; - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - return [ - Math.round(hue(p, q, h + 1 / 3) * 255), - Math.round(hue(p, q, h) * 255), - Math.round(hue(p, q, h - 1 / 3) * 255) - ]; +const hue = (p, q, t) => { +if (t < 0) t += 1; +if (t > 1) t -= 1; +if (t < 1 / 6) return p + (q - p) * 6 * t; +if (t < 1 / 2) return q; +if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; +return p; +}; +const q = l < 0.5 ? l * (1 + s) : l + s - l * s; +const p = 2 * l - q; +return [ +Math.round(hue(p, q, h + 1 / 3) * 255), +Math.round(hue(p, q, h) * 255), +Math.round(hue(p, q, h - 1 / 3) * 255) +]; } - function mix(a, b, t) { - return [ - Math.round(lerp(a[0], b[0], t)), - Math.round(lerp(a[1], b[1], t)), - Math.round(lerp(a[2], b[2], t)) - ]; +return [ +Math.round(lerp(a[0], b[0], t)), +Math.round(lerp(a[1], b[1], t)), +Math.round(lerp(a[2], b[2], t)) +]; } - function lerp(a, b, t) { - return a + (b - a) * t; +return a + (b - a) * t; } - function packArray(typedArray) { - const bytes = new Uint8Array(typedArray.buffer); - let binary = ""; - const chunkSize = 8192; - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); - } - return { - type: typedArray.constructor.name, - length: typedArray.length, - data: btoa(binary) - }; +const bytes = new Uint8Array(typedArray.buffer); +let binary = ""; +const chunkSize = 8192; +for (let i = 0; i < bytes.length; i += chunkSize) { +binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); +} +return { +type: typedArray.constructor.name, +length: typedArray.length, +data: btoa(binary) +}; } - function unpackArray(payload, TypedArray) { - const binary = atob(payload.data); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new TypedArray(bytes.buffer, 0, payload.length); +const binary = atob(payload.data); +const bytes = new Uint8Array(binary.length); +for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); +return new TypedArray(bytes.buffer, 0, payload.length); } - const Persistence = Object.freeze({ - save(currentSim) { - const raw = JSON.stringify(currentSim); - if (raw.length > SimConfig.save.maxBytes) { - throw new Error(`Save is too large (${raw.length.toLocaleString()} bytes)`); - } - localStorage.setItem(SimConfig.save.key, raw); - }, - - load() { - const raw = localStorage.getItem(SimConfig.save.key); - if (!raw) return null; - if (raw.length > SimConfig.save.maxBytes * 1.25) { - throw new Error("Saved world is too large to load safely"); - } - const state = JSON.parse(raw); - if (!state || !state.world || !state.agents) throw new Error("Saved world is missing required fields"); - return Simulation.fromJSON(state); - }, - - clear() { - localStorage.removeItem(SimConfig.save.key); - } +save(currentSim) { +const raw = JSON.stringify(currentSim); +if (raw.length > SimConfig.save.maxBytes) { +throw new Error(`Save is too large (${raw.length.toLocaleString()} bytes)`); +} +localStorage.setItem(SimConfig.save.key, raw); +}, +load() { +const raw = localStorage.getItem(SimConfig.save.key); +if (!raw) return null; +if (raw.length > SimConfig.save.maxBytes * 1.25) { +throw new Error("Saved world is too large to load safely"); +} +const state = JSON.parse(raw); +if (!state || !state.world || !state.agents) throw new Error("Saved world is missing required fields"); +return Simulation.fromJSON(state); +}, +clear() { +localStorage.removeItem(SimConfig.save.key); +} }); - function saveWorld() { - try { - Persistence.save(sim); - } catch (error) { - console.warn("Save failed", error); - } +try { +Persistence.save(sim); +} catch (error) { +console.warn("Save failed", error); +} } - function loadWorld() { - try { - const loaded = Persistence.load(); - if (!loaded) return; - sim = loaded; - els.worldSize.value = String(sim.world.size); - els.canvas.width = sim.world.size; - els.canvas.height = sim.world.size; - renderImage = null; - renderImageSize = 0; - setLegend(); - render(); - updateStats(true); - renderStateGraph(); - } catch (error) { - console.warn("Load failed", error); - } +try { +const loaded = Persistence.load(); +if (!loaded) return; +sim = loaded; +els.worldSize.value = String(sim.world.size); +els.canvas.width = sim.world.size; +els.canvas.height = sim.world.size; +renderImage = null; +renderImageSize = 0; +setLegend(); +drawFrame(true); +renderStateGraph(); +} catch (error) { +console.warn("Load failed", error); +} } - els.toggleRun.addEventListener("click", () => { - running = !running; - els.toggleRun.textContent = running ? "Pause" : "Run"; +running = !running; +els.toggleRun.textContent = running ? "Pause" : "Run"; +lastSimTickAt = 0; +if (running && loopTimer) { +clearTimeout(loopTimer); +loopTimer = null; +loopScheduled = false; +scheduleLoop(); +} else if (running) { +scheduleLoop(); +} }); els.stepOnce.addEventListener("click", () => { - sim.step(); - render(); - updateStats(true); - renderStateGraph(); - renderTooltip(); +sim.step(); +requestRender(); +drawFrame(true); +renderStateGraph(); }); els.resetWorld.addEventListener("click", reset); els.saveWorld.addEventListener("click", saveWorld); @@ -4656,31 +4356,41 @@ els.clearSave.addEventListener("click", () => Persistence.clear()); els.worldSize.addEventListener("change", reset); els.agentCount.addEventListener("change", reset); els.viewMode.addEventListener("change", () => { - setLegend(); - render(); +setLegend(); +requestRender(); +drawFrame(); }); els.canvas.addEventListener("mousemove", showTooltip); els.canvas.addEventListener("mouseleave", hideTooltip); for (const control of [els.historyFilter, els.historySort, els.historyMetric]) { - control?.addEventListener("change", () => { - renderStateGraph(); - clearStateGraphInfo(); - }); +control?.addEventListener("change", () => { +renderStateGraph(); +clearStateGraphInfo(); +}); } els.toggleActiveStates?.addEventListener("click", () => { - graphState.showActive = !graphState.showActive; - els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive)); - renderStateGraph(); - clearStateGraphInfo(); +graphState.showActive = !graphState.showActive; +els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive)); +renderStateGraph(); +clearStateGraphInfo(); }); els.togglePastStates?.addEventListener("click", () => { - graphState.showPast = !graphState.showPast; - els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast)); - renderStateGraph(); - clearStateGraphInfo(); +graphState.showPast = !graphState.showPast; +els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast)); +renderStateGraph(); +clearStateGraphInfo(); }); els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo); els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo); - +document.addEventListener("visibilitychange", () => { +lastSimTickAt = 0; +requestRender(); +if (!document.hidden && loopTimer) { +clearTimeout(loopTimer); +loopTimer = null; +loopScheduled = false; +scheduleLoop(); +} +}); reset(); loop();