Civ growth rebalance: urban crowding plateau, plagues, good-year cap

Cities used to climb a hugely stacked carrying capacity (K up to 20-40M
for the best trade hubs -- civMaxPopulation x habitability x siteQuality
x conditions x trade) at ~2%/yr for millennia: bounded in principle,
endless in practice. Three fixes, all population-only + derived vectors
(pure hashes, no RNG, no save-version bump, step-back exact):

- Urban crowding: mortality rises with the square of city size
  (civCrowdingLoss x (P/civMetropolisPop)^2 per year), so the best hubs
  PLATEAU at a historical metropolis scale (~1-1.5M) instead of chasing
  K; negligible below ~50k, not a clamp.
- Good-year cap (civCondBoomCap): a lucky harvest no longer inflates the
  K target by 70% (the logistic chased booms at full rate while famine
  corrected busts slowly -- an upward ratchet); droughts stay uncapped.
- Plagues (civPlague*): rare deterministic epidemics (1-3-year waves,
  20-40% deaths at full exposure) strike cities (exposure 0 below ~30k),
  harder when trade-connected -- contagion travels the routes, the
  historical check on big hubs. Derived sCivPlague + cell-info PLAGUE
  line + "Plague ravages X" / "Plague shrinks X" kind-3 events.

Retuned: civMaxPopulation 2e6 -> 1e6 (it is a capacity SCALE, not a
cap -- comment fixed), civEmpirePop 5e6 -> 2.5e6 for the new sizes.

test_civ gains a plateau/plague section: with the new model the largest
city settles ~1.1M vs 3.7M-and-climbing without it; villages never
plague; waves are deterministic, twin-identical and rewind exactly.
All 16 suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-07-04 15:52:27 +02:00
parent 4cb0ef9328
commit ab2c4024f8
11 changed files with 200 additions and 18 deletions

View File

