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 population0
Ethnicities0
Cities0
+ States0
Trade routes0
Collapse deaths0
Frame cost0ms
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);