const Terrain = Object.freeze({ 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 } ]; 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 }) }); function years(value) { 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") }; const ctx = els.canvas.getContext("2d", { alpha: false }); let sim; let running = true; let frame = 0; let lastStatsAt = 0; let lastGraphRenderAt = 0; let hoverState = null; let renderImage = null; let renderImageSize = 0; const graphState = { filter: "all", sort: "important", metric: "power", showActive: true, showPast: true, hoverPolityId: null, rows: [], 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); } } 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))]; } } 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.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.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); 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: [] }); } return this.polityHistory.get(polity.id); } 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; history.samples.push({ year: this.year, cities: cities.length, population, treasury, avgLoyalty, power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower }); 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); } 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); } } } 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); } } } 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 = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]); polity.centerCityId = center.id; center.loyalty = 1; 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(); 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, 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 })), wars: this.wars, 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.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 })); 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); 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 || [] }])); 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 || [] })); 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); drawTradeLinks(); drawAgentsAndCities(mode); drawGraphPolityHighlight(); maybeRenderStateGraph(); els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; } 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(); } 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(); } 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(); } function cityRenderRadius(city) { 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]; } 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); } 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(); } 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]}` : "-"; els.tooltip.innerHTML = ` ${agent ? "Agent group" : terrain.name} Tile${x}, ${y} Terrain${terrain.name} Resources${w.resource[i].toFixed(1)} Fertility${w.fertility[i].toFixed(2)} Temp / humid${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)} Water influence${waterInfluence} Minerals${w.mineral[i].toFixed(2)} Pheromone${w.pheromone[i].toFixed(1)} Pressure${w.pressure[i].toFixed(0)} Local culture${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)} ${w.tradeRoute[i] ? `Route${w.tradeRoute[i]}` : ""} ${city ? `City#${city.id}` : ""} ${city ? `Urban pop${city.population.toLocaleString()}` : ""} ${city ? `Food stock${city.storedResources.toFixed(1)}` : ""} ${city ? `Supply stress${(city.supplyStress || 0).toFixed(2)}` : ""} ${city ? `Farmland radius${city.agriculturalRadius}` : ""} ${city ? `Trade links${city.tradeLinks.size}` : ""} ${city ? `Trade value${(city.tradeValue || 0).toFixed(2)} / ${Math.round(city.tradeReach || 0)} tiles` : ""} ${city ? `City knowledge${(city.knowledge?.farming || 0).toFixed(2)} / ${(city.knowledge?.metallurgy || 0).toFixed(2)}` : ""} ${cityEthnicity ? `City majorityE${cityEthnicity}` : ""} ${city ? `State${polity ? `#${polity.id}` : "Independent"}` : ""} ${city ? `Loyalty${city.loyalty.toFixed(2)}` : ""} ${polity ? `Charisma${clamp(polity.charisma ?? 1, 0.5, 1.5).toFixed(2)}` : ""} ${polity ? `Leader tenure${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / MONTHS_PER_YEAR)}y` : ""} ${polity ? `Treasury${polity.treasury.toFixed(1)}` : ""} ${polity ? `Center${polity.centerCityId === city.id ? "yes" : "no"}` : ""} ${agent ? `EthnicityE${agent.ethnicity}` : ""} ${ethnicity ? `Climate pref${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : ""} ${agent ? `Climate mismatch${mismatch.toFixed(2)}` : ""} ${agent ? `Sedentary${getSedentary(agent.traits).toFixed(2)}` : ""} ${agent ? `Ethnocentrism${getEthnocentrism(agent.traits).toFixed(2)}` : ""} ${agent ? `Farming${(agent.tech?.farming || 0).toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : ""} ${agent ? `Metallurgy${(agent.tech?.metallurgy || 0).toFixed(3)}` : ""} ${agent ? `Stored${agent.resources.toFixed(1)}` : ""} ${ethnicity ? `Lineage pop${ethnicity.population}` : ""} `; 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; } function findAgentAt(x, y) { 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; } 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(); } 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"; } } function maybeRenderStateGraph() { 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; } 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); } 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); } 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.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.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); } }); } function updateStateGraphInfo(event) { if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return; const y = event.offsetY; const x = event.offsetX; 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"; 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(); } 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"; if (graphState.hoverPolityId !== null) { graphState.hoverPolityId = null; if (!running) render(); } } function formatGraphYear(month) { 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`; } 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 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(); } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function smoothstep(t) { return t * t * (3 - 2 * t); } function getSedentary(traits) { return traits.sedentary; } function getEthnocentrism(traits) { 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) }; } 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) }; } function emptyTraitSums() { 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); } 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 }; } function addBirthsToComposition(composition, 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; } } function dominantComposition(composition) { 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)); } 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) ]; } 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)) ]; } function lerp(a, b, 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) }; } 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 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); } }); function saveWorld() { 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); } } els.toggleRun.addEventListener("click", () => { running = !running; els.toggleRun.textContent = running ? "Pause" : "Run"; }); els.stepOnce.addEventListener("click", () => { sim.step(); render(); updateStats(true); renderStateGraph(); renderTooltip(); }); els.resetWorld.addEventListener("click", reset); els.saveWorld.addEventListener("click", saveWorld); els.loadWorld.addEventListener("click", loadWorld); els.clearSave.addEventListener("click", () => Persistence.clear()); els.worldSize.addEventListener("change", reset); els.agentCount.addEventListener("change", reset); els.viewMode.addEventListener("change", () => { setLegend(); render(); }); 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(); }); } els.toggleActiveStates?.addEventListener("click", () => { 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(); }); els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo); els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo); reset(); loop();