@ -146,7 +146,16 @@ on the Live World clock). **Steps 17 of the roadmap are done (plus a derived
year-to-year **harvests** (bigger swings in continental interiors), rare **cold years** and river year-to-year **harvests** (bigger swings in continental interiors), rare **cold years** and river
**floods**, plus **volcano ash**. **Storms** over a town kill people directly (hurricanes worst, **floods**, plus **volcano ash**. **Storms** over a town kill people directly (hurricanes worst,
`civStormDeathRate`/`civHurricaneDeathMult`); sustained famine / acute disasters can **collapse** a `civStormDeathRate`/`civHurricaneDeathMult`); sustained famine / acute disasters can **collapse** a
settlement to ruins (kept in the set, can revive). Tiers village→town→city; markers (3D + 2D) sized by settlement to ruins (kept in the set, can revive). **Big-city demography** (the growth rebalance —
cities used to climb a huge stacked K exponentially for millennia, "endless growth"): an **urban
crowding** mortality rises with the square of city size (`civCrowdingLoss·(P/civMetropolisPop)²`), so
the best trade hubs **plateau at a historical metropolis scale (~12M)** instead of chasing K; the
good-year `cond` upside in the K *target* is capped (`civCondBoomCap` — no boom-year ratchet; droughts
still bite uncapped); and rare deterministic **plagues** (`civPlague*`, pure hash of (id, year, seed) —
a 13-year wave killing 2040% at full exposure) strike **cities** (exposure 0 below ~30k) harder when
**trade-connected** (contagion travels the routes — the cost of being a hub; derived `sCivPlague`, a
cell-info `PLAGUE` line + "Plague ravages X" / "Plague shrinks X" kind=3 events). Tiers
village→town→city; markers (3D + 2D) sized by
tier and **withered-tinted** by hardship + a **Civ** tab + cell-info "conditions/drought" line + kind=3 tier and **withered-tinted** by hardship + a **Civ** tab + cell-info "conditions/drought" line + kind=3
`WorldEvent`s ("X grew into a city", "Hurricane <name> devastates X", "Famine shrinks X to a Town", "X `WorldEvent`s ("X grew into a city", "Hurricane <name> devastates X", "Famine shrinks X to a Town", "X
was abandoned"). The set is fixed, so the step-back snapshot only restores the per-settlement was abandoned"). The set is fixed, so the step-back snapshot only restores the per-settlement
@ -1022,22 +1031,32 @@ triangles (plates are fixed in phase 1).
tighter clusters), `civMinHabitability` (0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight` tighter clusters), `civMinHabitability` (0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight`
(0.40, the rest is temperature comfort), `civHabTempOpt` (18 °C, most comfortable mean), `civHabElevPenalty` (0.40, the rest is temperature comfort), `civHabTempOpt` (18 °C, most comfortable mean), `civHabElevPenalty`
(2500 m, high terrain steeply penalised above this). Population — `civSeedPopulation` (250, initial (2500 m, high terrain steeply penalised above this). Population — `civSeedPopulation` (250, initial
village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (2e6, the carrying capacity at village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (1e6, the carrying-capacity
habitability 1), tier thresholds `civTownPop` (5000) / `civCityPop` (100000), `civAbandonPop` (50, **scale** — multiplied by habitability/siteQuality/trade, a top hub's K is several × this; the real
metropolis ceiling is the crowding below), tier thresholds `civTownPop` (5000) / `civCityPop` (100000),
`civAbandonPop` (50,
below = abandoned/ruins but can revive). **Dynamics**`civSiteVariety` (1.0; 0 = flat capacities, below = abandoned/ruins but can revive). **Dynamics**`civSiteVariety` (1.0; 0 = flat capacities,
higher = big rivers/coasts host far larger cities → wide size spread), `civGrowthMin` (0.25, growth-rate higher = big rivers/coasts host far larger cities → wide size spread), `civGrowthMin` (0.25, growth-rate
floor at habitability 0), `civHarvestVar` (0.25, year-to-year harvest swing, scaled by continentality), floor at habitability 0), `civHarvestVar` (0.25, year-to-year harvest swing, scaled by continentality),
`civDroughtStrength` (0.70) / `civDroughtPeriod` (8 yr) / `civDroughtThresh` (0.15) / `civDroughtArid` `civDroughtStrength` (0.70) / `civDroughtPeriod` (8 yr) / `civDroughtThresh` (0.15) / `civDroughtArid`
(0.50, drought-proneness in arid regions), `civColdYearStrength` (0.50), `civFloodBonus` (0.25, river (0.50, drought-proneness in arid regions), `civColdYearStrength` (0.50), `civFloodBonus` (0.25, river
silt), `civFamineRate` (0.15, accelerated loss when food < population), `civStormDeathRate` (0.50) / silt), `civFamineRate` (0.15, accelerated loss when food < population), `civStormDeathRate` (0.50) /
`civHurricaneDeathMult` (3.0, deaths from a storm/hurricane over a town). Droughts/harvests are `civHurricaneDeathMult` (3.0, deaths from a storm/hurricane over a town). **Big-city plateau**
deterministic per (~20° region, year, seed); an active volcano's ash within ~1.5× its blast radius also `civMetropolisPop` (1.5e6, crowding scale: mortality = `civCrowdingLoss·(P/this)²`/yr; the best hubs
cuts capacity. Marker sizes/colours + hardship tint are render constants (ViewerRender.cpp). plateau near `this·sqrt(growth/civCrowdingLoss)` ≈ 11.5M), `civCrowdingLoss` (0.02/yr at P =
civMetropolisPop; **0 = the old unbounded behaviour**), `civCondBoomCap` (1.25, cap on the good-year
condition upside in the K target — droughts uncapped). **Plagues**`civPlagueRate` (0.01/yr outbreak
chance per settlement), `civPlagueDeathMin`/`Max` (0.20/0.40, total wave kill at full exposure over 13
years), `civPlagueTradeWeight` (0.5, exposure share from trade connectivity vs pure size; exposure is 0
below ~30k → villages never plague). Droughts/harvests/plagues are
deterministic per (region/settlement, year, seed); an active volcano's ash within ~1.5× its blast radius
also cuts capacity. Marker sizes/colours + hardship tint are render constants (ViewerRender.cpp).
- **Territory & nations (`civTerritory*`/`civVassal*`/`civEmpire*`, `planet.cfg`; key `P`):** - **Territory & nations (`civTerritory*`/`civVassal*`/`civEmpire*`, `planet.cfg`; key `P`):**
`civTerritoryBase` (0.035 rad, a village's reach), `civTerritoryScale` (0.05 rad per log10 of `civTerritoryBase` (0.035 rad, a village's reach), `civTerritoryScale` (0.05 rad per log10 of
population/seed — big cities reach far), `civTerritoryMax` (0.35 rad cap); `civVassalRange` (1.5 × population/seed — big cities reach far), `civTerritoryMax` (0.35 rad cap); `civVassalRange` (1.5 ×
a capital's range = its annexation reach for vassal towns → bigger = larger kingdoms); empire a capital's range = its annexation reach for vassal towns → bigger = larger kingdoms); empire
threshold `civEmpireMinMembers` (5 settlements) / `civEmpirePop` (5e6 total). Territory + realms are threshold `civEmpireMinMembers` (5 settlements) / `civEmpirePop` (2.5e6 total, retuned for the
plateaued city sizes). Territory + realms are
**derived** (recomputed each sim year, not saved). Realm colours/border colour/labels are render **derived** (recomputed each sim year, not saved). Realm colours/border colour/labels are render
constants (Colors.cpp / ViewerRender.cpp). constants (Colors.cpp / ViewerRender.cpp).
- **Culture, beliefs & governments (civ Step 4, key `X`):** **no config knobs** — the ethos/faith/ - **Culture, beliefs & governments (civ Step 4, key `X`):** **no config knobs** — the ethos/faith/

View File

@ -96,13 +96,16 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
: TextFormat("%.0f", s.population); : TextFormat("%.0f", s.population);
L.push_back(std::string(alive ? settleTierName(t) : "Ruins of") + " " + s.name L.push_back(std::string(alive ? settleTierName(t) : "Ruins of") + " " + s.name
+ " (pop " + pop + ")"); + " (pop " + pop + ")");
// Live conditions: drought / hardship / boom (derived each civ step). // Live conditions: drought / hardship / boom / plague (derived each civ step).
const auto& cond = p.settlementCondition(); const auto& dro = p.settlementDrought(); const auto& cond = p.settlementCondition(); const auto& dro = p.settlementDrought();
if (si < (int)cond.size()) { if (si < (int)cond.size()) {
double cd = cond[si], dr = (si < (int)dro.size()) ? dro[si] : 0.0; double cd = cond[si], dr = (si < (int)dro.size()) ? dro[si] : 0.0;
if (dr > 0.15) L.push_back(std::string(TextFormat(" drought %.0f%% conditions %.0f%%", dr * 100.0, cd * 100.0))); if (dr > 0.15) L.push_back(std::string(TextFormat(" drought %.0f%% conditions %.0f%%", dr * 100.0, cd * 100.0)));
else L.push_back(std::string(TextFormat(" conditions %.0f%% (%s)", cd * 100.0, else L.push_back(std::string(TextFormat(" conditions %.0f%% (%s)", cd * 100.0,
cd > 1.05 ? "good harvest" : cd < 0.8 ? "hardship" : "normal"))); cd > 1.05 ? "good harvest" : cd < 0.8 ? "hardship" : "normal")));
const auto& plv = p.settlementPlague();
double pl = (si < (int)plv.size()) ? plv[si] : 0.0;
if (pl > 0.01) L.push_back(std::string(TextFormat(" PLAGUE -%.0f%%/yr", pl * 100.0)));
} }
} }
} }

