diff --git a/index.html b/index.html index 6434e0a..b05c169 100644 --- a/index.html +++ b/index.html @@ -55,6 +55,7 @@ + @@ -67,6 +68,7 @@
Urban population
0
Ethnicities
0
Cities
0
+
States
0
Trade routes
0
Collapse deaths
0
Frame cost
0ms
diff --git a/script.js b/script.js index fbdec66..0853418 100644 --- a/script.js +++ b/script.js @@ -13,13 +13,14 @@ const terrainInfo = [ { 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.8, fertility: 0.1, mineral: 0.12, regen: 0.007 }, + { 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"), @@ -36,6 +37,7 @@ const els = { 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"), @@ -81,6 +83,8 @@ class World { 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); @@ -102,6 +106,9 @@ class World { 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++) { @@ -109,7 +116,7 @@ class World { const nx = x / s - 0.5; const ny = y / s - 0.5; const latitude = Math.abs(ny) * 0.18; - let elevation = 0.55 - Math.hypot(nx, ny) * 0.72 + this.smoothNoise(x, y, 42) * 0.38 + this.smoothNoise(x + 900, y - 300, 18) * 0.16; + 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); @@ -118,23 +125,52 @@ class World { 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; } - - let t = Terrain.PLAINS; - if (elevation < 0.24) t = Terrain.WATER; - else if (elevation > 0.68) t = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN; - else if (moisture < 0.22) t = Terrain.DESERT; - else if (moisture > 0.72 && elevation < 0.54) t = Terrain.FERTILE; - else if (moisture > 0.5) t = Terrain.FOREST; - - const info = terrainInfo[t]; - this.terrain[i] = t; - this.fertility[i] = clamp(info.fertility + this.rng.range(-0.07, 0.07), 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); + 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(); } @@ -169,19 +205,14 @@ class World { 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) { + 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)); } } } - noise(x, y, scale) { - const sx = Math.sin((x * 12.9898 + y * 78.233) / scale) * 43758.5453; - const v = sx - Math.floor(sx); - const sx2 = Math.sin(((x + scale) * 93.989 + (y - scale) * 67.345) / (scale * 1.7)) * 24634.6345; - return (v + sx2 - Math.floor(sx2)) * 0.5; - } - smoothNoise(x, y, scale) { const x0 = Math.floor(x / scale); const y0 = Math.floor(y / scale); @@ -198,6 +229,136 @@ class World { 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 { @@ -207,9 +368,11 @@ class Simulation { 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); @@ -221,23 +384,30 @@ class Simulation { 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 id = this.createEthnicity(0); - const origin = this.findHabitableTile(); - const baseTraits = this.randomTraits(); + 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( - clamp(origin.x + this.rng.int(9) - 4, 0, this.world.size - 1), - clamp(origin.y + this.rng.int(9) - 4, 0, this.world.size - 1), + spawn.x, + spawn.y, id, mutateTraits(baseTraits, this.rng, 0.07), - this.rng.range(6, 15) + desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15) )); } } } - createEthnicity(parent) { + createEthnicity(parent, climate = null) { const id = this.nextEthnicity++; this.ethnicities.set(id, { id, @@ -245,6 +415,8 @@ class Simulation { 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 @@ -259,11 +431,19 @@ class Simulation { assimilation: this.rng.range(0.03, 0.34), ethnocentrism: this.rng.range(0.12, 0.78), reproductionThreshold: this.rng.range(19, 34), - oceanic: this.rng.next() < 0.18 ? this.rng.range(0.48, 0.9) : this.rng.range(0.02, 0.28), 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, @@ -278,7 +458,48 @@ class Simulation { }; } - findHabitableTile() { + 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); @@ -311,10 +532,16 @@ class Simulation { this.updateWorldFields(); this.rebuildOccupancy(); - this.updateCities(); - this.updateTradeRoutes(); + if (this.year % 5 === 0) { + this.updateCities(); + this.updateTradeRoutes(); + } + this.updatePolities(); this.updateEthnicStats(); - if (this.year % 90 === 0) this.splitDivergentEthnicities(); + if (this.year % 90 === 0) { + this.splitDivergentEthnicities(); + this.updateEthnicStats(); + } this.year++; } @@ -345,6 +572,8 @@ class Simulation { 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; @@ -357,18 +586,17 @@ class Simulation { const y = clamp(a.y + dy, 0, s - 1); const i = w.idx(x, y); const isWater = w.terrain[i] === Terrain.WATER; - const oceanic = a.traits.oceanic ?? 0.12; - const waterAccess = oceanic * 0.42 + a.traits.mobility * 0.08 + (w.tradeRoute[i] ? 0.18 : 0); + 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 sameDensity = this.sameEthnicityNear(x, y, a.ethnicity); - const foreignDensity = this.foreignEthnicityNear(x, y, a.ethnicity); + 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 ? oceanic * 0.65 : 0); + 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 ? 2.2 - oceanic * 1.35 : 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; @@ -376,14 +604,14 @@ class Simulation { const score = resourceScore * a.traits.resourceAttraction + w.pheromone[i] * 0.021 + - sameDensity * ethnocentrism + + ethnicDensity.same * ethnocentrism + cityPull * sedentary + routePull + inertia - terrainPenalty - crowdPenalty + pressurePush - - foreignDensity * ethnocentrism * 0.72 + + ethnicDensity.foreign * ethnocentrism * 0.72 + this.rng.range(-0.55, 0.55); if (score > bestScore) { @@ -411,14 +639,22 @@ class Simulation { 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 productivity = 0.45 + w.fertility[i] * 1.15 + w.mineral[i] * 0.45 + w.farmland[i] * 0.32 + cityMarket; + 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 * pressurePenalty * this.rng.range(0.45, 1.2)); + 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 waterCost = w.terrain[i] === Terrain.WATER ? 0.32 - (a.traits.oceanic ?? 0.12) * 0.18 : 0; - a.resources -= 0.72 + w.move[i] * 0.06 + waterCost; + 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; @@ -433,26 +669,21 @@ class Simulation { } } - sameEthnicityNear(x, y, ethnicity) { - let count = 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; - count += counts.get(ethnicity) || 0; - if (count > 6) return count * 0.22; - } - } - return count * 0.22; + 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; } - foreignEthnicityNear(x, y, ethnicity) { - let count = 0; + 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++) { @@ -463,12 +694,13 @@ class Simulation { const counts = this.tileEthnicities.get(w.idx(tx, ty)); if (!counts) continue; for (const [id, value] of counts) { - if (id !== ethnicity) count += value; + if (id === ethnicity) same += value; + else foreign += value; } - if (count > 6) return count * 0.16; + if (same > 6 && foreign > 6) return { same: same * 0.22, foreign: foreign * 0.16 }; } } - return count * 0.16; + return { same: same * 0.22, foreign: foreign * 0.16 }; } resolveAssimilation(a) { @@ -522,42 +754,51 @@ class Simulation { updateWorldFields() { const w = this.world; for (let i = 0; i < w.count; i++) { - w.resource[i] = Math.min(44, w.resource[i] + w.regen[i] * (1 + w.farmland[i] * 0.55)); + 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; - w.farmland[i] *= 0.985; - w.cityPull[i] *= 0.96; } } updateCities() { const w = this.world; - if (this.year % 8 === 0) w.city.fill(-1); + 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 || a.settled < 7) continue; + if (!a.alive) continue; const local = w.idx(a.x, a.y); - if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.34) continue; - const cx = clamp(Math.round(a.x / 2) * 2, 0, w.size - 1); - const cy = clamp(Math.round(a.y / 2) * 2, 0, w.size - 1); + 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, ethnicities: new Map() }; + 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); } - for (const [i, group] of candidates) { - if (group.count < 5) continue; - let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 5); - if (!city && this.cities.length < 160) { + 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; @@ -576,8 +817,8 @@ class Simulation { c.age++; c.strength *= 0.996; if (c.population < 10) c.strength -= 0.02; - if (c.population <= 0 || c.strength <= 0.35) return false; - const radius = Math.min(8, c.agriculturalRadius); + 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); @@ -615,7 +856,10 @@ class Simulation { tradeLinks: new Set(), activeVisitors: 0, age: 0, - strength: 2 + strength: 2, + polityId: null, + loyalty: 0.5, + receivedAid: false }; } @@ -657,7 +901,7 @@ class Simulation { 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.03 + w.fertility[i] * 0.09 + w.mineral[i] * 0.035) * pull); + 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; @@ -675,20 +919,24 @@ class Simulation { addBirthsToComposition(city.ethnicityComposition, births); } if (city.storedResources < 0) { - const loss = Math.min(city.population, Math.ceil(Math.abs(city.storedResources) * 1.8)); + 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; - this.deaths += loss; removeFromComposition(city.ethnicityComposition, loss); - if (loss > 5 && this.agents.length < this.maxAgents) this.spawnUrbanRefugees(city, Math.min(18, Math.floor(loss / 3))); + 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) { - const dominant = dominantComposition(city.ethnicityComposition) || 1; + 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( @@ -714,6 +962,244 @@ class Simulation { 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++) { @@ -734,18 +1220,20 @@ class Simulation { 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.38) continue; + 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 / 10)); + 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; - if (c1.tradeLinks.size >= 1 || c2.tradeLinks.size >= 1) continue; + 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 }); @@ -908,22 +1396,28 @@ class Simulation { splitDivergentEthnicities() { for (const e of this.ethnicities.values()) { - if (e.population < 80 || e.diversity < 0.42) continue; - const newId = this.createEthnicity(e.id); - let moved = 0; + 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.18; - if ((far || spatial) && this.rng.next() < 0.48) { - a.ethnicity = newId; - moved++; + 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 (moved < 24) { - for (const a of this.agents) if (a.ethnicity === newId) a.ethnicity = e.id; - this.ethnicities.delete(newId); - this.nextEthnicity--; + 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; } } } @@ -936,6 +1430,7 @@ class Simulation { deaths: this.deaths, nextEthnicity: this.nextEthnicity, nextCity: this.nextCity, + nextPolity: this.nextPolity, maxAgents: this.maxAgents, world: { size: this.world.size, @@ -945,6 +1440,8 @@ class Simulation { 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), @@ -957,12 +1454,22 @@ class Simulation { 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 })) }; } @@ -974,6 +1481,7 @@ class Simulation { 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)); @@ -982,12 +1490,14 @@ class Simulation { 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(state.world.tradeRoute ? unpackArray(state.world.tradeRoute, Uint8Array) : new Uint8Array(sim.world.count)); - sim.world.farmland.set(state.world.farmland ? unpackArray(state.world.farmland, Float32Array) : new Float32Array(sim.world.count)); - sim.world.cityPull.set(state.world.cityPull ? unpackArray(state.world.cityPull, Float32Array) : new Float32Array(sim.world.count)); + 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(state.world.pressure ? unpackArray(state.world.pressure, Float32Array) : new Float32Array(sim.world.count)); + sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array)); sim.agents = state.agents || []; sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, { @@ -996,6 +1506,8 @@ class Simulation { born: e.born, population: 0, diversity: 0, + climateTemp: e.climateTemp, + climateHumidity: e.climateHumidity, color: e.color, centroidX: 0, centroidY: 0 @@ -1012,11 +1524,23 @@ class Simulation { tradeLinks: new Set(c.tradeLinks || []), activeVisitors: 0, age: c.age || 0, - strength: c.strength || 1 + 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; } @@ -1037,12 +1561,19 @@ function render() { 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([90, 68, 44], [255, 218, 91], clamp(w.tradeRoute[i] / 40, 0, 1)) : mix(terrainInfo[w.terrain[i]].color, [232, 193, 75], v); + 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; } @@ -1055,6 +1586,7 @@ function render() { ctx.putImageData(image, 0, 0); drawTradeLinks(); + drawFarmlandRings(mode); drawAgentsAndCities(mode); els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; } @@ -1062,7 +1594,7 @@ function render() { function drawTradeLinks() { const w = sim.world; ctx.save(); - ctx.globalAlpha = 0.62; + 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); @@ -1072,7 +1604,7 @@ function drawTradeLinks() { return; } for (const link of sim.tradeLinks) { - ctx.globalAlpha = clamp(0.22 + link.strength * 0.75, 0.25, 0.9); + 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); @@ -1081,11 +1613,34 @@ function drawTradeLinks() { 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); - ctx.fillStyle = "#f2d786"; + 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); } @@ -1104,27 +1659,42 @@ function drawAgentsAndCities(mode) { } for (const city of sim.cities) { - ctx.fillStyle = "#fff2b5"; + 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) / 18), 1, 5); + 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 - rect.left + 16, - top: event.clientY - rect.top + 16, - width: rect.width, - height: rect.height + left: event.clientX - simRect.left + 16, + top: event.clientY - simRect.top + 16, + width: simRect.width, + height: simRect.height }; renderTooltip(); } @@ -1143,10 +1713,11 @@ function renderTooltip() { 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 habitat = agent ? ((agent.traits.oceanic ?? 0) >= 0.45 ? "Oceanic" : "Continental") : ""; 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} @@ -1154,6 +1725,7 @@ function renderTooltip() { 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)} @@ -1165,8 +1737,13 @@ function renderTooltip() { ${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}` : ""} - ${agent ? `Habitat${habitat} ${(agent.traits.oceanic ?? 0).toFixed(2)}` : ""} + ${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)}` : ""} @@ -1221,6 +1798,7 @@ function updateStats(force = false) { 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(); @@ -1243,6 +1821,8 @@ function setLegend() { els.legend.innerHTML = "High local population pressure"; } else if (mode === "cities") { els.legend.innerHTML = "City nodes, farmland radius, and stored urban population"; + } else if (mode === "polities") { + els.legend.innerHTML = "Color = city-centered state. Uncolored cities are independent."; } else { els.legend.innerHTML = "Regenerating local resource stock"; } @@ -1280,14 +1860,11 @@ function smoothstep(t) { } function getSedentary(traits) { - return traits.sedentary ?? traits.settling ?? 0.35; + return traits.sedentary; } function getEthnocentrism(traits) { - if (traits.ethnocentrism !== undefined) return traits.ethnocentrism; - const same = traits.sameAttraction ?? 0.35; - const avoid = traits.foreignAvoidance ?? 0.22; - return clamp((same + avoid * 1.5) * 0.5, 0, 1.2); + return traits.ethnocentrism; } function mutateTraits(traits, rng, amount) { @@ -1297,7 +1874,6 @@ function mutateTraits(traits, rng, amount) { assimilation: clamp(traits.assimilation + rng.range(-amount, amount), 0, 0.75), ethnocentrism: clamp(getEthnocentrism(traits) + rng.range(-amount, amount), 0, 1.2), reproductionThreshold: clamp(traits.reproductionThreshold + rng.range(-amount * 16, amount * 16), 12, 48), - oceanic: clamp((traits.oceanic ?? 0.12) + rng.range(-amount, amount), 0, 1), sedentary: clamp(getSedentary(traits) + rng.range(-amount, amount), 0, 1) }; } @@ -1309,13 +1885,12 @@ function blendTraits(a, b, t) { assimilation: lerp(a.assimilation, b.assimilation, t), ethnocentrism: lerp(getEthnocentrism(a), getEthnocentrism(b), t), reproductionThreshold: lerp(a.reproductionThreshold, b.reproductionThreshold, t), - oceanic: lerp(a.oceanic ?? 0.12, b.oceanic ?? 0.12, t), sedentary: lerp(getSedentary(a), getSedentary(b), t) }; } function emptyTraitSums() { - return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, oceanic: 0, sedentary: 0 }; + return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, sedentary: 0 }; } function addTraits(sum, traits) { @@ -1324,7 +1899,6 @@ function addTraits(sum, traits) { sum.assimilation += traits.assimilation; sum.ethnocentrism += getEthnocentrism(traits); sum.reproductionThreshold += traits.reproductionThreshold / 48; - sum.oceanic += traits.oceanic ?? 0.12; sum.sedentary += getSedentary(traits); } @@ -1335,7 +1909,6 @@ function averageTraits(sum, count) { assimilation: sum.assimilation / count, ethnocentrism: sum.ethnocentrism / count, reproductionThreshold: (sum.reproductionThreshold / count) * 48, - oceanic: sum.oceanic / count, sedentary: sum.sedentary / count }; } @@ -1377,7 +1950,6 @@ function traitDistance(a, b) { Math.abs(a.assimilation - b.assimilation) + Math.abs(getEthnocentrism(a) - getEthnocentrism(b)) + Math.abs(a.reproductionThreshold - b.reproductionThreshold) / 48 + - Math.abs((a.oceanic ?? 0.12) - (b.oceanic ?? 0.12)) + Math.abs(getSedentary(a) - getSedentary(b)); } @@ -1426,7 +1998,6 @@ function packArray(typedArray) { } function unpackArray(payload, TypedArray) { - if (Array.isArray(payload)) return new TypedArray(payload); const binary = atob(payload.data); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);