diff --git a/config.js b/config.js index 2ec80df..9d22f69 100644 --- a/config.js +++ b/config.js @@ -32,7 +32,13 @@ pressurePenaltyBase: 0.2, pressureReproductionPenalty: 0.16 }), city: Object.freeze({ -maxCities: 300 +maxCities: 300, +agingStartYears: 180, +agingDecayPerYear: 0.002, +agingOverCapAttrition: 0.035, +agingRestoreBudgetShare: 0.18, +agingRestoreCostPerWear: 70, +agingRestoreMinWear: 0.08 }), render: Object.freeze({ graphThrottleMs: 1400, @@ -47,16 +53,14 @@ cityInnovation: 0.00045 }), polity: Object.freeze({ minimumPerCapitaFood: 0.06, -logisticsDistance: 34 +logisticsDistance: 34, +charismaMin: 0.5, +charismaMax: 3.0, +charismaAverage: 1.0 }), polityAccess: Object.freeze({ enabled: true, -maxSearchDepth: 8, -indirectPenalty: 0.012, -perHopPenalty: 0.006, -noAccessPenalty: 0.045, -lowLoyaltyTransitPenalty: 0.012, -foreignTransitPenalty: 0.008 +noAccessPenalty: 0.17 }), culture: Object.freeze({ spreadRadius: 3, diff --git a/engine.js b/engine.js index 3d1c45e..ee209d5 100644 --- a/engine.js +++ b/engine.js @@ -1488,6 +1488,8 @@ agriculturalRadius: 2, tradeLinks: new Set(), activeVisitors: 0, age: 0, +ageWear: 0, +peakPopulation: seedPopulation, strength: 3, sedentaryCulture: seedSedentary, knowledge: { farming: 0, metallurgy: 0 }, @@ -1499,6 +1501,21 @@ tradeValue: 0, tradeReach: 0 }; } +updateCityAging(city) { +city.ageWear ??= 0; +city.peakPopulation = Math.max(city.peakPopulation || 0, city.population || 0); +const cfg = SimConfig.city; +const ageStart = cfg.agingStartYears ?? 180; +if ((city.age || 0) <= ageStart) return; +city.ageWear = clamp(city.ageWear + (cfg.agingDecayPerYear ?? 0.002), 0, 1); +} +cityAgeCapacityFactor(city) { +return clamp(1 - (city?.ageWear || 0), 0, 1); +} +cityAgePopulationLimit(city) { +const peakPopulation = Math.max(city?.peakPopulation || 0, city?.population || 0, 1); +return Math.max(0, peakPopulation * this.cityAgeCapacityFactor(city)); +} absorbUrbanPopulation() { if (!this.cities.length) return; const absorbed = []; @@ -1535,6 +1552,7 @@ return whole + (this.rng.next() < value - whole ? 1 : 0); processCityEconomies() { const w = this.world; for (const city of this.cities) { +this.updateCityAging(city); city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14); city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8); city.knowledge ??= { farming: 0, metallurgy: 0 }; @@ -1579,13 +1597,22 @@ city.tradeReach = trade.reach; city.tradeValue = 0; city.tradeReach = 0; } -if (city.storedResources > city.population * 0.16 && city.population > 0) { +const agePopulationLimit = this.cityAgePopulationLimit(city); +if (city.storedResources > city.population * 0.16 && city.population > 0 && city.population < agePopulationLimit) { const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8); -const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015))); +const birthRoom = Math.max(0, Math.floor(agePopulationLimit - city.population)); +const births = Math.min(birthRoom, Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015)))); city.population += births; city.storedResources -= births * 0.55; addBirthsToComposition(city.ethnicityComposition, births, this.rng); } +if (city.population > agePopulationLimit) { +const overLimit = city.population - agePopulationLimit; +const ageLoss = Math.min(city.population, Math.max(1, Math.ceil(overLimit * (SimConfig.city.agingOverCapAttrition ?? 0.035)))); +city.population -= ageLoss; +removeFromComposition(city.ethnicityComposition, ageLoss); +city.strength -= Math.min(0.08, ageLoss / Math.max(1, city.population + ageLoss) * 0.18); +} if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) { const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010))); city.population -= attrition; @@ -1733,55 +1760,19 @@ const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityA const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB); return from != null && to != null ? [from, to] : null; } -polityTradeNeighbors(city, polityId) { -if (!city || polityId == null) return []; -const neighbors = []; -for (const link of this.tradeLinks || []) { -const endpoints = this.tradeLinkEndpointIds(link); -if (!endpoints) continue; -const [from, to] = endpoints; -const otherId = from === city.id ? to : to === city.id ? from : null; -if (otherId == null) continue; -const other = this.getCityById(otherId); -if (other?.polityId === polityId) neighbors.push(other); -} -return neighbors; -} -tradeAccessToCenter(city, center, polityId) { -if (!city || !center || polityId == null) return { reachable: false, hops: Infinity, transitCities: [] }; -if (city.id === center.id) return { reachable: true, hops: 0, transitCities: [] }; -if (this.hasDirectTradeConnection(city, center)) return { reachable: true, hops: 1, transitCities: [] }; -const maxDepth = SimConfig.polityAccess?.maxSearchDepth ?? 8; -const visited = new Set([city.id]); -const queue = [{ city, hops: 0, transitCities: [] }]; -while (queue.length) { -const current = queue.shift(); -if (current.hops >= maxDepth) continue; -for (const neighbor of this.polityTradeNeighbors(current.city, polityId)) { -if (visited.has(neighbor.id)) continue; -const hops = current.hops + 1; -if (neighbor.id === center.id) return { reachable: true, hops, transitCities: current.transitCities }; -visited.add(neighbor.id); -queue.push({ city: neighbor, hops, transitCities: [...current.transitCities, neighbor] }); -} -} -return { reachable: false, hops: Infinity, transitCities: [] }; -} tradeAccessLoyaltyPenalty(city, center, polity) { const cfg = SimConfig.polityAccess; if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0; -const access = this.tradeAccessToCenter(city, center, polity.id); -if (!access.reachable) return clamp(cfg.noAccessPenalty, 0, 0.07); -let penalty = cfg.indirectPenalty + Math.max(0, access.hops - 1) * cfg.perHopPenalty; -const originEthnicity = this.dominantCityEthnicity(city); -for (const transitCity of access.transitCities) { -if ((transitCity.loyalty ?? 0.5) < 0.35) penalty += cfg.lowLoyaltyTransitPenalty; -const transitEthnicity = this.dominantCityEthnicity(transitCity); -if (originEthnicity !== null && transitEthnicity !== null && transitEthnicity !== originEthnicity) { -penalty += cfg.foreignTransitPenalty; +return clamp(cfg.noAccessPenalty, 0, 0.24); } -} -return clamp(penalty, 0, 0.07); +rollLeaderCharisma() { +const cfg = SimConfig.polity; +const min = cfg?.charismaMin ?? 0.5; +const max = cfg?.charismaMax ?? 3.0; +const targetAverage = cfg?.charismaAverage ?? 1.0; +const normalizedAverage = clamp((targetAverage - min) / Math.max(0.001, max - min), 0.001, 0.999); +const exponent = (1 / normalizedAverage) - 1; +return min + (max - min) * Math.pow(this.rng.next(), exponent); } effectiveDistance(cityA, cityB) { let distance = this.distanceBetweenCities(cityA, cityB); @@ -1799,7 +1790,7 @@ color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), founded: this.year, legitimacy: this.rng.range(0.68, 0.94), cohesion: this.rng.range(0.58, 0.9), -charisma: this.rng.range(0.5, 1.5), +charisma: this.rollLeaderCharisma(), leaderStarted: this.year, leaderTenureYears: this.rng.range(24, 68), crisis: 0, @@ -1905,17 +1896,19 @@ while (this.graphEvents.length > 160) this.graphEvents.shift(); } selectNewLeader(polity, forced = false) { if (!polity) return; -const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5); +const charismaMin = SimConfig.polity?.charismaMin ?? 0.5; +const charismaMax = SimConfig.polity?.charismaMax ?? 3.0; +const previousCharisma = clamp(polity.charisma ?? 1, charismaMin, charismaMax); const center = this.getCityById(polity.centerCityId); const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5; const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3; -const randomLeader = this.rng.range(0.5, 1.5); +const randomLeader = this.rollLeaderCharisma(); const continuity = forced ? 0.18 : 0.34; const institutionalPull = 0.82 + institutionalBias * 0.36; const nextCharisma = clamp( previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull, -0.5, -1.5 +charismaMin, +charismaMax ); polity.charisma = nextCharisma; polity.leaderStarted = this.year; @@ -1927,7 +1920,7 @@ if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5); } updatePolityLeaders() { for (const polity of this.polities) { -polity.charisma = clamp(polity.charisma ?? 1, 0.5, 1.5); +polity.charisma = clamp(polity.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0); polity.leaderStarted ??= polity.founded ?? this.year; polity.leaderTenureYears ??= this.rng.range(24, 68); const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR; @@ -2194,7 +2187,7 @@ tech * 24 - instability * 12 - agePressure * 6 ); -return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5); +return basePower * clamp(polity.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0); } polityInfluenceRange(polity, wartime = false) { const power = this.polityPower(polity); @@ -2679,6 +2672,34 @@ city.receivedAid = true; city.loyalty = clamp(city.loyalty + 0.05, 0, 1); } } +this.restoreAgedPolityCities(polity, cities); +} +} +restoreAgedPolityCities(polity, cities) { +const cfg = SimConfig.city; +if (!polity || !cities?.length || (polity.treasury || 0) <= 0) return; +const minWear = cfg.agingRestoreMinWear ?? 0.08; +let budget = Math.min(polity.treasury, (polity.treasury || 0) * (cfg.agingRestoreBudgetShare ?? 0.18)); +if (budget <= 0) return; +const costPerWear = Math.max(1, cfg.agingRestoreCostPerWear ?? 70); +const candidates = [...cities] +.filter(city => (city.ageWear || 0) > minWear && city.population > 0) +.sort((a, b) => { +const aPressure = Math.max(0, (a.population || 0) - this.cityAgePopulationLimit(a)); +const bPressure = Math.max(0, (b.population || 0) - this.cityAgePopulationLimit(b)); +return bPressure - aPressure || (b.population || 0) - (a.population || 0); +}); +for (const city of candidates) { +if (budget <= 0 || polity.treasury <= 0) break; +const wear = city.ageWear || 0; +const maxRestoreCost = Math.min(budget, polity.treasury, wear * costPerWear); +if (maxRestoreCost <= 0) continue; +const restoredWear = maxRestoreCost / costPerWear; +city.ageWear = clamp(wear - restoredWear, 0, 1); +polity.treasury -= maxRestoreCost; +budget -= maxRestoreCost; +city.receivedAid = true; +city.loyalty = clamp((city.loyalty ?? 0.5) + restoredWear * 0.24, 0, 1); } } erodePolityLegitimacy() { @@ -2767,8 +2788,7 @@ delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045); delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012; delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045); const directCenterTrade = this.hasDirectTradeConnection(city, center); -if (directCenterTrade) delta += 0.025; -else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity); +if (!directCenterTrade) delta -= this.tradeAccessLoyaltyPenalty(city, center, polity); if (city.receivedAid) delta += 0.04; delta -= 0.004; if (city.storedResources < city.population * 0.05) delta -= 0.035; diff --git a/render.js b/render.js index fc340d7..4a6946b 100644 --- a/render.js +++ b/render.js @@ -290,15 +290,16 @@ els.tooltip.innerHTML = ` ])} ${tooltipSection("City & State", [ tooltipRow("Population", city?.population.toLocaleString(), city), + tooltipRow("Age cap", city ? `${Math.floor(sim.cityAgePopulationLimit(city)).toLocaleString()} / ${Math.round((city.ageWear || 0) * 100)}% wear` : "", city), tooltipRow("Food stock", city?.storedResources.toFixed(1), city), tooltipRow("Supply stress", (city?.supplyStress || 0).toFixed(2), city), tooltipRow("Trade", city ? `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value` : "", city), tooltipRow("Knowledge", city ? `${knowledgeLevel(city, "farming").toFixed(2)} farm / ${knowledgeLevel(city, "metallurgy").toFixed(2)} metal` : "", city), - tooltipRow("City majority", `E${cityEthnicity}`, cityEthnicity), + tooltipRow("City majority", cityEthnicity != null ? `E${cityEthnicity}` : "", cityEthnicity != null), city ? cityEthnicityPie(city) : "", tooltipRow("State", polity ? `#${polity.id}` : "Independent", city), tooltipRow("Loyalty", city?.loyalty.toFixed(2), city), - tooltipRow("Charisma", clamp(polity?.charisma ?? 1, 0.5, 1.5).toFixed(2), polity), + tooltipRow("Charisma", clamp(polity?.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0).toFixed(2), polity), tooltipRow("Leader tenure", polity ? `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y` : "", polity), tooltipRow("Treasury", polity?.treasury.toFixed(1), polity), tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity) @@ -328,33 +329,44 @@ els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.widt els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`; } function cityEthnicityPie(city) { -if (!city?.ethnicityComposition?.size) return ""; -const entries = [...city.ethnicityComposition] -.filter(([, count]) => count > 0) +if (!city) return ""; +let entries = [...(city.ethnicityComposition || new Map())] +.map(([id, count]) => [id, Number(count)]) +.filter(([, count]) => Number.isFinite(count) && count > 0) .sort((a, b) => b[1] - a[1]); +if (!entries.length && city.population > 0) { +const tile = sim.world.idx(city.x, city.y); +const fallbackId = sim.world.dominantEthnicity[tile] > 0 ? sim.world.dominantEthnicity[tile] : null; +if (fallbackId != null) entries = [[fallbackId, city.population]]; +} const total = entries.reduce((sum, [, count]) => sum + count, 0); if (total <= 0) return ""; const top = entries.slice(0, 5); const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0); const slices = other > 0 ? [...top, [0, other]] : top; +const sliceColor = id => { +const ethnicity = id !== 0 ? sim.ethnicities.get(id) : null; +return ethnicity?.color || [122, 130, 126]; +}; let cursor = 0; const gradient = slices.map(([id, count]) => { const start = cursor / total * 100; cursor += count; const end = cursor / total * 100; -const ethnicity = id ? sim.ethnicities.get(id) : null; -const color = ethnicity?.color || [122, 130, 126]; +const color = sliceColor(id); return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`; }).join(", "); const labels = slices.map(([id, count]) => { -const ethnicity = id ? sim.ethnicities.get(id) : null; -const color = ethnicity?.color || [122, 130, 126]; -const label = id ? `E${id}` : "Other"; +const color = sliceColor(id); +const label = id !== 0 ? `E${id}` : "Other"; return `${label} ${Math.round(count / total * 100)}%`; }).join(""); +const background = slices.length === 1 +? `rgb(${sliceColor(slices[0][0]).join(",")})` +: `conic-gradient(${gradient})`; return `
-
+
${labels}
`; diff --git a/utils.js b/utils.js index 7b1e571..65bea1a 100644 --- a/utils.js +++ b/utils.js @@ -77,6 +77,7 @@ function dominantComposition(composition) { let bestId = null; let bestCount = 0; for (const [id, count] of composition) { +if (!Number.isFinite(count) || count <= 0) continue; if (count > bestCount) { bestId = id; bestCount = count;