diff --git a/script.js b/script.js
index f502b26..36d8474 100644
--- a/script.js
+++ b/script.js
@@ -1,3790 +1,3520 @@
const Terrain = Object.freeze({
- PLAINS: 0,
- FOREST: 1,
- MOUNTAIN: 2,
- WATER: 3,
- DESERT: 4,
- FERTILE: 5,
- MINERAL: 6
+PLAINS: 0,
+FOREST: 1,
+MOUNTAIN: 2,
+WATER: 3,
+DESERT: 4,
+FERTILE: 5,
+MINERAL: 6
});
-
const terrainInfo = [
- { name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 },
- { name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 },
- { name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 },
- { name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 },
- { name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 },
- { name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 },
- { name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 }
+{ name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 },
+{ name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 },
+{ name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 },
+{ name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 },
+{ name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 },
+{ name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 },
+{ name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 }
];
-
const MONTHS_PER_YEAR = 12;
const SAVE_KEY = "civil-emergence-save";
const SAVE_VERSION = 3;
const MAX_SAVE_BYTES = 4_500_000;
-
const SimConfig = Object.freeze({
- population: Object.freeze({
- maxAgentsFloor: 8000,
- maxAgentsScale: 1.15,
- maxOffspringPerStep: 360,
- carryingCapacityBase: 2.4,
- carryingCapacityFertility: 7.8,
- carryingCapacityMineral: 1.8,
- carryingCapacityFarmland: 5.5,
- pressurePenaltyBase: 0.2,
- pressureReproductionPenalty: 0.16
- }),
- city: Object.freeze({
- maxCities: 300
- }),
- render: Object.freeze({
- graphThrottleMs: 700,
- statsThrottleMs: 350,
- historyWindowYears: 2000,
- historySamplePaddingYears: 240
- }),
- save: Object.freeze({
- key: SAVE_KEY,
- version: SAVE_VERSION,
- maxBytes: MAX_SAVE_BYTES
- }),
- technology: Object.freeze({
- cityDiffusion: 0.0022,
- tradeDiffusion: 0.0032,
- cityInnovation: 0.00045
- }),
- polity: Object.freeze({
- minimumPerCapitaFood: 0.06,
- logisticsDistance: 34
- }),
- culture: Object.freeze({
- spreadRadius: 3,
- cityWeight: 0.035,
- routeWeight: 1.35,
- minimumInfluence: 0.18
- }),
- disaster: Object.freeze({
- checkIntervalYears: 8,
- baseChance: 0.38,
- minRadius: 6,
- maxRadius: 34,
- rareLargeChance: 0.10,
- largeRadiusMin: 28,
- largeRadiusMax: 54,
- minIntensity: 0.25,
- maxIntensity: 0.95,
- visualDurationYears: 18,
- maxActiveVisuals: 12,
- maxHistory: 160
- })
+population: Object.freeze({
+maxAgentsFloor: 8000,
+maxAgentsScale: 1.15,
+maxOffspringPerStep: 360,
+carryingCapacityBase: 2.4,
+carryingCapacityFertility: 7.8,
+carryingCapacityMineral: 1.8,
+carryingCapacityFarmland: 5.5,
+pressurePenaltyBase: 0.2,
+pressureReproductionPenalty: 0.16
+}),
+city: Object.freeze({
+maxCities: 300
+}),
+render: Object.freeze({
+graphThrottleMs: 1400,
+statsThrottleMs: 900,
+historyWindowYears: 2000,
+historySamplePaddingYears: 240
+}),
+save: Object.freeze({
+key: SAVE_KEY,
+version: SAVE_VERSION,
+maxBytes: MAX_SAVE_BYTES
+}),
+technology: Object.freeze({
+cityDiffusion: 0.0022,
+tradeDiffusion: 0.0032,
+cityInnovation: 0.00045
+}),
+polity: Object.freeze({
+minimumPerCapitaFood: 0.06,
+logisticsDistance: 34
+}),
+culture: Object.freeze({
+spreadRadius: 3,
+cityWeight: 0.035,
+routeWeight: 1.35,
+minimumInfluence: 0.18
+}),
+disaster: Object.freeze({
+damageScale: 1.5,
+majorLossRate: 0.05,
+checkIntervalYears: 8,
+baseChance: 0.38,
+minRadius: 6,
+maxRadius: 34,
+rareLargeChance: 0.10,
+largeRadiusMin: 28,
+largeRadiusMax: 54,
+minIntensity: 0.25,
+maxIntensity: 0.95,
+visualDurationYears: 18,
+maxActiveVisuals: 12,
+maxHistory: 160
+})
});
-
function years(value) {
- return value * MONTHS_PER_YEAR;
+return value * MONTHS_PER_YEAR;
}
-
const els = {
- canvas: document.getElementById("world"),
- sim: document.querySelector(".sim"),
- toggleRun: document.getElementById("toggleRun"),
- stepOnce: document.getElementById("stepOnce"),
- resetWorld: document.getElementById("resetWorld"),
- saveWorld: document.getElementById("saveWorld"),
- loadWorld: document.getElementById("loadWorld"),
- clearSave: document.getElementById("clearSave"),
- speed: document.getElementById("speed"),
- agentCount: document.getElementById("agentCount"),
- worldSize: document.getElementById("worldSize"),
- viewMode: document.getElementById("viewMode"),
- year: document.getElementById("year"),
- activeGroups: document.getElementById("activeGroups"),
- urbanPopulation: document.getElementById("urbanPopulation"),
- ethnicities: document.getElementById("ethnicities"),
- cities: document.getElementById("cities"),
- polities: document.getElementById("polities"),
- wars: document.getElementById("wars"),
- routes: document.getElementById("routes"),
- farmingKnowledge: document.getElementById("farmingKnowledge"),
- metallurgyKnowledge: document.getElementById("metallurgyKnowledge"),
- deaths: document.getElementById("deaths"),
- frameCost: document.getElementById("frameCost"),
- ethnicityList: document.getElementById("ethnicityList"),
- legend: document.getElementById("legend"),
- tooltip: document.getElementById("tooltip"),
- stateGraph: document.getElementById("stateGraph"),
- historyRange: document.getElementById("historyRange"),
- historyFilter: document.getElementById("historyFilter"),
- historySort: document.getElementById("historySort"),
- historyMetric: document.getElementById("historyMetric"),
- toggleActiveStates: document.getElementById("toggleActiveStates"),
- togglePastStates: document.getElementById("togglePastStates"),
- stateGraphInfo: document.getElementById("stateGraphInfo"),
- stateGraphTooltip: document.getElementById("stateGraphTooltip")
+canvas: document.getElementById("world"),
+sim: document.querySelector(".sim"),
+toggleRun: document.getElementById("toggleRun"),
+stepOnce: document.getElementById("stepOnce"),
+resetWorld: document.getElementById("resetWorld"),
+saveWorld: document.getElementById("saveWorld"),
+loadWorld: document.getElementById("loadWorld"),
+clearSave: document.getElementById("clearSave"),
+speed: document.getElementById("speed"),
+agentCount: document.getElementById("agentCount"),
+worldSize: document.getElementById("worldSize"),
+viewMode: document.getElementById("viewMode"),
+year: document.getElementById("year"),
+activeGroups: document.getElementById("activeGroups"),
+urbanPopulation: document.getElementById("urbanPopulation"),
+ethnicities: document.getElementById("ethnicities"),
+cities: document.getElementById("cities"),
+polities: document.getElementById("polities"),
+wars: document.getElementById("wars"),
+routes: document.getElementById("routes"),
+farmingKnowledge: document.getElementById("farmingKnowledge"),
+metallurgyKnowledge: document.getElementById("metallurgyKnowledge"),
+deaths: document.getElementById("deaths"),
+frameCost: document.getElementById("frameCost"),
+ethnicityList: document.getElementById("ethnicityList"),
+legend: document.getElementById("legend"),
+tooltip: document.getElementById("tooltip"),
+stateGraph: document.getElementById("stateGraph"),
+historyRange: document.getElementById("historyRange"),
+historyFilter: document.getElementById("historyFilter"),
+historySort: document.getElementById("historySort"),
+historyMetric: document.getElementById("historyMetric"),
+toggleActiveStates: document.getElementById("toggleActiveStates"),
+togglePastStates: document.getElementById("togglePastStates"),
+stateGraphInfo: document.getElementById("stateGraphInfo"),
+stateGraphTooltip: document.getElementById("stateGraphTooltip")
};
-
const ctx = els.canvas.getContext("2d", { alpha: false });
let sim;
let running = true;
let frame = 0;
let lastStatsAt = 0;
let lastGraphRenderAt = 0;
+let lastRenderAt = 0;
+let lastSimTickAt = 0;
+let renderDirty = true;
+let loopTimer = null;
+let loopScheduled = false;
let hoverState = null;
let renderImage = null;
let renderImageSize = 0;
+const LoopConfig = Object.freeze({
+simTickMs: 33,
+renderMs: 66,
+stepBudgetMs: 4.5,
+idlePollMs: 180,
+hiddenPollMs: 1000
+});
const graphState = {
- filter: "all",
- sort: "important",
- metric: "power",
- showActive: true,
- showPast: true,
- hoverPolityId: null,
- rows: [],
- markers: [],
- scale: null
+filter: "all",
+sort: "important",
+metric: "power",
+showActive: true,
+showPast: true,
+hoverPolityId: null,
+rows: [],
+markers: [],
+scale: null
};
-
class Rng {
- constructor(seed) {
- this.seed = seed >>> 0;
- }
-
- next() {
- this.seed = (1664525 * this.seed + 1013904223) >>> 0;
- return this.seed / 4294967296;
- }
-
- range(min, max) {
- return min + (max - min) * this.next();
- }
-
- int(max) {
- return Math.floor(this.next() * max);
- }
+constructor(seed) {
+this.seed = seed >>> 0;
+}
+next() {
+this.seed = (1664525 * this.seed + 1013904223) >>> 0;
+return this.seed / 4294967296;
+}
+range(min, max) {
+return min + (max - min) * this.next();
+}
+int(max) {
+return Math.floor(this.next() * max);
+}
}
-
class World {
- constructor(size, rng, generate = true) {
- this.size = size;
- this.count = size * size;
- this.rng = rng;
- this.terrain = new Uint8Array(this.count);
- this.resource = new Float32Array(this.count);
- this.regen = new Float32Array(this.count);
- this.move = new Float32Array(this.count);
- this.fertility = new Float32Array(this.count);
- this.mineral = new Float32Array(this.count);
- this.temperature = new Float32Array(this.count);
- this.humidity = new Float32Array(this.count);
- this.pheromone = new Float32Array(this.count);
- this.tradeRoute = new Uint8Array(this.count);
- this.farmland = new Float32Array(this.count);
- this.cityPull = new Float32Array(this.count);
- this.city = new Int32Array(this.count);
- this.pressure = new Float32Array(this.count);
- this.dominantEthnicity = new Int32Array(this.count);
- this.cultureDiversity = new Float32Array(this.count);
- this.city.fill(-1);
- this.dominantEthnicity.fill(-1);
- if (generate) this.generate();
- }
-
- static blank(size, rng) {
- return new World(size, rng, false);
- }
-
- idx(x, y) {
- return y * this.size + x;
- }
-
- generate() {
- const s = this.size;
- const centers = Array.from({ length: 18 }, () => ({
- x: this.rng.range(0, s),
- y: this.rng.range(0, s),
- kind: this.rng.next()
- }));
- const height = new Float32Array(this.count);
- const moistureMap = new Float32Array(this.count);
- const mineralMap = new Float32Array(this.count);
-
- for (let y = 0; y < s; y++) {
- for (let x = 0; x < s; x++) {
- const i = this.idx(x, y);
- const nx = x / s - 0.5;
- const ny = y / s - 0.5;
- const latitude = Math.abs(ny) * 0.18;
- let elevation = 0.48 - Math.hypot(nx, ny) * 0.62 + this.smoothNoise(x, y, 58) * 0.42 + this.smoothNoise(x + 900, y - 300, 31) * 0.12;
- let moisture = this.smoothNoise(x + 500, y - 330, 54) * 0.68 + this.smoothNoise(x, y, 24) * 0.24 - latitude;
- let minerals = this.smoothNoise(x - 290, y + 120, 20);
-
- for (const c of centers) {
- const d = Math.hypot(x - c.x, y - c.y) / s;
- if (c.kind < 0.3) elevation += Math.max(0, 0.24 - d) * 0.85;
- if (c.kind > 0.72) moisture += Math.max(0, 0.22 - d) * 1.0;
- }
- height[i] = elevation;
- moistureMap[i] = moisture;
- mineralMap[i] = minerals;
- }
- }
-
- this.smoothField(height, 2);
- this.smoothField(moistureMap, 1);
- const seaLevel = this.percentile(height, 0.34);
-
- for (let y = 0; y < s; y++) {
- for (let x = 0; x < s; x++) {
- const i = this.idx(x, y);
- const latitude = Math.abs((y / (s - 1)) * 2 - 1);
- const altitudeCooling = clamp((height[i] - seaLevel) * 0.25, 0, 0.18);
- this.temperature[i] = clamp(1 - latitude * 0.92 - altitudeCooling + this.smoothNoise(x + 1700, y - 80, 70) * 0.08, 0, 1);
- this.terrain[i] = height[i] < seaLevel ? Terrain.WATER : Terrain.PLAINS;
- }
- }
-
- this.computeHumidityFromWater(moistureMap);
-
- for (let i = 0; i < this.count; i++) {
- const elevation = height[i];
- const minerals = mineralMap[i];
- const temp = this.temperature[i];
- const humid = this.humidity[i];
- let t = this.terrain[i];
- if (t !== Terrain.WATER) {
- if (elevation > seaLevel + 0.42) t = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN;
- else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT;
- else if (humid > 0.72 && temp > 0.25 && temp < 0.88) t = Terrain.FERTILE;
- else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST;
- else t = Terrain.PLAINS;
- }
-
- const info = terrainInfo[t];
- this.terrain[i] = t;
- this.fertility[i] = clamp(info.fertility + humid * 0.16 - Math.abs(temp - 0.58) * 0.08 + this.rng.range(-0.03, 0.03), 0, 1);
- this.mineral[i] = clamp(info.mineral + minerals * 0.16, 0, 1);
- this.regen[i] = info.regen * (0.7 + this.fertility[i]);
- this.move[i] = info.move;
- this.resource[i] = this.terrain[i] === Terrain.WATER ? 0 : this.rng.range(4, 18) * (0.5 + this.fertility[i] + this.mineral[i] * 0.35);
- }
- this.smoothTerrainTypes(2);
- this.ensureDesertPatches();
- this.enrichWaterMargins();
- }
-
- enrichWaterMargins() {
- const s = this.size;
- const nextFertility = new Float32Array(this.fertility);
- for (let y = 0; y < s; y++) {
- for (let x = 0; x < s; x++) {
- const i = this.idx(x, y);
- if (this.terrain[i] === Terrain.WATER) continue;
- let waterScore = 0;
- for (let dy = -3; dy <= 3; dy++) {
- for (let dx = -3; dx <= 3; dx++) {
- const d = Math.abs(dx) + Math.abs(dy);
- if (!d || d > 3) continue;
- const tx = x + dx;
- const ty = y + dy;
- if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
- if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) waterScore += (4 - d) / 4;
- }
- }
- if (waterScore <= 0) continue;
- const boost = Math.min(0.32, waterScore * 0.08);
- nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1);
- if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS;
- if ((this.terrain[i] === Terrain.PLAINS || this.terrain[i] === Terrain.FOREST) && nextFertility[i] > 0.82) this.terrain[i] = Terrain.FERTILE;
- }
- }
-
- for (let i = 0; i < this.count; i++) {
- const info = terrainInfo[this.terrain[i]];
- this.fertility[i] = nextFertility[i];
- this.regen[i] = info.regen * (0.7 + this.fertility[i]);
- this.move[i] = info.move;
- if (this.terrain[i] === Terrain.WATER) {
- this.resource[i] = 0;
- } else {
- this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25));
- }
- }
- }
-
- smoothNoise(x, y, scale) {
- const x0 = Math.floor(x / scale);
- const y0 = Math.floor(y / scale);
- const fx = smoothstep((x / scale) - x0);
- const fy = smoothstep((y / scale) - y0);
- const a = this.gridNoise(x0, y0);
- const b = this.gridNoise(x0 + 1, y0);
- const c = this.gridNoise(x0, y0 + 1);
- const d = this.gridNoise(x0 + 1, y0 + 1);
- return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
- }
-
- gridNoise(x, y) {
- const v = Math.sin(x * 127.1 + y * 311.7 + this.rng.seed * 0.000001) * 43758.5453123;
- return v - Math.floor(v);
- }
-
- computeHumidityFromWater(moistureMap) {
- const s = this.size;
- const radius = 10;
- for (let y = 0; y < s; y++) {
- for (let x = 0; x < s; x++) {
- const i = this.idx(x, y);
- if (this.terrain[i] === Terrain.WATER) {
- this.humidity[i] = 1;
- continue;
- }
- let best = 0;
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
- const d = Math.abs(dx) + Math.abs(dy);
- if (!d || d > radius) continue;
- const tx = x + dx;
- const ty = y + dy;
- if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
- if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) best = Math.max(best, 1 - d / (radius + 1));
- }
- }
- this.humidity[i] = clamp(best * 0.78 + moistureMap[i] * 0.22, 0, 1);
- }
- }
- this.smoothField(this.humidity, 1);
- }
-
- smoothTerrainTypes(passes) {
- const s = this.size;
- let source = new Uint8Array(this.terrain);
- let target = new Uint8Array(this.terrain.length);
- for (let pass = 0; pass < passes; pass++) {
- for (let y = 0; y < s; y++) {
- for (let x = 0; x < s; x++) {
- const counts = new Uint8Array(terrainInfo.length);
- for (let dy = -1; dy <= 1; dy++) {
- for (let dx = -1; dx <= 1; dx++) {
- const tx = x + dx;
- const ty = y + dy;
- if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
- counts[source[this.idx(tx, ty)]]++;
- }
- }
- let bestTerrain = source[this.idx(x, y)];
- let bestCount = counts[bestTerrain];
- for (let t = 0; t < counts.length; t++) {
- if (counts[t] > bestCount) {
- bestTerrain = t;
- bestCount = counts[t];
- }
- }
- target[this.idx(x, y)] = bestCount >= 4 ? bestTerrain : source[this.idx(x, y)];
- }
- }
- const swap = source;
- source = target;
- target = swap;
- }
- this.terrain.set(source);
- }
-
- ensureDesertPatches() {
- const candidates = [];
- let desertCount = 0;
- for (let i = 0; i < this.count; i++) {
- if (this.terrain[i] === Terrain.DESERT) desertCount++;
- if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.55 && this.humidity[i] < 0.28) {
- candidates.push(i);
- }
- }
- const target = Math.max(12, Math.floor(this.count * 0.012));
- if (desertCount >= target || !candidates.length) return;
- candidates.sort((a, b) => {
- const dryA = this.temperature[a] * (1 - this.humidity[a]);
- const dryB = this.temperature[b] * (1 - this.humidity[b]);
- return dryB - dryA;
- });
- const needed = Math.min(target - desertCount, candidates.length);
- for (let n = 0; n < needed; n++) {
- const center = candidates[n];
- const cx = center % this.size;
- const cy = Math.floor(center / this.size);
- for (let dy = -2; dy <= 2; dy++) {
- for (let dx = -2; dx <= 2; dx++) {
- if (Math.abs(dx) + Math.abs(dy) > 2) continue;
- const x = cx + dx;
- const y = cy + dy;
- if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;
- const i = this.idx(x, y);
- if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.5 && this.humidity[i] < 0.34) {
- this.terrain[i] = Terrain.DESERT;
- }
- }
- }
- }
- }
-
- smoothField(field, passes) {
- const s = this.size;
- let source = field;
- let target = new Float32Array(field.length);
- for (let pass = 0; pass < passes; pass++) {
- for (let y = 0; y < s; y++) {
- for (let x = 0; x < s; x++) {
- let sum = 0;
- let count = 0;
- for (let dy = -1; dy <= 1; dy++) {
- for (let dx = -1; dx <= 1; dx++) {
- const tx = x + dx;
- const ty = y + dy;
- if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
- sum += source[this.idx(tx, ty)];
- count++;
- }
- }
- target[this.idx(x, y)] = sum / count;
- }
- }
- const nextSource = target;
- target = source === field ? new Float32Array(field.length) : field;
- source = nextSource;
- }
- if (source !== field) field.set(source);
- }
-
- percentile(field, ratio) {
- const values = Array.from(field).sort((a, b) => a - b);
- return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))];
- }
+constructor(size, rng, generate = true) {
+this.size = size;
+this.count = size * size;
+this.rng = rng;
+this.terrain = new Uint8Array(this.count);
+this.resource = new Float32Array(this.count);
+this.regen = new Float32Array(this.count);
+this.move = new Float32Array(this.count);
+this.fertility = new Float32Array(this.count);
+this.mineral = new Float32Array(this.count);
+this.temperature = new Float32Array(this.count);
+this.humidity = new Float32Array(this.count);
+this.pheromone = new Float32Array(this.count);
+this.tradeRoute = new Uint8Array(this.count);
+this.farmland = new Float32Array(this.count);
+this.cityPull = new Float32Array(this.count);
+this.city = new Int32Array(this.count);
+this.pressure = new Float32Array(this.count);
+this.dominantEthnicity = new Int32Array(this.count);
+this.cultureDiversity = new Float32Array(this.count);
+this.city.fill(-1);
+this.dominantEthnicity.fill(-1);
+if (generate) this.generate();
+}
+static blank(size, rng) {
+return new World(size, rng, false);
+}
+idx(x, y) {
+return y * this.size + x;
+}
+generate() {
+const s = this.size;
+const centers = Array.from({ length: 18 }, () => ({
+x: this.rng.range(0, s),
+y: this.rng.range(0, s),
+kind: this.rng.next()
+}));
+const height = new Float32Array(this.count);
+const moistureMap = new Float32Array(this.count);
+const mineralMap = new Float32Array(this.count);
+for (let y = 0; y < s; y++) {
+for (let x = 0; x < s; x++) {
+const i = this.idx(x, y);
+const nx = x / s - 0.5;
+const ny = y / s - 0.5;
+const latitude = Math.abs(ny) * 0.18;
+let elevation = 0.48 - Math.hypot(nx, ny) * 0.62 + this.smoothNoise(x, y, 58) * 0.42 + this.smoothNoise(x + 900, y - 300, 31) * 0.12;
+let moisture = this.smoothNoise(x + 500, y - 330, 54) * 0.68 + this.smoothNoise(x, y, 24) * 0.24 - latitude;
+let minerals = this.smoothNoise(x - 290, y + 120, 20);
+for (const c of centers) {
+const d = Math.hypot(x - c.x, y - c.y) / s;
+if (c.kind < 0.3) elevation += Math.max(0, 0.24 - d) * 0.85;
+if (c.kind > 0.72) moisture += Math.max(0, 0.22 - d) * 1.0;
+}
+height[i] = elevation;
+moistureMap[i] = moisture;
+mineralMap[i] = minerals;
+}
+}
+this.smoothField(height, 2);
+this.smoothField(moistureMap, 1);
+const seaLevel = this.percentile(height, 0.34);
+for (let y = 0; y < s; y++) {
+for (let x = 0; x < s; x++) {
+const i = this.idx(x, y);
+const latitude = Math.abs((y / (s - 1)) * 2 - 1);
+const altitudeCooling = clamp((height[i] - seaLevel) * 0.25, 0, 0.18);
+this.temperature[i] = clamp(1 - latitude * 0.92 - altitudeCooling + this.smoothNoise(x + 1700, y - 80, 70) * 0.08, 0, 1);
+this.terrain[i] = height[i] < seaLevel ? Terrain.WATER : Terrain.PLAINS;
+}
+}
+this.computeHumidityFromWater(moistureMap);
+for (let i = 0; i < this.count; i++) {
+const elevation = height[i];
+const minerals = mineralMap[i];
+const temp = this.temperature[i];
+const humid = this.humidity[i];
+let t = this.terrain[i];
+if (t !== Terrain.WATER) {
+if (elevation > seaLevel + 0.42) t = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN;
+else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT;
+else if (humid > 0.72 && temp > 0.25 && temp < 0.88) t = Terrain.FERTILE;
+else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST;
+else t = Terrain.PLAINS;
+}
+const info = terrainInfo[t];
+this.terrain[i] = t;
+this.fertility[i] = clamp(info.fertility + humid * 0.16 - Math.abs(temp - 0.58) * 0.08 + this.rng.range(-0.03, 0.03), 0, 1);
+this.mineral[i] = clamp(info.mineral + minerals * 0.16, 0, 1);
+this.regen[i] = info.regen * (0.7 + this.fertility[i]);
+this.move[i] = info.move;
+this.resource[i] = this.terrain[i] === Terrain.WATER ? 0 : this.rng.range(4, 18) * (0.5 + this.fertility[i] + this.mineral[i] * 0.35);
+}
+this.smoothTerrainTypes(2);
+this.ensureDesertPatches();
+this.enrichWaterMargins();
+}
+enrichWaterMargins() {
+const s = this.size;
+const nextFertility = new Float32Array(this.fertility);
+for (let y = 0; y < s; y++) {
+for (let x = 0; x < s; x++) {
+const i = this.idx(x, y);
+if (this.terrain[i] === Terrain.WATER) continue;
+let waterScore = 0;
+for (let dy = -3; dy <= 3; dy++) {
+for (let dx = -3; dx <= 3; dx++) {
+const d = Math.abs(dx) + Math.abs(dy);
+if (!d || d > 3) continue;
+const tx = x + dx;
+const ty = y + dy;
+if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
+if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) waterScore += (4 - d) / 4;
+}
+}
+if (waterScore <= 0) continue;
+const boost = Math.min(0.32, waterScore * 0.08);
+nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1);
+if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS;
+if ((this.terrain[i] === Terrain.PLAINS || this.terrain[i] === Terrain.FOREST) && nextFertility[i] > 0.82) this.terrain[i] = Terrain.FERTILE;
+}
+}
+for (let i = 0; i < this.count; i++) {
+const info = terrainInfo[this.terrain[i]];
+this.fertility[i] = nextFertility[i];
+this.regen[i] = info.regen * (0.7 + this.fertility[i]);
+this.move[i] = info.move;
+if (this.terrain[i] === Terrain.WATER) {
+this.resource[i] = 0;
+} else {
+this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25));
+}
+}
+}
+smoothNoise(x, y, scale) {
+const x0 = Math.floor(x / scale);
+const y0 = Math.floor(y / scale);
+const fx = smoothstep((x / scale) - x0);
+const fy = smoothstep((y / scale) - y0);
+const a = this.gridNoise(x0, y0);
+const b = this.gridNoise(x0 + 1, y0);
+const c = this.gridNoise(x0, y0 + 1);
+const d = this.gridNoise(x0 + 1, y0 + 1);
+return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
+}
+gridNoise(x, y) {
+const v = Math.sin(x * 127.1 + y * 311.7 + this.rng.seed * 0.000001) * 43758.5453123;
+return v - Math.floor(v);
+}
+computeHumidityFromWater(moistureMap) {
+const s = this.size;
+const radius = 10;
+for (let y = 0; y < s; y++) {
+for (let x = 0; x < s; x++) {
+const i = this.idx(x, y);
+if (this.terrain[i] === Terrain.WATER) {
+this.humidity[i] = 1;
+continue;
+}
+let best = 0;
+for (let dy = -radius; dy <= radius; dy++) {
+for (let dx = -radius; dx <= radius; dx++) {
+const d = Math.abs(dx) + Math.abs(dy);
+if (!d || d > radius) continue;
+const tx = x + dx;
+const ty = y + dy;
+if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
+if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) best = Math.max(best, 1 - d / (radius + 1));
+}
+}
+this.humidity[i] = clamp(best * 0.78 + moistureMap[i] * 0.22, 0, 1);
+}
+}
+this.smoothField(this.humidity, 1);
+}
+smoothTerrainTypes(passes) {
+const s = this.size;
+let source = new Uint8Array(this.terrain);
+let target = new Uint8Array(this.terrain.length);
+for (let pass = 0; pass < passes; pass++) {
+for (let y = 0; y < s; y++) {
+for (let x = 0; x < s; x++) {
+const counts = new Uint8Array(terrainInfo.length);
+for (let dy = -1; dy <= 1; dy++) {
+for (let dx = -1; dx <= 1; dx++) {
+const tx = x + dx;
+const ty = y + dy;
+if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
+counts[source[this.idx(tx, ty)]]++;
+}
+}
+let bestTerrain = source[this.idx(x, y)];
+let bestCount = counts[bestTerrain];
+for (let t = 0; t < counts.length; t++) {
+if (counts[t] > bestCount) {
+bestTerrain = t;
+bestCount = counts[t];
+}
+}
+target[this.idx(x, y)] = bestCount >= 4 ? bestTerrain : source[this.idx(x, y)];
+}
+}
+const swap = source;
+source = target;
+target = swap;
+}
+this.terrain.set(source);
+}
+ensureDesertPatches() {
+const candidates = [];
+let desertCount = 0;
+for (let i = 0; i < this.count; i++) {
+if (this.terrain[i] === Terrain.DESERT) desertCount++;
+if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.55 && this.humidity[i] < 0.28) {
+candidates.push(i);
+}
+}
+const target = Math.max(12, Math.floor(this.count * 0.012));
+if (desertCount >= target || !candidates.length) return;
+candidates.sort((a, b) => {
+const dryA = this.temperature[a] * (1 - this.humidity[a]);
+const dryB = this.temperature[b] * (1 - this.humidity[b]);
+return dryB - dryA;
+});
+const needed = Math.min(target - desertCount, candidates.length);
+for (let n = 0; n < needed; n++) {
+const center = candidates[n];
+const cx = center % this.size;
+const cy = Math.floor(center / this.size);
+for (let dy = -2; dy <= 2; dy++) {
+for (let dx = -2; dx <= 2; dx++) {
+if (Math.abs(dx) + Math.abs(dy) > 2) continue;
+const x = cx + dx;
+const y = cy + dy;
+if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;
+const i = this.idx(x, y);
+if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.5 && this.humidity[i] < 0.34) {
+this.terrain[i] = Terrain.DESERT;
+}
+}
+}
+}
+}
+smoothField(field, passes) {
+const s = this.size;
+let source = field;
+let target = new Float32Array(field.length);
+for (let pass = 0; pass < passes; pass++) {
+for (let y = 0; y < s; y++) {
+for (let x = 0; x < s; x++) {
+let sum = 0;
+let count = 0;
+for (let dy = -1; dy <= 1; dy++) {
+for (let dx = -1; dx <= 1; dx++) {
+const tx = x + dx;
+const ty = y + dy;
+if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
+sum += source[this.idx(tx, ty)];
+count++;
+}
+}
+target[this.idx(x, y)] = sum / count;
+}
+}
+const nextSource = target;
+target = source === field ? new Float32Array(field.length) : field;
+source = nextSource;
+}
+if (source !== field) field.set(source);
+}
+percentile(field, ratio) {
+const values = Array.from(field).sort((a, b) => a - b);
+return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))];
+}
}
-
class Simulation {
- constructor(size, initialAgents, options = {}) {
- this.rng = new Rng(Date.now());
- this.world = options.blankWorld ? World.blank(size, this.rng) : new World(size, this.rng);
- this.agents = [];
- this.ethnicities = new Map();
- this.cities = [];
- this.cityById = new Map();
- this.cityBuckets = new Map();
- this.polities = [];
- this.polityById = new Map();
- this.wars = [];
- this.disasters = [];
- this.disasterHistory = [];
- this.polityHistory = new Map();
- this.deadPolityHistories = [];
- this.tradeLinks = [];
- this.activeTradeRouteTiles = new Set();
- this.ethnicDensityCache = new Map();
- this.dominantEthnicityCache = new Map();
- this.nextEthnicity = 1;
- this.nextCity = 1;
- this.nextPolity = 1;
- this.nextWar = 1;
- this.nextDisaster = 1;
- this.year = 0;
- this.deaths = 0;
- this.maxAgents = Math.max(
- Math.floor(initialAgents * SimConfig.population.maxAgentsScale),
- SimConfig.population.maxAgentsFloor
- );
- this.tileEthnicities = new Map();
- this.tileAgents = new Map();
- if (!options.skipSpawn) {
- this.spawnInitialAgents(initialAgents);
- this.rebuildOccupancy();
- this.updateEthnicStats();
- }
- }
-
- spawnInitialAgents(count) {
- const founders = Math.max(5, Math.min(16, Math.round(count / 180)));
- const desertFounders = Math.max(2, Math.floor(founders * 0.25));
- for (let e = 0; e < founders; e++) {
- const desertFounder = e < desertFounders;
- const origin = this.findHabitableTile(desertFounder ? Terrain.DESERT : null);
- const originTile = this.world.idx(origin.x, origin.y);
- const id = this.createEthnicity(0, {
- temperature: this.world.temperature[originTile],
- humidity: this.world.humidity[originTile]
- });
- const baseTraits = desertFounder ? this.desertFounderTraits() : this.randomTraits();
- for (let n = 0; n < Math.floor(count / founders); n++) {
- const spawn = this.findNearbySpawn(origin.x, origin.y, desertFounder ? Terrain.DESERT : null);
- this.agents.push(this.makeAgent(
- spawn.x,
- spawn.y,
- id,
- mutateTraits(baseTraits, this.rng, 0.07),
- desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15)
- ));
- }
- }
- }
-
- createEthnicity(parent, climate = null) {
- const id = this.nextEthnicity++;
- this.ethnicities.set(id, {
- id,
- parent,
- born: this.year,
- population: 0,
- diversity: 0,
- climateTemp: climate?.temperature ?? 0.55,
- climateHumidity: climate?.humidity ?? 0.45,
- color: hslToRgb((id * 0.61803398875) % 1, 0.55, 0.56),
- centroidX: 0,
- centroidY: 0
- });
- return id;
- }
-
- randomTraits() {
- return {
- mobility: this.rng.range(0.18, 0.85),
- resourceAttraction: this.rng.range(0.55, 1),
- assimilation: this.rng.range(0.03, 0.34),
- ethnocentrism: this.rng.range(0.12, 0.78),
- reproductionThreshold: this.rng.range(19, 34),
- sedentary: this.rng.range(0.08, 0.82)
- };
- }
-
- desertFounderTraits() {
- const traits = this.randomTraits();
- traits.mobility = this.rng.range(0.48, 0.9);
- traits.resourceAttraction = this.rng.range(0.82, 1.12);
- traits.reproductionThreshold = this.rng.range(24, 38);
- traits.sedentary = this.rng.range(0.18, 0.58);
- return traits;
- }
-
- makeAgent(x, y, ethnicity, traits, resources) {
- return {
- x,
- y,
- resources,
- ethnicity,
- traits,
- alive: true,
- foreignContact: 0,
- contactEthnicity: ethnicity,
- settled: 0,
- movedThisStep: false,
- tech: {
- farming: 0,
- metallurgy: 0
- },
- farmingWork: 0,
- lastFarmTile: -1
- };
- }
-
- findNearbySpawn(originX, originY, preferredTerrain = null) {
- const w = this.world;
- let best = { x: originX, y: originY };
- let bestScore = -Infinity;
- for (let tries = 0; tries < 24; tries++) {
- const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1);
- const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1);
- const i = w.idx(x, y);
- if (w.terrain[i] === Terrain.WATER) continue;
- const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0;
- const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16;
- if (score > bestScore) {
- bestScore = score;
- best = { x, y };
- }
- }
- return best;
- }
-
- findHabitableTile(preferredTerrain = null) {
- if (preferredTerrain !== null) {
- const exact = [];
- let best = null;
- let bestScore = -Infinity;
- for (let i = 0; i < this.world.count; i++) {
- const x = i % this.world.size;
- const y = Math.floor(i / this.world.size);
- if (this.world.terrain[i] === preferredTerrain) {
- exact.push({ x, y });
- continue;
- }
- if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) {
- const score = this.world.temperature[i] * (1 - this.world.humidity[i]);
- if (score > bestScore) {
- bestScore = score;
- best = { x, y };
- }
- }
- }
- if (exact.length) return exact[this.rng.int(exact.length)];
- if (best) return best;
- }
- for (let tries = 0; tries < 5000; tries++) {
- const x = this.rng.int(this.world.size);
- const y = this.rng.int(this.world.size);
- const i = this.world.idx(x, y);
- if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) {
- return { x, y };
- }
- }
- return { x: this.world.size >> 1, y: this.world.size >> 1 };
- }
-
- step() {
- for (const city of this.cities) city.activeVisitors = 0;
- this.rebuildOccupancy();
- this.ethnicDensityCache.clear();
-
- const offspring = [];
- for (const a of this.agents) {
- if (!a.alive) continue;
- this.moveAgent(a);
- }
- this.rebuildOccupancy();
- this.dominantEthnicityCache.clear();
-
- for (const a of this.agents) {
- if (!a.alive) continue;
- this.updateAgentTechnology(a);
- this.gatherConsumeReproduce(a, offspring);
- if (a.alive) this.payTechnologyCostOrForget(a);
- if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a);
- }
-
- this.agents = this.agents.filter(a => a.alive);
- const acceptedOffspring = this.agents.length + offspring.length < this.maxAgents
- ? offspring
- : offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length));
- this.agents.push(...acceptedOffspring);
- for (const child of acceptedOffspring) this.addAgentToOccupancy(child);
-
- if (this.year % 2 === 0) this.updateWorldFields(2);
- this.maybeSpawnDisaster();
- if (this.year % years(1) === 0) {
- this.updateCities();
- if (this.year % years(2) === 0) this.updateRegionalCultures();
- }
- if (this.year % years(5) === 0) {
- this.updateTradeRoutes();
- }
- this.updatePolities();
- this.updateEthnicStats();
- if (this.year % years(45) === 0) {
- this.splitDivergentEthnicities();
- this.updateEthnicStats();
- }
- this.year++;
- }
-
- rebuildOccupancy() {
- const w = this.world;
- w.pressure.fill(0);
- this.tileEthnicities.clear();
- this.tileAgents.clear();
- this.rebuildIndexes();
- for (const a of this.agents) {
- if (!a.alive) continue;
- const i = w.idx(a.x, a.y);
- w.pressure[i]++;
- let agents = this.tileAgents.get(i);
- if (!agents) {
- agents = [];
- this.tileAgents.set(i, agents);
- }
- agents.push(a);
- let counts = this.tileEthnicities.get(i);
- if (!counts) {
- counts = new Map();
- this.tileEthnicities.set(i, counts);
- }
- counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
- }
- for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile);
- }
-
- rebuildIndexes() {
- this.cityById = new Map(this.cities.map(city => [city.id, city]));
- this.polityById = new Map(this.polities.map(polity => [polity.id, polity]));
- this.cityBuckets = new Map();
- for (const city of this.cities) {
- const key = this.cityBucketKey(city.x, city.y);
- let bucket = this.cityBuckets.get(key);
- if (!bucket) {
- bucket = [];
- this.cityBuckets.set(key, bucket);
- }
- bucket.push(city);
- }
- }
-
- cityBucketKey(x, y) {
- return `${Math.floor(x / 8)},${Math.floor(y / 8)}`;
- }
-
- getCitiesNear(x, y, radius) {
- const cities = [];
- const minBx = Math.floor((x - radius) / 8);
- const maxBx = Math.floor((x + radius) / 8);
- const minBy = Math.floor((y - radius) / 8);
- const maxBy = Math.floor((y + radius) / 8);
- for (let by = minBy; by <= maxBy; by++) {
- for (let bx = minBx; bx <= maxBx; bx++) {
- const bucket = this.cityBuckets.get(`${bx},${by}`);
- if (!bucket) continue;
- for (const city of bucket) {
- if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city);
- }
- }
- }
- return cities;
- }
-
- updateCultureTile(tile) {
- const counts = this.tileEthnicities.get(tile);
- if (!counts || !counts.size) {
- this.world.cultureDiversity[tile] *= 0.98;
- return;
- }
- let total = 0;
- let dominant = -1;
- let dominantCount = 0;
- for (const [id, count] of counts) {
- total += count;
- if (count > dominantCount) {
- dominant = id;
- dominantCount = count;
- }
- }
- this.world.dominantEthnicity[tile] = dominant;
- this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0;
- }
-
- updateRegionalCultures() {
- const w = this.world;
- const nextDominant = new Int32Array(w.dominantEthnicity);
- const nextDiversity = new Float32Array(w.cultureDiversity);
- const radius = SimConfig.culture.spreadRadius;
- const cityById = new Map(this.cities.map(city => [city.id, city]));
-
- for (let y = 0; y < w.size; y++) {
- for (let x = 0; x < w.size; x++) {
- const tile = w.idx(x, y);
- if (w.terrain[tile] === Terrain.WATER) continue;
- const influence = new Map();
- let total = 0;
-
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
- const distance = Math.abs(dx) + Math.abs(dy);
- if (distance > radius) continue;
- const tx = x + dx;
- const ty = y + dy;
- if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
- const source = w.idx(tx, ty);
- const id = w.dominantEthnicity[source];
- if (id < 0) continue;
- const pressureWeight = Math.sqrt(Math.max(0, w.pressure[source]));
- const cityWeight = w.city[source] >= 0
- ? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight
- : 0;
- const routeWeight = w.tradeRoute[source] ? SimConfig.culture.routeWeight : 1;
- const weight = (pressureWeight + cityWeight) * routeWeight / (1 + distance);
- if (weight <= 0) continue;
- influence.set(id, (influence.get(id) || 0) + weight);
- total += weight;
- }
- }
-
- let bestId = nextDominant[tile];
- let bestWeight = 0;
- for (const [id, weight] of influence) {
- if (weight > bestWeight) {
- bestId = id;
- bestWeight = weight;
- }
- }
- if (bestWeight >= SimConfig.culture.minimumInfluence) {
- nextDominant[tile] = bestId;
- nextDiversity[tile] = total > 0 ? clamp(1 - bestWeight / total, 0, 1) : 0;
- }
- }
- }
-
- w.dominantEthnicity.set(nextDominant);
- w.cultureDiversity.set(nextDiversity);
- }
-
- addAgentToOccupancy(a) {
- if (!a.alive) return;
- const w = this.world;
- const i = w.idx(a.x, a.y);
- w.pressure[i]++;
- let agents = this.tileAgents.get(i);
- if (!agents) {
- agents = [];
- this.tileAgents.set(i, agents);
- }
- agents.push(a);
- let counts = this.tileEthnicities.get(i);
- if (!counts) {
- counts = new Map();
- this.tileEthnicities.set(i, counts);
- }
- counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
- this.updateCultureTile(i);
- }
-
- removeAgentFromOccupancy(a) {
- const w = this.world;
- const i = w.idx(a.x, a.y);
- w.pressure[i] = Math.max(0, w.pressure[i] - 1);
- const agents = this.tileAgents.get(i);
- if (agents) {
- const index = agents.indexOf(a);
- if (index >= 0) agents.splice(index, 1);
- if (!agents.length) this.tileAgents.delete(i);
- }
- const counts = this.tileEthnicities.get(i);
- if (counts) {
- const next = (counts.get(a.ethnicity) || 0) - 1;
- if (next > 0) counts.set(a.ethnicity, next);
- else counts.delete(a.ethnicity);
- if (!counts.size) this.tileEthnicities.delete(i);
- }
- this.updateCultureTile(i);
- }
-
- changeAgentEthnicity(a, nextEthnicity) {
- if (a.ethnicity === nextEthnicity) return;
- const w = this.world;
- const i = w.idx(a.x, a.y);
- const counts = this.tileEthnicities.get(i);
- if (counts) {
- const prev = (counts.get(a.ethnicity) || 0) - 1;
- if (prev > 0) counts.set(a.ethnicity, prev);
- else counts.delete(a.ethnicity);
- counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1);
- }
- a.ethnicity = nextEthnicity;
- this.updateCultureTile(i);
- }
-
- moveAgent(a) {
- const w = this.world;
- const s = w.size;
- const current = w.idx(a.x, a.y);
- const localPressure = w.pressure[current];
- let bestX = a.x;
- let bestY = a.y;
- let bestScore = -Infinity;
- const sedentary = getSedentary(a.traits);
- const ethnocentrism = getEthnocentrism(a.traits);
- const ethnicClimate = this.ethnicities.get(a.ethnicity);
- const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45;
- const localCapacity = this.carryingCapacityAt(current);
- const localOverCapacity = Math.max(0, localPressure - localCapacity);
- if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) {
- a.settled++;
- a.movedThisStep = false;
- return;
- }
- const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1;
-
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
- const x = clamp(a.x + dx, 0, s - 1);
- const y = clamp(a.y + dy, 0, s - 1);
- const i = w.idx(x, y);
- const isWater = w.terrain[i] === Terrain.WATER;
- const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45;
- const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0);
- if (isWater && this.rng.next() > waterAccess) continue;
-
- const ethnicDensity = ethnocentrism > 0.04
- ? this.ethnicDensityNear(x, y, a.ethnicity)
- : { same: 0, foreign: 0 };
- const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
- const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary;
- const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0);
- const destinationCapacity = this.carryingCapacityAt(i);
- const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity);
- const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1);
- const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85);
- const routeBonus = w.tradeRoute[i] ? 1.25 : 0;
- const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0;
- const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus;
- const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0;
- const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0;
- const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45);
-
- const score =
- resourceScore * a.traits.resourceAttraction +
- w.pheromone[i] * 0.021 +
- ethnicDensity.same * ethnocentrism +
- cityPull * sedentary +
- routePull +
- capacityPull +
- inertia -
- terrainPenalty -
- crowdPenalty +
- pressurePush -
- ethnicDensity.foreign * ethnocentrism * 0.72 +
- this.rng.range(-0.55, 0.55);
-
- if (score > bestScore) {
- bestScore = score;
- bestX = x;
- bestY = y;
- }
- }
- }
-
- const from = w.idx(a.x, a.y);
- a.x = bestX;
- a.y = bestY;
- const to = w.idx(a.x, a.y);
- if (from !== to) {
- a.movedThisStep = true;
- a.farmingWork = 0;
- a.lastFarmTile = to;
- const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1;
- w.pheromone[from] += (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale;
- w.pheromone[to] += (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale;
- a.settled = Math.max(0, a.settled - 1);
- } else {
- a.movedThisStep = false;
- a.settled++;
- }
- }
-
- ensureAgentTech(a) {
- a.tech ??= { farming: 0, metallurgy: 0 };
- a.tech.farming ??= 0;
- a.tech.metallurgy ??= 0;
- a.tech.farming = clamp(a.tech.farming, 0, 1);
- a.tech.metallurgy = clamp(a.tech.metallurgy, 0, 1);
- a.farmingWork ??= 0;
- a.lastFarmTile ??= -1;
- a.movedThisStep ??= false;
- }
-
- farmabilityAt(tile) {
- const t = this.world.terrain[tile];
- if (t === Terrain.FERTILE) return 1.0;
- if (t === Terrain.PLAINS) return 0.75;
- if (t === Terrain.FOREST) return 0.55;
- if (t === Terrain.DESERT) return 0.25;
- return 0.0;
- }
-
- carryingCapacityAt(tile) {
- const w = this.world;
- if (w.terrain[tile] === Terrain.WATER) return 1.2;
- return SimConfig.population.carryingCapacityBase +
- w.fertility[tile] * SimConfig.population.carryingCapacityFertility +
- w.mineral[tile] * SimConfig.population.carryingCapacityMineral +
- w.farmland[tile] * SimConfig.population.carryingCapacityFarmland +
- (w.city[tile] >= 0 ? 3.5 : 0);
- }
-
- updateAgentTechnology(agent, payMaintenance = false) {
- if (!agent.alive) return;
- this.ensureAgentTech(agent);
- this.updateFarmingWork(agent);
- this.tryInventTechnology(agent);
- this.learnTechnologyFromCity(agent);
- if (this.shouldRunAgentSocial(agent, 3)) {
- this.spreadTechnology(agent);
- this.improveTechnologyFromDensity(agent);
- }
- if (payMaintenance) this.payTechnologyCostOrForget(agent);
- }
-
- shouldRunAgentSocial(agent, interval) {
- return ((agent.x * 31 + agent.y * 17 + this.year) % interval) === 0;
- }
-
- updateFarmingWork(agent) {
- const w = this.world;
- const tile = w.idx(agent.x, agent.y);
- if (agent.lastFarmTile !== tile) {
- agent.farmingWork = 0;
- agent.lastFarmTile = tile;
- }
- if (agent.movedThisStep) return;
-
- const farming = agent.tech?.farming || 0;
- if (farming <= 0.02) return;
- if (agent.settled > 0) {
- agent.farmingWork = clamp((agent.farmingWork || 0) + farming * 0.015, 0, 1);
- agent.tech.farming = clamp(agent.tech.farming + farming * this.farmabilityAt(tile) * 0.00018, 0, 1);
- }
- }
-
- tryInventTechnology(agent) {
- const w = this.world;
- const tile = w.idx(agent.x, agent.y);
- const settledFactor = clamp((agent.settled || 0) / 12, 0, 1);
-
- if ((agent.tech?.farming || 0) <= 0.02) {
- const farmingBase = 0.00016;
- const farmability = this.farmabilityAt(tile);
- const sedentary = getSedentary(agent.traits);
- const farmingChance =
- farmingBase *
- (0.25 + farmability) *
- (0.4 + sedentary) *
- (0.3 + settledFactor);
- if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32);
- }
-
- if ((agent.tech?.metallurgy || 0) <= 0.02) {
- const metallurgyBase = 0.00011;
- const mineral = clamp(w.mineral[tile], 0, 1);
- const mineralTerrainBonus =
- w.terrain[tile] === Terrain.MINERAL || w.terrain[tile] === Terrain.MOUNTAIN
- ? 1.8
- : 1.0;
- const metallurgyChance =
- metallurgyBase *
- (0.2 + mineral) *
- mineralTerrainBonus *
- (0.5 + settledFactor);
- if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.28);
- }
- }
-
- grantTechnologyAround(agent, techName, amount) {
- this.ensureAgentTech(agent);
- this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
- if (!other.alive) return true;
- this.ensureAgentTech(other);
- const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
- if (distance > 2) return true;
- const gain = amount * (other === agent ? 1 : 0.55);
- other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1);
- return true;
- });
- }
-
- payTechnologyCostOrForget(agent) {
- if (!agent.alive) return;
- this.ensureAgentTech(agent);
- const farming = agent.tech.farming || 0;
- const metallurgy = agent.tech.metallurgy || 0;
- const cost = farming * 0.004 + metallurgy * 0.010;
- if (cost <= 0) return;
-
- if (agent.resources >= cost) {
- agent.resources -= cost;
- return;
- }
-
- const shortage = cost - Math.max(0, agent.resources);
- agent.resources = Math.max(0, agent.resources - cost);
- const decay = clamp(0.006 + shortage * 0.025, 0.006, 0.06);
- agent.tech.farming = Math.max(0, farming - decay);
- agent.tech.metallurgy = Math.max(0, metallurgy - decay * 1.15);
- if (agent.tech.farming < 0.02) agent.tech.farming = 0;
- if (agent.tech.metallurgy < 0.02) agent.tech.metallurgy = 0;
- if (agent.tech.farming <= 0) agent.farmingWork = 0;
- }
-
- spreadTechnology(agent) {
- const w = this.world;
- const tile = w.idx(agent.x, agent.y);
- const assimilation = agent.traits.assimilation || 0;
- const ethnocentrism = agent.traits.ethnocentrism || 0;
- let checked = 0;
-
- this.forEachLocalAgentNear(agent.x, agent.y, 1, other => {
- if (other === agent || !other.alive) return true;
- if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true;
- if (++checked > 12) return false;
- this.ensureAgentTech(other);
- const sameEthnicity = agent.ethnicity === other.ethnicity;
- let chance =
- 0.0035 +
- assimilation * 0.006 -
- ethnocentrism * 0.0012;
-
- if (sameEthnicity) chance += 0.004;
- if (w.tradeRoute[tile]) chance += 0.006;
- if (w.city[tile] >= 0) chance += 0.003;
- chance = clamp(chance, 0.0008, 0.028);
-
- this.learnTechnologyFrom(agent, other, "farming", chance);
- this.learnTechnologyFrom(agent, other, "metallurgy", chance);
- return true;
- });
- }
-
- learnTechnologyFrom(agent, other, techName, chance) {
- const current = agent.tech[techName] || 0;
- const otherLevel = other.tech?.[techName] || 0;
- if (otherLevel > current + 0.04 && this.rng.next() < chance) {
- agent.tech[techName] = clamp(current + (otherLevel - current) * 0.14, 0, 1);
- }
- }
-
- learnTechnologyFromCity(agent) {
- const tile = this.world.idx(agent.x, agent.y);
- const city = this.world.city[tile] >= 0 ? this.getCityById(this.world.city[tile]) : null;
- if (!city?.knowledge) return;
- const routeBonus = this.world.tradeRoute[tile] ? SimConfig.technology.tradeDiffusion : 0;
- const chance = SimConfig.technology.cityDiffusion + routeBonus + clamp(city.population / 1200, 0, 0.006);
- if (this.rng.next() < chance) {
- agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * 0.72), 0, 1);
- agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1);
- }
- }
-
- improveTechnologyFromDensity(agent) {
- let farmingCount = 0;
- let metallurgyCount = 0;
- let farmingSum = 0;
- let metallurgySum = 0;
-
- this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
- if (!other.alive) return true;
- this.ensureAgentTech(other);
- const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
- if (distance > 2) return true;
- const farming = other.tech.farming || 0;
- const metallurgy = other.tech.metallurgy || 0;
- if (farming > 0.1) {
- farmingCount++;
- farmingSum += farming;
- }
- if (metallurgy > 0.1) {
- metallurgyCount++;
- metallurgySum += metallurgy;
- }
- return true;
- });
-
- if (farmingCount >= 2) {
- const avgFarming = farmingSum / farmingCount;
- agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1);
- }
-
- if (metallurgyCount >= 2) {
- const avgMetallurgy = metallurgySum / metallurgyCount;
- agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1);
- }
- }
-
- forEachLocalAgentNear(x, y, radius, callback) {
- const w = this.world;
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
- const tx = x + dx;
- const ty = y + dy;
- if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
- const agents = this.tileAgents.get(w.idx(tx, ty));
- if (!agents) continue;
- for (const agent of agents) {
- if (callback(agent) === false) return;
- }
- }
- }
- }
-
- farmingGatherMultiplier(agent, tile) {
- const farmingLevel = agent.tech?.farming || 0;
- const farmingWork = agent.farmingWork || 0;
- const farmability = this.farmabilityAt(tile);
- return clamp(1 + farmingLevel * farmingWork * farmability * 2, 1, 3);
- }
-
- metallurgyGatherMultiplier(agent, tile) {
- const metallurgyLevel = agent.tech?.metallurgy || 0;
- if (metallurgyLevel > 0.02) {
- agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1);
- }
- return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1);
- }
-
- gatherConsumeReproduce(a, offspring) {
- const w = this.world;
- const i = w.idx(a.x, a.y);
- this.ensureAgentTech(a);
- const ethnicity = this.ethnicities.get(a.ethnicity);
- const climateMismatch = this.climateMismatch(a.ethnicity, i);
- const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1);
- const cityMarket = w.city[i] >= 0 ? 0.28 : 0;
- const drylandAdapted = this.isDrylandAdapted(ethnicity);
- const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0;
- const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage;
- const carryingCapacity = this.carryingCapacityAt(i);
- const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity);
- const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase);
- const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2);
- const gathered = Math.min(
- w.resource[i],
- baseGatherAmount * this.farmingGatherMultiplier(a, i) * this.metallurgyGatherMultiplier(a, i)
- );
- w.resource[i] -= gathered;
- a.resources += gathered;
- const waterAdaptation = ethnicity?.climateHumidity ?? 0.45;
- const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0;
- const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4;
- const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1;
- const sedentary = getSedentary(a.traits);
- const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08;
- a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead;
-
- if (a.resources <= 0) {
- this.removeAgentFromOccupancy(a);
- a.alive = false;
- this.deaths++;
- return;
- }
-
- const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12);
- const mobilityPenalty = (1 - sedentary) * 0.45;
- const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty;
- const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35);
- if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) {
- const childShare = 0.36 + sedentary * 0.08;
- const childResources = a.resources * childShare;
- a.resources -= childResources;
- const childTraits = mutateTraits(a.traits, this.rng, 0.035);
- const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources);
- child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1);
- child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1);
- offspring.push(child);
- }
- }
-
- climateMismatch(ethnicityId, tile) {
- const ethnicity = this.ethnicities.get(ethnicityId);
- if (!ethnicity) return 0;
- const tempDiff = Math.abs(this.world.temperature[tile] - ethnicity.climateTemp);
- const humidDiff = Math.abs(this.world.humidity[tile] - ethnicity.climateHumidity);
- return tempDiff * 0.58 + humidDiff * 0.42;
- }
-
- isDrylandAdapted(ethnicity) {
- return !!ethnicity && ethnicity.climateTemp > 0.5 && ethnicity.climateHumidity < 0.38;
- }
-
- ethnicDensityNear(x, y, ethnicity) {
- const key = `${x},${y},${ethnicity}`;
- const cached = this.ethnicDensityCache.get(key);
- if (cached) return cached;
- let same = 0;
- let foreign = 0;
- const w = this.world;
- for (let dy = -2; dy <= 2; dy++) {
- for (let dx = -2; dx <= 2; dx++) {
- if (Math.abs(dx) + Math.abs(dy) > 2) continue;
- const tx = x + dx;
- const ty = y + dy;
- if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
- const counts = this.tileEthnicities.get(w.idx(tx, ty));
- if (!counts) continue;
- for (const [id, value] of counts) {
- if (id === ethnicity) same += value;
- else foreign += value;
- }
- if (same > 6 && foreign > 6) {
- const result = { same: same * 0.22, foreign: foreign * 0.16 };
- this.ethnicDensityCache.set(key, result);
- return result;
- }
- }
- }
- const result = { same: same * 0.22, foreign: foreign * 0.16 };
- this.ethnicDensityCache.set(key, result);
- return result;
- }
-
- resolveAssimilation(a) {
- const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity);
- if (!dominant || dominant.id === a.ethnicity) {
- a.foreignContact = Math.max(0, a.foreignContact - 1);
- return;
- }
-
- if (a.contactEthnicity !== dominant.id) {
- a.contactEthnicity = dominant.id;
- a.foreignContact = 0;
- }
- a.foreignContact++;
-
- const pressure = dominant.count / Math.max(1, dominant.total);
- const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30);
- if (this.rng.next() < chance * 0.14) {
- this.changeAgentEthnicity(a, dominant.id);
- a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
- a.foreignContact = 0;
- }
- }
-
- dominantEthnicityNear(x, y, self) {
- const key = `${x},${y},${self}`;
- const cached = this.dominantEthnicityCache.get(key);
- if (cached) return cached;
- const counts = new Map();
- let total = 0;
- const w = this.world;
- for (let dy = -3; dy <= 3; dy++) {
- for (let dx = -3; dx <= 3; dx++) {
- if (Math.abs(dx) + Math.abs(dy) > 3) continue;
- const tx = x + dx;
- const ty = y + dy;
- if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
- const tileCounts = this.tileEthnicities.get(w.idx(tx, ty));
- if (!tileCounts) continue;
- for (const [id, count] of tileCounts) {
- total += count;
- counts.set(id, (counts.get(id) || 0) + count);
- }
- }
- }
- for (const city of this.getCitiesNear(x, y, 5)) {
- const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
- if (!city.ethnicityComposition?.size) continue;
- const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8);
- for (const [id, count] of city.ethnicityComposition) {
- const weighted = Math.max(1, Math.round(count * influence * 0.08));
- total += weighted;
- counts.set(id, (counts.get(id) || 0) + weighted);
- }
- }
- let best = null;
- for (const [id, count] of counts) {
- if (id !== self && (!best || count > best.count)) best = { id, count, total };
- }
- if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits();
- if (best) this.dominantEthnicityCache.set(key, best);
- return best;
- }
-
- updateWorldFields(scale = 1) {
- const w = this.world;
- const pheromoneDecay = Math.pow(0.996, scale);
- for (let i = 0; i < w.count; i++) {
- w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15));
- w.pheromone[i] *= pheromoneDecay;
- }
- }
-
- updateCities() {
- const w = this.world;
- w.city.fill(-1);
- w.farmland.fill(0);
- w.cityPull.fill(0);
-
- const candidates = new Map();
- for (const a of this.agents) {
- if (!a.alive) continue;
- const local = w.idx(a.x, a.y);
- if (a.settled < 3 && w.pressure[local] < 2) continue;
- if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.24) continue;
- const cx = clamp(Math.round(a.x / 4) * 4, 0, w.size - 1);
- const cy = clamp(Math.round(a.y / 4) * 4, 0, w.size - 1);
- const i = w.idx(cx, cy);
- let group = candidates.get(i);
- if (!group) {
- group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map() };
- candidates.set(i, group);
- }
- this.ensureAgentTech(a);
- group.count++;
- group.resources += Math.max(0, a.resources);
- group.sedentary += getSedentary(a.traits);
- group.farming += a.tech.farming || 0;
- group.metallurgy += a.tech.metallurgy || 0;
- group.techCount++;
- group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1);
- }
-
- let foundedThisTick = 0;
- const canFoundCities = this.year >= years(80) && this.year % years(10) === 0;
- const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0;
- const candidateEntries = [...candidates].sort((a, b) => {
- const sedentaryA = a[1].sedentary / Math.max(1, a[1].count);
- const sedentaryB = b[1].sedentary / Math.max(1, b[1].count);
- return (b[1].count * (0.7 + sedentaryB) + b[1].resources * 0.02) - (a[1].count * (0.7 + sedentaryA) + a[1].resources * 0.02);
- });
- for (const [i, group] of candidateEntries) {
- if (group.count < 3) continue;
- const avgSedentary = group.sedentary / group.count;
- let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null;
- if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) {
- const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98);
- if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue;
- city = this.createCity(i % w.size, Math.floor(i / w.size), group);
- this.cities.push(city);
- this.cityById.set(city.id, city);
- const bucketKey = this.cityBucketKey(city.x, city.y);
- if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []);
- this.cityBuckets.get(bucketKey).push(city);
- foundedThisTick++;
- }
- if (city) {
- city.activeVisitors += group.count;
- city.storedResources += group.resources * 0.08;
- city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02;
- city.knowledge ??= { farming: 0, metallurgy: 0 };
- city.knowledge.farming = Math.max(city.knowledge.farming * 0.998, group.farming / Math.max(1, group.techCount));
- city.knowledge.metallurgy = Math.max(city.knowledge.metallurgy * 0.998, group.metallurgy / Math.max(1, group.techCount));
- const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1);
- for (const [id, count] of group.ethnicities) {
- const urbanCount = this.weightedUrbanContribution(count * 0.2, urbanWeight);
- if (urbanCount > 0) city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + urbanCount);
- }
- city.strength = city.strength * 0.96 + group.count * 0.05;
- }
- }
-
- this.absorbUrbanPopulation();
- this.rebuildOccupancy();
- this.processCityEconomies();
-
- this.cities = this.cities.filter(c => {
- c.age++;
- c.strength *= 0.992;
- const foodPerCapita = c.storedResources / Math.max(1, c.population);
- if (c.age > 20) {
- if (c.activeVisitors < 2 && foodPerCapita < 0.04) c.strength -= 0.035;
- else if (c.activeVisitors < 5 && foodPerCapita < 0.02) c.strength -= 0.015;
- if (c.population < 25 && foodPerCapita < 0.05) c.strength -= 0.018;
- }
- if (c.population <= 0 || c.strength <= 0.06) return false;
- const radius = c.agriculturalRadius;
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
- const x = clamp(c.x + dx, 0, w.size - 1);
- const y = clamp(c.y + dy, 0, w.size - 1);
- const d = Math.abs(dx) + Math.abs(dy);
- if (d <= radius) {
- const tile = w.idx(x, y);
- w.city[tile] = c.id;
- if (w.terrain[tile] !== Terrain.WATER) {
- w.farmland[tile] = Math.max(w.farmland[tile], (radius - d + 1) / (radius + 1));
- w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1));
- }
- }
- }
- }
- return true;
- });
- this.rebuildIndexes();
- }
-
- createCity(x, y, seedGroup = null) {
- const seedPopulation = seedGroup ? Math.max(14, seedGroup.count * 5) : 10;
- const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5;
- const composition = new Map();
- if (seedGroup) {
- const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1);
- for (const [id, count] of seedGroup.ethnicities) {
- const urbanCount = this.weightedUrbanContribution(count * 3, urbanWeight);
- if (urbanCount > 0) composition.set(id, urbanCount);
- }
- }
- return {
- id: this.nextCity++,
- x,
- y,
- population: seedPopulation,
- storedResources: 34 + (seedGroup?.resources || 0) * 0.55,
- ethnicityComposition: composition,
- pheromoneOutput: 0,
- agriculturalRadius: 2,
- tradeLinks: new Set(),
- activeVisitors: 0,
- age: 0,
- strength: 3,
- sedentaryCulture: seedSedentary,
- knowledge: { farming: 0, metallurgy: 0 },
- supplyStress: 0,
- polityId: null,
- loyalty: 0.5,
- receivedAid: false,
- tradeValue: 0,
- tradeReach: 0
- };
- }
-
- absorbUrbanPopulation() {
- if (!this.cities.length) return;
- const absorbed = [];
- for (const a of this.agents) {
- if (!a.alive || a.settled < 5) {
- absorbed.push(a);
- continue;
- }
- const city = this.findCityNear(a.x, a.y, 7);
- const sedentary = getSedentary(a.traits);
- if (!city || this.rng.next() > sedentary * 0.85) {
- absorbed.push(a);
- continue;
- }
- const migrants = 1 + Math.floor(Math.min(8, a.resources / 8));
- const urbanWeight = clamp((sedentary - 0.14) * 1.5, 0.08, 1);
- city.population += migrants;
- city.storedResources += Math.max(0, a.resources) * 0.65;
- const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight);
- if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount);
- city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015;
- city.strength += 0.04;
- }
- this.agents = absorbed;
- }
-
- weightedUrbanContribution(amount, weight) {
- const value = amount * weight;
- const whole = Math.floor(value);
- return whole + (this.rng.next() < value - whole ? 1 : 0);
- }
-
- processCityEconomies() {
- const w = this.world;
- for (const city of this.cities) {
- city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14);
- city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8);
- city.knowledge ??= { farming: 0, metallurgy: 0 };
- let harvested = 0;
- const radius = city.agriculturalRadius;
-
- for (let dy = -radius; dy <= radius; dy++) {
- for (let dx = -radius; dx <= radius; dx++) {
- if (Math.abs(dx) + Math.abs(dy) > radius) continue;
- const x = clamp(city.x + dx, 0, w.size - 1);
- const y = clamp(city.y + dy, 0, w.size - 1);
- const i = w.idx(x, y);
- if (w.terrain[i] === Terrain.WATER) continue;
- const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1);
- const farmingYield = 1 + city.knowledge.farming * 0.85;
- const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35;
- const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull);
- w.resource[i] -= extraction;
- w.farmland[i] = Math.max(w.farmland[i], pull);
- w.pheromone[i] += city.pheromoneOutput * pull * 0.09;
- harvested += extraction;
- }
- }
-
- city.storedResources += harvested;
- const trade = this.cityTradeProfile(city);
- const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018);
- city.storedResources += tradeIncome;
- const supportRatio = city.activeVisitors / Math.max(1, city.population);
- const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012);
- const tradeRelief = clamp(trade.value * 0.09, 0, 0.32);
- const upkeep = (city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09)) * (1 - tradeRelief) + knowledgeMaintenance;
- city.storedResources -= upkeep;
- const foodPerCapita = city.storedResources / Math.max(1, city.population);
- city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8 - trade.value * 0.12, 0, 1.8);
- this.innovateCityKnowledge(city, harvested);
- if (trade.value > 0) {
- city.knowledge.farming = clamp(city.knowledge.farming + trade.value * 0.00018, 0, 1);
- city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + trade.value * 0.00024, 0, 1);
- city.strength += clamp(trade.value * 0.018, 0, 0.08);
- city.tradeValue = trade.value;
- city.tradeReach = trade.reach;
- } else {
- city.tradeValue = 0;
- city.tradeReach = 0;
- }
- if (city.storedResources > city.population * 0.16 && city.population > 0) {
- const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8);
- const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015)));
- city.population += births;
- city.storedResources -= births * 0.55;
- addBirthsToComposition(city.ethnicityComposition, births);
- }
- if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) {
- const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010)));
- city.population -= attrition;
- removeFromComposition(city.ethnicityComposition, attrition);
- city.strength -= city.activeVisitors === 0 ? 0.030 : 0.012;
- }
- if (city.storedResources < 0) {
- const deficit = Math.abs(city.storedResources);
- const dominant = dominantComposition(city.ethnicityComposition);
- const loss = Math.min(city.population, Math.ceil(deficit * 2.2 + city.population * 0.035));
- city.population -= loss;
- city.storedResources = 0;
- removeFromComposition(city.ethnicityComposition, loss);
- city.strength -= Math.min(0.4, 0.03 + deficit * 0.01);
- if (loss > 0 && this.agents.length < this.maxAgents) {
- this.spawnUrbanRefugees(city, Math.min(24, Math.max(2, Math.ceil(loss / 8))), dominant);
- }
- }
- city.population = Math.max(0, Math.floor(city.population));
- city.storedResources = clamp(city.storedResources, 0, Math.max(30, city.population * 1.4));
- }
- }
-
- cityTradeProfile(city) {
- if (!city?.tradeLinks?.size || !this.tradeLinks.length) return { value: 0, reach: 0 };
- let value = 0;
- let reach = 0;
- for (const link of this.tradeLinks) {
- if (link.from !== city.id && link.to !== city.id) continue;
- const other = this.getCityById(link.from === city.id ? link.to : link.from);
- if (!other) continue;
- const distance = this.distanceBetweenCities(city, other);
- const distanceFactor = clamp((distance - 14) / 30, 0, 1);
- if (distanceFactor <= 0) continue;
- const partnerScale = clamp(Math.sqrt(Math.max(1, other.population || 1)) / 24, 0.35, 2.4);
- const pathScale = clamp((link.path?.length || distance) / 30, 0.5, 1.6);
- value += (link.strength || 0.12) * distanceFactor * partnerScale * pathScale;
- reach = Math.max(reach, distance);
- }
- return {
- value: clamp(value, 0, 3.2),
- reach
- };
- }
-
- innovateCityKnowledge(city, harvested) {
- city.knowledge ??= { farming: 0, metallurgy: 0 };
- const scale = SimConfig.technology.cityInnovation;
- const density = clamp(Math.sqrt(city.population) / 20, 0, 1.6);
- const foodSurplus = clamp(city.storedResources / Math.max(1, city.population) - 0.08, 0, 0.5);
- city.knowledge.farming = clamp(
- city.knowledge.farming + scale * density * (0.4 + foodSurplus * 4) + harvested * 0.000015,
- 0,
- 1
- );
- city.knowledge.metallurgy = clamp(
- city.knowledge.metallurgy + scale * density * clamp(city.pheromoneOutput, 0.1, 1.8) * 0.35,
- 0,
- 1
- );
- if (city.supplyStress > 0.8) {
- city.knowledge.farming *= 0.998;
- city.knowledge.metallurgy *= 0.997;
- }
- }
-
- spawnUrbanRefugees(city, count, ethnicity = null) {
- const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1;
- const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits();
- for (let i = 0; i < count; i++) {
- this.agents.push(this.makeAgent(
- clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1),
- clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1),
- dominant,
- mutateTraits(template, this.rng, 0.05),
- this.rng.range(4, 11)
- ));
- }
- }
-
- findCityNear(x, y, radius) {
- let best = null;
- let bestDistance = Infinity;
- for (const c of this.getCitiesNear(x, y, radius)) {
- const d = Math.abs(c.x - x) + Math.abs(c.y - y);
- if (d <= radius && d < bestDistance) {
- best = c;
- bestDistance = d;
- }
- }
- return best;
- }
-
- cityInfluence(city) {
- if (!city || city.population <= 0 || city.strength <= 0) return 0;
- const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35);
- return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor;
- }
-
- getCityById(id) {
- const cached = this.cityById?.get(id);
- if (cached) return cached;
- const city = this.cities.find(c => c.id === id) || null;
- if (city) this.cityById.set(id, city);
- return city;
- }
-
- getPolityById(id) {
- const cached = this.polityById?.get(id);
- if (cached) return cached;
- const polity = this.polities.find(p => p.id === id) || null;
- if (polity) this.polityById.set(id, polity);
- return polity;
- }
-
- getPolityCities(polity) {
- const living = [];
- for (const id of [...polity.cityIds]) {
- const city = this.getCityById(id);
- if (!city || city.population <= 0) {
- polity.cityIds.delete(id);
- } else {
- living.push(city);
- }
- }
- return living;
- }
-
- dominantCityEthnicity(city) {
- if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null;
- return dominantComposition(city.ethnicityComposition) || null;
- }
-
- sameDominantEthnicity(cityA, cityB) {
- const a = this.dominantCityEthnicity(cityA);
- const b = this.dominantCityEthnicity(cityB);
- return a !== null && b !== null && a === b;
- }
-
- distanceBetweenCities(cityA, cityB) {
- return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y);
- }
-
- hasDirectTradeConnection(cityA, cityB) {
- return !!cityA?.tradeLinks?.has(cityB?.id) || !!cityB?.tradeLinks?.has(cityA?.id);
- }
-
- effectiveDistance(cityA, cityB) {
- let distance = this.distanceBetweenCities(cityA, cityB);
- if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55;
- return distance;
- }
-
- createPolity(centerCity) {
- const id = this.nextPolity++;
- const polity = {
- id,
- centerCityId: centerCity.id,
- cityIds: new Set([centerCity.id]),
- treasury: Math.max(0, centerCity.storedResources * 0.12),
- color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62),
- founded: this.year,
- legitimacy: this.rng.range(0.68, 0.94),
- cohesion: this.rng.range(0.58, 0.9),
- charisma: this.rng.range(0.5, 1.5),
- leaderStarted: this.year,
- leaderTenureYears: this.rng.range(24, 68),
- crisis: 0,
- lastCrisisYear: this.year
- };
- centerCity.polityId = id;
- centerCity.loyalty = 1;
- centerCity.receivedAid = false;
- this.polities.push(polity);
- this.polityById.set(polity.id, polity);
- this.ensurePolityHistory(polity);
- this.samplePolityHistory(polity);
- return polity;
- }
-
- ensurePolityHistory(polity) {
- if (!polity) return null;
- if (!this.polityHistory.has(polity.id)) {
- this.polityHistory.set(polity.id, {
- id: polity.id,
- color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62),
- founded: polity.founded ?? this.year,
- ended: null,
- active: true,
- centerCityId: polity.centerCityId ?? null,
- fate: null,
- samples: [],
- events: [],
- peakPower: 0,
- peakYear: null,
- peakEventYear: null
- });
- }
- const history = this.polityHistory.get(polity.id);
- history.events ??= [];
- history.peakPower ??= 0;
- history.peakYear ??= null;
- history.peakEventYear ??= null;
- return history;
- }
-
- averagePolityLoyalty(polity) {
- const cities = this.getPolityCities(polity);
- const subordinates = cities.filter(city => city.id !== polity.centerCityId);
- if (!subordinates.length) return 1;
- return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length;
- }
-
- samplePolityHistory(polity) {
- const history = this.ensurePolityHistory(polity);
- if (!history) return;
- const cities = this.getPolityCities(polity);
- const population = cities.reduce((sum, city) => sum + city.population, 0);
- const treasury = polity.treasury || 0;
- const avgLoyalty = this.averagePolityLoyalty(polity);
- const fallbackPower = Math.sqrt(population) * 1.35 +
- Math.sqrt(Math.max(0, treasury)) * 1.15 +
- avgLoyalty * 16;
- history.centerCityId = polity.centerCityId ?? history.centerCityId;
- const sample = {
- year: this.year,
- cities: cities.length,
- population,
- treasury,
- avgLoyalty,
- power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower
- };
- history.samples.push(sample);
- if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) {
- history.peakPower = sample.power;
- history.peakYear = this.year;
- const oldEnoughForPeakMarker = this.year - (history.founded ?? this.year) > years(40);
- if (oldEnoughForPeakMarker && (!history.peakEventYear || this.year - history.peakEventYear > years(120))) {
- this.addPolityEvent(polity.id, "peak", this.year, {
- power: sample.power,
- cities: sample.cities,
- population: sample.population
- }, 2);
- history.peakEventYear = this.year;
- }
- }
- const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears;
- while (history.samples.length > maxSamples) history.samples.shift();
- }
-
- samplePolityHistories() {
- for (const polity of this.polities) this.samplePolityHistory(polity);
- }
-
- addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) {
- let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null;
- if (!history) {
- const polity = this.getPolityById(polityId);
- if (polity) history = this.ensurePolityHistory(polity);
- }
- if (!history) return;
- history.events ??= [];
- const sameYearDuplicate = history.events.some(event =>
- event.type === type &&
- event.year === year &&
- JSON.stringify(event.data || {}) === JSON.stringify(data || {})
- );
- if (sameYearDuplicate) return;
- history.events.push({ year, type, importance, data });
- while (history.events.length > 120) history.events.shift();
- }
-
- selectNewLeader(polity, forced = false) {
- if (!polity) return;
- const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5);
- const center = this.getCityById(polity.centerCityId);
- const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5;
- const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3;
- const randomLeader = this.rng.range(0.5, 1.5);
- const continuity = forced ? 0.18 : 0.34;
- const institutionalPull = 0.82 + institutionalBias * 0.36;
- const nextCharisma = clamp(
- previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull,
- 0.5,
- 1.5
- );
- polity.charisma = nextCharisma;
- polity.leaderStarted = this.year;
- polity.leaderTenureYears = this.rng.range(22, 72);
-
- const change = nextCharisma - previousCharisma;
- polity.legitimacy = clamp((polity.legitimacy ?? 0.7) + change * 0.08 - (forced ? 0.035 : 0), 0, 1);
- polity.cohesion = clamp((polity.cohesion ?? 0.6) + change * 0.045 - (forced ? 0.020 : 0), 0, 1);
- if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5);
- }
-
- updatePolityLeaders() {
- for (const polity of this.polities) {
- polity.charisma = clamp(polity.charisma ?? 1, 0.5, 1.5);
- polity.leaderStarted ??= polity.founded ?? this.year;
- polity.leaderTenureYears ??= this.rng.range(24, 68);
- const tenureYears = (this.year - polity.leaderStarted) / MONTHS_PER_YEAR;
- const oldLeader = Math.max(0, tenureYears - polity.leaderTenureYears);
- const crisisPressure = clamp((polity.crisis || 0) * 0.045, 0, 0.09);
- const successionChance = clamp(oldLeader * 0.018 + crisisPressure, 0, 0.36);
- if (successionChance > 0 && this.rng.next() < successionChance) {
- this.selectNewLeader(polity, (polity.crisis || 0) > 0.85);
- }
- }
- }
-
- maybeSpawnDisaster() {
- const config = SimConfig.disaster;
- const interval = years(config?.checkIntervalYears ?? 8);
- if (!interval || this.year % interval !== 0) return;
- if (this.rng.next() > (config?.baseChance ?? 0.38)) return;
- this.spawnDisaster();
- }
-
- spawnDisaster() {
- const config = SimConfig.disaster;
- const large = this.rng.next() < (config?.rareLargeChance ?? 0.10);
- const radius = large
- ? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54)
- : this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34);
- const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95);
- this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity);
- }
-
- applyDisaster(x, y, radius, intensity) {
- const config = SimConfig.disaster;
- const w = this.world;
- const affectedPolityMap = new Map();
- const affectedPolityIds = new Set();
- for (const city of this.cities) {
- if (city.polityId === null) continue;
- if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId);
- }
- const polityPopulationBefore = new Map();
- for (const id of affectedPolityIds) {
- const polity = this.getPolityById(id);
- if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity));
- }
-
- let affectedAgents = 0;
- for (const agent of this.agents) {
- if (!agent.alive) continue;
- const d = Math.hypot(agent.x - x, agent.y - y);
- if (d > radius) continue;
- const falloff = 1 - d / radius;
- const damage = clamp(intensity * falloff * falloff, 0, 1);
- if (this.rng.next() < damage * 0.32) {
- if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent);
- agent.alive = false;
- this.deaths++;
- affectedAgents++;
- } else {
- agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45));
- if (agent.tech) {
- agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035);
- agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04);
- }
- }
- }
-
- let affectedCities = 0;
- let totalPopulationLoss = 0;
- for (const city of this.cities) {
- const d = Math.hypot(city.x - x, city.y - y);
- if (d > radius) continue;
- const falloff = 1 - d / radius;
- const damage = clamp(intensity * falloff * falloff, 0, 1);
- if (damage <= 0) continue;
- affectedCities++;
- const popBefore = city.population || 0;
- const lossRate = clamp(damage * 0.25, 0, 0.45);
- const loss = Math.floor(popBefore * lossRate);
- city.population = Math.max(0, popBefore - loss);
- if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss);
- city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55));
- city.strength = Math.max(0, city.strength - damage * 0.38);
- this.deaths += Math.floor(loss * 0.35);
- totalPopulationLoss += loss;
-
- if (city.polityId !== null) {
- let entry = affectedPolityMap.get(city.polityId);
- if (!entry) {
- entry = {
- polityId: city.polityId,
- populationBefore: polityPopulationBefore.get(city.polityId) || 0,
- populationLoss: 0,
- cityIds: new Set()
- };
- affectedPolityMap.set(city.polityId, entry);
- }
- entry.populationLoss += loss;
- entry.cityIds.add(city.id);
- const polity = this.getPolityById(city.polityId);
- if (polity) {
- polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5);
- polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1);
- polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1);
- }
- }
- }
-
- const minX = Math.max(0, Math.floor(x - radius));
- const maxX = Math.min(w.size - 1, Math.ceil(x + radius));
- const minY = Math.max(0, Math.floor(y - radius));
- const maxY = Math.min(w.size - 1, Math.ceil(y + radius));
- for (let ty = minY; ty <= maxY; ty++) {
- for (let tx = minX; tx <= maxX; tx++) {
- const d = Math.hypot(tx - x, ty - y);
- if (d > radius) continue;
- const falloff = 1 - d / radius;
- const damage = clamp(intensity * falloff * falloff, 0, 1);
- const tile = w.idx(tx, ty);
- w.resource[tile] *= (1 - damage * 0.45);
- w.farmland[tile] *= (1 - damage * 0.35);
- w.pheromone[tile] *= (1 - damage * 0.25);
- }
- }
-
- const affectedPolities = [...affectedPolityMap.values()].map(entry => {
- const populationBefore = Math.max(1, entry.populationBefore || 0);
- const lossRate = entry.populationLoss / populationBefore;
- return {
- polityId: entry.polityId,
- populationBefore: entry.populationBefore,
- populationLoss: entry.populationLoss,
- lossRate,
- cityIds: [...entry.cityIds]
- };
- });
- const id = this.nextDisaster++;
- const record = {
- id,
- year: this.year,
- x,
- y,
- radius,
- intensity,
- expires: this.year + years(config?.visualDurationYears ?? 18),
- affectedAgents,
- affectedCities,
- totalPopulationLoss,
- affectedPolities
- };
- this.disasters.push(record);
- this.disasterHistory.push(record);
- for (const entry of affectedPolities) {
- if (entry.lossRate >= 0.10) {
- this.addPolityEvent(entry.polityId, "disaster", this.year, {
- disasterId: id,
- populationLoss: entry.populationLoss,
- lossRate: entry.lossRate,
- affectedCities: entry.cityIds.length
- }, entry.lossRate >= 0.20 ? 3 : 2);
- }
- }
- this.disasters = this.disasters
- .filter(disaster => this.year <= disaster.expires)
- .slice(-(config?.maxActiveVisuals ?? 12));
- while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift();
- }
-
- markPolityEnded(polity, reason = "dissolved") {
- if (!polity) return;
- const history = this.ensurePolityHistory(polity);
- if (!history) return;
- this.samplePolityHistory(polity);
- history.ended = this.year;
- history.active = false;
- history.fate = reason;
- history.centerCityId = polity.centerCityId ?? history.centerCityId;
- this.polityHistory.delete(polity.id);
- this.deadPolityHistories.push(history);
- while (this.deadPolityHistories.length > 80) this.deadPolityHistories.shift();
- }
-
- getAllPolityHistories() {
- return [...this.deadPolityHistories, ...this.polityHistory.values()]
- .sort((a, b) => (a.founded - b.founded) || (a.id - b.id));
- }
-
- addCityToPolity(city, polity, initialLoyalty = 0.5) {
- if (!city || !polity) return;
- if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city);
- city.polityId = polity.id;
- city.loyalty = clamp(initialLoyalty, 0, 1);
- city.receivedAid = false;
- polity.cityIds.add(city.id);
- }
-
- removeCityFromPolity(city) {
- if (!city || city.polityId === null) return;
- const oldPolity = this.getPolityById(city.polityId);
- if (oldPolity) oldPolity.cityIds.delete(city.id);
- city.polityId = null;
- city.loyalty = 0.45;
- city.receivedAid = false;
- }
-
- isPolityAtWar(polityId) {
- return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId));
- }
-
- getWarBetween(aId, bId) {
- return this.wars.find(war =>
- war.ended === null &&
- ((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId))
- ) || null;
- }
-
- startWar(a, b) {
- if (!a || !b || a.id === b.id) return null;
- if (this.getWarBetween(a.id, b.id)) return null;
- if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null;
-
- const id = this.nextWar++;
- const war = {
- id,
- aPolityId: a.id,
- bPolityId: b.id,
- started: this.year,
- lastActionYear: this.year,
- intensity: this.rng.range(0.45, 1.05),
- exhaustionA: 0,
- exhaustionB: 0,
- ended: null
- };
- this.wars.push(war);
- a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5);
- b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5);
- return war;
- }
-
- endWar(war) {
- if (!war || war.ended !== null) return;
- war.ended = this.year;
- }
-
- totalPolityPopulation(polity) {
- return this.getPolityCities(polity)
- .reduce((sum, city) => sum + Math.max(0, city.population || 0), 0);
- }
-
- averagePolityTechnology(polity) {
- const cities = this.getPolityCities(polity);
- if (!cities.length) return 0;
- let total = 0;
- let count = 0;
- for (const city of cities) {
- if (!city?.knowledge) continue;
- total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5;
- count++;
- }
- return count ? total / count : 0;
- }
-
- polityPower(polity) {
- const cities = this.getPolityCities(polity);
- if (!cities.length) return 0;
-
- const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0);
- const treasury = Math.max(0, polity.treasury || 0);
- const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5;
- const tech = this.averagePolityTechnology(polity);
- const instability = this.polityInstability ? this.polityInstability(polity) : 0;
- const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0;
-
- const basePower = Math.max(0,
- Math.sqrt(population) * 1.35 +
- Math.sqrt(treasury) * 1.15 +
- avgLoyalty * 16 +
- tech * 24 -
- instability * 12 -
- agePressure * 6
- );
- return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5);
- }
-
- polityInfluenceRange(polity, wartime = false) {
- const power = this.polityPower(polity);
- const base = 18 + Math.sqrt(power) * 2.2;
- const range = wartime ? base * 1.15 : base * 0.75;
- return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42);
- }
-
- distanceToNearestPolityCity(polity, city) {
- if (!polity || !city) return Infinity;
- let best = Infinity;
- for (const source of this.getPolityCities(polity)) {
- const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city);
- if (distance < best) best = distance;
- }
- return best;
- }
-
- nearestPolityCity(polity, city) {
- if (!polity || !city) return null;
- let best = null;
- let bestDistance = Infinity;
- for (const source of this.getPolityCities(polity)) {
- const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city);
- if (distance < bestDistance) {
- best = source;
- bestDistance = distance;
- }
- }
- return best;
- }
-
- polityDistance(a, b) {
- const aCities = this.getPolityCities(a);
- const bCities = this.getPolityCities(b);
- if (!aCities.length || !bCities.length) return Infinity;
- let best = Infinity;
- for (const aCity of aCities) {
- for (const bCity of bCities) {
- const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity);
- if (distance < best) best = distance;
- }
- }
- return best;
- }
-
- polityAge(polity) {
- return (this.year - (polity?.founded ?? this.year)) / MONTHS_PER_YEAR;
- }
-
- polityAgePressure(polity) {
- const age = this.polityAge(polity);
- if (age < 80) return 0;
- const x = (age - 80) / 420;
- return clamp(1 - Math.exp(-x), 0, 1.35);
- }
-
- polityOverextension(polity) {
- const cities = this.getPolityCities(polity);
- const center = this.getCityById(polity.centerCityId);
- if (!center || cities.length <= 1) return 0;
-
- const cityCountPressure = Math.max(0, cities.length - 5) * 0.08;
- let distanceSum = 0;
- let count = 0;
- for (const city of cities) {
- if (city.id === center.id) continue;
- distanceSum += this.effectiveDistance(center, city);
- count++;
- }
-
- const avgDistance = count ? distanceSum / count : 0;
- const distancePressure = Math.max(0, avgDistance - 28) * 0.008;
- return clamp(cityCountPressure + distancePressure, 0, 2);
- }
-
- polityResourceStress(polity) {
- const cities = this.getPolityCities(polity);
- if (!cities.length) return 1;
-
- let poor = 0;
- let supplyStress = 0;
- for (const city of cities) {
- const perCapita = city.storedResources / Math.max(1, city.population);
- if (perCapita < 0.06) poor++;
- supplyStress += city.supplyStress || 0;
- }
-
- const povertyRate = poor / cities.length;
- const avgSupplyStress = supplyStress / cities.length;
- const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length);
- const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0;
- return clamp(povertyRate * 0.55 + avgSupplyStress * 0.35 + treasuryStress * 0.28, 0, 1.5);
- }
-
- polityLogisticsStress(polity) {
- const cities = this.getPolityCities(polity);
- const center = this.getCityById(polity.centerCityId);
- if (!center || cities.length <= 1) return 0;
- let stress = 0;
- let count = 0;
- for (const city of cities) {
- if (city.id === center.id) continue;
- const distance = this.effectiveDistance(center, city);
- const food = city.storedResources / Math.max(1, city.population);
- const distanceStress = Math.max(0, distance - SimConfig.polity.logisticsDistance) * 0.012;
- const foodStress = Math.max(0, SimConfig.polity.minimumPerCapitaFood - food) * 4.5;
- const routeRelief = this.hasDirectTradeConnection(center, city) ? 0.72 : 1;
- stress += (distanceStress + foodStress + (city.supplyStress || 0) * 0.35) * routeRelief;
- count++;
- }
- return clamp(stress / Math.max(1, count), 0, 1.8);
- }
-
- polityEthnicFragmentation(polity) {
- const cities = this.getPolityCities(polity);
- if (cities.length <= 1) return 0;
-
- const counts = new Map();
- for (const city of cities) {
- const e = this.dominantCityEthnicity(city);
- if (e !== null) counts.set(e, (counts.get(e) || 0) + 1);
- }
- if (!counts.size) return 0;
-
- let max = 0;
- for (const v of counts.values()) max = Math.max(max, v);
- const dominantShare = max / cities.length;
- return clamp(1 - dominantShare, 0, 1);
- }
-
- polityInstability(polity) {
- const agePressure = this.polityAgePressure(polity);
- const overextension = this.polityOverextension(polity);
- const resourceStress = this.polityResourceStress(polity);
- const logisticsStress = this.polityLogisticsStress(polity);
- const fragmentation = this.polityEthnicFragmentation(polity);
-
- const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85;
- const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.62;
- return clamp(
- agePressure * 0.35 +
- overextension * 0.24 +
- resourceStress * 0.38 +
- logisticsStress * 0.30 +
- fragmentation * 0.22 +
- (polity.crisis || 0) * 0.42 -
- legitimacyBuffer -
- cohesionBuffer,
- 0,
- 2.5
- );
- }
-
- foundPolities() {
- for (const city of this.cities) {
- if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue;
- const centerInfluence = this.cityInfluence(city);
- let absorbed = 0;
- for (const other of this.cities) {
- if (absorbed >= 3) break;
- if (other === city || other.polityId !== null) continue;
- if (this.distanceBetweenCities(city, other) > 34) continue;
- const targetInfluence = this.cityInfluence(other);
- if (centerInfluence <= targetInfluence * 1.08) continue;
-
- const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08);
- const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0;
- const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus;
- if (dominanceScore > 0.68 && this.rng.next() < 0.32) {
- const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId);
- this.addCityToPolity(other, polity, 0.68);
- absorbed++;
- }
- }
- }
- }
-
- expandPolities() {
- for (const polity of this.polities) {
- const center = this.getCityById(polity.centerCityId);
- if (!center) continue;
- const centerInfluence = this.cityInfluence(center);
- let absorbed = 0;
- for (const city of this.cities) {
- if (absorbed >= 2) break;
- if (city.polityId !== null || city.id === center.id) continue;
- const distance = this.effectiveDistance(center, city);
- if (distance > 44) continue;
- const targetInfluence = this.cityInfluence(city);
- if (centerInfluence <= targetInfluence * 0.9) continue;
- const dominanceScore =
- (centerInfluence / (targetInfluence + 1)) *
- (1 / (1 + distance * 0.08)) *
- (this.hasDirectTradeConnection(center, city) ? 1.5 : 1.0);
- if (dominanceScore > 0.62 && this.rng.next() < 0.24) {
- this.addCityToPolity(city, polity, 0.62);
- absorbed++;
- }
- }
- }
- }
-
- absorbIndependentCities() {
- if (this.year % years(5) !== 0) return;
-
- for (const polity of this.polities) {
- const cities = this.getPolityCities(polity);
- if (!cities.length) continue;
-
- const power = this.polityPower(polity);
- const range = this.polityInfluenceRange(polity, false);
- let absorbed = 0;
-
- const candidates = this.cities
- .filter(city => city.polityId === null && city.population > 0)
- .map(city => ({
- city,
- distance: this.distanceToNearestPolityCity(polity, city)
- }))
- .filter(x => x.distance <= range)
- .sort((a, b) => a.distance - b.distance);
-
- for (const { city, distance } of candidates) {
- if (absorbed >= 1) break;
-
- const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1);
- const proximity = 1 / (1 + distance * 0.07);
- const pressure = (power / Math.max(1, cityInfluence)) * proximity;
-
- if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) {
- this.addCityToPolity(city, polity, 0.42);
- city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99)));
- absorbed++;
- }
- }
- }
- }
-
- maybeStartWars() {
- if (this.year % years(10) !== 0) return;
-
- for (const a of this.polities) {
- if (this.isPolityAtWar(a.id)) continue;
-
- const aPower = this.polityPower(a);
- if (aPower <= 0) continue;
-
- let started = false;
-
- for (const b of this.polities) {
- if (started) break;
- if (a.id >= b.id) continue;
- if (this.isPolityAtWar(b.id)) continue;
- if (this.getWarBetween(a.id, b.id)) continue;
-
- const distance = this.polityDistance(a, b);
- if (distance > 46) continue;
-
- const bPower = this.polityPower(b);
- if (bPower <= 0) continue;
-
- const larger = Math.max(aPower, bPower);
- const smaller = Math.min(aPower, bPower);
- const advantage = larger / Math.max(1, smaller);
-
- const proximity = clamp((46 - distance) / 46, 0, 1);
- const aInstability = this.polityInstability ? this.polityInstability(a) : 0;
- const bInstability = this.polityInstability ? this.polityInstability(b) : 0;
- const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0;
- const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0;
- const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0;
- const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0;
-
- const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1);
- const weakSideInstability = aPower > bPower ? bInstability : aInstability;
- const weakSideAge = aPower > bPower ? bAge : aAge;
- const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension;
- const generalInstability = Math.max(aInstability, bInstability) * 0.04;
- const borderFriction = this.borderFriction(a, b, distance);
-
- const chance =
- 0.002 +
- proximity * 0.010 +
- asymmetryPressure * 0.010 +
- weakSideInstability * 0.020 +
- weakSideAge * 0.012 +
- weakSideOverextension * 0.012 +
- borderFriction * 0.010 +
- generalInstability;
-
- if (this.rng.next() < clamp(chance, 0, 0.08)) {
- this.startWar(a, b);
- started = true;
- }
- }
- }
- }
-
- borderFriction(a, b, distance) {
- if (!Number.isFinite(distance)) return 0;
- const proximity = clamp((34 - distance) / 34, 0, 1);
- const aCities = this.getPolityCities(a).length;
- const bCities = this.getPolityCities(b).length;
- const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1);
- return proximity * (0.35 + sizePressure * 0.65);
- }
-
- updateWars() {
- if (this.year % years(2) !== 0) return;
-
- for (const war of this.wars) {
- if (war.ended !== null) continue;
-
- const a = this.getPolityById(war.aPolityId);
- const b = this.getPolityById(war.bPolityId);
-
- if (!a || !b) {
- this.endWar(war);
- continue;
- }
-
- this.applyWarPressure(war, a, b);
- this.applyWarPressure(war, b, a);
- this.applyWarExhaustion(war, a, b);
- this.maybeEndWar(war);
- }
-
- this.wars = this.wars.filter(w => w.ended === null);
- }
-
- applyWarPressure(war, attacker, defender) {
- const attackerCities = this.getPolityCities(attacker);
- const defenderCities = this.getPolityCities(defender);
- if (!attackerCities.length || !defenderCities.length) return;
-
- const attackerPower = this.polityPower(attacker);
- const defenderPower = this.polityPower(defender);
- if (attackerPower <= defenderPower * 1.05) return;
-
- const range = this.polityInfluenceRange(attacker, true);
-
- const candidates = defenderCities
- .filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2)
- .map(city => ({
- city,
- distance: this.distanceToNearestPolityCity(attacker, city)
- }))
- .filter(x => x.distance <= range)
- .sort((a, b) => a.distance - b.distance);
-
- if (!candidates.length) return;
-
- const topCandidates = candidates.slice(0, 3);
- const selected = topCandidates[this.rng.int(topCandidates.length)];
- const pressure = this.warAbsorptionPressure(
- attacker,
- defender,
- selected.city,
- selected.distance,
- attackerPower,
- defenderPower
- );
-
- const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45);
-
- if (pressure > 0.95 && this.rng.next() < chance) {
- this.captureCityInWar(selected.city, attacker, defender, war, pressure);
- } else {
- selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1);
- }
- }
-
- warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) {
- const powerRatio = attackerPower / Math.max(1, defenderPower);
- const proximity = 1 / (1 + distance * 0.055);
- const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0;
- const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0;
- const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0;
- const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1);
-
- const techAdvantage = 1 + clamp(
- this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender),
- -0.25,
- 0.45
- );
-
- const nearest = this.nearestPolityCity(attacker, city);
- const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92;
- const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0;
-
- return (
- powerRatio *
- proximity *
- techAdvantage *
- ethnicityFactor *
- tradeFactor *
- (1 + defenderInstability * 0.32) *
- (1 + defenderAge * 0.18) *
- (1 + defenderOverextension * 0.14) *
- (0.65 + loyaltyWeakness * 0.72)
- );
- }
-
- captureCityInWar(city, attacker, defender, war, pressure) {
- const lossRate = clamp(
- 0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025,
- 0.05,
- 0.22
- );
-
- const loss = Math.floor((city.population || 0) * lossRate);
- if (loss > 0) {
- city.population = Math.max(1, city.population - loss);
- if (typeof removeFromComposition === "function" && city.ethnicityComposition) {
- removeFromComposition(city.ethnicityComposition, loss);
- }
- this.deaths += Math.floor(loss * 0.35);
- }
-
- this.removeCityFromPolity(city);
- this.addCityToPolity(city, attacker, 0.28);
-
- city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36);
-
- war.lastActionYear = this.year;
-
- if (war.aPolityId === attacker.id) {
- war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1);
- war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1);
- } else {
- war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1);
- war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1);
- }
-
- attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015);
- attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5);
-
- defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5);
- defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1);
- }
-
- applyWarExhaustion(war, a, b) {
- const intensity = war.intensity || 0.75;
-
- const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035;
- const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035;
-
- a.treasury = Math.max(0, (a.treasury || 0) - costA);
- b.treasury = Math.max(0, (b.treasury || 0) - costB);
-
- a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5);
- b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5);
-
- war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1);
- war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1);
- }
-
- maybeEndWar(war) {
- const a = this.getPolityById(war.aPolityId);
- const b = this.getPolityById(war.bPolityId);
- if (!a || !b) {
- this.endWar(war);
- return;
- }
-
- const age = this.year - war.started;
- const noActionFor = this.year - war.lastActionYear;
- const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0);
-
- if (age > years(90)) {
- this.endWar(war);
- return;
- }
-
- if (age > years(10) && noActionFor > years(18)) {
- this.endWar(war);
- return;
- }
-
- if (exhaustion > 0.85 && this.rng.next() < 0.35) {
- this.endWar(war);
- }
- }
-
- collectAndRedistributeResources() {
- for (const polity of this.polities) {
- const cities = this.getPolityCities(polity);
- const centerId = polity.centerCityId;
- for (const city of cities) city.receivedAid = false;
- for (const city of cities) {
- if (city.id === centerId) continue;
- const tax = city.storedResources * 0.035;
- city.storedResources -= tax;
- polity.treasury += tax;
- }
- const agePressure = this.polityAgePressure(polity);
- const overextension = this.polityOverextension(polity);
- const adminCost =
- cities.length * 0.24 +
- overextension * 1.1 +
- agePressure * cities.length * 0.18;
- polity.treasury -= adminCost;
- if (polity.treasury < 0) {
- const deficit = Math.abs(polity.treasury);
- polity.treasury = 0;
- polity.crisis = clamp((polity.crisis || 0) + deficit * 0.012, 0, 1.5);
- }
-
- const maintenance = Math.pow(cities.length, 1.12) * 0.10;
- if (maintenance > 0) {
- const center = this.getCityById(centerId);
- const treasuryPayment = Math.min(polity.treasury, maintenance);
- polity.treasury -= treasuryPayment;
- const unpaid = maintenance - treasuryPayment;
- if (unpaid > 0 && center) {
- const paidByCenter = this.drainCityResources(center, unpaid);
- if (paidByCenter < unpaid) {
- for (const city of cities) {
- if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.012, 0, 1);
- }
- }
- }
- }
-
- const poorCount = Math.ceil(cities.length * 0.1);
- const poorest = [...cities]
- .sort((a, b) => (a.storedResources / Math.max(1, a.population)) - (b.storedResources / Math.max(1, b.population)))
- .slice(0, poorCount);
- for (const city of poorest) {
- const target = city.population * 0.08;
- const need = target - city.storedResources;
- if (need > 0 && polity.treasury > 0) {
- const aid = Math.min(need, polity.treasury);
- city.storedResources += aid;
- polity.treasury -= aid;
- city.receivedAid = true;
- city.loyalty = clamp(city.loyalty + 0.05, 0, 1);
- }
- }
- }
- }
-
- erodePolityLegitimacy() {
- for (const polity of this.polities) {
- const agePressure = this.polityAgePressure(polity);
- const resourceStress = this.polityResourceStress(polity);
- const overextension = this.polityOverextension(polity);
- const erosion =
- 0.0015 +
- agePressure * 0.003 +
- resourceStress * 0.003 +
- overextension * 0.0015;
-
- polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - erosion, 0, 1);
- polity.cohesion = clamp((polity.cohesion ?? 0.6) - erosion * 0.45, 0, 1);
-
- const cities = this.getPolityCities(polity);
- if (this.polityAge(polity) < 80 && polity.treasury > cities.length * 10) {
- polity.legitimacy = clamp(polity.legitimacy + 0.008, 0, 1);
- polity.cohesion = clamp(polity.cohesion + 0.005, 0, 1);
- }
-
- polity.crisis = clamp((polity.crisis || 0) * 0.88, 0, 1.5);
- }
- }
-
- triggerPolityCrises() {
- for (const polity of this.polities) {
- const age = this.polityAge(polity);
- if (age < 140) continue;
- if ((this.year - (polity.lastCrisisYear ?? 0)) / MONTHS_PER_YEAR < 160) continue;
-
- const instability = this.polityInstability(polity);
- const agePressure = this.polityAgePressure(polity);
- const chance = 0.006 + agePressure * 0.018 + instability * 0.014;
- if (this.rng.next() >= chance) continue;
-
- polity.lastCrisisYear = this.year;
- const severity = clamp(
- 0.08 +
- agePressure * this.rng.range(0.08, 0.20) +
- instability * this.rng.range(0.05, 0.16),
- 0.05,
- 0.36
- );
-
- polity.crisis = clamp((polity.crisis || 0) + severity, 0, 1.5);
- polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - severity * 0.30, 0, 1);
- polity.cohesion = clamp((polity.cohesion ?? 0.6) - severity * 0.18, 0, 1);
-
- const center = this.getCityById(polity.centerCityId);
- for (const city of this.getPolityCities(polity)) {
- if (city.id === polity.centerCityId) continue;
- const distance = center ? this.effectiveDistance(center, city) : 40;
- const distanceFactor = clamp(distance / 40, 0.25, 1);
- city.loyalty = clamp(city.loyalty - severity * distanceFactor, 0, 1);
- }
- }
- }
-
- applyOldStateStress() {
- for (const polity of this.polities) {
- const age = this.polityAge(polity);
- if (age < 1000) continue;
-
- const cities = this.getPolityCities(polity);
- if (cities.length <= 1) continue;
-
- const oldAge = clamp((age - 1000) / 1000, 0, 1);
- polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - oldAge * 0.018, 0, 1);
- polity.cohesion = clamp((polity.cohesion ?? 0.6) - oldAge * 0.012, 0, 1);
- polity.crisis = clamp((polity.crisis || 0) + oldAge * 0.035, 0, 1.5);
-
- const center = this.getCityById(polity.centerCityId);
- for (const city of cities) {
- if (city.id === polity.centerCityId) continue;
- const distance = center ? this.effectiveDistance(center, city) : 40;
- const distanceFactor = clamp(distance / 35, 0.2, 1);
- city.loyalty = clamp(city.loyalty - oldAge * distanceFactor * 0.045, 0, 1);
- }
- }
- }
-
- updateCityLoyalty() {
- for (const polity of this.polities) {
- const center = this.getCityById(polity.centerCityId);
- if (!center) continue;
- center.loyalty = 1;
- const instability = this.polityInstability(polity);
- const agePressure = this.polityAgePressure(polity);
- const overextension = this.polityOverextension(polity);
- const logisticsStress = this.polityLogisticsStress(polity);
- for (const city of this.getPolityCities(polity)) {
- if (city.id === center.id) continue;
- const perCapita = city.storedResources / Math.max(1, city.population);
- const distance = this.effectiveDistance(city, center);
- let delta = 0;
- delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045);
- delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012;
- delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045);
- if (this.hasDirectTradeConnection(city, center)) delta += 0.025;
- if (city.receivedAid) delta += 0.04;
- delta -= 0.004;
- if (city.storedResources < city.population * 0.05) delta -= 0.035;
- delta -= (city.supplyStress || 0) * 0.018;
- delta -= instability * 0.020;
- delta -= agePressure * 0.010;
- delta -= overextension * clamp(distance / 48, 0, 1) * 0.014;
- delta -= logisticsStress * clamp(distance / 42, 0.25, 1) * 0.018;
- if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.020;
- if ((polity.crisis || 0) > 0.6) delta -= 0.014;
- city.loyalty = clamp(city.loyalty + delta, 0, 1);
- }
- }
- }
-
- splitUnloyalCities() {
- for (const polity of this.polities) {
- const instability = this.polityInstability(polity);
- const agePressure = this.polityAgePressure(polity);
- const threshold = clamp(0.14 + instability * 0.05 + agePressure * 0.03, 0.14, 0.28);
- for (const city of this.getPolityCities(polity)) {
- if (city.id === polity.centerCityId || city.loyalty >= threshold) continue;
- const chance =
- (threshold - city.loyalty) * 0.28 +
- instability * 0.012 +
- agePressure * 0.010;
- if (this.rng.next() < chance) this.removeCityFromPolity(city);
- }
- }
- }
-
- detectPolityFamines() {
- const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06;
- for (const polity of this.polities) {
- const cities = this.getPolityCities(polity);
- if (!cities.length) continue;
- let poorCities = 0;
- let totalFood = 0;
- let totalPopulation = 0;
- for (const city of cities) {
- const population = Math.max(1, city.population || 0);
- const perCapita = (city.storedResources || 0) / population;
- if (perCapita < minimumFood) poorCities++;
- totalFood += city.storedResources || 0;
- totalPopulation += population;
- }
- const poorRate = poorCities / cities.length;
- const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation);
- const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035;
- if (!famine) continue;
- if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue;
- this.addPolityEvent(polity.id, "famine", this.year, {
- poorRate,
- avgPerCapitaFood
- }, poorRate > 0.65 ? 3 : 2);
- polity.lastFamineYear = this.year;
- }
- }
-
- cleanupPolities() {
- const survivors = [];
- for (const polity of this.polities) {
- const cities = this.getPolityCities(polity);
- if (!cities.length) {
- this.markPolityEnded(polity, "collapsed");
- continue;
- }
- let center = this.getCityById(polity.centerCityId);
- if (!center || center.population <= 0) {
- const oldCenterCityId = polity.centerCityId;
- center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]);
- polity.centerCityId = center.id;
- center.loyalty = 1;
- if (oldCenterCityId !== center.id) {
- this.addPolityEvent(polity.id, "capitalShift", this.year, {
- oldCenterCityId,
- newCenterCityId: center.id
- }, 2);
- }
- polity.treasury *= 0.5;
- polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1);
- polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1);
- polity.crisis = clamp((polity.crisis || 0) + 0.35, 0, 1.5);
- polity.lastCrisisYear = this.year;
- for (const city of cities) {
- if (city.id !== center.id) city.loyalty = clamp(city.loyalty - 0.18, 0, 1);
- }
- const history = this.ensurePolityHistory(polity);
- if (history) history.centerCityId = center.id;
- }
- if (cities.length === 1) {
- this.markPolityEnded(polity, "fragmented");
- cities[0].polityId = null;
- cities[0].loyalty = 0.45;
- cities[0].receivedAid = false;
- continue;
- }
- survivors.push(polity);
- }
- this.polities = survivors;
- this.rebuildIndexes();
- const validPolities = new Set(this.polities.map(p => p.id));
- for (const city of this.cities) {
- if (city.polityId !== null && !validPolities.has(city.polityId)) {
- city.polityId = null;
- city.loyalty = 0.45;
- city.receivedAid = false;
- }
- }
- }
-
- updatePolities() {
- if (this.year % years(1) !== 0) return;
- this.cleanupPolities();
- this.foundPolities();
- this.expandPolities();
-
- if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes();
-
- this.collectAndRedistributeResources();
- this.detectPolityFamines();
-
- if (this.erodePolityLegitimacy) this.erodePolityLegitimacy();
- if (this.triggerPolityCrises) this.triggerPolityCrises();
- if (this.applyOldStateStress) this.applyOldStateStress();
- if (this.updatePolityLeaders) this.updatePolityLeaders();
-
- this.updateCityLoyalty();
- this.absorbIndependentCities();
- this.maybeStartWars();
- this.updateWars();
- this.splitUnloyalCities();
-
- this.cleanupPolities();
- if (this.samplePolityHistories) this.samplePolityHistories();
- }
-
- reinforcePolityTradeRoutes() {
- const w = this.world;
- for (const polity of this.polities) {
- if (this.rng.next() > 0.48) continue;
- const center = this.getCityById(polity.centerCityId);
- if (!center) continue;
- const candidates = this.getPolityCities(polity)
- .filter(city => city.id !== center.id && !this.hasDirectTradeConnection(center, city))
- .filter(city => this.distanceBetweenCities(center, city) <= 40)
- .sort((a, b) => this.effectiveDistance(center, a) - this.effectiveDistance(center, b));
- if (!candidates.length) continue;
-
- const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))];
- const path = this.findTerrainRoute(center.x, center.y, target.x, target.y);
- if (!this.isValidRoutePath(path, target.x, target.y)) continue;
- const cities = this.getPolityCities(polity);
- const roadCost = 8 + cities.length * 1.5 + this.polityOverextension(polity) * 3;
- if ((polity.treasury || 0) < roadCost) {
- polity.crisis = clamp((polity.crisis || 0) + 0.025, 0, 1.5);
- continue;
- }
- polity.treasury -= roadCost;
- if (!this.payRouteConstructionCost(center, target, path, polity)) continue;
-
- center.tradeLinks.add(target.id);
- target.tradeLinks.add(center.id);
- this.tradeLinks.push({ from: center.id, to: target.id, strength: 0.22, path });
- for (const tile of path) {
- w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 34);
- w.pheromone[tile] += 2.4;
- }
- }
- }
-
- updateTradeRoutes() {
- const w = this.world;
- for (let i = 0; i < w.count; i++) {
- const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2;
- if (invalidTerrain) w.tradeRoute[i] = 0;
- else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2);
- }
-
- this.tradeLinks = [];
- for (const city of this.cities) city.tradeLinks.clear();
- const pairCandidates = [];
- const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400);
- const sqrtPop = new Map(this.cities.map(city => [city.id, Math.sqrt(city.population || 1)]));
- for (let a = 0; a < this.cities.length; a++) {
- for (let b = a + 1; b < this.cities.length; b++) {
- const c1 = this.cities[a];
- const c2 = this.cities[b];
- const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
- if (d > 44) continue;
- const distanceFactor = clamp((d - 14) / 30, 0, 1);
- const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1);
- const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0;
- const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias;
- pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore });
- }
- }
- pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore);
-
- const candidates = [];
- for (const pair of pairCandidates.slice(0, maxPairsToRoute)) {
- const { c1, c2, distanceFactor, marketScale } = pair;
- const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y);
- if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
- const strength = this.routeStrengthForPath(path);
- if (strength < 0.08) continue;
- if (!this.canPayRouteConstructionCost(c1, c2, path)) continue;
- const tradeScore = strength * (1 + distanceFactor * 0.7) * marketScale;
- candidates.push({ c1, c2, strength, path, tradeScore });
- }
-
- candidates.sort((a, b) => b.tradeScore - a.tradeScore);
- const maxLinks = Math.max(1, Math.floor(this.cities.length / 2));
- const supportedRoutes = new Set();
- for (const candidate of candidates) {
- if (this.tradeLinks.length >= maxLinks) break;
- const { c1, c2, strength, path } = candidate;
- const c1Limit = c1.population > 90 ? 3 : 2;
- const c2Limit = c2.population > 90 ? 3 : 2;
- if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue;
- if (!this.payRouteConstructionCost(c1, c2, path)) continue;
- c1.tradeLinks.add(c2.id);
- c2.tradeLinks.add(c1.id);
- this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path });
- this.exchangeCityResources(c1, c2, strength, path);
- for (const tile of path) {
- supportedRoutes.add(tile);
- w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 18);
- }
- }
-
- for (let i = 0; i < w.count; i++) {
- if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4);
- }
- this.activeTradeRouteTiles = new Set();
- for (let i = 0; i < w.count; i++) {
- if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i);
- }
- this.diffuseCityKnowledgeThroughTrade();
- }
-
- diffuseCityKnowledgeThroughTrade() {
- for (const link of this.tradeLinks) {
- const a = this.getCityById(link.from);
- const b = this.getCityById(link.to);
- if (!a || !b) continue;
- a.knowledge ??= { farming: 0, metallurgy: 0 };
- b.knowledge ??= { farming: 0, metallurgy: 0 };
- const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + link.strength), 0, 0.012);
- const farmingDelta = (a.knowledge.farming - b.knowledge.farming) * rate;
- const metallurgyDelta = (a.knowledge.metallurgy - b.knowledge.metallurgy) * rate;
- a.knowledge.farming = clamp(a.knowledge.farming - farmingDelta, 0, 1);
- b.knowledge.farming = clamp(b.knowledge.farming + farmingDelta, 0, 1);
- a.knowledge.metallurgy = clamp(a.knowledge.metallurgy - metallurgyDelta, 0, 1);
- b.knowledge.metallurgy = clamp(b.knowledge.metallurgy + metallurgyDelta, 0, 1);
- }
- }
-
- findTerrainRoute(x1, y1, x2, y2) {
- const w = this.world;
- const path = [];
- const visited = new Set();
- let x = x1;
- let y = y1;
- const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60);
- const directions = [
- [1, 0],
- [-1, 0],
- [0, 1],
- [0, -1]
- ];
-
- for (let step = 0; step < maxSteps; step++) {
- const current = w.idx(x, y);
- path.push(current);
- visited.add(current);
- if (x === x2 && y === y2) break;
-
- let bestX = x;
- let bestY = y;
- let bestScore = Infinity;
- for (const [dx, dy] of directions) {
- const nx = x + dx;
- const ny = y + dy;
- if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue;
- const i = w.idx(nx, ny);
- const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0);
- const revisitCost = visited.has(i) ? 5 : 0;
- const routeEase = w.tradeRoute[i] ? -2 : 0;
- const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4);
- const distance = Math.abs(nx - x2) + Math.abs(ny - y2);
- const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase;
- if (score < bestScore) {
- bestScore = score;
- bestX = nx;
- bestY = ny;
- }
- }
-
- if (bestX === x && bestY === y) break;
- x = bestX;
- y = bestY;
- }
- return path;
- }
-
- routeStrengthForPath(path) {
- const w = this.world;
- if (!path.length) return 0;
- let route = 0;
- let pheromone = 0;
- let terrainEase = 0;
- for (const i of path) {
- route += w.tradeRoute[i] > 0 ? 1 : 0;
- pheromone += clamp(w.pheromone[i] / 9, 0, 1);
- terrainEase += 1 / Math.max(1, w.move[i]);
- }
- return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2;
- }
-
- isValidRoutePath(path, targetX, targetY) {
- const w = this.world;
- if (!path.length) return false;
- const last = path[path.length - 1];
- if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false;
- if (path.length > 42) return false;
-
- let totalCost = 0;
- let hardTiles = 0;
- const seen = new Set();
- for (const tile of path) {
- if (seen.has(tile)) return false;
- seen.add(tile);
- if (w.terrain[tile] === Terrain.WATER) return false;
- totalCost += w.move[tile];
- if (w.move[tile] > 1.8) hardTiles++;
- }
- return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12;
- }
-
- exchangeCityResources(a, b, strength, path) {
- const delta = (a.storedResources - b.storedResources) * 0.018 * strength;
- a.storedResources -= delta;
- b.storedResources += delta;
- const traffic = Math.min(0.8, strength * 0.26);
- this.depositRoutePheromone(path, traffic);
- }
-
- routeConstructionCost(path, polityBacked = false) {
- let weakTiles = 0;
- for (const tile of path) {
- if (this.world.tradeRoute[tile] < 24) weakTiles++;
- }
- return weakTiles * (polityBacked ? 0.22 : 0.16);
- }
-
- canPayRouteConstructionCost(cityA, cityB, path, polity = null) {
- const cost = this.routeConstructionCost(path, !!polity);
- if (cost <= 0) return true;
- return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost;
- }
-
- payRouteConstructionCost(cityA, cityB, path, polity = null) {
- const cost = this.routeConstructionCost(path, !!polity);
- if (cost <= 0) return true;
- if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) return false;
-
- let remaining = cost;
- if (polity) {
- const treasuryPayment = Math.min(polity.treasury, cost * 0.65);
- polity.treasury -= treasuryPayment;
- remaining -= treasuryPayment;
- }
-
- const half = remaining * 0.5;
- const paidA = this.drainCityResources(cityA, half);
- const paidB = this.drainCityResources(cityB, half);
- remaining -= paidA + paidB;
- if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining);
- if (remaining > 0 && polity) {
- const treasuryPayment = Math.min(polity.treasury, remaining);
- polity.treasury -= treasuryPayment;
- remaining -= treasuryPayment;
- }
- return remaining <= 0.001;
- }
-
- drainCityResources(city, amount) {
- const paid = Math.min(city.storedResources, amount);
- city.storedResources -= paid;
- return paid;
- }
-
- depositRoutePheromone(path, amount) {
- const w = this.world;
- for (const i of path) {
- w.pheromone[i] += amount;
- }
- }
-
- updateEthnicStats() {
- for (const e of this.ethnicities.values()) {
- e.population = 0;
- e.diversity = 0;
- e.centroidX = 0;
- e.centroidY = 0;
- e.activeTraitPopulation = 0;
- e.traitSums = emptyTraitSums();
- }
-
- for (const a of this.agents) {
- const e = this.ethnicities.get(a.ethnicity);
- if (!e) continue;
- e.population++;
- e.centroidX += a.x;
- e.centroidY += a.y;
- e.activeTraitPopulation++;
- addTraits(e.traitSums, a.traits);
- }
-
- for (const city of this.cities) {
- for (const [id, count] of city.ethnicityComposition) {
- const e = this.ethnicities.get(id);
- if (!e) continue;
- e.population += count;
- e.centroidX += city.x * count;
- e.centroidY += city.y * count;
- }
- }
-
- for (const e of this.ethnicities.values()) {
- if (!e.population) continue;
- e.centroidX /= e.population;
- e.centroidY /= e.population;
- const activeTraitCount = Math.max(1, e.activeTraitPopulation);
- e.averageTraits = averageTraits(e.traitSums, activeTraitCount);
- }
-
- for (const a of this.agents) {
- const e = this.ethnicities.get(a.ethnicity);
- if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits);
- }
-
- for (const e of this.ethnicities.values()) {
- if (e.population) e.diversity /= e.population;
- }
- }
-
- splitDivergentEthnicities() {
- for (const e of this.ethnicities.values()) {
- if (e.activeTraitPopulation < 24 || e.population < 40 || e.diversity < 0.14) continue;
- const candidates = [];
- let tempSum = 0;
- let humidSum = 0;
- for (const a of this.agents) {
- if (a.ethnicity !== e.id) continue;
- const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 0.95;
- const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.09;
- const tile = this.world.idx(a.x, a.y);
- const climate = this.climateMismatch(e.id, tile) > 0.18;
- if ((far || spatial || climate) && this.rng.next() < 0.72) {
- candidates.push(a);
- tempSum += this.world.temperature[tile];
- humidSum += this.world.humidity[tile];
- }
- }
- if (candidates.length >= 6) {
- const newId = this.createEthnicity(e.id, {
- temperature: tempSum / candidates.length,
- humidity: humidSum / candidates.length
- });
- for (const a of candidates) a.ethnicity = newId;
- }
- }
- }
-
- toJSON() {
- return {
- version: SimConfig.save.version,
- rngSeed: this.rng.seed,
- year: this.year,
- deaths: this.deaths,
- nextEthnicity: this.nextEthnicity,
- nextCity: this.nextCity,
- nextPolity: this.nextPolity,
- nextWar: this.nextWar,
- nextDisaster: this.nextDisaster,
- maxAgents: this.maxAgents,
- world: {
- size: this.world.size,
- terrain: packArray(this.world.terrain),
- resource: packArray(this.world.resource),
- regen: packArray(this.world.regen),
- move: packArray(this.world.move),
- fertility: packArray(this.world.fertility),
- mineral: packArray(this.world.mineral),
- temperature: packArray(this.world.temperature),
- humidity: packArray(this.world.humidity),
- pheromone: packArray(this.world.pheromone),
- tradeRoute: packArray(this.world.tradeRoute),
- farmland: packArray(this.world.farmland),
- cityPull: packArray(this.world.cityPull),
- city: packArray(this.world.city),
- pressure: packArray(this.world.pressure),
- dominantEthnicity: packArray(this.world.dominantEthnicity),
- cultureDiversity: packArray(this.world.cultureDiversity)
- },
- agents: this.agents,
- ethnicities: [...this.ethnicities.values()].map(e => ({
- id: e.id,
- parent: e.parent,
- born: e.born,
- climateTemp: e.climateTemp,
- climateHumidity: e.climateHumidity,
- color: e.color
- })),
- cities: this.cities.map(c => ({
- ...c,
- ethnicityComposition: [...c.ethnicityComposition],
- tradeLinks: [...c.tradeLinks]
- })),
- polities: this.polities.map(p => ({
- id: p.id,
- centerCityId: p.centerCityId,
- cityIds: [...p.cityIds],
- treasury: p.treasury,
- color: p.color,
- founded: p.founded,
- legitimacy: p.legitimacy,
- cohesion: p.cohesion,
- charisma: p.charisma,
- leaderStarted: p.leaderStarted,
- leaderTenureYears: p.leaderTenureYears,
- crisis: p.crisis,
- lastCrisisYear: p.lastCrisisYear,
- lastFamineYear: p.lastFamineYear ?? null
- })),
- wars: this.wars,
- disasters: this.disasters,
- disasterHistory: this.disasterHistory,
- polityHistory: [...this.polityHistory.values()],
- deadPolityHistories: this.deadPolityHistories
- };
- }
-
- static fromJSON(state) {
- if (!state?.world?.size) throw new Error("Invalid save payload");
- const sim = new Simulation(state.world.size, 0, { blankWorld: true, skipSpawn: true });
- sim.rng.seed = state.rngSeed >>> 0;
- sim.year = state.year || 0;
- sim.deaths = state.deaths || 0;
- sim.nextEthnicity = state.nextEthnicity || 1;
- sim.nextCity = state.nextCity || 1;
- sim.nextPolity = state.nextPolity || 1;
- sim.nextWar = state.nextWar || 1;
- sim.nextDisaster = state.nextDisaster || 1;
- sim.maxAgents = state.maxAgents || 30000;
-
- sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array));
- sim.world.resource.set(unpackArray(state.world.resource, Float32Array));
- sim.world.regen.set(unpackArray(state.world.regen, Float32Array));
- sim.world.move.set(unpackArray(state.world.move, Float32Array));
- sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array));
- sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array));
- sim.world.temperature.set(unpackArray(state.world.temperature, Float32Array));
- sim.world.humidity.set(unpackArray(state.world.humidity, Float32Array));
- sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array));
- sim.world.tradeRoute.set(unpackArray(state.world.tradeRoute, Uint8Array));
- sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array));
- sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array));
- sim.world.city.set(unpackArray(state.world.city, Int32Array));
- if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
- if (state.world.dominantEthnicity) sim.world.dominantEthnicity.set(unpackArray(state.world.dominantEthnicity, Int32Array));
- if (state.world.cultureDiversity) sim.world.cultureDiversity.set(unpackArray(state.world.cultureDiversity, Float32Array));
-
- sim.agents = state.agents || [];
- for (const a of sim.agents) sim.ensureAgentTech(a);
- sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, {
- id: e.id,
- parent: e.parent,
- born: e.born,
- population: 0,
- diversity: 0,
- climateTemp: e.climateTemp,
- climateHumidity: e.climateHumidity,
- color: e.color,
- centroidX: 0,
- centroidY: 0
- }]));
- sim.cities = (state.cities || []).map(c => ({
- id: c.id,
- x: c.x,
- y: c.y,
- population: c.population || 0,
- storedResources: c.storedResources || 0,
- ethnicityComposition: new Map(c.ethnicityComposition || []),
- pheromoneOutput: c.pheromoneOutput || 0,
- agriculturalRadius: c.agriculturalRadius || 2,
- tradeLinks: new Set(c.tradeLinks || []),
- activeVisitors: 0,
- age: c.age || 0,
- strength: c.strength || 1,
- sedentaryCulture: c.sedentaryCulture ?? 0.5,
- knowledge: {
- farming: clamp(c.knowledge?.farming ?? 0, 0, 1),
- metallurgy: clamp(c.knowledge?.metallurgy ?? 0, 0, 1)
- },
- supplyStress: c.supplyStress ?? 0,
- polityId: c.polityId ?? null,
- loyalty: c.loyalty ?? 0.5,
- receivedAid: c.receivedAid ?? false,
- tradeValue: c.tradeValue ?? 0,
- tradeReach: c.tradeReach ?? 0
- }));
- sim.polities = (state.polities || []).map(p => ({
- id: p.id,
- centerCityId: p.centerCityId,
- cityIds: new Set(p.cityIds || []),
- treasury: p.treasury || 0,
- color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62),
- founded: p.founded || 0,
- legitimacy: p.legitimacy ?? 0.7,
- cohesion: p.cohesion ?? 0.6,
- charisma: clamp(p.charisma ?? 1, 0.5, 1.5),
- leaderStarted: p.leaderStarted ?? p.founded ?? sim.year,
- leaderTenureYears: clamp(p.leaderTenureYears ?? 48, 12, 96),
- crisis: p.crisis ?? 0,
- lastCrisisYear: p.lastCrisisYear ?? sim.year,
- lastFamineYear: p.lastFamineYear ?? null
- }));
- sim.rebuildIndexes();
- sim.wars = (state.wars || []).map(w => ({
- id: w.id,
- aPolityId: w.aPolityId,
- bPolityId: w.bPolityId,
- started: w.started || sim.year,
- lastActionYear: w.lastActionYear || w.started || sim.year,
- intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25),
- exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1),
- exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1),
- ended: w.ended ?? null
- })).filter(w => w.ended === null);
- const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0);
- sim.nextWar = Math.max(sim.nextWar, maxWarId + 1);
- const restoreDisaster = d => ({
- id: d.id,
- year: d.year || 0,
- x: d.x || 0,
- y: d.y || 0,
- radius: d.radius || 8,
- intensity: d.intensity || 0.5,
- expires: d.expires ?? ((d.year || 0) + years(18)),
- affectedAgents: d.affectedAgents || 0,
- affectedCities: d.affectedCities || 0,
- totalPopulationLoss: d.totalPopulationLoss || 0,
- affectedPolities: d.affectedPolities || []
- });
- sim.disasters = (state.disasters || []).map(restoreDisaster);
- sim.disasterHistory = (state.disasterHistory || []).map(restoreDisaster);
- const maxDisasterId = [...sim.disasters, ...sim.disasterHistory].reduce((max, disaster) => Math.max(max, disaster.id || 0), 0);
- sim.nextDisaster = Math.max(sim.nextDisaster, maxDisasterId + 1);
- sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, {
- id: h.id,
- color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
- founded: h.founded || 0,
- ended: h.ended ?? null,
- active: h.active !== false,
- centerCityId: h.centerCityId ?? null,
- fate: h.fate || null,
- samples: h.samples || [],
- events: h.events || [],
- peakPower: h.peakPower || 0,
- peakYear: h.peakYear ?? null,
- peakEventYear: h.peakEventYear ?? null
- }]));
- sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({
- id: h.id,
- color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
- founded: h.founded || 0,
- ended: h.ended ?? null,
- active: false,
- centerCityId: h.centerCityId ?? null,
- fate: h.fate || null,
- samples: h.samples || [],
- events: h.events || [],
- peakPower: h.peakPower || 0,
- peakYear: h.peakYear ?? null,
- peakEventYear: h.peakEventYear ?? null
- }));
- for (const polity of sim.polities) sim.ensurePolityHistory(polity);
- sim.tradeLinks = [];
- sim.activeTradeRouteTiles = new Set();
- sim.rebuildOccupancy();
- sim.updateTradeRoutes();
- sim.cleanupPolities();
- sim.updateEthnicStats();
- return sim;
- }
+constructor(size, initialAgents, options = {}) {
+this.rng = new Rng(Date.now());
+this.world = options.blankWorld ? World.blank(size, this.rng) : new World(size, this.rng);
+this.agents = [];
+this.ethnicities = new Map();
+this.cities = [];
+this.cityById = new Map();
+this.cityBuckets = new Map();
+this.polities = [];
+this.polityById = new Map();
+this.wars = [];
+this.disasters = [];
+this.disasterHistory = [];
+this.polityHistory = new Map();
+this.deadPolityHistories = [];
+this.tradeLinks = [];
+this.activeTradeRouteTiles = new Set();
+this.ethnicDensityCache = new Map();
+this.dominantEthnicityCache = new Map();
+this.nextEthnicity = 1;
+this.nextCity = 1;
+this.nextPolity = 1;
+this.nextWar = 1;
+this.nextDisaster = 1;
+this.year = 0;
+this.deaths = 0;
+this.maxAgents = Math.max(
+Math.floor(initialAgents * SimConfig.population.maxAgentsScale),
+SimConfig.population.maxAgentsFloor
+);
+this.tileEthnicities = new Map();
+this.tileAgents = new Map();
+if (!options.skipSpawn) {
+this.spawnInitialAgents(initialAgents);
+this.rebuildOccupancy();
+this.updateEthnicStats();
+}
+}
+spawnInitialAgents(count) {
+const founders = Math.max(5, Math.min(16, Math.round(count / 180)));
+const desertFounders = Math.max(2, Math.floor(founders * 0.25));
+for (let e = 0; e < founders; e++) {
+const desertFounder = e < desertFounders;
+const origin = this.findHabitableTile(desertFounder ? Terrain.DESERT : null);
+const originTile = this.world.idx(origin.x, origin.y);
+const id = this.createEthnicity(0, {
+temperature: this.world.temperature[originTile],
+humidity: this.world.humidity[originTile]
+});
+const baseTraits = desertFounder ? this.desertFounderTraits() : this.randomTraits();
+for (let n = 0; n < Math.floor(count / founders); n++) {
+const spawn = this.findNearbySpawn(origin.x, origin.y, desertFounder ? Terrain.DESERT : null);
+this.agents.push(this.makeAgent(
+spawn.x,
+spawn.y,
+id,
+mutateTraits(baseTraits, this.rng, 0.07),
+desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15)
+));
+}
+}
+}
+createEthnicity(parent, climate = null) {
+const id = this.nextEthnicity++;
+this.ethnicities.set(id, {
+id,
+parent,
+born: this.year,
+population: 0,
+diversity: 0,
+climateTemp: climate?.temperature ?? 0.55,
+climateHumidity: climate?.humidity ?? 0.45,
+color: hslToRgb((id * 0.61803398875) % 1, 0.55, 0.56),
+centroidX: 0,
+centroidY: 0
+});
+return id;
+}
+randomTraits() {
+return {
+mobility: this.rng.range(0.18, 0.85),
+resourceAttraction: this.rng.range(0.55, 1),
+assimilation: this.rng.range(0.03, 0.34),
+ethnocentrism: this.rng.range(0.12, 0.78),
+reproductionThreshold: this.rng.range(19, 34),
+sedentary: this.rng.range(0.08, 0.82)
+};
+}
+desertFounderTraits() {
+const traits = this.randomTraits();
+traits.mobility = this.rng.range(0.48, 0.9);
+traits.resourceAttraction = this.rng.range(0.82, 1.12);
+traits.reproductionThreshold = this.rng.range(24, 38);
+traits.sedentary = this.rng.range(0.18, 0.58);
+return traits;
+}
+makeAgent(x, y, ethnicity, traits, resources) {
+return {
+x,
+y,
+resources,
+ethnicity,
+traits,
+alive: true,
+foreignContact: 0,
+contactEthnicity: ethnicity,
+settled: 0,
+movedThisStep: false,
+tech: {
+farming: 0,
+metallurgy: 0
+},
+farmingWork: 0,
+lastFarmTile: -1
+};
+}
+findNearbySpawn(originX, originY, preferredTerrain = null) {
+const w = this.world;
+let best = { x: originX, y: originY };
+let bestScore = -Infinity;
+for (let tries = 0; tries < 24; tries++) {
+const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1);
+const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1);
+const i = w.idx(x, y);
+if (w.terrain[i] === Terrain.WATER) continue;
+const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0;
+const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16;
+if (score > bestScore) {
+bestScore = score;
+best = { x, y };
+}
+}
+return best;
+}
+findHabitableTile(preferredTerrain = null) {
+if (preferredTerrain !== null) {
+const exact = [];
+let best = null;
+let bestScore = -Infinity;
+for (let i = 0; i < this.world.count; i++) {
+const x = i % this.world.size;
+const y = Math.floor(i / this.world.size);
+if (this.world.terrain[i] === preferredTerrain) {
+exact.push({ x, y });
+continue;
+}
+if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) {
+const score = this.world.temperature[i] * (1 - this.world.humidity[i]);
+if (score > bestScore) {
+bestScore = score;
+best = { x, y };
+}
+}
+}
+if (exact.length) return exact[this.rng.int(exact.length)];
+if (best) return best;
+}
+for (let tries = 0; tries < 5000; tries++) {
+const x = this.rng.int(this.world.size);
+const y = this.rng.int(this.world.size);
+const i = this.world.idx(x, y);
+if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) {
+return { x, y };
+}
+}
+return { x: this.world.size >> 1, y: this.world.size >> 1 };
+}
+step() {
+for (const city of this.cities) city.activeVisitors = 0;
+this.rebuildOccupancy();
+this.ethnicDensityCache.clear();
+const offspring = [];
+for (const a of this.agents) {
+if (!a.alive) continue;
+this.moveAgent(a);
+}
+this.rebuildOccupancy();
+this.dominantEthnicityCache.clear();
+for (const a of this.agents) {
+if (!a.alive) continue;
+this.updateAgentTechnology(a);
+this.gatherConsumeReproduce(a, offspring);
+if (a.alive) this.payTechnologyCostOrForget(a);
+if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a);
+}
+this.agents = this.agents.filter(a => a.alive);
+const acceptedOffspring = this.agents.length + offspring.length < this.maxAgents
+? offspring
+: offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length));
+this.agents.push(...acceptedOffspring);
+for (const child of acceptedOffspring) this.addAgentToOccupancy(child);
+if (this.year % 2 === 0) this.updateWorldFields(2);
+this.maybeSpawnDisaster();
+if (this.year % years(1) === 0) {
+this.updateCities();
+if (this.year % years(2) === 0) this.updateRegionalCultures();
+}
+if (this.year % years(5) === 0) {
+this.updateTradeRoutes();
+}
+this.updatePolities();
+this.updateEthnicStats();
+if (this.year % years(45) === 0) {
+this.splitDivergentEthnicities();
+this.updateEthnicStats();
+}
+this.year++;
+}
+rebuildOccupancy() {
+const w = this.world;
+w.pressure.fill(0);
+this.tileEthnicities.clear();
+this.tileAgents.clear();
+this.rebuildIndexes();
+for (const a of this.agents) {
+if (!a.alive) continue;
+const i = w.idx(a.x, a.y);
+w.pressure[i]++;
+let agents = this.tileAgents.get(i);
+if (!agents) {
+agents = [];
+this.tileAgents.set(i, agents);
+}
+agents.push(a);
+let counts = this.tileEthnicities.get(i);
+if (!counts) {
+counts = new Map();
+this.tileEthnicities.set(i, counts);
+}
+counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
+}
+for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile);
+}
+rebuildIndexes() {
+this.cityById = new Map(this.cities.map(city => [city.id, city]));
+this.polityById = new Map(this.polities.map(polity => [polity.id, polity]));
+this.cityBuckets = new Map();
+for (const city of this.cities) {
+const key = this.cityBucketKey(city.x, city.y);
+let bucket = this.cityBuckets.get(key);
+if (!bucket) {
+bucket = [];
+this.cityBuckets.set(key, bucket);
+}
+bucket.push(city);
+}
+}
+cityBucketKey(x, y) {
+return `${Math.floor(x / 8)},${Math.floor(y / 8)}`;
+}
+getCitiesNear(x, y, radius) {
+const cities = [];
+const minBx = Math.floor((x - radius) / 8);
+const maxBx = Math.floor((x + radius) / 8);
+const minBy = Math.floor((y - radius) / 8);
+const maxBy = Math.floor((y + radius) / 8);
+for (let by = minBy; by <= maxBy; by++) {
+for (let bx = minBx; bx <= maxBx; bx++) {
+const bucket = this.cityBuckets.get(`${bx},${by}`);
+if (!bucket) continue;
+for (const city of bucket) {
+if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city);
+}
+}
+}
+return cities;
+}
+updateCultureTile(tile) {
+const counts = this.tileEthnicities.get(tile);
+if (!counts || !counts.size) {
+this.world.cultureDiversity[tile] *= 0.98;
+return;
+}
+let total = 0;
+let dominant = -1;
+let dominantCount = 0;
+for (const [id, count] of counts) {
+total += count;
+if (count > dominantCount) {
+dominant = id;
+dominantCount = count;
+}
+}
+this.world.dominantEthnicity[tile] = dominant;
+this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0;
+}
+updateRegionalCultures() {
+const w = this.world;
+const nextDominant = new Int32Array(w.dominantEthnicity);
+const nextDiversity = new Float32Array(w.cultureDiversity);
+const radius = SimConfig.culture.spreadRadius;
+const cityById = new Map(this.cities.map(city => [city.id, city]));
+for (let y = 0; y < w.size; y++) {
+for (let x = 0; x < w.size; x++) {
+const tile = w.idx(x, y);
+if (w.terrain[tile] === Terrain.WATER) continue;
+const influence = new Map();
+let total = 0;
+for (let dy = -radius; dy <= radius; dy++) {
+for (let dx = -radius; dx <= radius; dx++) {
+const distance = Math.abs(dx) + Math.abs(dy);
+if (distance > radius) continue;
+const tx = x + dx;
+const ty = y + dy;
+if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
+const source = w.idx(tx, ty);
+const id = w.dominantEthnicity[source];
+if (id < 0) continue;
+const pressureWeight = Math.sqrt(Math.max(0, w.pressure[source]));
+const cityWeight = w.city[source] >= 0
+? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight
+: 0;
+const routeWeight = w.tradeRoute[source] ? SimConfig.culture.routeWeight : 1;
+const weight = (pressureWeight + cityWeight) * routeWeight / (1 + distance);
+if (weight <= 0) continue;
+influence.set(id, (influence.get(id) || 0) + weight);
+total += weight;
+}
+}
+let bestId = nextDominant[tile];
+let bestWeight = 0;
+for (const [id, weight] of influence) {
+if (weight > bestWeight) {
+bestId = id;
+bestWeight = weight;
+}
+}
+if (bestWeight >= SimConfig.culture.minimumInfluence) {
+nextDominant[tile] = bestId;
+nextDiversity[tile] = total > 0 ? clamp(1 - bestWeight / total, 0, 1) : 0;
+}
+}
+}
+w.dominantEthnicity.set(nextDominant);
+w.cultureDiversity.set(nextDiversity);
+}
+addAgentToOccupancy(a) {
+if (!a.alive) return;
+const w = this.world;
+const i = w.idx(a.x, a.y);
+w.pressure[i]++;
+let agents = this.tileAgents.get(i);
+if (!agents) {
+agents = [];
+this.tileAgents.set(i, agents);
+}
+agents.push(a);
+let counts = this.tileEthnicities.get(i);
+if (!counts) {
+counts = new Map();
+this.tileEthnicities.set(i, counts);
+}
+counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
+this.updateCultureTile(i);
+}
+removeAgentFromOccupancy(a) {
+const w = this.world;
+const i = w.idx(a.x, a.y);
+w.pressure[i] = Math.max(0, w.pressure[i] - 1);
+const agents = this.tileAgents.get(i);
+if (agents) {
+const index = agents.indexOf(a);
+if (index >= 0) agents.splice(index, 1);
+if (!agents.length) this.tileAgents.delete(i);
+}
+const counts = this.tileEthnicities.get(i);
+if (counts) {
+const next = (counts.get(a.ethnicity) || 0) - 1;
+if (next > 0) counts.set(a.ethnicity, next);
+else counts.delete(a.ethnicity);
+if (!counts.size) this.tileEthnicities.delete(i);
+}
+this.updateCultureTile(i);
+}
+changeAgentEthnicity(a, nextEthnicity) {
+if (a.ethnicity === nextEthnicity) return;
+const w = this.world;
+const i = w.idx(a.x, a.y);
+const counts = this.tileEthnicities.get(i);
+if (counts) {
+const prev = (counts.get(a.ethnicity) || 0) - 1;
+if (prev > 0) counts.set(a.ethnicity, prev);
+else counts.delete(a.ethnicity);
+counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1);
+}
+a.ethnicity = nextEthnicity;
+this.updateCultureTile(i);
+}
+moveAgent(a) {
+const w = this.world;
+const s = w.size;
+const current = w.idx(a.x, a.y);
+const localPressure = w.pressure[current];
+let bestX = a.x;
+let bestY = a.y;
+let bestScore = -Infinity;
+const sedentary = getSedentary(a.traits);
+const ethnocentrism = getEthnocentrism(a.traits);
+const ethnicClimate = this.ethnicities.get(a.ethnicity);
+const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45;
+const localCapacity = this.carryingCapacityAt(current);
+const localOverCapacity = Math.max(0, localPressure - localCapacity);
+if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) {
+a.settled++;
+a.movedThisStep = false;
+return;
+}
+const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1;
+for (let dy = -radius; dy <= radius; dy++) {
+for (let dx = -radius; dx <= radius; dx++) {
+const x = clamp(a.x + dx, 0, s - 1);
+const y = clamp(a.y + dy, 0, s - 1);
+const i = w.idx(x, y);
+const isWater = w.terrain[i] === Terrain.WATER;
+const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45;
+const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0);
+if (isWater && this.rng.next() > waterAccess) continue;
+const ethnicDensity = ethnocentrism > 0.04
+? this.ethnicDensityNear(x, y, a.ethnicity)
+: { same: 0, foreign: 0 };
+const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
+const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary;
+const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0);
+const destinationCapacity = this.carryingCapacityAt(i);
+const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity);
+const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1);
+const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85);
+const routeBonus = w.tradeRoute[i] ? 1.25 : 0;
+const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0;
+const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus;
+const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0;
+const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0;
+const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45);
+const score =
+resourceScore * a.traits.resourceAttraction +
+w.pheromone[i] * 0.021 +
+ethnicDensity.same * ethnocentrism +
+cityPull * sedentary +
+routePull +
+capacityPull +
+inertia -
+terrainPenalty -
+crowdPenalty +
+pressurePush -
+ethnicDensity.foreign * ethnocentrism * 0.72 +
+this.rng.range(-0.55, 0.55);
+if (score > bestScore) {
+bestScore = score;
+bestX = x;
+bestY = y;
+}
+}
+}
+const from = w.idx(a.x, a.y);
+a.x = bestX;
+a.y = bestY;
+const to = w.idx(a.x, a.y);
+if (from !== to) {
+a.movedThisStep = true;
+a.farmingWork = 0;
+a.lastFarmTile = to;
+const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1;
+w.pheromone[from] += (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale;
+w.pheromone[to] += (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale;
+a.settled = Math.max(0, a.settled - 1);
+} else {
+a.movedThisStep = false;
+a.settled++;
+}
+}
+ensureAgentTech(a) {
+a.tech ??= { farming: 0, metallurgy: 0 };
+a.tech.farming ??= 0;
+a.tech.metallurgy ??= 0;
+a.tech.farming = clamp(a.tech.farming, 0, 1);
+a.tech.metallurgy = clamp(a.tech.metallurgy, 0, 1);
+a.farmingWork ??= 0;
+a.lastFarmTile ??= -1;
+a.movedThisStep ??= false;
+}
+farmabilityAt(tile) {
+const t = this.world.terrain[tile];
+if (t === Terrain.FERTILE) return 1.0;
+if (t === Terrain.PLAINS) return 0.75;
+if (t === Terrain.FOREST) return 0.55;
+if (t === Terrain.DESERT) return 0.25;
+return 0.0;
+}
+carryingCapacityAt(tile) {
+const w = this.world;
+if (w.terrain[tile] === Terrain.WATER) return 1.2;
+return SimConfig.population.carryingCapacityBase +
+w.fertility[tile] * SimConfig.population.carryingCapacityFertility +
+w.mineral[tile] * SimConfig.population.carryingCapacityMineral +
+w.farmland[tile] * SimConfig.population.carryingCapacityFarmland +
+(w.city[tile] >= 0 ? 3.5 : 0);
+}
+updateAgentTechnology(agent, payMaintenance = false) {
+if (!agent.alive) return;
+this.ensureAgentTech(agent);
+this.updateFarmingWork(agent);
+this.tryInventTechnology(agent);
+this.learnTechnologyFromCity(agent);
+if (this.shouldRunAgentSocial(agent, 3)) {
+this.spreadTechnology(agent);
+this.improveTechnologyFromDensity(agent);
+}
+if (payMaintenance) this.payTechnologyCostOrForget(agent);
+}
+shouldRunAgentSocial(agent, interval) {
+return ((agent.x * 31 + agent.y * 17 + this.year) % interval) === 0;
+}
+updateFarmingWork(agent) {
+const w = this.world;
+const tile = w.idx(agent.x, agent.y);
+if (agent.lastFarmTile !== tile) {
+agent.farmingWork = 0;
+agent.lastFarmTile = tile;
+}
+if (agent.movedThisStep) return;
+const farming = agent.tech?.farming || 0;
+if (farming <= 0.02) return;
+if (agent.settled > 0) {
+agent.farmingWork = clamp((agent.farmingWork || 0) + farming * 0.015, 0, 1);
+agent.tech.farming = clamp(agent.tech.farming + farming * this.farmabilityAt(tile) * 0.00018, 0, 1);
+}
+}
+tryInventTechnology(agent) {
+const w = this.world;
+const tile = w.idx(agent.x, agent.y);
+const settledFactor = clamp((agent.settled || 0) / 12, 0, 1);
+if ((agent.tech?.farming || 0) <= 0.02) {
+const farmingBase = 0.00016;
+const farmability = this.farmabilityAt(tile);
+const sedentary = getSedentary(agent.traits);
+const farmingChance =
+farmingBase *
+(0.25 + farmability) *
+(0.4 + sedentary) *
+(0.3 + settledFactor);
+if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32);
+}
+if ((agent.tech?.metallurgy || 0) <= 0.02) {
+const metallurgyBase = 0.00011;
+const mineral = clamp(w.mineral[tile], 0, 1);
+const mineralTerrainBonus =
+w.terrain[tile] === Terrain.MINERAL || w.terrain[tile] === Terrain.MOUNTAIN
+? 1.8
+: 1.0;
+const metallurgyChance =
+metallurgyBase *
+(0.2 + mineral) *
+mineralTerrainBonus *
+(0.5 + settledFactor);
+if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.28);
+}
+}
+grantTechnologyAround(agent, techName, amount) {
+this.ensureAgentTech(agent);
+this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
+if (!other.alive) return true;
+this.ensureAgentTech(other);
+const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
+if (distance > 2) return true;
+const gain = amount * (other === agent ? 1 : 0.55);
+other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1);
+return true;
+});
+}
+payTechnologyCostOrForget(agent) {
+if (!agent.alive) return;
+this.ensureAgentTech(agent);
+const farming = agent.tech.farming || 0;
+const metallurgy = agent.tech.metallurgy || 0;
+const cost = farming * 0.004 + metallurgy * 0.010;
+if (cost <= 0) return;
+if (agent.resources >= cost) {
+agent.resources -= cost;
+return;
+}
+const shortage = cost - Math.max(0, agent.resources);
+agent.resources = Math.max(0, agent.resources - cost);
+const decay = clamp(0.006 + shortage * 0.025, 0.006, 0.06);
+agent.tech.farming = Math.max(0, farming - decay);
+agent.tech.metallurgy = Math.max(0, metallurgy - decay * 1.15);
+if (agent.tech.farming < 0.02) agent.tech.farming = 0;
+if (agent.tech.metallurgy < 0.02) agent.tech.metallurgy = 0;
+if (agent.tech.farming <= 0) agent.farmingWork = 0;
+}
+spreadTechnology(agent) {
+const w = this.world;
+const tile = w.idx(agent.x, agent.y);
+const assimilation = agent.traits.assimilation || 0;
+const ethnocentrism = agent.traits.ethnocentrism || 0;
+let checked = 0;
+this.forEachLocalAgentNear(agent.x, agent.y, 1, other => {
+if (other === agent || !other.alive) return true;
+if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true;
+if (++checked > 12) return false;
+this.ensureAgentTech(other);
+const sameEthnicity = agent.ethnicity === other.ethnicity;
+let chance =
+0.0035 +
+assimilation * 0.006 -
+ethnocentrism * 0.0012;
+if (sameEthnicity) chance += 0.004;
+if (w.tradeRoute[tile]) chance += 0.006;
+if (w.city[tile] >= 0) chance += 0.003;
+chance = clamp(chance, 0.0008, 0.028);
+this.learnTechnologyFrom(agent, other, "farming", chance);
+this.learnTechnologyFrom(agent, other, "metallurgy", chance);
+return true;
+});
+}
+learnTechnologyFrom(agent, other, techName, chance) {
+const current = agent.tech[techName] || 0;
+const otherLevel = other.tech?.[techName] || 0;
+if (otherLevel > current + 0.04 && this.rng.next() < chance) {
+agent.tech[techName] = clamp(current + (otherLevel - current) * 0.14, 0, 1);
+}
+}
+learnTechnologyFromCity(agent) {
+const tile = this.world.idx(agent.x, agent.y);
+const city = this.world.city[tile] >= 0 ? this.getCityById(this.world.city[tile]) : null;
+if (!city?.knowledge) return;
+const routeBonus = this.world.tradeRoute[tile] ? SimConfig.technology.tradeDiffusion : 0;
+const chance = SimConfig.technology.cityDiffusion + routeBonus + clamp(city.population / 1200, 0, 0.006);
+if (this.rng.next() < chance) {
+agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * 0.72), 0, 1);
+agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1);
+}
+}
+improveTechnologyFromDensity(agent) {
+let farmingCount = 0;
+let metallurgyCount = 0;
+let farmingSum = 0;
+let metallurgySum = 0;
+this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
+if (!other.alive) return true;
+this.ensureAgentTech(other);
+const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
+if (distance > 2) return true;
+const farming = other.tech.farming || 0;
+const metallurgy = other.tech.metallurgy || 0;
+if (farming > 0.1) {
+farmingCount++;
+farmingSum += farming;
+}
+if (metallurgy > 0.1) {
+metallurgyCount++;
+metallurgySum += metallurgy;
+}
+return true;
+});
+if (farmingCount >= 2) {
+const avgFarming = farmingSum / farmingCount;
+agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1);
+}
+if (metallurgyCount >= 2) {
+const avgMetallurgy = metallurgySum / metallurgyCount;
+agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1);
+}
+}
+forEachLocalAgentNear(x, y, radius, callback) {
+const w = this.world;
+for (let dy = -radius; dy <= radius; dy++) {
+for (let dx = -radius; dx <= radius; dx++) {
+const tx = x + dx;
+const ty = y + dy;
+if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
+const agents = this.tileAgents.get(w.idx(tx, ty));
+if (!agents) continue;
+for (const agent of agents) {
+if (callback(agent) === false) return;
+}
+}
+}
+}
+farmingGatherMultiplier(agent, tile) {
+const farmingLevel = agent.tech?.farming || 0;
+const farmingWork = agent.farmingWork || 0;
+const farmability = this.farmabilityAt(tile);
+return clamp(1 + farmingLevel * farmingWork * farmability * 2, 1, 3);
+}
+metallurgyGatherMultiplier(agent, tile) {
+const metallurgyLevel = agent.tech?.metallurgy || 0;
+if (metallurgyLevel > 0.02) {
+agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1);
+}
+return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1);
+}
+gatherConsumeReproduce(a, offspring) {
+const w = this.world;
+const i = w.idx(a.x, a.y);
+this.ensureAgentTech(a);
+const ethnicity = this.ethnicities.get(a.ethnicity);
+const climateMismatch = this.climateMismatch(a.ethnicity, i);
+const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1);
+const cityMarket = w.city[i] >= 0 ? 0.28 : 0;
+const drylandAdapted = this.isDrylandAdapted(ethnicity);
+const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0;
+const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage;
+const carryingCapacity = this.carryingCapacityAt(i);
+const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity);
+const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase);
+const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2);
+const gathered = Math.min(
+w.resource[i],
+baseGatherAmount * this.farmingGatherMultiplier(a, i) * this.metallurgyGatherMultiplier(a, i)
+);
+w.resource[i] -= gathered;
+a.resources += gathered;
+const waterAdaptation = ethnicity?.climateHumidity ?? 0.45;
+const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0;
+const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4;
+const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1;
+const sedentary = getSedentary(a.traits);
+const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08;
+a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead;
+if (a.resources <= 0) {
+this.removeAgentFromOccupancy(a);
+a.alive = false;
+this.deaths++;
+return;
+}
+const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12);
+const mobilityPenalty = (1 - sedentary) * 0.45;
+const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty;
+const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35);
+if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) {
+const childShare = 0.36 + sedentary * 0.08;
+const childResources = a.resources * childShare;
+a.resources -= childResources;
+const childTraits = mutateTraits(a.traits, this.rng, 0.035);
+const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources);
+child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1);
+child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1);
+offspring.push(child);
+}
+}
+climateMismatch(ethnicityId, tile) {
+const ethnicity = this.ethnicities.get(ethnicityId);
+if (!ethnicity) return 0;
+const tempDiff = Math.abs(this.world.temperature[tile] - ethnicity.climateTemp);
+const humidDiff = Math.abs(this.world.humidity[tile] - ethnicity.climateHumidity);
+return tempDiff * 0.58 + humidDiff * 0.42;
+}
+isDrylandAdapted(ethnicity) {
+return !!ethnicity && ethnicity.climateTemp > 0.5 && ethnicity.climateHumidity < 0.38;
+}
+ethnicDensityNear(x, y, ethnicity) {
+const key = `${x},${y},${ethnicity}`;
+const cached = this.ethnicDensityCache.get(key);
+if (cached) return cached;
+let same = 0;
+let foreign = 0;
+const w = this.world;
+for (let dy = -2; dy <= 2; dy++) {
+for (let dx = -2; dx <= 2; dx++) {
+if (Math.abs(dx) + Math.abs(dy) > 2) continue;
+const tx = x + dx;
+const ty = y + dy;
+if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
+const counts = this.tileEthnicities.get(w.idx(tx, ty));
+if (!counts) continue;
+for (const [id, value] of counts) {
+if (id === ethnicity) same += value;
+else foreign += value;
+}
+if (same > 6 && foreign > 6) {
+const result = { same: same * 0.22, foreign: foreign * 0.16 };
+this.ethnicDensityCache.set(key, result);
+return result;
+}
+}
+}
+const result = { same: same * 0.22, foreign: foreign * 0.16 };
+this.ethnicDensityCache.set(key, result);
+return result;
+}
+resolveAssimilation(a) {
+const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity);
+if (!dominant || dominant.id === a.ethnicity) {
+a.foreignContact = Math.max(0, a.foreignContact - 1);
+return;
+}
+if (a.contactEthnicity !== dominant.id) {
+a.contactEthnicity = dominant.id;
+a.foreignContact = 0;
+}
+a.foreignContact++;
+const pressure = dominant.count / Math.max(1, dominant.total);
+const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30);
+if (this.rng.next() < chance * 0.14) {
+this.changeAgentEthnicity(a, dominant.id);
+a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
+a.foreignContact = 0;
+}
+}
+dominantEthnicityNear(x, y, self) {
+const key = `${x},${y},${self}`;
+const cached = this.dominantEthnicityCache.get(key);
+if (cached) return cached;
+const counts = new Map();
+let total = 0;
+const w = this.world;
+for (let dy = -3; dy <= 3; dy++) {
+for (let dx = -3; dx <= 3; dx++) {
+if (Math.abs(dx) + Math.abs(dy) > 3) continue;
+const tx = x + dx;
+const ty = y + dy;
+if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
+const tileCounts = this.tileEthnicities.get(w.idx(tx, ty));
+if (!tileCounts) continue;
+for (const [id, count] of tileCounts) {
+total += count;
+counts.set(id, (counts.get(id) || 0) + count);
+}
+}
+}
+for (const city of this.getCitiesNear(x, y, 5)) {
+const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
+if (!city.ethnicityComposition?.size) continue;
+const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8);
+for (const [id, count] of city.ethnicityComposition) {
+const weighted = Math.max(1, Math.round(count * influence * 0.08));
+total += weighted;
+counts.set(id, (counts.get(id) || 0) + weighted);
+}
+}
+let best = null;
+for (const [id, count] of counts) {
+if (id !== self && (!best || count > best.count)) best = { id, count, total };
+}
+if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits();
+if (best) this.dominantEthnicityCache.set(key, best);
+return best;
+}
+updateWorldFields(scale = 1) {
+const w = this.world;
+const pheromoneDecay = Math.pow(0.996, scale);
+for (let i = 0; i < w.count; i++) {
+w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15));
+w.pheromone[i] *= pheromoneDecay;
+}
+}
+updateCities() {
+const w = this.world;
+w.city.fill(-1);
+w.farmland.fill(0);
+w.cityPull.fill(0);
+const candidates = new Map();
+for (const a of this.agents) {
+if (!a.alive) continue;
+const local = w.idx(a.x, a.y);
+if (a.settled < 3 && w.pressure[local] < 2) continue;
+if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.24) continue;
+const cx = clamp(Math.round(a.x / 4) * 4, 0, w.size - 1);
+const cy = clamp(Math.round(a.y / 4) * 4, 0, w.size - 1);
+const i = w.idx(cx, cy);
+let group = candidates.get(i);
+if (!group) {
+group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map() };
+candidates.set(i, group);
+}
+this.ensureAgentTech(a);
+group.count++;
+group.resources += Math.max(0, a.resources);
+group.sedentary += getSedentary(a.traits);
+group.farming += a.tech.farming || 0;
+group.metallurgy += a.tech.metallurgy || 0;
+group.techCount++;
+group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1);
+}
+let foundedThisTick = 0;
+const canFoundCities = this.year >= years(80) && this.year % years(10) === 0;
+const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0;
+const candidateEntries = [...candidates].sort((a, b) => {
+const sedentaryA = a[1].sedentary / Math.max(1, a[1].count);
+const sedentaryB = b[1].sedentary / Math.max(1, b[1].count);
+return (b[1].count * (0.7 + sedentaryB) + b[1].resources * 0.02) - (a[1].count * (0.7 + sedentaryA) + a[1].resources * 0.02);
+});
+for (const [i, group] of candidateEntries) {
+if (group.count < 3) continue;
+const avgSedentary = group.sedentary / group.count;
+let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null;
+if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) {
+const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98);
+if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue;
+city = this.createCity(i % w.size, Math.floor(i / w.size), group);
+this.cities.push(city);
+this.cityById.set(city.id, city);
+const bucketKey = this.cityBucketKey(city.x, city.y);
+if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []);
+this.cityBuckets.get(bucketKey).push(city);
+foundedThisTick++;
+}
+if (city) {
+city.activeVisitors += group.count;
+city.storedResources += group.resources * 0.08;
+city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02;
+city.knowledge ??= { farming: 0, metallurgy: 0 };
+city.knowledge.farming = Math.max(city.knowledge.farming * 0.998, group.farming / Math.max(1, group.techCount));
+city.knowledge.metallurgy = Math.max(city.knowledge.metallurgy * 0.998, group.metallurgy / Math.max(1, group.techCount));
+const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1);
+for (const [id, count] of group.ethnicities) {
+const urbanCount = this.weightedUrbanContribution(count * 0.2, urbanWeight);
+if (urbanCount > 0) city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + urbanCount);
+}
+city.strength = city.strength * 0.96 + group.count * 0.05;
+}
+}
+this.absorbUrbanPopulation();
+this.rebuildOccupancy();
+this.processCityEconomies();
+this.cities = this.cities.filter(c => {
+c.age++;
+c.strength *= 0.992;
+const foodPerCapita = c.storedResources / Math.max(1, c.population);
+if (c.age > 20) {
+if (c.activeVisitors < 2 && foodPerCapita < 0.04) c.strength -= 0.035;
+else if (c.activeVisitors < 5 && foodPerCapita < 0.02) c.strength -= 0.015;
+if (c.population < 25 && foodPerCapita < 0.05) c.strength -= 0.018;
+}
+if (c.population <= 0 || c.strength <= 0.06) return false;
+const radius = c.agriculturalRadius;
+for (let dy = -radius; dy <= radius; dy++) {
+for (let dx = -radius; dx <= radius; dx++) {
+const x = clamp(c.x + dx, 0, w.size - 1);
+const y = clamp(c.y + dy, 0, w.size - 1);
+const d = Math.abs(dx) + Math.abs(dy);
+if (d <= radius) {
+const tile = w.idx(x, y);
+w.city[tile] = c.id;
+if (w.terrain[tile] !== Terrain.WATER) {
+w.farmland[tile] = Math.max(w.farmland[tile], (radius - d + 1) / (radius + 1));
+w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1));
+}
+}
+}
+}
+return true;
+});
+this.rebuildIndexes();
+}
+createCity(x, y, seedGroup = null) {
+const seedPopulation = seedGroup ? Math.max(14, seedGroup.count * 5) : 10;
+const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5;
+const composition = new Map();
+if (seedGroup) {
+const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1);
+for (const [id, count] of seedGroup.ethnicities) {
+const urbanCount = this.weightedUrbanContribution(count * 3, urbanWeight);
+if (urbanCount > 0) composition.set(id, urbanCount);
+}
+}
+return {
+id: this.nextCity++,
+x,
+y,
+population: seedPopulation,
+storedResources: 34 + (seedGroup?.resources || 0) * 0.55,
+ethnicityComposition: composition,
+pheromoneOutput: 0,
+agriculturalRadius: 2,
+tradeLinks: new Set(),
+activeVisitors: 0,
+age: 0,
+strength: 3,
+sedentaryCulture: seedSedentary,
+knowledge: { farming: 0, metallurgy: 0 },
+supplyStress: 0,
+polityId: null,
+loyalty: 0.5,
+receivedAid: false,
+tradeValue: 0,
+tradeReach: 0
+};
+}
+absorbUrbanPopulation() {
+if (!this.cities.length) return;
+const absorbed = [];
+for (const a of this.agents) {
+if (!a.alive || a.settled < 5) {
+absorbed.push(a);
+continue;
+}
+const city = this.findCityNear(a.x, a.y, 7);
+const sedentary = getSedentary(a.traits);
+if (!city || this.rng.next() > sedentary * 0.85) {
+absorbed.push(a);
+continue;
+}
+const migrants = 1 + Math.floor(Math.min(8, a.resources / 8));
+const urbanWeight = clamp((sedentary - 0.14) * 1.5, 0.08, 1);
+city.population += migrants;
+city.storedResources += Math.max(0, a.resources) * 0.65;
+const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight);
+if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount);
+city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015;
+city.strength += 0.04;
+}
+this.agents = absorbed;
+}
+weightedUrbanContribution(amount, weight) {
+const value = amount * weight;
+const whole = Math.floor(value);
+return whole + (this.rng.next() < value - whole ? 1 : 0);
+}
+processCityEconomies() {
+const w = this.world;
+for (const city of this.cities) {
+city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14);
+city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8);
+city.knowledge ??= { farming: 0, metallurgy: 0 };
+let harvested = 0;
+const radius = city.agriculturalRadius;
+for (let dy = -radius; dy <= radius; dy++) {
+for (let dx = -radius; dx <= radius; dx++) {
+if (Math.abs(dx) + Math.abs(dy) > radius) continue;
+const x = clamp(city.x + dx, 0, w.size - 1);
+const y = clamp(city.y + dy, 0, w.size - 1);
+const i = w.idx(x, y);
+if (w.terrain[i] === Terrain.WATER) continue;
+const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1);
+const farmingYield = 1 + city.knowledge.farming * 0.85;
+const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35;
+const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull);
+w.resource[i] -= extraction;
+w.farmland[i] = Math.max(w.farmland[i], pull);
+w.pheromone[i] += city.pheromoneOutput * pull * 0.09;
+harvested += extraction;
+}
+}
+city.storedResources += harvested;
+const trade = this.cityTradeProfile(city);
+const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018);
+city.storedResources += tradeIncome;
+const supportRatio = city.activeVisitors / Math.max(1, city.population);
+const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012);
+const tradeRelief = clamp(trade.value * 0.09, 0, 0.32);
+const upkeep = (city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09)) * (1 - tradeRelief) + knowledgeMaintenance;
+city.storedResources -= upkeep;
+const foodPerCapita = city.storedResources / Math.max(1, city.population);
+city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8 - trade.value * 0.12, 0, 1.8);
+this.innovateCityKnowledge(city, harvested);
+if (trade.value > 0) {
+city.knowledge.farming = clamp(city.knowledge.farming + trade.value * 0.00018, 0, 1);
+city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + trade.value * 0.00024, 0, 1);
+city.strength += clamp(trade.value * 0.018, 0, 0.08);
+city.tradeValue = trade.value;
+city.tradeReach = trade.reach;
+} else {
+city.tradeValue = 0;
+city.tradeReach = 0;
+}
+if (city.storedResources > city.population * 0.16 && city.population > 0) {
+const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8);
+const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015)));
+city.population += births;
+city.storedResources -= births * 0.55;
+addBirthsToComposition(city.ethnicityComposition, births);
+}
+if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) {
+const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010)));
+city.population -= attrition;
+removeFromComposition(city.ethnicityComposition, attrition);
+city.strength -= city.activeVisitors === 0 ? 0.030 : 0.012;
+}
+if (city.storedResources < 0) {
+const deficit = Math.abs(city.storedResources);
+const dominant = dominantComposition(city.ethnicityComposition);
+const loss = Math.min(city.population, Math.ceil(deficit * 2.2 + city.population * 0.035));
+city.population -= loss;
+city.storedResources = 0;
+removeFromComposition(city.ethnicityComposition, loss);
+city.strength -= Math.min(0.4, 0.03 + deficit * 0.01);
+if (loss > 0 && this.agents.length < this.maxAgents) {
+this.spawnUrbanRefugees(city, Math.min(24, Math.max(2, Math.ceil(loss / 8))), dominant);
+}
+}
+city.population = Math.max(0, Math.floor(city.population));
+city.storedResources = clamp(city.storedResources, 0, Math.max(30, city.population * 1.4));
+}
+}
+cityTradeProfile(city) {
+if (!city?.tradeLinks?.size || !this.tradeLinks.length) return { value: 0, reach: 0 };
+let value = 0;
+let reach = 0;
+for (const link of this.tradeLinks) {
+if (link.from !== city.id && link.to !== city.id) continue;
+const other = this.getCityById(link.from === city.id ? link.to : link.from);
+if (!other) continue;
+const distance = this.distanceBetweenCities(city, other);
+const distanceFactor = clamp((distance - 14) / 30, 0, 1);
+if (distanceFactor <= 0) continue;
+const partnerScale = clamp(Math.sqrt(Math.max(1, other.population || 1)) / 24, 0.35, 2.4);
+const pathScale = clamp((link.path?.length || distance) / 30, 0.5, 1.6);
+value += (link.strength || 0.12) * distanceFactor * partnerScale * pathScale;
+reach = Math.max(reach, distance);
+}
+return {
+value: clamp(value, 0, 3.2),
+reach
+};
+}
+innovateCityKnowledge(city, harvested) {
+city.knowledge ??= { farming: 0, metallurgy: 0 };
+const scale = SimConfig.technology.cityInnovation;
+const density = clamp(Math.sqrt(city.population) / 20, 0, 1.6);
+const foodSurplus = clamp(city.storedResources / Math.max(1, city.population) - 0.08, 0, 0.5);
+city.knowledge.farming = clamp(
+city.knowledge.farming + scale * density * (0.4 + foodSurplus * 4) + harvested * 0.000015,
+0,
+1
+);
+city.knowledge.metallurgy = clamp(
+city.knowledge.metallurgy + scale * density * clamp(city.pheromoneOutput, 0.1, 1.8) * 0.35,
+0,
+1
+);
+if (city.supplyStress > 0.8) {
+city.knowledge.farming *= 0.998;
+city.knowledge.metallurgy *= 0.997;
+}
+}
+spawnUrbanRefugees(city, count, ethnicity = null) {
+const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1;
+const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits();
+for (let i = 0; i < count; i++) {
+this.agents.push(this.makeAgent(
+clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1),
+clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1),
+dominant,
+mutateTraits(template, this.rng, 0.05),
+this.rng.range(4, 11)
+));
+}
+}
+findCityNear(x, y, radius) {
+let best = null;
+let bestDistance = Infinity;
+for (const c of this.getCitiesNear(x, y, radius)) {
+const d = Math.abs(c.x - x) + Math.abs(c.y - y);
+if (d <= radius && d < bestDistance) {
+best = c;
+bestDistance = d;
+}
+}
+return best;
+}
+cityInfluence(city) {
+if (!city || city.population <= 0 || city.strength <= 0) return 0;
+const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35);
+return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor;
+}
+getCityById(id) {
+const cached = this.cityById?.get(id);
+if (cached) return cached;
+const city = this.cities.find(c => c.id === id) || null;
+if (city) this.cityById.set(id, city);
+return city;
+}
+getPolityById(id) {
+const cached = this.polityById?.get(id);
+if (cached) return cached;
+const polity = this.polities.find(p => p.id === id) || null;
+if (polity) this.polityById.set(id, polity);
+return polity;
+}
+getPolityCities(polity) {
+const living = [];
+for (const id of [...polity.cityIds]) {
+const city = this.getCityById(id);
+if (!city || city.population <= 0) {
+polity.cityIds.delete(id);
+} else {
+living.push(city);
+}
+}
+return living;
+}
+dominantCityEthnicity(city) {
+if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null;
+return dominantComposition(city.ethnicityComposition) || null;
+}
+sameDominantEthnicity(cityA, cityB) {
+const a = this.dominantCityEthnicity(cityA);
+const b = this.dominantCityEthnicity(cityB);
+return a !== null && b !== null && a === b;
+}
+distanceBetweenCities(cityA, cityB) {
+return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y);
+}
+hasDirectTradeConnection(cityA, cityB) {
+return !!cityA?.tradeLinks?.has(cityB?.id) || !!cityB?.tradeLinks?.has(cityA?.id);
+}
+effectiveDistance(cityA, cityB) {
+let distance = this.distanceBetweenCities(cityA, cityB);
+if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55;
+return distance;
+}
+createPolity(centerCity) {
+const id = this.nextPolity++;
+const polity = {
+id,
+centerCityId: centerCity.id,
+cityIds: new Set([centerCity.id]),
+treasury: Math.max(0, centerCity.storedResources * 0.12),
+color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62),
+founded: this.year,
+legitimacy: this.rng.range(0.68, 0.94),
+cohesion: this.rng.range(0.58, 0.9),
+charisma: this.rng.range(0.5, 1.5),
+leaderStarted: this.year,
+leaderTenureYears: this.rng.range(24, 68),
+crisis: 0,
+lastCrisisYear: this.year
+};
+centerCity.polityId = id;
+centerCity.loyalty = 1;
+centerCity.receivedAid = false;
+this.polities.push(polity);
+this.polityById.set(polity.id, polity);
+this.ensurePolityHistory(polity);
+this.samplePolityHistory(polity);
+return polity;
+}
+ensurePolityHistory(polity) {
+if (!polity) return null;
+if (!this.polityHistory.has(polity.id)) {
+this.polityHistory.set(polity.id, {
+id: polity.id,
+color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62),
+founded: polity.founded ?? this.year,
+ended: null,
+active: true,
+centerCityId: polity.centerCityId ?? null,
+fate: null,
+samples: [],
+events: [],
+peakPower: 0,
+peakYear: null,
+peakEventYear: null
+});
+}
+const history = this.polityHistory.get(polity.id);
+history.events ??= [];
+history.peakPower ??= 0;
+history.peakYear ??= null;
+history.peakEventYear ??= null;
+return history;
+}
+averagePolityLoyalty(polity) {
+const cities = this.getPolityCities(polity);
+const subordinates = cities.filter(city => city.id !== polity.centerCityId);
+if (!subordinates.length) return 1;
+return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length;
+}
+samplePolityHistory(polity) {
+const history = this.ensurePolityHistory(polity);
+if (!history) return;
+const cities = this.getPolityCities(polity);
+const population = cities.reduce((sum, city) => sum + city.population, 0);
+const treasury = polity.treasury || 0;
+const avgLoyalty = this.averagePolityLoyalty(polity);
+const fallbackPower = Math.sqrt(population) * 1.35 +
+Math.sqrt(Math.max(0, treasury)) * 1.15 +
+avgLoyalty * 16;
+history.centerCityId = polity.centerCityId ?? history.centerCityId;
+const sample = {
+year: this.year,
+cities: cities.length,
+population,
+treasury,
+avgLoyalty,
+power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower
+};
+history.samples.push(sample);
+if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) {
+history.peakPower = sample.power;
+history.peakYear = this.year;
+const oldEnoughForPeakMarker = this.year - (history.founded ?? this.year) > years(40);
+if (oldEnoughForPeakMarker && (!history.peakEventYear || this.year - history.peakEventYear > years(120))) {
+this.addPolityEvent(polity.id, "peak", this.year, {
+power: sample.power,
+cities: sample.cities,
+population: sample.population
+}, 2);
+history.peakEventYear = this.year;
+}
+}
+const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears;
+while (history.samples.length > maxSamples) history.samples.shift();
+}
+samplePolityHistories() {
+for (const polity of this.polities) this.samplePolityHistory(polity);
+}
+addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) {
+let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null;
+if (!history) {
+const polity = this.getPolityById(polityId);
+if (polity) history = this.ensurePolityHistory(polity);
+}
+if (!history) return;
+history.events ??= [];
+const sameYearDuplicate = history.events.some(event =>
+event.type === type &&
+event.year === year &&
+JSON.stringify(event.data || {}) === JSON.stringify(data || {})
+);
+if (sameYearDuplicate) return;
+history.events.push({ year, type, importance, data });
+while (history.events.length > 120) history.events.shift();
+}
+selectNewLeader(polity, forced = false) {
+if (!polity) return;
+const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5);
+const center = this.getCityById(polity.centerCityId);
+const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5;
+const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3;
+const randomLeader = this.rng.range(0.5, 1.5);
+const continuity = forced ? 0.18 : 0.34;
+const institutionalPull = 0.82 + institutionalBias * 0.36;
+const nextCharisma = clamp(
+previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull,
+0.5,
+1.5
+);
+polity.charisma = nextCharisma;
+polity.leaderStarted = this.year;
+polity.leaderTenureYears = this.rng.range(22, 72);
+const change = nextCharisma - previousCharisma;
+polity.legitimacy = clamp((polity.legitimacy ?? 0.7) + change * 0.08 - (forced ? 0.035 : 0), 0, 1);
+polity.cohesion = clamp((polity.cohesion ?? 0.6) + change * 0.045 - (forced ? 0.020 : 0), 0, 1);
+if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5);
+}
+updatePolityLeaders() {
+for (const polity of this.polities) {
+polity.charisma = clamp(polity.charisma ?? 1, 0.5, 1.5);
+polity.leaderStarted ??= polity.founded ?? this.year;
+polity.leaderTenureYears ??= this.rng.range(24, 68);
+const tenureYears = (this.year - polity.leaderStarted) / MONTHS_PER_YEAR;
+const oldLeader = Math.max(0, tenureYears - polity.leaderTenureYears);
+const crisisPressure = clamp((polity.crisis || 0) * 0.045, 0, 0.09);
+const successionChance = clamp(oldLeader * 0.018 + crisisPressure, 0, 0.36);
+if (successionChance > 0 && this.rng.next() < successionChance) {
+this.selectNewLeader(polity, (polity.crisis || 0) > 0.85);
+}
+}
+}
+maybeSpawnDisaster() {
+const config = SimConfig.disaster;
+const interval = years(config?.checkIntervalYears ?? 8);
+if (!interval || this.year % interval !== 0) return;
+if (this.rng.next() > (config?.baseChance ?? 0.38)) return;
+this.spawnDisaster();
+}
+spawnDisaster() {
+const config = SimConfig.disaster;
+const large = this.rng.next() < (config?.rareLargeChance ?? 0.10);
+const radius = large
+? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54)
+: this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34);
+const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95);
+this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity);
+}
+applyDisaster(x, y, radius, intensity) {
+const config = SimConfig.disaster;
+const w = this.world;
+const scaledIntensity = intensity * (config?.damageScale ?? 1);
+const majorLossRate = config?.majorLossRate ?? 0.05;
+const affectedPolityMap = new Map();
+const affectedPolityIds = new Set();
+for (const city of this.cities) {
+if (city.polityId === null) continue;
+if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId);
+}
+const polityPopulationBefore = new Map();
+for (const id of affectedPolityIds) {
+const polity = this.getPolityById(id);
+if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity));
+}
+let affectedAgents = 0;
+for (const agent of this.agents) {
+if (!agent.alive) continue;
+const d = Math.hypot(agent.x - x, agent.y - y);
+if (d > radius) continue;
+const falloff = 1 - d / radius;
+const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
+if (this.rng.next() < damage * 0.32) {
+if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent);
+agent.alive = false;
+this.deaths++;
+affectedAgents++;
+} else {
+agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45));
+if (agent.tech) {
+agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035);
+agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04);
+}
+}
+}
+let affectedCities = 0;
+let totalPopulationLoss = 0;
+for (const city of this.cities) {
+const d = Math.hypot(city.x - x, city.y - y);
+if (d > radius) continue;
+const falloff = 1 - d / radius;
+const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
+if (damage <= 0) continue;
+affectedCities++;
+const popBefore = city.population || 0;
+const lossRate = clamp(damage * 0.25, 0, 0.45);
+const loss = Math.floor(popBefore * lossRate);
+city.population = Math.max(0, popBefore - loss);
+if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss);
+city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55));
+city.strength = Math.max(0, city.strength - damage * 0.38);
+this.deaths += Math.floor(loss * 0.35);
+totalPopulationLoss += loss;
+if (city.polityId !== null) {
+let entry = affectedPolityMap.get(city.polityId);
+if (!entry) {
+entry = {
+polityId: city.polityId,
+populationBefore: polityPopulationBefore.get(city.polityId) || 0,
+populationLoss: 0,
+cityIds: new Set()
+};
+affectedPolityMap.set(city.polityId, entry);
+}
+entry.populationLoss += loss;
+entry.cityIds.add(city.id);
+const polity = this.getPolityById(city.polityId);
+if (polity) {
+polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5);
+polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1);
+polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1);
+}
+}
+}
+const minX = Math.max(0, Math.floor(x - radius));
+const maxX = Math.min(w.size - 1, Math.ceil(x + radius));
+const minY = Math.max(0, Math.floor(y - radius));
+const maxY = Math.min(w.size - 1, Math.ceil(y + radius));
+for (let ty = minY; ty <= maxY; ty++) {
+for (let tx = minX; tx <= maxX; tx++) {
+const d = Math.hypot(tx - x, ty - y);
+if (d > radius) continue;
+const falloff = 1 - d / radius;
+const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
+const tile = w.idx(tx, ty);
+w.resource[tile] *= (1 - damage * 0.45);
+w.farmland[tile] *= (1 - damage * 0.35);
+w.pheromone[tile] *= (1 - damage * 0.25);
+}
+}
+const affectedPolities = [...affectedPolityMap.values()].map(entry => {
+const populationBefore = Math.max(1, entry.populationBefore || 0);
+const lossRate = entry.populationLoss / populationBefore;
+return {
+polityId: entry.polityId,
+populationBefore: entry.populationBefore,
+populationLoss: entry.populationLoss,
+lossRate,
+cityIds: [...entry.cityIds]
+};
+});
+const id = this.nextDisaster++;
+const record = {
+id,
+year: this.year,
+x,
+y,
+radius,
+intensity,
+expires: this.year + years(config?.visualDurationYears ?? 18),
+affectedAgents,
+affectedCities,
+totalPopulationLoss,
+affectedPolities
+};
+this.disasters.push(record);
+this.disasterHistory.push(record);
+for (const entry of affectedPolities) {
+if (entry.lossRate >= majorLossRate) {
+this.addPolityEvent(entry.polityId, "disaster", this.year, {
+disasterId: id,
+populationLoss: entry.populationLoss,
+lossRate: entry.lossRate,
+affectedCities: entry.cityIds.length
+}, entry.lossRate >= majorLossRate * 2 ? 3 : 2);
+}
+}
+this.disasters = this.disasters
+.filter(disaster => this.year <= disaster.expires)
+.slice(-(config?.maxActiveVisuals ?? 12));
+while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift();
+}
+markPolityEnded(polity, reason = "dissolved") {
+if (!polity) return;
+const history = this.ensurePolityHistory(polity);
+if (!history) return;
+this.samplePolityHistory(polity);
+history.ended = this.year;
+history.active = false;
+history.fate = reason;
+history.centerCityId = polity.centerCityId ?? history.centerCityId;
+this.polityHistory.delete(polity.id);
+this.deadPolityHistories.push(history);
+while (this.deadPolityHistories.length > 80) this.deadPolityHistories.shift();
+}
+getAllPolityHistories() {
+return [...this.deadPolityHistories, ...this.polityHistory.values()]
+.sort((a, b) => (a.founded - b.founded) || (a.id - b.id));
+}
+addCityToPolity(city, polity, initialLoyalty = 0.5) {
+if (!city || !polity) return;
+if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city);
+city.polityId = polity.id;
+city.loyalty = clamp(initialLoyalty, 0, 1);
+city.receivedAid = false;
+polity.cityIds.add(city.id);
+}
+removeCityFromPolity(city) {
+if (!city || city.polityId === null) return;
+const oldPolity = this.getPolityById(city.polityId);
+if (oldPolity) oldPolity.cityIds.delete(city.id);
+city.polityId = null;
+city.loyalty = 0.45;
+city.receivedAid = false;
+}
+isPolityAtWar(polityId) {
+return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId));
+}
+getWarBetween(aId, bId) {
+return this.wars.find(war =>
+war.ended === null &&
+((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId))
+) || null;
+}
+startWar(a, b) {
+if (!a || !b || a.id === b.id) return null;
+if (this.getWarBetween(a.id, b.id)) return null;
+if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null;
+const id = this.nextWar++;
+const war = {
+id,
+aPolityId: a.id,
+bPolityId: b.id,
+started: this.year,
+lastActionYear: this.year,
+intensity: this.rng.range(0.45, 1.05),
+exhaustionA: 0,
+exhaustionB: 0,
+ended: null
+};
+this.wars.push(war);
+a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5);
+b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5);
+return war;
+}
+endWar(war) {
+if (!war || war.ended !== null) return;
+war.ended = this.year;
+}
+totalPolityPopulation(polity) {
+return this.getPolityCities(polity)
+.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0);
+}
+averagePolityTechnology(polity) {
+const cities = this.getPolityCities(polity);
+if (!cities.length) return 0;
+let total = 0;
+let count = 0;
+for (const city of cities) {
+if (!city?.knowledge) continue;
+total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5;
+count++;
+}
+return count ? total / count : 0;
+}
+polityPower(polity) {
+const cities = this.getPolityCities(polity);
+if (!cities.length) return 0;
+const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0);
+const treasury = Math.max(0, polity.treasury || 0);
+const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5;
+const tech = this.averagePolityTechnology(polity);
+const instability = this.polityInstability ? this.polityInstability(polity) : 0;
+const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0;
+const basePower = Math.max(0,
+Math.sqrt(population) * 1.35 +
+Math.sqrt(treasury) * 1.15 +
+avgLoyalty * 16 +
+tech * 24 -
+instability * 12 -
+agePressure * 6
+);
+return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5);
+}
+polityInfluenceRange(polity, wartime = false) {
+const power = this.polityPower(polity);
+const base = 18 + Math.sqrt(power) * 2.2;
+const range = wartime ? base * 1.15 : base * 0.75;
+return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42);
+}
+nearestPolityCity(polity, city) {
+if (!polity || !city) return null;
+let best = null;
+let bestDistance = Infinity;
+for (const source of this.getPolityCities(polity)) {
+const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city);
+if (distance < bestDistance) {
+best = source;
+bestDistance = distance;
+}
+}
+return best;
+}
+polityConnectionToCity(polity, city) {
+if (!polity || !city) return { source: null, distance: Infinity, connected: false };
+let source = null;
+let distance = Infinity;
+for (const c of this.getPolityCities(polity)) {
+if (c.id === city.id) continue;
+const d = this.effectiveDistance(c, city);
+if (d < distance) {
+source = c;
+distance = d;
+}
+}
+const limit = SimConfig.polity.logisticsDistance;
+return { source, distance, connected: !!source && distance <= limit };
+}
+polityDistance(a, b) {
+const aCities = this.getPolityCities(a);
+const bCities = this.getPolityCities(b);
+if (!aCities.length || !bCities.length) return Infinity;
+let best = Infinity;
+for (const aCity of aCities) {
+for (const bCity of bCities) {
+const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity);
+if (distance < best) best = distance;
+}
+}
+return best;
+}
+polityAge(polity) {
+return (this.year - (polity?.founded ?? this.year)) / MONTHS_PER_YEAR;
+}
+polityAgePressure(polity) {
+const age = this.polityAge(polity);
+if (age < 80) return 0;
+const x = (age - 80) / 420;
+return clamp(1 - Math.exp(-x), 0, 1.35);
+}
+polityOverextension(polity) {
+const cities = this.getPolityCities(polity);
+const center = this.getCityById(polity.centerCityId);
+if (!center || cities.length <= 1) return 0;
+const cityCountPressure = Math.max(0, cities.length - 5) * 0.08;
+let distanceSum = 0;
+let count = 0;
+for (const city of cities) {
+if (city.id === center.id) continue;
+distanceSum += this.effectiveDistance(center, city);
+count++;
+}
+const avgDistance = count ? distanceSum / count : 0;
+const distancePressure = Math.max(0, avgDistance - 28) * 0.008;
+return clamp(cityCountPressure + distancePressure, 0, 2);
+}
+polityResourceStress(polity) {
+const cities = this.getPolityCities(polity);
+if (!cities.length) return 1;
+let poor = 0;
+let supplyStress = 0;
+for (const city of cities) {
+const perCapita = city.storedResources / Math.max(1, city.population);
+if (perCapita < 0.06) poor++;
+supplyStress += city.supplyStress || 0;
+}
+const povertyRate = poor / cities.length;
+const avgSupplyStress = supplyStress / cities.length;
+const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length);
+const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0;
+return clamp(povertyRate * 0.55 + avgSupplyStress * 0.35 + treasuryStress * 0.28, 0, 1.5);
+}
+polityLogisticsStress(polity) {
+const cities = this.getPolityCities(polity);
+const center = this.getCityById(polity.centerCityId);
+if (!center || cities.length <= 1) return 0;
+let stress = 0;
+let count = 0;
+for (const city of cities) {
+if (city.id === center.id) continue;
+const distance = this.effectiveDistance(center, city);
+const food = city.storedResources / Math.max(1, city.population);
+const distanceStress = Math.max(0, distance - SimConfig.polity.logisticsDistance) * 0.012;
+const foodStress = Math.max(0, SimConfig.polity.minimumPerCapitaFood - food) * 4.5;
+const routeRelief = this.hasDirectTradeConnection(center, city) ? 0.72 : 1;
+stress += (distanceStress + foodStress + (city.supplyStress || 0) * 0.35) * routeRelief;
+count++;
+}
+return clamp(stress / Math.max(1, count), 0, 1.8);
+}
+polityEthnicFragmentation(polity) {
+const cities = this.getPolityCities(polity);
+if (cities.length <= 1) return 0;
+const counts = new Map();
+for (const city of cities) {
+const e = this.dominantCityEthnicity(city);
+if (e !== null) counts.set(e, (counts.get(e) || 0) + 1);
+}
+if (!counts.size) return 0;
+let max = 0;
+for (const v of counts.values()) max = Math.max(max, v);
+const dominantShare = max / cities.length;
+return clamp(1 - dominantShare, 0, 1);
+}
+polityInstability(polity) {
+const agePressure = this.polityAgePressure(polity);
+const overextension = this.polityOverextension(polity);
+const resourceStress = this.polityResourceStress(polity);
+const logisticsStress = this.polityLogisticsStress(polity);
+const fragmentation = this.polityEthnicFragmentation(polity);
+const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85;
+const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.62;
+return clamp(
+agePressure * 0.35 +
+overextension * 0.24 +
+resourceStress * 0.38 +
+logisticsStress * 0.30 +
+fragmentation * 0.22 +
+(polity.crisis || 0) * 0.42 -
+legitimacyBuffer -
+cohesionBuffer,
+0,
+2.5
+);
+}
+foundPolities() {
+for (const city of this.cities) {
+if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue;
+const centerInfluence = this.cityInfluence(city);
+let absorbed = 0;
+for (const other of this.cities) {
+if (absorbed >= 3) break;
+if (other === city || other.polityId !== null) continue;
+if (this.distanceBetweenCities(city, other) > 34) continue;
+const targetInfluence = this.cityInfluence(other);
+if (centerInfluence <= targetInfluence * 1.08) continue;
+const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08);
+const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0;
+const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus;
+if (dominanceScore > 0.68 && this.rng.next() < 0.32) {
+const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId);
+const connection = this.polityConnectionToCity(polity, other);
+if (!connection.connected) continue;
+this.addCityToPolity(other, polity, 0.68);
+absorbed++;
+}
+}
+}
+}
+expandPolities() {
+for (const polity of this.polities) {
+const center = this.getCityById(polity.centerCityId);
+if (!center) continue;
+const centerInfluence = this.cityInfluence(center);
+let absorbed = 0;
+for (const city of this.cities) {
+if (absorbed >= 2) break;
+if (city.polityId !== null || city.id === center.id) continue;
+const connection = this.polityConnectionToCity(polity, city);
+if (!connection.connected) continue;
+const distance = connection.distance;
+const targetInfluence = this.cityInfluence(city);
+if (centerInfluence <= targetInfluence * 0.9) continue;
+const dominanceScore =
+(centerInfluence / (targetInfluence + 1)) *
+(1 / (1 + distance * 0.08)) *
+(this.hasDirectTradeConnection(connection.source, city) ? 1.5 : 1.0);
+if (dominanceScore > 0.62 && this.rng.next() < 0.24) {
+this.addCityToPolity(city, polity, 0.62);
+absorbed++;
+}
+}
+}
+}
+absorbIndependentCities() {
+if (this.year % years(5) !== 0) return;
+for (const polity of this.polities) {
+const cities = this.getPolityCities(polity);
+if (!cities.length) continue;
+const power = this.polityPower(polity);
+const range = this.polityInfluenceRange(polity, false);
+let absorbed = 0;
+const candidates = this.cities
+.filter(city => city.polityId === null && city.population > 0)
+.map(city => ({ city, connection: this.polityConnectionToCity(polity, city) }))
+.filter(x => x.connection.connected && x.connection.distance <= range)
+.sort((a, b) => a.connection.distance - b.connection.distance);
+for (const { city, connection } of candidates) {
+if (absorbed >= 1) break;
+const distance = connection.distance;
+const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1);
+const proximity = 1 / (1 + distance * 0.07);
+const pressure = (power / Math.max(1, cityInfluence)) * proximity;
+if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) {
+this.addCityToPolity(city, polity, 0.42);
+city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99)));
+absorbed++;
+}
+}
+}
+}
+maybeStartWars() {
+if (this.year % years(10) !== 0) return;
+for (const a of this.polities) {
+if (this.isPolityAtWar(a.id)) continue;
+const aPower = this.polityPower(a);
+if (aPower <= 0) continue;
+let started = false;
+for (const b of this.polities) {
+if (started) break;
+if (a.id >= b.id) continue;
+if (this.isPolityAtWar(b.id)) continue;
+if (this.getWarBetween(a.id, b.id)) continue;
+const distance = this.polityDistance(a, b);
+if (distance > 46) continue;
+const bPower = this.polityPower(b);
+if (bPower <= 0) continue;
+const larger = Math.max(aPower, bPower);
+const smaller = Math.min(aPower, bPower);
+const advantage = larger / Math.max(1, smaller);
+const proximity = clamp((46 - distance) / 46, 0, 1);
+const aInstability = this.polityInstability ? this.polityInstability(a) : 0;
+const bInstability = this.polityInstability ? this.polityInstability(b) : 0;
+const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0;
+const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0;
+const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0;
+const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0;
+const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1);
+const weakSideInstability = aPower > bPower ? bInstability : aInstability;
+const weakSideAge = aPower > bPower ? bAge : aAge;
+const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension;
+const generalInstability = Math.max(aInstability, bInstability) * 0.04;
+const borderFriction = this.borderFriction(a, b, distance);
+const chance =
+0.002 +
+proximity * 0.010 +
+asymmetryPressure * 0.010 +
+weakSideInstability * 0.020 +
+weakSideAge * 0.012 +
+weakSideOverextension * 0.012 +
+borderFriction * 0.010 +
+generalInstability;
+if (this.rng.next() < clamp(chance, 0, 0.08)) {
+this.startWar(a, b);
+started = true;
+}
+}
+}
+}
+borderFriction(a, b, distance) {
+if (!Number.isFinite(distance)) return 0;
+const proximity = clamp((34 - distance) / 34, 0, 1);
+const aCities = this.getPolityCities(a).length;
+const bCities = this.getPolityCities(b).length;
+const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1);
+return proximity * (0.35 + sizePressure * 0.65);
+}
+updateWars() {
+if (this.year % years(2) !== 0) return;
+for (const war of this.wars) {
+if (war.ended !== null) continue;
+const a = this.getPolityById(war.aPolityId);
+const b = this.getPolityById(war.bPolityId);
+if (!a || !b) {
+this.endWar(war);
+continue;
+}
+this.applyWarPressure(war, a, b);
+this.applyWarPressure(war, b, a);
+this.applyWarExhaustion(war, a, b);
+this.maybeEndWar(war);
+}
+this.wars = this.wars.filter(w => w.ended === null);
+}
+applyWarPressure(war, attacker, defender) {
+const attackerCities = this.getPolityCities(attacker);
+const defenderCities = this.getPolityCities(defender);
+if (!attackerCities.length || !defenderCities.length) return;
+const attackerPower = this.polityPower(attacker);
+const defenderPower = this.polityPower(defender);
+if (attackerPower <= defenderPower * 1.05) return;
+const range = this.polityInfluenceRange(attacker, true);
+const candidates = defenderCities
+.filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2)
+.map(city => ({ city, connection: this.polityConnectionToCity(attacker, city) }))
+.filter(x => x.connection.connected && x.connection.distance <= range)
+.sort((a, b) => a.connection.distance - b.connection.distance);
+if (!candidates.length) return;
+const topCandidates = candidates.slice(0, 3);
+const selected = topCandidates[this.rng.int(topCandidates.length)];
+const pressure = this.warAbsorptionPressure(
+attacker,
+defender,
+selected.city,
+selected.connection.distance,
+attackerPower,
+defenderPower
+);
+const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45);
+if (pressure > 0.95 && this.rng.next() < chance) {
+this.captureCityInWar(selected.city, attacker, defender, war, pressure);
+} else {
+selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1);
+}
+}
+warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) {
+const powerRatio = attackerPower / Math.max(1, defenderPower);
+const proximity = 1 / (1 + distance * 0.055);
+const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0;
+const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0;
+const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0;
+const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1);
+const techAdvantage = 1 + clamp(
+this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender),
+-0.25,
+0.45
+);
+const nearest = this.nearestPolityCity(attacker, city);
+const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92;
+const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0;
+return (
+powerRatio *
+proximity *
+techAdvantage *
+ethnicityFactor *
+tradeFactor *
+(1 + defenderInstability * 0.32) *
+(1 + defenderAge * 0.18) *
+(1 + defenderOverextension * 0.14) *
+(0.65 + loyaltyWeakness * 0.72)
+);
+}
+captureCityInWar(city, attacker, defender, war, pressure) {
+const lossRate = clamp(
+0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025,
+0.05,
+0.22
+);
+const loss = Math.floor((city.population || 0) * lossRate);
+if (loss > 0) {
+city.population = Math.max(1, city.population - loss);
+if (typeof removeFromComposition === "function" && city.ethnicityComposition) {
+removeFromComposition(city.ethnicityComposition, loss);
+}
+this.deaths += Math.floor(loss * 0.35);
+}
+this.removeCityFromPolity(city);
+this.addCityToPolity(city, attacker, 0.28);
+city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36);
+war.lastActionYear = this.year;
+if (war.aPolityId === attacker.id) {
+war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1);
+war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1);
+} else {
+war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1);
+war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1);
+}
+attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015);
+attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5);
+defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5);
+defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1);
+}
+applyWarExhaustion(war, a, b) {
+const intensity = war.intensity || 0.75;
+const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035;
+const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035;
+a.treasury = Math.max(0, (a.treasury || 0) - costA);
+b.treasury = Math.max(0, (b.treasury || 0) - costB);
+a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5);
+b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5);
+war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1);
+war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1);
+}
+maybeEndWar(war) {
+const a = this.getPolityById(war.aPolityId);
+const b = this.getPolityById(war.bPolityId);
+if (!a || !b) {
+this.endWar(war);
+return;
+}
+const age = this.year - war.started;
+const noActionFor = this.year - war.lastActionYear;
+const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0);
+if (age > years(90)) {
+this.endWar(war);
+return;
+}
+if (age > years(10) && noActionFor > years(18)) {
+this.endWar(war);
+return;
+}
+if (exhaustion > 0.85 && this.rng.next() < 0.35) {
+this.endWar(war);
+}
+}
+collectAndRedistributeResources() {
+for (const polity of this.polities) {
+const cities = this.getPolityCities(polity);
+const centerId = polity.centerCityId;
+for (const city of cities) city.receivedAid = false;
+for (const city of cities) {
+if (city.id === centerId) continue;
+const tax = city.storedResources * 0.035;
+city.storedResources -= tax;
+polity.treasury += tax;
+}
+const agePressure = this.polityAgePressure(polity);
+const overextension = this.polityOverextension(polity);
+const adminCost =
+cities.length * 0.24 +
+overextension * 1.1 +
+agePressure * cities.length * 0.18;
+polity.treasury -= adminCost;
+if (polity.treasury < 0) {
+const deficit = Math.abs(polity.treasury);
+polity.treasury = 0;
+polity.crisis = clamp((polity.crisis || 0) + deficit * 0.012, 0, 1.5);
+}
+const maintenance = Math.pow(cities.length, 1.12) * 0.10;
+if (maintenance > 0) {
+const center = this.getCityById(centerId);
+const treasuryPayment = Math.min(polity.treasury, maintenance);
+polity.treasury -= treasuryPayment;
+const unpaid = maintenance - treasuryPayment;
+if (unpaid > 0 && center) {
+const paidByCenter = this.drainCityResources(center, unpaid);
+if (paidByCenter < unpaid) {
+for (const city of cities) {
+if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.012, 0, 1);
+}
+}
+}
+}
+const poorCount = Math.ceil(cities.length * 0.1);
+const poorest = [...cities]
+.sort((a, b) => (a.storedResources / Math.max(1, a.population)) - (b.storedResources / Math.max(1, b.population)))
+.slice(0, poorCount);
+for (const city of poorest) {
+const target = city.population * 0.08;
+const need = target - city.storedResources;
+if (need > 0 && polity.treasury > 0) {
+const aid = Math.min(need, polity.treasury);
+city.storedResources += aid;
+polity.treasury -= aid;
+city.receivedAid = true;
+city.loyalty = clamp(city.loyalty + 0.05, 0, 1);
+}
+}
+}
+}
+erodePolityLegitimacy() {
+for (const polity of this.polities) {
+const agePressure = this.polityAgePressure(polity);
+const resourceStress = this.polityResourceStress(polity);
+const overextension = this.polityOverextension(polity);
+const erosion =
+0.0015 +
+agePressure * 0.003 +
+resourceStress * 0.003 +
+overextension * 0.0015;
+polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - erosion, 0, 1);
+polity.cohesion = clamp((polity.cohesion ?? 0.6) - erosion * 0.45, 0, 1);
+const cities = this.getPolityCities(polity);
+if (this.polityAge(polity) < 80 && polity.treasury > cities.length * 10) {
+polity.legitimacy = clamp(polity.legitimacy + 0.008, 0, 1);
+polity.cohesion = clamp(polity.cohesion + 0.005, 0, 1);
+}
+polity.crisis = clamp((polity.crisis || 0) * 0.88, 0, 1.5);
+}
+}
+triggerPolityCrises() {
+for (const polity of this.polities) {
+const age = this.polityAge(polity);
+if (age < 140) continue;
+if ((this.year - (polity.lastCrisisYear ?? 0)) / MONTHS_PER_YEAR < 160) continue;
+const instability = this.polityInstability(polity);
+const agePressure = this.polityAgePressure(polity);
+const chance = 0.006 + agePressure * 0.018 + instability * 0.014;
+if (this.rng.next() >= chance) continue;
+polity.lastCrisisYear = this.year;
+const severity = clamp(
+0.08 +
+agePressure * this.rng.range(0.08, 0.20) +
+instability * this.rng.range(0.05, 0.16),
+0.05,
+0.36
+);
+polity.crisis = clamp((polity.crisis || 0) + severity, 0, 1.5);
+polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - severity * 0.30, 0, 1);
+polity.cohesion = clamp((polity.cohesion ?? 0.6) - severity * 0.18, 0, 1);
+const center = this.getCityById(polity.centerCityId);
+for (const city of this.getPolityCities(polity)) {
+if (city.id === polity.centerCityId) continue;
+const distance = center ? this.effectiveDistance(center, city) : 40;
+const distanceFactor = clamp(distance / 40, 0.25, 1);
+city.loyalty = clamp(city.loyalty - severity * distanceFactor, 0, 1);
+}
+}
+}
+applyOldStateStress() {
+for (const polity of this.polities) {
+const age = this.polityAge(polity);
+if (age < 1000) continue;
+const cities = this.getPolityCities(polity);
+if (cities.length <= 1) continue;
+const oldAge = clamp((age - 1000) / 1000, 0, 1);
+polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - oldAge * 0.018, 0, 1);
+polity.cohesion = clamp((polity.cohesion ?? 0.6) - oldAge * 0.012, 0, 1);
+polity.crisis = clamp((polity.crisis || 0) + oldAge * 0.035, 0, 1.5);
+const center = this.getCityById(polity.centerCityId);
+for (const city of cities) {
+if (city.id === polity.centerCityId) continue;
+const distance = center ? this.effectiveDistance(center, city) : 40;
+const distanceFactor = clamp(distance / 35, 0.2, 1);
+city.loyalty = clamp(city.loyalty - oldAge * distanceFactor * 0.045, 0, 1);
+}
+}
+}
+updateCityLoyalty() {
+for (const polity of this.polities) {
+const center = this.getCityById(polity.centerCityId);
+if (!center) continue;
+center.loyalty = 1;
+const instability = this.polityInstability(polity);
+const agePressure = this.polityAgePressure(polity);
+const overextension = this.polityOverextension(polity);
+const logisticsStress = this.polityLogisticsStress(polity);
+for (const city of this.getPolityCities(polity)) {
+if (city.id === center.id) continue;
+const perCapita = city.storedResources / Math.max(1, city.population);
+const distance = this.effectiveDistance(city, center);
+let delta = 0;
+delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045);
+delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012;
+delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045);
+if (this.hasDirectTradeConnection(city, center)) delta += 0.025;
+if (city.receivedAid) delta += 0.04;
+delta -= 0.004;
+if (city.storedResources < city.population * 0.05) delta -= 0.035;
+delta -= (city.supplyStress || 0) * 0.018;
+delta -= instability * 0.020;
+delta -= agePressure * 0.010;
+delta -= overextension * clamp(distance / 48, 0, 1) * 0.014;
+delta -= logisticsStress * clamp(distance / 42, 0.25, 1) * 0.018;
+if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.020;
+if ((polity.crisis || 0) > 0.6) delta -= 0.014;
+city.loyalty = clamp(city.loyalty + delta, 0, 1);
+}
+}
+}
+splitUnloyalCities() {
+for (const polity of this.polities) {
+const instability = this.polityInstability(polity);
+const agePressure = this.polityAgePressure(polity);
+const threshold = clamp(0.14 + instability * 0.05 + agePressure * 0.03, 0.14, 0.28);
+for (const city of this.getPolityCities(polity)) {
+if (city.id === polity.centerCityId || city.loyalty >= threshold) continue;
+const chance =
+(threshold - city.loyalty) * 0.28 +
+instability * 0.012 +
+agePressure * 0.010;
+if (this.rng.next() < chance) this.removeCityFromPolity(city);
+}
+}
+}
+enforcePolityContinuity(polity) {
+const center = this.getCityById(polity.centerCityId);
+const cities = this.getPolityCities(polity);
+if (!center || cities.length <= 1) return cities;
+const limit = SimConfig.polity.logisticsDistance;
+const reachable = new Set([center.id]);
+const frontier = [center];
+while (frontier.length) {
+const source = frontier.pop();
+for (const city of cities) {
+if (reachable.has(city.id)) continue;
+if (this.effectiveDistance(source, city) > limit) continue;
+reachable.add(city.id);
+frontier.push(city);
+}
+}
+const isolated = cities.filter(city => !reachable.has(city.id));
+if (!isolated.length) return cities;
+for (const city of isolated) {
+this.removeCityFromPolity(city);
+city.loyalty = clamp(city.loyalty - 0.12, 0, 1);
+}
+polity.cohesion = clamp((polity.cohesion ?? 0.6) - isolated.length * 0.035, 0, 1);
+polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - isolated.length * 0.02, 0, 1);
+polity.crisis = clamp((polity.crisis || 0) + isolated.length * 0.025, 0, 1.5);
+return this.getPolityCities(polity);
+}
+detectPolityFamines() {
+const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06;
+for (const polity of this.polities) {
+const cities = this.getPolityCities(polity);
+if (!cities.length) continue;
+let poorCities = 0;
+let totalFood = 0;
+let totalPopulation = 0;
+for (const city of cities) {
+const population = Math.max(1, city.population || 0);
+const perCapita = (city.storedResources || 0) / population;
+if (perCapita < minimumFood) poorCities++;
+totalFood += city.storedResources || 0;
+totalPopulation += population;
+}
+const poorRate = poorCities / cities.length;
+const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation);
+const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035;
+if (!famine) continue;
+if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue;
+this.addPolityEvent(polity.id, "famine", this.year, {
+poorRate,
+avgPerCapitaFood
+}, poorRate > 0.65 ? 3 : 2);
+polity.lastFamineYear = this.year;
+}
+}
+cleanupPolities() {
+const survivors = [];
+for (const polity of this.polities) {
+const cities = this.getPolityCities(polity);
+if (!cities.length) {
+this.markPolityEnded(polity, "collapsed");
+continue;
+}
+let center = this.getCityById(polity.centerCityId);
+if (!center || center.population <= 0) {
+const oldCenterCityId = polity.centerCityId;
+center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]);
+polity.centerCityId = center.id;
+center.loyalty = 1;
+if (oldCenterCityId !== center.id) {
+this.addPolityEvent(polity.id, "capitalShift", this.year, {
+oldCenterCityId,
+newCenterCityId: center.id
+}, 2);
+}
+polity.treasury *= 0.5;
+polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1);
+polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1);
+polity.crisis = clamp((polity.crisis || 0) + 0.35, 0, 1.5);
+polity.lastCrisisYear = this.year;
+for (const city of cities) {
+if (city.id !== center.id) city.loyalty = clamp(city.loyalty - 0.18, 0, 1);
+}
+const history = this.ensurePolityHistory(polity);
+if (history) history.centerCityId = center.id;
+}
+const connectedCities = this.enforcePolityContinuity(polity);
+if (connectedCities.length !== cities.length) {
+cities.length = 0;
+cities.push(...connectedCities);
+}
+if (cities.length === 1) {
+this.markPolityEnded(polity, "fragmented");
+cities[0].polityId = null;
+cities[0].loyalty = 0.45;
+cities[0].receivedAid = false;
+continue;
+}
+survivors.push(polity);
+}
+this.polities = survivors;
+this.rebuildIndexes();
+const validPolities = new Set(this.polities.map(p => p.id));
+for (const city of this.cities) {
+if (city.polityId !== null && !validPolities.has(city.polityId)) {
+city.polityId = null;
+city.loyalty = 0.45;
+city.receivedAid = false;
+}
+}
+}
+updatePolities() {
+if (this.year % years(1) !== 0) return;
+this.cleanupPolities();
+this.foundPolities();
+this.expandPolities();
+if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes();
+this.collectAndRedistributeResources();
+this.detectPolityFamines();
+if (this.erodePolityLegitimacy) this.erodePolityLegitimacy();
+if (this.triggerPolityCrises) this.triggerPolityCrises();
+if (this.applyOldStateStress) this.applyOldStateStress();
+if (this.updatePolityLeaders) this.updatePolityLeaders();
+this.updateCityLoyalty();
+this.absorbIndependentCities();
+this.maybeStartWars();
+this.updateWars();
+this.splitUnloyalCities();
+this.cleanupPolities();
+if (this.samplePolityHistories) this.samplePolityHistories();
+}
+reinforcePolityTradeRoutes() {
+const w = this.world;
+for (const polity of this.polities) {
+if (this.rng.next() > 0.48) continue;
+const center = this.getCityById(polity.centerCityId);
+if (!center) continue;
+const candidates = this.getPolityCities(polity)
+.filter(city => city.id !== center.id && !this.hasDirectTradeConnection(center, city))
+.filter(city => this.distanceBetweenCities(center, city) <= 40)
+.sort((a, b) => this.effectiveDistance(center, a) - this.effectiveDistance(center, b));
+if (!candidates.length) continue;
+const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))];
+const path = this.findTerrainRoute(center.x, center.y, target.x, target.y);
+if (!this.isValidRoutePath(path, target.x, target.y)) continue;
+const cities = this.getPolityCities(polity);
+const roadCost = 8 + cities.length * 1.5 + this.polityOverextension(polity) * 3;
+if ((polity.treasury || 0) < roadCost) {
+polity.crisis = clamp((polity.crisis || 0) + 0.025, 0, 1.5);
+continue;
+}
+polity.treasury -= roadCost;
+if (!this.payRouteConstructionCost(center, target, path, polity)) continue;
+center.tradeLinks.add(target.id);
+target.tradeLinks.add(center.id);
+this.tradeLinks.push({ from: center.id, to: target.id, strength: 0.22, path });
+for (const tile of path) {
+w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 34);
+w.pheromone[tile] += 2.4;
+}
+}
+}
+updateTradeRoutes() {
+const w = this.world;
+for (let i = 0; i < w.count; i++) {
+const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2;
+if (invalidTerrain) w.tradeRoute[i] = 0;
+else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2);
+}
+this.tradeLinks = [];
+for (const city of this.cities) city.tradeLinks.clear();
+const pairCandidates = [];
+const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400);
+const sqrtPop = new Map(this.cities.map(city => [city.id, Math.sqrt(city.population || 1)]));
+for (let a = 0; a < this.cities.length; a++) {
+for (let b = a + 1; b < this.cities.length; b++) {
+const c1 = this.cities[a];
+const c2 = this.cities[b];
+const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
+if (d > 44) continue;
+const distanceFactor = clamp((d - 14) / 30, 0, 1);
+const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1);
+const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0;
+const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias;
+pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore });
+}
+}
+pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore);
+const candidates = [];
+for (const pair of pairCandidates.slice(0, maxPairsToRoute)) {
+const { c1, c2, distanceFactor, marketScale } = pair;
+const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y);
+if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
+const strength = this.routeStrengthForPath(path);
+if (strength < 0.08) continue;
+if (!this.canPayRouteConstructionCost(c1, c2, path)) continue;
+const tradeScore = strength * (1 + distanceFactor * 0.7) * marketScale;
+candidates.push({ c1, c2, strength, path, tradeScore });
+}
+candidates.sort((a, b) => b.tradeScore - a.tradeScore);
+const maxLinks = Math.max(1, Math.floor(this.cities.length / 2));
+const supportedRoutes = new Set();
+for (const candidate of candidates) {
+if (this.tradeLinks.length >= maxLinks) break;
+const { c1, c2, strength, path } = candidate;
+const c1Limit = c1.population > 90 ? 3 : 2;
+const c2Limit = c2.population > 90 ? 3 : 2;
+if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue;
+if (!this.payRouteConstructionCost(c1, c2, path)) continue;
+c1.tradeLinks.add(c2.id);
+c2.tradeLinks.add(c1.id);
+this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path });
+this.exchangeCityResources(c1, c2, strength, path);
+for (const tile of path) {
+supportedRoutes.add(tile);
+w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 18);
+}
+}
+for (let i = 0; i < w.count; i++) {
+if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4);
+}
+this.activeTradeRouteTiles = new Set();
+for (let i = 0; i < w.count; i++) {
+if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i);
+}
+this.diffuseCityKnowledgeThroughTrade();
+}
+diffuseCityKnowledgeThroughTrade() {
+for (const link of this.tradeLinks) {
+const a = this.getCityById(link.from);
+const b = this.getCityById(link.to);
+if (!a || !b) continue;
+a.knowledge ??= { farming: 0, metallurgy: 0 };
+b.knowledge ??= { farming: 0, metallurgy: 0 };
+const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + link.strength), 0, 0.012);
+const farmingDelta = (a.knowledge.farming - b.knowledge.farming) * rate;
+const metallurgyDelta = (a.knowledge.metallurgy - b.knowledge.metallurgy) * rate;
+a.knowledge.farming = clamp(a.knowledge.farming - farmingDelta, 0, 1);
+b.knowledge.farming = clamp(b.knowledge.farming + farmingDelta, 0, 1);
+a.knowledge.metallurgy = clamp(a.knowledge.metallurgy - metallurgyDelta, 0, 1);
+b.knowledge.metallurgy = clamp(b.knowledge.metallurgy + metallurgyDelta, 0, 1);
+}
+}
+findTerrainRoute(x1, y1, x2, y2) {
+const w = this.world;
+const path = [];
+const visited = new Set();
+let x = x1;
+let y = y1;
+const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60);
+const directions = [
+[1, 0],
+[-1, 0],
+[0, 1],
+[0, -1]
+];
+for (let step = 0; step < maxSteps; step++) {
+const current = w.idx(x, y);
+path.push(current);
+visited.add(current);
+if (x === x2 && y === y2) break;
+let bestX = x;
+let bestY = y;
+let bestScore = Infinity;
+for (const [dx, dy] of directions) {
+const nx = x + dx;
+const ny = y + dy;
+if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue;
+const i = w.idx(nx, ny);
+const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0);
+const revisitCost = visited.has(i) ? 5 : 0;
+const routeEase = w.tradeRoute[i] ? -2 : 0;
+const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4);
+const distance = Math.abs(nx - x2) + Math.abs(ny - y2);
+const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase;
+if (score < bestScore) {
+bestScore = score;
+bestX = nx;
+bestY = ny;
+}
+}
+if (bestX === x && bestY === y) break;
+x = bestX;
+y = bestY;
+}
+return path;
+}
+routeStrengthForPath(path) {
+const w = this.world;
+if (!path.length) return 0;
+let route = 0;
+let pheromone = 0;
+let terrainEase = 0;
+for (const i of path) {
+route += w.tradeRoute[i] > 0 ? 1 : 0;
+pheromone += clamp(w.pheromone[i] / 9, 0, 1);
+terrainEase += 1 / Math.max(1, w.move[i]);
+}
+return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2;
+}
+isValidRoutePath(path, targetX, targetY) {
+const w = this.world;
+if (!path.length) return false;
+const last = path[path.length - 1];
+if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false;
+if (path.length > 42) return false;
+let totalCost = 0;
+let hardTiles = 0;
+const seen = new Set();
+for (const tile of path) {
+if (seen.has(tile)) return false;
+seen.add(tile);
+if (w.terrain[tile] === Terrain.WATER) return false;
+totalCost += w.move[tile];
+if (w.move[tile] > 1.8) hardTiles++;
+}
+return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12;
+}
+exchangeCityResources(a, b, strength, path) {
+const delta = (a.storedResources - b.storedResources) * 0.018 * strength;
+a.storedResources -= delta;
+b.storedResources += delta;
+const traffic = Math.min(0.8, strength * 0.26);
+this.depositRoutePheromone(path, traffic);
+}
+routeConstructionCost(path, polityBacked = false) {
+let weakTiles = 0;
+for (const tile of path) {
+if (this.world.tradeRoute[tile] < 24) weakTiles++;
+}
+return weakTiles * (polityBacked ? 0.22 : 0.16);
+}
+canPayRouteConstructionCost(cityA, cityB, path, polity = null) {
+const cost = this.routeConstructionCost(path, !!polity);
+if (cost <= 0) return true;
+return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost;
+}
+payRouteConstructionCost(cityA, cityB, path, polity = null) {
+const cost = this.routeConstructionCost(path, !!polity);
+if (cost <= 0) return true;
+if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) return false;
+let remaining = cost;
+if (polity) {
+const treasuryPayment = Math.min(polity.treasury, cost * 0.65);
+polity.treasury -= treasuryPayment;
+remaining -= treasuryPayment;
+}
+const half = remaining * 0.5;
+const paidA = this.drainCityResources(cityA, half);
+const paidB = this.drainCityResources(cityB, half);
+remaining -= paidA + paidB;
+if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining);
+if (remaining > 0 && polity) {
+const treasuryPayment = Math.min(polity.treasury, remaining);
+polity.treasury -= treasuryPayment;
+remaining -= treasuryPayment;
+}
+return remaining <= 0.001;
+}
+drainCityResources(city, amount) {
+const paid = Math.min(city.storedResources, amount);
+city.storedResources -= paid;
+return paid;
+}
+depositRoutePheromone(path, amount) {
+const w = this.world;
+for (const i of path) {
+w.pheromone[i] += amount;
+}
+}
+updateEthnicStats() {
+for (const e of this.ethnicities.values()) {
+e.population = 0;
+e.diversity = 0;
+e.centroidX = 0;
+e.centroidY = 0;
+e.activeTraitPopulation = 0;
+e.traitSums = emptyTraitSums();
+}
+for (const a of this.agents) {
+const e = this.ethnicities.get(a.ethnicity);
+if (!e) continue;
+e.population++;
+e.centroidX += a.x;
+e.centroidY += a.y;
+e.activeTraitPopulation++;
+addTraits(e.traitSums, a.traits);
+}
+for (const city of this.cities) {
+for (const [id, count] of city.ethnicityComposition) {
+const e = this.ethnicities.get(id);
+if (!e) continue;
+e.population += count;
+e.centroidX += city.x * count;
+e.centroidY += city.y * count;
+}
+}
+for (const e of this.ethnicities.values()) {
+if (!e.population) continue;
+e.centroidX /= e.population;
+e.centroidY /= e.population;
+const activeTraitCount = Math.max(1, e.activeTraitPopulation);
+e.averageTraits = averageTraits(e.traitSums, activeTraitCount);
+}
+for (const a of this.agents) {
+const e = this.ethnicities.get(a.ethnicity);
+if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits);
+}
+for (const e of this.ethnicities.values()) {
+if (e.population) e.diversity /= e.population;
+}
+}
+splitDivergentEthnicities() {
+for (const e of this.ethnicities.values()) {
+if (e.activeTraitPopulation < 24 || e.population < 40 || e.diversity < 0.14) continue;
+const candidates = [];
+let tempSum = 0;
+let humidSum = 0;
+for (const a of this.agents) {
+if (a.ethnicity !== e.id) continue;
+const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 0.95;
+const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.09;
+const tile = this.world.idx(a.x, a.y);
+const climate = this.climateMismatch(e.id, tile) > 0.18;
+if ((far || spatial || climate) && this.rng.next() < 0.72) {
+candidates.push(a);
+tempSum += this.world.temperature[tile];
+humidSum += this.world.humidity[tile];
+}
+}
+if (candidates.length >= 6) {
+const newId = this.createEthnicity(e.id, {
+temperature: tempSum / candidates.length,
+humidity: humidSum / candidates.length
+});
+for (const a of candidates) a.ethnicity = newId;
+}
+}
+}
+toJSON() {
+return {
+version: SimConfig.save.version,
+rngSeed: this.rng.seed,
+year: this.year,
+deaths: this.deaths,
+nextEthnicity: this.nextEthnicity,
+nextCity: this.nextCity,
+nextPolity: this.nextPolity,
+nextWar: this.nextWar,
+nextDisaster: this.nextDisaster,
+maxAgents: this.maxAgents,
+world: {
+size: this.world.size,
+terrain: packArray(this.world.terrain),
+resource: packArray(this.world.resource),
+regen: packArray(this.world.regen),
+move: packArray(this.world.move),
+fertility: packArray(this.world.fertility),
+mineral: packArray(this.world.mineral),
+temperature: packArray(this.world.temperature),
+humidity: packArray(this.world.humidity),
+pheromone: packArray(this.world.pheromone),
+tradeRoute: packArray(this.world.tradeRoute),
+farmland: packArray(this.world.farmland),
+cityPull: packArray(this.world.cityPull),
+city: packArray(this.world.city),
+pressure: packArray(this.world.pressure),
+dominantEthnicity: packArray(this.world.dominantEthnicity),
+cultureDiversity: packArray(this.world.cultureDiversity)
+},
+agents: this.agents,
+ethnicities: [...this.ethnicities.values()].map(e => ({
+id: e.id,
+parent: e.parent,
+born: e.born,
+climateTemp: e.climateTemp,
+climateHumidity: e.climateHumidity,
+color: e.color
+})),
+cities: this.cities.map(c => ({
+...c,
+ethnicityComposition: [...c.ethnicityComposition],
+tradeLinks: [...c.tradeLinks]
+})),
+polities: this.polities.map(p => ({
+id: p.id,
+centerCityId: p.centerCityId,
+cityIds: [...p.cityIds],
+treasury: p.treasury,
+color: p.color,
+founded: p.founded,
+legitimacy: p.legitimacy,
+cohesion: p.cohesion,
+charisma: p.charisma,
+leaderStarted: p.leaderStarted,
+leaderTenureYears: p.leaderTenureYears,
+crisis: p.crisis,
+lastCrisisYear: p.lastCrisisYear,
+lastFamineYear: p.lastFamineYear ?? null
+})),
+wars: this.wars,
+disasters: this.disasters,
+disasterHistory: this.disasterHistory,
+polityHistory: [...this.polityHistory.values()],
+deadPolityHistories: this.deadPolityHistories
+};
+}
+static fromJSON(state) {
+if (!state?.world?.size) throw new Error("Invalid save payload");
+const sim = new Simulation(state.world.size, 0, { blankWorld: true, skipSpawn: true });
+sim.rng.seed = state.rngSeed >>> 0;
+sim.year = state.year || 0;
+sim.deaths = state.deaths || 0;
+sim.nextEthnicity = state.nextEthnicity || 1;
+sim.nextCity = state.nextCity || 1;
+sim.nextPolity = state.nextPolity || 1;
+sim.nextWar = state.nextWar || 1;
+sim.nextDisaster = state.nextDisaster || 1;
+sim.maxAgents = state.maxAgents || 30000;
+sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array));
+sim.world.resource.set(unpackArray(state.world.resource, Float32Array));
+sim.world.regen.set(unpackArray(state.world.regen, Float32Array));
+sim.world.move.set(unpackArray(state.world.move, Float32Array));
+sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array));
+sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array));
+sim.world.temperature.set(unpackArray(state.world.temperature, Float32Array));
+sim.world.humidity.set(unpackArray(state.world.humidity, Float32Array));
+sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array));
+sim.world.tradeRoute.set(unpackArray(state.world.tradeRoute, Uint8Array));
+sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array));
+sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array));
+sim.world.city.set(unpackArray(state.world.city, Int32Array));
+if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
+if (state.world.dominantEthnicity) sim.world.dominantEthnicity.set(unpackArray(state.world.dominantEthnicity, Int32Array));
+if (state.world.cultureDiversity) sim.world.cultureDiversity.set(unpackArray(state.world.cultureDiversity, Float32Array));
+sim.agents = state.agents || [];
+for (const a of sim.agents) sim.ensureAgentTech(a);
+sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, {
+id: e.id,
+parent: e.parent,
+born: e.born,
+population: 0,
+diversity: 0,
+climateTemp: e.climateTemp,
+climateHumidity: e.climateHumidity,
+color: e.color,
+centroidX: 0,
+centroidY: 0
+}]));
+sim.cities = (state.cities || []).map(c => ({
+id: c.id,
+x: c.x,
+y: c.y,
+population: c.population || 0,
+storedResources: c.storedResources || 0,
+ethnicityComposition: new Map(c.ethnicityComposition || []),
+pheromoneOutput: c.pheromoneOutput || 0,
+agriculturalRadius: c.agriculturalRadius || 2,
+tradeLinks: new Set(c.tradeLinks || []),
+activeVisitors: 0,
+age: c.age || 0,
+strength: c.strength || 1,
+sedentaryCulture: c.sedentaryCulture ?? 0.5,
+knowledge: {
+farming: clamp(c.knowledge?.farming ?? 0, 0, 1),
+metallurgy: clamp(c.knowledge?.metallurgy ?? 0, 0, 1)
+},
+supplyStress: c.supplyStress ?? 0,
+polityId: c.polityId ?? null,
+loyalty: c.loyalty ?? 0.5,
+receivedAid: c.receivedAid ?? false,
+tradeValue: c.tradeValue ?? 0,
+tradeReach: c.tradeReach ?? 0
+}));
+sim.polities = (state.polities || []).map(p => ({
+id: p.id,
+centerCityId: p.centerCityId,
+cityIds: new Set(p.cityIds || []),
+treasury: p.treasury || 0,
+color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62),
+founded: p.founded || 0,
+legitimacy: p.legitimacy ?? 0.7,
+cohesion: p.cohesion ?? 0.6,
+charisma: clamp(p.charisma ?? 1, 0.5, 1.5),
+leaderStarted: p.leaderStarted ?? p.founded ?? sim.year,
+leaderTenureYears: clamp(p.leaderTenureYears ?? 48, 12, 96),
+crisis: p.crisis ?? 0,
+lastCrisisYear: p.lastCrisisYear ?? sim.year,
+lastFamineYear: p.lastFamineYear ?? null
+}));
+sim.rebuildIndexes();
+sim.wars = (state.wars || []).map(w => ({
+id: w.id,
+aPolityId: w.aPolityId,
+bPolityId: w.bPolityId,
+started: w.started || sim.year,
+lastActionYear: w.lastActionYear || w.started || sim.year,
+intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25),
+exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1),
+exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1),
+ended: w.ended ?? null
+})).filter(w => w.ended === null);
+const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0);
+sim.nextWar = Math.max(sim.nextWar, maxWarId + 1);
+const restoreDisaster = d => ({
+id: d.id,
+year: d.year || 0,
+x: d.x || 0,
+y: d.y || 0,
+radius: d.radius || 8,
+intensity: d.intensity || 0.5,
+expires: d.expires ?? ((d.year || 0) + years(18)),
+affectedAgents: d.affectedAgents || 0,
+affectedCities: d.affectedCities || 0,
+totalPopulationLoss: d.totalPopulationLoss || 0,
+affectedPolities: d.affectedPolities || []
+});
+sim.disasters = (state.disasters || []).map(restoreDisaster);
+sim.disasterHistory = (state.disasterHistory || []).map(restoreDisaster);
+const maxDisasterId = [...sim.disasters, ...sim.disasterHistory].reduce((max, disaster) => Math.max(max, disaster.id || 0), 0);
+sim.nextDisaster = Math.max(sim.nextDisaster, maxDisasterId + 1);
+sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, {
+id: h.id,
+color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
+founded: h.founded || 0,
+ended: h.ended ?? null,
+active: h.active !== false,
+centerCityId: h.centerCityId ?? null,
+fate: h.fate || null,
+samples: h.samples || [],
+events: h.events || [],
+peakPower: h.peakPower || 0,
+peakYear: h.peakYear ?? null,
+peakEventYear: h.peakEventYear ?? null
+}]));
+sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({
+id: h.id,
+color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
+founded: h.founded || 0,
+ended: h.ended ?? null,
+active: false,
+centerCityId: h.centerCityId ?? null,
+fate: h.fate || null,
+samples: h.samples || [],
+events: h.events || [],
+peakPower: h.peakPower || 0,
+peakYear: h.peakYear ?? null,
+peakEventYear: h.peakEventYear ?? null
+}));
+for (const polity of sim.polities) sim.ensurePolityHistory(polity);
+sim.tradeLinks = [];
+sim.activeTradeRouteTiles = new Set();
+sim.rebuildOccupancy();
+sim.updateTradeRoutes();
+sim.cleanupPolities();
+sim.updateEthnicStats();
+return sim;
+}
}
-
function render() {
- const start = performance.now();
- const w = sim.world;
- const size = w.size;
- if (!renderImage || renderImageSize !== size) {
- renderImage = ctx.createImageData(size, size);
- renderImageSize = size;
- }
- const image = renderImage;
- const data = image.data;
- const mode = els.viewMode.value;
- const cityPolityColor = new Map();
- if (mode === "polities") {
- for (const city of sim.cities) {
- if (city.polityId === null) continue;
- const polity = sim.getPolityById(city.polityId);
- if (!polity) continue;
- const isGraphHover = graphState.hoverPolityId === polity.id;
- cityPolityColor.set(city.id, {
- color: polity.color,
- isGraphHover
- });
- }
- }
-
- for (let i = 0; i < w.count; i++) {
- let color;
- if (mode === "resources") {
- const v = clamp(w.resource[i] / 30, 0, 1);
- color = mix([28, 36, 40], [107, 188, 85], v);
- } else if (mode === "ethnicity") {
- const id = w.dominantEthnicity[i];
- const ethnicity = id >= 0 ? sim.ethnicities.get(id) : null;
- color = ethnicity
- ? mix(ethnicity.color, [178, 184, 177], clamp(w.cultureDiversity[i] * 0.85, 0, 0.65))
- : mix(terrainInfo[w.terrain[i]].color, [18, 21, 23], 0.55);
- } else if (mode === "pressure") {
- const v = clamp(w.pressure[i] / 12, 0, 1);
- color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
- } else if (mode === "polities") {
- color = terrainInfo[w.terrain[i]].color;
- if (w.city[i] >= 0) {
- const polityColor = cityPolityColor.get(w.city[i]);
- if (polityColor) {
- color = mix(color, polityColor.color, polityColor.isGraphHover ? 0.82 : 0.5);
- if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.28);
- }
- }
- } else if (mode === "technology") {
- color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35);
- } else {
- color = terrainInfo[w.terrain[i]].color;
- }
- const p = i * 4;
- data[p] = color[0];
- data[p + 1] = color[1];
- data[p + 2] = color[2];
- data[p + 3] = 255;
- }
-
- ctx.putImageData(image, 0, 0);
- renderDisasters();
- drawTradeLinks();
- drawAgentsAndCities(mode);
- drawGraphPolityHighlight();
- maybeRenderStateGraph();
- els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
+const start = performance.now();
+const w = sim.world;
+const size = w.size;
+if (!renderImage || renderImageSize !== size) {
+renderImage = ctx.createImageData(size, size);
+renderImageSize = size;
+}
+const image = renderImage;
+const data = image.data;
+const mode = els.viewMode.value;
+const cityPolityColor = new Map();
+if (mode === "polities") {
+for (const city of sim.cities) {
+if (city.polityId === null) continue;
+const polity = sim.getPolityById(city.polityId);
+if (!polity) continue;
+const isGraphHover = graphState.hoverPolityId === polity.id;
+cityPolityColor.set(city.id, {
+color: polity.color,
+isGraphHover
+});
+}
+}
+for (let i = 0; i < w.count; i++) {
+let color;
+if (mode === "resources") {
+const v = clamp(w.resource[i] / 30, 0, 1);
+color = mix([28, 36, 40], [107, 188, 85], v);
+} else if (mode === "ethnicity") {
+color = terrainInfo[w.terrain[i]].color;
+} else if (mode === "pressure") {
+const v = clamp(w.pressure[i] / 12, 0, 1);
+color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
+} else if (mode === "polities") {
+color = terrainInfo[w.terrain[i]].color;
+if (w.city[i] >= 0) {
+const polityColor = cityPolityColor.get(w.city[i]);
+if (polityColor) {
+color = mix(color, polityColor.color, polityColor.isGraphHover ? 0.82 : 0.5);
+if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.28);
+}
+}
+} else if (mode === "technology") {
+color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35);
+} else {
+color = terrainInfo[w.terrain[i]].color;
+}
+const p = i * 4;
+data[p] = color[0];
+data[p + 1] = color[1];
+data[p + 2] = color[2];
+data[p + 3] = 255;
+}
+ctx.putImageData(image, 0, 0);
+renderDisasters();
+drawTradeLinks();
+drawAgentsAndCities(mode);
+drawGraphPolityHighlight();
+maybeRenderStateGraph();
+els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
}
-
function renderDisasters() {
- if (!sim?.disasters?.length) return;
- sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires);
- if (!sim.disasters.length) return;
- ctx.save();
- ctx.globalCompositeOperation = "source-over";
- for (const disaster of sim.disasters) {
- const duration = Math.max(1, disaster.expires - disaster.year);
- const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1);
- const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22);
- if (alpha <= 0.005) continue;
- const innerRadius = Math.max(1, disaster.radius * 0.32);
- const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius);
- gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`);
- gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`);
- gradient.addColorStop(1, "rgba(211, 95, 84, 0)");
- ctx.fillStyle = gradient;
- ctx.beginPath();
- ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2);
- ctx.fill();
- ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`;
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2);
- ctx.stroke();
- }
- ctx.restore();
+if (!sim?.disasters?.length) return;
+sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires);
+if (!sim.disasters.length) return;
+ctx.save();
+ctx.globalCompositeOperation = "source-over";
+for (const disaster of sim.disasters) {
+const duration = Math.max(1, disaster.expires - disaster.year);
+const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1);
+const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22);
+if (alpha <= 0.005) continue;
+const innerRadius = Math.max(1, disaster.radius * 0.32);
+const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius);
+gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`);
+gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`);
+gradient.addColorStop(1, "rgba(211, 95, 84, 0)");
+ctx.fillStyle = gradient;
+ctx.beginPath();
+ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2);
+ctx.fill();
+ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`;
+ctx.lineWidth = 1;
+ctx.beginPath();
+ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2);
+ctx.stroke();
+}
+ctx.restore();
}
-
function drawTradeLinks() {
- const w = sim.world;
- ctx.save();
- ctx.globalAlpha = 0.24;
- ctx.fillStyle = "#d9b650";
- for (const i of sim.activeTradeRouteTiles || []) {
- ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
- }
- if (!sim.tradeLinks.length) {
- ctx.restore();
- return;
- }
- for (const link of sim.tradeLinks) {
- ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42);
- ctx.fillStyle = "#ffd75a";
- for (const tile of link.path || []) {
- ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
- }
- }
- ctx.restore();
+const w = sim.world;
+ctx.save();
+ctx.globalAlpha = 0.24;
+ctx.fillStyle = "#d9b650";
+for (const i of sim.activeTradeRouteTiles || []) {
+ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
+}
+if (!sim.tradeLinks.length) {
+ctx.restore();
+return;
+}
+for (const link of sim.tradeLinks) {
+ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42);
+ctx.fillStyle = "#ffd75a";
+for (const tile of link.path || []) {
+ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
+}
+}
+ctx.restore();
}
-
function drawAgentsAndCities(mode) {
- ctx.save();
- for (const city of sim.cities) {
- const radius = cityRenderRadius(city);
- const color = cityDisplayColor(city, mode);
- ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.72)`;
- ctx.globalAlpha = 0.88;
- ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
- }
- ctx.globalAlpha = 1;
-
- if (mode === "technology") {
- for (const a of sim.agents) {
- sim.ensureAgentTech(a);
- const farming = a.tech.farming || 0;
- const metallurgy = a.tech.metallurgy || 0;
- const tech = clamp(Math.max(farming, metallurgy), 0, 1);
- if (tech <= 0.01) continue;
- const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
- ctx.fillStyle = `rgba(${color[0]},${color[1]},${color[2]},${clamp(0.32 + tech * 0.68, 0.32, 1)})`;
- ctx.fillRect(a.x, a.y, 1, 1);
- }
- } else if (mode === "ethnicity") {
- for (const [tile, counts] of sim.tileEthnicities) {
- let bestId = null;
- let bestCount = 0;
- for (const [id, count] of counts) {
- if (count > bestCount) {
- bestId = id;
- bestCount = count;
- }
- }
- const e = bestId !== null ? sim.ethnicities.get(bestId) : null;
- if (!e) continue;
- ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`;
- ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1);
- }
- } else {
- ctx.fillStyle = "#eeeccf";
- for (const tile of sim.tileAgents.keys()) {
- ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1);
- }
- }
-
- for (const city of sim.cities) {
- const color = cityDisplayColor(city, mode);
- ctx.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`;
- ctx.fillRect(city.x, city.y, 1, 1);
- }
- ctx.restore();
+ctx.save();
+for (const city of sim.cities) {
+const radius = cityRenderRadius(city);
+const color = cityDisplayColor(city, mode);
+ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.72)`;
+ctx.globalAlpha = 0.88;
+ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
+}
+ctx.globalAlpha = 1;
+if (mode === "technology") {
+for (const a of sim.agents) {
+sim.ensureAgentTech(a);
+const farming = a.tech.farming || 0;
+const metallurgy = a.tech.metallurgy || 0;
+const tech = clamp(Math.max(farming, metallurgy), 0, 1);
+if (tech <= 0.01) continue;
+const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
+ctx.fillStyle = `rgba(${color[0]},${color[1]},${color[2]},${clamp(0.32 + tech * 0.68, 0.32, 1)})`;
+ctx.fillRect(a.x, a.y, 1, 1);
+}
+} else if (mode === "ethnicity") {
+for (const a of sim.agents) {
+const e = sim.ethnicities.get(a.ethnicity);
+if (!e) continue;
+ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`;
+ctx.fillRect(a.x, a.y, 1, 1);
+}
+} else {
+ctx.fillStyle = "#eeeccf";
+for (const tile of sim.tileAgents.keys()) {
+ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1);
+}
+}
+for (const city of sim.cities) {
+const color = cityDisplayColor(city, mode);
+ctx.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`;
+ctx.fillRect(city.x, city.y, 1, 1);
+}
+ctx.restore();
}
-
function drawGraphPolityHighlight() {
- const polityId = graphState.hoverPolityId;
- if (polityId === null) return;
- const polity = sim.getPolityById(polityId);
- if (!polity) return;
- const cities = sim.getPolityCities(polity);
- if (!cities.length) return;
-
- ctx.save();
- ctx.lineWidth = 1;
- for (const city of cities) {
- const radius = Math.max(3, cityRenderRadius(city) + 2);
- const color = polity.color || [238, 232, 188];
- ctx.globalAlpha = city.id === polity.centerCityId ? 0.95 : 0.72;
- ctx.strokeStyle = `rgb(${Math.min(255, color[0] + 72)}, ${Math.min(255, color[1] + 72)}, ${Math.min(255, color[2] + 72)})`;
- ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
- ctx.globalAlpha = 0.32;
- ctx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
- ctx.fillRect(city.x - radius + 1, city.y - radius + 1, Math.max(1, radius * 2 - 1), Math.max(1, radius * 2 - 1));
- }
- ctx.restore();
+const polityId = graphState.hoverPolityId;
+if (polityId === null) return;
+const polity = sim.getPolityById(polityId);
+if (!polity) return;
+const cities = sim.getPolityCities(polity);
+if (!cities.length) return;
+ctx.save();
+ctx.lineWidth = 1;
+for (const city of cities) {
+const radius = Math.max(3, cityRenderRadius(city) + 2);
+const color = polity.color || [238, 232, 188];
+ctx.globalAlpha = city.id === polity.centerCityId ? 0.95 : 0.72;
+ctx.strokeStyle = `rgb(${Math.min(255, color[0] + 72)}, ${Math.min(255, color[1] + 72)}, ${Math.min(255, color[2] + 72)})`;
+ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
+ctx.globalAlpha = 0.32;
+ctx.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
+ctx.fillRect(city.x - radius + 1, city.y - radius + 1, Math.max(1, radius * 2 - 1), Math.max(1, radius * 2 - 1));
+}
+ctx.restore();
}
-
function cityRenderRadius(city) {
- return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7);
+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];
+const id = dominantComposition(city.ethnicityComposition);
+return sim.ethnicities.get(id)?.color || [242, 215, 134];
}
-
function cityDisplayColor(city, mode) {
- if (mode === "polities") {
- if (city.polityId !== null) {
- const polity = sim.getPolityById(city.polityId);
- if (polity) return polity.color;
- }
- return [218, 205, 154];
- }
- return cityMajorityColor(city);
+if (mode === "polities") {
+if (city.polityId !== null) {
+const polity = sim.getPolityById(city.polityId);
+if (polity) return polity.color;
+}
+return [218, 205, 154];
+}
+return cityMajorityColor(city);
}
-
function showTooltip(event) {
- const rect = els.canvas.getBoundingClientRect();
- const simRect = els.sim.getBoundingClientRect();
- const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size);
- const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size);
- hoverState = {
- x,
- y,
- left: event.clientX - simRect.left + 16,
- top: event.clientY - simRect.top + 16,
- width: simRect.width,
- height: simRect.height
- };
- renderTooltip();
+const rect = els.canvas.getBoundingClientRect();
+const simRect = els.sim.getBoundingClientRect();
+const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size);
+const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size);
+hoverState = {
+x,
+y,
+left: event.clientX - simRect.left + 16,
+top: event.clientY - simRect.top + 16,
+width: simRect.width,
+height: simRect.height
+};
+renderTooltip();
}
-
function renderTooltip() {
- if (!hoverState) return;
- const x = hoverState.x;
- const y = hoverState.y;
- if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) {
- hideTooltip();
- return;
- }
-
- const w = sim.world;
- const i = w.idx(x, y);
- const agent = findAgentAt(x, y);
- const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null;
- const terrain = terrainInfo[w.terrain[i]];
- const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null;
- const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2);
- const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
- const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
- const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
- const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-";
- const rows = items => items.filter(Boolean).join("");
- const row = (label, value) => `${label}${value}`;
- const section = (title, items) => {
- const body = rows(items);
- return body ? `${title}
${body}${title}
${body}