View File

@ -379,7 +379,8 @@ void Viewer::appendEvent(uint8_t kind, uint8_t severity, double timeHours, int c
void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms, void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes, const std::vector<Volcano>& beforeVolcanoes,
const std::vector<Settlement>& beforeSettlements) { const std::vector<Settlement>& beforeSettlements,
const std::vector<double>& beforePlague) {
auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* { auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* {
for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws; for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws;
return nullptr; return nullptr;
@ -445,6 +446,11 @@ void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const Settlement& o = beforeSettlements[k]; const Settlement& o = beforeSettlements[k];
SettleTier tb = settleTierOf(o.population, townP, cityP), ta = settleTierOf(s.population, townP, cityP); SettleTier tb = settleTierOf(o.population, townP, cityP), ta = settleTierOf(s.population, townP, cityP);
bool aliveB = o.population >= abP, aliveA = s.population >= abP; bool aliveB = o.population >= abP, aliveA = s.population >= abP;
double pl = (k < planet.settlementPlague().size()) ? planet.settlementPlague()[k] : 0.0;
// Plague onset: an epidemic wave started in this settlement this frame.
if (aliveA && pl > 0.02 && (k >= beforePlague.size() || beforePlague[k] <= 1e-9))
appendEvent(3, 2, liveTime, s.cell, s.id, "Plague ravages " + s.name,
std::string(TextFormat("%.0f%%/yr dying, ", pl * 100.0)) + popLine(s));
if (aliveB && !aliveA) if (aliveB && !aliveA)
appendEvent(3, 2, liveTime, s.cell, s.id, s.name + " was abandoned", popLine(s)); appendEvent(3, 2, liveTime, s.cell, s.id, s.name + " was abandoned", popLine(s));
else if (!aliveB && aliveA) else if (!aliveB && aliveA)
@ -461,6 +467,8 @@ void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
double dr = (k < planet.settlementDrought().size()) ? planet.settlementDrought()[k] : 0.0; double dr = (k < planet.settlementDrought().size()) ? planet.settlementDrought()[k] : 0.0;
std::string title, detail = popLine(s); std::string title, detail = popLine(s);
if (stormName) title = std::string(stormName) + " devastates " + s.name; if (stormName) title = std::string(stormName) + " devastates " + s.name;
else if (pl > 0.02) { title = std::string("Plague shrinks ") + s.name + " to a " + settleTierName(ta);
detail = std::string(TextFormat("%.0f%%/yr dying, ", pl * 100.0)) + popLine(s); }
else if (dr > 0.25) { title = std::string("Famine shrinks ") + s.name + " to a " + settleTierName(ta); else if (dr > 0.25) { title = std::string("Famine shrinks ") + s.name + " to a " + settleTierName(ta);
detail = std::string(TextFormat("drought %.0f%%, ", dr * 100.0)) + popLine(s); } detail = std::string(TextFormat("drought %.0f%%, ", dr * 100.0)) + popLine(s); }
else title = s.name + " declined to a " + settleTierName(ta); else title = s.name + " declined to a " + settleTierName(ta);
@ -816,17 +824,19 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
std::vector<WeatherSystem> beforeStorms; std::vector<WeatherSystem> beforeStorms;
std::vector<Volcano> beforeVolcanoes; std::vector<Volcano> beforeVolcanoes;
std::vector<Settlement> beforeSettlements; std::vector<Settlement> beforeSettlements;
std::vector<double> beforePlague;
if (dtWeather > 0.0) { if (dtWeather > 0.0) {
beforeStorms = planet.storms(); beforeStorms = planet.storms();
beforeVolcanoes = planet.volcanoes; beforeVolcanoes = planet.volcanoes;
beforeSettlements = planet.settlements; beforeSettlements = planet.settlements;
beforePlague = planet.settlementPlague();
} }
planet.stepWeather(dtWeather); planet.stepWeather(dtWeather);
// Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here // Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here
// reasserts restored terrain/biome state without advancing the lifecycle. // reasserts restored terrain/biome state without advancing the lifecycle.
VolcanoUpdate vu = planet.stepVolcanoes(dtWeather); VolcanoUpdate vu = planet.stepVolcanoes(dtWeather);
CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock
if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements); if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements, beforePlague);
// Territory & nations shift slowly -> recompute once per sim year (and rebuild the border lines). // Territory & nations shift slowly -> recompute once per sim year (and rebuild the border lines).
// Wars (civ Step 5) run on the same yearly tick: stepConflict mutates allegiance/populations first, // Wars (civ Step 5) run on the same yearly tick: stepConflict mutates allegiance/populations first,
// then territory recomputes so borders move as cities change hands. // then territory recomputes so borders move as cities change hands.

View File

@ -187,7 +187,8 @@ struct Viewer {
const std::string& title, const std::string& detail); const std::string& title, const std::string& detail);
void detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms, void detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes, const std::vector<Volcano>& beforeVolcanoes,
const std::vector<Settlement>& beforeSettlements); const std::vector<Settlement>& beforeSettlements,
const std::vector<double>& beforePlague);
void detectNationEvents(const std::vector<Nation>& beforeNations); void detectNationEvents(const std::vector<Nation>& beforeNations);
void focusCell(int idx, const std::string& status = ""); void focusCell(int idx, const std::string& status = "");

