diff --git a/index.html b/index.html
index 96574af..269d45d 100644
--- a/index.html
+++ b/index.html
@@ -20,12 +20,6 @@
-
-
-
-
-
-
diff --git a/script.js b/script.js
index 36d8474..d843674 100644
--- a/script.js
+++ b/script.js
@@ -16,14 +16,17 @@ const terrainInfo = [
{ 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 SAVE_KEY = "civil-emergence-save";
-const SAVE_VERSION = 3;
-const MAX_SAVE_BYTES = 4_500_000;
+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,
@@ -41,11 +44,6 @@ statsThrottleMs: 900,
historyWindowYears: 2000,
historySamplePaddingYears: 240
}),
-save: Object.freeze({
-key: SAVE_KEY,
-version: SAVE_VERSION,
-maxBytes: MAX_SAVE_BYTES
-}),
technology: Object.freeze({
cityDiffusion: 0.0022,
tradeDiffusion: 0.0032,
@@ -55,17 +53,38 @@ 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.38,
+baseChance: 0.76,
minRadius: 6,
maxRadius: 34,
rareLargeChance: 0.10,
@@ -76,53 +95,36 @@ 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 * MONTHS_PER_YEAR;
+return value * WEEKS_PER_YEAR;
}
-const els = {
-canvas: document.getElementById("world"),
-sim: document.querySelector(".sim"),
-toggleRun: document.getElementById("toggleRun"),
-stepOnce: document.getElementById("stepOnce"),
-resetWorld: document.getElementById("resetWorld"),
-saveWorld: document.getElementById("saveWorld"),
-loadWorld: document.getElementById("loadWorld"),
-clearSave: document.getElementById("clearSave"),
-speed: document.getElementById("speed"),
-agentCount: document.getElementById("agentCount"),
-worldSize: document.getElementById("worldSize"),
-viewMode: document.getElementById("viewMode"),
-year: document.getElementById("year"),
-activeGroups: document.getElementById("activeGroups"),
-urbanPopulation: document.getElementById("urbanPopulation"),
-ethnicities: document.getElementById("ethnicities"),
-cities: document.getElementById("cities"),
-polities: document.getElementById("polities"),
-wars: document.getElementById("wars"),
-routes: document.getElementById("routes"),
-farmingKnowledge: document.getElementById("farmingKnowledge"),
-metallurgyKnowledge: document.getElementById("metallurgyKnowledge"),
-deaths: document.getElementById("deaths"),
-frameCost: document.getElementById("frameCost"),
-ethnicityList: document.getElementById("ethnicityList"),
-legend: document.getElementById("legend"),
-tooltip: document.getElementById("tooltip"),
-stateGraph: document.getElementById("stateGraph"),
-historyRange: document.getElementById("historyRange"),
-historyFilter: document.getElementById("historyFilter"),
-historySort: document.getElementById("historySort"),
-historyMetric: document.getElementById("historyMetric"),
-toggleActiveStates: document.getElementById("toggleActiveStates"),
-togglePastStates: document.getElementById("togglePastStates"),
-stateGraphInfo: document.getElementById("stateGraphInfo"),
-stateGraphTooltip: document.getElementById("stateGraphTooltip")
-};
+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 frame = 0;
let lastStatsAt = 0;
let lastGraphRenderAt = 0;
let lastRenderAt = 0;
@@ -151,6 +153,15 @@ 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;
@@ -167,7 +178,7 @@ return Math.floor(this.next() * max);
}
}
class World {
-constructor(size, rng, generate = true) {
+constructor(size, rng) {
this.size = size;
this.count = size * size;
this.rng = rng;
@@ -189,10 +200,7 @@ this.dominantEthnicity = new Int32Array(this.count);
this.cultureDiversity = new Float32Array(this.count);
this.city.fill(-1);
this.dominantEthnicity.fill(-1);
-if (generate) this.generate();
-}
-static blank(size, rng) {
-return new World(size, rng, false);
+this.generate();
}
idx(x, y) {
return y * this.size + x;
@@ -443,9 +451,9 @@ return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))];
}
}
class Simulation {
-constructor(size, initialAgents, options = {}) {
+constructor(size, initialAgents) {
this.rng = new Rng(Date.now());
-this.world = options.blankWorld ? World.blank(size, this.rng) : new World(size, this.rng);
+this.world = new World(size, this.rng);
this.agents = [];
this.ethnicities = new Map();
this.cities = [];
@@ -456,6 +464,7 @@ this.polityById = new Map();
this.wars = [];
this.disasters = [];
this.disasterHistory = [];
+this.graphEvents = [];
this.polityHistory = new Map();
this.deadPolityHistories = [];
this.tradeLinks = [];
@@ -475,12 +484,10 @@ SimConfig.population.maxAgentsFloor
);
this.tileEthnicities = new Map();
this.tileAgents = new Map();
-if (!options.skipSpawn) {
this.spawnInitialAgents(initialAgents);
this.rebuildOccupancy();
this.updateEthnicStats();
}
-}
spawnInitialAgents(count) {
const founders = Math.max(5, Math.min(16, Math.round(count / 180)));
const desertFounders = Math.max(2, Math.floor(founders * 0.25));
@@ -556,7 +563,11 @@ farming: 0,
metallurgy: 0
},
farmingWork: 0,
-lastFarmTile: -1
+lastFarmTile: -1,
+tradeOriginCityId: null,
+lastTradeCityId: null,
+tradeMemory: 0,
+tradeCooldown: 0
};
}
findNearbySpawn(originX, originY, preferredTerrain = null) {
@@ -610,6 +621,74 @@ 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();
@@ -624,18 +703,31 @@ 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 = this.agents.length + offspring.length < this.maxAgents
-? offspring
-: offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length));
+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();
@@ -839,8 +931,8 @@ const localPressure = w.pressure[current];
let bestX = a.x;
let bestY = a.y;
let bestScore = -Infinity;
-const sedentary = getSedentary(a.traits);
-const ethnocentrism = getEthnocentrism(a.traits);
+const 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);
@@ -864,13 +956,14 @@ const ethnicDensity = ethnocentrism > 0.04
? this.ethnicDensityNear(x, y, a.ethnicity)
: { same: 0, foreign: 0 };
const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
-const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary;
+const 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.25 : 0;
+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;
@@ -905,8 +998,8 @@ a.movedThisStep = true;
a.farmingWork = 0;
a.lastFarmTile = to;
const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1;
-w.pheromone[from] += (w.tradeRoute[from] ? 0.14 : 0.42) * depositScale;
-w.pheromone[to] += (w.tradeRoute[to] ? 0.10 : 0.30) * depositScale;
+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;
@@ -923,6 +1016,19 @@ 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;
@@ -977,7 +1083,7 @@ const settledFactor = clamp((agent.settled || 0) / 12, 0, 1);
if ((agent.tech?.farming || 0) <= 0.02) {
const farmingBase = 0.00016;
const farmability = this.farmabilityAt(tile);
-const sedentary = getSedentary(agent.traits);
+const sedentary = agent.traits.sedentary;
const farmingChance =
farmingBase *
(0.25 + farmability) *
@@ -1037,6 +1143,7 @@ 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;
@@ -1051,6 +1158,7 @@ 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);
@@ -1075,6 +1183,111 @@ agent.tech.farming = clamp(Math.max(agent.tech.farming, city.knowledge.farming *
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;
@@ -1159,7 +1372,7 @@ const waterAdaptation = ethnicity?.climateHumidity ?? 0.45;
const waterCost = w.terrain[i] === Terrain.WATER ? (waterAdaptation >= 0.82 ? 0.1 : 0.4 - waterAdaptation * 0.12) : 0;
const climateCost = Math.max(0, climateMismatch - 0.22) * 2.4;
const drylandUpkeep = drylandAdapted && w.terrain[i] === Terrain.DESERT ? 0.72 : 1;
-const sedentary = getSedentary(a.traits);
+const 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) {
@@ -1235,8 +1448,8 @@ a.foreignContact = 0;
}
a.foreignContact++;
const pressure = dominant.count / Math.max(1, dominant.total);
-const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 30);
-if (this.rng.next() < chance * 0.14) {
+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;
@@ -1268,7 +1481,7 @@ const distance = Math.abs(city.x - x) + Math.abs(city.y - y);
if (!city.ethnicityComposition?.size) continue;
const influence = clamp((6 - distance) / 6, 0, 1) * clamp(Math.sqrt(city.population) / 8, 0.5, 8);
for (const [id, count] of city.ethnicityComposition) {
-const weighted = Math.max(1, Math.round(count * influence * 0.08));
+const weighted = Math.max(1, Math.round(count * influence * 0.015));
total += weighted;
counts.set(id, (counts.get(id) || 0) + weighted);
}
@@ -1283,10 +1496,41 @@ return best;
}
updateWorldFields(scale = 1) {
const w = this.world;
-const pheromoneDecay = Math.pow(0.996, scale);
+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));
-w.pheromone[i] *= pheromoneDecay;
+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() {
@@ -1311,7 +1555,7 @@ candidates.set(i, group);
this.ensureAgentTech(a);
group.count++;
group.resources += Math.max(0, a.resources);
-group.sedentary += getSedentary(a.traits);
+group.sedentary += a.traits.sedentary;
group.farming += a.tech.farming || 0;
group.metallurgy += a.tech.metallurgy || 0;
group.techCount++;
@@ -1426,18 +1670,21 @@ absorbUrbanPopulation() {
if (!this.cities.length) return;
const absorbed = [];
for (const a of this.agents) {
-if (!a.alive || a.settled < 5) {
+if (!a.alive || a.settled < 3) {
absorbed.push(a);
continue;
}
-const city = this.findCityNear(a.x, a.y, 7);
-const sedentary = getSedentary(a.traits);
-if (!city || this.rng.next() > sedentary * 0.85) {
+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(8, a.resources / 8));
-const urbanWeight = clamp((sedentary - 0.14) * 1.5, 0.08, 1);
+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);
@@ -1473,7 +1720,7 @@ const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35;
const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull);
w.resource[i] -= extraction;
w.farmland[i] = Math.max(w.farmland[i], pull);
-w.pheromone[i] += city.pheromoneOutput * pull * 0.09;
+this.addPheromone(i, city.pheromoneOutput * pull * 0.09);
harvested += extraction;
}
}
@@ -1504,7 +1751,7 @@ const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0
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);
+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)));
@@ -1638,7 +1885,70 @@ distanceBetweenCities(cityA, cityB) {
return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y);
}
hasDirectTradeConnection(cityA, cityB) {
-return !!cityA?.tradeLinks?.has(cityB?.id) || !!cityB?.tradeLinks?.has(cityA?.id);
+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);
@@ -1725,15 +2035,6 @@ history.samples.push(sample);
if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) {
history.peakPower = sample.power;
history.peakYear = this.year;
-const oldEnoughForPeakMarker = this.year - (history.founded ?? this.year) > years(40);
-if (oldEnoughForPeakMarker && (!history.peakEventYear || this.year - history.peakEventYear > years(120))) {
-this.addPolityEvent(polity.id, "peak", this.year, {
-power: sample.power,
-cities: sample.cities,
-population: sample.population
-}, 2);
-history.peakEventYear = this.year;
-}
}
const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears;
while (history.samples.length > maxSamples) history.samples.shift();
@@ -1758,6 +2059,17 @@ 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);
@@ -1785,7 +2097,7 @@ for (const polity of this.polities) {
polity.charisma = clamp(polity.charisma ?? 1, 0.5, 1.5);
polity.leaderStarted ??= polity.founded ?? this.year;
polity.leaderTenureYears ??= this.rng.range(24, 68);
-const tenureYears = (this.year - polity.leaderStarted) / MONTHS_PER_YEAR;
+const 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);
@@ -1979,6 +2291,12 @@ 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 &&
@@ -1988,7 +2306,7 @@ war.ended === null &&
startWar(a, b) {
if (!a || !b || a.id === b.id) return null;
if (this.getWarBetween(a.id, b.id)) return null;
-if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null;
+if (this.isPolityAtWar(a.id) || !this.canBeWarTarget(b.id)) return null;
const id = this.nextWar++;
const war = {
id,
@@ -2093,7 +2411,7 @@ if (distance < best) best = distance;
return best;
}
polityAge(polity) {
-return (this.year - (polity?.founded ?? this.year)) / MONTHS_PER_YEAR;
+return (this.year - (polity?.founded ?? this.year)) / WEEKS_PER_YEAR;
}
polityAgePressure(polity) {
const age = this.polityAge(polity);
@@ -2263,7 +2581,7 @@ absorbed++;
}
}
maybeStartWars() {
-if (this.year % years(10) !== 0) return;
+if (this.year % years(1) !== 0) return;
for (const a of this.polities) {
if (this.isPolityAtWar(a.id)) continue;
const aPower = this.polityPower(a);
@@ -2271,8 +2589,8 @@ if (aPower <= 0) continue;
let started = false;
for (const b of this.polities) {
if (started) break;
-if (a.id >= b.id) continue;
-if (this.isPolityAtWar(b.id)) continue;
+if (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;
@@ -2288,22 +2606,41 @@ 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 generalInstability = Math.max(aInstability, bInstability) * 0.04;
+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.002 +
-proximity * 0.010 +
-asymmetryPressure * 0.010 +
-weakSideInstability * 0.020 +
-weakSideAge * 0.012 +
-weakSideOverextension * 0.012 +
-borderFriction * 0.010 +
-generalInstability;
-if (this.rng.next() < clamp(chance, 0, 0.08)) {
+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;
}
@@ -2535,7 +2872,7 @@ triggerPolityCrises() {
for (const polity of this.polities) {
const age = this.polityAge(polity);
if (age < 140) continue;
-if ((this.year - (polity.lastCrisisYear ?? 0)) / MONTHS_PER_YEAR < 160) continue;
+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;
@@ -2596,7 +2933,9 @@ let delta = 0;
delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045);
delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012;
delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045);
-if (this.hasDirectTradeConnection(city, center)) delta += 0.025;
+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;
@@ -2622,7 +2961,15 @@ const chance =
(threshold - city.loyalty) * 0.28 +
instability * 0.012 +
agePressure * 0.010;
-if (this.rng.next() < chance) this.removeCityFromPolity(city);
+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);
+}
}
}
}
@@ -2757,7 +3104,6 @@ this.cleanupPolities();
if (this.samplePolityHistories) this.samplePolityHistories();
}
reinforcePolityTradeRoutes() {
-const w = this.world;
for (const polity of this.polities) {
if (this.rng.next() > 0.48) continue;
const center = this.getCityById(polity.centerCityId);
@@ -2770,6 +3116,7 @@ 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) {
@@ -2778,13 +3125,7 @@ continue;
}
polity.treasury -= roadCost;
if (!this.payRouteConstructionCost(center, target, path, polity)) continue;
-center.tradeLinks.add(target.id);
-target.tradeLinks.add(center.id);
-this.tradeLinks.push({ from: center.id, to: target.id, strength: 0.22, path });
-for (const tile of path) {
-w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 34);
-w.pheromone[tile] += 2.4;
-}
+this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id);
}
}
updateTradeRoutes() {
@@ -2792,7 +3133,12 @@ const w = this.world;
for (let i = 0; i < w.count; i++) {
const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2;
if (invalidTerrain) w.tradeRoute[i] = 0;
-else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2);
+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();
@@ -2804,7 +3150,7 @@ for (let b = a + 1; b < this.cities.length; b++) {
const c1 = this.cities[a];
const c2 = this.cities[b];
const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
-if (d > 44) continue;
+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;
@@ -2819,32 +3165,43 @@ 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 (!this.canPayRouteConstructionCost(c1, c2, path)) continue;
-const tradeScore = strength * (1 + distanceFactor * 0.7) * marketScale;
-candidates.push({ c1, c2, strength, path, tradeScore });
+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 } = candidate;
+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.payRouteConstructionCost(c1, c2, path)) continue;
-c1.tradeLinks.add(c2.id);
-c2.tradeLinks.add(c1.id);
-this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path });
-this.exchangeCityResources(c1, c2, strength, path);
-for (const tile of path) {
-supportedRoutes.add(tile);
-w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 18);
+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)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 4);
+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++) {
@@ -2852,6 +3209,16 @@ 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);
@@ -2874,7 +3241,7 @@ const path = [];
const visited = new Set();
let x = x1;
let y = y1;
-const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60);
+const maxSteps = Math.min(SimConfig.route.maxRouteLength, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 24);
const directions = [
[1, 0],
[-1, 0],
@@ -2925,12 +3292,28 @@ 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 > 42) return false;
+if (path.length > SimConfig.route.maxRouteLength) return false;
let totalCost = 0;
let hardTiles = 0;
const seen = new Set();
@@ -2950,6 +3333,52 @@ 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) {
@@ -2989,10 +3418,13 @@ 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) {
-const w = this.world;
for (const i of path) {
-w.pheromone[i] += amount;
+this.addPheromone(i, amount);
}
}
updateEthnicStats() {
@@ -3064,225 +3496,6 @@ for (const a of candidates) a.ethnicity = newId;
}
}
}
-toJSON() {
-return {
-version: SimConfig.save.version,
-rngSeed: this.rng.seed,
-year: this.year,
-deaths: this.deaths,
-nextEthnicity: this.nextEthnicity,
-nextCity: this.nextCity,
-nextPolity: this.nextPolity,
-nextWar: this.nextWar,
-nextDisaster: this.nextDisaster,
-maxAgents: this.maxAgents,
-world: {
-size: this.world.size,
-terrain: packArray(this.world.terrain),
-resource: packArray(this.world.resource),
-regen: packArray(this.world.regen),
-move: packArray(this.world.move),
-fertility: packArray(this.world.fertility),
-mineral: packArray(this.world.mineral),
-temperature: packArray(this.world.temperature),
-humidity: packArray(this.world.humidity),
-pheromone: packArray(this.world.pheromone),
-tradeRoute: packArray(this.world.tradeRoute),
-farmland: packArray(this.world.farmland),
-cityPull: packArray(this.world.cityPull),
-city: packArray(this.world.city),
-pressure: packArray(this.world.pressure),
-dominantEthnicity: packArray(this.world.dominantEthnicity),
-cultureDiversity: packArray(this.world.cultureDiversity)
-},
-agents: this.agents,
-ethnicities: [...this.ethnicities.values()].map(e => ({
-id: e.id,
-parent: e.parent,
-born: e.born,
-climateTemp: e.climateTemp,
-climateHumidity: e.climateHumidity,
-color: e.color
-})),
-cities: this.cities.map(c => ({
-...c,
-ethnicityComposition: [...c.ethnicityComposition],
-tradeLinks: [...c.tradeLinks]
-})),
-polities: this.polities.map(p => ({
-id: p.id,
-centerCityId: p.centerCityId,
-cityIds: [...p.cityIds],
-treasury: p.treasury,
-color: p.color,
-founded: p.founded,
-legitimacy: p.legitimacy,
-cohesion: p.cohesion,
-charisma: p.charisma,
-leaderStarted: p.leaderStarted,
-leaderTenureYears: p.leaderTenureYears,
-crisis: p.crisis,
-lastCrisisYear: p.lastCrisisYear,
-lastFamineYear: p.lastFamineYear ?? null
-})),
-wars: this.wars,
-disasters: this.disasters,
-disasterHistory: this.disasterHistory,
-polityHistory: [...this.polityHistory.values()],
-deadPolityHistories: this.deadPolityHistories
-};
-}
-static fromJSON(state) {
-if (!state?.world?.size) throw new Error("Invalid save payload");
-const sim = new Simulation(state.world.size, 0, { blankWorld: true, skipSpawn: true });
-sim.rng.seed = state.rngSeed >>> 0;
-sim.year = state.year || 0;
-sim.deaths = state.deaths || 0;
-sim.nextEthnicity = state.nextEthnicity || 1;
-sim.nextCity = state.nextCity || 1;
-sim.nextPolity = state.nextPolity || 1;
-sim.nextWar = state.nextWar || 1;
-sim.nextDisaster = state.nextDisaster || 1;
-sim.maxAgents = state.maxAgents || 30000;
-sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array));
-sim.world.resource.set(unpackArray(state.world.resource, Float32Array));
-sim.world.regen.set(unpackArray(state.world.regen, Float32Array));
-sim.world.move.set(unpackArray(state.world.move, Float32Array));
-sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array));
-sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array));
-sim.world.temperature.set(unpackArray(state.world.temperature, Float32Array));
-sim.world.humidity.set(unpackArray(state.world.humidity, Float32Array));
-sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array));
-sim.world.tradeRoute.set(unpackArray(state.world.tradeRoute, Uint8Array));
-sim.world.farmland.set(unpackArray(state.world.farmland, Float32Array));
-sim.world.cityPull.set(unpackArray(state.world.cityPull, Float32Array));
-sim.world.city.set(unpackArray(state.world.city, Int32Array));
-if (state.world.pressure) sim.world.pressure.set(unpackArray(state.world.pressure, Float32Array));
-if (state.world.dominantEthnicity) sim.world.dominantEthnicity.set(unpackArray(state.world.dominantEthnicity, Int32Array));
-if (state.world.cultureDiversity) sim.world.cultureDiversity.set(unpackArray(state.world.cultureDiversity, Float32Array));
-sim.agents = state.agents || [];
-for (const a of sim.agents) sim.ensureAgentTech(a);
-sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, {
-id: e.id,
-parent: e.parent,
-born: e.born,
-population: 0,
-diversity: 0,
-climateTemp: e.climateTemp,
-climateHumidity: e.climateHumidity,
-color: e.color,
-centroidX: 0,
-centroidY: 0
-}]));
-sim.cities = (state.cities || []).map(c => ({
-id: c.id,
-x: c.x,
-y: c.y,
-population: c.population || 0,
-storedResources: c.storedResources || 0,
-ethnicityComposition: new Map(c.ethnicityComposition || []),
-pheromoneOutput: c.pheromoneOutput || 0,
-agriculturalRadius: c.agriculturalRadius || 2,
-tradeLinks: new Set(c.tradeLinks || []),
-activeVisitors: 0,
-age: c.age || 0,
-strength: c.strength || 1,
-sedentaryCulture: c.sedentaryCulture ?? 0.5,
-knowledge: {
-farming: clamp(c.knowledge?.farming ?? 0, 0, 1),
-metallurgy: clamp(c.knowledge?.metallurgy ?? 0, 0, 1)
-},
-supplyStress: c.supplyStress ?? 0,
-polityId: c.polityId ?? null,
-loyalty: c.loyalty ?? 0.5,
-receivedAid: c.receivedAid ?? false,
-tradeValue: c.tradeValue ?? 0,
-tradeReach: c.tradeReach ?? 0
-}));
-sim.polities = (state.polities || []).map(p => ({
-id: p.id,
-centerCityId: p.centerCityId,
-cityIds: new Set(p.cityIds || []),
-treasury: p.treasury || 0,
-color: p.color || hslToRgb((p.id * 0.38196601125) % 1, 0.58, 0.62),
-founded: p.founded || 0,
-legitimacy: p.legitimacy ?? 0.7,
-cohesion: p.cohesion ?? 0.6,
-charisma: clamp(p.charisma ?? 1, 0.5, 1.5),
-leaderStarted: p.leaderStarted ?? p.founded ?? sim.year,
-leaderTenureYears: clamp(p.leaderTenureYears ?? 48, 12, 96),
-crisis: p.crisis ?? 0,
-lastCrisisYear: p.lastCrisisYear ?? sim.year,
-lastFamineYear: p.lastFamineYear ?? null
-}));
-sim.rebuildIndexes();
-sim.wars = (state.wars || []).map(w => ({
-id: w.id,
-aPolityId: w.aPolityId,
-bPolityId: w.bPolityId,
-started: w.started || sim.year,
-lastActionYear: w.lastActionYear || w.started || sim.year,
-intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25),
-exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1),
-exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1),
-ended: w.ended ?? null
-})).filter(w => w.ended === null);
-const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0);
-sim.nextWar = Math.max(sim.nextWar, maxWarId + 1);
-const restoreDisaster = d => ({
-id: d.id,
-year: d.year || 0,
-x: d.x || 0,
-y: d.y || 0,
-radius: d.radius || 8,
-intensity: d.intensity || 0.5,
-expires: d.expires ?? ((d.year || 0) + years(18)),
-affectedAgents: d.affectedAgents || 0,
-affectedCities: d.affectedCities || 0,
-totalPopulationLoss: d.totalPopulationLoss || 0,
-affectedPolities: d.affectedPolities || []
-});
-sim.disasters = (state.disasters || []).map(restoreDisaster);
-sim.disasterHistory = (state.disasterHistory || []).map(restoreDisaster);
-const maxDisasterId = [...sim.disasters, ...sim.disasterHistory].reduce((max, disaster) => Math.max(max, disaster.id || 0), 0);
-sim.nextDisaster = Math.max(sim.nextDisaster, maxDisasterId + 1);
-sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, {
-id: h.id,
-color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
-founded: h.founded || 0,
-ended: h.ended ?? null,
-active: h.active !== false,
-centerCityId: h.centerCityId ?? null,
-fate: h.fate || null,
-samples: h.samples || [],
-events: h.events || [],
-peakPower: h.peakPower || 0,
-peakYear: h.peakYear ?? null,
-peakEventYear: h.peakEventYear ?? null
-}]));
-sim.deadPolityHistories = (state.deadPolityHistories || []).map(h => ({
-id: h.id,
-color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
-founded: h.founded || 0,
-ended: h.ended ?? null,
-active: false,
-centerCityId: h.centerCityId ?? null,
-fate: h.fate || null,
-samples: h.samples || [],
-events: h.events || [],
-peakPower: h.peakPower || 0,
-peakYear: h.peakYear ?? null,
-peakEventYear: h.peakEventYear ?? null
-}));
-for (const polity of sim.polities) sim.ensurePolityHistory(polity);
-sim.tradeLinks = [];
-sim.activeTradeRouteTiles = new Set();
-sim.rebuildOccupancy();
-sim.updateTradeRoutes();
-sim.cleanupPolities();
-sim.updateEthnicStats();
-return sim;
-}
}
function render() {
const start = performance.now();
@@ -3299,7 +3512,7 @@ const cityPolityColor = new Map();
if (mode === "polities") {
for (const city of sim.cities) {
if (city.polityId === null) continue;
-const polity = sim.getPolityById(city.polityId);
+const polity = cityPolity(city);
if (!polity) continue;
const isGraphHover = graphState.hoverPolityId === polity.id;
cityPolityColor.set(city.id, {
@@ -3315,6 +3528,10 @@ 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);
@@ -3329,6 +3546,9 @@ if (graphState.hoverPolityId !== null && !polityColor.isGraphHover) color = mix(
}
} 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;
}
@@ -3363,23 +3583,44 @@ gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`);
gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`);
gradient.addColorStop(1, "rgba(211, 95, 84, 0)");
ctx.fillStyle = gradient;
-ctx.beginPath();
-ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2);
-ctx.fill();
+drawGraphCircle(ctx, disaster.x, disaster.y, disaster.radius);
ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`;
ctx.lineWidth = 1;
-ctx.beginPath();
-ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2);
-ctx.stroke();
+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) {
@@ -3397,30 +3638,32 @@ 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]}, ${color[1]}, ${color[2]}, 0.72)`;
+ctx.fillStyle = rgba(color, 0.72);
ctx.globalAlpha = 0.88;
-ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
+fillSquare(ctx, city.x, city.y, radius);
+}
}
ctx.globalAlpha = 1;
if (mode === "technology") {
for (const a of sim.agents) {
sim.ensureAgentTech(a);
-const farming = a.tech.farming || 0;
-const metallurgy = a.tech.metallurgy || 0;
+const 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[0]},${color[1]},${color[2]},${clamp(0.32 + tech * 0.68, 0.32, 1)})`;
+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[0]},${e.color[1]},${e.color[2]})`;
+ctx.fillStyle = rgb(e.color);
ctx.fillRect(a.x, a.y, 1, 1);
}
} else {
@@ -3430,8 +3673,16 @@ 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.fillStyle = `rgb(${Math.min(255, color[0] + 55)}, ${Math.min(255, color[1] + 55)}, ${Math.min(255, color[2] + 55)})`;
+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();
@@ -3449,10 +3700,10 @@ for (const city of cities) {
const radius = Math.max(3, cityRenderRadius(city) + 2);
const color = polity.color || [238, 232, 188];
ctx.globalAlpha = city.id === polity.centerCityId ? 0.95 : 0.72;
-ctx.strokeStyle = `rgb(${Math.min(255, color[0] + 72)}, ${Math.min(255, color[1] + 72)}, ${Math.min(255, color[2] + 72)})`;
+ctx.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[0]}, ${color[1]}, ${color[2]})`;
+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();
@@ -3464,16 +3715,23 @@ 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") {
-if (city.polityId !== null) {
-const polity = sim.getPolityById(city.polityId);
+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();
@@ -3489,6 +3747,13 @@ 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;
@@ -3506,53 +3771,48 @@ const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null;
const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2);
const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
-const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
+const polity = cityPolity(city);
const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-";
-const rows = items => items.filter(Boolean).join("");
-const row = (label, value) => `${label}${value}`;
-const section = (title, items) => {
-const body = rows(items);
-return body ? `` : "";
-};
els.tooltip.innerHTML = `
${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name}
- ${section("Tile", [
- row("Position", `${x}, ${y}`),
- row("Terrain", terrain.name),
- row("Resources", w.resource[i].toFixed(1)),
- row("Fertility", w.fertility[i].toFixed(2)),
- row("Minerals", w.mineral[i].toFixed(2)),
- row("Temp / humid", `${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}`),
- row("Water", waterInfluence),
- row("Pressure", w.pressure[i].toFixed(0)),
- row("Pheromone", w.pheromone[i].toFixed(1)),
- w.tradeRoute[i] ? row("Route", w.tradeRoute[i]) : ""
+ ${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])
])}
- ${section("City & State", [
- city ? row("Population", city.population.toLocaleString()) : "",
- city ? row("Food stock", city.storedResources.toFixed(1)) : "",
- city ? row("Supply stress", (city.supplyStress || 0).toFixed(2)) : "",
- city ? row("Trade", `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value`) : "",
- city ? row("Knowledge", `${(city.knowledge?.farming || 0).toFixed(2)} farm / ${(city.knowledge?.metallurgy || 0).toFixed(2)} metal`) : "",
- cityEthnicity ? row("City majority", `E${cityEthnicity}`) : "",
- city ? row("State", polity ? `#${polity.id}` : "Independent") : "",
- city ? row("Loyalty", city.loyalty.toFixed(2)) : "",
- polity ? row("Charisma", clamp(polity.charisma ?? 1, 0.5, 1.5).toFixed(2)) : "",
- polity ? row("Leader tenure", `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / MONTHS_PER_YEAR)}y`) : "",
- polity ? row("Treasury", polity.treasury.toFixed(1)) : "",
- polity ? row("Capital", polity.centerCityId === city.id ? "yes" : "no") : ""
+ ${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)
])}
- ${section("Culture & Agent", [
- row("Local culture", `${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}`),
- agent ? row("Agent ethnicity", `E${agent.ethnicity}`) : "",
- ethnicity ? row("Lineage pop", ethnicity.population) : "",
- ethnicity ? row("Climate pref", `${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}`) : "",
- agent ? row("Climate mismatch", mismatch.toFixed(2)) : "",
- agent ? row("Sedentary", getSedentary(agent.traits).toFixed(2)) : "",
- agent ? row("Ethnocentrism", getEthnocentrism(agent.traits).toFixed(2)) : "",
- agent ? row("Farming", `${(agent.tech?.farming || 0).toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}`) : "",
- agent ? row("Metallurgy", (agent.tech?.metallurgy || 0).toFixed(3)) : "",
- agent ? row("Stored", agent.resources.toFixed(1)) : ""
+ ${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;
@@ -3566,6 +3826,38 @@ if (top + height + margin > hoverState.height) top = hoverState.height - height
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;
@@ -3600,23 +3892,28 @@ let farmingHolders = 0;
let metallurgyHolders = 0;
for (const a of sim.agents) {
sim.ensureAgentTech(a);
-farmingTotal += a.tech.farming || 0;
-metallurgyTotal += a.tech.metallurgy || 0;
-if ((a.tech.farming || 0) > 0.02) farmingHolders++;
-if ((a.tech.metallurgy || 0) > 0.02) metallurgyHolders++;
+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);
-els.year.textContent = formatSimDate(sim.year);
-els.activeGroups.textContent = sim.agents.length.toLocaleString();
-els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString();
-els.ethnicities.textContent = livingEthnicities.length.toLocaleString();
-els.cities.textContent = sim.cities.length.toLocaleString();
-els.polities.textContent = sim.polities.length.toLocaleString();
-if (els.wars) els.wars.textContent = sim.wars.length.toLocaleString();
-els.routes.textContent = sim.tradeLinks.length.toLocaleString();
-els.farmingKnowledge.textContent = `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`;
-els.metallurgyKnowledge.textContent = `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`;
-els.deaths.textContent = sim.deaths.toLocaleString();
+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 => (
`` +
@@ -3626,19 +3923,7 @@ maybeRenderStateGraph();
}
function setLegend() {
const mode = els.viewMode.value;
-if (mode === "terrain") {
-els.legend.innerHTML = terrainInfo.map(t => `${t.name}`).join("");
-} else if (mode === "ethnicity") {
-els.legend.innerHTML = "Agent and city colors show lineage. Land remains terrain-colored.";
-} else if (mode === "pressure") {
-els.legend.innerHTML = "High local population pressure";
-} else if (mode === "polities") {
-els.legend.innerHTML = "Color = city-centered state. Uncolored cities are independent.";
-} else if (mode === "technology") {
-els.legend.innerHTML = "Farming knowledgeMetallurgy knowledge";
-} else {
-els.legend.innerHTML = "Regenerating local resource stock";
-}
+els.legend.innerHTML = (legendByMode[mode] || legendByMode.resources)();
}
function maybeRenderStateGraph() {
const now = performance.now();
@@ -3652,17 +3937,29 @@ 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) {
-if (metric === "population") return sample?.population || 0;
-if (metric === "cities") return sample?.cities || 0;
-if (metric === "loyalty") return sample?.avgLoyalty ?? 0.5;
-return sampleGraphPower(sample);
+return (graphMetrics[metric] || graphMetrics.power).value(sample);
}
function formatGraphValue(value, metric) {
-if (metric === "loyalty") return `${Math.round(clamp(value, 0, 1) * 100)}%`;
-if (metric === "cities") return Math.round(value).toLocaleString();
-if (metric === "population") return Math.round(value).toLocaleString();
-return value.toFixed(1);
+return (graphMetrics[metric] || graphMetrics.power).format(value);
}
function renderStateGraph() {
const canvas = els.stateGraph;
@@ -3827,10 +4124,7 @@ graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, wid
const axisY = h - paddingBottom + 4;
g.strokeStyle = "rgba(168, 177, 170, 0.22)";
g.lineWidth = 1;
-g.beginPath();
-g.moveTo(paddingLeft, axisY);
-g.lineTo(w - paddingRight, axisY);
-g.stroke();
+strokeLine(g, paddingLeft, axisY, w - paddingRight, axisY);
g.fillStyle = "#8c9690";
g.font = "10px ui-sans-serif, system-ui, sans-serif";
g.textAlign = "left";
@@ -3839,26 +4133,34 @@ 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, 0.16)";
-g.lineWidth = 1;
-g.beginPath();
-g.moveTo(paddingLeft, row.y);
-g.lineTo(w - paddingRight, row.y);
-g.stroke();
-return;
}
-g.strokeStyle = "rgba(168, 177, 170, 0.12)";
+g.strokeStyle = `rgba(168, 177, 170, ${alpha})`;
g.lineWidth = 1;
-g.beginPath();
-g.moveTo(paddingLeft, row.y);
-g.lineTo(w - paddingRight, row.y);
-g.stroke();
+strokeLine(g, paddingLeft, row.y, w - paddingRight, row.y);
});
rows.forEach(row => {
if (row.type !== "state") return;
@@ -3882,16 +4184,10 @@ 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)})`;
-g.beginPath();
-g.moveTo(yearToX(fromYear), row.y);
-g.lineTo(yearToX(toYear), row.y);
-g.stroke();
+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);
-g.beginPath();
-g.moveTo(yearToX(fromYear), row.y);
-g.lineTo(yearToX(toYear), row.y);
-g.stroke();
+strokeLine(g, yearToX(fromYear), row.y, yearToX(toYear), row.y);
g.lineCap = "butt";
};
if (row.samples.length > 1) {
@@ -3923,7 +4219,7 @@ 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 => ["capitalShift", "disaster", "peak", "famine"].includes(event.type));
+.filter(event => graphEventTypes[event.type]);
const collisions = new Map();
for (const event of events) {
const x = yearToX(event.year);
@@ -3932,19 +4228,9 @@ 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;
-if (event.type === "capitalShift") {
-g.fillStyle = "rgba(227, 179, 65, 0.95)";
-drawGraphDiamond(g, x, y, r);
-} else if (event.type === "disaster") {
-g.fillStyle = "rgba(211, 95, 84, 0.95)";
-drawGraphCircle(g, x, y, r);
-} else if (event.type === "peak") {
-g.fillStyle = "rgba(238, 241, 237, 0.90)";
-drawGraphTriangleUp(g, x, y, r);
-} else if (event.type === "famine") {
-g.fillStyle = "rgba(227, 128, 65, 0.90)";
-drawGraphTriangleDown(g, x, y, r);
-}
+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 });
}
});
@@ -3954,58 +4240,67 @@ g.beginPath();
g.arc(x, y, r, 0, Math.PI * 2);
g.fill();
}
-function drawGraphDiamond(g, x, y, r) {
+function drawGraphPolygon(g, points) {
g.beginPath();
-g.moveTo(x, y - r);
-g.lineTo(x + r, y);
-g.lineTo(x, y + r);
-g.lineTo(x - r, y);
+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) {
-g.beginPath();
-g.moveTo(x, y - r);
-g.lineTo(x + r, y + r);
-g.lineTo(x - r, y + r);
-g.closePath();
-g.fill();
+drawGraphPolygon(g, [[x, y - r], [x + r, y + r], [x - r, y + r]]);
}
function drawGraphTriangleDown(g, x, y, r) {
-g.beginPath();
-g.moveTo(x, y + r);
-g.lineTo(x + r, y - r);
-g.lineTo(x - r, y - r);
-g.closePath();
-g.fill();
+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) {
-if (event.type === "capitalShift") return "capital shift";
-if (event.type === "disaster") return "major disaster";
-if (event.type === "peak") return "peak power";
-if (event.type === "famine") return "famine";
-return event.type;
+return graphEventTypes[event.type]?.label || event.type;
}
function graphEventSummary(event) {
-const data = event.data || {};
-if (event.type === "capitalShift") {
-return `capital #${data.oldCenterCityId ?? "-"} to #${data.newCenterCityId ?? "-"}`;
-}
-if (event.type === "disaster") {
-return `lost ${Math.round((data.lossRate || 0) * 100)}%, ${Math.round(data.populationLoss || 0).toLocaleString()} people, ${data.affectedCities || 0} cities`;
-}
-if (event.type === "peak") {
-return `power ${Number(data.power || 0).toFixed(1)}, pop ${Math.round(data.population || 0).toLocaleString()}, cities ${data.cities || 0}`;
-}
-if (event.type === "famine") {
-return `poor cities ${Math.round((data.poorRate || 0) * 100)}%, food ${Number(data.avgPerCapitaFood || 0).toFixed(3)}/cap`;
-}
-return "";
+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 = `
- S${marker.row.history.id} ${graphEventLabel(marker.event)}
+ ${title}
${formatGraphYear(marker.event.year)}
${graphEventSummary(marker.event)}
`;
@@ -4026,23 +4321,32 @@ 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)
-.sort((a, b) => a.distance - b.distance)[0]?.candidate;
-if (marker) {
-const status = marker.row.history.active && marker.row.history.ended === null ? "living" : "past";
-els.stateGraphInfo.textContent = `S${marker.row.history.id} ${status} ${formatGraphYear(marker.event.year)} ${graphEventLabel(marker.event)}`;
-showStateGraphMarkerTooltip(marker, x, y);
-if (graphState.hoverPolityId !== marker.row.history.id) {
-graphState.hoverPolityId = marker.row.history.id;
+.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)
-.sort((a, b) => a.distance - b.distance)[0]?.candidate;
-if (!row) {
+.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) {
@@ -4051,26 +4355,24 @@ if (!running) render();
}
return;
}
-if (graphState.hoverPolityId !== row.history.id) {
-graphState.hoverPolityId = row.history.id;
+if (graphState.hoverPolityId !== nearestRow.history.id) {
+graphState.hoverPolityId = nearestRow.history.id;
if (!running) render();
}
hideStateGraphMarkerTooltip();
const year = graphState.scale.xToYear(x);
-const samples = row.samples.length ? row.samples : [{
+const samples = nearestRow.samples.length ? nearestRow.samples : [{
year,
population: 0,
-cities: row.peakCities,
+cities: nearestRow.peakCities,
avgLoyalty: 0.5,
-power: row.peakPower
+power: nearestRow.peakPower
}];
-const sample = samples
-.map(candidate => ({ candidate, distance: Math.abs((candidate.year ?? year) - year) }))
-.sort((a, b) => a.distance - b.distance)[0].candidate;
+const sample = bestBy(samples, candidate => -Math.abs((candidate.year ?? year) - year));
const metricValue = graphSampleValue(sample, graphState.metric);
-const status = row.history.active && row.history.ended === null ? "living" : "past";
+const status = nearestRow.history.active && nearestRow.history.ended === null ? "living" : "past";
els.stateGraphInfo.textContent =
-`S${row.history.id} ${status} ${formatGraphYear(sample.year ?? year)} ` +
+`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()} ` +
@@ -4084,13 +4386,15 @@ graphState.hoverPolityId = null;
if (!running) render();
}
}
-function formatGraphYear(month) {
-return `${Math.floor(month / MONTHS_PER_YEAR).toLocaleString()}y`;
+function formatGraphYear(week) {
+return `${Math.floor(week / WEEKS_PER_YEAR).toLocaleString()}y`;
}
-function formatSimDate(month) {
-const year = Math.floor(month / MONTHS_PER_YEAR);
-const monthOfYear = month % MONTHS_PER_YEAR + 1;
-return `${year.toLocaleString()}y ${monthOfYear}m`;
+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;
@@ -4122,19 +4426,19 @@ 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 > LoopConfig.stepBudgetMs) break;
+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();
-frame++;
scheduleLoop();
}
function reset() {
@@ -4152,64 +4456,69 @@ 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);
}
-function getSedentary(traits) {
-return traits.sedentary;
-}
-function getEthnocentrism(traits) {
-return traits.ethnocentrism;
-}
+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 {
-mobility: clamp(traits.mobility + rng.range(-amount, amount), 0.02, 1),
-resourceAttraction: clamp(traits.resourceAttraction + rng.range(-amount, amount), 0.05, 1.2),
-assimilation: clamp(traits.assimilation + rng.range(-amount, amount), 0, 0.75),
-ethnocentrism: clamp(getEthnocentrism(traits) + rng.range(-amount, amount), 0, 1.2),
-reproductionThreshold: clamp(traits.reproductionThreshold + rng.range(-amount * 16, amount * 16), 12, 48),
-sedentary: clamp(getSedentary(traits) + rng.range(-amount, amount), 0, 1)
-};
+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 {
-mobility: lerp(a.mobility, b.mobility, t),
-resourceAttraction: lerp(a.resourceAttraction, b.resourceAttraction, t),
-assimilation: lerp(a.assimilation, b.assimilation, t),
-ethnocentrism: lerp(getEthnocentrism(a), getEthnocentrism(b), t),
-reproductionThreshold: lerp(a.reproductionThreshold, b.reproductionThreshold, t),
-sedentary: lerp(getSedentary(a), getSedentary(b), t)
-};
+return Object.fromEntries(traitFields.map(([key, get]) => [key, lerp(get(a), get(b), t)]));
}
function emptyTraitSums() {
-return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, sedentary: 0 };
+return Object.fromEntries(traitFields.map(([key]) => [key, 0]));
}
function addTraits(sum, traits) {
-sum.mobility += traits.mobility;
-sum.resourceAttraction += traits.resourceAttraction;
-sum.assimilation += traits.assimilation;
-sum.ethnocentrism += getEthnocentrism(traits);
-sum.reproductionThreshold += traits.reproductionThreshold / 48;
-sum.sedentary += getSedentary(traits);
+for (const [key, get, , , , sumScale] of traitFields) sum[key] += get(traits) * sumScale;
}
function averageTraits(sum, count) {
-return {
-mobility: sum.mobility / count,
-resourceAttraction: sum.resourceAttraction / count,
-assimilation: sum.assimilation / count,
-ethnocentrism: sum.ethnocentrism / count,
-reproductionThreshold: (sum.reproductionThreshold / count) * 48,
-sedentary: sum.sedentary / count
-};
+return Object.fromEntries(traitFields.map(([key, , , , , sumScale]) => [key, sum[key] / count / sumScale]));
}
-function addBirthsToComposition(composition, births) {
-const dominant = dominantComposition(composition);
-if (!dominant) return;
-composition.set(dominant, (composition.get(dominant) || 0) + births);
+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 = [...composition.values()].reduce((sum, value) => sum + value, 0);
+const total = compositionTotal(composition);
if (!total) return;
for (const [id, count] of [...composition]) {
const removed = Math.min(count, Math.ceil(loss * (count / total)));
@@ -4231,12 +4540,7 @@ bestCount = count;
return bestId;
}
function traitDistance(a, b) {
-return Math.abs(a.mobility - b.mobility) +
-Math.abs(a.resourceAttraction - b.resourceAttraction) +
-Math.abs(a.assimilation - b.assimilation) +
-Math.abs(getEthnocentrism(a) - getEthnocentrism(b)) +
-Math.abs(a.reproductionThreshold - b.reproductionThreshold) / 48 +
-Math.abs(getSedentary(a) - getSedentary(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) => {
@@ -4265,70 +4569,9 @@ Math.round(lerp(a[2], b[2], t))
function lerp(a, b, t) {
return a + (b - a) * t;
}
-function packArray(typedArray) {
-const bytes = new Uint8Array(typedArray.buffer);
-let binary = "";
-const chunkSize = 8192;
-for (let i = 0; i < bytes.length; i += chunkSize) {
-binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
-}
-return {
-type: typedArray.constructor.name,
-length: typedArray.length,
-data: btoa(binary)
-};
-}
-function unpackArray(payload, TypedArray) {
-const binary = atob(payload.data);
-const bytes = new Uint8Array(binary.length);
-for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
-return new TypedArray(bytes.buffer, 0, payload.length);
-}
-const Persistence = Object.freeze({
-save(currentSim) {
-const raw = JSON.stringify(currentSim);
-if (raw.length > SimConfig.save.maxBytes) {
-throw new Error(`Save is too large (${raw.length.toLocaleString()} bytes)`);
-}
-localStorage.setItem(SimConfig.save.key, raw);
-},
-load() {
-const raw = localStorage.getItem(SimConfig.save.key);
-if (!raw) return null;
-if (raw.length > SimConfig.save.maxBytes * 1.25) {
-throw new Error("Saved world is too large to load safely");
-}
-const state = JSON.parse(raw);
-if (!state || !state.world || !state.agents) throw new Error("Saved world is missing required fields");
-return Simulation.fromJSON(state);
-},
-clear() {
-localStorage.removeItem(SimConfig.save.key);
-}
-});
-function saveWorld() {
-try {
-Persistence.save(sim);
-} catch (error) {
-console.warn("Save failed", error);
-}
-}
-function loadWorld() {
-try {
-const loaded = Persistence.load();
-if (!loaded) return;
-sim = loaded;
-els.worldSize.value = String(sim.world.size);
-els.canvas.width = sim.world.size;
-els.canvas.height = sim.world.size;
-renderImage = null;
-renderImageSize = 0;
-setLegend();
-drawFrame(true);
+function refreshStateGraph() {
renderStateGraph();
-} catch (error) {
-console.warn("Load failed", error);
-}
+clearStateGraphInfo();
}
els.toggleRun.addEventListener("click", () => {
running = !running;
@@ -4350,11 +4593,7 @@ drawFrame(true);
renderStateGraph();
});
els.resetWorld.addEventListener("click", reset);
-els.saveWorld.addEventListener("click", saveWorld);
-els.loadWorld.addEventListener("click", loadWorld);
-els.clearSave.addEventListener("click", () => Persistence.clear());
-els.worldSize.addEventListener("change", reset);
-els.agentCount.addEventListener("change", reset);
+for (const control of [els.worldSize, els.agentCount]) control.addEventListener("change", reset);
els.viewMode.addEventListener("change", () => {
setLegend();
requestRender();
@@ -4363,22 +4602,17 @@ drawFrame();
els.canvas.addEventListener("mousemove", showTooltip);
els.canvas.addEventListener("mouseleave", hideTooltip);
for (const control of [els.historyFilter, els.historySort, els.historyMetric]) {
-control?.addEventListener("change", () => {
-renderStateGraph();
-clearStateGraphInfo();
-});
+control?.addEventListener("change", refreshStateGraph);
}
els.toggleActiveStates?.addEventListener("click", () => {
graphState.showActive = !graphState.showActive;
els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive));
-renderStateGraph();
-clearStateGraphInfo();
+refreshStateGraph();
});
els.togglePastStates?.addEventListener("click", () => {
graphState.showPast = !graphState.showPast;
els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast));
-renderStateGraph();
-clearStateGraphInfo();
+refreshStateGraph();
});
els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo);
els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo);
diff --git a/styles.css b/styles.css
index 1e8814b..1b770d9 100644
--- a/styles.css
+++ b/styles.css
@@ -412,6 +412,43 @@ canvas#world {
text-align: right;
}
+.ethnicity-pie-wrap {
+ display: grid;
+ grid-template-columns: 44px 1fr;
+ align-items: center;
+ gap: 8px;
+ margin-top: 6px;
+}
+
+.ethnicity-pie {
+ width: 40px;
+ height: 40px;
+ border: 1px solid rgba(238, 241, 237, 0.28);
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
+}
+
+.ethnicity-pie-labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 3px 7px;
+}
+
+.tooltip .ethnicity-pie-key {
+ display: inline-flex;
+ justify-content: flex-start;
+ align-items: center;
+ gap: 4px;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.ethnicity-pie-key i {
+ width: 8px;
+ height: 8px;
+ border-radius: 2px;
+}
+
@media (max-width: 1100px) {
body {
overflow: auto;