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 WEEKS_PER_MONTH = 4; const MONTHS_PER_YEAR = 12; const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH; const SimConfig = Object.freeze({ population: Object.freeze({ maxAgentsFloor: 8000, maxAgentsScale: 1.15, softCapStart: 0.85, hardCapScale: 1.35, offspringCrowdingPenalty: 1.8, minOffspringAcceptance: 0.035, maxOffspringPerStep: 360, carryingCapacityBase: 2.4, carryingCapacityFertility: 7.8, carryingCapacityMineral: 1.8, carryingCapacityFarmland: 5.5, pressurePenaltyBase: 0.2, pressureReproductionPenalty: 0.16 }), city: Object.freeze({ maxCities: 300 }), render: Object.freeze({ graphThrottleMs: 1400, statsThrottleMs: 900, historyWindowYears: 2000, historySamplePaddingYears: 240 }), technology: Object.freeze({ cityDiffusion: 0.0022, tradeDiffusion: 0.0032, cityInnovation: 0.00045 }), polity: Object.freeze({ minimumPerCapitaFood: 0.06, logisticsDistance: 34 }), polityAccess: Object.freeze({ enabled: true, maxSearchDepth: 8, indirectPenalty: 0.012, perHopPenalty: 0.006, noAccessPenalty: 0.045, lowLoyaltyTransitPenalty: 0.012, foreignTransitPenalty: 0.008 }), culture: Object.freeze({ spreadRadius: 3, cityWeight: 0.035, routeWeight: 1.35, minimumInfluence: 0.18 }), route: Object.freeze({ maxRouteLength: 42, pheromoneDecay: 0.996, pheromoneDiffusion: 0.045, maxPheromone: 18, routePheromoneBuild: 0.18, routePheromoneMaintain: 0.12, routeUpkeepPerTile: 0.018, routeWeakThreshold: 8, routeUnsupportedDecay: 7, routeMaintainedBoost: 10 }), disaster: Object.freeze({ damageScale: 1.5, majorLossRate: 0.05, checkIntervalYears: 8, baseChance: 0.76, minRadius: 6, maxRadius: 34, rareLargeChance: 0.10, largeRadiusMin: 28, largeRadiusMax: 54, minIntensity: 0.25, maxIntensity: 0.95, visualDurationYears: 18, maxActiveVisuals: 12, maxHistory: 160 }), frontierWave: Object.freeze({ enabledAfterYears: 300, intervalYears: 80, chance: 0.45, minAgents: 60, maxAgents: 140, maxPopulationRatio: 1.28, edgeBand: 4, mutation: 0.08, minResources: 12, maxResources: 28 }) }); function years(value) { return value * WEEKS_PER_YEAR; } const elementIds = [ "world", "toggleRun", "stepOnce", "resetWorld", "speed", "agentCount", "worldSize", "viewMode", "year", "activeGroups", "urbanPopulation", "ethnicities", "cities", "polities", "wars", "routes", "farmingKnowledge", "metallurgyKnowledge", "deaths", "frameCost", "ethnicityList", "legend", "tooltip", "stateGraph", "historyRange", "historyFilter", "historySort", "historyMetric", "toggleActiveStates", "togglePastStates", "stateGraphInfo", "stateGraphTooltip" ]; const els = Object.fromEntries(elementIds.map(id => [id === "world" ? "canvas" : id, document.getElementById(id)])); els.sim = document.querySelector(".sim"); const ctx = els.canvas.getContext("2d", { alpha: false }); let sim; let running = true; let lastStatsAt = 0; let lastGraphRenderAt = 0; let lastRenderAt = 0; let lastSimTickAt = 0; let renderDirty = true; let loopTimer = null; let loopScheduled = false; let hoverState = null; let renderImage = null; let renderImageSize = 0; const LoopConfig = Object.freeze({ simTickMs: 33, renderMs: 66, stepBudgetMs: 4.5, idlePollMs: 180, hiddenPollMs: 1000 }); const graphState = { filter: "all", sort: "important", metric: "power", showActive: true, showPast: true, hoverPolityId: null, rows: [], markers: [], scale: null }; const legendByMode = { terrain: () => terrainInfo.map(t => `${t.name}`).join(""), ethnicity: () => "Agent and city colors show lineage. Land remains terrain-colored.", pressure: () => "High local population pressure", polities: () => "Color = city-centered state. Uncolored cities are independent.", technology: () => "Farming knowledgeMetallurgy knowledge", pheromone: () => "Pheromone strengthFormal trade routes", resources: () => "Regenerating local resource stock" }; class Rng { constructor(seed) { this.seed = seed >>> 0; } next() { this.seed = (1664525 * this.seed + 1013904223) >>> 0; return this.seed / 4294967296; } range(min, max) { return min + (max - min) * this.next(); } int(max) { return Math.floor(this.next() * max); } } class World { constructor(size, rng) { this.size = size; this.count = size * size; this.rng = rng; this.terrain = new Uint8Array(this.count); this.resource = new Float32Array(this.count); this.regen = new Float32Array(this.count); this.move = new Float32Array(this.count); this.fertility = new Float32Array(this.count); this.mineral = new Float32Array(this.count); this.temperature = new Float32Array(this.count); this.humidity = new Float32Array(this.count); this.pheromone = new Float32Array(this.count); this.tradeRoute = new Uint8Array(this.count); this.farmland = new Float32Array(this.count); this.cityPull = new Float32Array(this.count); this.city = new Int32Array(this.count); this.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); this.generate(); } 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) { this.rng = new Rng(Date.now()); this.world = new World(size, this.rng); this.agents = []; this.ethnicities = new Map(); this.cities = []; this.cityById = new Map(); this.cityBuckets = new Map(); this.polities = []; this.polityById = new Map(); this.wars = []; this.disasters = []; this.disasterHistory = []; this.graphEvents = []; this.polityHistory = new Map(); this.deadPolityHistories = []; this.tradeLinks = []; this.activeTradeRouteTiles = new Set(); this.ethnicDensityCache = new Map(); this.dominantEthnicityCache = new Map(); this.nextEthnicity = 1; this.nextCity = 1; this.nextPolity = 1; this.nextWar = 1; this.nextDisaster = 1; this.year = 0; this.deaths = 0; this.maxAgents = Math.max( Math.floor(initialAgents * SimConfig.population.maxAgentsScale), SimConfig.population.maxAgentsFloor ); this.tileEthnicities = new Map(); this.tileAgents = new Map(); 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, tradeOriginCityId: null, lastTradeCityId: null, tradeMemory: 0, tradeCooldown: 0 }; } findNearbySpawn(originX, originY, preferredTerrain = null) { const w = this.world; let best = { x: originX, y: originY }; let bestScore = -Infinity; for (let tries = 0; tries < 24; tries++) { const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1); const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1); const i = w.idx(x, y); if (w.terrain[i] === Terrain.WATER) continue; const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0; const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16; if (score > bestScore) { bestScore = score; best = { x, y }; } } return best; } findHabitableTile(preferredTerrain = null) { if (preferredTerrain !== null) { const exact = []; let best = null; let bestScore = -Infinity; for (let i = 0; i < this.world.count; i++) { const x = i % this.world.size; const y = Math.floor(i / this.world.size); if (this.world.terrain[i] === preferredTerrain) { exact.push({ x, y }); continue; } if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) { const score = this.world.temperature[i] * (1 - this.world.humidity[i]); if (score > bestScore) { bestScore = score; best = { x, y }; } } } if (exact.length) return exact[this.rng.int(exact.length)]; if (best) return best; } for (let tries = 0; tries < 5000; tries++) { const x = this.rng.int(this.world.size); const y = this.rng.int(this.world.size); const i = this.world.idx(x, y); if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) { return { x, y }; } } return { x: this.world.size >> 1, y: this.world.size >> 1 }; } frontierSpawnTile(side, edgeBand = 4) { const w = this.world; const band = Math.max(1, Math.floor(edgeBand)); let best = null; let bestScore = -Infinity; for (let tries = 0; tries < 160; tries++) { let x = this.rng.int(w.size); let y = this.rng.int(w.size); if (side === 0) y = this.rng.int(band); else if (side === 1) y = w.size - 1 - this.rng.int(band); else if (side === 2) x = this.rng.int(band); else x = w.size - 1 - this.rng.int(band); const i = w.idx(x, y); if (w.terrain[i] === Terrain.WATER) continue; const score = w.resource[i] * 0.04 + w.fertility[i] * 0.85 + w.mineral[i] * 0.25 - w.move[i] * 0.12; if (score > bestScore) { best = { x, y }; bestScore = score; } } return best || this.findHabitableTile(); } maybeSpawnFrontierWave() { const cfg = SimConfig.frontierWave; if (!cfg) return; if (this.year < years(cfg.enabledAfterYears ?? 300)) return; const interval = years(cfg.intervalYears ?? 80); if (!interval || this.year % interval !== 0) return; if (this.rng.next() > (cfg.chance ?? 0.45)) return; const populationLimit = this.maxAgents * (cfg.maxPopulationRatio ?? 1.28); if (this.agents.length >= populationLimit) return; const side = this.rng.int(4); const targetCount = this.rng.int(Math.max(1, (cfg.maxAgents ?? 140) - (cfg.minAgents ?? 60) + 1)) + (cfg.minAgents ?? 60); const origin = this.frontierSpawnTile(side, cfg.edgeBand ?? 4); const originTile = this.world.idx(origin.x, origin.y); const ethnicity = this.createEthnicity(0, { temperature: this.world.temperature[originTile], humidity: this.world.humidity[originTile] }); const baseTraits = this.randomTraits(); baseTraits.mobility = this.rng.range(0.65, 1.0); baseTraits.sedentary = this.rng.range(0.02, 0.35); baseTraits.ethnocentrism = this.rng.range(0.42, 0.95); baseTraits.assimilation = this.rng.range(0.02, 0.22); baseTraits.resourceAttraction = this.rng.range(0.78, 1.15); let spawned = 0; for (let n = 0; n < targetCount && this.agents.length < populationLimit; n++) { const spawn = this.frontierSpawnTile(side, cfg.edgeBand ?? 4); const tile = this.world.idx(spawn.x, spawn.y); if (this.world.terrain[tile] === Terrain.WATER) continue; const agent = this.makeAgent( spawn.x, spawn.y, ethnicity, mutateTraits(baseTraits, this.rng, cfg.mutation ?? 0.08), this.rng.range(cfg.minResources ?? 12, cfg.maxResources ?? 28) ); agent.tradeMemory = this.rng.range(0.05, 0.22); this.agents.push(agent); if (this.addAgentToOccupancy) this.addAgentToOccupancy(agent); spawned++; } if (spawned <= 0) this.ethnicities.delete(ethnicity); else this.addGraphEvent("newEthnicity", this.year, { ethnicity, population: spawned }); } step() { for (const city of this.cities) city.activeVisitors = 0; this.rebuildOccupancy(); this.ethnicDensityCache.clear(); const offspring = []; for (const a of this.agents) { if (!a.alive) continue; this.moveAgent(a); } this.rebuildOccupancy(); this.dominantEthnicityCache.clear(); for (const a of this.agents) { if (!a.alive) continue; this.updateAgentTechnology(a); this.updateAgentTrade(a); this.gatherConsumeReproduce(a, offspring); if (a.alive) this.payTechnologyCostOrForget(a); if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a); } this.agents = this.agents.filter(a => a.alive); const acceptedOffspring = []; const softCap = Math.max(1, this.maxAgents); const hardCap = Math.max(softCap, Math.floor(softCap * (SimConfig.population.hardCapScale ?? 1.35))); const softCapStart = SimConfig.population.softCapStart ?? 0.85; const crowdingPenalty = SimConfig.population.offspringCrowdingPenalty ?? 1.8; const minAcceptance = SimConfig.population.minOffspringAcceptance ?? 0.035; for (const child of offspring) { const projectedPopulation = this.agents.length + acceptedOffspring.length; if (projectedPopulation >= hardCap) break; const pressure = projectedPopulation / softCap; const overSoftCap = Math.max(0, pressure - softCapStart); const acceptChance = clamp(1 - overSoftCap * crowdingPenalty, minAcceptance, 1); if (pressure <= softCapStart || this.rng.next() < acceptChance) acceptedOffspring.push(child); } this.agents.push(...acceptedOffspring); for (const child of acceptedOffspring) this.addAgentToOccupancy(child); if (this.year % 2 === 0) this.updateWorldFields(2); this.maybeSpawnDisaster(); if (this.year % years(1) === 0) this.maybeSpawnFrontierWave(); 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 = a.traits.sedentary; const ethnocentrism = a.traits.ethnocentrism; const ethnicClimate = this.ethnicities.get(a.ethnicity); const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45; const localCapacity = this.carryingCapacityAt(current); const localOverCapacity = Math.max(0, localPressure - localCapacity); if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) { a.settled++; a.movedThisStep = false; return; } const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const x = clamp(a.x + dx, 0, s - 1); const y = clamp(a.y + dy, 0, s - 1); const i = w.idx(x, y); const isWater = w.terrain[i] === Terrain.WATER; const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45; const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0); if (isWater && this.rng.next() > waterAccess) continue; const ethnicDensity = ethnocentrism > 0.04 ? this.ethnicDensityNear(x, y, a.ethnicity) : { same: 0, foreign: 0 }; const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i]; const routeStrength = clamp(w.tradeRoute[i] / 80, 0, 1.8); const routePull = (w.tradeRoute[i] ? 1.55 + routeStrength * 0.55 : clamp(w.pheromone[i] / 8, 0, 1)) * (0.35 + a.traits.mobility * 0.55 + sedentary * 0.45); const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0); const destinationCapacity = this.carryingCapacityAt(i); const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity); const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1); const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85); const routeBonus = w.tradeRoute[i] ? 1.35 + routeStrength * 0.45 : 0; const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0; const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus; const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0; const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0; const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45); const score = resourceScore * a.traits.resourceAttraction + w.pheromone[i] * 0.021 + ethnicDensity.same * ethnocentrism + cityPull * sedentary + routePull + capacityPull + inertia - terrainPenalty - crowdPenalty + pressurePush - ethnicDensity.foreign * ethnocentrism * 0.72 + this.rng.range(-0.55, 0.55); if (score > bestScore) { bestScore = score; bestX = x; bestY = y; } } } const from = w.idx(a.x, a.y); a.x = bestX; a.y = bestY; const to = w.idx(a.x, a.y); if (from !== to) { a.movedThisStep = true; a.farmingWork = 0; a.lastFarmTile = to; const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1; this.addPheromone(from, (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale); this.addPheromone(to, (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale); a.settled = Math.max(0, a.settled - 1); } else { a.movedThisStep = false; a.settled++; } } ensureAgentTech(a) { a.tech ??= { farming: 0, metallurgy: 0 }; a.tech.farming ??= 0; a.tech.metallurgy ??= 0; a.tech.farming = clamp(a.tech.farming, 0, 1); a.tech.metallurgy = clamp(a.tech.metallurgy, 0, 1); a.farmingWork ??= 0; a.lastFarmTile ??= -1; a.movedThisStep ??= false; } ensureAgentTrade(agent) { agent.tradeOriginCityId ??= null; agent.lastTradeCityId ??= null; agent.tradeMemory ??= 0; agent.tradeCooldown ??= 0; agent.tradeMemory = clamp(agent.tradeMemory, 0, 1); agent.tradeCooldown = Math.max(0, Math.floor(agent.tradeCooldown)); } agentNomadism(agent) { const mobility = clamp(agent.traits?.mobility ?? 0.5, 0, 1); const sedentary = clamp(agent.traits?.sedentary ?? 0.5, 0, 1); return clamp(mobility * 0.75 + (1 - sedentary) * 0.55, 0, 1); } farmabilityAt(tile) { const t = this.world.terrain[tile]; if (t === Terrain.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 = agent.traits.sedentary; const farmingChance = farmingBase * (0.25 + farmability) * (0.4 + sedentary) * (0.3 + settledFactor); if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32); } 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; const nomadism = this.agentNomadism(agent); let checked = 0; this.forEachLocalAgentNear(agent.x, agent.y, 1, other => { if (other === agent || !other.alive) return true; if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true; if (++checked > 12) return false; this.ensureAgentTech(other); const sameEthnicity = agent.ethnicity === other.ethnicity; let chance = 0.0035 + assimilation * 0.006 - ethnocentrism * 0.0012; if (sameEthnicity) chance += 0.004; if (w.tradeRoute[tile]) chance += 0.006; if (w.city[tile] >= 0) chance += 0.003; chance += nomadism * 0.004; chance = clamp(chance, 0.0008, 0.028); this.learnTechnologyFrom(agent, other, "farming", chance); this.learnTechnologyFrom(agent, other, "metallurgy", chance); return true; }); } 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); } } updateAgentTrade(agent) { this.ensureAgentTrade(agent); this.ensureAgentTech(agent); if (agent.tradeCooldown > 0) { agent.tradeCooldown--; return; } const city = this.encounteredCityForAgent(agent); if (!city) return; const nomadism = this.agentNomadism(agent); let chance = 0.015 + nomadism * 0.08 + (agent.tradeMemory || 0) * 0.04; if (nomadism < 0.35) chance *= 0.18; const tile = this.world.idx(agent.x, agent.y); if (this.world.tradeRoute[tile]) chance += 0.04; chance += clamp(Math.sqrt(Math.max(0, city.population || 0)) / 80, 0, 0.08); chance += ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.015; chance = clamp(chance, 0.005, 0.22); if (this.rng.next() > chance) return; if (agent.tradeOriginCityId === null) { agent.tradeOriginCityId = city.id; agent.lastTradeCityId = city.id; agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.01, 0, 1); agent.tradeCooldown = Math.floor(years(1)); return; } if (agent.tradeOriginCityId === city.id) { agent.lastTradeCityId = city.id; agent.tradeCooldown = Math.floor(years(0.5)); return; } const originCity = this.getCityById(agent.tradeOriginCityId); if (!originCity) { agent.tradeOriginCityId = city.id; agent.lastTradeCityId = city.id; agent.tradeCooldown = Math.floor(years(1)); return; } this.completeAgentTrade(agent, originCity, city); } encounteredCityForAgent(agent) { const w = this.world; const tile = w.idx(agent.x, agent.y); if (w.city[tile] >= 0) { const city = this.getCityById(w.city[tile]); if (city) return city; } const candidates = this.getCitiesNear(agent.x, agent.y, 4); if (!candidates.length) return null; candidates.sort((a, b) => { const da = Math.abs(a.x - agent.x) + Math.abs(a.y - agent.y); const db = Math.abs(b.x - agent.x) + Math.abs(b.y - agent.y); if (da !== db) return da - db; return (b.population || 0) - (a.population || 0); }); return candidates[0]; } completeAgentTrade(agent, originCity, destinationCity) { if (!originCity || !destinationCity || originCity.id === destinationCity.id) return; this.ensureAgentTech(agent); this.ensureAgentTrade(agent); const nomadism = this.agentNomadism(agent); const distance = this.distanceBetweenCities(originCity, destinationCity); const routeFactor = this.hasDirectTradeConnection(originCity, destinationCity) ? 1.35 : 1.0; const distanceFactor = clamp(distance / 28, 0.45, 2.1); const cityScale = Math.sqrt(Math.max(1, originCity.population || 1)) * Math.sqrt(Math.max(1, destinationCity.population || 1)); const techFactor = 1 + ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.12; const memoryFactor = 1 + (agent.tradeMemory || 0) * 0.35; const rawProfit = cityScale * 0.010 * distanceFactor * routeFactor * techFactor * memoryFactor * clamp(nomadism, 0.25, 1); const profit = clamp(rawProfit, 0.25, 7.5); agent.resources += profit * 0.50; originCity.storedResources += profit * 0.20; destinationCity.storedResources += profit * 0.30; this.exchangeAgentTradeKnowledge(agent, originCity, destinationCity); const currentTile = this.world.idx(agent.x, agent.y); this.addPheromone(currentTile, profit * 0.45); this.addPheromone(this.world.idx(originCity.x, originCity.y), profit * 0.30); this.addPheromone(this.world.idx(destinationCity.x, destinationCity.y), profit * 0.35); agent.tradeOriginCityId = destinationCity.id; agent.lastTradeCityId = destinationCity.id; agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.035, 0, 1); agent.tradeCooldown = Math.floor(years(1.5)); } exchangeAgentTradeKnowledge(agent, originCity, destinationCity) { if (originCity.knowledge) { originCity.knowledge.farming = clamp(Math.max(originCity.knowledge.farming, (agent.tech?.farming || 0) * 0.45), 0, 1); originCity.knowledge.metallurgy = clamp(Math.max(originCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.45), 0, 1); } if (destinationCity.knowledge) { destinationCity.knowledge.farming = clamp(Math.max(destinationCity.knowledge.farming, (agent.tech?.farming || 0) * 0.55), 0, 1); destinationCity.knowledge.metallurgy = clamp(Math.max(destinationCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.55), 0, 1); agent.tech.farming = clamp(Math.max(agent.tech.farming || 0, destinationCity.knowledge.farming * 0.25), 0, 1); agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy || 0, destinationCity.knowledge.metallurgy * 0.25), 0, 1); } } improveTechnologyFromDensity(agent) { let farmingCount = 0; let metallurgyCount = 0; let farmingSum = 0; let metallurgySum = 0; this.forEachLocalAgentNear(agent.x, agent.y, 2, other => { if (!other.alive) return true; this.ensureAgentTech(other); const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y); if (distance > 2) return true; const farming = other.tech.farming || 0; const metallurgy = other.tech.metallurgy || 0; if (farming > 0.1) { farmingCount++; farmingSum += farming; } if (metallurgy > 0.1) { metallurgyCount++; metallurgySum += metallurgy; } return true; }); if (farmingCount >= 2) { const avgFarming = farmingSum / farmingCount; agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1); } if (metallurgyCount >= 2) { const avgMetallurgy = metallurgySum / metallurgyCount; agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1); } } forEachLocalAgentNear(x, y, radius, callback) { const w = this.world; for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; const agents = this.tileAgents.get(w.idx(tx, ty)); if (!agents) continue; for (const agent of agents) { if (callback(agent) === false) return; } } } } farmingGatherMultiplier(agent, tile) { const farmingLevel = agent.tech?.farming || 0; const farmingWork = agent.farmingWork || 0; const farmability = this.farmabilityAt(tile); return clamp(1 + farmingLevel * farmingWork * farmability * 2, 1, 3); } metallurgyGatherMultiplier(agent, tile) { const metallurgyLevel = agent.tech?.metallurgy || 0; if (metallurgyLevel > 0.02) { agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1); } return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1); } gatherConsumeReproduce(a, offspring) { const w = this.world; const i = w.idx(a.x, a.y); this.ensureAgentTech(a); const ethnicity = this.ethnicities.get(a.ethnicity); const climateMismatch = this.climateMismatch(a.ethnicity, i); const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1); const cityMarket = w.city[i] >= 0 ? 0.28 : 0; const drylandAdapted = this.isDrylandAdapted(ethnicity); const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0; const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage; const carryingCapacity = this.carryingCapacityAt(i); const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity); const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase); const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2); const gathered = Math.min( w.resource[i], baseGatherAmount * this.farmingGatherMultiplier(a, i) * this.metallurgyGatherMultiplier(a, i) ); w.resource[i] -= gathered; a.resources += gathered; const waterAdaptation = ethnicity?.climateHumidity ?? 0.45; const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0; const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4; const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1; const sedentary = a.traits.sedentary; const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08; a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead; if (a.resources <= 0) { this.removeAgentFromOccupancy(a); a.alive = false; this.deaths++; return; } const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12); const mobilityPenalty = (1 - sedentary) * 0.45; const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty; const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35); if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) { const childShare = 0.36 + sedentary * 0.08; const childResources = a.resources * childShare; a.resources -= childResources; const childTraits = mutateTraits(a.traits, this.rng, 0.035); const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources); child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1); child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1); offspring.push(child); } } climateMismatch(ethnicityId, tile) { 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 / 100); if (this.rng.next() < chance * 0.0001) { this.changeAgentEthnicity(a, dominant.id); a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22); a.foreignContact = 0; } } dominantEthnicityNear(x, y, self) { const key = `${x},${y},${self}`; const cached = this.dominantEthnicityCache.get(key); if (cached) return cached; const counts = new Map(); let total = 0; const w = this.world; for (let dy = -3; dy <= 3; dy++) { for (let dx = -3; dx <= 3; dx++) { if (Math.abs(dx) + Math.abs(dy) > 3) continue; const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; const tileCounts = this.tileEthnicities.get(w.idx(tx, ty)); if (!tileCounts) continue; for (const [id, count] of tileCounts) { total += count; counts.set(id, (counts.get(id) || 0) + count); } } } for (const city of this.getCitiesNear(x, y, 5)) { const distance = Math.abs(city.x - x) + Math.abs(city.y - y); if (!city.ethnicityComposition?.size) continue; const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8); for (const [id, count] of city.ethnicityComposition) { const weighted = Math.max(1, Math.round(count * influence * 0.015)); total += weighted; counts.set(id, (counts.get(id) || 0) + weighted); } } let best = null; for (const [id, count] of counts) { if (id !== self && (!best || count > best.count)) best = { id, count, total }; } if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits(); if (best) this.dominantEthnicityCache.set(key, best); return best; } updateWorldFields(scale = 1) { const w = this.world; const pheromoneDecay = Math.pow(SimConfig.route.pheromoneDecay, scale); const diffusion = SimConfig.route.pheromoneDiffusion * scale; const nextPheromone = diffusion > 0 ? new Float32Array(w.pheromone) : null; for (let i = 0; i < w.count; i++) { w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15)); const decayed = w.pheromone[i] * pheromoneDecay; if (nextPheromone) nextPheromone[i] = decayed; else w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, decayed); } if (nextPheromone) { for (let y = 0; y < w.size; y++) { for (let x = 0; x < w.size; x++) { const i = w.idx(x, y); if (w.terrain[i] === Terrain.WATER) { w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, nextPheromone[i] * 0.85); continue; } let neighborSum = 0; let neighborCount = 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const tx = x + dx; const ty = y + dy; if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; const tile = w.idx(tx, ty); if (w.terrain[tile] === Terrain.WATER) continue; neighborSum += nextPheromone[tile]; neighborCount++; } } const neighborAverage = neighborCount ? neighborSum / neighborCount : nextPheromone[i]; w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, lerp(nextPheromone[i], neighborAverage, diffusion)); } } } } 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 += a.traits.sedentary; 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 < 3) { absorbed.push(a); continue; } const city = this.findCityNear(a.x, a.y, 9); const sedentary = a.traits.sedentary; const nomadism = this.agentNomadism(a); const nomadRetention = 1 - nomadism * 0.85; const absorbChance = clamp((0.08 + sedentary * 1.05) * clamp(nomadRetention, 0.10, 1), 0.03, 0.92); if (!city || this.rng.next() > absorbChance) { absorbed.push(a); continue; } const migrants = 1 + Math.floor(Math.min(10, a.resources / 7)); const urbanWeight = clamp(0.12 + (sedentary - 0.10) * 1.55, 0.12, 1); city.population += migrants; city.storedResources += Math.max(0, a.resources) * 0.65; const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight); if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount); city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015; city.strength += 0.04; } this.agents = absorbed; } 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); this.addPheromone(i, city.pheromoneOutput * pull * 0.09); harvested += extraction; } } city.storedResources += harvested; const trade = this.cityTradeProfile(city); const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018); 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, this.rng); } 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) { if (!cityA || !cityB) return false; if (cityA.tradeLinks?.has(cityB.id) || cityB.tradeLinks?.has(cityA.id)) return true; return (this.tradeLinks || []).some(link => { const endpoints = this.tradeLinkEndpointIds(link); return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id); }); } tradeLinkEndpointId(value) { return typeof value === "object" ? value?.id ?? null : value ?? null; } tradeLinkEndpointIds(link) { const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityAId ?? link.aId ?? link.sourceId ?? link.source ?? link.a ?? link.cityA); const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB); return from != null && to != null ? [from, to] : null; } polityTradeNeighbors(city, polityId) { if (!city || polityId == null) return []; const neighbors = []; for (const link of this.tradeLinks || []) { const endpoints = this.tradeLinkEndpointIds(link); if (!endpoints) continue; const [from, to] = endpoints; const otherId = from === city.id ? to : to === city.id ? from : null; if (otherId == null) continue; const other = this.getCityById(otherId); if (other?.polityId === polityId) neighbors.push(other); } return neighbors; } tradeAccessToCenter(city, center, polityId) { if (!city || !center || polityId == null) return { reachable: false, hops: Infinity, transitCities: [] }; if (city.id === center.id) return { reachable: true, hops: 0, transitCities: [] }; if (this.hasDirectTradeConnection(city, center)) return { reachable: true, hops: 1, transitCities: [] }; const maxDepth = SimConfig.polityAccess?.maxSearchDepth ?? 8; const visited = new Set([city.id]); const queue = [{ city, hops: 0, transitCities: [] }]; while (queue.length) { const current = queue.shift(); if (current.hops >= maxDepth) continue; for (const neighbor of this.polityTradeNeighbors(current.city, polityId)) { if (visited.has(neighbor.id)) continue; const hops = current.hops + 1; if (neighbor.id === center.id) return { reachable: true, hops, transitCities: current.transitCities }; visited.add(neighbor.id); queue.push({ city: neighbor, hops, transitCities: [...current.transitCities, neighbor] }); } } return { reachable: false, hops: Infinity, transitCities: [] }; } tradeAccessLoyaltyPenalty(city, center, polity) { const cfg = SimConfig.polityAccess; if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0; const access = this.tradeAccessToCenter(city, center, polity.id); if (!access.reachable) return clamp(cfg.noAccessPenalty, 0, 0.07); let penalty = cfg.indirectPenalty + Math.max(0, access.hops - 1) * cfg.perHopPenalty; const originEthnicity = this.dominantCityEthnicity(city); for (const transitCity of access.transitCities) { if ((transitCity.loyalty ?? 0.5) < 0.35) penalty += cfg.lowLoyaltyTransitPenalty; const transitEthnicity = this.dominantCityEthnicity(transitCity); if (originEthnicity !== null && transitEthnicity !== null && transitEthnicity !== originEthnicity) { penalty += cfg.foreignTransitPenalty; } } return clamp(penalty, 0, 0.07); } effectiveDistance(cityA, cityB) { let distance = this.distanceBetweenCities(cityA, cityB); if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55; return distance; } createPolity(centerCity) { const id = this.nextPolity++; const polity = { id, centerCityId: centerCity.id, cityIds: new Set([centerCity.id]), treasury: Math.max(0, centerCity.storedResources * 0.12), color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), founded: this.year, legitimacy: this.rng.range(0.68, 0.94), cohesion: this.rng.range(0.58, 0.9), charisma: this.rng.range(0.5, 1.5), leaderStarted: this.year, leaderTenureYears: this.rng.range(24, 68), crisis: 0, lastCrisisYear: this.year }; centerCity.polityId = id; centerCity.loyalty = 1; centerCity.receivedAid = false; this.polities.push(polity); this.polityById.set(polity.id, polity); this.ensurePolityHistory(polity); this.samplePolityHistory(polity); return polity; } ensurePolityHistory(polity) { if (!polity) return null; if (!this.polityHistory.has(polity.id)) { this.polityHistory.set(polity.id, { id: polity.id, color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62), founded: polity.founded ?? this.year, ended: null, active: true, centerCityId: polity.centerCityId ?? null, fate: null, samples: [], events: [], peakPower: 0, peakYear: null, peakEventYear: null }); } const history = this.polityHistory.get(polity.id); history.events ??= []; history.peakPower ??= 0; history.peakYear ??= null; history.peakEventYear ??= null; return history; } averagePolityLoyalty(polity) { const cities = this.getPolityCities(polity); const subordinates = cities.filter(city => city.id !== polity.centerCityId); if (!subordinates.length) return 1; return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length; } samplePolityHistory(polity) { const history = this.ensurePolityHistory(polity); if (!history) return; const cities = this.getPolityCities(polity); const population = cities.reduce((sum, city) => sum + city.population, 0); const treasury = polity.treasury || 0; const avgLoyalty = this.averagePolityLoyalty(polity); const fallbackPower = Math.sqrt(population) * 1.35 + Math.sqrt(Math.max(0, treasury)) * 1.15 + avgLoyalty * 16; history.centerCityId = polity.centerCityId ?? history.centerCityId; const sample = { year: this.year, cities: cities.length, population, treasury, avgLoyalty, power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower }; history.samples.push(sample); if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) { history.peakPower = sample.power; history.peakYear = this.year; } const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears; while (history.samples.length > maxSamples) history.samples.shift(); } samplePolityHistories() { for (const polity of this.polities) this.samplePolityHistory(polity); } addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) { let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null; if (!history) { const polity = this.getPolityById(polityId); if (polity) history = this.ensurePolityHistory(polity); } if (!history) return; history.events ??= []; const sameYearDuplicate = history.events.some(event => event.type === type && event.year === year && JSON.stringify(event.data || {}) === JSON.stringify(data || {}) ); if (sameYearDuplicate) return; history.events.push({ year, type, importance, data }); while (history.events.length > 120) history.events.shift(); } addGraphEvent(type, year = this.year, data = {}, importance = 2) { this.graphEvents ??= []; const duplicate = this.graphEvents.some(event => event.type === type && event.year === year && JSON.stringify(event.data || {}) === JSON.stringify(data || {}) ); if (duplicate) return; this.graphEvents.push({ year, type, importance, data }); while (this.graphEvents.length > 160) this.graphEvents.shift(); } selectNewLeader(polity, forced = false) { if (!polity) return; const 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) / WEEKS_PER_YEAR; const oldLeader = Math.max(0, tenureYears - polity.leaderTenureYears); const crisisPressure = clamp((polity.crisis || 0) * 0.045, 0, 0.09); const successionChance = clamp(oldLeader * 0.018 + crisisPressure, 0, 0.36); if (successionChance > 0 && this.rng.next() < successionChance) { this.selectNewLeader(polity, (polity.crisis || 0) > 0.85); } } } maybeSpawnDisaster() { const config = SimConfig.disaster; const interval = years(config?.checkIntervalYears ?? 8); if (!interval || this.year % interval !== 0) return; if (this.rng.next() > (config?.baseChance ?? 0.38)) return; this.spawnDisaster(); } spawnDisaster() { const config = SimConfig.disaster; const large = this.rng.next() < (config?.rareLargeChance ?? 0.10); const radius = large ? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54) : this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34); const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95); this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity); } applyDisaster(x, y, radius, intensity) { const config = SimConfig.disaster; const w = this.world; const scaledIntensity = intensity * (config?.damageScale ?? 1); const majorLossRate = config?.majorLossRate ?? 0.05; const affectedPolityMap = new Map(); const affectedPolityIds = new Set(); for (const city of this.cities) { if (city.polityId === null) continue; if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId); } const polityPopulationBefore = new Map(); for (const id of affectedPolityIds) { const polity = this.getPolityById(id); if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity)); } let affectedAgents = 0; for (const agent of this.agents) { if (!agent.alive) continue; const d = Math.hypot(agent.x - x, agent.y - y); if (d > radius) continue; const falloff = 1 - d / radius; const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); if (this.rng.next() < damage * 0.32) { if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent); agent.alive = false; this.deaths++; affectedAgents++; } else { agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45)); if (agent.tech) { agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035); agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04); } } } let affectedCities = 0; let totalPopulationLoss = 0; for (const city of this.cities) { const d = Math.hypot(city.x - x, city.y - y); if (d > radius) continue; const falloff = 1 - d / radius; const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); if (damage <= 0) continue; affectedCities++; const popBefore = city.population || 0; const lossRate = clamp(damage * 0.25, 0, 0.45); const loss = Math.floor(popBefore * lossRate); city.population = Math.max(0, popBefore - loss); if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss); city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55)); city.strength = Math.max(0, city.strength - damage * 0.38); this.deaths += Math.floor(loss * 0.35); totalPopulationLoss += loss; if (city.polityId !== null) { let entry = affectedPolityMap.get(city.polityId); if (!entry) { entry = { polityId: city.polityId, populationBefore: polityPopulationBefore.get(city.polityId) || 0, populationLoss: 0, cityIds: new Set() }; affectedPolityMap.set(city.polityId, entry); } entry.populationLoss += loss; entry.cityIds.add(city.id); const polity = this.getPolityById(city.polityId); if (polity) { polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1); } } } const minX = Math.max(0, Math.floor(x - radius)); const maxX = Math.min(w.size - 1, Math.ceil(x + radius)); const minY = Math.max(0, Math.floor(y - radius)); const maxY = Math.min(w.size - 1, Math.ceil(y + radius)); for (let ty = minY; ty <= maxY; ty++) { for (let tx = minX; tx <= maxX; tx++) { const d = Math.hypot(tx - x, ty - y); if (d > radius) continue; const falloff = 1 - d / radius; const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); const tile = w.idx(tx, ty); w.resource[tile] *= (1 - damage * 0.45); w.farmland[tile] *= (1 - damage * 0.35); w.pheromone[tile] *= (1 - damage * 0.25); } } const affectedPolities = [...affectedPolityMap.values()].map(entry => { const populationBefore = Math.max(1, entry.populationBefore || 0); const lossRate = entry.populationLoss / populationBefore; return { polityId: entry.polityId, populationBefore: entry.populationBefore, populationLoss: entry.populationLoss, lossRate, cityIds: [...entry.cityIds] }; }); const id = this.nextDisaster++; const record = { id, year: this.year, x, y, radius, intensity, expires: this.year + years(config?.visualDurationYears ?? 18), affectedAgents, affectedCities, totalPopulationLoss, affectedPolities }; this.disasters.push(record); this.disasterHistory.push(record); for (const entry of affectedPolities) { if (entry.lossRate >= majorLossRate) { this.addPolityEvent(entry.polityId, "disaster", this.year, { disasterId: id, populationLoss: entry.populationLoss, lossRate: entry.lossRate, affectedCities: entry.cityIds.length }, entry.lossRate >= majorLossRate * 2 ? 3 : 2); } } this.disasters = this.disasters .filter(disaster => this.year <= disaster.expires) .slice(-(config?.maxActiveVisuals ?? 12)); while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift(); } markPolityEnded(polity, reason = "dissolved") { if (!polity) return; const history = this.ensurePolityHistory(polity); if (!history) return; this.samplePolityHistory(polity); history.ended = this.year; history.active = false; history.fate = reason; history.centerCityId = polity.centerCityId ?? history.centerCityId; this.polityHistory.delete(polity.id); this.deadPolityHistories.push(history); while (this.deadPolityHistories.length > 80) this.deadPolityHistories.shift(); } getAllPolityHistories() { return [...this.deadPolityHistories, ...this.polityHistory.values()] .sort((a, b) => (a.founded - b.founded) || (a.id - b.id)); } addCityToPolity(city, polity, initialLoyalty = 0.5) { if (!city || !polity) return; if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city); city.polityId = polity.id; city.loyalty = clamp(initialLoyalty, 0, 1); city.receivedAid = false; polity.cityIds.add(city.id); } removeCityFromPolity(city) { if (!city || city.polityId === null) return; const oldPolity = this.getPolityById(city.polityId); if (oldPolity) oldPolity.cityIds.delete(city.id); city.polityId = null; city.loyalty = 0.45; city.receivedAid = false; } isPolityAtWar(polityId) { return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)); } warCountForPolity(polityId) { return this.wars.filter(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)).length; } canBeWarTarget(polityId) { return this.warCountForPolity(polityId) < 3; } getWarBetween(aId, bId) { return this.wars.find(war => war.ended === null && ((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId)) ) || null; } startWar(a, b) { if (!a || !b || a.id === b.id) return null; if (this.getWarBetween(a.id, b.id)) return null; if (this.isPolityAtWar(a.id) || !this.canBeWarTarget(b.id)) return null; const id = this.nextWar++; const war = { id, aPolityId: a.id, bPolityId: b.id, started: this.year, lastActionYear: this.year, intensity: this.rng.range(0.45, 1.05), exhaustionA: 0, exhaustionB: 0, ended: null }; this.wars.push(war); a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5); b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5); return war; } endWar(war) { if (!war || war.ended !== null) return; war.ended = this.year; } totalPolityPopulation(polity) { return this.getPolityCities(polity) .reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); } averagePolityTechnology(polity) { const cities = this.getPolityCities(polity); if (!cities.length) return 0; let total = 0; let count = 0; for (const city of cities) { if (!city?.knowledge) continue; total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5; count++; } return count ? total / count : 0; } polityPower(polity) { const cities = this.getPolityCities(polity); if (!cities.length) return 0; const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0); const treasury = Math.max(0, polity.treasury || 0); const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5; const tech = this.averagePolityTechnology(polity); const instability = this.polityInstability ? this.polityInstability(polity) : 0; const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0; const basePower = Math.max(0, Math.sqrt(population) * 1.35 + Math.sqrt(treasury) * 1.15 + avgLoyalty * 16 + tech * 24 - instability * 12 - agePressure * 6 ); return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5); } polityInfluenceRange(polity, wartime = false) { const power = this.polityPower(polity); const base = 18 + Math.sqrt(power) * 2.2; const range = wartime ? base * 1.15 : base * 0.75; return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42); } nearestPolityCity(polity, city) { if (!polity || !city) return null; let best = null; let bestDistance = Infinity; for (const source of this.getPolityCities(polity)) { const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city); if (distance < bestDistance) { best = source; bestDistance = distance; } } return best; } polityConnectionToCity(polity, city) { if (!polity || !city) return { source: null, distance: Infinity, connected: false }; let source = null; let distance = Infinity; for (const c of this.getPolityCities(polity)) { if (c.id === city.id) continue; const d = this.effectiveDistance(c, city); if (d < distance) { source = c; distance = d; } } const limit = SimConfig.polity.logisticsDistance; return { source, distance, connected: !!source && distance <= limit }; } polityDistance(a, b) { const aCities = this.getPolityCities(a); const bCities = this.getPolityCities(b); if (!aCities.length || !bCities.length) return Infinity; let best = Infinity; for (const aCity of aCities) { for (const bCity of bCities) { const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity); if (distance < best) best = distance; } } return best; } polityAge(polity) { return (this.year - (polity?.founded ?? this.year)) / WEEKS_PER_YEAR; } polityAgePressure(polity) { const age = this.polityAge(polity); if (age < 80) return 0; const x = (age - 80) / 420; return clamp(1 - Math.exp(-x), 0, 1.35); } polityOverextension(polity) { const cities = this.getPolityCities(polity); const center = this.getCityById(polity.centerCityId); if (!center || cities.length <= 1) return 0; const cityCountPressure = Math.max(0, cities.length - 5) * 0.08; let distanceSum = 0; let count = 0; for (const city of cities) { if (city.id === center.id) continue; distanceSum += this.effectiveDistance(center, city); count++; } const avgDistance = count ? distanceSum / count : 0; const distancePressure = Math.max(0, avgDistance - 28) * 0.008; return clamp(cityCountPressure + distancePressure, 0, 2); } polityResourceStress(polity) { const cities = this.getPolityCities(polity); if (!cities.length) return 1; let poor = 0; let supplyStress = 0; for (const city of cities) { const perCapita = city.storedResources / Math.max(1, city.population); if (perCapita < 0.06) poor++; supplyStress += city.supplyStress || 0; } const povertyRate = poor / cities.length; const avgSupplyStress = supplyStress / cities.length; const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length); const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0; return clamp(povertyRate * 0.55 + avgSupplyStress * 0.35 + treasuryStress * 0.28, 0, 1.5); } polityLogisticsStress(polity) { const cities = this.getPolityCities(polity); const center = this.getCityById(polity.centerCityId); if (!center || cities.length <= 1) return 0; let stress = 0; let count = 0; for (const city of cities) { if (city.id === center.id) continue; const distance = this.effectiveDistance(center, city); const food = city.storedResources / Math.max(1, city.population); const distanceStress = Math.max(0, distance - SimConfig.polity.logisticsDistance) * 0.012; const foodStress = Math.max(0, SimConfig.polity.minimumPerCapitaFood - food) * 4.5; const routeRelief = this.hasDirectTradeConnection(center, city) ? 0.72 : 1; stress += (distanceStress + foodStress + (city.supplyStress || 0) * 0.35) * routeRelief; count++; } return clamp(stress / Math.max(1, count), 0, 1.8); } polityEthnicFragmentation(polity) { const cities = this.getPolityCities(polity); if (cities.length <= 1) return 0; const counts = new Map(); for (const city of cities) { const e = this.dominantCityEthnicity(city); if (e !== null) counts.set(e, (counts.get(e) || 0) + 1); } if (!counts.size) return 0; let max = 0; for (const v of counts.values()) max = Math.max(max, v); const dominantShare = max / cities.length; return clamp(1 - dominantShare, 0, 1); } polityInstability(polity) { const agePressure = this.polityAgePressure(polity); const overextension = this.polityOverextension(polity); const resourceStress = this.polityResourceStress(polity); const logisticsStress = this.polityLogisticsStress(polity); const fragmentation = this.polityEthnicFragmentation(polity); const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85; const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.62; return clamp( agePressure * 0.35 + overextension * 0.24 + resourceStress * 0.38 + logisticsStress * 0.30 + fragmentation * 0.22 + (polity.crisis || 0) * 0.42 - legitimacyBuffer - cohesionBuffer, 0, 2.5 ); } foundPolities() { for (const city of this.cities) { if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue; const centerInfluence = this.cityInfluence(city); let absorbed = 0; for (const other of this.cities) { if (absorbed >= 3) break; if (other === city || other.polityId !== null) continue; if (this.distanceBetweenCities(city, other) > 34) continue; const targetInfluence = this.cityInfluence(other); if (centerInfluence <= targetInfluence * 1.08) continue; const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08); const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0; const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus; if (dominanceScore > 0.68 && this.rng.next() < 0.32) { const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId); const connection = this.polityConnectionToCity(polity, other); if (!connection.connected) continue; this.addCityToPolity(other, polity, 0.68); absorbed++; } } } } expandPolities() { for (const polity of this.polities) { const center = this.getCityById(polity.centerCityId); if (!center) continue; const centerInfluence = this.cityInfluence(center); let absorbed = 0; for (const city of this.cities) { if (absorbed >= 2) break; if (city.polityId !== null || city.id === center.id) continue; const connection = this.polityConnectionToCity(polity, city); if (!connection.connected) continue; const distance = connection.distance; const targetInfluence = this.cityInfluence(city); if (centerInfluence <= targetInfluence * 0.9) continue; const dominanceScore = (centerInfluence / (targetInfluence + 1)) * (1 / (1 + distance * 0.08)) * (this.hasDirectTradeConnection(connection.source, city) ? 1.5 : 1.0); if (dominanceScore > 0.62 && this.rng.next() < 0.24) { this.addCityToPolity(city, polity, 0.62); absorbed++; } } } } absorbIndependentCities() { if (this.year % years(5) !== 0) return; for (const polity of this.polities) { const cities = this.getPolityCities(polity); if (!cities.length) continue; const power = this.polityPower(polity); const range = this.polityInfluenceRange(polity, false); let absorbed = 0; const candidates = this.cities .filter(city => city.polityId === null && city.population > 0) .map(city => ({ city, connection: this.polityConnectionToCity(polity, city) })) .filter(x => x.connection.connected && x.connection.distance <= range) .sort((a, b) => a.connection.distance - b.connection.distance); for (const { city, connection } of candidates) { if (absorbed >= 1) break; const distance = connection.distance; const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1); const proximity = 1 / (1 + distance * 0.07); const pressure = (power / Math.max(1, cityInfluence)) * proximity; if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) { this.addCityToPolity(city, polity, 0.42); city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99))); absorbed++; } } } } maybeStartWars() { if (this.year % years(1) !== 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.canBeWarTarget(b.id)) continue; if (this.getWarBetween(a.id, b.id)) continue; const distance = this.polityDistance(a, b); if (distance > 46) continue; const bPower = this.polityPower(b); if (bPower <= 0) continue; const larger = Math.max(aPower, bPower); const smaller = Math.min(aPower, bPower); const advantage = larger / Math.max(1, smaller); const proximity = clamp((46 - distance) / 46, 0, 1); const aInstability = this.polityInstability ? this.polityInstability(a) : 0; const bInstability = this.polityInstability ? this.polityInstability(b) : 0; const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0; const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0; const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0; const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0; const aLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(a) : 0.5; const bLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5; const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1); const weakSideInstability = aPower > bPower ? bInstability : aInstability; const weakSideAge = aPower > bPower ? bAge : aAge; const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension; const weakSideLoyalty = aPower > bPower ? bLoyalty : aLoyalty; const lowLoyaltyPressure = clamp(1 - Math.min(aLoyalty, bLoyalty), 0, 1); const weakSideLowLoyalty = clamp(1 - weakSideLoyalty, 0, 1); const generalInstability = Math.max(aInstability, bInstability) * 0.035; const targetWarCount = this.warCountForPolity(b.id); const targetLowLoyalty = clamp(1 - bLoyalty, 0, 1); const attackerAdvantage = clamp((aPower / Math.max(1, bPower) - 1) / 2.25, 0, 1); const opportunisticPressure = targetWarCount > 0 ? clamp(targetWarCount / 3, 0, 1) * ( 0.010 + bInstability * 0.018 + targetLowLoyalty * 0.018 + attackerAdvantage * 0.020 ) : 0; const borderFriction = this.borderFriction(a, b, distance); const chance = 0.001 + proximity * 0.007 + asymmetryPressure * 0.008 + weakSideInstability * 0.018 + weakSideAge * 0.010 + weakSideOverextension * 0.010 + borderFriction * 0.008 + generalInstability + lowLoyaltyPressure * 0.018 + weakSideLowLoyalty * 0.028 + opportunisticPressure; if (this.rng.next() < clamp(chance, 0, 0.12)) { this.startWar(a, b); started = true; } } } } borderFriction(a, b, distance) { if (!Number.isFinite(distance)) return 0; const proximity = clamp((34 - distance) / 34, 0, 1); const aCities = this.getPolityCities(a).length; const bCities = this.getPolityCities(b).length; const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1); return proximity * (0.35 + sizePressure * 0.65); } updateWars() { if (this.year % years(2) !== 0) return; for (const war of this.wars) { if (war.ended !== null) continue; const a = this.getPolityById(war.aPolityId); const b = this.getPolityById(war.bPolityId); if (!a || !b) { this.endWar(war); continue; } this.applyWarPressure(war, a, b); this.applyWarPressure(war, b, a); this.applyWarExhaustion(war, a, b); this.maybeEndWar(war); } this.wars = this.wars.filter(w => w.ended === null); } applyWarPressure(war, attacker, defender) { const attackerCities = this.getPolityCities(attacker); const defenderCities = this.getPolityCities(defender); if (!attackerCities.length || !defenderCities.length) return; const attackerPower = this.polityPower(attacker); const defenderPower = this.polityPower(defender); if (attackerPower <= defenderPower * 1.05) return; const range = this.polityInfluenceRange(attacker, true); const candidates = defenderCities .filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2) .map(city => ({ city, connection: this.polityConnectionToCity(attacker, city) })) .filter(x => x.connection.connected && x.connection.distance <= range) .sort((a, b) => a.connection.distance - b.connection.distance); if (!candidates.length) return; const topCandidates = candidates.slice(0, 3); const selected = topCandidates[this.rng.int(topCandidates.length)]; const pressure = this.warAbsorptionPressure( attacker, defender, selected.city, selected.connection.distance, attackerPower, defenderPower ); const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45); if (pressure > 0.95 && this.rng.next() < chance) { this.captureCityInWar(selected.city, attacker, defender, war, pressure); } else { selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1); } } warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) { const powerRatio = attackerPower / Math.max(1, defenderPower); const proximity = 1 / (1 + distance * 0.055); const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0; const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0; const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0; const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1); const techAdvantage = 1 + clamp( this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender), -0.25, 0.45 ); const nearest = this.nearestPolityCity(attacker, city); const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92; const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0; return ( powerRatio * proximity * techAdvantage * ethnicityFactor * tradeFactor * (1 + defenderInstability * 0.32) * (1 + defenderAge * 0.18) * (1 + defenderOverextension * 0.14) * (0.65 + loyaltyWeakness * 0.72) ); } captureCityInWar(city, attacker, defender, war, pressure) { const lossRate = clamp( 0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025, 0.05, 0.22 ); const loss = Math.floor((city.population || 0) * lossRate); if (loss > 0) { city.population = Math.max(1, city.population - loss); if (typeof removeFromComposition === "function" && city.ethnicityComposition) { removeFromComposition(city.ethnicityComposition, loss); } this.deaths += Math.floor(loss * 0.35); } this.removeCityFromPolity(city); this.addCityToPolity(city, attacker, 0.28); city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36); war.lastActionYear = this.year; if (war.aPolityId === attacker.id) { war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1); war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1); } else { war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1); war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1); } attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015); attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5); defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5); defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1); } applyWarExhaustion(war, a, b) { const intensity = war.intensity || 0.75; const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035; const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035; a.treasury = Math.max(0, (a.treasury || 0) - costA); b.treasury = Math.max(0, (b.treasury || 0) - costB); a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5); b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5); war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1); war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1); } maybeEndWar(war) { const a = this.getPolityById(war.aPolityId); const b = this.getPolityById(war.bPolityId); if (!a || !b) { this.endWar(war); return; } const age = this.year - war.started; const noActionFor = this.year - war.lastActionYear; const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0); if (age > years(90)) { this.endWar(war); return; } if (age > years(10) && noActionFor > years(18)) { this.endWar(war); return; } if (exhaustion > 0.85 && this.rng.next() < 0.35) { this.endWar(war); } } collectAndRedistributeResources() { for (const polity of this.polities) { const cities = this.getPolityCities(polity); const centerId = polity.centerCityId; for (const city of cities) city.receivedAid = false; for (const city of cities) { if (city.id === centerId) continue; const tax = city.storedResources * 0.035; city.storedResources -= tax; polity.treasury += tax; } const agePressure = this.polityAgePressure(polity); const overextension = this.polityOverextension(polity); const adminCost = cities.length * 0.24 + overextension * 1.1 + agePressure * cities.length * 0.18; polity.treasury -= adminCost; if (polity.treasury < 0) { const deficit = Math.abs(polity.treasury); polity.treasury = 0; polity.crisis = clamp((polity.crisis || 0) + deficit * 0.012, 0, 1.5); } const maintenance = Math.pow(cities.length, 1.12) * 0.10; if (maintenance > 0) { const center = this.getCityById(centerId); const treasuryPayment = Math.min(polity.treasury, maintenance); polity.treasury -= treasuryPayment; const unpaid = maintenance - treasuryPayment; if (unpaid > 0 && center) { const paidByCenter = this.drainCityResources(center, unpaid); if (paidByCenter < unpaid) { for (const city of cities) { if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.012, 0, 1); } } } } const poorCount = Math.ceil(cities.length * 0.1); const poorest = [...cities] .sort((a, b) => (a.storedResources / Math.max(1, a.population)) - (b.storedResources / Math.max(1, b.population))) .slice(0, poorCount); for (const city of poorest) { const target = city.population * 0.08; const need = target - city.storedResources; if (need > 0 && polity.treasury > 0) { const aid = Math.min(need, polity.treasury); city.storedResources += aid; polity.treasury -= aid; city.receivedAid = true; city.loyalty = clamp(city.loyalty + 0.05, 0, 1); } } } } erodePolityLegitimacy() { for (const polity of this.polities) { const agePressure = this.polityAgePressure(polity); const resourceStress = this.polityResourceStress(polity); const overextension = this.polityOverextension(polity); const erosion = 0.0015 + agePressure * 0.003 + resourceStress * 0.003 + overextension * 0.0015; polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - erosion, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - erosion * 0.45, 0, 1); const cities = this.getPolityCities(polity); if (this.polityAge(polity) < 80 && polity.treasury > cities.length * 10) { polity.legitimacy = clamp(polity.legitimacy + 0.008, 0, 1); polity.cohesion = clamp(polity.cohesion + 0.005, 0, 1); } polity.crisis = clamp((polity.crisis || 0) * 0.88, 0, 1.5); } } triggerPolityCrises() { for (const polity of this.polities) { const age = this.polityAge(polity); if (age < 140) continue; if ((this.year - (polity.lastCrisisYear ?? 0)) / WEEKS_PER_YEAR < 160) continue; const instability = this.polityInstability(polity); const agePressure = this.polityAgePressure(polity); const chance = 0.006 + agePressure * 0.018 + instability * 0.014; if (this.rng.next() >= chance) continue; polity.lastCrisisYear = this.year; const severity = clamp( 0.08 + agePressure * this.rng.range(0.08, 0.20) + instability * this.rng.range(0.05, 0.16), 0.05, 0.36 ); polity.crisis = clamp((polity.crisis || 0) + severity, 0, 1.5); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - severity * 0.30, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - severity * 0.18, 0, 1); const center = this.getCityById(polity.centerCityId); for (const city of this.getPolityCities(polity)) { if (city.id === polity.centerCityId) continue; const distance = center ? this.effectiveDistance(center, city) : 40; const distanceFactor = clamp(distance / 40, 0.25, 1); city.loyalty = clamp(city.loyalty - severity * distanceFactor, 0, 1); } } } applyOldStateStress() { for (const polity of this.polities) { const age = this.polityAge(polity); if (age < 1000) continue; const cities = this.getPolityCities(polity); if (cities.length <= 1) continue; const oldAge = clamp((age - 1000) / 1000, 0, 1); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - oldAge * 0.018, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - oldAge * 0.012, 0, 1); polity.crisis = clamp((polity.crisis || 0) + oldAge * 0.035, 0, 1.5); const center = this.getCityById(polity.centerCityId); for (const city of cities) { if (city.id === polity.centerCityId) continue; const distance = center ? this.effectiveDistance(center, city) : 40; const distanceFactor = clamp(distance / 35, 0.2, 1); city.loyalty = clamp(city.loyalty - oldAge * distanceFactor * 0.045, 0, 1); } } } updateCityLoyalty() { for (const polity of this.polities) { const center = this.getCityById(polity.centerCityId); if (!center) continue; 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); const directCenterTrade = this.hasDirectTradeConnection(city, center); if (directCenterTrade) delta += 0.025; else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity); if (city.receivedAid) delta += 0.04; delta -= 0.004; if (city.storedResources < city.population * 0.05) delta -= 0.035; delta -= (city.supplyStress || 0) * 0.018; delta -= instability * 0.020; delta -= agePressure * 0.010; delta -= overextension * clamp(distance / 48, 0, 1) * 0.014; delta -= logisticsStress * clamp(distance / 42, 0.25, 1) * 0.018; if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.020; if ((polity.crisis || 0) > 0.6) delta -= 0.014; city.loyalty = clamp(city.loyalty + delta, 0, 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) { const rebellionLoyalty = city.loyalty ?? 0; this.removeCityFromPolity(city); this.addPolityEvent(polity.id, "rebellion", this.year, { cityId: city.id, population: city.population || 0, loyalty: rebellionLoyalty }, 3); } } } } enforcePolityContinuity(polity) { const center = this.getCityById(polity.centerCityId); const cities = this.getPolityCities(polity); if (!center || cities.length <= 1) return cities; const limit = SimConfig.polity.logisticsDistance; const reachable = new Set([center.id]); const frontier = [center]; while (frontier.length) { const source = frontier.pop(); for (const city of cities) { if (reachable.has(city.id)) continue; if (this.effectiveDistance(source, city) > limit) continue; reachable.add(city.id); frontier.push(city); } } const isolated = cities.filter(city => !reachable.has(city.id)); if (!isolated.length) return cities; for (const city of isolated) { this.removeCityFromPolity(city); city.loyalty = clamp(city.loyalty - 0.12, 0, 1); } polity.cohesion = clamp((polity.cohesion ?? 0.6) - isolated.length * 0.035, 0, 1); polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - isolated.length * 0.02, 0, 1); polity.crisis = clamp((polity.crisis || 0) + isolated.length * 0.025, 0, 1.5); return this.getPolityCities(polity); } detectPolityFamines() { const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06; for (const polity of this.polities) { const cities = this.getPolityCities(polity); if (!cities.length) continue; let poorCities = 0; let totalFood = 0; let totalPopulation = 0; for (const city of cities) { const population = Math.max(1, city.population || 0); const perCapita = (city.storedResources || 0) / population; if (perCapita < minimumFood) poorCities++; totalFood += city.storedResources || 0; totalPopulation += population; } const poorRate = poorCities / cities.length; const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation); const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035; if (!famine) continue; if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue; this.addPolityEvent(polity.id, "famine", this.year, { poorRate, avgPerCapitaFood }, poorRate > 0.65 ? 3 : 2); polity.lastFamineYear = this.year; } } cleanupPolities() { const survivors = []; for (const polity of this.polities) { const cities = this.getPolityCities(polity); if (!cities.length) { this.markPolityEnded(polity, "collapsed"); continue; } let center = this.getCityById(polity.centerCityId); if (!center || center.population <= 0) { const oldCenterCityId = polity.centerCityId; center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]); polity.centerCityId = center.id; center.loyalty = 1; if (oldCenterCityId !== center.id) { this.addPolityEvent(polity.id, "capitalShift", this.year, { oldCenterCityId, newCenterCityId: center.id }, 2); } polity.treasury *= 0.5; polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1); polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1); polity.crisis = clamp((polity.crisis || 0) + 0.35, 0, 1.5); polity.lastCrisisYear = this.year; for (const city of cities) { if (city.id !== center.id) city.loyalty = clamp(city.loyalty - 0.18, 0, 1); } const history = this.ensurePolityHistory(polity); if (history) history.centerCityId = center.id; } const connectedCities = this.enforcePolityContinuity(polity); if (connectedCities.length !== cities.length) { cities.length = 0; cities.push(...connectedCities); } if (cities.length === 1) { this.markPolityEnded(polity, "fragmented"); cities[0].polityId = null; cities[0].loyalty = 0.45; cities[0].receivedAid = false; continue; } survivors.push(polity); } this.polities = survivors; this.rebuildIndexes(); const validPolities = new Set(this.polities.map(p => p.id)); for (const city of this.cities) { if (city.polityId !== null && !validPolities.has(city.polityId)) { city.polityId = null; city.loyalty = 0.45; city.receivedAid = false; } } } updatePolities() { if (this.year % years(1) !== 0) return; this.cleanupPolities(); this.foundPolities(); this.expandPolities(); if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes(); this.collectAndRedistributeResources(); this.detectPolityFamines(); if (this.erodePolityLegitimacy) this.erodePolityLegitimacy(); if (this.triggerPolityCrises) this.triggerPolityCrises(); if (this.applyOldStateStress) this.applyOldStateStress(); if (this.updatePolityLeaders) this.updatePolityLeaders(); this.updateCityLoyalty(); this.absorbIndependentCities(); this.maybeStartWars(); this.updateWars(); this.splitUnloyalCities(); this.cleanupPolities(); if (this.samplePolityHistories) this.samplePolityHistories(); } reinforcePolityTradeRoutes() { 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; if (this.hasReservedRouteSegment(path, this.activeTradeRouteTiles || new Set())) 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; this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id); } } 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) { const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); const decay = pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 1 : SimConfig.route.routeUnsupportedDecay; w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - decay); if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0; } } this.tradeLinks = []; for (const city of this.cities) city.tradeLinks.clear(); const 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 > SimConfig.route.maxRouteLength - 1) continue; const distanceFactor = clamp((d - 14) / 30, 0, 1); const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1); const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0; const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias; pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore }); } } pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore); const candidates = []; for (const pair of pairCandidates.slice(0, maxPairsToRoute)) { const { c1, c2, distanceFactor, marketScale } = pair; const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y); if (!this.isValidRoutePath(path, c2.x, c2.y)) continue; const strength = this.routeStrengthForPath(path); const pheromoneSupport = this.routePheromoneSupport(path); const ownerPolityId = c1.polityId !== null && c1.polityId === c2.polityId ? c1.polityId : null; const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; if (strength < 0.08) continue; if (ownerPolityId === null && pheromoneSupport < SimConfig.route.routePheromoneBuild) continue; if (!this.canPayRouteConstructionCost(c1, c2, path, ownerPolity)) continue; const tradeScore = strength * (1 + distanceFactor * 0.7 + pheromoneSupport * 0.75 + (ownerPolityId !== null ? 0.28 : 0)) * marketScale; candidates.push({ c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId }); } 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, pheromoneSupport, ownerPolityId } = 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.hasReservedRouteSegment(path, supportedRoutes)) continue; const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; if (!this.payRouteConstructionCost(c1, c2, path, ownerPolity)) continue; const upkeepPaid = this.payRouteUpkeep(c1, c2, path, ownerPolity, pheromoneSupport); if (!upkeepPaid && pheromoneSupport < SimConfig.route.routePheromoneMaintain) { this.weakenRoutePath(path, SimConfig.route.routeUnsupportedDecay); continue; } const routeBoost = upkeepPaid ? SimConfig.route.routeMaintainedBoost : Math.floor(SimConfig.route.routeMaintainedBoost * 0.45); this.registerTradeLink(c1, c2, strength, path, supportedRoutes, routeBoost, 0, ownerPolityId); this.exchangeCityResources(c1, c2, strength, path); this.applyRouteConnectionBenefits(c1, c2, strength, pheromoneSupport, upkeepPaid); } for (let i = 0; i < w.count; i++) { if (w.tradeRoute[i] && !supportedRoutes.has(i)) { const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - (pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 2 : SimConfig.route.routeUnsupportedDecay)); if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0; } } this.activeTradeRouteTiles = new Set(); for (let i = 0; i < w.count; i++) { if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i); } this.diffuseCityKnowledgeThroughTrade(); } registerTradeLink(cityA, cityB, strength, path, supportedRoutes = null, routeBoost = 18, pheromoneBoost = 0, ownerPolityId = null) { cityA.tradeLinks.add(cityB.id); cityB.tradeLinks.add(cityA.id); this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path, ownerPolityId }); for (const tile of path) { if (supportedRoutes) supportedRoutes.add(tile); this.world.tradeRoute[tile] = Math.min(255, this.world.tradeRoute[tile] + routeBoost); this.addPheromone(tile, pheromoneBoost); } } diffuseCityKnowledgeThroughTrade() { for (const link of this.tradeLinks) { const a = this.getCityById(link.from); const b = this.getCityById(link.to); if (!a || !b) continue; a.knowledge ??= { farming: 0, metallurgy: 0 }; b.knowledge ??= { farming: 0, metallurgy: 0 }; const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + link.strength), 0, 0.012); const farmingDelta = (a.knowledge.farming - b.knowledge.farming) * rate; const metallurgyDelta = (a.knowledge.metallurgy - b.knowledge.metallurgy) * rate; a.knowledge.farming = clamp(a.knowledge.farming - farmingDelta, 0, 1); b.knowledge.farming = clamp(b.knowledge.farming + farmingDelta, 0, 1); a.knowledge.metallurgy = clamp(a.knowledge.metallurgy - metallurgyDelta, 0, 1); b.knowledge.metallurgy = clamp(b.knowledge.metallurgy + metallurgyDelta, 0, 1); } } findTerrainRoute(x1, y1, x2, y2) { const w = this.world; const path = []; const visited = new Set(); let x = x1; let y = y1; const maxSteps = Math.min(SimConfig.route.maxRouteLength, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 24); const directions = [ [1, 0], [-1, 0], [0, 1], [0, -1] ]; for (let step = 0; step < maxSteps; step++) { const current = w.idx(x, y); 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; } hasReservedRouteSegment(path, reservedTiles) { let previousReserved = false; for (let p = 1; p < path.length - 1; p++) { const reserved = reservedTiles.has(path[p]); if (reserved && previousReserved) return true; previousReserved = reserved; } return false; } routePheromoneSupport(path) { const w = this.world; if (!path.length) return 0; let support = 0; for (const i of path) support += clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); return support / path.length; } isValidRoutePath(path, targetX, targetY) { const w = this.world; if (!path.length) return false; const last = path[path.length - 1]; if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false; if (path.length > SimConfig.route.maxRouteLength) return false; let totalCost = 0; let hardTiles = 0; const 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); } routeUpkeepCost(path, pheromoneSupport) { const pheromoneRelief = clamp(pheromoneSupport / Math.max(0.001, SimConfig.route.routePheromoneMaintain), 0, 1) * 0.55; return path.length * SimConfig.route.routeUpkeepPerTile * (1 - pheromoneRelief); } payRouteUpkeep(cityA, cityB, path, polity = null, pheromoneSupport = 0) { const cost = this.routeUpkeepCost(path, pheromoneSupport); if (cost <= 0.001) return true; let remaining = cost; if (polity) { const paid = Math.min(polity.treasury || 0, remaining * 0.72); polity.treasury = Math.max(0, (polity.treasury || 0) - paid); remaining -= paid; } const cityShare = remaining * 0.5; remaining -= this.drainCityResources(cityA, cityShare); remaining -= this.drainCityResources(cityB, cityShare); if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining); if (remaining > 0 && polity) { const paid = Math.min(polity.treasury || 0, remaining); polity.treasury = Math.max(0, (polity.treasury || 0) - paid); remaining -= paid; } return remaining <= cost * 0.25; } weakenRoutePath(path, amount) { for (const tile of path) { this.world.tradeRoute[tile] = Math.max(0, this.world.tradeRoute[tile] - amount); if (this.world.tradeRoute[tile] < SimConfig.route.routeWeakThreshold) this.world.tradeRoute[tile] = 0; } } applyRouteConnectionBenefits(cityA, cityB, strength, pheromoneSupport, upkeepPaid) { const reliability = clamp(strength * 0.65 + pheromoneSupport * 0.35, 0, 1) * (upkeepPaid ? 1 : 0.55); if (reliability <= 0) return; const tradeValue = reliability * 0.18; cityA.tradeValue = (cityA.tradeValue || 0) + tradeValue; cityB.tradeValue = (cityB.tradeValue || 0) + tradeValue; cityA.tradeReach = Math.max(cityA.tradeReach || 0, reliability); cityB.tradeReach = Math.max(cityB.tradeReach || 0, reliability); const resourceBonus = reliability * 0.12; cityA.storedResources += resourceBonus; cityB.storedResources += resourceBonus; if (cityA.polityId !== null && cityA.polityId === cityB.polityId) { cityA.loyalty = clamp((cityA.loyalty ?? 0.5) + reliability * 0.002, 0, 1); cityB.loyalty = clamp((cityB.loyalty ?? 0.5) + reliability * 0.002, 0, 1); } } 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; } addPheromone(tile, amount) { if (tile < 0 || tile >= this.world.count || amount <= 0) return; this.world.pheromone[tile] = Math.min(SimConfig.route.maxPheromone, this.world.pheromone[tile] + amount); } depositRoutePheromone(path, amount) { for (const i of path) { this.addPheromone(i, amount); } } updateEthnicStats() { for (const e of this.ethnicities.values()) { e.population = 0; e.diversity = 0; e.centroidX = 0; e.centroidY = 0; 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; } } } } 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 = cityPolity(city); if (!polity) continue; const isGraphHover = graphState.hoverPolityId === polity.id; cityPolityColor.set(city.id, { color: polity.color, isGraphHover }); } } for (let i = 0; i < w.count; i++) { let color; if (mode === "resources") { const v = clamp(w.resource[i] / 30, 0, 1); color = mix([28, 36, 40], [107, 188, 85], v); } else if (mode === "ethnicity") { color = terrainInfo[w.terrain[i]].color; if (w.city[i] >= 0) { const city = sim.getCityById(w.city[i]); if (city) color = mix(color, cityMajorityColor(city), 0.72); } } 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 if (mode === "pheromone") { const v = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); color = mix(mix(terrainInfo[w.terrain[i]].color, [18, 20, 18], 0.58), [216, 177, 86], v); } else { color = terrainInfo[w.terrain[i]].color; } const p = i * 4; data[p] = color[0]; data[p + 1] = color[1]; data[p + 2] = color[2]; data[p + 3] = 255; } ctx.putImageData(image, 0, 0); renderDisasters(); drawTradeLinks(); drawAgentsAndCities(mode); drawGraphPolityHighlight(); maybeRenderStateGraph(); els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; } function renderDisasters() { if (!sim?.disasters?.length) return; sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires); if (!sim.disasters.length) return; ctx.save(); ctx.globalCompositeOperation = "source-over"; for (const disaster of sim.disasters) { const duration = Math.max(1, disaster.expires - disaster.year); const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1); const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22); if (alpha <= 0.005) continue; const innerRadius = Math.max(1, disaster.radius * 0.32); const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius); gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`); gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`); gradient.addColorStop(1, "rgba(211, 95, 84, 0)"); ctx.fillStyle = gradient; drawGraphCircle(ctx, disaster.x, disaster.y, disaster.radius); ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`; ctx.lineWidth = 1; strokeCircle(ctx, disaster.x, disaster.y, disaster.radius); } ctx.restore(); } function rgb(color, boost = 0) { return `rgb(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)})`; } function rgba(color, alpha, boost = 0) { return `rgba(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)}, ${alpha})`; } function fillSquare(g, x, y, radius) { g.fillRect(x - radius, y - radius, radius * 2 + 1, radius * 2 + 1); } function strokeLine(g, x1, y1, x2, y2) { g.beginPath(); g.moveTo(x1, y1); g.lineTo(x2, y2); g.stroke(); } function strokeCircle(g, x, y, r) { g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.stroke(); } function drawTradeLinks() { const w = sim.world; ctx.save(); const currentLinkTiles = new Set(); for (const link of sim.tradeLinks) { for (const tile of link.path || []) currentLinkTiles.add(tile); } ctx.globalAlpha = 0.24; ctx.fillStyle = "#d9b650"; for (const i of sim.activeTradeRouteTiles || []) { if (currentLinkTiles.has(i)) continue; 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(); if (mode !== "ethnicity") { for (const city of sim.cities) { const radius = cityRenderRadius(city); const color = cityDisplayColor(city, mode); ctx.fillStyle = rgba(color, 0.72); ctx.globalAlpha = 0.88; fillSquare(ctx, city.x, city.y, radius); } } ctx.globalAlpha = 1; if (mode === "technology") { for (const a of sim.agents) { sim.ensureAgentTech(a); const farming = techLevel(a, "farming"); const metallurgy = techLevel(a, "metallurgy"); const tech = clamp(Math.max(farming, metallurgy), 0, 1); if (tech <= 0.01) continue; const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy)); ctx.fillStyle = rgba(color, clamp(0.32 + tech * 0.68, 0.32, 1)); ctx.fillRect(a.x, a.y, 1, 1); } } else if (mode === "ethnicity") { for (const a of sim.agents) { const e = sim.ethnicities.get(a.ethnicity); if (!e) continue; ctx.fillStyle = rgb(e.color); ctx.fillRect(a.x, a.y, 1, 1); } } else { ctx.fillStyle = "#eeeccf"; for (const tile of sim.tileAgents.keys()) { ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1); } } for (const city of sim.cities) { if (mode === "ethnicity") { const radius = cityRenderRadius(city); const color = cityDisplayColor(city, mode); ctx.globalAlpha = 0.90; ctx.fillStyle = rgba(color, 0.78); fillSquare(ctx, city.x, city.y, radius); ctx.globalAlpha = 1; } const color = cityDisplayColor(city, mode); ctx.fillStyle = rgb(color, 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(color, 72); ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); ctx.globalAlpha = 0.32; ctx.fillStyle = rgb(color); 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 cityPolity(city) { return city?.polityId != null ? sim.getPolityById(city.polityId) : null; } function cityDisplayColor(city, mode) { if (mode === "polities") { const polity = cityPolity(city); if (polity) return polity.color; return [218, 205, 154]; } return cityMajorityColor(city); } function techLevel(holder, key) { return holder?.tech?.[key] || 0; } function knowledgeLevel(city, key) { return city?.knowledge?.[key] || 0; } 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 tooltipRow(label, value, show = true) { return show ? `${label}${value}` : ""; } function tooltipSection(title, items) { const body = items.filter(Boolean).join(""); return body ? `