View File

@ -62,7 +62,7 @@ void Planet::buildGeometry() {
volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au; volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au;
settlements.clear(); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu; settlements.clear(); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu;
sCellSettlement.assign(cells.size(), -1); sHabitability.clear(); sCellSettlement.assign(cells.size(), -1); sHabitability.clear();
sCivCond.clear(); sCivDrought.clear(); sCivCond.clear(); sCivDrought.clear(); sCivPlague.clear();
nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear(); nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear();
cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); sCultureNextId = 1; cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); sCultureNextId = 1;
sCellSettleOwner.assign(cells.size(), -1); sCellSettleOwner.assign(cells.size(), -1);

View File

@ -251,6 +251,7 @@ public:
// current drought severity 0..1. Parallel to `settlements`. Used by the viewer for tint + events. // current drought severity 0..1. Parallel to `settlements`. Used by the viewer for tint + events.
const std::vector<double>& settlementCondition() const { return sCivCond; } const std::vector<double>& settlementCondition() const { return sCivCond; }
const std::vector<double>& settlementDrought() const { return sCivDrought; } const std::vector<double>& settlementDrought() const { return sCivDrought; }
const std::vector<double>& settlementPlague() const { return sCivPlague; } // active epidemic loss (/yr, 0 = none)
// Build a fine-resolution subgrid patch for one macro cell (phase 4/5 hook). // Build a fine-resolution subgrid patch for one macro cell (phase 4/5 hook).
std::shared_ptr<SubGrid> makeSubGrid(int cellIndex, int res) const; std::shared_ptr<SubGrid> makeSubGrid(int cellIndex, int res) const;
@ -383,7 +384,7 @@ private:
// a derived habitability field, and a separate RNG so placement never perturbs tectonics. // a derived habitability field, and a separate RNG so placement never perturbs tectonics.
std::vector<int> sCellSettlement; std::vector<int> sCellSettlement;
std::vector<double> sHabitability; std::vector<double> sHabitability;
std::vector<double> sCivCond, sCivDrought; // per-settlement live conditions (derived) std::vector<double> sCivCond, sCivDrought, sCivPlague; // per-settlement live conditions (derived)
uint32_t sCivRng = 1; uint32_t sCivRng = 1;
// Territory & nations (derived from settlements; not saved). sCellNation: nation index per cell // Territory & nations (derived from settlements; not saved). sCellNation: nation index per cell
// (-1 = wilderness/ocean); sSettleNation: nation index per settlement. // (-1 = wilderness/ocean); sSettleNation: nation index per settlement.

View File

