civ/script.js
2026-05-14 14:38:34 +09:00

4630 lines
166 KiB
JavaScript

const Terrain = Object.freeze({
PLAINS: 0,
FOREST: 1,
MOUNTAIN: 2,
WATER: 3,
DESERT: 4,
FERTILE: 5,
MINERAL: 6
});
const terrainInfo = [
{ name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 },
{ name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 },
{ name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 },
{ name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 },
{ name: "Desert", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 },
{ name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 },
{ name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 }
];
const WEEKS_PER_MONTH = 4;
const MONTHS_PER_YEAR = 12;
const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH;
const SimConfig = Object.freeze({
population: Object.freeze({
maxAgentsFloor: 8000,
maxAgentsScale: 1.15,
softCapStart: 0.85,
hardCapScale: 1.35,
offspringCrowdingPenalty: 1.8,
minOffspringAcceptance: 0.035,
maxOffspringPerStep: 360,
carryingCapacityBase: 2.4,
carryingCapacityFertility: 7.8,
carryingCapacityMineral: 1.8,
carryingCapacityFarmland: 5.5,
pressurePenaltyBase: 0.2,
pressureReproductionPenalty: 0.16
}),
city: Object.freeze({
maxCities: 300
}),
render: Object.freeze({
graphThrottleMs: 1400,
statsThrottleMs: 900,
historyWindowYears: 2000,
historySamplePaddingYears: 240
}),
technology: Object.freeze({
cityDiffusion: 0.0022,
tradeDiffusion: 0.0032,
cityInnovation: 0.00045
}),
polity: Object.freeze({
minimumPerCapitaFood: 0.06,
logisticsDistance: 34
}),
polityAccess: Object.freeze({
enabled: true,
maxSearchDepth: 8,
indirectPenalty: 0.012,
perHopPenalty: 0.006,
noAccessPenalty: 0.045,
lowLoyaltyTransitPenalty: 0.012,
foreignTransitPenalty: 0.008
}),
culture: Object.freeze({
spreadRadius: 3,
cityWeight: 0.035,
routeWeight: 1.35,
minimumInfluence: 0.18
}),
route: Object.freeze({
maxRouteLength: 42,
pheromoneDecay: 0.996,
pheromoneDiffusion: 0.045,
maxPheromone: 18,
routePheromoneBuild: 0.18,
routePheromoneMaintain: 0.12,
routeUpkeepPerTile: 0.018,
routeWeakThreshold: 8,
routeUnsupportedDecay: 7,
routeMaintainedBoost: 10
}),
disaster: Object.freeze({
damageScale: 1.5,
majorLossRate: 0.05,
checkIntervalYears: 8,
baseChance: 0.76,
minRadius: 6,
maxRadius: 34,
rareLargeChance: 0.10,
largeRadiusMin: 28,
largeRadiusMax: 54,
minIntensity: 0.25,
maxIntensity: 0.95,
visualDurationYears: 18,
maxActiveVisuals: 12,
maxHistory: 160
}),
frontierWave: Object.freeze({
enabledAfterYears: 300,
intervalYears: 80,
chance: 0.45,
minAgents: 60,
maxAgents: 140,
maxPopulationRatio: 1.28,
edgeBand: 4,
mutation: 0.08,
minResources: 12,
maxResources: 28
})
});
function years(value) {
return value * WEEKS_PER_YEAR;
}
const elementIds = [
"world", "toggleRun", "stepOnce", "resetWorld",
"speed", "agentCount", "worldSize", "viewMode", "year", "activeGroups", "urbanPopulation",
"ethnicities", "cities", "polities", "wars", "routes", "farmingKnowledge", "metallurgyKnowledge",
"deaths", "frameCost", "ethnicityList", "legend", "tooltip", "stateGraph", "historyRange",
"historyFilter", "historySort", "historyMetric", "toggleActiveStates", "togglePastStates",
"stateGraphInfo", "stateGraphTooltip"
];
const els = Object.fromEntries(elementIds.map(id => [id === "world" ? "canvas" : id, document.getElementById(id)]));
els.sim = document.querySelector(".sim");
const ctx = els.canvas.getContext("2d", { alpha: false });
let sim;
let running = true;
let lastStatsAt = 0;
let lastGraphRenderAt = 0;
let lastRenderAt = 0;
let lastSimTickAt = 0;
let renderDirty = true;
let loopTimer = null;
let loopScheduled = false;
let hoverState = null;
let renderImage = null;
let renderImageSize = 0;
const LoopConfig = Object.freeze({
simTickMs: 33,
renderMs: 66,
stepBudgetMs: 4.5,
idlePollMs: 180,
hiddenPollMs: 1000
});
const graphState = {
filter: "all",
sort: "important",
metric: "power",
showActive: true,
showPast: true,
hoverPolityId: null,
rows: [],
markers: [],
scale: null
};
const legendByMode = {
terrain: () => terrainInfo.map(t => `<span><i style="background: rgb(${t.color.join(",")})"></i>${t.name}</span>`).join(""),
ethnicity: () => "<span>Agent and city colors show lineage. Land remains terrain-colored.</span>",
pressure: () => "<span><i style=\"background:#d24943\"></i>High local population pressure</span>",
polities: () => "<span>Color = city-centered state. Uncolored cities are independent.</span>",
technology: () => "<span><i style=\"background:#5fab5b\"></i>Farming knowledge</span><span><i style=\"background:#caa966\"></i>Metallurgy knowledge</span>",
pheromone: () => "<span><i style=\"background:#d8b156\"></i>Pheromone strength</span><span><i style=\"background:#ffd75a\"></i>Formal trade routes</span>",
resources: () => "<span><i style=\"background:#6bbc55\"></i>Regenerating local resource stock</span>"
};
class Rng {
constructor(seed) {
this.seed = seed >>> 0;
}
next() {
this.seed = (1664525 * this.seed + 1013904223) >>> 0;
return this.seed / 4294967296;
}
range(min, max) {
return min + (max - min) * this.next();
}
int(max) {
return Math.floor(this.next() * max);
}
}
class World {
constructor(size, rng) {
this.size = size;
this.count = size * size;
this.rng = rng;
this.terrain = new Uint8Array(this.count);
this.resource = new Float32Array(this.count);
this.regen = new Float32Array(this.count);
this.move = new Float32Array(this.count);
this.fertility = new Float32Array(this.count);
this.mineral = new Float32Array(this.count);
this.temperature = new Float32Array(this.count);
this.humidity = new Float32Array(this.count);
this.pheromone = new Float32Array(this.count);
this.tradeRoute = new Uint8Array(this.count);
this.farmland = new Float32Array(this.count);
this.cityPull = new Float32Array(this.count);
this.city = new Int32Array(this.count);
this.pressure = new Float32Array(this.count);
this.dominantEthnicity = new Int32Array(this.count);
this.cultureDiversity = new Float32Array(this.count);
this.city.fill(-1);
this.dominantEthnicity.fill(-1);
this.generate();
}
idx(x, y) {
return y * this.size + x;
}
generate() {
const s = this.size;
const centers = Array.from({ length: 18 }, () => ({
x: this.rng.range(0, s),
y: this.rng.range(0, s),
kind: this.rng.next()
}));
const height = new Float32Array(this.count);
const moistureMap = new Float32Array(this.count);
const mineralMap = new Float32Array(this.count);
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const i = this.idx(x, y);
const nx = x / s - 0.5;
const ny = y / s - 0.5;
const latitude = Math.abs(ny) * 0.18;
let elevation = 0.48 - Math.hypot(nx, ny) * 0.62 + this.smoothNoise(x, y, 58) * 0.42 + this.smoothNoise(x + 900, y - 300, 31) * 0.12;
let moisture = this.smoothNoise(x + 500, y - 330, 54) * 0.68 + this.smoothNoise(x, y, 24) * 0.24 - latitude;
let minerals = this.smoothNoise(x - 290, y + 120, 20);
for (const c of centers) {
const d = Math.hypot(x - c.x, y - c.y) / s;
if (c.kind < 0.3) elevation += Math.max(0, 0.24 - d) * 0.85;
if (c.kind > 0.72) moisture += Math.max(0, 0.22 - d) * 1.0;
}
height[i] = elevation;
moistureMap[i] = moisture;
mineralMap[i] = minerals;
}
}
this.smoothField(height, 2);
this.smoothField(moistureMap, 1);
const seaLevel = this.percentile(height, 0.34);
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const i = this.idx(x, y);
const latitude = Math.abs((y / (s - 1)) * 2 - 1);
const altitudeCooling = clamp((height[i] - seaLevel) * 0.25, 0, 0.18);
this.temperature[i] = clamp(1 - latitude * 0.92 - altitudeCooling + this.smoothNoise(x + 1700, y - 80, 70) * 0.08, 0, 1);
this.terrain[i] = height[i] < seaLevel ? Terrain.WATER : Terrain.PLAINS;
}
}
this.computeHumidityFromWater(moistureMap);
for (let i = 0; i < this.count; i++) {
const elevation = height[i];
const minerals = mineralMap[i];
const temp = this.temperature[i];
const humid = this.humidity[i];
let t = this.terrain[i];
if (t !== Terrain.WATER) {
if (elevation > seaLevel + 0.42) t = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN;
else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT;
else if (humid > 0.72 && temp > 0.25 && temp < 0.88) t = Terrain.FERTILE;
else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST;
else t = Terrain.PLAINS;
}
const info = terrainInfo[t];
this.terrain[i] = t;
this.fertility[i] = clamp(info.fertility + humid * 0.16 - Math.abs(temp - 0.58) * 0.08 + this.rng.range(-0.03, 0.03), 0, 1);
this.mineral[i] = clamp(info.mineral + minerals * 0.16, 0, 1);
this.regen[i] = info.regen * (0.7 + this.fertility[i]);
this.move[i] = info.move;
this.resource[i] = this.terrain[i] === Terrain.WATER ? 0 : this.rng.range(4, 18) * (0.5 + this.fertility[i] + this.mineral[i] * 0.35);
}
this.smoothTerrainTypes(2);
this.ensureDesertPatches();
this.enrichWaterMargins();
}
enrichWaterMargins() {
const s = this.size;
const nextFertility = new Float32Array(this.fertility);
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const i = this.idx(x, y);
if (this.terrain[i] === Terrain.WATER) continue;
let waterScore = 0;
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const d = Math.abs(dx) + Math.abs(dy);
if (!d || d > 3) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) waterScore += (4 - d) / 4;
}
}
if (waterScore <= 0) continue;
const boost = Math.min(0.32, waterScore * 0.08);
nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1);
if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS;
if ((this.terrain[i] === Terrain.PLAINS || this.terrain[i] === Terrain.FOREST) && nextFertility[i] > 0.82) this.terrain[i] = Terrain.FERTILE;
}
}
for (let i = 0; i < this.count; i++) {
const info = terrainInfo[this.terrain[i]];
this.fertility[i] = nextFertility[i];
this.regen[i] = info.regen * (0.7 + this.fertility[i]);
this.move[i] = info.move;
if (this.terrain[i] === Terrain.WATER) {
this.resource[i] = 0;
} else {
this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25));
}
}
}
smoothNoise(x, y, scale) {
const x0 = Math.floor(x / scale);
const y0 = Math.floor(y / scale);
const fx = smoothstep((x / scale) - x0);
const fy = smoothstep((y / scale) - y0);
const a = this.gridNoise(x0, y0);
const b = this.gridNoise(x0 + 1, y0);
const c = this.gridNoise(x0, y0 + 1);
const d = this.gridNoise(x0 + 1, y0 + 1);
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
gridNoise(x, y) {
const v = Math.sin(x * 127.1 + y * 311.7 + this.rng.seed * 0.000001) * 43758.5453123;
return v - Math.floor(v);
}
computeHumidityFromWater(moistureMap) {
const s = this.size;
const radius = 10;
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const i = this.idx(x, y);
if (this.terrain[i] === Terrain.WATER) {
this.humidity[i] = 1;
continue;
}
let best = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const d = Math.abs(dx) + Math.abs(dy);
if (!d || d > radius) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) best = Math.max(best, 1 - d / (radius + 1));
}
}
this.humidity[i] = clamp(best * 0.78 + moistureMap[i] * 0.22, 0, 1);
}
}
this.smoothField(this.humidity, 1);
}
smoothTerrainTypes(passes) {
const s = this.size;
let source = new Uint8Array(this.terrain);
let target = new Uint8Array(this.terrain.length);
for (let pass = 0; pass < passes; pass++) {
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
const counts = new Uint8Array(terrainInfo.length);
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
counts[source[this.idx(tx, ty)]]++;
}
}
let bestTerrain = source[this.idx(x, y)];
let bestCount = counts[bestTerrain];
for (let t = 0; t < counts.length; t++) {
if (counts[t] > bestCount) {
bestTerrain = t;
bestCount = counts[t];
}
}
target[this.idx(x, y)] = bestCount >= 4 ? bestTerrain : source[this.idx(x, y)];
}
}
const swap = source;
source = target;
target = swap;
}
this.terrain.set(source);
}
ensureDesertPatches() {
const candidates = [];
let desertCount = 0;
for (let i = 0; i < this.count; i++) {
if (this.terrain[i] === Terrain.DESERT) desertCount++;
if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.55 && this.humidity[i] < 0.28) {
candidates.push(i);
}
}
const target = Math.max(12, Math.floor(this.count * 0.012));
if (desertCount >= target || !candidates.length) return;
candidates.sort((a, b) => {
const dryA = this.temperature[a] * (1 - this.humidity[a]);
const dryB = this.temperature[b] * (1 - this.humidity[b]);
return dryB - dryA;
});
const needed = Math.min(target - desertCount, candidates.length);
for (let n = 0; n < needed; n++) {
const center = candidates[n];
const cx = center % this.size;
const cy = Math.floor(center / this.size);
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (Math.abs(dx) + Math.abs(dy) > 2) continue;
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue;
const i = this.idx(x, y);
if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.5 && this.humidity[i] < 0.34) {
this.terrain[i] = Terrain.DESERT;
}
}
}
}
}
smoothField(field, passes) {
const s = this.size;
let source = field;
let target = new Float32Array(field.length);
for (let pass = 0; pass < passes; pass++) {
for (let y = 0; y < s; y++) {
for (let x = 0; x < s; x++) {
let sum = 0;
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
sum += source[this.idx(tx, ty)];
count++;
}
}
target[this.idx(x, y)] = sum / count;
}
}
const nextSource = target;
target = source === field ? new Float32Array(field.length) : field;
source = nextSource;
}
if (source !== field) field.set(source);
}
percentile(field, ratio) {
const values = Array.from(field).sort((a, b) => a - b);
return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))];
}
}
class Simulation {
constructor(size, initialAgents) {
this.rng = new Rng(Date.now());
this.world = new World(size, this.rng);
this.agents = [];
this.ethnicities = new Map();
this.cities = [];
this.cityById = new Map();
this.cityBuckets = new Map();
this.polities = [];
this.polityById = new Map();
this.wars = [];
this.disasters = [];
this.disasterHistory = [];
this.graphEvents = [];
this.polityHistory = new Map();
this.deadPolityHistories = [];
this.tradeLinks = [];
this.activeTradeRouteTiles = new Set();
this.ethnicDensityCache = new Map();
this.dominantEthnicityCache = new Map();
this.nextEthnicity = 1;
this.nextCity = 1;
this.nextPolity = 1;
this.nextWar = 1;
this.nextDisaster = 1;
this.year = 0;
this.deaths = 0;
this.maxAgents = Math.max(
Math.floor(initialAgents * SimConfig.population.maxAgentsScale),
SimConfig.population.maxAgentsFloor
);
this.tileEthnicities = new Map();
this.tileAgents = new Map();
this.spawnInitialAgents(initialAgents);
this.rebuildOccupancy();
this.updateEthnicStats();
}
spawnInitialAgents(count) {
const founders = Math.max(5, Math.min(16, Math.round(count / 180)));
const desertFounders = Math.max(2, Math.floor(founders * 0.25));
for (let e = 0; e < founders; e++) {
const desertFounder = e < desertFounders;
const origin = this.findHabitableTile(desertFounder ? Terrain.DESERT : null);
const originTile = this.world.idx(origin.x, origin.y);
const id = this.createEthnicity(0, {
temperature: this.world.temperature[originTile],
humidity: this.world.humidity[originTile]
});
const baseTraits = desertFounder ? this.desertFounderTraits() : this.randomTraits();
for (let n = 0; n < Math.floor(count / founders); n++) {
const spawn = this.findNearbySpawn(origin.x, origin.y, desertFounder ? Terrain.DESERT : null);
this.agents.push(this.makeAgent(
spawn.x,
spawn.y,
id,
mutateTraits(baseTraits, this.rng, 0.07),
desertFounder ? this.rng.range(18, 32) : this.rng.range(6, 15)
));
}
}
}
createEthnicity(parent, climate = null) {
const id = this.nextEthnicity++;
this.ethnicities.set(id, {
id,
parent,
born: this.year,
population: 0,
diversity: 0,
climateTemp: climate?.temperature ?? 0.55,
climateHumidity: climate?.humidity ?? 0.45,
color: hslToRgb((id * 0.61803398875) % 1, 0.55, 0.56),
centroidX: 0,
centroidY: 0
});
return id;
}
randomTraits() {
return {
mobility: this.rng.range(0.18, 0.85),
resourceAttraction: this.rng.range(0.55, 1),
assimilation: this.rng.range(0.03, 0.34),
ethnocentrism: this.rng.range(0.12, 0.78),
reproductionThreshold: this.rng.range(19, 34),
sedentary: this.rng.range(0.08, 0.82)
};
}
desertFounderTraits() {
const traits = this.randomTraits();
traits.mobility = this.rng.range(0.48, 0.9);
traits.resourceAttraction = this.rng.range(0.82, 1.12);
traits.reproductionThreshold = this.rng.range(24, 38);
traits.sedentary = this.rng.range(0.18, 0.58);
return traits;
}
makeAgent(x, y, ethnicity, traits, resources) {
return {
x,
y,
resources,
ethnicity,
traits,
alive: true,
foreignContact: 0,
contactEthnicity: ethnicity,
settled: 0,
movedThisStep: false,
tech: {
farming: 0,
metallurgy: 0
},
farmingWork: 0,
lastFarmTile: -1,
tradeOriginCityId: null,
lastTradeCityId: null,
tradeMemory: 0,
tradeCooldown: 0
};
}
findNearbySpawn(originX, originY, preferredTerrain = null) {
const w = this.world;
let best = { x: originX, y: originY };
let bestScore = -Infinity;
for (let tries = 0; tries < 24; tries++) {
const x = clamp(originX + this.rng.int(9) - 4, 0, w.size - 1);
const y = clamp(originY + this.rng.int(9) - 4, 0, w.size - 1);
const i = w.idx(x, y);
if (w.terrain[i] === Terrain.WATER) continue;
const preferred = preferredTerrain !== null && w.terrain[i] === preferredTerrain ? 1.5 : 0;
const score = preferred + w.resource[i] * 0.04 + w.fertility[i] * 0.8 + w.mineral[i] * 0.25 - w.move[i] * 0.16;
if (score > bestScore) {
bestScore = score;
best = { x, y };
}
}
return best;
}
findHabitableTile(preferredTerrain = null) {
if (preferredTerrain !== null) {
const exact = [];
let best = null;
let bestScore = -Infinity;
for (let i = 0; i < this.world.count; i++) {
const x = i % this.world.size;
const y = Math.floor(i / this.world.size);
if (this.world.terrain[i] === preferredTerrain) {
exact.push({ x, y });
continue;
}
if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) {
const score = this.world.temperature[i] * (1 - this.world.humidity[i]);
if (score > bestScore) {
bestScore = score;
best = { x, y };
}
}
}
if (exact.length) return exact[this.rng.int(exact.length)];
if (best) return best;
}
for (let tries = 0; tries < 5000; tries++) {
const x = this.rng.int(this.world.size);
const y = this.rng.int(this.world.size);
const i = this.world.idx(x, y);
if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) {
return { x, y };
}
}
return { x: this.world.size >> 1, y: this.world.size >> 1 };
}
frontierSpawnTile(side, edgeBand = 4) {
const w = this.world;
const band = Math.max(1, Math.floor(edgeBand));
let best = null;
let bestScore = -Infinity;
for (let tries = 0; tries < 160; tries++) {
let x = this.rng.int(w.size);
let y = this.rng.int(w.size);
if (side === 0) y = this.rng.int(band);
else if (side === 1) y = w.size - 1 - this.rng.int(band);
else if (side === 2) x = this.rng.int(band);
else x = w.size - 1 - this.rng.int(band);
const i = w.idx(x, y);
if (w.terrain[i] === Terrain.WATER) continue;
const score = w.resource[i] * 0.04 + w.fertility[i] * 0.85 + w.mineral[i] * 0.25 - w.move[i] * 0.12;
if (score > bestScore) {
best = { x, y };
bestScore = score;
}
}
return best || this.findHabitableTile();
}
maybeSpawnFrontierWave() {
const cfg = SimConfig.frontierWave;
if (!cfg) return;
if (this.year < years(cfg.enabledAfterYears ?? 300)) return;
const interval = years(cfg.intervalYears ?? 80);
if (!interval || this.year % interval !== 0) return;
if (this.rng.next() > (cfg.chance ?? 0.45)) return;
const populationLimit = this.maxAgents * (cfg.maxPopulationRatio ?? 1.28);
if (this.agents.length >= populationLimit) return;
const side = this.rng.int(4);
const targetCount = this.rng.int(Math.max(1, (cfg.maxAgents ?? 140) - (cfg.minAgents ?? 60) + 1)) + (cfg.minAgents ?? 60);
const origin = this.frontierSpawnTile(side, cfg.edgeBand ?? 4);
const originTile = this.world.idx(origin.x, origin.y);
const ethnicity = this.createEthnicity(0, {
temperature: this.world.temperature[originTile],
humidity: this.world.humidity[originTile]
});
const baseTraits = this.randomTraits();
baseTraits.mobility = this.rng.range(0.65, 1.0);
baseTraits.sedentary = this.rng.range(0.02, 0.35);
baseTraits.ethnocentrism = this.rng.range(0.42, 0.95);
baseTraits.assimilation = this.rng.range(0.02, 0.22);
baseTraits.resourceAttraction = this.rng.range(0.78, 1.15);
let spawned = 0;
for (let n = 0; n < targetCount && this.agents.length < populationLimit; n++) {
const spawn = this.frontierSpawnTile(side, cfg.edgeBand ?? 4);
const tile = this.world.idx(spawn.x, spawn.y);
if (this.world.terrain[tile] === Terrain.WATER) continue;
const agent = this.makeAgent(
spawn.x,
spawn.y,
ethnicity,
mutateTraits(baseTraits, this.rng, cfg.mutation ?? 0.08),
this.rng.range(cfg.minResources ?? 12, cfg.maxResources ?? 28)
);
agent.tradeMemory = this.rng.range(0.05, 0.22);
this.agents.push(agent);
if (this.addAgentToOccupancy) this.addAgentToOccupancy(agent);
spawned++;
}
if (spawned <= 0) this.ethnicities.delete(ethnicity);
else this.addGraphEvent("newEthnicity", this.year, {
ethnicity,
population: spawned
});
}
step() {
for (const city of this.cities) city.activeVisitors = 0;
this.rebuildOccupancy();
this.ethnicDensityCache.clear();
const offspring = [];
for (const a of this.agents) {
if (!a.alive) continue;
this.moveAgent(a);
}
this.rebuildOccupancy();
this.dominantEthnicityCache.clear();
for (const a of this.agents) {
if (!a.alive) continue;
this.updateAgentTechnology(a);
this.updateAgentTrade(a);
this.gatherConsumeReproduce(a, offspring);
if (a.alive) this.payTechnologyCostOrForget(a);
if (a.alive && this.shouldRunAgentSocial(a, 2)) this.resolveAssimilation(a);
}
this.agents = this.agents.filter(a => a.alive);
const acceptedOffspring = [];
const softCap = Math.max(1, this.maxAgents);
const hardCap = Math.max(softCap, Math.floor(softCap * (SimConfig.population.hardCapScale ?? 1.35)));
const softCapStart = SimConfig.population.softCapStart ?? 0.85;
const crowdingPenalty = SimConfig.population.offspringCrowdingPenalty ?? 1.8;
const minAcceptance = SimConfig.population.minOffspringAcceptance ?? 0.035;
for (const child of offspring) {
const projectedPopulation = this.agents.length + acceptedOffspring.length;
if (projectedPopulation >= hardCap) break;
const pressure = projectedPopulation / softCap;
const overSoftCap = Math.max(0, pressure - softCapStart);
const acceptChance = clamp(1 - overSoftCap * crowdingPenalty, minAcceptance, 1);
if (pressure <= softCapStart || this.rng.next() < acceptChance) acceptedOffspring.push(child);
}
this.agents.push(...acceptedOffspring);
for (const child of acceptedOffspring) this.addAgentToOccupancy(child);
if (this.year % 2 === 0) this.updateWorldFields(2);
this.maybeSpawnDisaster();
if (this.year % years(1) === 0) this.maybeSpawnFrontierWave();
if (this.year % years(1) === 0) {
this.updateCities();
if (this.year % years(2) === 0) this.updateRegionalCultures();
}
if (this.year % years(5) === 0) {
this.updateTradeRoutes();
}
this.updatePolities();
this.updateEthnicStats();
if (this.year % years(45) === 0) {
this.splitDivergentEthnicities();
this.updateEthnicStats();
}
this.year++;
}
rebuildOccupancy() {
const w = this.world;
w.pressure.fill(0);
this.tileEthnicities.clear();
this.tileAgents.clear();
this.rebuildIndexes();
for (const a of this.agents) {
if (!a.alive) continue;
const i = w.idx(a.x, a.y);
w.pressure[i]++;
let agents = this.tileAgents.get(i);
if (!agents) {
agents = [];
this.tileAgents.set(i, agents);
}
agents.push(a);
let counts = this.tileEthnicities.get(i);
if (!counts) {
counts = new Map();
this.tileEthnicities.set(i, counts);
}
counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
}
for (const tile of this.tileEthnicities.keys()) this.updateCultureTile(tile);
}
rebuildIndexes() {
this.cityById = new Map(this.cities.map(city => [city.id, city]));
this.polityById = new Map(this.polities.map(polity => [polity.id, polity]));
this.cityBuckets = new Map();
for (const city of this.cities) {
const key = this.cityBucketKey(city.x, city.y);
let bucket = this.cityBuckets.get(key);
if (!bucket) {
bucket = [];
this.cityBuckets.set(key, bucket);
}
bucket.push(city);
}
}
cityBucketKey(x, y) {
return `${Math.floor(x / 8)},${Math.floor(y / 8)}`;
}
getCitiesNear(x, y, radius) {
const cities = [];
const minBx = Math.floor((x - radius) / 8);
const maxBx = Math.floor((x + radius) / 8);
const minBy = Math.floor((y - radius) / 8);
const maxBy = Math.floor((y + radius) / 8);
for (let by = minBy; by <= maxBy; by++) {
for (let bx = minBx; bx <= maxBx; bx++) {
const bucket = this.cityBuckets.get(`${bx},${by}`);
if (!bucket) continue;
for (const city of bucket) {
if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city);
}
}
}
return cities;
}
updateCultureTile(tile) {
const counts = this.tileEthnicities.get(tile);
if (!counts || !counts.size) {
this.world.cultureDiversity[tile] *= 0.98;
return;
}
let total = 0;
let dominant = -1;
let dominantCount = 0;
for (const [id, count] of counts) {
total += count;
if (count > dominantCount) {
dominant = id;
dominantCount = count;
}
}
this.world.dominantEthnicity[tile] = dominant;
this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0;
}
updateRegionalCultures() {
const w = this.world;
const nextDominant = new Int32Array(w.dominantEthnicity);
const nextDiversity = new Float32Array(w.cultureDiversity);
const radius = SimConfig.culture.spreadRadius;
const cityById = new Map(this.cities.map(city => [city.id, city]));
for (let y = 0; y < w.size; y++) {
for (let x = 0; x < w.size; x++) {
const tile = w.idx(x, y);
if (w.terrain[tile] === Terrain.WATER) continue;
const influence = new Map();
let total = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const distance = Math.abs(dx) + Math.abs(dy);
if (distance > radius) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
const source = w.idx(tx, ty);
const id = w.dominantEthnicity[source];
if (id < 0) continue;
const pressureWeight = Math.sqrt(Math.max(0, w.pressure[source]));
const cityWeight = w.city[source] >= 0
? Math.sqrt(Math.max(1, cityById.get(w.city[source])?.population || 1)) * SimConfig.culture.cityWeight
: 0;
const routeWeight = w.tradeRoute[source] ? SimConfig.culture.routeWeight : 1;
const weight = (pressureWeight + cityWeight) * routeWeight / (1 + distance);
if (weight <= 0) continue;
influence.set(id, (influence.get(id) || 0) + weight);
total += weight;
}
}
let bestId = nextDominant[tile];
let bestWeight = 0;
for (const [id, weight] of influence) {
if (weight > bestWeight) {
bestId = id;
bestWeight = weight;
}
}
if (bestWeight >= SimConfig.culture.minimumInfluence) {
nextDominant[tile] = bestId;
nextDiversity[tile] = total > 0 ? clamp(1 - bestWeight / total, 0, 1) : 0;
}
}
}
w.dominantEthnicity.set(nextDominant);
w.cultureDiversity.set(nextDiversity);
}
addAgentToOccupancy(a) {
if (!a.alive) return;
const w = this.world;
const i = w.idx(a.x, a.y);
w.pressure[i]++;
let agents = this.tileAgents.get(i);
if (!agents) {
agents = [];
this.tileAgents.set(i, agents);
}
agents.push(a);
let counts = this.tileEthnicities.get(i);
if (!counts) {
counts = new Map();
this.tileEthnicities.set(i, counts);
}
counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
this.updateCultureTile(i);
}
removeAgentFromOccupancy(a) {
const w = this.world;
const i = w.idx(a.x, a.y);
w.pressure[i] = Math.max(0, w.pressure[i] - 1);
const agents = this.tileAgents.get(i);
if (agents) {
const index = agents.indexOf(a);
if (index >= 0) agents.splice(index, 1);
if (!agents.length) this.tileAgents.delete(i);
}
const counts = this.tileEthnicities.get(i);
if (counts) {
const next = (counts.get(a.ethnicity) || 0) - 1;
if (next > 0) counts.set(a.ethnicity, next);
else counts.delete(a.ethnicity);
if (!counts.size) this.tileEthnicities.delete(i);
}
this.updateCultureTile(i);
}
changeAgentEthnicity(a, nextEthnicity) {
if (a.ethnicity === nextEthnicity) return;
const w = this.world;
const i = w.idx(a.x, a.y);
const counts = this.tileEthnicities.get(i);
if (counts) {
const prev = (counts.get(a.ethnicity) || 0) - 1;
if (prev > 0) counts.set(a.ethnicity, prev);
else counts.delete(a.ethnicity);
counts.set(nextEthnicity, (counts.get(nextEthnicity) || 0) + 1);
}
a.ethnicity = nextEthnicity;
this.updateCultureTile(i);
}
moveAgent(a) {
const w = this.world;
const s = w.size;
const current = w.idx(a.x, a.y);
const localPressure = w.pressure[current];
let bestX = a.x;
let bestY = a.y;
let bestScore = -Infinity;
const sedentary = a.traits.sedentary;
const ethnocentrism = a.traits.ethnocentrism;
const ethnicClimate = this.ethnicities.get(a.ethnicity);
const waterAdaptation = ethnicClimate?.climateHumidity ?? 0.45;
const localCapacity = this.carryingCapacityAt(current);
const localOverCapacity = Math.max(0, localPressure - localCapacity);
if (localOverCapacity <= 0.5 && localPressure < 7 && this.rng.next() < sedentary * 0.58) {
a.settled++;
a.movedThisStep = false;
return;
}
const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = clamp(a.x + dx, 0, s - 1);
const y = clamp(a.y + dy, 0, s - 1);
const i = w.idx(x, y);
const isWater = w.terrain[i] === Terrain.WATER;
const canSail = waterAdaptation >= 0.82 && a.traits.mobility > 0.45;
const waterAccess = canSail ? 0.82 + (w.tradeRoute[i] ? 0.08 : 0) : waterAdaptation * 0.18 + a.traits.mobility * 0.04 + (w.tradeRoute[i] ? 0.08 : 0);
if (isWater && this.rng.next() > waterAccess) continue;
const ethnicDensity = ethnocentrism > 0.04
? this.ethnicDensityNear(x, y, a.ethnicity)
: { same: 0, foreign: 0 };
const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
const routeStrength = clamp(w.tradeRoute[i] / 80, 0, 1.8);
const routePull = (w.tradeRoute[i] ? 1.55 + routeStrength * 0.55 : clamp(w.pheromone[i] / 8, 0, 1)) * (0.35 + a.traits.mobility * 0.55 + sedentary * 0.45);
const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? waterAdaptation * 0.35 : 0);
const destinationCapacity = this.carryingCapacityAt(i);
const destinationOverCapacity = Math.max(0, w.pressure[i] - destinationCapacity);
const capacitySpace = clamp((destinationCapacity - w.pressure[i]) / Math.max(1, destinationCapacity), -1, 1);
const crowdPenalty = destinationOverCapacity * (0.42 + a.traits.mobility * 0.85);
const routeBonus = w.tradeRoute[i] ? 1.35 + routeStrength * 0.45 : 0;
const waterPenalty = isWater ? (canSail ? 0.22 : 2.45 - waterAdaptation * 0.9) : 0;
const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus;
const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0;
const pressurePush = localOverCapacity > 0 ? a.traits.mobility * (1.2 + localOverCapacity * 0.18 - sedentary * 0.45) : 0;
const capacityPull = capacitySpace * (0.72 + a.traits.mobility * 0.45);
const score =
resourceScore * a.traits.resourceAttraction +
w.pheromone[i] * 0.021 +
ethnicDensity.same * ethnocentrism +
cityPull * sedentary +
routePull +
capacityPull +
inertia -
terrainPenalty -
crowdPenalty +
pressurePush -
ethnicDensity.foreign * ethnocentrism * 0.72 +
this.rng.range(-0.55, 0.55);
if (score > bestScore) {
bestScore = score;
bestX = x;
bestY = y;
}
}
}
const from = w.idx(a.x, a.y);
a.x = bestX;
a.y = bestY;
const to = w.idx(a.x, a.y);
if (from !== to) {
a.movedThisStep = true;
a.farmingWork = 0;
a.lastFarmTile = to;
const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1;
this.addPheromone(from, (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale);
this.addPheromone(to, (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale);
a.settled = Math.max(0, a.settled - 1);
} else {
a.movedThisStep = false;
a.settled++;
}
}
ensureAgentTech(a) {
a.tech ??= { farming: 0, metallurgy: 0 };
a.tech.farming ??= 0;
a.tech.metallurgy ??= 0;
a.tech.farming = clamp(a.tech.farming, 0, 1);
a.tech.metallurgy = clamp(a.tech.metallurgy, 0, 1);
a.farmingWork ??= 0;
a.lastFarmTile ??= -1;
a.movedThisStep ??= false;
}
ensureAgentTrade(agent) {
agent.tradeOriginCityId ??= null;
agent.lastTradeCityId ??= null;
agent.tradeMemory ??= 0;
agent.tradeCooldown ??= 0;
agent.tradeMemory = clamp(agent.tradeMemory, 0, 1);
agent.tradeCooldown = Math.max(0, Math.floor(agent.tradeCooldown));
}
agentNomadism(agent) {
const mobility = clamp(agent.traits?.mobility ?? 0.5, 0, 1);
const sedentary = clamp(agent.traits?.sedentary ?? 0.5, 0, 1);
return clamp(mobility * 0.75 + (1 - sedentary) * 0.55, 0, 1);
}
farmabilityAt(tile) {
const t = this.world.terrain[tile];
if (t === Terrain.FERTILE) return 1.0;
if (t === Terrain.PLAINS) return 0.75;
if (t === Terrain.FOREST) return 0.55;
if (t === Terrain.DESERT) return 0.25;
return 0.0;
}
carryingCapacityAt(tile) {
const w = this.world;
if (w.terrain[tile] === Terrain.WATER) return 1.2;
return SimConfig.population.carryingCapacityBase +
w.fertility[tile] * SimConfig.population.carryingCapacityFertility +
w.mineral[tile] * SimConfig.population.carryingCapacityMineral +
w.farmland[tile] * SimConfig.population.carryingCapacityFarmland +
(w.city[tile] >= 0 ? 3.5 : 0);
}
updateAgentTechnology(agent, payMaintenance = false) {
if (!agent.alive) return;
this.ensureAgentTech(agent);
this.updateFarmingWork(agent);
this.tryInventTechnology(agent);
this.learnTechnologyFromCity(agent);
if (this.shouldRunAgentSocial(agent, 3)) {
this.spreadTechnology(agent);
this.improveTechnologyFromDensity(agent);
}
if (payMaintenance) this.payTechnologyCostOrForget(agent);
}
shouldRunAgentSocial(agent, interval) {
return ((agent.x * 31 + agent.y * 17 + this.year) % interval) === 0;
}
updateFarmingWork(agent) {
const w = this.world;
const tile = w.idx(agent.x, agent.y);
if (agent.lastFarmTile !== tile) {
agent.farmingWork = 0;
agent.lastFarmTile = tile;
}
if (agent.movedThisStep) return;
const farming = agent.tech?.farming || 0;
if (farming <= 0.02) return;
if (agent.settled > 0) {
agent.farmingWork = clamp((agent.farmingWork || 0) + farming * 0.015, 0, 1);
agent.tech.farming = clamp(agent.tech.farming + farming * this.farmabilityAt(tile) * 0.00018, 0, 1);
}
}
tryInventTechnology(agent) {
const w = this.world;
const tile = w.idx(agent.x, agent.y);
const settledFactor = clamp((agent.settled || 0) / 12, 0, 1);
if ((agent.tech?.farming || 0) <= 0.02) {
const farmingBase = 0.00016;
const farmability = this.farmabilityAt(tile);
const sedentary = agent.traits.sedentary;
const farmingChance =
farmingBase *
(0.25 + farmability) *
(0.4 + sedentary) *
(0.3 + settledFactor);
if (this.rng.next() < farmingChance) this.grantTechnologyAround(agent, "farming", 0.32);
}
if ((agent.tech?.metallurgy || 0) <= 0.02) {
const metallurgyBase = 0.00011;
const mineral = clamp(w.mineral[tile], 0, 1);
const mineralTerrainBonus =
w.terrain[tile] === Terrain.MINERAL || w.terrain[tile] === Terrain.MOUNTAIN
? 1.8
: 1.0;
const metallurgyChance =
metallurgyBase *
(0.2 + mineral) *
mineralTerrainBonus *
(0.5 + settledFactor);
if (this.rng.next() < metallurgyChance) this.grantTechnologyAround(agent, "metallurgy", 0.28);
}
}
grantTechnologyAround(agent, techName, amount) {
this.ensureAgentTech(agent);
this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
if (!other.alive) return true;
this.ensureAgentTech(other);
const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
if (distance > 2) return true;
const gain = amount * (other === agent ? 1 : 0.55);
other.tech[techName] = clamp(Math.max(other.tech[techName] || 0, gain), 0, 1);
return true;
});
}
payTechnologyCostOrForget(agent) {
if (!agent.alive) return;
this.ensureAgentTech(agent);
const farming = agent.tech.farming || 0;
const metallurgy = agent.tech.metallurgy || 0;
const cost = farming * 0.004 + metallurgy * 0.010;
if (cost <= 0) return;
if (agent.resources >= cost) {
agent.resources -= cost;
return;
}
const shortage = cost - Math.max(0, agent.resources);
agent.resources = Math.max(0, agent.resources - cost);
const decay = clamp(0.006 + shortage * 0.025, 0.006, 0.06);
agent.tech.farming = Math.max(0, farming - decay);
agent.tech.metallurgy = Math.max(0, metallurgy - decay * 1.15);
if (agent.tech.farming < 0.02) agent.tech.farming = 0;
if (agent.tech.metallurgy < 0.02) agent.tech.metallurgy = 0;
if (agent.tech.farming <= 0) agent.farmingWork = 0;
}
spreadTechnology(agent) {
const w = this.world;
const tile = w.idx(agent.x, agent.y);
const assimilation = agent.traits.assimilation || 0;
const ethnocentrism = agent.traits.ethnocentrism || 0;
const nomadism = this.agentNomadism(agent);
let checked = 0;
this.forEachLocalAgentNear(agent.x, agent.y, 1, other => {
if (other === agent || !other.alive) return true;
if (Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y) > 1) return true;
if (++checked > 12) return false;
this.ensureAgentTech(other);
const sameEthnicity = agent.ethnicity === other.ethnicity;
let chance =
0.0035 +
assimilation * 0.006 -
ethnocentrism * 0.0012;
if (sameEthnicity) chance += 0.004;
if (w.tradeRoute[tile]) chance += 0.006;
if (w.city[tile] >= 0) chance += 0.003;
chance += nomadism * 0.004;
chance = clamp(chance, 0.0008, 0.028);
this.learnTechnologyFrom(agent, other, "farming", chance);
this.learnTechnologyFrom(agent, other, "metallurgy", chance);
return true;
});
}
learnTechnologyFrom(agent, other, techName, chance) {
const current = agent.tech[techName] || 0;
const otherLevel = other.tech?.[techName] || 0;
if (otherLevel > current + 0.04 && this.rng.next() < chance) {
agent.tech[techName] = clamp(current + (otherLevel - current) * 0.14, 0, 1);
}
}
learnTechnologyFromCity(agent) {
const tile = this.world.idx(agent.x, agent.y);
const city = this.world.city[tile] >= 0 ? this.getCityById(this.world.city[tile]) : null;
if (!city?.knowledge) return;
const routeBonus = this.world.tradeRoute[tile] ? SimConfig.technology.tradeDiffusion : 0;
const chance = SimConfig.technology.cityDiffusion + routeBonus + clamp(city.population / 1200, 0, 0.006);
if (this.rng.next() < chance) {
agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming * 0.72), 0, 1);
agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy, city.knowledge.metallurgy * 0.68), 0, 1);
}
}
updateAgentTrade(agent) {
this.ensureAgentTrade(agent);
this.ensureAgentTech(agent);
if (agent.tradeCooldown > 0) {
agent.tradeCooldown--;
return;
}
const city = this.encounteredCityForAgent(agent);
if (!city) return;
const nomadism = this.agentNomadism(agent);
let chance =
0.015 +
nomadism * 0.08 +
(agent.tradeMemory || 0) * 0.04;
if (nomadism < 0.35) chance *= 0.18;
const tile = this.world.idx(agent.x, agent.y);
if (this.world.tradeRoute[tile]) chance += 0.04;
chance += clamp(Math.sqrt(Math.max(0, city.population || 0)) / 80, 0, 0.08);
chance += ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.015;
chance = clamp(chance, 0.005, 0.22);
if (this.rng.next() > chance) return;
if (agent.tradeOriginCityId === null) {
agent.tradeOriginCityId = city.id;
agent.lastTradeCityId = city.id;
agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.01, 0, 1);
agent.tradeCooldown = Math.floor(years(1));
return;
}
if (agent.tradeOriginCityId === city.id) {
agent.lastTradeCityId = city.id;
agent.tradeCooldown = Math.floor(years(0.5));
return;
}
const originCity = this.getCityById(agent.tradeOriginCityId);
if (!originCity) {
agent.tradeOriginCityId = city.id;
agent.lastTradeCityId = city.id;
agent.tradeCooldown = Math.floor(years(1));
return;
}
this.completeAgentTrade(agent, originCity, city);
}
encounteredCityForAgent(agent) {
const w = this.world;
const tile = w.idx(agent.x, agent.y);
if (w.city[tile] >= 0) {
const city = this.getCityById(w.city[tile]);
if (city) return city;
}
const candidates = this.getCitiesNear(agent.x, agent.y, 4);
if (!candidates.length) return null;
candidates.sort((a, b) => {
const da = Math.abs(a.x - agent.x) + Math.abs(a.y - agent.y);
const db = Math.abs(b.x - agent.x) + Math.abs(b.y - agent.y);
if (da !== db) return da - db;
return (b.population || 0) - (a.population || 0);
});
return candidates[0];
}
completeAgentTrade(agent, originCity, destinationCity) {
if (!originCity || !destinationCity || originCity.id === destinationCity.id) return;
this.ensureAgentTech(agent);
this.ensureAgentTrade(agent);
const nomadism = this.agentNomadism(agent);
const distance = this.distanceBetweenCities(originCity, destinationCity);
const routeFactor = this.hasDirectTradeConnection(originCity, destinationCity) ? 1.35 : 1.0;
const distanceFactor = clamp(distance / 28, 0.45, 2.1);
const cityScale = Math.sqrt(Math.max(1, originCity.population || 1)) *
Math.sqrt(Math.max(1, destinationCity.population || 1));
const techFactor = 1 + ((agent.tech?.farming || 0) + (agent.tech?.metallurgy || 0)) * 0.12;
const memoryFactor = 1 + (agent.tradeMemory || 0) * 0.35;
const rawProfit =
cityScale *
0.010 *
distanceFactor *
routeFactor *
techFactor *
memoryFactor *
clamp(nomadism, 0.25, 1);
const profit = clamp(rawProfit, 0.25, 7.5);
agent.resources += profit * 0.50;
originCity.storedResources += profit * 0.20;
destinationCity.storedResources += profit * 0.30;
this.exchangeAgentTradeKnowledge(agent, originCity, destinationCity);
const currentTile = this.world.idx(agent.x, agent.y);
this.addPheromone(currentTile, profit * 0.45);
this.addPheromone(this.world.idx(originCity.x, originCity.y), profit * 0.30);
this.addPheromone(this.world.idx(destinationCity.x, destinationCity.y), profit * 0.35);
agent.tradeOriginCityId = destinationCity.id;
agent.lastTradeCityId = destinationCity.id;
agent.tradeMemory = clamp((agent.tradeMemory || 0) + 0.035, 0, 1);
agent.tradeCooldown = Math.floor(years(1.5));
}
exchangeAgentTradeKnowledge(agent, originCity, destinationCity) {
if (originCity.knowledge) {
originCity.knowledge.farming = clamp(Math.max(originCity.knowledge.farming, (agent.tech?.farming || 0) * 0.45), 0, 1);
originCity.knowledge.metallurgy = clamp(Math.max(originCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.45), 0, 1);
}
if (destinationCity.knowledge) {
destinationCity.knowledge.farming = clamp(Math.max(destinationCity.knowledge.farming, (agent.tech?.farming || 0) * 0.55), 0, 1);
destinationCity.knowledge.metallurgy = clamp(Math.max(destinationCity.knowledge.metallurgy, (agent.tech?.metallurgy || 0) * 0.55), 0, 1);
agent.tech.farming = clamp(Math.max(agent.tech.farming || 0, destinationCity.knowledge.farming * 0.25), 0, 1);
agent.tech.metallurgy = clamp(Math.max(agent.tech.metallurgy || 0, destinationCity.knowledge.metallurgy * 0.25), 0, 1);
}
}
improveTechnologyFromDensity(agent) {
let farmingCount = 0;
let metallurgyCount = 0;
let farmingSum = 0;
let metallurgySum = 0;
this.forEachLocalAgentNear(agent.x, agent.y, 2, other => {
if (!other.alive) return true;
this.ensureAgentTech(other);
const distance = Math.abs(agent.x - other.x) + Math.abs(agent.y - other.y);
if (distance > 2) return true;
const farming = other.tech.farming || 0;
const metallurgy = other.tech.metallurgy || 0;
if (farming > 0.1) {
farmingCount++;
farmingSum += farming;
}
if (metallurgy > 0.1) {
metallurgyCount++;
metallurgySum += metallurgy;
}
return true;
});
if (farmingCount >= 2) {
const avgFarming = farmingSum / farmingCount;
agent.tech.farming = clamp(agent.tech.farming + 0.0024 * farmingCount * avgFarming, 0, 1);
}
if (metallurgyCount >= 2) {
const avgMetallurgy = metallurgySum / metallurgyCount;
agent.tech.metallurgy = clamp(agent.tech.metallurgy + 0.0018 * metallurgyCount * avgMetallurgy, 0, 1);
}
}
forEachLocalAgentNear(x, y, radius, callback) {
const w = this.world;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
const agents = this.tileAgents.get(w.idx(tx, ty));
if (!agents) continue;
for (const agent of agents) {
if (callback(agent) === false) return;
}
}
}
}
farmingGatherMultiplier(agent, tile) {
const farmingLevel = agent.tech?.farming || 0;
const farmingWork = agent.farmingWork || 0;
const farmability = this.farmabilityAt(tile);
return clamp(1 + farmingLevel * farmingWork * farmability * 2, 1, 3);
}
metallurgyGatherMultiplier(agent, tile) {
const metallurgyLevel = agent.tech?.metallurgy || 0;
if (metallurgyLevel > 0.02) {
agent.tech.metallurgy = clamp(agent.tech.metallurgy + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1) * 0.00012, 0, 1);
}
return 1 + metallurgyLevel * clamp(this.world.mineral[tile], 0, 1);
}
gatherConsumeReproduce(a, offspring) {
const w = this.world;
const i = w.idx(a.x, a.y);
this.ensureAgentTech(a);
const ethnicity = this.ethnicities.get(a.ethnicity);
const climateMismatch = this.climateMismatch(a.ethnicity, i);
const climateFit = clamp(1 - climateMismatch * 1.35, 0.18, 1);
const cityMarket = w.city[i] >= 0 ? 0.28 : 0;
const drylandAdapted = this.isDrylandAdapted(ethnicity);
const desertForage = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.55 : 0;
const productivity = 0.45 + w.fertility[i] * 1.35 + w.mineral[i] * 0.45 + w.farmland[i] * 0.72 + cityMarket + desertForage;
const carryingCapacity = this.carryingCapacityAt(i);
const overCapacity = Math.max(0, w.pressure[i] - carryingCapacity);
const pressurePenalty = 1 / (1 + overCapacity * SimConfig.population.pressurePenaltyBase);
const baseGatherAmount = productivity * climateFit * pressurePenalty * this.rng.range(0.45, 1.2);
const gathered = Math.min(
w.resource[i],
baseGatherAmount * this.farmingGatherMultiplier(a, i) * this.metallurgyGatherMultiplier(a, i)
);
w.resource[i] -= gathered;
a.resources += gathered;
const waterAdaptation = ethnicity?.climateHumidity ?? 0.45;
const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0;
const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4;
const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1;
const sedentary = a.traits.sedentary;
const mobileOverhead = (1 - sedentary) * 0.18 + a.traits.mobility * 0.08;
a.resources -= ((0.72 + w.move[i] * 0.06 + waterCost + climateCost) * drylandUpkeep) + mobileOverhead;
if (a.resources <= 0) {
this.removeAgentFromOccupancy(a);
a.alive = false;
this.deaths++;
return;
}
const settlementBonus = sedentary * (w.farmland[i] * 0.18 + cityMarket * 0.12);
const mobilityPenalty = (1 - sedentary) * 0.45;
const capacityPenalty = overCapacity * SimConfig.population.pressureReproductionPenalty;
const reproductionThreshold = a.traits.reproductionThreshold * clamp(1 + mobilityPenalty + capacityPenalty - settlementBonus, 0.82, 2.35);
if (a.resources > reproductionThreshold && offspring.length < SimConfig.population.maxOffspringPerStep) {
const childShare = 0.36 + sedentary * 0.08;
const childResources = a.resources * childShare;
a.resources -= childResources;
const childTraits = mutateTraits(a.traits, this.rng, 0.035);
const child = this.makeAgent(a.x, a.y, a.ethnicity, childTraits, childResources);
child.tech.farming = clamp((a.tech?.farming || 0) * this.rng.range(0.75, 0.95), 0, 1);
child.tech.metallurgy = clamp((a.tech?.metallurgy || 0) * this.rng.range(0.70, 0.92), 0, 1);
offspring.push(child);
}
}
climateMismatch(ethnicityId, tile) {
const ethnicity = this.ethnicities.get(ethnicityId);
if (!ethnicity) return 0;
const tempDiff = Math.abs(this.world.temperature[tile] - ethnicity.climateTemp);
const humidDiff = Math.abs(this.world.humidity[tile] - ethnicity.climateHumidity);
return tempDiff * 0.58 + humidDiff * 0.42;
}
isDrylandAdapted(ethnicity) {
return !!ethnicity && ethnicity.climateTemp > 0.5 && ethnicity.climateHumidity < 0.38;
}
ethnicDensityNear(x, y, ethnicity) {
const key = `${x},${y},${ethnicity}`;
const cached = this.ethnicDensityCache.get(key);
if (cached) return cached;
let same = 0;
let foreign = 0;
const w = this.world;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
if (Math.abs(dx) + Math.abs(dy) > 2) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
const counts = this.tileEthnicities.get(w.idx(tx, ty));
if (!counts) continue;
for (const [id, value] of counts) {
if (id === ethnicity) same += value;
else foreign += value;
}
if (same > 6 && foreign > 6) {
const result = { same: same * 0.22, foreign: foreign * 0.16 };
this.ethnicDensityCache.set(key, result);
return result;
}
}
}
const result = { same: same * 0.22, foreign: foreign * 0.16 };
this.ethnicDensityCache.set(key, result);
return result;
}
resolveAssimilation(a) {
const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity);
if (!dominant || dominant.id === a.ethnicity) {
a.foreignContact = Math.max(0, a.foreignContact - 1);
return;
}
if (a.contactEthnicity !== dominant.id) {
a.contactEthnicity = dominant.id;
a.foreignContact = 0;
}
a.foreignContact++;
const pressure = dominant.count / Math.max(1, dominant.total);
const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 100);
if (this.rng.next() < chance * 0.0001) {
this.changeAgentEthnicity(a, dominant.id);
a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
a.foreignContact = 0;
}
}
dominantEthnicityNear(x, y, self) {
const key = `${x},${y},${self}`;
const cached = this.dominantEthnicityCache.get(key);
if (cached) return cached;
const counts = new Map();
let total = 0;
const w = this.world;
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
if (Math.abs(dx) + Math.abs(dy) > 3) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
const tileCounts = this.tileEthnicities.get(w.idx(tx, ty));
if (!tileCounts) continue;
for (const [id, count] of tileCounts) {
total += count;
counts.set(id, (counts.get(id) || 0) + count);
}
}
}
for (const city of this.getCitiesNear(x, y, 5)) {
const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
if (!city.ethnicityComposition?.size) continue;
const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8);
for (const [id, count] of city.ethnicityComposition) {
const weighted = Math.max(1, Math.round(count * influence * 0.015));
total += weighted;
counts.set(id, (counts.get(id) || 0) + weighted);
}
}
let best = null;
for (const [id, count] of counts) {
if (id !== self && (!best || count > best.count)) best = { id, count, total };
}
if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits();
if (best) this.dominantEthnicityCache.set(key, best);
return best;
}
updateWorldFields(scale = 1) {
const w = this.world;
const pheromoneDecay = Math.pow(SimConfig.route.pheromoneDecay, scale);
const diffusion = SimConfig.route.pheromoneDiffusion * scale;
const nextPheromone = diffusion > 0 ? new Float32Array(w.pheromone) : null;
for (let i = 0; i < w.count; i++) {
w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15));
const decayed = w.pheromone[i] * pheromoneDecay;
if (nextPheromone) nextPheromone[i] = decayed;
else w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, decayed);
}
if (nextPheromone) {
for (let y = 0; y < w.size; y++) {
for (let x = 0; x < w.size; x++) {
const i = w.idx(x, y);
if (w.terrain[i] === Terrain.WATER) {
w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, nextPheromone[i] * 0.85);
continue;
}
let neighborSum = 0;
let neighborCount = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
const tile = w.idx(tx, ty);
if (w.terrain[tile] === Terrain.WATER) continue;
neighborSum += nextPheromone[tile];
neighborCount++;
}
}
const neighborAverage = neighborCount ? neighborSum / neighborCount : nextPheromone[i];
w.pheromone[i] = Math.min(SimConfig.route.maxPheromone, lerp(nextPheromone[i], neighborAverage, diffusion));
}
}
}
}
updateCities() {
const w = this.world;
w.city.fill(-1);
w.farmland.fill(0);
w.cityPull.fill(0);
const candidates = new Map();
for (const a of this.agents) {
if (!a.alive) continue;
const local = w.idx(a.x, a.y);
if (a.settled < 3 && w.pressure[local] < 2) continue;
if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.24) continue;
const cx = clamp(Math.round(a.x / 4) * 4, 0, w.size - 1);
const cy = clamp(Math.round(a.y / 4) * 4, 0, w.size - 1);
const i = w.idx(cx, cy);
let group = candidates.get(i);
if (!group) {
group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map() };
candidates.set(i, group);
}
this.ensureAgentTech(a);
group.count++;
group.resources += Math.max(0, a.resources);
group.sedentary += a.traits.sedentary;
group.farming += a.tech.farming || 0;
group.metallurgy += a.tech.metallurgy || 0;
group.techCount++;
group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1);
}
let foundedThisTick = 0;
const canFoundCities = this.year >= years(80) && this.year % years(10) === 0;
const foundingLimit = canFoundCities ? 1 + Math.floor(this.year / years(600)) : 0;
const candidateEntries = [...candidates].sort((a, b) => {
const sedentaryA = a[1].sedentary / Math.max(1, a[1].count);
const sedentaryB = b[1].sedentary / Math.max(1, b[1].count);
return (b[1].count * (0.7 + sedentaryB) + b[1].resources * 0.02) - (a[1].count * (0.7 + sedentaryA) + a[1].resources * 0.02);
});
for (const [i, group] of candidateEntries) {
if (group.count < 3) continue;
const avgSedentary = group.sedentary / group.count;
let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), 6)[0] || null;
if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) {
const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98);
if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue;
city = this.createCity(i % w.size, Math.floor(i / w.size), group);
this.cities.push(city);
this.cityById.set(city.id, city);
const bucketKey = this.cityBucketKey(city.x, city.y);
if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []);
this.cityBuckets.get(bucketKey).push(city);
foundedThisTick++;
}
if (city) {
city.activeVisitors += group.count;
city.storedResources += group.resources * 0.08;
city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02;
city.knowledge ??= { farming: 0, metallurgy: 0 };
city.knowledge.farming = Math.max(city.knowledge.farming * 0.998, group.farming / Math.max(1, group.techCount));
city.knowledge.metallurgy = Math.max(city.knowledge.metallurgy * 0.998, group.metallurgy / Math.max(1, group.techCount));
const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1);
for (const [id, count] of group.ethnicities) {
const urbanCount = this.weightedUrbanContribution(count * 0.2, urbanWeight);
if (urbanCount > 0) city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + urbanCount);
}
city.strength = city.strength * 0.96 + group.count * 0.05;
}
}
this.absorbUrbanPopulation();
this.rebuildOccupancy();
this.processCityEconomies();
this.cities = this.cities.filter(c => {
c.age++;
c.strength *= 0.992;
const foodPerCapita = c.storedResources / Math.max(1, c.population);
if (c.age > 20) {
if (c.activeVisitors < 2 && foodPerCapita < 0.04) c.strength -= 0.035;
else if (c.activeVisitors < 5 && foodPerCapita < 0.02) c.strength -= 0.015;
if (c.population < 25 && foodPerCapita < 0.05) c.strength -= 0.018;
}
if (c.population <= 0 || c.strength <= 0.06) return false;
const radius = c.agriculturalRadius;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = clamp(c.x + dx, 0, w.size - 1);
const y = clamp(c.y + dy, 0, w.size - 1);
const d = Math.abs(dx) + Math.abs(dy);
if (d <= radius) {
const tile = w.idx(x, y);
w.city[tile] = c.id;
if (w.terrain[tile] !== Terrain.WATER) {
w.farmland[tile] = Math.max(w.farmland[tile], (radius - d + 1) / (radius + 1));
w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1));
}
}
}
}
return true;
});
this.rebuildIndexes();
}
createCity(x, y, seedGroup = null) {
const seedPopulation = seedGroup ? Math.max(14, seedGroup.count * 5) : 10;
const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5;
const composition = new Map();
if (seedGroup) {
const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1);
for (const [id, count] of seedGroup.ethnicities) {
const urbanCount = this.weightedUrbanContribution(count * 3, urbanWeight);
if (urbanCount > 0) composition.set(id, urbanCount);
}
}
return {
id: this.nextCity++,
x,
y,
population: seedPopulation,
storedResources: 34 + (seedGroup?.resources || 0) * 0.55,
ethnicityComposition: composition,
pheromoneOutput: 0,
agriculturalRadius: 2,
tradeLinks: new Set(),
activeVisitors: 0,
age: 0,
strength: 3,
sedentaryCulture: seedSedentary,
knowledge: { farming: 0, metallurgy: 0 },
supplyStress: 0,
polityId: null,
loyalty: 0.5,
receivedAid: false,
tradeValue: 0,
tradeReach: 0
};
}
absorbUrbanPopulation() {
if (!this.cities.length) return;
const absorbed = [];
for (const a of this.agents) {
if (!a.alive || a.settled < 3) {
absorbed.push(a);
continue;
}
const city = this.findCityNear(a.x, a.y, 9);
const sedentary = a.traits.sedentary;
const nomadism = this.agentNomadism(a);
const nomadRetention = 1 - nomadism * 0.85;
const absorbChance = clamp((0.08 + sedentary * 1.05) * clamp(nomadRetention, 0.10, 1), 0.03, 0.92);
if (!city || this.rng.next() > absorbChance) {
absorbed.push(a);
continue;
}
const migrants = 1 + Math.floor(Math.min(10, a.resources / 7));
const urbanWeight = clamp(0.12 + (sedentary - 0.10) * 1.55, 0.12, 1);
city.population += migrants;
city.storedResources += Math.max(0, a.resources) * 0.65;
const urbanCount = this.weightedUrbanContribution(migrants, urbanWeight);
if (urbanCount > 0) city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + urbanCount);
city.sedentaryCulture = city.sedentaryCulture * 0.985 + sedentary * 0.015;
city.strength += 0.04;
}
this.agents = absorbed;
}
weightedUrbanContribution(amount, weight) {
const value = amount * weight;
const whole = Math.floor(value);
return whole + (this.rng.next() < value - whole ? 1 : 0);
}
processCityEconomies() {
const w = this.world;
for (const city of this.cities) {
city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14);
city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8);
city.knowledge ??= { farming: 0, metallurgy: 0 };
let harvested = 0;
const radius = city.agriculturalRadius;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (Math.abs(dx) + Math.abs(dy) > radius) continue;
const x = clamp(city.x + dx, 0, w.size - 1);
const y = clamp(city.y + dy, 0, w.size - 1);
const i = w.idx(x, y);
if (w.terrain[i] === Terrain.WATER) continue;
const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1);
const farmingYield = 1 + city.knowledge.farming * 0.85;
const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35;
const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull);
w.resource[i] -= extraction;
w.farmland[i] = Math.max(w.farmland[i], pull);
this.addPheromone(i, city.pheromoneOutput * pull * 0.09);
harvested += extraction;
}
}
city.storedResources += harvested;
const trade = this.cityTradeProfile(city);
const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population)) * 0.018);
city.storedResources += tradeIncome;
const supportRatio = city.activeVisitors / Math.max(1, city.population);
const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012);
const tradeRelief = clamp(trade.value * 0.09, 0, 0.32);
const upkeep = (city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09)) * (1 - tradeRelief) + knowledgeMaintenance;
city.storedResources -= upkeep;
const foodPerCapita = city.storedResources / Math.max(1, city.population);
city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8 - trade.value * 0.12, 0, 1.8);
this.innovateCityKnowledge(city, harvested);
if (trade.value > 0) {
city.knowledge.farming = clamp(city.knowledge.farming + trade.value * 0.00018, 0, 1);
city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + trade.value * 0.00024, 0, 1);
city.strength += clamp(trade.value * 0.018, 0, 0.08);
city.tradeValue = trade.value;
city.tradeReach = trade.reach;
} else {
city.tradeValue = 0;
city.tradeReach = 0;
}
if (city.storedResources > city.population * 0.16 && city.population > 0) {
const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8);
const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015)));
city.population += births;
city.storedResources -= births * 0.55;
addBirthsToComposition(city.ethnicityComposition, births, this.rng);
}
if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) {
const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010)));
city.population -= attrition;
removeFromComposition(city.ethnicityComposition, attrition);
city.strength -= city.activeVisitors === 0 ? 0.030 : 0.012;
}
if (city.storedResources < 0) {
const deficit = Math.abs(city.storedResources);
const dominant = dominantComposition(city.ethnicityComposition);
const loss = Math.min(city.population, Math.ceil(deficit * 2.2 + city.population * 0.035));
city.population -= loss;
city.storedResources = 0;
removeFromComposition(city.ethnicityComposition, loss);
city.strength -= Math.min(0.4, 0.03 + deficit * 0.01);
if (loss > 0 && this.agents.length < this.maxAgents) {
this.spawnUrbanRefugees(city, Math.min(24, Math.max(2, Math.ceil(loss / 8))), dominant);
}
}
city.population = Math.max(0, Math.floor(city.population));
city.storedResources = clamp(city.storedResources, 0, Math.max(30, city.population * 1.4));
}
}
cityTradeProfile(city) {
if (!city?.tradeLinks?.size || !this.tradeLinks.length) return { value: 0, reach: 0 };
let value = 0;
let reach = 0;
for (const link of this.tradeLinks) {
if (link.from !== city.id && link.to !== city.id) continue;
const other = this.getCityById(link.from === city.id ? link.to : link.from);
if (!other) continue;
const distance = this.distanceBetweenCities(city, other);
const distanceFactor = clamp((distance - 14) / 30, 0, 1);
if (distanceFactor <= 0) continue;
const partnerScale = clamp(Math.sqrt(Math.max(1, other.population || 1)) / 24, 0.35, 2.4);
const pathScale = clamp((link.path?.length || distance) / 30, 0.5, 1.6);
value += (link.strength || 0.12) * distanceFactor * partnerScale * pathScale;
reach = Math.max(reach, distance);
}
return {
value: clamp(value, 0, 3.2),
reach
};
}
innovateCityKnowledge(city, harvested) {
city.knowledge ??= { farming: 0, metallurgy: 0 };
const scale = SimConfig.technology.cityInnovation;
const density = clamp(Math.sqrt(city.population) / 20, 0, 1.6);
const foodSurplus = clamp(city.storedResources / Math.max(1, city.population) - 0.08, 0, 0.5);
city.knowledge.farming = clamp(
city.knowledge.farming + scale * density * (0.4 + foodSurplus * 4) + harvested * 0.000015,
0,
1
);
city.knowledge.metallurgy = clamp(
city.knowledge.metallurgy + scale * density * clamp(city.pheromoneOutput, 0.1, 1.8) * 0.35,
0,
1
);
if (city.supplyStress > 0.8) {
city.knowledge.farming *= 0.998;
city.knowledge.metallurgy *= 0.997;
}
}
spawnUrbanRefugees(city, count, ethnicity = null) {
const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || 1;
const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits();
for (let i = 0; i < count; i++) {
this.agents.push(this.makeAgent(
clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1),
clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1),
dominant,
mutateTraits(template, this.rng, 0.05),
this.rng.range(4, 11)
));
}
}
findCityNear(x, y, radius) {
let best = null;
let bestDistance = Infinity;
for (const c of this.getCitiesNear(x, y, radius)) {
const d = Math.abs(c.x - x) + Math.abs(c.y - y);
if (d <= radius && d < bestDistance) {
best = c;
bestDistance = d;
}
}
return best;
}
cityInfluence(city) {
if (!city || city.population <= 0 || city.strength <= 0) return 0;
const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35);
return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor;
}
getCityById(id) {
const cached = this.cityById?.get(id);
if (cached) return cached;
const city = this.cities.find(c => c.id === id) || null;
if (city) this.cityById.set(id, city);
return city;
}
getPolityById(id) {
const cached = this.polityById?.get(id);
if (cached) return cached;
const polity = this.polities.find(p => p.id === id) || null;
if (polity) this.polityById.set(id, polity);
return polity;
}
getPolityCities(polity) {
const living = [];
for (const id of [...polity.cityIds]) {
const city = this.getCityById(id);
if (!city || city.population <= 0) {
polity.cityIds.delete(id);
} else {
living.push(city);
}
}
return living;
}
dominantCityEthnicity(city) {
if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null;
return dominantComposition(city.ethnicityComposition) || null;
}
sameDominantEthnicity(cityA, cityB) {
const a = this.dominantCityEthnicity(cityA);
const b = this.dominantCityEthnicity(cityB);
return a !== null && b !== null && a === b;
}
distanceBetweenCities(cityA, cityB) {
return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y);
}
hasDirectTradeConnection(cityA, cityB) {
if (!cityA || !cityB) return false;
if (cityA.tradeLinks?.has(cityB.id) || cityB.tradeLinks?.has(cityA.id)) return true;
return (this.tradeLinks || []).some(link => {
const endpoints = this.tradeLinkEndpointIds(link);
return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id);
});
}
tradeLinkEndpointId(value) {
return typeof value === "object" ? value?.id ?? null : value ?? null;
}
tradeLinkEndpointIds(link) {
const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityAId ?? link.aId ?? link.sourceId ?? link.source ?? link.a ?? link.cityA);
const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB);
return from != null && to != null ? [from, to] : null;
}
polityTradeNeighbors(city, polityId) {
if (!city || polityId == null) return [];
const neighbors = [];
for (const link of this.tradeLinks || []) {
const endpoints = this.tradeLinkEndpointIds(link);
if (!endpoints) continue;
const [from, to] = endpoints;
const otherId = from === city.id ? to : to === city.id ? from : null;
if (otherId == null) continue;
const other = this.getCityById(otherId);
if (other?.polityId === polityId) neighbors.push(other);
}
return neighbors;
}
tradeAccessToCenter(city, center, polityId) {
if (!city || !center || polityId == null) return { reachable: false, hops: Infinity, transitCities: [] };
if (city.id === center.id) return { reachable: true, hops: 0, transitCities: [] };
if (this.hasDirectTradeConnection(city, center)) return { reachable: true, hops: 1, transitCities: [] };
const maxDepth = SimConfig.polityAccess?.maxSearchDepth ?? 8;
const visited = new Set([city.id]);
const queue = [{ city, hops: 0, transitCities: [] }];
while (queue.length) {
const current = queue.shift();
if (current.hops >= maxDepth) continue;
for (const neighbor of this.polityTradeNeighbors(current.city, polityId)) {
if (visited.has(neighbor.id)) continue;
const hops = current.hops + 1;
if (neighbor.id === center.id) return { reachable: true, hops, transitCities: current.transitCities };
visited.add(neighbor.id);
queue.push({ city: neighbor, hops, transitCities: [...current.transitCities, neighbor] });
}
}
return { reachable: false, hops: Infinity, transitCities: [] };
}
tradeAccessLoyaltyPenalty(city, center, polity) {
const cfg = SimConfig.polityAccess;
if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0;
const access = this.tradeAccessToCenter(city, center, polity.id);
if (!access.reachable) return clamp(cfg.noAccessPenalty, 0, 0.07);
let penalty = cfg.indirectPenalty + Math.max(0, access.hops - 1) * cfg.perHopPenalty;
const originEthnicity = this.dominantCityEthnicity(city);
for (const transitCity of access.transitCities) {
if ((transitCity.loyalty ?? 0.5) < 0.35) penalty += cfg.lowLoyaltyTransitPenalty;
const transitEthnicity = this.dominantCityEthnicity(transitCity);
if (originEthnicity !== null && transitEthnicity !== null && transitEthnicity !== originEthnicity) {
penalty += cfg.foreignTransitPenalty;
}
}
return clamp(penalty, 0, 0.07);
}
effectiveDistance(cityA, cityB) {
let distance = this.distanceBetweenCities(cityA, cityB);
if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55;
return distance;
}
createPolity(centerCity) {
const id = this.nextPolity++;
const polity = {
id,
centerCityId: centerCity.id,
cityIds: new Set([centerCity.id]),
treasury: Math.max(0, centerCity.storedResources * 0.12),
color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62),
founded: this.year,
legitimacy: this.rng.range(0.68, 0.94),
cohesion: this.rng.range(0.58, 0.9),
charisma: this.rng.range(0.5, 1.5),
leaderStarted: this.year,
leaderTenureYears: this.rng.range(24, 68),
crisis: 0,
lastCrisisYear: this.year
};
centerCity.polityId = id;
centerCity.loyalty = 1;
centerCity.receivedAid = false;
this.polities.push(polity);
this.polityById.set(polity.id, polity);
this.ensurePolityHistory(polity);
this.samplePolityHistory(polity);
return polity;
}
ensurePolityHistory(polity) {
if (!polity) return null;
if (!this.polityHistory.has(polity.id)) {
this.polityHistory.set(polity.id, {
id: polity.id,
color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62),
founded: polity.founded ?? this.year,
ended: null,
active: true,
centerCityId: polity.centerCityId ?? null,
fate: null,
samples: [],
events: [],
peakPower: 0,
peakYear: null,
peakEventYear: null
});
}
const history = this.polityHistory.get(polity.id);
history.events ??= [];
history.peakPower ??= 0;
history.peakYear ??= null;
history.peakEventYear ??= null;
return history;
}
averagePolityLoyalty(polity) {
const cities = this.getPolityCities(polity);
const subordinates = cities.filter(city => city.id !== polity.centerCityId);
if (!subordinates.length) return 1;
return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length;
}
samplePolityHistory(polity) {
const history = this.ensurePolityHistory(polity);
if (!history) return;
const cities = this.getPolityCities(polity);
const population = cities.reduce((sum, city) => sum + city.population, 0);
const treasury = polity.treasury || 0;
const avgLoyalty = this.averagePolityLoyalty(polity);
const fallbackPower = Math.sqrt(population) * 1.35 +
Math.sqrt(Math.max(0, treasury)) * 1.15 +
avgLoyalty * 16;
history.centerCityId = polity.centerCityId ?? history.centerCityId;
const sample = {
year: this.year,
cities: cities.length,
population,
treasury,
avgLoyalty,
power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower
};
history.samples.push(sample);
if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) {
history.peakPower = sample.power;
history.peakYear = this.year;
}
const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears;
while (history.samples.length > maxSamples) history.samples.shift();
}
samplePolityHistories() {
for (const polity of this.polities) this.samplePolityHistory(polity);
}
addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) {
let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null;
if (!history) {
const polity = this.getPolityById(polityId);
if (polity) history = this.ensurePolityHistory(polity);
}
if (!history) return;
history.events ??= [];
const sameYearDuplicate = history.events.some(event =>
event.type === type &&
event.year === year &&
JSON.stringify(event.data || {}) === JSON.stringify(data || {})
);
if (sameYearDuplicate) return;
history.events.push({ year, type, importance, data });
while (history.events.length > 120) history.events.shift();
}
addGraphEvent(type, year = this.year, data = {}, importance = 2) {
this.graphEvents ??= [];
const duplicate = this.graphEvents.some(event =>
event.type === type &&
event.year === year &&
JSON.stringify(event.data || {}) === JSON.stringify(data || {})
);
if (duplicate) return;
this.graphEvents.push({ year, type, importance, data });
while (this.graphEvents.length > 160) this.graphEvents.shift();
}
selectNewLeader(polity, forced = false) {
if (!polity) return;
const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5);
const center = this.getCityById(polity.centerCityId);
const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5;
const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3;
const randomLeader = this.rng.range(0.5, 1.5);
const continuity = forced ? 0.18 : 0.34;
const institutionalPull = 0.82 + institutionalBias * 0.36;
const nextCharisma = clamp(
previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull,
0.5,
1.5
);
polity.charisma = nextCharisma;
polity.leaderStarted = this.year;
polity.leaderTenureYears = this.rng.range(22, 72);
const change = nextCharisma - previousCharisma;
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) + change * 0.08 - (forced ? 0.035 : 0), 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) + change * 0.045 - (forced ? 0.020 : 0), 0, 1);
if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5);
}
updatePolityLeaders() {
for (const polity of this.polities) {
polity.charisma = clamp(polity.charisma ?? 1, 0.5, 1.5);
polity.leaderStarted ??= polity.founded ?? this.year;
polity.leaderTenureYears ??= this.rng.range(24, 68);
const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR;
const oldLeader = Math.max(0, tenureYears - polity.leaderTenureYears);
const crisisPressure = clamp((polity.crisis || 0) * 0.045, 0, 0.09);
const successionChance = clamp(oldLeader * 0.018 + crisisPressure, 0, 0.36);
if (successionChance > 0 && this.rng.next() < successionChance) {
this.selectNewLeader(polity, (polity.crisis || 0) > 0.85);
}
}
}
maybeSpawnDisaster() {
const config = SimConfig.disaster;
const interval = years(config?.checkIntervalYears ?? 8);
if (!interval || this.year % interval !== 0) return;
if (this.rng.next() > (config?.baseChance ?? 0.38)) return;
this.spawnDisaster();
}
spawnDisaster() {
const config = SimConfig.disaster;
const large = this.rng.next() < (config?.rareLargeChance ?? 0.10);
const radius = large
? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54)
: this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34);
const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95);
this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity);
}
applyDisaster(x, y, radius, intensity) {
const config = SimConfig.disaster;
const w = this.world;
const scaledIntensity = intensity * (config?.damageScale ?? 1);
const majorLossRate = config?.majorLossRate ?? 0.05;
const affectedPolityMap = new Map();
const affectedPolityIds = new Set();
for (const city of this.cities) {
if (city.polityId === null) continue;
if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId);
}
const polityPopulationBefore = new Map();
for (const id of affectedPolityIds) {
const polity = this.getPolityById(id);
if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity));
}
let affectedAgents = 0;
for (const agent of this.agents) {
if (!agent.alive) continue;
const d = Math.hypot(agent.x - x, agent.y - y);
if (d > radius) continue;
const falloff = 1 - d / radius;
const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
if (this.rng.next() < damage * 0.32) {
if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent);
agent.alive = false;
this.deaths++;
affectedAgents++;
} else {
agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45));
if (agent.tech) {
agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035);
agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04);
}
}
}
let affectedCities = 0;
let totalPopulationLoss = 0;
for (const city of this.cities) {
const d = Math.hypot(city.x - x, city.y - y);
if (d > radius) continue;
const falloff = 1 - d / radius;
const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
if (damage <= 0) continue;
affectedCities++;
const popBefore = city.population || 0;
const lossRate = clamp(damage * 0.25, 0, 0.45);
const loss = Math.floor(popBefore * lossRate);
city.population = Math.max(0, popBefore - loss);
if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss);
city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55));
city.strength = Math.max(0, city.strength - damage * 0.38);
this.deaths += Math.floor(loss * 0.35);
totalPopulationLoss += loss;
if (city.polityId !== null) {
let entry = affectedPolityMap.get(city.polityId);
if (!entry) {
entry = {
polityId: city.polityId,
populationBefore: polityPopulationBefore.get(city.polityId) || 0,
populationLoss: 0,
cityIds: new Set()
};
affectedPolityMap.set(city.polityId, entry);
}
entry.populationLoss += loss;
entry.cityIds.add(city.id);
const polity = this.getPolityById(city.polityId);
if (polity) {
polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5);
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1);
}
}
}
const minX = Math.max(0, Math.floor(x - radius));
const maxX = Math.min(w.size - 1, Math.ceil(x + radius));
const minY = Math.max(0, Math.floor(y - radius));
const maxY = Math.min(w.size - 1, Math.ceil(y + radius));
for (let ty = minY; ty <= maxY; ty++) {
for (let tx = minX; tx <= maxX; tx++) {
const d = Math.hypot(tx - x, ty - y);
if (d > radius) continue;
const falloff = 1 - d / radius;
const damage = clamp(scaledIntensity * falloff * falloff, 0, 1);
const tile = w.idx(tx, ty);
w.resource[tile] *= (1 - damage * 0.45);
w.farmland[tile] *= (1 - damage * 0.35);
w.pheromone[tile] *= (1 - damage * 0.25);
}
}
const affectedPolities = [...affectedPolityMap.values()].map(entry => {
const populationBefore = Math.max(1, entry.populationBefore || 0);
const lossRate = entry.populationLoss / populationBefore;
return {
polityId: entry.polityId,
populationBefore: entry.populationBefore,
populationLoss: entry.populationLoss,
lossRate,
cityIds: [...entry.cityIds]
};
});
const id = this.nextDisaster++;
const record = {
id,
year: this.year,
x,
y,
radius,
intensity,
expires: this.year + years(config?.visualDurationYears ?? 18),
affectedAgents,
affectedCities,
totalPopulationLoss,
affectedPolities
};
this.disasters.push(record);
this.disasterHistory.push(record);
for (const entry of affectedPolities) {
if (entry.lossRate >= majorLossRate) {
this.addPolityEvent(entry.polityId, "disaster", this.year, {
disasterId: id,
populationLoss: entry.populationLoss,
lossRate: entry.lossRate,
affectedCities: entry.cityIds.length
}, entry.lossRate >= majorLossRate * 2 ? 3 : 2);
}
}
this.disasters = this.disasters
.filter(disaster => this.year <= disaster.expires)
.slice(-(config?.maxActiveVisuals ?? 12));
while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift();
}
markPolityEnded(polity, reason = "dissolved") {
if (!polity) return;
const history = this.ensurePolityHistory(polity);
if (!history) return;
this.samplePolityHistory(polity);
history.ended = this.year;
history.active = false;
history.fate = reason;
history.centerCityId = polity.centerCityId ?? history.centerCityId;
this.polityHistory.delete(polity.id);
this.deadPolityHistories.push(history);
while (this.deadPolityHistories.length > 80) this.deadPolityHistories.shift();
}
getAllPolityHistories() {
return [...this.deadPolityHistories, ...this.polityHistory.values()]
.sort((a, b) => (a.founded - b.founded) || (a.id - b.id));
}
addCityToPolity(city, polity, initialLoyalty = 0.5) {
if (!city || !polity) return;
if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city);
city.polityId = polity.id;
city.loyalty = clamp(initialLoyalty, 0, 1);
city.receivedAid = false;
polity.cityIds.add(city.id);
}
removeCityFromPolity(city) {
if (!city || city.polityId === null) return;
const oldPolity = this.getPolityById(city.polityId);
if (oldPolity) oldPolity.cityIds.delete(city.id);
city.polityId = null;
city.loyalty = 0.45;
city.receivedAid = false;
}
isPolityAtWar(polityId) {
return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId));
}
warCountForPolity(polityId) {
return this.wars.filter(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)).length;
}
canBeWarTarget(polityId) {
return this.warCountForPolity(polityId) < 3;
}
getWarBetween(aId, bId) {
return this.wars.find(war =>
war.ended === null &&
((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId))
) || null;
}
startWar(a, b) {
if (!a || !b || a.id === b.id) return null;
if (this.getWarBetween(a.id, b.id)) return null;
if (this.isPolityAtWar(a.id) || !this.canBeWarTarget(b.id)) return null;
const id = this.nextWar++;
const war = {
id,
aPolityId: a.id,
bPolityId: b.id,
started: this.year,
lastActionYear: this.year,
intensity: this.rng.range(0.45, 1.05),
exhaustionA: 0,
exhaustionB: 0,
ended: null
};
this.wars.push(war);
a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5);
b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5);
return war;
}
endWar(war) {
if (!war || war.ended !== null) return;
war.ended = this.year;
}
totalPolityPopulation(polity) {
return this.getPolityCities(polity)
.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0);
}
averagePolityTechnology(polity) {
const cities = this.getPolityCities(polity);
if (!cities.length) return 0;
let total = 0;
let count = 0;
for (const city of cities) {
if (!city?.knowledge) continue;
total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5;
count++;
}
return count ? total / count : 0;
}
polityPower(polity) {
const cities = this.getPolityCities(polity);
if (!cities.length) return 0;
const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0);
const treasury = Math.max(0, polity.treasury || 0);
const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5;
const tech = this.averagePolityTechnology(polity);
const instability = this.polityInstability ? this.polityInstability(polity) : 0;
const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0;
const basePower = Math.max(0,
Math.sqrt(population) * 1.35 +
Math.sqrt(treasury) * 1.15 +
avgLoyalty * 16 +
tech * 24 -
instability * 12 -
agePressure * 6
);
return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5);
}
polityInfluenceRange(polity, wartime = false) {
const power = this.polityPower(polity);
const base = 18 + Math.sqrt(power) * 2.2;
const range = wartime ? base * 1.15 : base * 0.75;
return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42);
}
nearestPolityCity(polity, city) {
if (!polity || !city) return null;
let best = null;
let bestDistance = Infinity;
for (const source of this.getPolityCities(polity)) {
const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city);
if (distance < bestDistance) {
best = source;
bestDistance = distance;
}
}
return best;
}
polityConnectionToCity(polity, city) {
if (!polity || !city) return { source: null, distance: Infinity, connected: false };
let source = null;
let distance = Infinity;
for (const c of this.getPolityCities(polity)) {
if (c.id === city.id) continue;
const d = this.effectiveDistance(c, city);
if (d < distance) {
source = c;
distance = d;
}
}
const limit = SimConfig.polity.logisticsDistance;
return { source, distance, connected: !!source && distance <= limit };
}
polityDistance(a, b) {
const aCities = this.getPolityCities(a);
const bCities = this.getPolityCities(b);
if (!aCities.length || !bCities.length) return Infinity;
let best = Infinity;
for (const aCity of aCities) {
for (const bCity of bCities) {
const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity);
if (distance < best) best = distance;
}
}
return best;
}
polityAge(polity) {
return (this.year - (polity?.founded ?? this.year)) / WEEKS_PER_YEAR;
}
polityAgePressure(polity) {
const age = this.polityAge(polity);
if (age < 80) return 0;
const x = (age - 80) / 420;
return clamp(1 - Math.exp(-x), 0, 1.35);
}
polityOverextension(polity) {
const cities = this.getPolityCities(polity);
const center = this.getCityById(polity.centerCityId);
if (!center || cities.length <= 1) return 0;
const cityCountPressure = Math.max(0, cities.length - 5) * 0.08;
let distanceSum = 0;
let count = 0;
for (const city of cities) {
if (city.id === center.id) continue;
distanceSum += this.effectiveDistance(center, city);
count++;
}
const avgDistance = count ? distanceSum / count : 0;
const distancePressure = Math.max(0, avgDistance - 28) * 0.008;
return clamp(cityCountPressure + distancePressure, 0, 2);
}
polityResourceStress(polity) {
const cities = this.getPolityCities(polity);
if (!cities.length) return 1;
let poor = 0;
let supplyStress = 0;
for (const city of cities) {
const perCapita = city.storedResources / Math.max(1, city.population);
if (perCapita < 0.06) poor++;
supplyStress += city.supplyStress || 0;
}
const povertyRate = poor / cities.length;
const avgSupplyStress = supplyStress / cities.length;
const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length);
const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0;
return clamp(povertyRate * 0.55 + avgSupplyStress * 0.35 + treasuryStress * 0.28, 0, 1.5);
}
polityLogisticsStress(polity) {
const cities = this.getPolityCities(polity);
const center = this.getCityById(polity.centerCityId);
if (!center || cities.length <= 1) return 0;
let stress = 0;
let count = 0;
for (const city of cities) {
if (city.id === center.id) continue;
const distance = this.effectiveDistance(center, city);
const food = city.storedResources / Math.max(1, city.population);
const distanceStress = Math.max(0, distance - SimConfig.polity.logisticsDistance) * 0.012;
const foodStress = Math.max(0, SimConfig.polity.minimumPerCapitaFood - food) * 4.5;
const routeRelief = this.hasDirectTradeConnection(center, city) ? 0.72 : 1;
stress += (distanceStress + foodStress + (city.supplyStress || 0) * 0.35) * routeRelief;
count++;
}
return clamp(stress / Math.max(1, count), 0, 1.8);
}
polityEthnicFragmentation(polity) {
const cities = this.getPolityCities(polity);
if (cities.length <= 1) return 0;
const counts = new Map();
for (const city of cities) {
const e = this.dominantCityEthnicity(city);
if (e !== null) counts.set(e, (counts.get(e) || 0) + 1);
}
if (!counts.size) return 0;
let max = 0;
for (const v of counts.values()) max = Math.max(max, v);
const dominantShare = max / cities.length;
return clamp(1 - dominantShare, 0, 1);
}
polityInstability(polity) {
const agePressure = this.polityAgePressure(polity);
const overextension = this.polityOverextension(polity);
const resourceStress = this.polityResourceStress(polity);
const logisticsStress = this.polityLogisticsStress(polity);
const fragmentation = this.polityEthnicFragmentation(polity);
const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85;
const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.62;
return clamp(
agePressure * 0.35 +
overextension * 0.24 +
resourceStress * 0.38 +
logisticsStress * 0.30 +
fragmentation * 0.22 +
(polity.crisis || 0) * 0.42 -
legitimacyBuffer -
cohesionBuffer,
0,
2.5
);
}
foundPolities() {
for (const city of this.cities) {
if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue;
const centerInfluence = this.cityInfluence(city);
let absorbed = 0;
for (const other of this.cities) {
if (absorbed >= 3) break;
if (other === city || other.polityId !== null) continue;
if (this.distanceBetweenCities(city, other) > 34) continue;
const targetInfluence = this.cityInfluence(other);
if (centerInfluence <= targetInfluence * 1.08) continue;
const proximity = 1 / (1 + this.distanceBetweenCities(city, other) * 0.08);
const routeBonus = this.hasDirectTradeConnection(city, other) ? 1.5 : 1.0;
const dominanceScore = (centerInfluence / (targetInfluence + 1)) * proximity * routeBonus;
if (dominanceScore > 0.68 && this.rng.next() < 0.32) {
const polity = city.polityId === null ? this.createPolity(city) : this.getPolityById(city.polityId);
const connection = this.polityConnectionToCity(polity, other);
if (!connection.connected) continue;
this.addCityToPolity(other, polity, 0.68);
absorbed++;
}
}
}
}
expandPolities() {
for (const polity of this.polities) {
const center = this.getCityById(polity.centerCityId);
if (!center) continue;
const centerInfluence = this.cityInfluence(center);
let absorbed = 0;
for (const city of this.cities) {
if (absorbed >= 2) break;
if (city.polityId !== null || city.id === center.id) continue;
const connection = this.polityConnectionToCity(polity, city);
if (!connection.connected) continue;
const distance = connection.distance;
const targetInfluence = this.cityInfluence(city);
if (centerInfluence <= targetInfluence * 0.9) continue;
const dominanceScore =
(centerInfluence / (targetInfluence + 1)) *
(1 / (1 + distance * 0.08)) *
(this.hasDirectTradeConnection(connection.source, city) ? 1.5 : 1.0);
if (dominanceScore > 0.62 && this.rng.next() < 0.24) {
this.addCityToPolity(city, polity, 0.62);
absorbed++;
}
}
}
}
absorbIndependentCities() {
if (this.year % years(5) !== 0) return;
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
if (!cities.length) continue;
const power = this.polityPower(polity);
const range = this.polityInfluenceRange(polity, false);
let absorbed = 0;
const candidates = this.cities
.filter(city => city.polityId === null && city.population > 0)
.map(city => ({ city, connection: this.polityConnectionToCity(polity, city) }))
.filter(x => x.connection.connected && x.connection.distance <= range)
.sort((a, b) => a.connection.distance - b.connection.distance);
for (const { city, connection } of candidates) {
if (absorbed >= 1) break;
const distance = connection.distance;
const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1);
const proximity = 1 / (1 + distance * 0.07);
const pressure = (power / Math.max(1, cityInfluence)) * proximity;
if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) {
this.addCityToPolity(city, polity, 0.42);
city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99)));
absorbed++;
}
}
}
}
maybeStartWars() {
if (this.year % years(1) !== 0) return;
for (const a of this.polities) {
if (this.isPolityAtWar(a.id)) continue;
const aPower = this.polityPower(a);
if (aPower <= 0) continue;
let started = false;
for (const b of this.polities) {
if (started) break;
if (a.id === b.id) continue;
if (!this.canBeWarTarget(b.id)) continue;
if (this.getWarBetween(a.id, b.id)) continue;
const distance = this.polityDistance(a, b);
if (distance > 46) continue;
const bPower = this.polityPower(b);
if (bPower <= 0) continue;
const larger = Math.max(aPower, bPower);
const smaller = Math.min(aPower, bPower);
const advantage = larger / Math.max(1, smaller);
const proximity = clamp((46 - distance) / 46, 0, 1);
const aInstability = this.polityInstability ? this.polityInstability(a) : 0;
const bInstability = this.polityInstability ? this.polityInstability(b) : 0;
const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0;
const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0;
const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0;
const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0;
const aLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(a) : 0.5;
const bLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5;
const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1);
const weakSideInstability = aPower > bPower ? bInstability : aInstability;
const weakSideAge = aPower > bPower ? bAge : aAge;
const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension;
const weakSideLoyalty = aPower > bPower ? bLoyalty : aLoyalty;
const lowLoyaltyPressure = clamp(1 - Math.min(aLoyalty, bLoyalty), 0, 1);
const weakSideLowLoyalty = clamp(1 - weakSideLoyalty, 0, 1);
const generalInstability = Math.max(aInstability, bInstability) * 0.035;
const targetWarCount = this.warCountForPolity(b.id);
const targetLowLoyalty = clamp(1 - bLoyalty, 0, 1);
const attackerAdvantage = clamp((aPower / Math.max(1, bPower) - 1) / 2.25, 0, 1);
const opportunisticPressure = targetWarCount > 0
? clamp(targetWarCount / 3, 0, 1) * (
0.010 +
bInstability * 0.018 +
targetLowLoyalty * 0.018 +
attackerAdvantage * 0.020
)
: 0;
const borderFriction = this.borderFriction(a, b, distance);
const chance =
0.001 +
proximity * 0.007 +
asymmetryPressure * 0.008 +
weakSideInstability * 0.018 +
weakSideAge * 0.010 +
weakSideOverextension * 0.010 +
borderFriction * 0.008 +
generalInstability +
lowLoyaltyPressure * 0.018 +
weakSideLowLoyalty * 0.028 +
opportunisticPressure;
if (this.rng.next() < clamp(chance, 0, 0.12)) {
this.startWar(a, b);
started = true;
}
}
}
}
borderFriction(a, b, distance) {
if (!Number.isFinite(distance)) return 0;
const proximity = clamp((34 - distance) / 34, 0, 1);
const aCities = this.getPolityCities(a).length;
const bCities = this.getPolityCities(b).length;
const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1);
return proximity * (0.35 + sizePressure * 0.65);
}
updateWars() {
if (this.year % years(2) !== 0) return;
for (const war of this.wars) {
if (war.ended !== null) continue;
const a = this.getPolityById(war.aPolityId);
const b = this.getPolityById(war.bPolityId);
if (!a || !b) {
this.endWar(war);
continue;
}
this.applyWarPressure(war, a, b);
this.applyWarPressure(war, b, a);
this.applyWarExhaustion(war, a, b);
this.maybeEndWar(war);
}
this.wars = this.wars.filter(w => w.ended === null);
}
applyWarPressure(war, attacker, defender) {
const attackerCities = this.getPolityCities(attacker);
const defenderCities = this.getPolityCities(defender);
if (!attackerCities.length || !defenderCities.length) return;
const attackerPower = this.polityPower(attacker);
const defenderPower = this.polityPower(defender);
if (attackerPower <= defenderPower * 1.05) return;
const range = this.polityInfluenceRange(attacker, true);
const candidates = defenderCities
.filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2)
.map(city => ({ city, connection: this.polityConnectionToCity(attacker, city) }))
.filter(x => x.connection.connected && x.connection.distance <= range)
.sort((a, b) => a.connection.distance - b.connection.distance);
if (!candidates.length) return;
const topCandidates = candidates.slice(0, 3);
const selected = topCandidates[this.rng.int(topCandidates.length)];
const pressure = this.warAbsorptionPressure(
attacker,
defender,
selected.city,
selected.connection.distance,
attackerPower,
defenderPower
);
const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45);
if (pressure > 0.95 && this.rng.next() < chance) {
this.captureCityInWar(selected.city, attacker, defender, war, pressure);
} else {
selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1);
}
}
warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) {
const powerRatio = attackerPower / Math.max(1, defenderPower);
const proximity = 1 / (1 + distance * 0.055);
const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0;
const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0;
const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0;
const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1);
const techAdvantage = 1 + clamp(
this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender),
-0.25,
0.45
);
const nearest = this.nearestPolityCity(attacker, city);
const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92;
const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0;
return (
powerRatio *
proximity *
techAdvantage *
ethnicityFactor *
tradeFactor *
(1 + defenderInstability * 0.32) *
(1 + defenderAge * 0.18) *
(1 + defenderOverextension * 0.14) *
(0.65 + loyaltyWeakness * 0.72)
);
}
captureCityInWar(city, attacker, defender, war, pressure) {
const lossRate = clamp(
0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025,
0.05,
0.22
);
const loss = Math.floor((city.population || 0) * lossRate);
if (loss > 0) {
city.population = Math.max(1, city.population - loss);
if (typeof removeFromComposition === "function" && city.ethnicityComposition) {
removeFromComposition(city.ethnicityComposition, loss);
}
this.deaths += Math.floor(loss * 0.35);
}
this.removeCityFromPolity(city);
this.addCityToPolity(city, attacker, 0.28);
city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36);
war.lastActionYear = this.year;
if (war.aPolityId === attacker.id) {
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1);
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1);
} else {
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1);
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1);
}
attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015);
attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5);
defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5);
defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1);
}
applyWarExhaustion(war, a, b) {
const intensity = war.intensity || 0.75;
const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035;
const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035;
a.treasury = Math.max(0, (a.treasury || 0) - costA);
b.treasury = Math.max(0, (b.treasury || 0) - costB);
a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5);
b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5);
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1);
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1);
}
maybeEndWar(war) {
const a = this.getPolityById(war.aPolityId);
const b = this.getPolityById(war.bPolityId);
if (!a || !b) {
this.endWar(war);
return;
}
const age = this.year - war.started;
const noActionFor = this.year - war.lastActionYear;
const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0);
if (age > years(90)) {
this.endWar(war);
return;
}
if (age > years(10) && noActionFor > years(18)) {
this.endWar(war);
return;
}
if (exhaustion > 0.85 && this.rng.next() < 0.35) {
this.endWar(war);
}
}
collectAndRedistributeResources() {
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
const centerId = polity.centerCityId;
for (const city of cities) city.receivedAid = false;
for (const city of cities) {
if (city.id === centerId) continue;
const tax = city.storedResources * 0.035;
city.storedResources -= tax;
polity.treasury += tax;
}
const agePressure = this.polityAgePressure(polity);
const overextension = this.polityOverextension(polity);
const adminCost =
cities.length * 0.24 +
overextension * 1.1 +
agePressure * cities.length * 0.18;
polity.treasury -= adminCost;
if (polity.treasury < 0) {
const deficit = Math.abs(polity.treasury);
polity.treasury = 0;
polity.crisis = clamp((polity.crisis || 0) + deficit * 0.012, 0, 1.5);
}
const maintenance = Math.pow(cities.length, 1.12) * 0.10;
if (maintenance > 0) {
const center = this.getCityById(centerId);
const treasuryPayment = Math.min(polity.treasury, maintenance);
polity.treasury -= treasuryPayment;
const unpaid = maintenance - treasuryPayment;
if (unpaid > 0 && center) {
const paidByCenter = this.drainCityResources(center, unpaid);
if (paidByCenter < unpaid) {
for (const city of cities) {
if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.012, 0, 1);
}
}
}
}
const poorCount = Math.ceil(cities.length * 0.1);
const poorest = [...cities]
.sort((a, b) => (a.storedResources / Math.max(1, a.population)) - (b.storedResources / Math.max(1, b.population)))
.slice(0, poorCount);
for (const city of poorest) {
const target = city.population * 0.08;
const need = target - city.storedResources;
if (need > 0 && polity.treasury > 0) {
const aid = Math.min(need, polity.treasury);
city.storedResources += aid;
polity.treasury -= aid;
city.receivedAid = true;
city.loyalty = clamp(city.loyalty + 0.05, 0, 1);
}
}
}
}
erodePolityLegitimacy() {
for (const polity of this.polities) {
const agePressure = this.polityAgePressure(polity);
const resourceStress = this.polityResourceStress(polity);
const overextension = this.polityOverextension(polity);
const erosion =
0.0015 +
agePressure * 0.003 +
resourceStress * 0.003 +
overextension * 0.0015;
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - erosion, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - erosion * 0.45, 0, 1);
const cities = this.getPolityCities(polity);
if (this.polityAge(polity) < 80 && polity.treasury > cities.length * 10) {
polity.legitimacy = clamp(polity.legitimacy + 0.008, 0, 1);
polity.cohesion = clamp(polity.cohesion + 0.005, 0, 1);
}
polity.crisis = clamp((polity.crisis || 0) * 0.88, 0, 1.5);
}
}
triggerPolityCrises() {
for (const polity of this.polities) {
const age = this.polityAge(polity);
if (age < 140) continue;
if ((this.year - (polity.lastCrisisYear ?? 0)) / WEEKS_PER_YEAR < 160) continue;
const instability = this.polityInstability(polity);
const agePressure = this.polityAgePressure(polity);
const chance = 0.006 + agePressure * 0.018 + instability * 0.014;
if (this.rng.next() >= chance) continue;
polity.lastCrisisYear = this.year;
const severity = clamp(
0.08 +
agePressure * this.rng.range(0.08, 0.20) +
instability * this.rng.range(0.05, 0.16),
0.05,
0.36
);
polity.crisis = clamp((polity.crisis || 0) + severity, 0, 1.5);
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - severity * 0.30, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - severity * 0.18, 0, 1);
const center = this.getCityById(polity.centerCityId);
for (const city of this.getPolityCities(polity)) {
if (city.id === polity.centerCityId) continue;
const distance = center ? this.effectiveDistance(center, city) : 40;
const distanceFactor = clamp(distance / 40, 0.25, 1);
city.loyalty = clamp(city.loyalty - severity * distanceFactor, 0, 1);
}
}
}
applyOldStateStress() {
for (const polity of this.polities) {
const age = this.polityAge(polity);
if (age < 1000) continue;
const cities = this.getPolityCities(polity);
if (cities.length <= 1) continue;
const oldAge = clamp((age - 1000) / 1000, 0, 1);
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - oldAge * 0.018, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - oldAge * 0.012, 0, 1);
polity.crisis = clamp((polity.crisis || 0) + oldAge * 0.035, 0, 1.5);
const center = this.getCityById(polity.centerCityId);
for (const city of cities) {
if (city.id === polity.centerCityId) continue;
const distance = center ? this.effectiveDistance(center, city) : 40;
const distanceFactor = clamp(distance / 35, 0.2, 1);
city.loyalty = clamp(city.loyalty - oldAge * distanceFactor * 0.045, 0, 1);
}
}
}
updateCityLoyalty() {
for (const polity of this.polities) {
const center = this.getCityById(polity.centerCityId);
if (!center) continue;
center.loyalty = 1;
const instability = this.polityInstability(polity);
const agePressure = this.polityAgePressure(polity);
const overextension = this.polityOverextension(polity);
const logisticsStress = this.polityLogisticsStress(polity);
for (const city of this.getPolityCities(polity)) {
if (city.id === center.id) continue;
const perCapita = city.storedResources / Math.max(1, city.population);
const distance = this.effectiveDistance(city, center);
let delta = 0;
delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045);
delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012;
delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045);
const directCenterTrade = this.hasDirectTradeConnection(city, center);
if (directCenterTrade) delta += 0.025;
else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity);
if (city.receivedAid) delta += 0.04;
delta -= 0.004;
if (city.storedResources < city.population * 0.05) delta -= 0.035;
delta -= (city.supplyStress || 0) * 0.018;
delta -= instability * 0.020;
delta -= agePressure * 0.010;
delta -= overextension * clamp(distance / 48, 0, 1) * 0.014;
delta -= logisticsStress * clamp(distance / 42, 0.25, 1) * 0.018;
if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.020;
if ((polity.crisis || 0) > 0.6) delta -= 0.014;
city.loyalty = clamp(city.loyalty + delta, 0, 1);
}
}
}
splitUnloyalCities() {
for (const polity of this.polities) {
const instability = this.polityInstability(polity);
const agePressure = this.polityAgePressure(polity);
const threshold = clamp(0.14 + instability * 0.05 + agePressure * 0.03, 0.14, 0.28);
for (const city of this.getPolityCities(polity)) {
if (city.id === polity.centerCityId || city.loyalty >= threshold) continue;
const chance =
(threshold - city.loyalty) * 0.28 +
instability * 0.012 +
agePressure * 0.010;
if (this.rng.next() < chance) {
const rebellionLoyalty = city.loyalty ?? 0;
this.removeCityFromPolity(city);
this.addPolityEvent(polity.id, "rebellion", this.year, {
cityId: city.id,
population: city.population || 0,
loyalty: rebellionLoyalty
}, 3);
}
}
}
}
enforcePolityContinuity(polity) {
const center = this.getCityById(polity.centerCityId);
const cities = this.getPolityCities(polity);
if (!center || cities.length <= 1) return cities;
const limit = SimConfig.polity.logisticsDistance;
const reachable = new Set([center.id]);
const frontier = [center];
while (frontier.length) {
const source = frontier.pop();
for (const city of cities) {
if (reachable.has(city.id)) continue;
if (this.effectiveDistance(source, city) > limit) continue;
reachable.add(city.id);
frontier.push(city);
}
}
const isolated = cities.filter(city => !reachable.has(city.id));
if (!isolated.length) return cities;
for (const city of isolated) {
this.removeCityFromPolity(city);
city.loyalty = clamp(city.loyalty - 0.12, 0, 1);
}
polity.cohesion = clamp((polity.cohesion ?? 0.6) - isolated.length * 0.035, 0, 1);
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - isolated.length * 0.02, 0, 1);
polity.crisis = clamp((polity.crisis || 0) + isolated.length * 0.025, 0, 1.5);
return this.getPolityCities(polity);
}
detectPolityFamines() {
const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06;
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
if (!cities.length) continue;
let poorCities = 0;
let totalFood = 0;
let totalPopulation = 0;
for (const city of cities) {
const population = Math.max(1, city.population || 0);
const perCapita = (city.storedResources || 0) / population;
if (perCapita < minimumFood) poorCities++;
totalFood += city.storedResources || 0;
totalPopulation += population;
}
const poorRate = poorCities / cities.length;
const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation);
const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035;
if (!famine) continue;
if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue;
this.addPolityEvent(polity.id, "famine", this.year, {
poorRate,
avgPerCapitaFood
}, poorRate > 0.65 ? 3 : 2);
polity.lastFamineYear = this.year;
}
}
cleanupPolities() {
const survivors = [];
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
if (!cities.length) {
this.markPolityEnded(polity, "collapsed");
continue;
}
let center = this.getCityById(polity.centerCityId);
if (!center || center.population <= 0) {
const oldCenterCityId = polity.centerCityId;
center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]);
polity.centerCityId = center.id;
center.loyalty = 1;
if (oldCenterCityId !== center.id) {
this.addPolityEvent(polity.id, "capitalShift", this.year, {
oldCenterCityId,
newCenterCityId: center.id
}, 2);
}
polity.treasury *= 0.5;
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1);
polity.crisis = clamp((polity.crisis || 0) + 0.35, 0, 1.5);
polity.lastCrisisYear = this.year;
for (const city of cities) {
if (city.id !== center.id) city.loyalty = clamp(city.loyalty - 0.18, 0, 1);
}
const history = this.ensurePolityHistory(polity);
if (history) history.centerCityId = center.id;
}
const connectedCities = this.enforcePolityContinuity(polity);
if (connectedCities.length !== cities.length) {
cities.length = 0;
cities.push(...connectedCities);
}
if (cities.length === 1) {
this.markPolityEnded(polity, "fragmented");
cities[0].polityId = null;
cities[0].loyalty = 0.45;
cities[0].receivedAid = false;
continue;
}
survivors.push(polity);
}
this.polities = survivors;
this.rebuildIndexes();
const validPolities = new Set(this.polities.map(p => p.id));
for (const city of this.cities) {
if (city.polityId !== null && !validPolities.has(city.polityId)) {
city.polityId = null;
city.loyalty = 0.45;
city.receivedAid = false;
}
}
}
updatePolities() {
if (this.year % years(1) !== 0) return;
this.cleanupPolities();
this.foundPolities();
this.expandPolities();
if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes();
this.collectAndRedistributeResources();
this.detectPolityFamines();
if (this.erodePolityLegitimacy) this.erodePolityLegitimacy();
if (this.triggerPolityCrises) this.triggerPolityCrises();
if (this.applyOldStateStress) this.applyOldStateStress();
if (this.updatePolityLeaders) this.updatePolityLeaders();
this.updateCityLoyalty();
this.absorbIndependentCities();
this.maybeStartWars();
this.updateWars();
this.splitUnloyalCities();
this.cleanupPolities();
if (this.samplePolityHistories) this.samplePolityHistories();
}
reinforcePolityTradeRoutes() {
for (const polity of this.polities) {
if (this.rng.next() > 0.48) continue;
const center = this.getCityById(polity.centerCityId);
if (!center) continue;
const candidates = this.getPolityCities(polity)
.filter(city => city.id !== center.id && !this.hasDirectTradeConnection(center, city))
.filter(city => this.distanceBetweenCities(center, city) <= 40)
.sort((a, b) => this.effectiveDistance(center, a) - this.effectiveDistance(center, b));
if (!candidates.length) continue;
const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))];
const path = this.findTerrainRoute(center.x, center.y, target.x, target.y);
if (!this.isValidRoutePath(path, target.x, target.y)) continue;
if (this.hasReservedRouteSegment(path, this.activeTradeRouteTiles || new Set())) continue;
const cities = this.getPolityCities(polity);
const roadCost = 8 + cities.length * 1.5 + this.polityOverextension(polity) * 3;
if ((polity.treasury || 0) < roadCost) {
polity.crisis = clamp((polity.crisis || 0) + 0.025, 0, 1.5);
continue;
}
polity.treasury -= roadCost;
if (!this.payRouteConstructionCost(center, target, path, polity)) continue;
this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id);
}
}
updateTradeRoutes() {
const w = this.world;
for (let i = 0; i < w.count; i++) {
const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2;
if (invalidTerrain) w.tradeRoute[i] = 0;
else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) {
const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
const decay = pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 1 : SimConfig.route.routeUnsupportedDecay;
w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - decay);
if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0;
}
}
this.tradeLinks = [];
for (const city of this.cities) city.tradeLinks.clear();
const pairCandidates = [];
const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400);
const sqrtPop = new Map(this.cities.map(city => [city.id, Math.sqrt(city.population || 1)]));
for (let a = 0; a < this.cities.length; a++) {
for (let b = a + 1; b < this.cities.length; b++) {
const c1 = this.cities[a];
const c2 = this.cities[b];
const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
if (d > SimConfig.route.maxRouteLength - 1) continue;
const distanceFactor = clamp((d - 14) / 30, 0, 1);
const marketScale = clamp(((sqrtPop.get(c1.id) || 1) + (sqrtPop.get(c2.id) || 1)) / 34, 0.45, 2.1);
const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0;
const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias;
pairCandidates.push({ c1, c2, d, distanceFactor, marketScale, preliminaryScore });
}
}
pairCandidates.sort((a, b) => b.preliminaryScore - a.preliminaryScore);
const candidates = [];
for (const pair of pairCandidates.slice(0, maxPairsToRoute)) {
const { c1, c2, distanceFactor, marketScale } = pair;
const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y);
if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
const strength = this.routeStrengthForPath(path);
const pheromoneSupport = this.routePheromoneSupport(path);
const ownerPolityId = c1.polityId !== null && c1.polityId === c2.polityId ? c1.polityId : null;
const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null;
if (strength < 0.08) continue;
if (ownerPolityId === null && pheromoneSupport < SimConfig.route.routePheromoneBuild) continue;
if (!this.canPayRouteConstructionCost(c1, c2, path, ownerPolity)) continue;
const tradeScore = strength * (1 + distanceFactor * 0.7 + pheromoneSupport * 0.75 + (ownerPolityId !== null ? 0.28 : 0)) * marketScale;
candidates.push({ c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId });
}
candidates.sort((a, b) => b.tradeScore - a.tradeScore);
const maxLinks = Math.max(1, Math.floor(this.cities.length / 2));
const supportedRoutes = new Set();
for (const candidate of candidates) {
if (this.tradeLinks.length >= maxLinks) break;
const { c1, c2, strength, path, pheromoneSupport, ownerPolityId } = candidate;
const c1Limit = c1.population > 90 ? 3 : 2;
const c2Limit = c2.population > 90 ? 3 : 2;
if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue;
if (this.hasReservedRouteSegment(path, supportedRoutes)) continue;
const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null;
if (!this.payRouteConstructionCost(c1, c2, path, ownerPolity)) continue;
const upkeepPaid = this.payRouteUpkeep(c1, c2, path, ownerPolity, pheromoneSupport);
if (!upkeepPaid && pheromoneSupport < SimConfig.route.routePheromoneMaintain) {
this.weakenRoutePath(path, SimConfig.route.routeUnsupportedDecay);
continue;
}
const routeBoost = upkeepPaid ? SimConfig.route.routeMaintainedBoost : Math.floor(SimConfig.route.routeMaintainedBoost * 0.45);
this.registerTradeLink(c1, c2, strength, path, supportedRoutes, routeBoost, 0, ownerPolityId);
this.exchangeCityResources(c1, c2, strength, path);
this.applyRouteConnectionBenefits(c1, c2, strength, pheromoneSupport, upkeepPaid);
}
for (let i = 0; i < w.count; i++) {
if (w.tradeRoute[i] && !supportedRoutes.has(i)) {
const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - (pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 2 : SimConfig.route.routeUnsupportedDecay));
if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0;
}
}
this.activeTradeRouteTiles = new Set();
for (let i = 0; i < w.count; i++) {
if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i);
}
this.diffuseCityKnowledgeThroughTrade();
}
registerTradeLink(cityA, cityB, strength, path, supportedRoutes = null, routeBoost = 18, pheromoneBoost = 0, ownerPolityId = null) {
cityA.tradeLinks.add(cityB.id);
cityB.tradeLinks.add(cityA.id);
this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path, ownerPolityId });
for (const tile of path) {
if (supportedRoutes) supportedRoutes.add(tile);
this.world.tradeRoute[tile] = Math.min(255, this.world.tradeRoute[tile] + routeBoost);
this.addPheromone(tile, pheromoneBoost);
}
}
diffuseCityKnowledgeThroughTrade() {
for (const link of this.tradeLinks) {
const a = this.getCityById(link.from);
const b = this.getCityById(link.to);
if (!a || !b) continue;
a.knowledge ??= { farming: 0, metallurgy: 0 };
b.knowledge ??= { farming: 0, metallurgy: 0 };
const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + link.strength), 0, 0.012);
const farmingDelta = (a.knowledge.farming - b.knowledge.farming) * rate;
const metallurgyDelta = (a.knowledge.metallurgy - b.knowledge.metallurgy) * rate;
a.knowledge.farming = clamp(a.knowledge.farming - farmingDelta, 0, 1);
b.knowledge.farming = clamp(b.knowledge.farming + farmingDelta, 0, 1);
a.knowledge.metallurgy = clamp(a.knowledge.metallurgy - metallurgyDelta, 0, 1);
b.knowledge.metallurgy = clamp(b.knowledge.metallurgy + metallurgyDelta, 0, 1);
}
}
findTerrainRoute(x1, y1, x2, y2) {
const w = this.world;
const path = [];
const visited = new Set();
let x = x1;
let y = y1;
const maxSteps = Math.min(SimConfig.route.maxRouteLength, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 24);
const directions = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1]
];
for (let step = 0; step < maxSteps; step++) {
const current = w.idx(x, y);
path.push(current);
visited.add(current);
if (x === x2 && y === y2) break;
let bestX = x;
let bestY = y;
let bestScore = Infinity;
for (const [dx, dy] of directions) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue;
const i = w.idx(nx, ny);
const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0);
const revisitCost = visited.has(i) ? 5 : 0;
const routeEase = w.tradeRoute[i] ? -2 : 0;
const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4);
const distance = Math.abs(nx - x2) + Math.abs(ny - y2);
const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase;
if (score < bestScore) {
bestScore = score;
bestX = nx;
bestY = ny;
}
}
if (bestX === x && bestY === y) break;
x = bestX;
y = bestY;
}
return path;
}
routeStrengthForPath(path) {
const w = this.world;
if (!path.length) return 0;
let route = 0;
let pheromone = 0;
let terrainEase = 0;
for (const i of path) {
route += w.tradeRoute[i] > 0 ? 1 : 0;
pheromone += clamp(w.pheromone[i] / 9, 0, 1);
terrainEase += 1 / Math.max(1, w.move[i]);
}
return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2;
}
hasReservedRouteSegment(path, reservedTiles) {
let previousReserved = false;
for (let p = 1; p < path.length - 1; p++) {
const reserved = reservedTiles.has(path[p]);
if (reserved && previousReserved) return true;
previousReserved = reserved;
}
return false;
}
routePheromoneSupport(path) {
const w = this.world;
if (!path.length) return 0;
let support = 0;
for (const i of path) support += clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
return support / path.length;
}
isValidRoutePath(path, targetX, targetY) {
const w = this.world;
if (!path.length) return false;
const last = path[path.length - 1];
if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false;
if (path.length > SimConfig.route.maxRouteLength) return false;
let totalCost = 0;
let hardTiles = 0;
const seen = new Set();
for (const tile of path) {
if (seen.has(tile)) return false;
seen.add(tile);
if (w.terrain[tile] === Terrain.WATER) return false;
totalCost += w.move[tile];
if (w.move[tile] > 1.8) hardTiles++;
}
return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12;
}
exchangeCityResources(a, b, strength, path) {
const delta = (a.storedResources - b.storedResources) * 0.018 * strength;
a.storedResources -= delta;
b.storedResources += delta;
const traffic = Math.min(0.8, strength * 0.26);
this.depositRoutePheromone(path, traffic);
}
routeUpkeepCost(path, pheromoneSupport) {
const pheromoneRelief = clamp(pheromoneSupport / Math.max(0.001, SimConfig.route.routePheromoneMaintain), 0, 1) * 0.55;
return path.length * SimConfig.route.routeUpkeepPerTile * (1 - pheromoneRelief);
}
payRouteUpkeep(cityA, cityB, path, polity = null, pheromoneSupport = 0) {
const cost = this.routeUpkeepCost(path, pheromoneSupport);
if (cost <= 0.001) return true;
let remaining = cost;
if (polity) {
const paid = Math.min(polity.treasury || 0, remaining * 0.72);
polity.treasury = Math.max(0, (polity.treasury || 0) - paid);
remaining -= paid;
}
const cityShare = remaining * 0.5;
remaining -= this.drainCityResources(cityA, cityShare);
remaining -= this.drainCityResources(cityB, cityShare);
if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining);
if (remaining > 0 && polity) {
const paid = Math.min(polity.treasury || 0, remaining);
polity.treasury = Math.max(0, (polity.treasury || 0) - paid);
remaining -= paid;
}
return remaining <= cost * 0.25;
}
weakenRoutePath(path, amount) {
for (const tile of path) {
this.world.tradeRoute[tile] = Math.max(0, this.world.tradeRoute[tile] - amount);
if (this.world.tradeRoute[tile] < SimConfig.route.routeWeakThreshold) this.world.tradeRoute[tile] = 0;
}
}
applyRouteConnectionBenefits(cityA, cityB, strength, pheromoneSupport, upkeepPaid) {
const reliability = clamp(strength * 0.65 + pheromoneSupport * 0.35, 0, 1) * (upkeepPaid ? 1 : 0.55);
if (reliability <= 0) return;
const tradeValue = reliability * 0.18;
cityA.tradeValue = (cityA.tradeValue || 0) + tradeValue;
cityB.tradeValue = (cityB.tradeValue || 0) + tradeValue;
cityA.tradeReach = Math.max(cityA.tradeReach || 0, reliability);
cityB.tradeReach = Math.max(cityB.tradeReach || 0, reliability);
const resourceBonus = reliability * 0.12;
cityA.storedResources += resourceBonus;
cityB.storedResources += resourceBonus;
if (cityA.polityId !== null && cityA.polityId === cityB.polityId) {
cityA.loyalty = clamp((cityA.loyalty ?? 0.5) + reliability * 0.002, 0, 1);
cityB.loyalty = clamp((cityB.loyalty ?? 0.5) + reliability * 0.002, 0, 1);
}
}
routeConstructionCost(path, polityBacked = false) {
let weakTiles = 0;
for (const tile of path) {
if (this.world.tradeRoute[tile] < 24) weakTiles++;
}
return weakTiles * (polityBacked ? 0.22 : 0.16);
}
canPayRouteConstructionCost(cityA, cityB, path, polity = null) {
const cost = this.routeConstructionCost(path, !!polity);
if (cost <= 0) return true;
return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) >= cost;
}
payRouteConstructionCost(cityA, cityB, path, polity = null) {
const cost = this.routeConstructionCost(path, !!polity);
if (cost <= 0) return true;
if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity)) return false;
let remaining = cost;
if (polity) {
const treasuryPayment = Math.min(polity.treasury, cost * 0.65);
polity.treasury -= treasuryPayment;
remaining -= treasuryPayment;
}
const half = remaining * 0.5;
const paidA = this.drainCityResources(cityA, half);
const paidB = this.drainCityResources(cityB, half);
remaining -= paidA + paidB;
if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining);
if (remaining > 0 && polity) {
const treasuryPayment = Math.min(polity.treasury, remaining);
polity.treasury -= treasuryPayment;
remaining -= treasuryPayment;
}
return remaining <= 0.001;
}
drainCityResources(city, amount) {
const paid = Math.min(city.storedResources, amount);
city.storedResources -= paid;
return paid;
}
addPheromone(tile, amount) {
if (tile < 0 || tile >= this.world.count || amount <= 0) return;
this.world.pheromone[tile] = Math.min(SimConfig.route.maxPheromone, this.world.pheromone[tile] + amount);
}
depositRoutePheromone(path, amount) {
for (const i of path) {
this.addPheromone(i, amount);
}
}
updateEthnicStats() {
for (const e of this.ethnicities.values()) {
e.population = 0;
e.diversity = 0;
e.centroidX = 0;
e.centroidY = 0;
e.activeTraitPopulation = 0;
e.traitSums = emptyTraitSums();
}
for (const a of this.agents) {
const e = this.ethnicities.get(a.ethnicity);
if (!e) continue;
e.population++;
e.centroidX += a.x;
e.centroidY += a.y;
e.activeTraitPopulation++;
addTraits(e.traitSums, a.traits);
}
for (const city of this.cities) {
for (const [id, count] of city.ethnicityComposition) {
const e = this.ethnicities.get(id);
if (!e) continue;
e.population += count;
e.centroidX += city.x * count;
e.centroidY += city.y * count;
}
}
for (const e of this.ethnicities.values()) {
if (!e.population) continue;
e.centroidX /= e.population;
e.centroidY /= e.population;
const activeTraitCount = Math.max(1, e.activeTraitPopulation);
e.averageTraits = averageTraits(e.traitSums, activeTraitCount);
}
for (const a of this.agents) {
const e = this.ethnicities.get(a.ethnicity);
if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits);
}
for (const e of this.ethnicities.values()) {
if (e.population) e.diversity /= e.population;
}
}
splitDivergentEthnicities() {
for (const e of this.ethnicities.values()) {
if (e.activeTraitPopulation < 24 || e.population < 40 || e.diversity < 0.14) continue;
const candidates = [];
let tempSum = 0;
let humidSum = 0;
for (const a of this.agents) {
if (a.ethnicity !== e.id) continue;
const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 0.95;
const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.09;
const tile = this.world.idx(a.x, a.y);
const climate = this.climateMismatch(e.id, tile) > 0.18;
if ((far || spatial || climate) && this.rng.next() < 0.72) {
candidates.push(a);
tempSum += this.world.temperature[tile];
humidSum += this.world.humidity[tile];
}
}
if (candidates.length >= 6) {
const newId = this.createEthnicity(e.id, {
temperature: tempSum / candidates.length,
humidity: humidSum / candidates.length
});
for (const a of candidates) a.ethnicity = newId;
}
}
}
}
function render() {
const start = performance.now();
const w = sim.world;
const size = w.size;
if (!renderImage || renderImageSize !== size) {
renderImage = ctx.createImageData(size, size);
renderImageSize = size;
}
const image = renderImage;
const data = image.data;
const mode = els.viewMode.value;
const cityPolityColor = new Map();
if (mode === "polities") {
for (const city of sim.cities) {
if (city.polityId === null) continue;
const polity = cityPolity(city);
if (!polity) continue;
const isGraphHover = graphState.hoverPolityId === polity.id;
cityPolityColor.set(city.id, {
color: polity.color,
isGraphHover
});
}
}
for (let i = 0; i < w.count; i++) {
let color;
if (mode === "resources") {
const v = clamp(w.resource[i] / 30, 0, 1);
color = mix([28, 36, 40], [107, 188, 85], v);
} else if (mode === "ethnicity") {
color = terrainInfo[w.terrain[i]].color;
if (w.city[i] >= 0) {
const city = sim.getCityById(w.city[i]);
if (city) color = mix(color, cityMajorityColor(city), 0.72);
}
} else if (mode === "pressure") {
const v = clamp(w.pressure[i] / 12, 0, 1);
color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
} else if (mode === "polities") {
color = terrainInfo[w.terrain[i]].color;
if (w.city[i] >= 0) {
const polityColor = cityPolityColor.get(w.city[i]);
if (polityColor) {
color = mix(color, polityColor.color, polityColor.isGraphHover ? 0.82 : 0.5);
if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(color, [16, 18, 19], 0.28);
}
}
} else if (mode === "technology") {
color = mix(terrainInfo[w.terrain[i]].color, [44, 42, 48], 0.35);
} else if (mode === "pheromone") {
const v = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1);
color = mix(mix(terrainInfo[w.terrain[i]].color, [18, 20, 18], 0.58), [216, 177, 86], v);
} else {
color = terrainInfo[w.terrain[i]].color;
}
const p = i * 4;
data[p] = color[0];
data[p + 1] = color[1];
data[p + 2] = color[2];
data[p + 3] = 255;
}
ctx.putImageData(image, 0, 0);
renderDisasters();
drawTradeLinks();
drawAgentsAndCities(mode);
drawGraphPolityHighlight();
maybeRenderStateGraph();
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
}
function renderDisasters() {
if (!sim?.disasters?.length) return;
sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires);
if (!sim.disasters.length) return;
ctx.save();
ctx.globalCompositeOperation = "source-over";
for (const disaster of sim.disasters) {
const duration = Math.max(1, disaster.expires - disaster.year);
const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1);
const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22);
if (alpha <= 0.005) continue;
const innerRadius = Math.max(1, disaster.radius * 0.32);
const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius);
gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`);
gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`);
gradient.addColorStop(1, "rgba(211, 95, 84, 0)");
ctx.fillStyle = gradient;
drawGraphCircle(ctx, disaster.x, disaster.y, disaster.radius);
ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`;
ctx.lineWidth = 1;
strokeCircle(ctx, disaster.x, disaster.y, disaster.radius);
}
ctx.restore();
}
function rgb(color, boost = 0) {
return `rgb(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)})`;
}
function rgba(color, alpha, boost = 0) {
return `rgba(${Math.min(255, color[0] + boost)}, ${Math.min(255, color[1] + boost)}, ${Math.min(255, color[2] + boost)}, ${alpha})`;
}
function fillSquare(g, x, y, radius) {
g.fillRect(x - radius, y - radius, radius * 2 + 1, radius * 2 + 1);
}
function strokeLine(g, x1, y1, x2, y2) {
g.beginPath();
g.moveTo(x1, y1);
g.lineTo(x2, y2);
g.stroke();
}
function strokeCircle(g, x, y, r) {
g.beginPath();
g.arc(x, y, r, 0, Math.PI * 2);
g.stroke();
}
function drawTradeLinks() {
const w = sim.world;
ctx.save();
const currentLinkTiles = new Set();
for (const link of sim.tradeLinks) {
for (const tile of link.path || []) currentLinkTiles.add(tile);
}
ctx.globalAlpha = 0.24;
ctx.fillStyle = "#d9b650";
for (const i of sim.activeTradeRouteTiles || []) {
if (currentLinkTiles.has(i)) continue;
ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
}
if (!sim.tradeLinks.length) {
ctx.restore();
return;
}
for (const link of sim.tradeLinks) {
ctx.globalAlpha = clamp(0.12 + link.strength * 0.35, 0.16, 0.42);
ctx.fillStyle = "#ffd75a";
for (const tile of link.path || []) {
ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
}
}
ctx.restore();
}
function drawAgentsAndCities(mode) {
ctx.save();
if (mode !== "ethnicity") {
for (const city of sim.cities) {
const radius = cityRenderRadius(city);
const color = cityDisplayColor(city, mode);
ctx.fillStyle = rgba(color, 0.72);
ctx.globalAlpha = 0.88;
fillSquare(ctx, city.x, city.y, radius);
}
}
ctx.globalAlpha = 1;
if (mode === "technology") {
for (const a of sim.agents) {
sim.ensureAgentTech(a);
const farming = techLevel(a, "farming");
const metallurgy = techLevel(a, "metallurgy");
const tech = clamp(Math.max(farming, metallurgy), 0, 1);
if (tech <= 0.01) continue;
const color = mix([95, 171, 91], [202, 169, 102], metallurgy / Math.max(0.001, farming + metallurgy));
ctx.fillStyle = rgba(color, clamp(0.32 + tech * 0.68, 0.32, 1));
ctx.fillRect(a.x, a.y, 1, 1);
}
} else if (mode === "ethnicity") {
for (const a of sim.agents) {
const e = sim.ethnicities.get(a.ethnicity);
if (!e) continue;
ctx.fillStyle = rgb(e.color);
ctx.fillRect(a.x, a.y, 1, 1);
}
} else {
ctx.fillStyle = "#eeeccf";
for (const tile of sim.tileAgents.keys()) {
ctx.fillRect(tile % sim.world.size, Math.floor(tile / sim.world.size), 1, 1);
}
}
for (const city of sim.cities) {
if (mode === "ethnicity") {
const radius = cityRenderRadius(city);
const color = cityDisplayColor(city, mode);
ctx.globalAlpha = 0.90;
ctx.fillStyle = rgba(color, 0.78);
fillSquare(ctx, city.x, city.y, radius);
ctx.globalAlpha = 1;
}
const color = cityDisplayColor(city, mode);
ctx.fillStyle = rgb(color, 55);
ctx.fillRect(city.x, city.y, 1, 1);
}
ctx.restore();
}
function drawGraphPolityHighlight() {
const polityId = graphState.hoverPolityId;
if (polityId === null) return;
const polity = sim.getPolityById(polityId);
if (!polity) return;
const cities = sim.getPolityCities(polity);
if (!cities.length) return;
ctx.save();
ctx.lineWidth = 1;
for (const city of cities) {
const radius = Math.max(3, cityRenderRadius(city) + 2);
const color = polity.color || [238, 232, 188];
ctx.globalAlpha = city.id === polity.centerCityId ? 0.95 : 0.72;
ctx.strokeStyle = rgb(color, 72);
ctx.strokeRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
ctx.globalAlpha = 0.32;
ctx.fillStyle = rgb(color);
ctx.fillRect(city.x - radius + 1, city.y - radius + 1, Math.max(1, radius * 2 - 1), Math.max(1, radius * 2 - 1));
}
ctx.restore();
}
function cityRenderRadius(city) {
return clamp(Math.floor(Math.sqrt(city.population) / 12), 1, 7);
}
function cityMajorityColor(city) {
const id = dominantComposition(city.ethnicityComposition);
return sim.ethnicities.get(id)?.color || [242, 215, 134];
}
function cityPolity(city) {
return city?.polityId != null ? sim.getPolityById(city.polityId) : null;
}
function cityDisplayColor(city, mode) {
if (mode === "polities") {
const polity = cityPolity(city);
if (polity) return polity.color;
return [218, 205, 154];
}
return cityMajorityColor(city);
}
function techLevel(holder, key) {
return holder?.tech?.[key] || 0;
}
function knowledgeLevel(city, key) {
return city?.knowledge?.[key] || 0;
}
function showTooltip(event) {
const rect = els.canvas.getBoundingClientRect();
const simRect = els.sim.getBoundingClientRect();
const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size);
const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size);
hoverState = {
x,
y,
left: event.clientX - simRect.left + 16,
top: event.clientY - simRect.top + 16,
width: simRect.width,
height: simRect.height
};
renderTooltip();
}
function tooltipRow(label, value, show = true) {
return show ? `<span><b>${label}</b><em>${value}</em></span>` : "";
}
function tooltipSection(title, items) {
const body = items.filter(Boolean).join("");
return body ? `<section><h3>${title}</h3>${body}</section>` : "";
}
function renderTooltip() {
if (!hoverState) return;
const x = hoverState.x;
const y = hoverState.y;
if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) {
hideTooltip();
return;
}
const w = sim.world;
const i = w.idx(x, y);
const agent = findAgentAt(x, y);
const city = w.city[i] >= 0 ? sim.getCityById(w.city[i]) : null;
const terrain = terrainInfo[w.terrain[i]];
const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null;
const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2);
const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
const polity = cityPolity(city);
const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-";
els.tooltip.innerHTML = `
<strong>${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name}</strong>
${tooltipSection("Tile", [
tooltipRow("Position", `${x}, ${y}`),
tooltipRow("Terrain", terrain.name),
tooltipRow("Resources", w.resource[i].toFixed(1)),
tooltipRow("Fertility", w.fertility[i].toFixed(2)),
tooltipRow("Minerals", w.mineral[i].toFixed(2)),
tooltipRow("Temp / humid", `${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}`),
tooltipRow("Water", waterInfluence),
tooltipRow("Pressure", w.pressure[i].toFixed(0)),
tooltipRow("Pheromone", w.pheromone[i].toFixed(1)),
tooltipRow("Route", w.tradeRoute[i], w.tradeRoute[i])
])}
${tooltipSection("City & State", [
tooltipRow("Population", city?.population.toLocaleString(), city),
tooltipRow("Food stock", city?.storedResources.toFixed(1), city),
tooltipRow("Supply stress", (city?.supplyStress || 0).toFixed(2), city),
tooltipRow("Trade", city ? `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value` : "", city),
tooltipRow("Knowledge", city ? `${knowledgeLevel(city, "farming").toFixed(2)} farm / ${knowledgeLevel(city, "metallurgy").toFixed(2)} metal` : "", city),
tooltipRow("City majority", `E${cityEthnicity}`, cityEthnicity),
city ? cityEthnicityPie(city) : "",
tooltipRow("State", polity ? `#${polity.id}` : "Independent", city),
tooltipRow("Loyalty", city?.loyalty.toFixed(2), city),
tooltipRow("Charisma", clamp(polity?.charisma ?? 1, 0.5, 1.5).toFixed(2), polity),
tooltipRow("Leader tenure", polity ? `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y` : "", polity),
tooltipRow("Treasury", polity?.treasury.toFixed(1), polity),
tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity)
])}
${tooltipSection("Culture & Agent", [
tooltipRow("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`),
tooltipRow("Agent ethnicity", `E${agent?.ethnicity}`, agent),
tooltipRow("Lineage pop", ethnicity?.population, ethnicity),
tooltipRow("Climate pref", ethnicity ? `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : "", ethnicity),
tooltipRow("Climate mismatch", mismatch.toFixed(2), agent),
tooltipRow("Sedentary", agent ? agent.traits.sedentary.toFixed(2) : "", agent),
tooltipRow("Ethnocentrism", agent ? agent.traits.ethnocentrism.toFixed(2) : "", agent),
tooltipRow("Farming", agent ? `${techLevel(agent, "farming").toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : "", agent),
tooltipRow("Metallurgy", techLevel(agent, "metallurgy").toFixed(3), agent),
tooltipRow("Stored", agent?.resources.toFixed(1), agent)
])}
`;
els.tooltip.hidden = false;
const margin = 8;
const width = els.tooltip.offsetWidth;
const height = els.tooltip.offsetHeight;
let left = hoverState.left;
let top = hoverState.top;
if (left + width + margin > hoverState.width) left = hoverState.left - width - 32;
if (top + height + margin > hoverState.height) top = hoverState.height - height - margin;
els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.width - width - margin))}px`;
els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`;
}
function cityEthnicityPie(city) {
if (!city?.ethnicityComposition?.size) return "";
const entries = [...city.ethnicityComposition]
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
const total = entries.reduce((sum, [, count]) => sum + count, 0);
if (total <= 0) return "";
const top = entries.slice(0, 5);
const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0);
const slices = other > 0 ? [...top, [0, other]] : top;
let cursor = 0;
const gradient = slices.map(([id, count]) => {
const start = cursor / total * 100;
cursor += count;
const end = cursor / total * 100;
const ethnicity = id ? sim.ethnicities.get(id) : null;
const color = ethnicity?.color || [122, 130, 126];
return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`;
}).join(", ");
const labels = slices.map(([id, count]) => {
const ethnicity = id ? sim.ethnicities.get(id) : null;
const color = ethnicity?.color || [122, 130, 126];
const label = id ? `E${id}` : "Other";
return `<span class="ethnicity-pie-key"><i style="background: rgb(${color.join(",")})"></i>${label} ${Math.round(count / total * 100)}%</span>`;
}).join("");
return `
<div class="ethnicity-pie-wrap">
<div class="ethnicity-pie" style="background: conic-gradient(${gradient})"></div>
<div class="ethnicity-pie-labels">${labels}</div>
</div>
`;
}
function hideTooltip() {
hoverState = null;
els.tooltip.hidden = true;
}
function findAgentAt(x, y) {
const directIndex = sim.world.idx(x, y);
return sim.tileAgents.get(directIndex)?.[0] || null;
}
function waterInfluenceAt(world, x, y) {
let score = 0;
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const d = Math.abs(dx) + Math.abs(dy);
if (!d || d > 3) continue;
const tx = x + dx;
const ty = y + dy;
if (tx < 0 || ty < 0 || tx >= world.size || ty >= world.size) continue;
if (world.terrain[world.idx(tx, ty)] === Terrain.WATER) score += (4 - d) / 4;
}
}
return score;
}
function updateStats(force = false) {
const now = performance.now();
if (!force && now - lastStatsAt < SimConfig.render.statsThrottleMs) return;
lastStatsAt = now;
const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0);
const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0);
let farmingTotal = 0;
let metallurgyTotal = 0;
let farmingHolders = 0;
let metallurgyHolders = 0;
for (const a of sim.agents) {
sim.ensureAgentTech(a);
farmingTotal += techLevel(a, "farming");
metallurgyTotal += techLevel(a, "metallurgy");
if (techLevel(a, "farming") > 0.02) farmingHolders++;
if (techLevel(a, "metallurgy") > 0.02) metallurgyHolders++;
}
const agentCount = Math.max(1, sim.agents.length);
const stats = {
year: formatSimDate(sim.year),
activeGroups: sim.agents.length.toLocaleString(),
urbanPopulation: Math.floor(urbanPopulation).toLocaleString(),
ethnicities: livingEthnicities.length.toLocaleString(),
cities: sim.cities.length.toLocaleString(),
polities: sim.polities.length.toLocaleString(),
wars: sim.wars.length.toLocaleString(),
routes: sim.tradeLinks.length.toLocaleString(),
farmingKnowledge: `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`,
metallurgyKnowledge: `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`,
deaths: sim.deaths.toLocaleString()
};
for (const [id, value] of Object.entries(stats)) {
if (els[id]) els[id].textContent = value;
}
const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9);
els.ethnicityList.innerHTML = top.map(e => (
`<li><span class="lineage-chip" style="background: rgb(${e.color.join(",")})"></span>` +
`E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}</li>`
)).join("");
maybeRenderStateGraph();
}
function setLegend() {
const mode = els.viewMode.value;
els.legend.innerHTML = (legendByMode[mode] || legendByMode.resources)();
}
function maybeRenderStateGraph() {
const now = performance.now();
if (now - lastGraphRenderAt < SimConfig.render.graphThrottleMs) return;
lastGraphRenderAt = now;
renderStateGraph();
}
function sampleGraphPower(sample) {
if (Number.isFinite(sample?.power)) return sample.power;
return Math.sqrt(sample?.population || 0) * 1.35 +
Math.sqrt(Math.max(0, sample?.treasury || 0)) * 1.15 +
(sample?.avgLoyalty ?? 0.5) * 16;
}
const graphMetrics = {
power: {
value: sampleGraphPower,
format: value => value.toFixed(1)
},
population: {
value: sample => sample?.population || 0,
format: value => Math.round(value).toLocaleString()
},
cities: {
value: sample => sample?.cities || 0,
format: value => Math.round(value).toLocaleString()
},
loyalty: {
value: sample => sample?.avgLoyalty ?? 0.5,
format: value => `${Math.round(clamp(value, 0, 1) * 100)}%`
}
};
function graphSampleValue(sample, metric) {
return (graphMetrics[metric] || graphMetrics.power).value(sample);
}
function formatGraphValue(value, metric) {
return (graphMetrics[metric] || graphMetrics.power).format(value);
}
function renderStateGraph() {
const canvas = els.stateGraph;
if (!canvas || !sim) return;
const rect = canvas.getBoundingClientRect();
if (!rect.width) return;
const historyWindowSpan = years(SimConfig.render.historyWindowYears);
const historyWindowEnd = sim.year;
const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan);
graphState.filter = els.historyFilter?.value || graphState.filter;
graphState.sort = els.historySort?.value || graphState.sort;
graphState.metric = els.historyMetric?.value || graphState.metric;
const histories = sim.getAllPolityHistories()
.filter(history => (history.ended ?? sim.year) >= historyWindowStart)
.map(history => ({
...history,
visibleFounded: Math.max(history.founded ?? 0, historyWindowStart),
visibleSamples: (history.samples || []).filter(sample => sample.year >= historyWindowStart)
}))
.filter(history => history.visibleSamples.length > 0 || (history.ended ?? sim.year) >= historyWindowStart)
.filter(history => {
const isActive = history.active && history.ended === null;
if (graphState.filter === "active" && !isActive) return false;
if (graphState.filter === "ended" && isActive) return false;
if (isActive && !graphState.showActive) return false;
if (!isActive && !graphState.showPast) return false;
return true;
});
const paddingLeft = 34;
const paddingRight = 8;
const paddingTop = 18;
const paddingBottom = 22;
const rowHeight = 24;
const groupHeight = 18;
const scored = histories.map(history => {
const samples = history.visibleSamples.length ? history.visibleSamples : [{ population: 0, cities: 0, avgLoyalty: 0.5 }];
const peakPower = Math.max(...samples.map(sample => sampleGraphPower(sample)));
const peakPopulation = Math.max(...samples.map(sample => sample.population || 0));
const peakCities = Math.max(...samples.map(sample => sample.cities || 0));
const peakLoyalty = Math.max(...samples.map(sample => sample.avgLoyalty ?? 0.5));
const peakMetric = Math.max(...samples.map(sample => graphSampleValue(sample, graphState.metric)));
const lifespan = (history.ended ?? sim.year) - history.visibleFounded;
const importance = peakPower * 1.6 +
peakCities * 130 +
Math.sqrt(Math.max(0, peakPopulation)) * 6 +
lifespan * 0.35;
return { history, peakPopulation, peakCities, peakLoyalty, peakPower, peakMetric, importance };
});
const compareRows = (a, b) => {
if (graphState.sort === "oldest") {
return ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id);
}
if (graphState.sort === "strongest") {
return (b.peakPower - a.peakPower) || ((a.history.founded ?? 0) - (b.history.founded ?? 0));
}
if (graphState.sort === "largest") {
return (b.peakPopulation - a.peakPopulation) || (b.peakCities - a.peakCities);
}
const activeDelta = (b.history.active ? 1 : 0) - (a.history.active ? 1 : 0);
if (graphState.sort === "active") {
return activeDelta || (b.importance - a.importance) || (a.history.id - b.history.id);
}
return activeDelta || (b.importance - a.importance) || ((a.history.founded ?? 0) - (b.history.founded ?? 0)) || (a.history.id - b.history.id);
};
const selected = scored.sort(compareRows).slice(0, 90).sort(compareRows);
const activeRows = selected.filter(item => item.history.active && item.history.ended === null);
const pastRows = selected.filter(item => !(item.history.active && item.history.ended === null));
const displayRows = [];
if (graphState.showActive && graphState.filter !== "ended" && activeRows.length) {
displayRows.push({ type: "group", label: `Living ${activeRows.length}` });
for (const item of activeRows) displayRows.push({ type: "state", item });
}
if (graphState.showPast && graphState.filter !== "active" && pastRows.length) {
displayRows.push({ type: "group", label: `Past ${pastRows.length}` });
for (const item of pastRows) displayRows.push({ type: "state", item });
}
const logicalHeight = paddingTop +
displayRows.reduce((sum, row) => sum + (row.type === "group" ? groupHeight : rowHeight), 0) +
paddingBottom;
canvas.style.height = `${logicalHeight}px`;
const updatedRect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.floor(updatedRect.width * dpr));
const height = Math.max(1, Math.floor(logicalHeight * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const g = canvas.getContext("2d");
g.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = updatedRect.width;
const h = logicalHeight;
g.clearRect(0, 0, w, h);
g.fillStyle = "#101315";
g.fillRect(0, 0, w, h);
if (!histories.length || !selected.length || !displayRows.length) {
if (els.historyRange) els.historyRange.textContent = "-";
graphState.rows = [];
graphState.markers = [];
graphState.scale = null;
g.fillStyle = "#7f8984";
g.font = "12px ui-sans-serif, system-ui, sans-serif";
g.fillText("No state history yet", 16, 28);
return;
}
let minYear = historyWindowStart;
let maxYear = historyWindowEnd;
for (const item of selected) {
const history = item.history;
minYear = Math.min(minYear, history.visibleFounded);
maxYear = Math.max(maxYear, history.ended ?? sim.year);
}
if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`;
const plotWidth = Math.max(1, w - paddingLeft - paddingRight);
const yearToX = year => paddingLeft + ((clamp(year, minYear, maxYear) - minYear) / Math.max(1, maxYear - minYear)) * plotWidth;
const xToYear = x => minYear + ((clamp(x, paddingLeft, w - paddingRight) - paddingLeft) / plotWidth) * Math.max(1, maxYear - minYear);
const rows = [];
let cursorY = paddingTop;
for (const displayRow of displayRows) {
if (displayRow.type === "group") {
rows.push({ ...displayRow, y: cursorY + groupHeight * 0.5, height: groupHeight });
cursorY += groupHeight;
continue;
}
const item = displayRow.item;
rows.push({
...item,
type: "state",
y: cursorY + rowHeight * 0.5,
height: rowHeight,
color: item.history.color || hslToRgb((item.history.id * 0.38196601125) % 1, 0.58, 0.62),
samples: (item.history.visibleSamples || [])
.filter(sample => Number.isFinite(sample.year))
.sort((a, b) => a.year - b.year)
});
cursorY += rowHeight;
}
let minMetric = Infinity;
let maxMetric = -Infinity;
let minCities = Infinity;
let maxCities = -Infinity;
for (const row of rows.filter(row => row.type === "state")) {
for (const sample of row.samples) {
const value = graphSampleValue(sample, graphState.metric);
minMetric = Math.min(minMetric, value);
maxMetric = Math.max(maxMetric, value);
minCities = Math.min(minCities, sample.cities || 0);
maxCities = Math.max(maxCities, sample.cities || 0);
}
}
if (!Number.isFinite(minMetric) || !Number.isFinite(maxMetric)) {
minMetric = 0;
maxMetric = 1;
}
if (!Number.isFinite(minCities) || !Number.isFinite(maxCities)) {
minCities = 0;
maxCities = 1;
}
graphState.rows = rows.filter(row => row.type === "state");
graphState.markers = [];
graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear };
const axisY = h - paddingBottom + 4;
g.strokeStyle = "rgba(168, 177, 170, 0.22)";
g.lineWidth = 1;
strokeLine(g, paddingLeft, axisY, w - paddingRight, axisY);
g.fillStyle = "#8c9690";
g.font = "10px ui-sans-serif, system-ui, sans-serif";
g.textAlign = "left";
g.fillText(formatGraphYear(minYear), paddingLeft, h - 5);
g.textAlign = "center";
g.fillText(formatGraphYear((minYear + maxYear) / 2), paddingLeft + plotWidth / 2, h - 5);
g.textAlign = "right";
g.fillText(formatGraphYear(maxYear), w - paddingRight, h - 5);
const topEvents = (sim.graphEvents || [])
.filter(event => event.importance >= 2)
.filter(event => event.year >= minYear && event.year <= maxYear)
.filter(event => graphEventTypes[event.type]);
const topCollisions = new Map();
for (const event of topEvents) {
const x = yearToX(event.year);
const bucket = Math.round(x / 7);
const count = topCollisions.get(bucket) || 0;
topCollisions.set(bucket, count + 1);
const y = Math.max(7, paddingTop * 0.5 + ([-3, 0, 3][count % 3]));
const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3;
const graphEvent = graphEventTypes[event.type];
g.fillStyle = graphEvent.color;
graphEvent.draw(g, x, y, r);
graphState.markers.push({ row: null, event, x, y, radius: r + 3 });
}
rows.forEach(row => {
const alpha = row.type === "group" ? 0.16 : 0.12;
if (row.type === "group") {
g.fillStyle = "#7f8984";
g.font = "10px ui-sans-serif, system-ui, sans-serif";
g.textAlign = "left";
g.fillText(row.label, 4, row.y + 3);
}
g.strokeStyle = `rgba(168, 177, 170, ${alpha})`;
g.lineWidth = 1;
strokeLine(g, paddingLeft, row.y, w - paddingRight, row.y);
});
rows.forEach(row => {
if (row.type !== "state") return;
const history = row.history;
g.textAlign = "left";
g.fillStyle = "#a8b1aa";
g.font = "9px ui-sans-serif, system-ui, sans-serif";
g.fillText(`S${history.id}`, 4, row.y + 3);
const inactiveMultiplier = history.active ? 1 : 0.65;
const strokeSegment = (fromYear, toYear, metricValue, cities, alphaScale = 1) => {
if (toYear <= fromYear) return;
const normalizedMetric = (metricValue - minMetric) / Math.max(1, maxMetric - minMetric);
const metricT = clamp(normalizedMetric, 0, 1);
const cityT = clamp((cities - minCities) / Math.max(1, maxCities - minCities), 0, 1);
const lowColor = mix([56, 62, 60], row.color, 0.34);
const highColor = mix(row.color, [244, 246, 238], 0.18);
const segmentColor = mix(lowColor, highColor, metricT);
let alpha = (0.22 + metricT * 0.76) * inactiveMultiplier * alphaScale;
alpha = clamp(alpha, 0.10, 0.96);
const thickness = clamp(3.5 + cityT * 5.5 + metricT * 4.5, 3.5, 13);
g.lineWidth = thickness;
g.lineCap = "round";
g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${clamp(alpha * 0.28, 0.08, 0.28)})`;
strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y);
g.strokeStyle = `rgba(${segmentColor[0]}, ${segmentColor[1]}, ${segmentColor[2]}, ${alpha})`;
g.lineWidth = Math.max(2, thickness * 0.62);
strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y);
g.lineCap = "butt";
};
if (row.samples.length > 1) {
if (history.visibleFounded < row.samples[0].year) {
strokeSegment(history.visibleFounded, row.samples[0].year, graphSampleValue(row.samples[0], graphState.metric), row.samples[0].cities, 0.42);
}
for (let i = 0; i < row.samples.length - 1; i++) {
const sampleA = row.samples[i];
const sampleB = row.samples[i + 1];
strokeSegment(
sampleA.year,
sampleB.year,
(graphSampleValue(sampleA, graphState.metric) + graphSampleValue(sampleB, graphState.metric)) * 0.5,
((sampleA.cities || 0) + (sampleB.cities || 0)) * 0.5
);
}
const lastSample = row.samples[row.samples.length - 1];
const endYear = history.ended ?? sim.year;
if (lastSample.year < endYear) {
strokeSegment(lastSample.year, endYear, graphSampleValue(lastSample, graphState.metric), lastSample.cities, 0.55);
}
} else {
const sample = row.samples[0] || { population: 0, cities: row.peakCities, avgLoyalty: 0.5 };
strokeSegment(history.visibleFounded, history.ended ?? sim.year, graphSampleValue(sample, graphState.metric), sample.cities || row.peakCities);
}
});
rows.forEach(row => {
if (row.type !== "state") return;
const events = (row.history.events || [])
.filter(event => event.importance >= 2)
.filter(event => event.year >= minYear && event.year <= maxYear)
.filter(event => graphEventTypes[event.type]);
const collisions = new Map();
for (const event of events) {
const x = yearToX(event.year);
const bucket = Math.round(x / 7);
const count = collisions.get(bucket) || 0;
collisions.set(bucket, count + 1);
const y = row.y + ([-4, 0, 4][count % 3]);
const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3;
const graphEvent = graphEventTypes[event.type];
g.fillStyle = graphEvent.color;
graphEvent.draw(g, x, y, r);
graphState.markers.push({ row, event, x, y, radius: r + 3 });
}
});
}
function drawGraphCircle(g, x, y, r) {
g.beginPath();
g.arc(x, y, r, 0, Math.PI * 2);
g.fill();
}
function drawGraphPolygon(g, points) {
g.beginPath();
g.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) g.lineTo(points[i][0], points[i][1]);
g.closePath();
g.fill();
}
function drawGraphDiamond(g, x, y, r) {
drawGraphPolygon(g, [[x, y - r], [x + r, y], [x, y + r], [x - r, y]]);
}
function drawGraphTriangleUp(g, x, y, r) {
drawGraphPolygon(g, [[x, y - r], [x + r, y + r], [x - r, y + r]]);
}
function drawGraphTriangleDown(g, x, y, r) {
drawGraphPolygon(g, [[x, y + r], [x + r, y - r], [x - r, y - r]]);
}
const graphEventTypes = {
capitalShift: {
label: "capital shift",
color: "rgba(227, 179, 65, 0.95)",
draw: drawGraphDiamond,
summary: data => `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}`
},
disaster: {
label: "major disaster",
color: "rgba(211, 95, 84, 0.95)",
draw: drawGraphCircle,
summary: data => `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities`
},
famine: {
label: "famine",
color: "rgba(227, 128, 65, 0.90)",
draw: drawGraphTriangleDown,
summary: data => `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap`
},
rebellion: {
label: "rebellion",
color: "rgba(236, 126, 111, 0.95)",
draw: drawGraphTriangleUp,
summary: data => `city #${data.cityId ?? "-"}, pop ${Math.round(data.population || 0).toLocaleString()}, loyalty ${Number(data.loyalty || 0).toFixed(2)}`
},
newEthnicity: {
label: "new ethnicity",
color: "rgba(105, 181, 120, 0.95)",
draw: drawGraphDiamond,
summary: data => `E${data.ethnicity ?? "-"}, ${Math.round(data.population || 0).toLocaleString()} frontier groups`
}
};
function graphEventLabel(event) {
return graphEventTypes[event.type]?.label || event.type;
}
function graphEventSummary(event) {
return graphEventTypes[event.type]?.summary(event.data || {}) || "";
}
function showStateGraphMarkerTooltip(marker, pointerX, pointerY) {
if (!els.stateGraphTooltip) return;
const title = marker.row
? `S${marker.row.history.id} ${graphEventLabel(marker.event)}`
: graphEventLabel(marker.event);
els.stateGraphTooltip.innerHTML = `
<strong>${title}</strong>
<span>${formatGraphYear(marker.event.year)}</span>
<span>${graphEventSummary(marker.event)}</span>
`;
els.stateGraphTooltip.hidden = false;
const scroll = els.stateGraph.parentElement;
const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8);
const left = clamp(pointerX + 10, 8, maxLeft);
const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10);
els.stateGraphTooltip.style.left = `${left}px`;
els.stateGraphTooltip.style.top = `${top}px`;
}
function hideStateGraphMarkerTooltip() {
if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true;
}
function updateStateGraphInfo(event) {
if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return;
const y = event.offsetY;
const x = event.offsetX;
const marker = (graphState.markers || [])
.map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) }))
.filter(item => item.distance <= item.candidate.radius);
const nearestMarker = bestBy(marker, item => -item.distance)?.candidate;
if (nearestMarker) {
if (!nearestMarker.row) {
els.stateGraphInfo.textContent = `${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`;
showStateGraphMarkerTooltip(nearestMarker, x, y);
if (graphState.hoverPolityId !== null) {
graphState.hoverPolityId = null;
if (!running) render();
}
return;
}
const status = nearestMarker.row.history.active && nearestMarker.row.history.ended === null ? "living" : "past";
els.stateGraphInfo.textContent = `S${nearestMarker.row.history.id} ${status} ${formatGraphYear(nearestMarker.event.year)} ${graphEventLabel(nearestMarker.event)}`;
showStateGraphMarkerTooltip(nearestMarker, x, y);
if (graphState.hoverPolityId !== nearestMarker.row.history.id) {
graphState.hoverPolityId = nearestMarker.row.history.id;
if (!running) render();
}
return;
}
const row = graphState.rows
.map(candidate => ({ candidate, distance: Math.abs(candidate.y - y) }))
.filter(item => item.distance <= item.candidate.height * 0.5);
const nearestRow = bestBy(row, item => -item.distance)?.candidate;
if (!nearestRow) {
els.stateGraphInfo.textContent = "Hover a row for details";
hideStateGraphMarkerTooltip();
if (graphState.hoverPolityId !== null) {
graphState.hoverPolityId = null;
if (!running) render();
}
return;
}
if (graphState.hoverPolityId !== nearestRow.history.id) {
graphState.hoverPolityId = nearestRow.history.id;
if (!running) render();
}
hideStateGraphMarkerTooltip();
const year = graphState.scale.xToYear(x);
const samples = nearestRow.samples.length ? nearestRow.samples : [{
year,
population: 0,
cities: nearestRow.peakCities,
avgLoyalty: 0.5,
power: nearestRow.peakPower
}];
const sample = bestBy(samples, candidate => -Math.abs((candidate.year ?? year) - year));
const metricValue = graphSampleValue(sample, graphState.metric);
const status = nearestRow.history.active && nearestRow.history.ended === null ? "living" : "past";
els.stateGraphInfo.textContent =
`S${nearestRow.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` +
`${graphState.metric} ${formatGraphValue(metricValue, graphState.metric)} ` +
`pop ${Math.round(sample.population || 0).toLocaleString()} ` +
`cities ${Math.round(sample.cities || 0).toLocaleString()} ` +
`loyalty ${formatGraphValue(sample.avgLoyalty ?? 0.5, "loyalty")}`;
}
function clearStateGraphInfo() {
if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details";
hideStateGraphMarkerTooltip();
if (graphState.hoverPolityId !== null) {
graphState.hoverPolityId = null;
if (!running) render();
}
}
function formatGraphYear(week) {
return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}y`;
}
function formatSimDate(week) {
const year = Math.floor(week / WEEKS_PER_YEAR);
const weekOfYear = week % WEEKS_PER_YEAR;
const monthOfYear = Math.floor(weekOfYear / WEEKS_PER_MONTH) + 1;
const weekOfMonth = weekOfYear % WEEKS_PER_MONTH + 1;
return `${year.toLocaleString()}y ${monthOfYear}m ${weekOfMonth}w`;
}
function requestRender() {
renderDirty = true;
}
function drawFrame(forceStats = false) {
render();
updateStats(forceStats);
renderTooltip();
renderDirty = false;
lastRenderAt = performance.now();
}
function scheduleLoop() {
if (loopScheduled) return;
loopScheduled = true;
const delay = document.hidden
? LoopConfig.hiddenPollMs
: running ? 0 : LoopConfig.idlePollMs;
if (delay > 0) {
loopTimer = setTimeout(() => {
loopTimer = null;
requestAnimationFrame(loop);
}, delay);
} else {
requestAnimationFrame(loop);
}
}
function loop(now = performance.now()) {
loopScheduled = false;
if (running && !document.hidden && now - lastSimTickAt >= LoopConfig.simTickMs) {
lastSimTickAt = now;
const steps = Number(els.speed.value);
const stepBudget = LoopConfig.stepBudgetMs * Math.max(1, steps);
const loopStart = performance.now();
let completedSteps = 0;
for (let i = 0; i < steps; i++) {
sim.step();
completedSteps++;
if (completedSteps > 0 && performance.now() - loopStart > stepBudget) break;
}
if (completedSteps > 0) requestRender();
}
const shouldRender = !document.hidden &&
(renderDirty || (running && now - lastRenderAt >= LoopConfig.renderMs));
if (shouldRender) drawFrame();
scheduleLoop();
}
function reset() {
const size = Number(els.worldSize.value);
const count = Number(els.agentCount.value);
els.canvas.width = size;
els.canvas.height = size;
renderImage = null;
renderImageSize = 0;
sim = new Simulation(size, count);
setLegend();
drawFrame(true);
renderStateGraph();
}
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
function bestBy(items, score) {
let best = null;
let bestScore = -Infinity;
for (const item of items) {
const value = score(item);
if (value > bestScore) {
best = item;
bestScore = value;
}
}
return best;
}
function smoothstep(t) {
return t * t * (3 - 2 * t);
}
const traitFields = [
["mobility", traits => traits.mobility, 0.02, 1, 1, 1],
["resourceAttraction", traits => traits.resourceAttraction, 0.05, 1.2, 1, 1],
["assimilation", traits => traits.assimilation, 0, 0.75, 1, 1],
["ethnocentrism", traits => traits.ethnocentrism, 0, 1.2, 1, 1],
["reproductionThreshold", traits => traits.reproductionThreshold, 12, 48, 16, 1 / 48],
["sedentary", traits => traits.sedentary, 0, 1, 1, 1]
];
function mutateTraits(traits, rng, amount) {
return Object.fromEntries(traitFields.map(([key, get, min, max, mutationScale]) => [
key,
clamp(get(traits) + rng.range(-amount * mutationScale, amount * mutationScale), min, max)
]));
}
function blendTraits(a, b, t) {
return Object.fromEntries(traitFields.map(([key, get]) => [key, lerp(get(a), get(b), t)]));
}
function emptyTraitSums() {
return Object.fromEntries(traitFields.map(([key]) => [key, 0]));
}
function addTraits(sum, traits) {
for (const [key, get, , , , sumScale] of traitFields) sum[key] += get(traits) * sumScale;
}
function averageTraits(sum, count) {
return Object.fromEntries(traitFields.map(([key, , , , , sumScale]) => [key, sum[key] / count / sumScale]));
}
function compositionTotal(composition) {
return [...composition.values()].reduce((sum, value) => sum + value, 0);
}
function addBirthsToComposition(composition, births, rng = null) {
const entries = [...composition.entries()];
const total = entries.reduce((sum, [, value]) => sum + value, 0);
if (!total || births <= 0) return;
for (let n = 0; n < births; n++) {
let r = (rng ? rng.next() : Math.random()) * total;
for (const [id, count] of entries) {
r -= count;
if (r <= 0) {
composition.set(id, (composition.get(id) || 0) + 1);
break;
}
}
}
}
function removeFromComposition(composition, loss) {
let remaining = loss;
const total = compositionTotal(composition);
if (!total) return;
for (const [id, count] of [...composition]) {
const removed = Math.min(count, Math.ceil(loss * (count / total)));
composition.set(id, Math.max(0, count - removed));
remaining -= removed;
if (composition.get(id) <= 0) composition.delete(id);
if (remaining <= 0) break;
}
}
function dominantComposition(composition) {
let bestId = null;
let bestCount = 0;
for (const [id, count] of composition) {
if (count > bestCount) {
bestId = id;
bestCount = count;
}
}
return bestId;
}
function traitDistance(a, b) {
return traitFields.reduce((sum, [, get, , , , distanceScale]) => sum + Math.abs(get(a) - get(b)) * distanceScale, 0);
}
function hslToRgb(h, s, l) {
const hue = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [
Math.round(hue(p, q, h + 1 / 3) * 255),
Math.round(hue(p, q, h) * 255),
Math.round(hue(p, q, h - 1 / 3) * 255)
];
}
function mix(a, b, t) {
return [
Math.round(lerp(a[0], b[0], t)),
Math.round(lerp(a[1], b[1], t)),
Math.round(lerp(a[2], b[2], t))
];
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function refreshStateGraph() {
renderStateGraph();
clearStateGraphInfo();
}
els.toggleRun.addEventListener("click", () => {
running = !running;
els.toggleRun.textContent = running ? "Pause" : "Run";
lastSimTickAt = 0;
if (running && loopTimer) {
clearTimeout(loopTimer);
loopTimer = null;
loopScheduled = false;
scheduleLoop();
} else if (running) {
scheduleLoop();
}
});
els.stepOnce.addEventListener("click", () => {
sim.step();
requestRender();
drawFrame(true);
renderStateGraph();
});
els.resetWorld.addEventListener("click", reset);
for (const control of [els.worldSize, els.agentCount]) control.addEventListener("change", reset);
els.viewMode.addEventListener("change", () => {
setLegend();
requestRender();
drawFrame();
});
els.canvas.addEventListener("mousemove", showTooltip);
els.canvas.addEventListener("mouseleave", hideTooltip);
for (const control of [els.historyFilter, els.historySort, els.historyMetric]) {
control?.addEventListener("change", refreshStateGraph);
}
els.toggleActiveStates?.addEventListener("click", () => {
graphState.showActive = !graphState.showActive;
els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive));
refreshStateGraph();
});
els.togglePastStates?.addEventListener("click", () => {
graphState.showPast = !graphState.showPast;
els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast));
refreshStateGraph();
});
els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo);
els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo);
document.addEventListener("visibilitychange", () => {
lastSimTickAt = 0;
requestRender();
if (!document.hidden && loopTimer) {
clearTimeout(loopTimer);
loopTimer = null;
loopScheduled = false;
scheduleLoop();
}
});
reset();
loop();