diff --git a/index.html b/index.html index 4b44b7f..96451c4 100644 --- a/index.html +++ b/index.html @@ -67,6 +67,7 @@
Ethnicities
0
Cities
0
States
0
+
Wars
0
Trade routes
0
Farming knowledge
0.000
Metallurgy knowledge
0.000
diff --git a/script.js b/script.js index 78b0d28..08daf48 100644 --- a/script.js +++ b/script.js @@ -35,9 +35,14 @@ const SimConfig = Object.freeze({ pressurePenaltyBase: 0.2, pressureReproductionPenalty: 0.16 }), + city: Object.freeze({ + maxCities: 300 + }), render: Object.freeze({ graphThrottleMs: 700, - statsThrottleMs: 350 + statsThrottleMs: 350, + historyWindowYears: 2000, + historySamplePaddingYears: 240 }), save: Object.freeze({ key: SAVE_KEY, @@ -84,6 +89,7 @@ const els = { ethnicities: document.getElementById("ethnicities"), cities: document.getElementById("cities"), polities: document.getElementById("polities"), + wars: document.getElementById("wars"), routes: document.getElementById("routes"), farmingKnowledge: document.getElementById("farmingKnowledge"), metallurgyKnowledge: document.getElementById("metallurgyKnowledge"), @@ -429,12 +435,14 @@ class Simulation { this.ethnicities = new Map(); this.cities = []; this.polities = []; + this.wars = []; this.polityHistory = new Map(); this.deadPolityHistories = []; this.tradeLinks = []; this.nextEthnicity = 1; this.nextCity = 1; this.nextPolity = 1; + this.nextWar = 1; this.year = 0; this.deaths = 0; this.maxAgents = Math.max( @@ -1306,7 +1314,7 @@ class Simulation { if (group.count < 3) continue; const avgSedentary = group.sedentary / group.count; let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 7); - if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < 80) { + if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) { const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98); if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue; city = this.createCity(i % w.size, Math.floor(i / w.size), group); @@ -1652,7 +1660,8 @@ class Simulation { treasury: polity.treasury || 0, avgLoyalty: this.averagePolityLoyalty(polity) }); - while (history.samples.length > 160) history.samples.shift(); + const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears; + while (history.samples.length > maxSamples) history.samples.shift(); } samplePolityHistories() { @@ -1696,6 +1705,128 @@ class Simulation { city.receivedAid = false; } + isPolityAtWar(polityId) { + return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)); + } + + getWarBetween(aId, bId) { + return this.wars.find(war => + war.ended === null && + ((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId)) + ) || null; + } + + startWar(a, b) { + if (!a || !b || a.id === b.id) return null; + if (this.getWarBetween(a.id, b.id)) return null; + if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null; + + const id = this.nextWar++; + const war = { + id, + aPolityId: a.id, + bPolityId: b.id, + started: this.year, + lastActionYear: this.year, + intensity: this.rng.range(0.45, 1.05), + exhaustionA: 0, + exhaustionB: 0, + ended: null + }; + this.wars.push(war); + a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5); + b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5); + return war; + } + + endWar(war) { + if (war && war.ended === null) war.ended = this.year; + } + + totalPolityPopulation(polity) { + return this.getPolityCities(polity) + .reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); + } + + averagePolityTechnology(polity) { + const cities = this.getPolityCities(polity); + if (!cities.length) return 0; + let total = 0; + let count = 0; + for (const city of cities) { + if (!city?.knowledge) continue; + total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5; + count++; + } + return count ? total / count : 0; + } + + polityPower(polity) { + const cities = this.getPolityCities(polity); + if (!cities.length) return 0; + + const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0); + const treasury = Math.max(0, polity.treasury || 0); + const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5; + const tech = this.averagePolityTechnology(polity); + const instability = this.polityInstability ? this.polityInstability(polity) : 0; + const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0; + + return Math.max(0, + Math.sqrt(population) * 1.35 + + Math.sqrt(treasury) * 1.15 + + avgLoyalty * 16 + + tech * 24 - + instability * 12 - + agePressure * 6 + ); + } + + polityInfluenceRange(polity, wartime = false) { + const power = this.polityPower(polity); + const base = 18 + Math.sqrt(power) * 2.2; + const range = wartime ? base * 1.15 : base * 0.75; + return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42); + } + + distanceToNearestPolityCity(polity, city) { + if (!polity || !city) return Infinity; + let best = Infinity; + for (const source of this.getPolityCities(polity)) { + const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city); + if (distance < best) best = distance; + } + return best; + } + + nearestPolityCity(polity, city) { + if (!polity || !city) return null; + let best = null; + let bestDistance = Infinity; + for (const source of this.getPolityCities(polity)) { + const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city); + if (distance < bestDistance) { + best = source; + bestDistance = distance; + } + } + return best; + } + + polityDistance(a, b) { + const aCities = this.getPolityCities(a); + const bCities = this.getPolityCities(b); + if (!aCities.length || !bCities.length) return Infinity; + let best = Infinity; + for (const aCity of aCities) { + for (const bCity of bCities) { + const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity); + if (distance < best) best = distance; + } + } + return best; + } + polityAge(polity) { return (this.year - (polity?.founded ?? this.year)) / MONTHS_PER_YEAR; } @@ -1853,6 +1984,288 @@ class Simulation { } } + absorbIndependentCities() { + if (this.year % years(5) !== 0) return; + + for (const polity of this.polities) { + const cities = this.getPolityCities(polity); + if (!cities.length) continue; + + const power = this.polityPower(polity); + const range = this.polityInfluenceRange(polity, false); + let absorbed = 0; + + const candidates = this.cities + .filter(city => city.polityId === null && city.population > 0) + .map(city => ({ + city, + distance: this.distanceToNearestPolityCity(polity, city) + })) + .filter(x => x.distance <= range) + .sort((a, b) => a.distance - b.distance); + + for (const { city, distance } of candidates) { + if (absorbed >= 1) break; + + const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1); + const proximity = 1 / (1 + distance * 0.07); + const pressure = (power / Math.max(1, cityInfluence)) * proximity; + + if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) { + this.addCityToPolity(city, polity, 0.42); + city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99))); + absorbed++; + } + } + } + } + + maybeStartWars() { + if (this.year % years(10) !== 0) return; + + for (const a of this.polities) { + if (this.isPolityAtWar(a.id)) continue; + + const aPower = this.polityPower(a); + if (aPower <= 0) continue; + + let started = false; + + for (const b of this.polities) { + if (started) break; + if (a.id >= b.id) continue; + if (this.isPolityAtWar(b.id)) continue; + if (this.getWarBetween(a.id, b.id)) continue; + + const distance = this.polityDistance(a, b); + if (distance > 46) continue; + + const bPower = this.polityPower(b); + if (bPower <= 0) continue; + + const larger = Math.max(aPower, bPower); + const smaller = Math.min(aPower, bPower); + const advantage = larger / Math.max(1, smaller); + + const proximity = clamp((46 - distance) / 46, 0, 1); + const aInstability = this.polityInstability ? this.polityInstability(a) : 0; + const bInstability = this.polityInstability ? this.polityInstability(b) : 0; + const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0; + const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0; + const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0; + const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0; + + const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1); + const weakSideInstability = aPower > bPower ? bInstability : aInstability; + const weakSideAge = aPower > bPower ? bAge : aAge; + const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension; + const generalInstability = Math.max(aInstability, bInstability) * 0.04; + const borderFriction = this.borderFriction(a, b, distance); + + const chance = + 0.002 + + proximity * 0.010 + + asymmetryPressure * 0.010 + + weakSideInstability * 0.020 + + weakSideAge * 0.012 + + weakSideOverextension * 0.012 + + borderFriction * 0.010 + + generalInstability; + + if (this.rng.next() < clamp(chance, 0, 0.08)) { + this.startWar(a, b); + started = true; + } + } + } + } + + borderFriction(a, b, distance) { + if (!Number.isFinite(distance)) return 0; + const proximity = clamp((34 - distance) / 34, 0, 1); + const aCities = this.getPolityCities(a).length; + const bCities = this.getPolityCities(b).length; + const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1); + return proximity * (0.35 + sizePressure * 0.65); + } + + updateWars() { + if (this.year % years(2) !== 0) return; + + for (const war of this.wars) { + if (war.ended !== null) continue; + + const a = this.getPolityById(war.aPolityId); + const b = this.getPolityById(war.bPolityId); + + if (!a || !b) { + war.ended = this.year; + continue; + } + + this.applyWarPressure(war, a, b); + this.applyWarPressure(war, b, a); + this.applyWarExhaustion(war, a, b); + this.maybeEndWar(war); + } + + this.wars = this.wars.filter(w => w.ended === null); + } + + applyWarPressure(war, attacker, defender) { + const attackerCities = this.getPolityCities(attacker); + const defenderCities = this.getPolityCities(defender); + if (!attackerCities.length || !defenderCities.length) return; + + const attackerPower = this.polityPower(attacker); + const defenderPower = this.polityPower(defender); + if (attackerPower <= defenderPower * 1.05) return; + + const range = this.polityInfluenceRange(attacker, true); + + const candidates = defenderCities + .filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2) + .map(city => ({ + city, + distance: this.distanceToNearestPolityCity(attacker, city) + })) + .filter(x => x.distance <= range) + .sort((a, b) => a.distance - b.distance); + + if (!candidates.length) return; + + const topCandidates = candidates.slice(0, 3); + const selected = topCandidates[this.rng.int(topCandidates.length)]; + const pressure = this.warAbsorptionPressure( + attacker, + defender, + selected.city, + selected.distance, + attackerPower, + defenderPower + ); + + const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45); + + if (pressure > 0.95 && this.rng.next() < chance) { + this.captureCityInWar(selected.city, attacker, defender, war, pressure); + } else { + selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1); + } + } + + warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) { + const powerRatio = attackerPower / Math.max(1, defenderPower); + const proximity = 1 / (1 + distance * 0.055); + const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0; + const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0; + const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0; + const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1); + + const techAdvantage = 1 + clamp( + this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender), + -0.25, + 0.45 + ); + + const nearest = this.nearestPolityCity(attacker, city); + const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92; + const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0; + + return ( + powerRatio * + proximity * + techAdvantage * + ethnicityFactor * + tradeFactor * + (1 + defenderInstability * 0.32) * + (1 + defenderAge * 0.18) * + (1 + defenderOverextension * 0.14) * + (0.65 + loyaltyWeakness * 0.72) + ); + } + + captureCityInWar(city, attacker, defender, war, pressure) { + const lossRate = clamp( + 0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025, + 0.05, + 0.22 + ); + + const loss = Math.floor((city.population || 0) * lossRate); + if (loss > 0) { + city.population = Math.max(1, city.population - loss); + if (typeof removeFromComposition === "function" && city.ethnicityComposition) { + removeFromComposition(city.ethnicityComposition, loss); + } + this.deaths += Math.floor(loss * 0.35); + } + + this.removeCityFromPolity(city); + this.addCityToPolity(city, attacker, 0.28); + + city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36); + + war.lastActionYear = this.year; + + if (war.aPolityId === attacker.id) { + war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1); + war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1); + } else { + war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1); + war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1); + } + + attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015); + attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5); + + defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5); + defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1); + } + + applyWarExhaustion(war, a, b) { + const intensity = war.intensity || 0.75; + + const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035; + const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035; + + a.treasury = Math.max(0, (a.treasury || 0) - costA); + b.treasury = Math.max(0, (b.treasury || 0) - costB); + + a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5); + b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5); + + war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1); + war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1); + } + + maybeEndWar(war) { + const a = this.getPolityById(war.aPolityId); + const b = this.getPolityById(war.bPolityId); + if (!a || !b) { + war.ended = this.year; + return; + } + + const age = this.year - war.started; + const noActionFor = this.year - war.lastActionYear; + const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0); + + if (age > years(90)) { + war.ended = this.year; + return; + } + + if (age > years(10) && noActionFor > years(18)) { + war.ended = this.year; + return; + } + + if (exhaustion > 0.85 && this.rng.next() < 0.35) { + war.ended = this.year; + } + } + collectAndRedistributeResources() { for (const polity of this.polities) { const cities = this.getPolityCities(polity); @@ -2090,13 +2503,21 @@ class Simulation { this.cleanupPolities(); this.foundPolities(); this.expandPolities(); + if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes(); + this.collectAndRedistributeResources(); - this.erodePolityLegitimacy(); - this.triggerPolityCrises(); - this.applyOldStateStress(); + + if (this.erodePolityLegitimacy) this.erodePolityLegitimacy(); + if (this.triggerPolityCrises) this.triggerPolityCrises(); + if (this.applyOldStateStress) this.applyOldStateStress(); + this.updateCityLoyalty(); + this.absorbIndependentCities(); + this.maybeStartWars(); + this.updateWars(); this.splitUnloyalCities(); + this.cleanupPolities(); if (this.samplePolityHistories) this.samplePolityHistories(); } @@ -2431,6 +2852,7 @@ class Simulation { nextEthnicity: this.nextEthnicity, nextCity: this.nextCity, nextPolity: this.nextPolity, + nextWar: this.nextWar, maxAgents: this.maxAgents, world: { size: this.world.size, @@ -2477,6 +2899,7 @@ class Simulation { crisis: p.crisis, lastCrisisYear: p.lastCrisisYear })), + wars: this.wars, polityHistory: [...this.polityHistory.values()], deadPolityHistories: this.deadPolityHistories }; @@ -2491,6 +2914,7 @@ class Simulation { sim.nextEthnicity = state.nextEthnicity || 1; sim.nextCity = state.nextCity || 1; sim.nextPolity = state.nextPolity || 1; + sim.nextWar = state.nextWar || 1; sim.maxAgents = state.maxAgents || 30000; sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array)); @@ -2559,6 +2983,19 @@ class Simulation { crisis: p.crisis ?? 0, lastCrisisYear: p.lastCrisisYear ?? sim.year })); + sim.wars = (state.wars || []).map(w => ({ + id: w.id, + aPolityId: w.aPolityId, + bPolityId: w.bPolityId, + started: w.started || sim.year, + lastActionYear: w.lastActionYear || w.started || sim.year, + intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25), + exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1), + exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1), + ended: w.ended ?? null + })).filter(w => w.ended === null); + const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0); + sim.nextWar = Math.max(sim.nextWar, maxWarId + 1); 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), @@ -2615,8 +3052,6 @@ function render() { } else if (mode === "pressure") { const v = clamp(w.pressure[i] / 12, 0, 1); color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v); - } else if (mode === "cities") { - color = w.city[i] >= 0 ? mix([74, 105, 58], [226, 198, 121], clamp(w.farmland[i], 0, 1)) : terrainInfo[w.terrain[i]].color; } else if (mode === "polities") { color = terrainInfo[w.terrain[i]].color; if (w.city[i] >= 0) { @@ -2638,7 +3073,6 @@ function render() { ctx.putImageData(image, 0, 0); drawTradeLinks(); - drawFarmlandRings(mode); drawAgentsAndCities(mode); maybeRenderStateGraph(); els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`; @@ -2666,28 +3100,6 @@ function drawTradeLinks() { ctx.restore(); } -function drawFarmlandRings(mode) { - if (mode !== "cities") return; - const w = sim.world; - ctx.save(); - ctx.globalAlpha = 0.78; - ctx.fillStyle = "#d9c45f"; - for (const city of sim.cities) { - const radius = Math.max(1, city.agriculturalRadius); - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const d = Math.abs(dx) + Math.abs(dy); - if (d !== radius) continue; - const x = city.x + dx; - const y = city.y + dy; - if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; - if (w.terrain[w.idx(x, y)] !== Terrain.WATER) ctx.fillRect(x, y, 1, 1); - } - } - } - ctx.restore(); -} - function drawAgentsAndCities(mode) { ctx.save(); for (const city of sim.cities) { @@ -2886,6 +3298,7 @@ function updateStats(force = false) { els.ethnicities.textContent = livingEthnicities.length.toLocaleString(); els.cities.textContent = sim.cities.length.toLocaleString(); els.polities.textContent = sim.polities.length.toLocaleString(); + if (els.wars) els.wars.textContent = sim.wars.length.toLocaleString(); els.routes.textContent = sim.tradeLinks.length.toLocaleString(); els.farmingKnowledge.textContent = `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`; els.metallurgyKnowledge.textContent = `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`; @@ -2907,8 +3320,6 @@ function setLegend() { els.legend.innerHTML = "Color = dominant regional lineage. Mixed tiles brighten toward gray."; } else if (mode === "pressure") { els.legend.innerHTML = "High local population pressure"; - } else if (mode === "cities") { - els.legend.innerHTML = "City nodes, farmland radius, and stored urban population"; } else if (mode === "polities") { els.legend.innerHTML = "Color = city-centered state. Uncolored cities are independent."; } else if (mode === "technology") { @@ -2946,7 +3357,9 @@ function renderStateGraph() { g.fillStyle = "#101315"; g.fillRect(0, 0, w, h); - const historyWindowStart = Math.max(0, sim.year - years(10000)); + const historyWindowSpan = years(SimConfig.render.historyWindowYears); + const historyWindowEnd = sim.year; + const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan); const histories = sim.getAllPolityHistories() .filter(history => (history.ended ?? sim.year) >= historyWindowStart) .map(history => ({ @@ -2963,13 +3376,11 @@ function renderStateGraph() { return; } - let minYear = Infinity; - let maxYear = sim.year; + let minYear = historyWindowStart; + let maxYear = historyWindowEnd; for (const history of histories) { minYear = Math.min(minYear, history.visibleFounded); - maxYear = Math.max(maxYear, history.ended ?? 0, history.visibleSamples[history.visibleSamples.length - 1]?.year ?? 0); } - if (!Number.isFinite(minYear)) minYear = 0; if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`; const paddingLeft = 34;