@ -130,6 +130,7 @@ void Planet::placeSettlements() {
} }
sCivCond.assign(settlements.size(), 1.0); sCivCond.assign(settlements.size(), 1.0);
sCivDrought.assign(settlements.size(), 0.0); sCivDrought.assign(settlements.size(), 0.0);
sCivPlague.assign(settlements.size(), 0.0);
} }
// One live-frame civilization step. Each settlement's population moves logistically toward a food-driven // One live-frame civilization step. Each settlement's population moves logistically toward a food-driven
@ -145,6 +146,7 @@ CivUpdate Planet::stepCivilization(double dtHours, double liveTime) {
const int n = (int)cells.size(); const int n = (int)cells.size();
if ((int)sHabitability.size() != n) computeHabitability(); if ((int)sHabitability.size() != n) computeHabitability();
if ((int)sCivCond.size() != (int)settlements.size()) { sCivCond.assign(settlements.size(), 1.0); sCivDrought.assign(settlements.size(), 0.0); } if ((int)sCivCond.size() != (int)settlements.size()) { sCivCond.assign(settlements.size(), 1.0); sCivDrought.assign(settlements.size(), 0.0); }
if (sCivPlague.size() != settlements.size()) sCivPlague.assign(settlements.size(), 0.0);
const double yearHours = std::max(1.0, cfg.dayLengthHours * cfg.yearLengthDays); const double yearHours = std::max(1.0, cfg.dayLengthHours * cfg.yearLengthDays);
const double dtYears = std::max(0.0, dtHours) / yearHours; const double dtYears = std::max(0.0, dtHours) / yearHours;
@ -218,9 +220,34 @@ CivUpdate Planet::stepCivilization(double dtHours, double liveTime) {
double cond = harvest * droughtFactor * coldFactor * floodFactor * ashFactor; double cond = harvest * droughtFactor * coldFactor * floodFactor * ashFactor;
double prosp = ((size_t)k < sProsperity.size()) ? sProsperity[(size_t)k] : 0.0; // civ Step 7: trade wealth boosts capacity double prosp = ((size_t)k < sProsperity.size()) ? sProsperity[(size_t)k] : 0.0; // civ Step 7: trade wealth boosts capacity
double K = cfg.civMaxPopulation * hab * siteQ * cond * (1.0 + cfg.tradeProsperityWeight * prosp); // Good-year cap: a lucky harvest must not inflate the K TARGET by 70% (the logistic would chase
// it at full rate while busts correct slowly -- an upward ratchet). The downside stays uncapped
// (droughts/ash bite hard); famine, tint and the conditions display keep the raw cond.
double condK = std::min(cond, cfg.civCondBoomCap);
double K = cfg.civMaxPopulation * hab * siteQ * condK * (1.0 + cfg.tradeProsperityWeight * prosp);
sCivCond[k] = cond; sCivDrought[k] = drought; sCivCond[k] = cond; sCivDrought[k] = drought;
// Plague: rare epidemics keep big cities in check (pre-industrial metropolises were population
// sinks). A wave is a pure function of (settlement id, onset year, seed); to know whether one is
// active at `year`, re-derive the onset test for the last 3 years -- stateless, so a step-back
// replays it exactly. Exposure needs a real city (population) and grows with trade connectivity
// (contagion travels the routes -- the cost of being a hub); villages are untouched.
double plague = 0.0;
for (int back = 0; back < 3; ++back) {
uint32_t y0 = (uint32_t)(year - back);
uint32_t h = civHash(base ^ civHash(st.id * 0x85EBCA6Bu ^ y0 * 0xC2B2AE35u ^ 0x9A19D5Cu));
if (civHashf(h) >= cfg.civPlagueRate) continue; // no outbreak begun that year
int L = 1 + (int)((h >> 8) % 3u); // the wave lasts 1..3 years
if (back >= L) continue; // already burned out
double sev = cfg.civPlagueDeathMin
+ (cfg.civPlagueDeathMax - cfg.civPlagueDeathMin) * civHashf(h ^ 0xA341316Cu);
plague += sev / (double)L; // annual loss rate of this wave
}
double popF = std::clamp((st.population / std::max(1.0, cfg.civCityPop) - 0.3) / 2.7, 0.0, 1.0);
double connF = std::clamp(prosp / 3.0, 0.0, 1.0); // sProsperity is clamped <= 3
double expo = popF * (1.0 - cfg.civPlagueTradeWeight + cfg.civPlagueTradeWeight * connF);
sCivPlague[k] = std::min(plague, cfg.civPlagueDeathMax) * expo;
// Storms over the town kill people directly (acute), hurricanes worst. Storms are snapshotted, so // Storms over the town kill people directly (acute), hurricanes worst. Storms are snapshotted, so
// this stays consistent on a step-back. // this stays consistent on a step-back.
double stormLoss = 0.0; double stormLoss = 0.0;
@ -237,6 +264,12 @@ CivUpdate Planet::stepCivilization(double dtHours, double liveTime) {
double r = cfg.civGrowthRate * (cfg.civGrowthMin + (1.0 - cfg.civGrowthMin) * hab); // env-driven rate double r = cfg.civGrowthRate * (cfg.civGrowthMin + (1.0 - cfg.civGrowthMin) * hab); // env-driven rate
P += r * P * (1.0 - P / std::max(1.0, K)) * dtYears; // logistic toward K P += r * P * (1.0 - P / std::max(1.0, K)) * dtYears; // logistic toward K
if (K < P) P -= cfg.civFamineRate * (1.0 - K / P) * P * dtYears; // accelerated famine if (K < P) P -= cfg.civFamineRate * (1.0 - K / P) * P * dtYears; // accelerated famine
// Urban crowding: mortality rises with the square of city size (disease/logistics), so the
// best sites PLATEAU near civMetropolisPop x sqrt(r/civCrowdingLoss) instead of chasing a
// huge K for millennia. Negligible below ~50k; not a clamp -- everything still moves P.
double crowd = P / std::max(1.0, cfg.civMetropolisPop);
P -= std::min(cfg.civCrowdingLoss * crowd * crowd, 0.25) * P * dtYears;
P -= sCivPlague[k] * P * dtYears; // epidemic deaths (big hubs)
P -= stormLoss * P * dtYears; // acute storm deaths P -= stormLoss * P * dtYears; // acute storm deaths
P = std::max(1.0, P); P = std::max(1.0, P);
if (std::fabs(P - st.population) > std::max(1.0, st.population * 0.0005)) up.recolor = true; if (std::fabs(P - st.population) > std::max(1.0, st.population * 0.0005)) up.recolor = true;
@ -324,6 +357,7 @@ std::vector<WarEvent> Planet::stepColonization(long year) {
if (sSettleCulture.size() != settlements.size()) sSettleCulture.resize(settlements.size(), -1); if (sSettleCulture.size() != settlements.size()) sSettleCulture.resize(settlements.size(), -1);
sSettleCulture[newIdx] = (cap < (int)sSettleCulture.size()) ? sSettleCulture[cap] : -1; sSettleCulture[newIdx] = (cap < (int)sSettleCulture.size()) ? sSettleCulture[cap] : -1;
if (sCivCond.size() != settlements.size()) { sCivCond.resize(settlements.size(), 1.0); sCivDrought.resize(settlements.size(), 0.0); } if (sCivCond.size() != settlements.size()) { sCivCond.resize(settlements.size(), 1.0); sCivDrought.resize(settlements.size(), 0.0); }
if (sCivPlague.size() != settlements.size()) sCivPlague.resize(settlements.size(), 0.0);
ev.push_back(WarEvent{ 1, best, "The " + nat.name + " founds the colony of " + settlements[newIdx].name, ev.push_back(WarEvent{ 1, best, "The " + nat.name + " founds the colony of " + settlements[newIdx].name,
"A new settlement rises on distant land.", 3 }); "A new settlement rises on distant land.", 3 });
} }

View File

@ -57,6 +57,8 @@
D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \ D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \
D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \ D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \
D(civStormDeathRate) D(civHurricaneDeathMult) \ D(civStormDeathRate) D(civHurricaneDeathMult) \
D(civMetropolisPop) D(civCrowdingLoss) D(civCondBoomCap) \
D(civPlagueRate) D(civPlagueDeathMin) D(civPlagueDeathMax) D(civPlagueTradeWeight) \
D(civTerritoryBase) D(civTerritoryScale) D(civTerritoryMax) D(civVassalRange) D(civEmpirePop) \ D(civTerritoryBase) D(civTerritoryScale) D(civTerritoryMax) D(civVassalRange) D(civEmpirePop) \
D(warDeclareRate) D(warAmbition) D(warIdeology) D(warBorder) D(warWarlikeMult) D(warCasualtyRate) \ D(warDeclareRate) D(warAmbition) D(warIdeology) D(warBorder) D(warWarlikeMult) D(warCasualtyRate) \
D(warConquerScore) D(warSackChance) D(warExhaustion) D(warRevoltRate) D(warMinRealmPop) \ D(warConquerScore) D(warSackChance) D(warExhaustion) D(warRevoltRate) D(warMinRealmPop) \
@ -293,6 +295,15 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.civFamineRate, 0.0, 10.0, "civFamineRate")); E(rng(cfg.civFamineRate, 0.0, 10.0, "civFamineRate"));
E(rng(cfg.civStormDeathRate, 0.0, 10.0, "civStormDeathRate")); E(rng(cfg.civStormDeathRate, 0.0, 10.0, "civStormDeathRate"));
E(rng(cfg.civHurricaneDeathMult, 1.0, 50.0, "civHurricaneDeathMult")); E(rng(cfg.civHurricaneDeathMult, 1.0, 50.0, "civHurricaneDeathMult"));
E(rng(cfg.civMetropolisPop, 1.0e3, 1.0e12, "civMetropolisPop"));
E(rng(cfg.civCrowdingLoss, 0.0, 10.0, "civCrowdingLoss"));
E(rng(cfg.civCondBoomCap, 1.0, 10.0, "civCondBoomCap"));
E(rng(cfg.civPlagueRate, 0.0, 1.0, "civPlagueRate"));
E(rng(cfg.civPlagueDeathMin, 0.0, 1.0, "civPlagueDeathMin"));
E(rng(cfg.civPlagueDeathMax, 0.0, 1.0, "civPlagueDeathMax"));
E(rng(cfg.civPlagueTradeWeight, 0.0, 1.0, "civPlagueTradeWeight"));
if (cfg.civPlagueDeathMin > cfg.civPlagueDeathMax)
E("civPlagueDeathMin > civPlagueDeathMax");
E(rng(cfg.civTerritoryBase, 0.0, 3.14159, "civTerritoryBase")); E(rng(cfg.civTerritoryBase, 0.0, 3.14159, "civTerritoryBase"));
E(rng(cfg.civTerritoryScale, 0.0, 3.14159, "civTerritoryScale")); E(rng(cfg.civTerritoryScale, 0.0, 3.14159, "civTerritoryScale"));
E(rng(cfg.civTerritoryMax, 0.01, 3.14159, "civTerritoryMax")); E(rng(cfg.civTerritoryMax, 0.01, 3.14159, "civTerritoryMax"));

