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 els = { canvas: document.getElementById("world"), sim: document.querySelector(".sim"), toggleRun: document.getElementById("toggleRun"), stepOnce: document.getElementById("stepOnce"), resetWorld: document.getElementById("resetWorld"), saveWorld: document.getElementById("saveWorld"), loadWorld: document.getElementById("loadWorld"), clearSave: document.getElementById("clearSave"), speed: document.getElementById("speed"), agentCount: document.getElementById("agentCount"), worldSize: document.getElementById("worldSize"), viewMode: document.getElementById("viewMode"), year: document.getElementById("year"), population: document.getElementById("population"), activeGroups: document.getElementById("activeGroups"), urbanPopulation: document.getElementById("urbanPopulation"), ethnicities: document.getElementById("ethnicities"), cities: document.getElementById("cities"), polities: document.getElementById("polities"), routes: document.getElementById("routes"), deaths: document.getElementById("deaths"), frameCost: document.getElementById("frameCost"), ethnicityList: document.getElementById("ethnicityList"), legend: document.getElementById("legend"), tooltip: document.getElementById("tooltip") }; const ctx = els.canvas.getContext("2d", { alpha: false }); let sim; let running = true; let frame = 0; let lastStatsAt = 0; let hoverState = null; class Rng { constructor(seed) { this.seed = seed >>> 0; } next() { this.seed = (1664525 * this.seed + 1013904223) >>> 0; return this.seed / 4294967296; } range(min, max) { return min + (max - min) * this.next(); } int(max) { return Math.floor(this.next() * max); } } class World { constructor(size, rng) { 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.city.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.polities = []; this.tradeLinks = []; this.nextEthnicity = 1; this.nextCity = 1; this.nextPolity = 1; this.year = 0; this.deaths = 0; this.maxAgents = Math.max(initialAgents * 1.25, 30000); this.tileEthnicities = 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 }; } findNearbySpawn(originX, originY, preferredTerrain = null) { const w = this.world; let best = { x: originX, y: originY }; let bestScore = -Infinity; for (let tries = 0; tries < 24; tries++) { const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1); const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1); const i = w.idx(x, y); if (w.terrain[i] === Terrain.WATER) continue; const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0; const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16; if (score > bestScore) { bestScore = score; best = { x, y }; } } return best; } findHabitableTile(preferredTerrain = null) { if (preferredTerrain !== null) { const exact = []; let best = null; let bestScore = -Infinity; for (let i = 0; i < this.world.count; i++) { const x = i % this.world.size; const y = Math.floor(i / this.world.size); if (this.world.terrain[i] === preferredTerrain) { exact.push({ x, y }); continue; } if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) { const score = this.world.temperature[i] * (1 - this.world.humidity[i]); if (score > bestScore) { bestScore = score; best = { x, y }; } } } if (exact.length) return exact[this.rng.int(exact.length)]; if (best) return best; } for (let tries = 0; tries < 5000; tries++) { const x = this.rng.int(this.world.size); const y = this.rng.int(this.world.size); const i = this.world.idx(x, y); if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) { return { x, y }; } } return { x: this.world.size >> 1, y: this.world.size >> 1 }; } step() { for (const city of this.cities) city.activeVisitors = 0; this.rebuildOccupancy(); const offspring = []; for (const a of this.agents) { if (!a.alive) continue; this.moveAgent(a); this.gatherConsumeReproduce(a, offspring); this.resolveAssimilation(a); } this.agents = this.agents.filter(a => a.alive); if (this.agents.length + offspring.length < this.maxAgents) { this.agents.push(...offspring); } else { this.agents.push(...offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length))); } this.updateWorldFields(); this.rebuildOccupancy(); if (this.year % 5 === 0) { this.updateCities(); this.updateTradeRoutes(); } this.updatePolities(); this.updateEthnicStats(); if (this.year % 90 === 0) { this.splitDivergentEthnicities(); this.updateEthnicStats(); } this.year++; } rebuildOccupancy() { const w = this.world; w.pressure.fill(0); this.tileEthnicities.clear(); for (const a of this.agents) { if (!a.alive) continue; const i = w.idx(a.x, a.y); w.pressure[i]++; 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); } } moveAgent(a) { const w = this.world; const s = w.size; const current = w.idx(a.x, a.y); const localPressure = w.pressure[current]; let bestX = a.x; let bestY = a.y; let bestScore = -Infinity; const sedentary = getSedentary(a.traits); const ethnocentrism = getEthnocentrism(a.traits); const ethnicClimate = this.ethnicities.get(a.ethnicity); const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45; if (localPressure < 7 && this.rng.next() < sedentary * 0.58) { a.settled++; 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 = this.ethnicDensityNear(x, y, a.ethnicity); const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i]; const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary; const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0); const crowdPenalty = Math.max(0, w.pressure[i] - 3) * (0.35 + a.traits.mobility); const routeBonus = w.tradeRoute[i] ? 1.25 : 0; const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0; const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus; const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0; const pressurePush = localPressure > 8 ? a.traits.mobility * (1.65 - sedentary * 0.7) : 0; const score = resourceScore * a.traits.resourceAttraction + w.pheromone[i] * 0.021 + ethnicDensity.same * ethnocentrism + cityPull * sedentary + routePull + 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) { const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1; w.pheromone[from] += (w.tradeRoute[from] ? 0.08 : 0.22) * depositScale; w.pheromone[to] += (w.tradeRoute[to] ? 0.06 : 0.15) * depositScale; a.settled = Math.max(0, a.settled - 1); } else { a.settled++; } } gatherConsumeReproduce(a, offspring) { const w = this.world; const i = w.idx(a.x, a.y); 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 pressurePenalty = 1 / (1 + Math.max(0, w.pressure[i] - 2) * 0.18); const gathered = Math.min(w.resource[i], productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2)); 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; a.resources -= (0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep; if (a.resources <= 0) { a.alive = false; this.deaths++; return; } if (a.resources > a.traits.reproductionThreshold && offspring.length < 700) { a.resources *= 0.58; const childTraits = mutateTraits(a.traits, this.rng, 0.035); offspring.push(this.makeAgent(a.x, a.y, a.ethnicity, childTraits, a.resources * 0.42)); } } 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) { 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) return { same: same * 0.22, foreign: foreign * 0.16 }; } } return { same: same * 0.22, foreign: foreign * 0.16 }; } 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 / 120); if (this.rng.next() < chance * 0.035) { a.ethnicity = dominant.id; a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22); a.foreignContact = 0; } } dominantEthnicityNear(x, y, self) { 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); } } } 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(); return best; } updateWorldFields() { const w = this.world; for (let i = 0; i < w.count; i++) { w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * (1 + w.farmland[i] * 1.15)); w.pheromone[i] *= 0.992; w.pressure[i] *= 0.88; } } 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, ethnicities: new Map() }; candidates.set(i, group); } group.count++; group.resources += Math.max(0, a.resources); group.sedentary += getSedentary(a.traits); group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1); } let foundedThisTick = 0; const canFoundCities = this.year >= 220 && this.year % 40 === 0; const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / 1500) : 0; const candidateEntries = [...candidates].sort((a, b) => b[1].count - a[1].count || b[1].resources - a[1].resources); for (const [i, group] of candidateEntries) { if (group.count < 6) continue; const avgSedentary = group.sedentary / group.count; let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 7); if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < 50) { if (avgSedentary < 0.42 || this.rng.next() > avgSedentary * avgSedentary) continue; city = this.createCity(i % w.size, Math.floor(i / w.size), group); this.cities.push(city); foundedThisTick++; } if (city) { city.activeVisitors += group.count; city.storedResources += group.resources * 0.08; for (const [id, count] of group.ethnicities) { city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + Math.ceil(count * 0.2)); } city.strength = city.strength * 0.96 + group.count * 0.05; } } this.absorbUrbanPopulation(); this.processCityEconomies(); this.cities = this.cities.filter(c => { c.age++; c.strength *= 0.996; if (c.population < 10) c.strength -= 0.02; if (c.population <= 0 || c.strength <= 0.12) 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; }); } createCity(x, y, seedGroup = null) { const seedPopulation = seedGroup ? Math.max(8, seedGroup.count * 3) : 8; const composition = new Map(); if (seedGroup) { for (const [id, count] of seedGroup.ethnicities) composition.set(id, Math.max(1, count * 3)); } return { id: this.nextCity++, x, y, population: seedPopulation, storedResources: 24 + (seedGroup?.resources || 0) * 0.4, ethnicityComposition: composition, pheromoneOutput: 0, agriculturalRadius: 2, tradeLinks: new Set(), activeVisitors: 0, age: 0, strength: 2, polityId: null, loyalty: 0.5, receivedAid: false }; } absorbUrbanPopulation() { if (!this.cities.length) return; const absorbed = []; for (const a of this.agents) { if (!a.alive || a.settled < 9) { absorbed.push(a); continue; } const city = this.findCityNear(a.x, a.y, 5); if (!city || this.rng.next() > getSedentary(a.traits) * 0.55) { absorbed.push(a); continue; } const migrants = 1 + Math.floor(Math.min(5, a.resources / 10)); city.population += migrants; city.storedResources += Math.max(0, a.resources) * 0.65; city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + migrants); city.strength += 0.04; } this.agents = absorbed; } 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); 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 extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 + w.mineral[i] * 0.045) * pull); w.resource[i] -= extraction; w.farmland[i] = Math.max(w.farmland[i], pull); w.pheromone[i] += city.pheromoneOutput * pull * 0.04; harvested += extraction; } } city.storedResources += harvested; const upkeep = city.population * 0.012; city.storedResources -= upkeep; if (city.storedResources > city.population * 0.24 && city.population > 0) { const births = Math.max(1, Math.floor(city.population * 0.004)); city.population += births; city.storedResources -= births * 0.7; addBirthsToComposition(city.ethnicityComposition, births); } 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)); } } 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.cities) { 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; return Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources)); } getCityById(id) { return this.cities.find(c => c.id === id) || null; } getPolityById(id) { return this.polities.find(p => p.id === id) || null; } getPolityCities(polity) { const living = []; for (const id of [...polity.cityIds]) { const city = this.getCityById(id); if (!city || city.population <= 0) { polity.cityIds.delete(id); } else { living.push(city); } } return living; } dominantCityEthnicity(city) { if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null; return dominantComposition(city.ethnicityComposition) || null; } sameDominantEthnicity(cityA, cityB) { const a = this.dominantCityEthnicity(cityA); const b = this.dominantCityEthnicity(cityB); return a !== null && b !== null && a === b; } distanceBetweenCities(cityA, cityB) { return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y); } hasDirectTradeConnection(cityA, cityB) { return !!cityA?.tradeLinks?.has(cityB?.id) || !!cityB?.tradeLinks?.has(cityA?.id); } effectiveDistance(cityA, cityB) { let distance = this.distanceBetweenCities(cityA, cityB); if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55; return distance; } createPolity(centerCity) { const id = this.nextPolity++; const polity = { id, centerCityId: centerCity.id, cityIds: new Set([centerCity.id]), treasury: 0, color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), founded: this.year }; centerCity.polityId = id; centerCity.loyalty = 1; centerCity.receivedAid = false; this.polities.push(polity); return polity; } 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; } foundPolities() { for (const city of this.cities) { if (city.polityId !== null || city.population < 80 || city.storedResources < 30) continue; const centerInfluence = this.cityInfluence(city); let absorbed = 0; for (const other of this.cities) { if (absorbed >= 2) break; if (other === city || other.polityId !== null) continue; if (this.distanceBetweenCities(city, other) > 28) continue; const targetInfluence = this.cityInfluence(other); if (centerInfluence <= targetInfluence * 1.25) 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.9 && this.rng.next() < 0.18) { const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId); this.addCityToPolity(other, polity, 0.55); 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 >= 1) break; if (city.polityId !== null || city.id === center.id) continue; const distance = this.effectiveDistance(center, city); if (distance > 32) continue; const targetInfluence = this.cityInfluence(city); if (centerInfluence <= targetInfluence) continue; const dominanceScore = (centerInfluence / (targetInfluence + 1)) * (1 / (1 + distance * 0.08)) * (this.hasDirectTradeConnection(center, city) ? 1.5 : 1.0); if (dominanceScore > 0.85 && this.rng.next() < 0.12) { this.addCityToPolity(city, polity, 0.48); absorbed++; } } } } 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.04; city.storedResources -= tax; polity.treasury += tax; } 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); } } } } updateCityLoyalty() { for (const polity of this.polities) { const center = this.getCityById(polity.centerCityId); if (!center) continue; center.loyalty = 1; 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.12) * 0.08, -0.04, 0.04); delta += this.sameDominantEthnicity(city, center) ? 0.025 : -0.025; delta += clamp(0.035 - distance * 0.0015, -0.045, 0.035); if (this.hasDirectTradeConnection(city, center)) delta += 0.015; if (city.receivedAid) delta += 0.04; delta -= 0.01; if (city.storedResources < city.population * 0.05) delta -= 0.06; city.loyalty = clamp(city.loyalty + delta, 0, 1); } } } splitUnloyalCities() { for (const polity of this.polities) { for (const city of this.getPolityCities(polity)) { if (city.id === polity.centerCityId || city.loyalty >= 0.2) continue; const chance = (0.2 - city.loyalty) * 0.4; if (this.rng.next() < chance) this.removeCityFromPolity(city); } } } cleanupPolities() { const survivors = []; for (const polity of this.polities) { const cities = this.getPolityCities(polity); if (!cities.length) continue; let center = this.getCityById(polity.centerCityId); if (!center) { center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]); polity.centerCityId = center.id; center.loyalty = 1; } if (cities.length === 1) { cities[0].polityId = null; cities[0].loyalty = 0.45; cities[0].receivedAid = false; continue; } survivors.push(polity); } this.polities = survivors; 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 % 12 !== 0) return; this.cleanupPolities(); this.foundPolities(); this.expandPolities(); this.collectAndRedistributeResources(); this.updateCityLoyalty(); this.splitUnloyalCities(); this.cleanupPolities(); } 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 % 4 === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2); } this.tradeLinks = []; for (const city of this.cities) city.tradeLinks.clear(); const candidates = []; 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 > 34) continue; const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y); if (!this.isValidRoutePath(path, c2.x, c2.y)) continue; const strength = this.routeStrengthForPath(path); if (strength < 0.12) continue; candidates.push({ c1, c2, strength, path }); } } candidates.sort((a, b) => b.strength - a.strength); const maxLinks = Math.max(1, Math.floor(this.cities.length / 3)); const supportedRoutes = new Set(); for (const candidate of candidates) { if (this.tradeLinks.length >= maxLinks) break; const { c1, c2, strength, path } = candidate; const c1Limit = c1.population > 90 ? 2 : 1; const c2Limit = c2.population > 90 ? 2 : 1; if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue; c1.tradeLinks.add(c2.id); c2.tradeLinks.add(c1.id); this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path }); this.exchangeCityResources(c1, c2, strength, path); for (const tile of path) { supportedRoutes.add(tile); w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 12); } } for (let i = 0; i < w.count; i++) { if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 10); } } findTerrainRoute(x1, y1, x2, y2) { const w = this.world; const path = []; const visited = new Set(); let x = x1; let y = y1; const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60); const directions = [ [1, 0], [-1, 0], [0, 1], [0, -1] ]; for (let step = 0; step < maxSteps; step++) { const current = w.idx(x, y); path.push(current); visited.add(current); if (x === x2 && y === y2) break; let bestX = x; let bestY = y; let bestScore = Infinity; for (const [dx, dy] of directions) { const nx = x + dx; const ny = y + dy; if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue; const i = w.idx(nx, ny); const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0); const revisitCost = visited.has(i) ? 5 : 0; const routeEase = w.tradeRoute[i] ? -2 : 0; const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4); const distance = Math.abs(nx - x2) + Math.abs(ny - y2); const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase; if (score < bestScore) { bestScore = score; bestX = nx; bestY = ny; } } if (bestX === x && bestY === y) break; x = bestX; y = bestY; } return path; } routeStrengthForPath(path) { const w = this.world; if (!path.length) return 0; let route = 0; let pheromone = 0; let terrainEase = 0; for (const i of path) { route += w.tradeRoute[i] > 0 ? 1 : 0; pheromone += clamp(w.pheromone[i] / 9, 0, 1); terrainEase += 1 / Math.max(1, w.move[i]); } return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2; } isValidRoutePath(path, targetX, targetY) { const w = this.world; if (!path.length) return false; const last = path[path.length - 1]; if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false; if (path.length > 42) return false; let totalCost = 0; let hardTiles = 0; const seen = new Set(); for (const tile of path) { if (seen.has(tile)) return false; seen.add(tile); if (w.terrain[tile] === Terrain.WATER) return false; totalCost += w.move[tile]; if (w.move[tile] > 1.8) hardTiles++; } return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12; } exchangeCityResources(a, b, strength, path) { const delta = (a.storedResources - b.storedResources) * 0.018 * strength; a.storedResources -= delta; b.storedResources += delta; const traffic = Math.min(0.8, strength * 0.26); this.depositRoutePheromone(path, traffic); } depositRoutePheromone(path, amount) { const w = this.world; for (const i of path) { w.pheromone[i] += amount; } } updateEthnicStats() { for (const e of this.ethnicities.values()) { e.population = 0; e.diversity = 0; e.centroidX = 0; e.centroidY = 0; e.activeTraitPopulation = 0; e.traitSums = emptyTraitSums(); } for (const a of this.agents) { const e = this.ethnicities.get(a.ethnicity); if (!e) continue; e.population++; e.centroidX += a.x; e.centroidY += a.y; e.activeTraitPopulation++; addTraits(e.traitSums, a.traits); } for (const city of this.cities) { for (const [id, count] of city.ethnicityComposition) { const e = this.ethnicities.get(id); if (!e) continue; e.population += count; e.centroidX += city.x * count; e.centroidY += city.y * count; } } for (const e of this.ethnicities.values()) { if (!e.population) continue; e.centroidX /= e.population; e.centroidY /= e.population; const activeTraitCount = Math.max(1, e.activeTraitPopulation); e.averageTraits = averageTraits(e.traitSums, activeTraitCount); } for (const a of this.agents) { const e = this.ethnicities.get(a.ethnicity); if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits); } for (const e of this.ethnicities.values()) { if (e.population) e.diversity /= e.population; } } splitDivergentEthnicities() { for (const e of this.ethnicities.values()) { if (e.activeTraitPopulation < 35 || e.population < 55 || e.diversity < 0.22) 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 * 1.12; const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.12; const tile = this.world.idx(a.x, a.y); const climate = this.climateMismatch(e.id, tile) > 0.26; if ((far || spatial || climate) && this.rng.next() < 0.58) { candidates.push(a); tempSum += this.world.temperature[tile]; humidSum += this.world.humidity[tile]; } } if (candidates.length >= 10) { const newId = this.createEthnicity(e.id, { temperature: tempSum / candidates.length, humidity: humidSum / candidates.length }); for (const a of candidates) a.ethnicity = newId; } } } toJSON() { return { version: 2, rngSeed: this.rng.seed, year: this.year, deaths: this.deaths, nextEthnicity: this.nextEthnicity, nextCity: this.nextCity, nextPolity: this.nextPolity, maxAgents: this.maxAgents, world: { size: this.world.size, terrain: packArray(this.world.terrain), resource: packArray(this.world.resource), regen: packArray(this.world.regen), move: packArray(this.world.move), fertility: packArray(this.world.fertility), mineral: packArray(this.world.mineral), temperature: packArray(this.world.temperature), humidity: packArray(this.world.humidity), pheromone: packArray(this.world.pheromone), tradeRoute: packArray(this.world.tradeRoute), farmland: packArray(this.world.farmland), cityPull: packArray(this.world.cityPull), city: packArray(this.world.city), pressure: packArray(this.world.pressure) }, agents: this.agents, ethnicities: [...this.ethnicities.values()].map(e => ({ id: e.id, parent: e.parent, born: e.born, climateTemp: e.climateTemp, climateHumidity: e.climateHumidity, color: e.color })), cities: this.cities.map(c => ({ ...c, ethnicityComposition: [...c.ethnicityComposition], tradeLinks: [...c.tradeLinks] })), polities: this.polities.map(p => ({ id: p.id, centerCityId: p.centerCityId, cityIds: [...p.cityIds], treasury: p.treasury, color: p.color, founded: p.founded })) }; } static fromJSON(state) { const sim = new Simulation(state.world.size, 0); sim.rng.seed = state.rngSeed >>> 0; sim.year = state.year || 0; sim.deaths = state.deaths || 0; sim.nextEthnicity = state.nextEthnicity || 1; sim.nextCity = state.nextCity || 1; sim.nextPolity = state.nextPolity || 1; sim.maxAgents = state.maxAgents || 30000; sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array)); sim.world.resource.set(unpackArray(state.world.resource, Float32Array)); sim.world.regen.set(unpackArray(state.world.regen, Float32Array)); sim.world.move.set(unpackArray(state.world.move, Float32Array)); sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array)); sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array)); sim.world.temperature.set(unpackArray(state.world.temperature, Float32Array)); sim.world.humidity.set(unpackArray(state.world.humidity, Float32Array)); sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array)); sim.world.tradeRoute.set(unpackArray(state.world.tradeRoute, Uint8Array)); sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array)); sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array)); sim.world.city.set(unpackArray(state.world.city, Int32Array)); sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array)); sim.agents = state.agents || []; sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, { id: e.id, parent: e.parent, born: e.born, population: 0, diversity: 0, climateTemp: e.climateTemp, climateHumidity: e.climateHumidity, color: e.color, centroidX: 0, centroidY: 0 }])); sim.cities = (state.cities || []).map(c => ({ id: c.id, x: c.x, y: c.y, population: c.population || 0, storedResources: c.storedResources || 0, ethnicityComposition: new Map(c.ethnicityComposition || []), pheromoneOutput: c.pheromoneOutput || 0, agriculturalRadius: c.agriculturalRadius || 2, tradeLinks: new Set(c.tradeLinks || []), activeVisitors: 0, age: c.age || 0, strength: c.strength || 1, polityId: c.polityId ?? null, loyalty: c.loyalty ?? 0.5, receivedAid: c.receivedAid ?? false })); sim.polities = (state.polities || []).map(p => ({ id: p.id, centerCityId: p.centerCityId, cityIds: new Set(p.cityIds || []), treasury: p.treasury || 0, color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62), founded: p.founded || 0 })); sim.tradeLinks = []; sim.rebuildOccupancy(); sim.updateTradeRoutes(); sim.cleanupPolities(); sim.updateEthnicStats(); return sim; } } function render() { const start = performance.now(); const w = sim.world; const size = w.size; const image = ctx.createImageData(size, size); const data = image.data; const mode = els.viewMode.value; 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 === "pheromone") { const v = clamp(w.pheromone[i] / 9, 0, 1); color = w.tradeRoute[i] ? mix(terrainInfo[w.terrain[i]].color, [255, 218, 91], 0.38) : mix(terrainInfo[w.terrain[i]].color, [232, 193, 75], v); } 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 === "cities") { color = w.city[i] >= 0 ? mix([74, 105, 58], [226, 198, 121], clamp(w.farmland[i], 0, 1)) : terrainInfo[w.terrain[i]].color; } else if (mode === "polities") { color = terrainInfo[w.terrain[i]].color; if (w.city[i] >= 0) { const city = sim.getCityById(w.city[i]); const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null; if (polity) color = mix(color, polity.color, 0.5); } } else { color = terrainInfo[w.terrain[i]].color; } const p = i * 4; data[p] = color[0]; data[p + 1] = color[1]; data[p + 2] = color[2]; data[p + 3] = 255; } ctx.putImageData(image, 0, 0); drawTradeLinks(); drawFarmlandRings(mode); drawAgentsAndCities(mode); els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; } function drawTradeLinks() { const w = sim.world; ctx.save(); ctx.globalAlpha = 0.24; ctx.fillStyle = "#d9b650"; for (let i = 0; i < w.count; i++) { if (w.tradeRoute[i]) 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 drawFarmlandRings(mode) { if (mode !== "cities") return; const w = sim.world; ctx.save(); ctx.globalAlpha = 0.78; ctx.fillStyle = "#d9c45f"; for (const city of sim.cities) { const radius = Math.max(1, city.agriculturalRadius); for (let dy = -radius; dy <= radius; dy++) { for (let dx = -radius; dx <= radius; dx++) { const d = Math.abs(dx) + Math.abs(dy); if (d !== radius) continue; const x = city.x + dx; const y = city.y + dy; if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; if (w.terrain[w.idx(x, y)] !== Terrain.WATER) ctx.fillRect(x, y, 1, 1); } } } ctx.restore(); } function drawAgentsAndCities(mode) { ctx.save(); for (const city of sim.cities) { const radius = cityRenderRadius(city); const color = cityDisplayColor(city, mode); ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.72)`; ctx.globalAlpha = 0.88; ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1); } ctx.globalAlpha = 1; for (const a of sim.agents) { if (mode === "ethnicity") { const e = sim.ethnicities.get(a.ethnicity); if (!e) continue; ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`; ctx.fillRect(a.x, a.y, 1, 1); } else { ctx.fillStyle = "#eeeccf"; ctx.fillRect(a.x, a.y, 1, 1); } } for (const city of sim.cities) { const color = cityDisplayColor(city, mode); ctx.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`; ctx.fillRect(city.x, city.y, 1, 1); } ctx.restore(); } function cityRenderRadius(city) { return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7); } function cityMajorityColor(city) { const id = dominantComposition(city.ethnicityComposition); return sim.ethnicities.get(id)?.color || [242, 215, 134]; } function cityDisplayColor(city, mode) { if (mode === "polities" && city.polityId !== null) { const polity = sim.getPolityById(city.polityId); if (polity) return polity.color; } return cityMajorityColor(city); } function showTooltip(event) { const rect = els.canvas.getBoundingClientRect(); const simRect = els.sim.getBoundingClientRect(); const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size); const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size); hoverState = { x, y, left: event.clientX - simRect.left + 16, top: event.clientY - simRect.top + 16, width: simRect.width, height: simRect.height }; renderTooltip(); } function renderTooltip() { if (!hoverState) return; const x = hoverState.x; const y = hoverState.y; if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) { hideTooltip(); return; } const w = sim.world; const i = w.idx(x, y); const agent = findAgentAt(x, y); const city = w.city[i] >= 0 ? sim.cities.find(c => c.id === w.city[i]) : null; const terrain = terrainInfo[w.terrain[i]]; const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null; const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2); const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null; const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0; const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null; els.tooltip.innerHTML = ` ${agent ? "Agent group" : terrain.name} Tile${x}, ${y} Terrain${terrain.name} Resources${w.resource[i].toFixed(1)} Fertility${w.fertility[i].toFixed(2)} Temp / humid${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)} Water influence${waterInfluence} Minerals${w.mineral[i].toFixed(2)} Pheromone${w.pheromone[i].toFixed(1)} Pressure${w.pressure[i].toFixed(0)} ${w.tradeRoute[i] ? `Route${w.tradeRoute[i]}` : ""} ${city ? `City#${city.id}` : ""} ${city ? `Urban pop${city.population.toLocaleString()}` : ""} ${city ? `Food stock${city.storedResources.toFixed(1)}` : ""} ${city ? `Farmland radius${city.agriculturalRadius}` : ""} ${city ? `Trade links${city.tradeLinks.size}` : ""} ${cityEthnicity ? `City majorityE${cityEthnicity}` : ""} ${city ? `State${polity ? `#${polity.id}` : "Independent"}` : ""} ${city ? `Loyalty${city.loyalty.toFixed(2)}` : ""} ${polity ? `Treasury${polity.treasury.toFixed(1)}` : ""} ${polity ? `Center${polity.centerCityId === city.id ? "yes" : "no"}` : ""} ${agent ? `EthnicityE${agent.ethnicity}` : ""} ${ethnicity ? `Climate pref${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : ""} ${agent ? `Climate mismatch${mismatch.toFixed(2)}` : ""} ${agent ? `Sedentary${getSedentary(agent.traits).toFixed(2)}` : ""} ${agent ? `Ethnocentrism${getEthnocentrism(agent.traits).toFixed(2)}` : ""} ${agent ? `Stored${agent.resources.toFixed(1)}` : ""} ${ethnicity ? `Lineage pop${ethnicity.population}` : ""} `; els.tooltip.hidden = false; const left = Math.min(hoverState.left, hoverState.width - 280); const top = Math.min(hoverState.top, hoverState.height - 230); els.tooltip.style.left = `${Math.max(8, left)}px`; els.tooltip.style.top = `${Math.max(8, top)}px`; } function hideTooltip() { hoverState = null; els.tooltip.hidden = true; } function findAgentAt(x, y) { const directIndex = sim.world.idx(x, y); const directCounts = sim.tileEthnicities.get(directIndex); if (!directCounts) return null; for (const a of sim.agents) { if (a.x === x && a.y === y) return a; } return 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 < 300) return; lastStatsAt = now; const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0); const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0); els.year.textContent = sim.year.toLocaleString(); els.population.textContent = Math.floor(sim.agents.length + urbanPopulation).toLocaleString(); els.activeGroups.textContent = sim.agents.length.toLocaleString(); els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString(); els.ethnicities.textContent = livingEthnicities.length.toLocaleString(); els.cities.textContent = sim.cities.length.toLocaleString(); els.polities.textContent = sim.polities.length.toLocaleString(); els.routes.textContent = sim.tradeLinks.length.toLocaleString(); els.deaths.textContent = sim.deaths.toLocaleString(); const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9); els.ethnicityList.innerHTML = top.map(e => ( `