tweak
This commit is contained in:
parent
09da7eee8a
commit
9ef246e399
3 changed files with 551 additions and 48 deletions
543
script.js
543
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) => `<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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue