diff --git a/index.html b/index.html
index ff778b3..96574af 100644
--- a/index.html
+++ b/index.html
@@ -120,6 +120,7 @@
Hover a row for details
diff --git a/script.js b/script.js
index 2dad4bc..f502b26 100644
--- a/script.js
+++ b/script.js
@@ -63,6 +63,20 @@ const SimConfig = Object.freeze({
cityWeight: 0.035,
routeWeight: 1.35,
minimumInfluence: 0.18
+ }),
+ disaster: Object.freeze({
+ checkIntervalYears: 8,
+ baseChance: 0.38,
+ minRadius: 6,
+ maxRadius: 34,
+ rareLargeChance: 0.10,
+ largeRadiusMin: 28,
+ largeRadiusMax: 54,
+ minIntensity: 0.25,
+ maxIntensity: 0.95,
+ visualDurationYears: 18,
+ maxActiveVisuals: 12,
+ maxHistory: 160
})
});
@@ -105,7 +119,8 @@ const els = {
historyMetric: document.getElementById("historyMetric"),
toggleActiveStates: document.getElementById("toggleActiveStates"),
togglePastStates: document.getElementById("togglePastStates"),
- stateGraphInfo: document.getElementById("stateGraphInfo")
+ stateGraphInfo: document.getElementById("stateGraphInfo"),
+ stateGraphTooltip: document.getElementById("stateGraphTooltip")
};
const ctx = els.canvas.getContext("2d", { alpha: false });
@@ -125,6 +140,7 @@ const graphState = {
showPast: true,
hoverPolityId: null,
rows: [],
+ markers: [],
scale: null
};
@@ -455,6 +471,8 @@ class Simulation {
this.polities = [];
this.polityById = new Map();
this.wars = [];
+ this.disasters = [];
+ this.disasterHistory = [];
this.polityHistory = new Map();
this.deadPolityHistories = [];
this.tradeLinks = [];
@@ -465,6 +483,7 @@ class Simulation {
this.nextCity = 1;
this.nextPolity = 1;
this.nextWar = 1;
+ this.nextDisaster = 1;
this.year = 0;
this.deaths = 0;
this.maxAgents = Math.max(
@@ -645,6 +664,7 @@ class Simulation {
for (const child of acceptedOffspring) this.addAgentToOccupancy(child);
if (this.year % 2 === 0) this.updateWorldFields(2);
+ this.maybeSpawnDisaster();
if (this.year % years(1) === 0) {
this.updateCities();
if (this.year % years(2) === 0) this.updateRegionalCultures();
@@ -1774,10 +1794,19 @@ class Simulation {
active: true,
centerCityId: polity.centerCityId ?? null,
fate: null,
- samples: []
+ samples: [],
+ events: [],
+ peakPower: 0,
+ peakYear: null,
+ peakEventYear: null
});
}
- return this.polityHistory.get(polity.id);
+ const history = this.polityHistory.get(polity.id);
+ history.events ??= [];
+ history.peakPower ??= 0;
+ history.peakYear ??= null;
+ history.peakEventYear ??= null;
+ return history;
}
averagePolityLoyalty(polity) {
@@ -1798,14 +1827,28 @@ class Simulation {
Math.sqrt(Math.max(0, treasury)) * 1.15 +
avgLoyalty * 16;
history.centerCityId = polity.centerCityId ?? history.centerCityId;
- history.samples.push({
+ const sample = {
year: this.year,
cities: cities.length,
population,
treasury,
avgLoyalty,
power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower
- });
+ };
+ history.samples.push(sample);
+ if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) {
+ history.peakPower = sample.power;
+ history.peakYear = this.year;
+ const oldEnoughForPeakMarker = this.year - (history.founded ?? this.year) > years(40);
+ if (oldEnoughForPeakMarker && (!history.peakEventYear || this.year - history.peakEventYear > years(120))) {
+ this.addPolityEvent(polity.id, "peak", this.year, {
+ power: sample.power,
+ cities: sample.cities,
+ population: sample.population
+ }, 2);
+ history.peakEventYear = this.year;
+ }
+ }
const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears;
while (history.samples.length > maxSamples) history.samples.shift();
}
@@ -1814,6 +1857,24 @@ class Simulation {
for (const polity of this.polities) this.samplePolityHistory(polity);
}
+ addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) {
+ let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null;
+ if (!history) {
+ const polity = this.getPolityById(polityId);
+ if (polity) history = this.ensurePolityHistory(polity);
+ }
+ if (!history) return;
+ history.events ??= [];
+ const sameYearDuplicate = history.events.some(event =>
+ event.type === type &&
+ event.year === year &&
+ JSON.stringify(event.data || {}) === JSON.stringify(data || {})
+ );
+ if (sameYearDuplicate) return;
+ history.events.push({ year, type, importance, data });
+ while (history.events.length > 120) history.events.shift();
+ }
+
selectNewLeader(polity, forced = false) {
if (!polity) return;
const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5);
@@ -1853,6 +1914,161 @@ class Simulation {
}
}
+ maybeSpawnDisaster() {
+ const config = SimConfig.disaster;
+ const interval = years(config?.checkIntervalYears ?? 8);
+ if (!interval || this.year % interval !== 0) return;
+ if (this.rng.next() > (config?.baseChance ?? 0.38)) return;
+ this.spawnDisaster();
+ }
+
+ spawnDisaster() {
+ const config = SimConfig.disaster;
+ const large = this.rng.next() < (config?.rareLargeChance ?? 0.10);
+ const radius = large
+ ? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54)
+ : this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34);
+ const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95);
+ this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity);
+ }
+
+ applyDisaster(x, y, radius, intensity) {
+ const config = SimConfig.disaster;
+ const w = this.world;
+ const affectedPolityMap = new Map();
+ const affectedPolityIds = new Set();
+ for (const city of this.cities) {
+ if (city.polityId === null) continue;
+ if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId);
+ }
+ const polityPopulationBefore = new Map();
+ for (const id of affectedPolityIds) {
+ const polity = this.getPolityById(id);
+ if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity));
+ }
+
+ let affectedAgents = 0;
+ for (const agent of this.agents) {
+ if (!agent.alive) continue;
+ const d = Math.hypot(agent.x - x, agent.y - y);
+ if (d > radius) continue;
+ const falloff = 1 - d / radius;
+ const damage = clamp(intensity * falloff * falloff, 0, 1);
+ if (this.rng.next() < damage * 0.32) {
+ if (this.removeAgentFromOccupancy) this.removeAgentFromOccupancy(agent);
+ agent.alive = false;
+ this.deaths++;
+ affectedAgents++;
+ } else {
+ agent.resources = Math.max(0, agent.resources * (1 - damage * 0.45));
+ if (agent.tech) {
+ agent.tech.farming = Math.max(0, agent.tech.farming - damage * 0.035);
+ agent.tech.metallurgy = Math.max(0, agent.tech.metallurgy - damage * 0.04);
+ }
+ }
+ }
+
+ let affectedCities = 0;
+ let totalPopulationLoss = 0;
+ for (const city of this.cities) {
+ const d = Math.hypot(city.x - x, city.y - y);
+ if (d > radius) continue;
+ const falloff = 1 - d / radius;
+ const damage = clamp(intensity * falloff * falloff, 0, 1);
+ if (damage <= 0) continue;
+ affectedCities++;
+ const popBefore = city.population || 0;
+ const lossRate = clamp(damage * 0.25, 0, 0.45);
+ const loss = Math.floor(popBefore * lossRate);
+ city.population = Math.max(0, popBefore - loss);
+ if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss);
+ city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55));
+ city.strength = Math.max(0, city.strength - damage * 0.38);
+ this.deaths += Math.floor(loss * 0.35);
+ totalPopulationLoss += loss;
+
+ if (city.polityId !== null) {
+ let entry = affectedPolityMap.get(city.polityId);
+ if (!entry) {
+ entry = {
+ polityId: city.polityId,
+ populationBefore: polityPopulationBefore.get(city.polityId) || 0,
+ populationLoss: 0,
+ cityIds: new Set()
+ };
+ affectedPolityMap.set(city.polityId, entry);
+ }
+ entry.populationLoss += loss;
+ entry.cityIds.add(city.id);
+ const polity = this.getPolityById(city.polityId);
+ if (polity) {
+ polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5);
+ polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1);
+ polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1);
+ }
+ }
+ }
+
+ const minX = Math.max(0, Math.floor(x - radius));
+ const maxX = Math.min(w.size - 1, Math.ceil(x + radius));
+ const minY = Math.max(0, Math.floor(y - radius));
+ const maxY = Math.min(w.size - 1, Math.ceil(y + radius));
+ for (let ty = minY; ty <= maxY; ty++) {
+ for (let tx = minX; tx <= maxX; tx++) {
+ const d = Math.hypot(tx - x, ty - y);
+ if (d > radius) continue;
+ const falloff = 1 - d / radius;
+ const damage = clamp(intensity * falloff * falloff, 0, 1);
+ const tile = w.idx(tx, ty);
+ w.resource[tile] *= (1 - damage * 0.45);
+ w.farmland[tile] *= (1 - damage * 0.35);
+ w.pheromone[tile] *= (1 - damage * 0.25);
+ }
+ }
+
+ const affectedPolities = [...affectedPolityMap.values()].map(entry => {
+ const populationBefore = Math.max(1, entry.populationBefore || 0);
+ const lossRate = entry.populationLoss / populationBefore;
+ return {
+ polityId: entry.polityId,
+ populationBefore: entry.populationBefore,
+ populationLoss: entry.populationLoss,
+ lossRate,
+ cityIds: [...entry.cityIds]
+ };
+ });
+ const id = this.nextDisaster++;
+ const record = {
+ id,
+ year: this.year,
+ x,
+ y,
+ radius,
+ intensity,
+ expires: this.year + years(config?.visualDurationYears ?? 18),
+ affectedAgents,
+ affectedCities,
+ totalPopulationLoss,
+ affectedPolities
+ };
+ this.disasters.push(record);
+ this.disasterHistory.push(record);
+ for (const entry of affectedPolities) {
+ if (entry.lossRate >= 0.10) {
+ this.addPolityEvent(entry.polityId, "disaster", this.year, {
+ disasterId: id,
+ populationLoss: entry.populationLoss,
+ lossRate: entry.lossRate,
+ affectedCities: entry.cityIds.length
+ }, entry.lossRate >= 0.20 ? 3 : 2);
+ }
+ }
+ this.disasters = this.disasters
+ .filter(disaster => this.year <= disaster.expires)
+ .slice(-(config?.maxActiveVisuals ?? 12));
+ while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift();
+ }
+
markPolityEnded(polity, reason = "dissolved") {
if (!polity) return;
const history = this.ensurePolityHistory(polity);
@@ -2641,6 +2857,34 @@ class Simulation {
}
}
+ detectPolityFamines() {
+ const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06;
+ for (const polity of this.polities) {
+ const cities = this.getPolityCities(polity);
+ if (!cities.length) continue;
+ let poorCities = 0;
+ let totalFood = 0;
+ let totalPopulation = 0;
+ for (const city of cities) {
+ const population = Math.max(1, city.population || 0);
+ const perCapita = (city.storedResources || 0) / population;
+ if (perCapita < minimumFood) poorCities++;
+ totalFood += city.storedResources || 0;
+ totalPopulation += population;
+ }
+ const poorRate = poorCities / cities.length;
+ const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation);
+ const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035;
+ if (!famine) continue;
+ if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue;
+ this.addPolityEvent(polity.id, "famine", this.year, {
+ poorRate,
+ avgPerCapitaFood
+ }, poorRate > 0.65 ? 3 : 2);
+ polity.lastFamineYear = this.year;
+ }
+ }
+
cleanupPolities() {
const survivors = [];
for (const polity of this.polities) {
@@ -2650,10 +2894,17 @@ class Simulation {
continue;
}
let center = this.getCityById(polity.centerCityId);
- if (!center) {
+ if (!center || center.population <= 0) {
+ const oldCenterCityId = polity.centerCityId;
center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]);
polity.centerCityId = center.id;
center.loyalty = 1;
+ if (oldCenterCityId !== center.id) {
+ this.addPolityEvent(polity.id, "capitalShift", this.year, {
+ oldCenterCityId,
+ newCenterCityId: center.id
+ }, 2);
+ }
polity.treasury *= 0.5;
polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1);
polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1);
@@ -2695,6 +2946,7 @@ class Simulation {
if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes();
this.collectAndRedistributeResources();
+ this.detectPolityFamines();
if (this.erodePolityLegitimacy) this.erodePolityLegitimacy();
if (this.triggerPolityCrises) this.triggerPolityCrises();
@@ -3060,6 +3312,7 @@ class Simulation {
nextCity: this.nextCity,
nextPolity: this.nextPolity,
nextWar: this.nextWar,
+ nextDisaster: this.nextDisaster,
maxAgents: this.maxAgents,
world: {
size: this.world.size,
@@ -3107,9 +3360,12 @@ class Simulation {
leaderStarted: p.leaderStarted,
leaderTenureYears: p.leaderTenureYears,
crisis: p.crisis,
- lastCrisisYear: p.lastCrisisYear
+ lastCrisisYear: p.lastCrisisYear,
+ lastFamineYear: p.lastFamineYear ?? null
})),
wars: this.wars,
+ disasters: this.disasters,
+ disasterHistory: this.disasterHistory,
polityHistory: [...this.polityHistory.values()],
deadPolityHistories: this.deadPolityHistories
};
@@ -3125,6 +3381,7 @@ class Simulation {
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));
@@ -3196,7 +3453,8 @@ class Simulation {
leaderStarted: p.leaderStarted ?? p.founded ?? sim.year,
leaderTenureYears: clamp(p.leaderTenureYears ?? 48, 12, 96),
crisis: p.crisis ?? 0,
- lastCrisisYear: p.lastCrisisYear ?? sim.year
+ lastCrisisYear: p.lastCrisisYear ?? sim.year,
+ lastFamineYear: p.lastFamineYear ?? null
}));
sim.rebuildIndexes();
sim.wars = (state.wars || []).map(w => ({
@@ -3212,6 +3470,23 @@ class Simulation {
})).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),
@@ -3220,7 +3495,11 @@ class Simulation {
active: h.active !== false,
centerCityId: h.centerCityId ?? null,
fate: h.fate || null,
- samples: h.samples || []
+ 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,
@@ -3230,7 +3509,11 @@ class Simulation {
active: false,
centerCityId: h.centerCityId ?? null,
fate: h.fate || null,
- samples: h.samples || []
+ 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 = [];
@@ -3304,6 +3587,7 @@ function render() {
}
ctx.putImageData(image, 0, 0);
+ renderDisasters();
drawTradeLinks();
drawAgentsAndCities(mode);
drawGraphPolityHighlight();
@@ -3311,6 +3595,35 @@ function render() {
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
}
+function renderDisasters() {
+ if (!sim?.disasters?.length) return;
+ sim.disasters = sim.disasters.filter(disaster => sim.year <= disaster.expires);
+ if (!sim.disasters.length) return;
+ ctx.save();
+ ctx.globalCompositeOperation = "source-over";
+ for (const disaster of sim.disasters) {
+ const duration = Math.max(1, disaster.expires - disaster.year);
+ const ageFraction = clamp((sim.year - disaster.year) / duration, 0, 1);
+ const alpha = (1 - ageFraction) * (0.10 + (disaster.intensity || 0.5) * 0.22);
+ if (alpha <= 0.005) continue;
+ const innerRadius = Math.max(1, disaster.radius * 0.32);
+ const gradient = ctx.createRadialGradient(disaster.x, disaster.y, innerRadius, disaster.x, disaster.y, disaster.radius);
+ gradient.addColorStop(0, `rgba(211, 95, 84, ${alpha * 1.35})`);
+ gradient.addColorStop(0.55, `rgba(211, 95, 84, ${alpha * 0.65})`);
+ gradient.addColorStop(1, "rgba(211, 95, 84, 0)");
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = `rgba(236, 126, 111, ${alpha * 0.85})`;
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(disaster.x, disaster.y, disaster.radius, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ ctx.restore();
+}
+
function drawTradeLinks() {
const w = sim.world;
ctx.save();
@@ -3464,44 +3777,53 @@ function renderTooltip() {
const mismatch = agent ? sim.climateMismatch(agent.ethnicity, i) : 0;
const polity = city && city.polityId !== null ? sim.getPolityById(city.polityId) : null;
const regionalEthnicity = w.dominantEthnicity[i] >= 0 ? `E${w.dominantEthnicity[i]}` : "-";
+ const rows = items => items.filter(Boolean).join("");
+ const row = (label, value) => `${label}${value}`;
+ const section = (title, items) => {
+ const body = rows(items);
+ return body ? `` : "";
+ };
els.tooltip.innerHTML = `
- ${agent ? "Agent group" : terrain.name}
- Tile${x}, ${y}
- Terrain${terrain.name}
- Resources${w.resource[i].toFixed(1)}
- Fertility${w.fertility[i].toFixed(2)}
- Temp / humid${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}
- Water influence${waterInfluence}
- Minerals${w.mineral[i].toFixed(2)}
- Pheromone${w.pheromone[i].toFixed(1)}
- Pressure${w.pressure[i].toFixed(0)}
- Local culture${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}
- ${w.tradeRoute[i] ? `Route${w.tradeRoute[i]}` : ""}
- ${city ? `City#${city.id}` : ""}
- ${city ? `Urban pop${city.population.toLocaleString()}` : ""}
- ${city ? `Food stock${city.storedResources.toFixed(1)}` : ""}
- ${city ? `Supply stress${(city.supplyStress || 0).toFixed(2)}` : ""}
- ${city ? `Farmland radius${city.agriculturalRadius}` : ""}
- ${city ? `Trade links${city.tradeLinks.size}` : ""}
- ${city ? `Trade value${(city.tradeValue || 0).toFixed(2)} / ${Math.round(city.tradeReach || 0)} tiles` : ""}
- ${city ? `City knowledge${(city.knowledge?.farming || 0).toFixed(2)} / ${(city.knowledge?.metallurgy || 0).toFixed(2)}` : ""}
- ${cityEthnicity ? `City majorityE${cityEthnicity}` : ""}
- ${city ? `State${polity ? `#${polity.id}` : "Independent"}` : ""}
- ${city ? `Loyalty${city.loyalty.toFixed(2)}` : ""}
- ${polity ? `Charisma${clamp(polity.charisma ?? 1, 0.5, 1.5).toFixed(2)}` : ""}
- ${polity ? `Leader tenure${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / MONTHS_PER_YEAR)}y` : ""}
- ${polity ? `Treasury${polity.treasury.toFixed(1)}` : ""}
- ${polity ? `Center${polity.centerCityId === city.id ? "yes" : "no"}` : ""}
- ${agent ? `EthnicityE${agent.ethnicity}` : ""}
- ${ethnicity ? `Climate pref${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}` : ""}
- ${agent ? `Climate mismatch${mismatch.toFixed(2)}` : ""}
- ${agent ? `Sedentary${getSedentary(agent.traits).toFixed(2)}` : ""}
- ${agent ? `Ethnocentrism${getEthnocentrism(agent.traits).toFixed(2)}` : ""}
- ${agent ? `Farming${(agent.tech?.farming || 0).toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}` : ""}
- ${agent ? `Metallurgy${(agent.tech?.metallurgy || 0).toFixed(3)}` : ""}
- ${agent ? `Stored${agent.resources.toFixed(1)}` : ""}
- ${ethnicity ? `Lineage pop${ethnicity.population}` : ""}
+ ${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]) : ""
+ ])}
+ ${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") : ""
+ ])}
+ ${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)) : ""
+ ])}
`;
els.tooltip.hidden = false;
const margin = 8;
@@ -3723,6 +4045,7 @@ function renderStateGraph() {
if (!histories.length || !selected.length || !displayRows.length) {
if (els.historyRange) els.historyRange.textContent = "-";
graphState.rows = [];
+ graphState.markers = [];
graphState.scale = null;
g.fillStyle = "#7f8984";
g.font = "12px ui-sans-serif, system-ui, sans-serif";
@@ -3785,6 +4108,7 @@ function renderStateGraph() {
maxCities = 1;
}
graphState.rows = rows.filter(row => row.type === "state");
+ graphState.markers = [];
graphState.scale = { minYear, maxYear, paddingLeft, paddingRight, plotWidth, width: w, xToYear };
const axisY = h - paddingBottom + 4;
@@ -3887,18 +4211,143 @@ function renderStateGraph() {
}
});
+
+ rows.forEach(row => {
+ if (row.type !== "state") return;
+ const events = (row.history.events || [])
+ .filter(event => event.importance >= 2)
+ .filter(event => event.year >= minYear && event.year <= maxYear)
+ .filter(event => ["capitalShift", "disaster", "peak", "famine"].includes(event.type));
+ const collisions = new Map();
+ for (const event of events) {
+ const x = yearToX(event.year);
+ const bucket = Math.round(x / 7);
+ const count = collisions.get(bucket) || 0;
+ collisions.set(bucket, count + 1);
+ const y = row.y + ([-4, 0, 4][count % 3]);
+ const r = event.importance >= 4 ? 5 : event.importance >= 3 ? 4 : 3;
+ 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);
+ }
+ graphState.markers.push({ row, event, x, y, radius: r + 3 });
+ }
+ });
+}
+
+function drawGraphCircle(g, x, y, r) {
+ g.beginPath();
+ g.arc(x, y, r, 0, Math.PI * 2);
+ g.fill();
+}
+
+function drawGraphDiamond(g, x, y, r) {
+ g.beginPath();
+ g.moveTo(x, y - r);
+ g.lineTo(x + r, y);
+ g.lineTo(x, y + r);
+ g.lineTo(x - r, y);
+ g.closePath();
+ g.fill();
+}
+
+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();
+}
+
+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();
+}
+
+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;
+}
+
+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 "";
+}
+
+function showStateGraphMarkerTooltip(marker, pointerX, pointerY) {
+ if (!els.stateGraphTooltip) return;
+ els.stateGraphTooltip.innerHTML = `
+ S${marker.row.history.id} ${graphEventLabel(marker.event)}
+ ${formatGraphYear(marker.event.year)}
+ ${graphEventSummary(marker.event)}
+ `;
+ els.stateGraphTooltip.hidden = false;
+ const scroll = els.stateGraph.parentElement;
+ const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8);
+ const left = clamp(pointerX + 10, 8, maxLeft);
+ const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10);
+ els.stateGraphTooltip.style.left = `${left}px`;
+ els.stateGraphTooltip.style.top = `${top}px`;
+}
+
+function hideStateGraphMarkerTooltip() {
+ if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true;
}
function updateStateGraphInfo(event) {
if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return;
const y = event.offsetY;
const x = event.offsetX;
+ const marker = (graphState.markers || [])
+ .map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) }))
+ .filter(item => item.distance <= item.candidate.radius)
+ .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;
+ 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) {
els.stateGraphInfo.textContent = "Hover a row for details";
+ hideStateGraphMarkerTooltip();
if (graphState.hoverPolityId !== null) {
graphState.hoverPolityId = null;
if (!running) render();
@@ -3909,6 +4358,7 @@ function updateStateGraphInfo(event) {
graphState.hoverPolityId = row.history.id;
if (!running) render();
}
+ hideStateGraphMarkerTooltip();
const year = graphState.scale.xToYear(x);
const samples = row.samples.length ? row.samples : [{
@@ -3933,6 +4383,7 @@ function updateStateGraphInfo(event) {
function clearStateGraphInfo() {
if (els.stateGraphInfo) els.stateGraphInfo.textContent = "Hover a row for details";
+ hideStateGraphMarkerTooltip();
if (graphState.hoverPolityId !== null) {
graphState.hoverPolityId = null;
if (!running) render();
diff --git a/styles.css b/styles.css
index 69cc302..1e8814b 100644
--- a/styles.css
+++ b/styles.css
@@ -131,6 +131,7 @@ select {
}
.state-graph-scroll {
+ position: relative;
flex: 1;
min-height: 0;
overflow-y: auto;
@@ -140,6 +141,32 @@ select {
background: #101315;
}
+.state-graph-tooltip {
+ position: absolute;
+ z-index: 2;
+ max-width: min(240px, calc(100% - 16px));
+ padding: 7px 9px;
+ border: 1px solid #435057;
+ border-radius: 6px;
+ background: rgba(15, 18, 20, 0.50);
+ color: var(--text);
+ font-size: 11px;
+ line-height: 1.3;
+ pointer-events: none;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
+}
+
+.state-graph-tooltip strong {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 12px;
+}
+
+.state-graph-tooltip span {
+ display: block;
+ color: var(--muted);
+}
+
.state-graph-info {
min-height: 18px;
margin-top: 7px;
@@ -340,7 +367,7 @@ canvas#world {
padding: 8px 10px;
border: 1px solid #435057;
border-radius: 6px;
- background: rgba(15, 18, 20, 0.96);
+ background: rgba(15, 18, 20, 0.80);
color: var(--text);
font-size: 11px;
line-height: 1.35;
@@ -350,10 +377,28 @@ canvas#world {
.tooltip strong {
display: block;
- margin-bottom: 4px;
+ margin-bottom: 6px;
font-size: 12px;
}
+.tooltip section {
+ padding-top: 5px;
+ margin-top: 5px;
+ border-top: 1px solid rgba(168, 177, 170, 0.16);
+}
+
+.tooltip section:first-of-type {
+ margin-top: 0;
+}
+
+.tooltip h3 {
+ margin: 0 0 3px;
+ color: var(--text);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
.tooltip span {
display: flex;
justify-content: space-between;
@@ -361,6 +406,12 @@ canvas#world {
color: var(--muted);
}
+.tooltip em {
+ color: var(--text);
+ font-style: normal;
+ text-align: right;
+}
+
@media (max-width: 1100px) {
body {
overflow: auto;