diff --git a/app-state.js b/app-state.js
new file mode 100644
index 0000000..2974c27
--- /dev/null
+++ b/app-state.js
@@ -0,0 +1,50 @@
+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: "oldest",
+metric: "power",
+showActive: true,
+showPast: true,
+hoverPolityId: null,
+rows: [],
+markers: [],
+scale: null
+};
+const legendByMode = {
+terrain: () => terrainInfo.map(t => `${t.name}`).join(""),
+ethnicity: () => "Agent and city colors show lineage. Land remains terrain-colored.",
+pressure: () => "High local population pressure",
+polities: () => "Color = city-centered state. Uncolored cities are independent.",
+technology: () => "Farming knowledgeMetallurgy knowledge",
+pheromone: () => "Pheromone strengthFormal trade routes",
+resources: () => "Regenerating local resource stock"
+};
diff --git a/boot.js b/boot.js
new file mode 100644
index 0000000..7a24abc
--- /dev/null
+++ b/boot.js
@@ -0,0 +1,116 @@
+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 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();
diff --git a/config.js b/config.js
new file mode 100644
index 0000000..2ec80df
--- /dev/null
+++ b/config.js
@@ -0,0 +1,110 @@
+const Terrain = Object.freeze({
+PLAINS: 0,
+FOREST: 1,
+MOUNTAIN: 2,
+WATER: 3,
+DESERT: 4
+});
+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 }
+];
+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;
+}
diff --git a/script.js b/engine.js
similarity index 71%
rename from script.js
rename to engine.js
index d843674..3d1c45e 100644
--- a/script.js
+++ b/engine.js
@@ -1,167 +1,3 @@
-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 => `${t.name}`).join(""),
-ethnicity: () => "Agent and city colors show lineage. Land remains terrain-colored.",
-pressure: () => "High local population pressure",
-polities: () => "Color = city-centered state. Uncolored cities are independent.",
-technology: () => "Farming knowledgeMetallurgy knowledge",
-pheromone: () => "Pheromone strengthFormal trade routes",
-resources: () => "Regenerating local resource stock"
-};
class Rng {
constructor(seed) {
this.seed = seed >>> 0;
@@ -254,9 +90,8 @@ 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;
+if (elevation > seaLevel + 0.42) t = 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;
}
@@ -294,7 +129,6 @@ 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++) {
@@ -1031,7 +865,6 @@ 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;
@@ -1095,7 +928,7 @@ 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
+w.terrain[tile] === Terrain.MOUNTAIN
? 1.8
: 1.0;
const metallurgyChance =
@@ -1543,7 +1376,7 @@ 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;
+if (w.terrain[local] === Terrain.WATER) 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);
@@ -2955,6 +2788,7 @@ 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);
+const rebellions = [];
for (const city of this.getPolityCities(polity)) {
if (city.id === polity.centerCityId || city.loyalty >= threshold) continue;
const chance =
@@ -2964,13 +2798,25 @@ agePressure * 0.010;
if (this.rng.next() < chance) {
const rebellionLoyalty = city.loyalty ?? 0;
this.removeCityFromPolity(city);
-this.addPolityEvent(polity.id, "rebellion", this.year, {
+rebellions.push({
cityId: city.id,
population: city.population || 0,
loyalty: rebellionLoyalty
-}, 3);
+});
}
}
+if (rebellions.length === 1) {
+this.addPolityEvent(polity.id, "rebellion", this.year, rebellions[0], 1);
+} else if (rebellions.length > 1) {
+const population = rebellions.reduce((sum, rebellion) => sum + (rebellion.population || 0), 0);
+const avgLoyalty = rebellions.reduce((sum, rebellion) => sum + (rebellion.loyalty || 0), 0) / rebellions.length;
+this.addPolityEvent(polity.id, "rebellion", this.year, {
+cityIds: rebellions.map(rebellion => rebellion.cityId),
+cityCount: rebellions.length,
+population,
+loyalty: avgLoyalty
+}, 3);
+}
}
}
enforcePolityContinuity(polity) {
@@ -2993,7 +2839,7 @@ 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);
+city.loyalty = clamp(city.loyalty - 0.28, 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);
@@ -3497,1134 +3343,3 @@ 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 ? `${label}${value}` : "";
-}
-function tooltipSection(title, items) {
-const body = items.filter(Boolean).join("");
-return body ? `` : "";
-}
-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 = `
- ${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name}
- ${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 `${label} ${Math.round(count / total * 100)}%`;
-}).join("");
-return `
-
-`;
-}
-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 => (
-`` +
-`E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}`
-)).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 = `
- ${title}
- ${formatGraphYear(marker.event.year)}
- ${graphEventSummary(marker.event)}
- `;
-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();
diff --git a/index.html b/index.html
index 269d45d..0e6abfb 100644
--- a/index.html
+++ b/index.html
@@ -96,8 +96,6 @@