This commit is contained in:
33333-33333 2026-05-13 18:27:08 +09:00
commit 9ef246e399
3 changed files with 551 additions and 48 deletions

View file

@ -120,6 +120,7 @@
</div>
<div class="state-graph-scroll">
<canvas id="stateGraph" width="300" height="720" aria-label="State lifespan graph"></canvas>
<div id="stateGraphTooltip" class="state-graph-tooltip" hidden></div>
</div>
<div id="stateGraphInfo" class="state-graph-info">Hover a row for details</div>
</section>

543
script.js
View file

@ -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) => `<span><b>${label}</b><em>${value}</em></span>`;
const section = (title, items) => {
const body = rows(items);
return body ? `<section><h3>${title}</h3>${body}</section>` : "";
};
els.tooltip.innerHTML = `
<strong>${agent ? "Agent group" : terrain.name}</strong>
<span><b>Tile</b><em>${x}, ${y}</em></span>
<span><b>Terrain</b><em>${terrain.name}</em></span>
<span><b>Resources</b><em>${w.resource[i].toFixed(1)}</em></span>
<span><b>Fertility</b><em>${w.fertility[i].toFixed(2)}</em></span>
<span><b>Temp / humid</b><em>${w.temperature[i].toFixed(2)} / ${w.humidity[i].toFixed(2)}</em></span>
<span><b>Water influence</b><em>${waterInfluence}</em></span>
<span><b>Minerals</b><em>${w.mineral[i].toFixed(2)}</em></span>
<span><b>Pheromone</b><em>${w.pheromone[i].toFixed(1)}</em></span>
<span><b>Pressure</b><em>${w.pressure[i].toFixed(0)}</em></span>
<span><b>Local culture</b><em>${regionalEthnicity} / ${w.cultureDiversity[i].toFixed(2)}</em></span>
${w.tradeRoute[i] ? `<span><b>Route</b><em>${w.tradeRoute[i]}</em></span>` : ""}
${city ? `<span><b>City</b><em>#${city.id}</em></span>` : ""}
${city ? `<span><b>Urban pop</b><em>${city.population.toLocaleString()}</em></span>` : ""}
${city ? `<span><b>Food stock</b><em>${city.storedResources.toFixed(1)}</em></span>` : ""}
${city ? `<span><b>Supply stress</b><em>${(city.supplyStress || 0).toFixed(2)}</em></span>` : ""}
${city ? `<span><b>Farmland radius</b><em>${city.agriculturalRadius}</em></span>` : ""}
${city ? `<span><b>Trade links</b><em>${city.tradeLinks.size}</em></span>` : ""}
${city ? `<span><b>Trade value</b><em>${(city.tradeValue || 0).toFixed(2)} / ${Math.round(city.tradeReach || 0)} tiles</em></span>` : ""}
${city ? `<span><b>City knowledge</b><em>${(city.knowledge?.farming || 0).toFixed(2)} / ${(city.knowledge?.metallurgy || 0).toFixed(2)}</em></span>` : ""}
${cityEthnicity ? `<span><b>City majority</b><em>E${cityEthnicity}</em></span>` : ""}
${city ? `<span><b>State</b><em>${polity ? `#${polity.id}` : "Independent"}</em></span>` : ""}
${city ? `<span><b>Loyalty</b><em>${city.loyalty.toFixed(2)}</em></span>` : ""}
${polity ? `<span><b>Charisma</b><em>${clamp(polity.charisma ?? 1, 0.5, 1.5).toFixed(2)}</em></span>` : ""}
${polity ? `<span><b>Leader tenure</b><em>${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / MONTHS_PER_YEAR)}y</em></span>` : ""}
${polity ? `<span><b>Treasury</b><em>${polity.treasury.toFixed(1)}</em></span>` : ""}
${polity ? `<span><b>Center</b><em>${polity.centerCityId === city.id ? "yes" : "no"}</em></span>` : ""}
${agent ? `<span><b>Ethnicity</b><em>E${agent.ethnicity}</em></span>` : ""}
${ethnicity ? `<span><b>Climate pref</b><em>${ethnicity.climateTemp.toFixed(2)} / ${ethnicity.climateHumidity.toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Climate mismatch</b><em>${mismatch.toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Sedentary</b><em>${getSedentary(agent.traits).toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Ethnocentrism</b><em>${getEthnocentrism(agent.traits).toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Farming</b><em>${(agent.tech?.farming || 0).toFixed(3)} / work ${(agent.farmingWork || 0).toFixed(2)}</em></span>` : ""}
${agent ? `<span><b>Metallurgy</b><em>${(agent.tech?.metallurgy || 0).toFixed(3)}</em></span>` : ""}
${agent ? `<span><b>Stored</b><em>${agent.resources.toFixed(1)}</em></span>` : ""}
${ethnicity ? `<span><b>Lineage pop</b><em>${ethnicity.population}</em></span>` : ""}
<strong>${city ? `City #${city.id}` : agent ? "Agent group" : terrain.name}</strong>
${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 = `
<strong>S${marker.row.history.id} ${graphEventLabel(marker.event)}</strong>
<span>${formatGraphYear(marker.event.year)}</span>
<span>${graphEventSummary(marker.event)}</span>
`;
els.stateGraphTooltip.hidden = false;
const scroll = els.stateGraph.parentElement;
const maxLeft = Math.max(8, (scroll?.clientWidth || 260) - els.stateGraphTooltip.offsetWidth - 8);
const left = clamp(pointerX + 10, 8, maxLeft);
const top = Math.max(8, pointerY - els.stateGraphTooltip.offsetHeight - 10);
els.stateGraphTooltip.style.left = `${left}px`;
els.stateGraphTooltip.style.top = `${top}px`;
}
function hideStateGraphMarkerTooltip() {
if (els.stateGraphTooltip) els.stateGraphTooltip.hidden = true;
}
function updateStateGraphInfo(event) {
if (!els.stateGraphInfo || !graphState.rows.length || !graphState.scale) return;
const y = event.offsetY;
const x = event.offsetX;
const marker = (graphState.markers || [])
.map(candidate => ({ candidate, distance: Math.hypot(candidate.x - x, candidate.y - y) }))
.filter(item => item.distance <= item.candidate.radius)
.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();

View file

@ -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;