diff --git a/CLAUDE.md b/CLAUDE.md index 0d033f5..5585342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,16 @@ on the Live World clock). **Steps 1–7 of the roadmap are done (plus a derived 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, `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 (~1–2M)** 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 1–3-year wave killing 20–40% 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 `WorldEvent`s ("X grew into a city", "Hurricane 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 @@ -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` (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 - village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (2e6, the carrying capacity at - habitability 1), tier thresholds `civTownPop` (5000) / `civCityPop` (100000), `civAbandonPop` (50, + village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (1e6, the carrying-capacity + **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, 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), `civDroughtStrength` (0.70) / `civDroughtPeriod` (8 yr) / `civDroughtThresh` (−0.15) / `civDroughtArid` (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) / - `civHurricaneDeathMult` (3.0, deaths from a storm/hurricane over a town). Droughts/harvests are - deterministic per (~20° region, 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). + `civHurricaneDeathMult` (3.0, deaths from a storm/hurricane over a town). **Big-city plateau** — + `civMetropolisPop` (1.5e6, crowding scale: mortality = `civCrowdingLoss·(P/this)²`/yr; the best hubs + plateau near `this·sqrt(growth/civCrowdingLoss)` ≈ 1–1.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 1–3 + 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`):** `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 × 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 constants (Colors.cpp / ViewerRender.cpp). - **Culture, beliefs & governments (civ Step 4, key `X`):** **no config knobs** — the ethos/faith/ diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index 4193132..8ff628f 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -96,13 +96,16 @@ static std::vector cellInfo(const Planet& p, int i, double elev, do : TextFormat("%.0f", s.population); L.push_back(std::string(alive ? settleTierName(t) : "Ruins of") + " " + s.name + " (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(); if (si < (int)cond.size()) { 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))); else L.push_back(std::string(TextFormat(" conditions %.0f%% (%s)", cd * 100.0, 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))); } } } diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 0c39c38..ac07761 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -379,7 +379,8 @@ void Viewer::appendEvent(uint8_t kind, uint8_t severity, double timeHours, int c void Viewer::detectLiveEvents(const std::vector& beforeStorms, const std::vector& beforeVolcanoes, - const std::vector& beforeSettlements) { + const std::vector& beforeSettlements, + const std::vector& beforePlague) { auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* { for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws; return nullptr; @@ -445,6 +446,11 @@ void Viewer::detectLiveEvents(const std::vector& beforeStorms, const Settlement& o = beforeSettlements[k]; SettleTier tb = settleTierOf(o.population, townP, cityP), ta = settleTierOf(s.population, townP, cityP); 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) appendEvent(3, 2, liveTime, s.cell, s.id, s.name + " was abandoned", popLine(s)); else if (!aliveB && aliveA) @@ -461,6 +467,8 @@ void Viewer::detectLiveEvents(const std::vector& beforeStorms, double dr = (k < planet.settlementDrought().size()) ? planet.settlementDrought()[k] : 0.0; std::string title, detail = popLine(s); 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); detail = std::string(TextFormat("drought %.0f%%, ", dr * 100.0)) + popLine(s); } else title = s.name + " declined to a " + settleTierName(ta); @@ -816,17 +824,19 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) { std::vector beforeStorms; std::vector beforeVolcanoes; std::vector beforeSettlements; + std::vector beforePlague; if (dtWeather > 0.0) { beforeStorms = planet.storms(); beforeVolcanoes = planet.volcanoes; beforeSettlements = planet.settlements; + beforePlague = planet.settlementPlague(); } planet.stepWeather(dtWeather); // Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here // reasserts restored terrain/biome state without advancing the lifecycle. VolcanoUpdate vu = planet.stepVolcanoes(dtWeather); 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). // 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. diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index d9e658b..a59eab8 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -187,7 +187,8 @@ struct Viewer { const std::string& title, const std::string& detail); void detectLiveEvents(const std::vector& beforeStorms, const std::vector& beforeVolcanoes, - const std::vector& beforeSettlements); + const std::vector& beforeSettlements, + const std::vector& beforePlague); void detectNationEvents(const std::vector& beforeNations); void focusCell(int idx, const std::string& status = ""); diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index d0d76dd..89295d4 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -62,7 +62,7 @@ void Planet::buildGeometry() { volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au; settlements.clear(); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu; 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(); cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); sCultureNextId = 1; sCellSettleOwner.assign(cells.size(), -1); diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 3b4442f..4802830 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -251,6 +251,7 @@ public: // current drought severity 0..1. Parallel to `settlements`. Used by the viewer for tint + events. const std::vector& settlementCondition() const { return sCivCond; } const std::vector& settlementDrought() const { return sCivDrought; } + const std::vector& settlementPlague() const { return sCivPlague; } // active epidemic loss (/yr, 0 = none) // Build a fine-resolution subgrid patch for one macro cell (phase 4/5 hook). std::shared_ptr makeSubGrid(int cellIndex, int res) const; @@ -383,7 +384,7 @@ private: // a derived habitability field, and a separate RNG so placement never perturbs tectonics. std::vector sCellSettlement; std::vector sHabitability; - std::vector sCivCond, sCivDrought; // per-settlement live conditions (derived) + std::vector sCivCond, sCivDrought, sCivPlague; // per-settlement live conditions (derived) uint32_t sCivRng = 1; // Territory & nations (derived from settlements; not saved). sCellNation: nation index per cell // (-1 = wilderness/ocean); sSettleNation: nation index per settlement. diff --git a/src/sim/PlanetCiv.cpp b/src/sim/PlanetCiv.cpp index 948e8db..9d7bcdc 100644 --- a/src/sim/PlanetCiv.cpp +++ b/src/sim/PlanetCiv.cpp @@ -130,6 +130,7 @@ void Planet::placeSettlements() { } sCivCond.assign(settlements.size(), 1.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 @@ -145,6 +146,7 @@ CivUpdate Planet::stepCivilization(double dtHours, double liveTime) { const int n = (int)cells.size(); 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 (sCivPlague.size() != settlements.size()) sCivPlague.assign(settlements.size(), 0.0); const double yearHours = std::max(1.0, cfg.dayLengthHours * cfg.yearLengthDays); 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 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; + // 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 // this stays consistent on a step-back. 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 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 + // 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 = std::max(1.0, P); if (std::fabs(P - st.population) > std::max(1.0, st.population * 0.0005)) up.recolor = true; @@ -324,6 +357,7 @@ std::vector Planet::stepColonization(long year) { if (sSettleCulture.size() != settlements.size()) sSettleCulture.resize(settlements.size(), -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 (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, "A new settlement rises on distant land.", 3 }); } diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 39fa519..1f36e13 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -57,6 +57,8 @@ D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \ D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \ 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(warDeclareRate) D(warAmbition) D(warIdeology) D(warBorder) D(warWarlikeMult) D(warCasualtyRate) \ 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.civStormDeathRate, 0.0, 10.0, "civStormDeathRate")); 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.civTerritoryScale, 0.0, 3.14159, "civTerritoryScale")); E(rng(cfg.civTerritoryMax, 0.01, 3.14159, "civTerritoryMax")); diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index 189529a..03f8323 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -429,7 +429,9 @@ struct PlanetConfig { double civMinHabitability = 0.22; // don't place a settlement below this habitability double civSeedPopulation = 250.0; // initial village population at placement 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 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) @@ -452,6 +454,18 @@ struct PlanetConfig { 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 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), // 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) @@ -459,7 +473,7 @@ struct PlanetConfig { 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 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 // 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). diff --git a/src/sim/PlanetWeather.cpp b/src/sim/PlanetWeather.cpp index 46ef3a6..2b54741 100644 --- a/src/sim/PlanetWeather.cpp +++ b/src/sim/PlanetWeather.cpp @@ -53,7 +53,7 @@ void Planet::restoreWeather(const WeatherSnapshot& s) { if (cnt <= settlements.size()) { settlements.resize(cnt); 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); 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; diff --git a/test_civ.cpp b/test_civ.cpp index 2cfe206..28d8a0e 100644 --- a/test_civ.cpp +++ b/test_civ.cpp @@ -10,7 +10,9 @@ // src/sim/PlanetIO.cpp -o /tmp/tc && /tmp/tc // // 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 @@ -195,6 +197,93 @@ int main() { 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 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"); p.generate(cfg); check(p.settlements.empty() && (p.cellSettlement().empty() || p.cellSettlement()[0] == -1), "reseed clears the settlement set");