View File

@ -429,7 +429,9 @@ struct PlanetConfig {
double civMinHabitability = 0.22; // don't place a settlement below this habitability double civMinHabitability = 0.22; // don't place a settlement below this habitability
double civSeedPopulation = 250.0; // initial village population at placement double civSeedPopulation = 250.0; // initial village population at placement
double civGrowthRate = 0.02; // logistic growth rate per year (toward carrying capacity) double civGrowthRate = 0.02; // logistic growth rate per year (toward carrying capacity)
double civMaxPopulation = 2.0e6; // population at habitability 1 (carrying-capacity scale) double civMaxPopulation = 1.0e6; // carrying-capacity SCALE (multiplied by habitability, site
// quality and trade -- a top hub's K is several x this; the
// real metropolis ceiling is the civMetropolisPop crowding)
double civTownPop = 5000.0; // population at/above which a settlement is a Town double civTownPop = 5000.0; // population at/above which a settlement is a Town
double civCityPop = 100000.0;// population at/above which a settlement is a City double civCityPop = 100000.0;// population at/above which a settlement is a City
double civAbandonPop = 50.0; // below this a settlement is abandoned (dormant; can revive) double civAbandonPop = 50.0; // below this a settlement is abandoned (dormant; can revive)
@ -452,6 +454,18 @@ struct PlanetConfig {
double civFamineRate = 0.15; // /year accelerated population loss when food < population double civFamineRate = 0.15; // /year accelerated population loss when food < population
double civStormDeathRate = 0.50; // /year population loss for a full-strength storm over a settlement double civStormDeathRate = 0.50; // /year population loss for a full-strength storm over a settlement
double civHurricaneDeathMult= 3.0; // extra storm death multiplier for a hurricane/typhoon double civHurricaneDeathMult= 3.0; // extra storm death multiplier for a hurricane/typhoon
// Big-city demography: pre-industrial metropolises were population sinks (disease, crowding, food
// logistics), so growth meets a headwind that rises with city size -- cities PLATEAU (~1-2M for the
// best trade hubs) instead of exponentially chasing a huge carrying capacity for millennia. Plus
// rare deterministic PLAGUES: probability rises with population and trade connectivity (contagion
// is the cost of being a hub). All pure functions of (id, year, seed) -> reversible, no save change.
double civMetropolisPop = 1.5e6; // crowding scale: mortality = civCrowdingLoss x (P/this)^2 per year
double civCrowdingLoss = 0.02; // /yr crowding mortality at P = civMetropolisPop (0 = no plateau)
double civCondBoomCap = 1.25; // cap on the good-year condition upside in the K target (droughts uncapped)
double civPlagueRate = 0.01; // /yr per-settlement plague-outbreak chance (hash gate)
double civPlagueDeathMin = 0.20; // min total wave kill fraction at full exposure
double civPlagueDeathMax = 0.40; // max total wave kill fraction (also caps the per-year loss)
double civPlagueTradeWeight = 0.5; // exposure share driven by trade connectivity vs pure size
// Territory & nations (PlanetNation.cpp): influence range each settlement projects (size-scaled), // Territory & nations (PlanetNation.cpp): influence range each settlement projects (size-scaled),
// realm grouping (vassals/kingdoms), and the empire threshold. Derived -> recomputed, not saved. // realm grouping (vassals/kingdoms), and the empire threshold. Derived -> recomputed, not saved.
double civTerritoryBase = 0.035; // rad: base influence range of a seed-size village (~220 km) double civTerritoryBase = 0.035; // rad: base influence range of a seed-size village (~220 km)
@ -459,7 +473,7 @@ struct PlanetConfig {
double civTerritoryMax = 0.35; // rad: cap on a single settlement's reach (~2200 km) double civTerritoryMax = 0.35; // rad: cap on a single settlement's reach (~2200 km)
double civVassalRange = 1.5; // a capital annexes smaller settlements within this x its range double civVassalRange = 1.5; // a capital annexes smaller settlements within this x its range
int civEmpireMinMembers = 5; // realm of >= this many settlements counts as an Empire int civEmpireMinMembers = 5; // realm of >= this many settlements counts as an Empire
double civEmpirePop = 5.0e6; // ...or total population >= this counts as an Empire double civEmpirePop = 2.5e6; // ...or total population >= this counts as an Empire
// Civilization Step 5: conflict & war (stateful, saved v21). Neighbouring realms grow hostile and // Civilization Step 5: conflict & war (stateful, saved v21). Neighbouring realms grow hostile and
// fight; casualties shrink frontier cities, winners conquer (flip) or sack (raze) them, empires // fight; casualties shrink frontier cities, winners conquer (flip) or sack (raze) them, empires
// fracture as provinces revolt. All rolls come from a separate war RNG (tectonic stream intact). // fracture as provinces revolt. All rolls come from a separate war RNG (tectonic stream intact).

View File

@ -53,7 +53,7 @@ void Planet::restoreWeather(const WeatherSnapshot& s) {
if (cnt <= settlements.size()) { if (cnt <= settlements.size()) {
settlements.resize(cnt); settlements.resize(cnt);
for (size_t k = 0; k < cnt; ++k) settlements[k].population = s.settlementPop[k]; for (size_t k = 0; k < cnt; ++k) settlements[k].population = s.settlementPop[k];
sCivCond.assign(cnt, 1.0); sCivDrought.assign(cnt, 0.0); sCivCond.assign(cnt, 1.0); sCivDrought.assign(cnt, 0.0); sCivPlague.assign(cnt, 0.0);
sCellSettlement.assign(cells.size(), -1); sCellSettlement.assign(cells.size(), -1);
for (size_t k = 0; k < cnt; ++k) for (size_t k = 0; k < cnt; ++k)
if (settlements[k].cell >= 0 && settlements[k].cell < (int)cells.size()) sCellSettlement[settlements[k].cell] = (int)k; if (settlements[k].cell >= 0 && settlements[k].cell < (int)cells.size()) sCellSettlement[settlements[k].cell] = (int)k;

View File

@ -10,7 +10,9 @@
// src/sim/PlanetIO.cpp -o /tmp/tc && /tmp/tc // src/sim/PlanetIO.cpp -o /tmp/tc && /tmp/tc
// //
// Verifies: habitability range/zeros; placement spacing/cap/land + unique names; food-driven growth // Verifies: habitability range/zeros; placement spacing/cap/land + unique names; food-driven growth
// and decline; tiers; determinism + RNG isolation; population snapshot round-trip; v20 save; reseed clear. // and decline; tiers; determinism + RNG isolation; population snapshot round-trip; v20 save; reseed clear;
// growth plateau (crowding + plague bound city size at a historical metropolis scale; villages untouched;
// plague waves are deterministic + step-back exact).
#include "Planet.hpp" #include "Planet.hpp"
#include <cstdio> #include <cstdio>
@ -195,6 +197,93 @@ int main() {
check(r.cellSettlement() == p.cellSettlement(), "cellSettlement index rebuilt on load"); check(r.cellSettlement() == p.cellSettlement(), "cellSettlement index rebuilt on load");
} }
std::printf("Civ: growth plateau (crowding + plague keep cities at a historical scale)\n");
{
Planet base; base.generate(cfg); settle(base); drift(base, 400);
base.placeSettlements();
const double stepH = 2.0 * yearH;
// One era: fixed cadence (territory/culture/trade every 10 steps so prosperity feeds K, like
// the viewer's yearly rebuild but cheaper); returns the end clock so eras chain year-correctly.
auto era = [&](Planet& x, int iters, double lt) {
for (int it = 0; it < iters; ++it) {
if (it % 10 == 0) { x.computeTerritory(); x.computeCultures(); x.computeTrade(); }
lt += stepH;
x.stepCivilization(stepH, lt);
}
return lt;
};
Planet A = base; // defaults: crowding + plague + good-year cap
Planet B = base; // the old unbounded behaviour
B.cfg.civCrowdingLoss = 0.0; B.cfg.civPlagueRate = 0.0; B.cfg.civCondBoomCap = 100.0;
// Run A inline so we can watch every step: villages (< 30k) must never see plague.
bool villagesClean = true;
double ltA = 0.0;
for (int it = 0; it < 2000; ++it) { // 4000 years
if (it % 10 == 0) { A.computeTerritory(); A.computeCultures(); A.computeTrade(); }
ltA += stepH;
A.stepCivilization(stepH, ltA);
for (size_t k = 0; k < A.settlements.size(); ++k)
if (A.settlements[k].population < 0.3 * A.cfg.civCityPop
&& k < A.settlementPlague().size() && A.settlementPlague()[k] > 0.0) villagesClean = false;
}
era(B, 2000, 0.0);
double mxA = 0.0, mxB = 0.0;
for (const auto& s : A.settlements) mxA = std::max(mxA, s.population);
for (const auto& s : B.settlements) mxB = std::max(mxB, s.population);
std::printf(" max pop with plateau %.0f without %.0f (x%.1f)\n", mxA, mxB, mxA > 0 ? mxB / mxA : 0.0);
check(mxA > 4.0e5 && mxA < 3.0e6, "the largest city plateaus at a historical metropolis scale (~1-2M)");
check(mxB > mxA * 2.0, "without crowding/plague/boom-cap cities grow far larger (the old runaway)");
check(villagesClean, "small settlements never suffer plague");
// A plague wave hits the biggest hub within a few centuries, is deterministic and rewindable.
int big = 0;
for (size_t k = 0; k < A.settlements.size(); ++k)
if (A.settlements[k].population > A.settlements[big].population) big = (int)k;
bool sawPlague = false, dropInWave = false;
double ltP = ltA;
for (int yr = 0; yr < 400; ++yr) {
double before = A.settlements[big].population;
if (yr % 10 == 0) { A.computeTerritory(); A.computeCultures(); A.computeTrade(); }
ltP += yearH;
A.stepCivilization(yearH, ltP);
if (A.settlementPlague()[big] > 0.02) {
sawPlague = true;
if (A.settlements[big].population < before * 0.98) dropInWave = true;
}
}
std::printf(" plague hit the largest hub: %s\n", sawPlague ? "yes" : "no");
check(sawPlague, "a plague wave strikes a large trade hub within a few centuries");
check(dropInWave, "an active plague year visibly shrinks the city");
// Determinism: two identical copies evolve identical populations through plague years.
{
Planet d1 = base, d2 = base;
double l1 = era(d1, 200, 0.0), l2 = era(d2, 200, 0.0);
bool same = (l1 == l2) && d1.settlements.size() == d2.settlements.size();
for (size_t k = 0; same && k < d1.settlements.size(); ++k)
if (d1.settlements[k].population != d2.settlements[k].population) same = false;
check(same, "growth incl. plague/crowding is deterministic (bit-identical twins)");
}
// Step-back: capture -> 50 years (across plague waves) -> restore -> replay -> identical.
{
WeatherSnapshot snap = A.captureWeather();
double lt0 = ltP;
std::vector<double> endPops;
double lt1 = era(A, 25, lt0); // 50 years
for (const auto& s : A.settlements) endPops.push_back(s.population);
A.restoreWeather(snap);
bool back = true;
for (size_t k = 0; k < A.settlements.size(); ++k)
if (A.settlements[k].population != snap.settlementPop[k]) back = false;
check(back, "restoreWeather rewinds populations across plague years");
double lt2 = era(A, 25, lt0); // deterministic replay
bool same = (lt1 == lt2);
for (size_t k = 0; same && k < A.settlements.size(); ++k)
if (A.settlements[k].population != endPops[k]) same = false;
check(same, "replaying the same years reproduces the same populations (plague is stateless)");
}
}
std::printf("Civ: reseed clears settlements\n"); std::printf("Civ: reseed clears settlements\n");
p.generate(cfg); p.generate(cfg);
check(p.settlements.empty() && (p.cellSettlement().empty() || p.cellSettlement()[0] == -1), "reseed clears the settlement set"); check(p.settlements.empty() && (p.cellSettlement().empty() || p.cellSettlement()[0] == -1), "reseed clears the settlement set");