${title}

${body}
` : ""; } 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 = cityPolity(city); const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-"; els.tooltip.innerHTML = ` ${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name} ${tooltipSection("Tile", [ tooltipRow("Position", `${x}, ${y}`), tooltipRow("Terrain", terrain.name), tooltipRow("Resources", w.resource[i].toFixed(1)), tooltipRow("Fertility", w.fertility[i].toFixed(2)), tooltipRow("Minerals", w.mineral[i].toFixed(2)), tooltipRow("Temp / humid", `${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}`), tooltipRow("Water", waterInfluence), tooltipRow("Pressure", w.pressure[i].toFixed(0)), tooltipRow("Pheromone", w.pheromone[i].toFixed(1)), tooltipRow("Route", w.tradeRoute[i], w.tradeRoute[i]) ])} ${tooltipSection("City & State", [ tooltipRow("Population", city?.population.toLocaleString(), city), tooltipRow("Food stock", city?.storedResources.toFixed(1), city), tooltipRow("Supply stress", (city?.supplyStress || 0).toFixed(2), city), tooltipRow("Trade", city ? `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value` : "", city), tooltipRow("Knowledge", city ? `${knowledgeLevel(city, "farming").toFixed(2)} farm / ${knowledgeLevel(city, "metallurgy").toFixed(2)} metal` : "", city), tooltipRow("City majority", `E${cityEthnicity}`, cityEthnicity), city ? cityEthnicityPie(city) : "", tooltipRow("State", polity ? `#${polity.id}` : "Independent", city), tooltipRow("Loyalty", city?.loyalty.toFixed(2), city), tooltipRow("Charisma", clamp(polity?.charisma ?? 1, 0.5, 1.5).toFixed(2), polity), tooltipRow("Leader tenure", polity ? `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y` : "", polity), tooltipRow("Treasury", polity?.treasury.toFixed(1), polity), tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity) ])} ${tooltipSection("Culture & Agent", [ tooltipRow("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`), tooltipRow("Agent ethnicity", `E${agent?.ethnicity}`, agent), tooltipRow("Lineage pop", ethnicity?.population, ethnicity), tooltipRow("Climate pref", ethnicity ? `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : "", ethnicity), tooltipRow("Climate mismatch", mismatch.toFixed(2), agent), tooltipRow("Sedentary", agent ? agent.traits.sedentary.toFixed(2) : "", agent), tooltipRow("Ethnocentrism", agent ? agent.traits.ethnocentrism.toFixed(2) : "", agent), tooltipRow("Farming", agent ? `${techLevel(agent, "farming").toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : "", agent), tooltipRow("Metallurgy", techLevel(agent, "metallurgy").toFixed(3), agent), tooltipRow("Stored", agent?.resources.toFixed(1), agent) ])} `; 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 cityEthnicityPie(city) { if (!city?.ethnicityComposition?.size) return ""; const entries = [...city.ethnicityComposition] .filter(([, count]) => count > 0) .sort((a, b) => b[1] - a[1]); const total = entries.reduce((sum, [, count]) => sum + count, 0); if (total <= 0) return ""; const top = entries.slice(0, 5); const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0); const slices = other > 0 ? [...top, [0, other]] : top; let cursor = 0; const gradient = slices.map(([id, count]) => { const start = cursor / total * 100; cursor += count; const end = cursor / total * 100; const ethnicity = id ? sim.ethnicities.get(id) : null; const color = ethnicity?.color || [122, 130, 126]; return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`; }).join(", "); const labels = slices.map(([id, count]) => { const ethnicity = id ? sim.ethnicities.get(id) : null; const color = ethnicity?.color || [122, 130, 126]; const label = id ? `E${id}` : "Other"; return `${label} ${Math.round(count / total * 100)}%`; }).join(""); return `
${labels}
`; } 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 += techLevel(a, "farming"); metallurgyTotal += techLevel(a, "metallurgy"); if (techLevel(a, "farming") > 0.02) farmingHolders++; if (techLevel(a, "metallurgy") > 0.02) metallurgyHolders++; } const agentCount = Math.max(1, sim.agents.length); const stats = { year: formatSimDate(sim.year), activeGroups: sim.agents.length.toLocaleString(), urbanPopulation: Math.floor(urbanPopulation).toLocaleString(), ethnicities: livingEthnicities.length.toLocaleString(), cities: sim.cities.length.toLocaleString(), polities: sim.polities.length.toLocaleString(), wars: sim.wars.length.toLocaleString(), routes: sim.tradeLinks.length.toLocaleString(), farmingKnowledge: `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`, metallurgyKnowledge: `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`, deaths: sim.deaths.toLocaleString() }; for (const [id, value] of Object.entries(stats)) { if (els[id]) els[id].textContent = value; } 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; els.legend.innerHTML = (legendByMode[mode] || legendByMode.resources)(); } 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; } const graphMetrics = { power: { value: sampleGraphPower, format: value => value.toFixed(1) }, population: { value: sample => sample?.population || 0, format: value => Math.round(value).toLocaleString() }, cities: { value: sample => sample?.cities || 0, format: value => Math.round(value).toLocaleString() }, loyalty: { value: sample => sample?.avgLoyalty ?? 0.5, format: value => `${Math.round(clamp(value, 0, 1) * 100)}%` } }; function graphSampleValue(sample, metric) { return (graphMetrics[metric] || graphMetrics.power).value(sample); } function formatGraphValue(value, metric) { return (graphMetrics[metric] || graphMetrics.power).format(value); } function renderStateGraph() { const canvas = els.stateGraph; if (!canvas || !sim) return; const rect = canvas.getBoundingClientRect(); if (!rect.width) return; const historyWindowSpan = years(SimConfig.render.historyWindowYears); const historyWindowEnd = sim.year; const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan); graphState.filter = els.historyFilter?.value || graphState.filter; graphState.sort = els.historySort?.value || graphState.sort; graphState.metric = els.historyMetric?.value || graphState.metric; const histories = sim.getAllPolityHistories() .filter(history => (history.ended ?? sim.year) >= historyWindowStart) .map(history => ({ ...history, visibleFounded: Math.max(history.founded ?? 0, historyWindowStart), visibleSamples: (history.samples || []).filter(sample => sample.year >= historyWindowStart) })) .filter(history => history.visibleSamples.length > 0 || (history.ended ?? sim.year) >= historyWindowStart) .filter(history => { const isActive = history.active && history.ended === null; if (graphState.filter === "active" && !isActive) return false; if (graphState.filter === "ended" && isActive) return false; if (isActive && !graphState.showActive) return false; if (!isActive && !graphState.showPast) return false; return true; }); const paddingLeft = 34; const paddingRight = 8; const paddingTop = 18; const paddingBottom = 22; const rowHeight = 24; const groupHeight = 18; const scored = histories.map(history => { const samples = history.visibleSamples.length ? history.visibleSamples : [{ population: 0, cities: 0, avgLoyalty: 0.5 }]; const peakPower = Math.max(...samples.map(sample => sampleGraphPower(sample))); const peakPopulation = Math.max(...samples.map(sample => sample.population || 0)); const peakCities = Math.max(...samples.map(sample => sample.cities || 0)); const peakLoyalty = Math.max(...samples.map(sample => sample.avgLoyalty ?? 0.5)); const peakMetric = Math.max(...samples.map(sample => graphSampleValue(sample, graphState.metric))); const lifespan = (history.ended ?? sim.year) - history.visibleFounded; const importance = peakPower * 1.6 + peakCities * 130 + Math.sqrt(Math.max(0, peakPopulation)) * 6 + lifespan * 0.35; return { history, peakPopulation, peakCities, peakLoyalty, peakPower, peakMetric, importance }; }); const compareRows = (a, b) => { if (graphState.sort === "oldest") { return ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id); } if (graphState.sort === "strongest") { return (b.peakPower - a.peakPower) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)); } if (graphState.sort === "largest") { return (b.peakPopulation - a.peakPopulation) || (b.peakCities - a.peakCities); } const activeDelta = (b.history.active ? 1 : 0) - (a.history.active ? 1 : 0); if (graphState.sort === "active") { return activeDelta || (b.importance - a.importance) || (a.history.id - b.history.id); } return activeDelta || (b.importance - a.importance) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id); }; const selected = scored.sort(compareRows).slice(0, 90).sort(compareRows); const activeRows = selected.filter(item => item.history.active && item.history.ended === null); const pastRows = selected.filter(item => !(item.history.active && item.history.ended === null)); const displayRows = []; if (graphState.showActive && graphState.filter !== "ended" && activeRows.length) { displayRows.push({ type: "group", label: `Living ${activeRows.length}` }); for (const item of activeRows) displayRows.push({ type: "state", item }); } if (graphState.showPast && graphState.filter !== "active" && pastRows.length) { displayRows.push({ type: "group", label: `Past ${pastRows.length}` }); for (const item of pastRows) displayRows.push({ type: "state", item }); } const logicalHeight = paddingTop + displayRows.reduce((sum, row) => sum + (row.type === "group" ? groupHeight : rowHeight), 0) + paddingBottom; canvas.style.height = `${logicalHeight}px`; const updatedRect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const width = Math.max(1, Math.floor(updatedRect.width * dpr)); const height = Math.max(1, Math.floor(logicalHeight * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } const g = canvas.getContext("2d"); g.setTransform(dpr, 0, 0, dpr, 0, 0); const w = updatedRect.width; const h = logicalHeight; g.clearRect(0, 0, w, h); g.fillStyle = "#101315"; g.fillRect(0, 0, w, h); if (!histories.length || !selected.length || !displayRows.length) { if (els.historyRange) els.historyRange.textContent = "-"; graphState.rows = []; graphState.markers = []; graphState.scale = null; g.fillStyle = "#7f8984"; g.font = "12px ui-sans-serif, system-ui, sans-serif"; g.fillText("No state history yet", 16, 28); return; } let minYear = historyWindowStart; let maxYear = historyWindowEnd; for (const item of selected) { const history = item.history; minYear = Math.min(minYear, history.visibleFounded); maxYear = Math.max(maxYear, history.ended ?? sim.year); } if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`; const plotWidth = Math.max(1, w - paddingLeft - paddingRight); const yearToX = year => paddingLeft + ((clamp(year, minYear, maxYear) - minYear) / Math.max(1, maxYear - minYear)) * plotWidth; const xToYear = x => minYear + ((clamp(x, paddingLeft, w - paddingRight) - paddingLeft) / plotWidth) * Math.max(1, maxYear - minYear); const rows = []; let cursorY = paddingTop; for (const displayRow of displayRows) { if (displayRow.type === "group") { rows.push({ ...displayRow, y: cursorY + groupHeight * 0.5, height: groupHeight }); cursorY += groupHeight; continue; } const item = displayRow.item; rows.push({ ...item, type: "state", y: cursorY + rowHeight * 0.5, height: rowHeight, color: item.history.color || hslToRgb((item.history.id * 0.38196601125) % 1, 0.58, 0.62), samples: (item.history.visibleSamples || []) .filter(sample => Number.isFinite(sample.year)) .sort((a, b) => a.year - b.year) }); cursorY += rowHeight; } let minMetric = Infinity; let maxMetric = -Infinity; let minCities = Infinity; let maxCities = -Infinity; for (const row of rows.filter(row => row.type === "state")) { for (const sample of row.samples) { const value = graphSampleValue(sample, graphState.metric); minMetric = Math.min(minMetric, value); maxMetric = Math.max(maxMetric, value); minCities = Math.min(minCities, sample.cities || 0); maxCities = Math.max(maxCities, sample.cities || 0); } } if (!Number.isFinite(minMetric) || !Number.isFinite(maxMetric)) { minMetric = 0; maxMetric = 1; } if (!Number.isFinite(minCities) || !Number.isFinite(maxCities)) { minCities = 0; maxCities = 1; } graphState.rows = rows.filter(row => row.type === "state"); graphState.markers = []; graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear }; const axisY = h - paddingBottom + 4; g.strokeStyle = "rgba(168, 177, 170, 0.22)"; g.lineWidth = 1; strokeLine(g, paddingLeft, axisY, w - paddingRight, axisY); 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); const topEvents = (sim.graphEvents || []) .filter(event => event.importance >= 2) .filter(event => event.year >= minYear && event.year <= maxYear) .filter(event => graphEventTypes[event.type]); const topCollisions = new Map(); for (const event of topEvents) { const x = yearToX(event.year); const bucket = Math.round(x / 7); const count = topCollisions.get(bucket) || 0; topCollisions.set(bucket, count + 1); const y = Math.max(7, paddingTop * 0.5 + ([-3, 0, 3][count % 3])); const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3; const graphEvent = graphEventTypes[event.type]; g.fillStyle = graphEvent.color; graphEvent.draw(g, x, y, r); graphState.markers.push({ row: null, event, x, y, radius: r + 3 }); } rows.forEach(row => { const alpha = row.type === "group" ? 0.16 : 0.12; 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, ${alpha})`; g.lineWidth = 1; strokeLine(g, paddingLeft, row.y, w - paddingRight, row.y); }); 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)})`; strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y); g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${alpha})`; g.lineWidth = Math.max(2, thickness * 0.62); strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y); g.lineCap = "butt"; }; if (row.samples.length > 1) { if (history.visibleFounded < row.samples[0].year) { strokeSegment(history.visibleFounded, row.samples[0].year, graphSampleValue(row.samples[0], graphState.metric), row.samples[0].cities, 0.42); } for (let i = 0; i < row.samples.length - 1; i++) { const sampleA = row.samples[i]; const sampleB = row.samples[i + 1]; strokeSegment( sampleA.year, sampleB.year, (graphSampleValue(sampleA, graphState.metric) + graphSampleValue(sampleB, graphState.metric)) * 0.5, ((sampleA.cities || 0) + (sampleB.cities || 0)) * 0.5 ); } const lastSample = row.samples[row.samples.length - 1]; const endYear = history.ended ?? sim.year; if (lastSample.year < endYear) { strokeSegment(lastSample.year, endYear, graphSampleValue(lastSample, graphState.metric), lastSample.cities, 0.55); } } else { const sample = row.samples[0] || { population: 0, cities: row.peakCities, avgLoyalty: 0.5 }; strokeSegment(history.visibleFounded, history.ended ?? sim.year, graphSampleValue(sample, graphState.metric), sample.cities || row.peakCities); } }); rows.forEach(row => { if (row.type !== "state") return; const events = (row.history.events || []) .filter(event => event.importance >= 2) .filter(event => event.year >= minYear && event.year <= maxYear) .filter(event => graphEventTypes[event.type]); const collisions = new Map(); for (const event of events) { const x = yearToX(event.year); const bucket = Math.round(x / 7); const count = collisions.get(bucket) || 0; collisions.set(bucket, count + 1); const y = row.y + ([-4, 0, 4][count % 3]); const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3; const graphEvent = graphEventTypes[event.type]; g.fillStyle = graphEvent.color; graphEvent.draw(g, x, y, r); graphState.markers.push({ row, event, x, y, radius: r + 3 }); } }); } function drawGraphCircle(g, x, y, r) { g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fill(); } function drawGraphPolygon(g, points) { g.beginPath(); g.moveTo(points[0][0], points[0][1]); for (let i = 1; i < points.length; i++) g.lineTo(points[i][0], points[i][1]); g.closePath(); g.fill(); } function drawGraphDiamond(g, x, y, r) { drawGraphPolygon(g, [[x, y - r], [x + r, y], [x, y + r], [x - r, y]]); } function drawGraphTriangleUp(g, x, y, r) { drawGraphPolygon(g, [[x, y - r], [x + r, y + r], [x - r, y + r]]); } function drawGraphTriangleDown(g, x, y, r) { drawGraphPolygon(g, [[x, y + r], [x + r, y - r], [x - r, y - r]]); } const graphEventTypes = { capitalShift: { label: "capital shift", color: "rgba(227, 179, 65, 0.95)", draw: drawGraphDiamond, summary: data => `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}` }, disaster: { label: "major disaster", color: "rgba(211, 95, 84, 0.95)", draw: drawGraphCircle, summary: data => `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities` }, famine: { label: "famine", color: "rgba(227, 128, 65, 0.90)", draw: drawGraphTriangleDown, summary: data => `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap` }, rebellion: { label: "rebellion", color: "rgba(236, 126, 111, 0.95)", draw: drawGraphTriangleUp, summary: data => `city #${data.cityId ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}, loyalty ${Number(data.loyalty || 0).toFixed(2)}` }, newEthnicity: { label: "new ethnicity", color: "rgba(105, 181, 120, 0.95)", draw: drawGraphDiamond, summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier groups` } }; function graphEventLabel(event) { return graphEventTypes[event.type]?.label || event.type; } function graphEventSummary(event) { return graphEventTypes[event.type]?.summary(event.data || {}) || ""; } function showStateGraphMarkerTooltip(marker, pointerX, pointerY) { if (!els.stateGraphTooltip) return; const title = marker.row ? `S${marker.row.history.id} ${graphEventLabel(marker.event)}` : graphEventLabel(marker.event); els.stateGraphTooltip.innerHTML = ` ${title} ${formatGraphYear(marker.event.year)} ${graphEventSummary(marker.event)} `; els.stateGraphTooltip.hidden = false; const scroll = els.stateGraph.parentElement; const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8); const left = clamp(pointerX + 10, 8, maxLeft); const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10); els.stateGraphTooltip.style.left = `${left}px`; els.stateGraphTooltip.style.top = `${top}px`; } function hideStateGraphMarkerTooltip() { if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true; } function updateStateGraphInfo(event) { if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return; const y = event.offsetY; const x = event.offsetX; const marker = (graphState.markers || []) .map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) })) .filter(item => item.distance <= item.candidate.radius); const nearestMarker = bestBy(marker, item => -item.distance)?.candidate; if (nearestMarker) { if (!nearestMarker.row) { els.stateGraphInfo.textContent = `${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`; showStateGraphMarkerTooltip(nearestMarker, x, y); if (graphState.hoverPolityId !== null) { graphState.hoverPolityId = null; if (!running) render(); } return; } const status = nearestMarker.row.history.active && nearestMarker.row.history.ended === null ? "living" : "past"; els.stateGraphInfo.textContent = `S${nearestMarker.row.history.id} ${status} ${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`; showStateGraphMarkerTooltip(nearestMarker, x, y); if (graphState.hoverPolityId !== nearestMarker.row.history.id) { graphState.hoverPolityId = nearestMarker.row.history.id; if (!running) render(); } return; } const row = graphState.rows .map(candidate => ({ candidate, distance: Math.abs(candidate.y - y) })) .filter(item => item.distance <= item.candidate.height * 0.5); const nearestRow = bestBy(row, item => -item.distance)?.candidate; if (!nearestRow) { els.stateGraphInfo.textContent = "Hover a row for details"; hideStateGraphMarkerTooltip(); if (graphState.hoverPolityId !== null) { graphState.hoverPolityId = null; if (!running) render(); } return; } if (graphState.hoverPolityId !== nearestRow.history.id) { graphState.hoverPolityId = nearestRow.history.id; if (!running) render(); } hideStateGraphMarkerTooltip(); const year = graphState.scale.xToYear(x); const samples = nearestRow.samples.length ? nearestRow.samples : [{ year, population: 0, cities: nearestRow.peakCities, avgLoyalty: 0.5, power: nearestRow.peakPower }]; const sample = bestBy(samples, candidate => -Math.abs((candidate.year ?? year) - year)); const metricValue = graphSampleValue(sample, graphState.metric); const status = nearestRow.history.active && nearestRow.history.ended === null ? "living" : "past"; els.stateGraphInfo.textContent = `S${nearestRow.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` + `${graphState.metric} ${formatGraphValue(metricValue, graphState.metric)} ` + `pop ${Math.round(sample.population || 0).toLocaleString()} ` + `cities ${Math.round(sample.cities || 0).toLocaleString()} ` + `loyalty ${formatGraphValue(sample.avgLoyalty ?? 0.5, "loyalty")}`; } function clearStateGraphInfo() { if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details"; hideStateGraphMarkerTooltip(); if (graphState.hoverPolityId !== null) { graphState.hoverPolityId = null; if (!running) render(); } } function formatGraphYear(week) { return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}y`; } function formatSimDate(week) { const year = Math.floor(week / WEEKS_PER_YEAR); const weekOfYear = week % WEEKS_PER_YEAR; const monthOfYear = Math.floor(weekOfYear / WEEKS_PER_MONTH) + 1; const weekOfMonth = weekOfYear % WEEKS_PER_MONTH + 1; return `${year.toLocaleString()}y ${monthOfYear}m ${weekOfMonth}w`; } function requestRender() { renderDirty = true; } function drawFrame(forceStats = false) { render(); updateStats(forceStats); renderTooltip(); renderDirty = false; lastRenderAt = performance.now(); } function scheduleLoop() { if (loopScheduled) return; loopScheduled = true; const delay = document.hidden ? LoopConfig.hiddenPollMs : running ? 0 : LoopConfig.idlePollMs; if (delay > 0) { loopTimer = setTimeout(() => { loopTimer = null; requestAnimationFrame(loop); }, delay); } else { requestAnimationFrame(loop); } } function loop(now = performance.now()) { loopScheduled = false; if (running && !document.hidden && now - lastSimTickAt >= LoopConfig.simTickMs) { lastSimTickAt = now; const steps = Number(els.speed.value); const stepBudget = LoopConfig.stepBudgetMs * Math.max(1, steps); const loopStart = performance.now(); let completedSteps = 0; for (let i = 0; i < steps; i++) { sim.step(); completedSteps++; if (completedSteps > 0 && performance.now() - loopStart > stepBudget) break; } if (completedSteps > 0) requestRender(); } const shouldRender = !document.hidden && (renderDirty || (running && now - lastRenderAt >= LoopConfig.renderMs)); if (shouldRender) drawFrame(); scheduleLoop(); } function reset() { const size = Number(els.worldSize.value); const count = Number(els.agentCount.value); els.canvas.width = size; els.canvas.height = size; renderImage = null; renderImageSize = 0; sim = new Simulation(size, count); setLegend(); drawFrame(true); renderStateGraph(); } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function bestBy(items, score) { let best = null; let bestScore = -Infinity; for (const item of items) { const value = score(item); if (value > bestScore) { best = item; bestScore = value; } } return best; } function smoothstep(t) { return t * t * (3 - 2 * t); } const traitFields = [ ["mobility", traits => traits.mobility, 0.02, 1, 1, 1], ["resourceAttraction", traits => traits.resourceAttraction, 0.05, 1.2, 1, 1], ["assimilation", traits => traits.assimilation, 0, 0.75, 1, 1], ["ethnocentrism", traits => traits.ethnocentrism, 0, 1.2, 1, 1], ["reproductionThreshold", traits => traits.reproductionThreshold, 12, 48, 16, 1 / 48], ["sedentary", traits => traits.sedentary, 0, 1, 1, 1] ]; function mutateTraits(traits, rng, amount) { return Object.fromEntries(traitFields.map(([key, get, min, max, mutationScale]) => [ key, clamp(get(traits) + rng.range(-amount * mutationScale, amount * mutationScale), min, max) ])); } function blendTraits(a, b, t) { return Object.fromEntries(traitFields.map(([key, get]) => [key, lerp(get(a), get(b), t)])); } function emptyTraitSums() { return Object.fromEntries(traitFields.map(([key]) => [key, 0])); } function addTraits(sum, traits) { for (const [key, get, , , , sumScale] of traitFields) sum[key] += get(traits) * sumScale; } function averageTraits(sum, count) { return Object.fromEntries(traitFields.map(([key, , , , , sumScale]) => [key, sum[key] / count / sumScale])); } function compositionTotal(composition) { return [...composition.values()].reduce((sum, value) => sum + value, 0); } function addBirthsToComposition(composition, births, rng = null) { const entries = [...composition.entries()]; const total = entries.reduce((sum, [, value]) => sum + value, 0); if (!total || births <= 0) return; for (let n = 0; n < births; n++) { let r = (rng ? rng.next() : Math.random()) * total; for (const [id, count] of entries) { r -= count; if (r <= 0) { composition.set(id, (composition.get(id) || 0) + 1); break; } } } } function removeFromComposition(composition, loss) { let remaining = loss; const total = compositionTotal(composition); 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 traitFields.reduce((sum, [, get, , , , distanceScale]) => sum + Math.abs(get(a) - get(b)) * distanceScale, 0); } 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 refreshStateGraph() { renderStateGraph(); clearStateGraphInfo(); } els.toggleRun.addEventListener("click", () => { running = !running; els.toggleRun.textContent = running ? "Pause" : "Run"; lastSimTickAt = 0; if (running && loopTimer) { clearTimeout(loopTimer); loopTimer = null; loopScheduled = false; scheduleLoop(); } else if (running) { scheduleLoop(); } }); els.stepOnce.addEventListener("click", () => { sim.step(); requestRender(); drawFrame(true); renderStateGraph(); }); els.resetWorld.addEventListener("click", reset); for (const control of [els.worldSize, els.agentCount]) control.addEventListener("change", reset); els.viewMode.addEventListener("change", () => { setLegend(); requestRender(); drawFrame(); }); els.canvas.addEventListener("mousemove", showTooltip); els.canvas.addEventListener("mouseleave", hideTooltip); for (const control of [els.historyFilter, els.historySort, els.historyMetric]) { control?.addEventListener("change", refreshStateGraph); } els.toggleActiveStates?.addEventListener("click", () => { graphState.showActive = !graphState.showActive; els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive)); refreshStateGraph(); }); els.togglePastStates?.addEventListener("click", () => { graphState.showPast = !graphState.showPast; els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast)); refreshStateGraph(); }); els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo); els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo); document.addEventListener("visibilitychange", () => { lastSimTickAt = 0; requestRender(); if (!document.hidden && loopTimer) { clearTimeout(loopTimer); loopTimer = null; loopScheduled = false; scheduleLoop(); } }); reset(); loop();