diff --git a/BUILD.md b/BUILD.md index a942ef5..f3b77c1 100644 --- a/BUILD.md +++ b/BUILD.md @@ -296,6 +296,18 @@ population grows/declines on the Live World clock toward a food-driven carrying civHabFoodWeight 0.40 habitability weight of food (flora/fauna + ecoregion productivity) civHabTempOpt 18 C most comfortable annual-mean temperature civHabElevPenalty 2500 m high terrain steeply penalised above this + civSiteVariety 1.0 0 = flat city sizes; higher = big rivers/coasts host far larger cities + civGrowthMin 0.25 growth-rate floor at habitability 0 (rate scales with habitability) + civHarvestVar 0.25 year-to-year harvest swing (scaled by continentality) + civDroughtStrength 0.70 how hard a full drought cuts a region's carrying capacity + civDroughtPeriod 8 yr drought-noise epoch length (drought duration scale) + civDroughtThresh -0.15 drought-onset threshold (lower = rarer) + civDroughtArid 0.50 extra drought-proneness in arid regions + civColdYearStrength 0.50 crop loss in a rare cold year, x the cell's cold exposure + civFloodBonus 0.25 fertile-silt bonus on river cells (rare flood disaster) + civFamineRate 0.15 /yr accelerated population loss when food < population + civStormDeathRate 0.50 /yr deaths from a full-strength storm over a settlement + civHurricaneDeathMult 3.0 extra storm-death multiplier for a hurricane/typhoon ## Headless logic test (no display) diff --git a/CLAUDE.md b/CLAUDE.md index 0848a6f..b981218 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,13 +133,22 @@ on the Live World clock). **Steps 1–2 of the roadmap are done (plus a derived coast) + food (flora/fauna density + ecoregion productivity), gated by freezing winters / high terrain; colour mode `Habitability`, key `I`). On key **`U`** ("the dawn") `placeSettlements()` seeds a fixed set **once** on the best, well-spaced (`civMinSpacingRadians`) fertile cells (separate - `sCivRng`; named from the continent's `NameGen` bank). `stepCivilization()` runs each live frame: - each settlement's population moves **logistically toward a food-driven carrying capacity** `K = - civMaxPopulation·habitability` (cut transiently where an active volcano ashes the area), so it - **grows / declines / is abandoned** (kept in the set, can revive). Tiers village→town→city by - population; markers (3D + 2D) sized by tier + a **Civ** tab + cell-info line + kind=3 `WorldEvent`s - ("X grew into a city", "X was abandoned"). Since the set is fixed, the step-back snapshot only - restores the per-settlement **population** vector. Saved (**v20**). Knobs `civ*`. + `sCivRng`; named from the continent's `NameGen` bank). `stepCivilization(dtHours, liveTime)` runs each + live frame: population moves **logistically toward a food-driven carrying capacity**, but everything is + **environment-driven and dynamic** (not the old "grow the same everywhere"): the growth **rate** scales + with habitability (fertile cells boom, marginal crawl); the capacity `K = civMaxPopulation·habitability· + **siteQuality**·conditions` where **siteQuality** spreads max size by orders of magnitude (a great river + — `discharge` is log-scaled — or a coast hosts a metropolis, a dry inland cell a town); and + **conditions** are deterministic time-varying drivers (pure functions of (region, year, seed) → constant + within a year, reversible on step-back): **regional droughts** (multi-year, worse in arid regions), + 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 + 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 + **population** vector; conditions recompute. Saved (**v20**). Knobs `civ*`. *Next steps (not yet built): territory + borders, kingdoms/empires, culture + beliefs, conflict + diplomacy.* @@ -858,9 +867,15 @@ triangles (plates are fixed in phase 1). (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, - below = abandoned/ruins but can revive). An active volcano's ash within ~1.5× its blast radius cuts a - settlement's carrying capacity (the "ashed-out region" decline). Marker sizes/colours are render - constants (ViewerRender.cpp). + 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). - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main knob for how fast/high relief builds. - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks diff --git a/docs/design-notes.md b/docs/design-notes.md index d577811..c3ffb21 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -366,16 +366,35 @@ seeds a **fixed** set once — greedily the highest-habitability cells with a mi Because placement is one-time, the settlement *set* never changes, so the only mutable per-step state is each settlement's **population** — which is all the step-back snapshot stores (a `vector` in -`WeatherSnapshot`, restored in `restoreWeather`; no per-frame string churn). `stepCivilization(dtHours)` -runs in `liveAdvance` after `stepVolcanoes`: each population moves logistically toward a carrying -capacity `K = civMaxPopulation·habitability(cell)`, cut transiently where an active volcano's ash plume -(`ashTimer>0`, within ~1.5× blast radius) overlaps — so towns **grow, decline, and are abandoned** -(floored at 1 so a site can revive when K recovers). Tier (village/town/city) is derived from -population. The viewer compares before/after populations in `detectLiveEvents` to log kind=3 -`WorldEvent`s (tier crossings, abandonment), draws markers (3D spheres + 2D dots, sized by tier; city/ -town names as 3D labels), adds a **Civ** tab (7th) and a cell-info line, and a `Habitability` colour -mode (key `I`). `buildGeometry()` clears the set on reseed (like geography/volcanoes). Save **v20** -appends the settlement records (population included); `sCellSettlement` is rebuilt on load. Knobs `civ*`. +`WeatherSnapshot`, restored in `restoreWeather`; no per-frame string churn). `stepCivilization(dtHours, +liveTime)` runs in `liveAdvance` after `stepVolcanoes`. It is **environment-driven and dynamic** (the +first cut grew every town uniformly to the same cap): +- growth **rate** `r = civGrowthRate·(civGrowthMin + (1−civGrowthMin)·habitability)` so fertile cells + grow far faster than marginal ones; +- carrying capacity `K = civMaxPopulation · habitability · siteQuality · conditions`, where + **siteQuality** = `0.45 + civSiteVariety·(coastBonus + log10(1+discharge/20))` makes max city size vary + by an order of magnitude (a continental river or coast → a metropolis, a dry inland cell → a town — + this is what spreads final sizes instead of all saturating equally); +- **conditions** = `harvest · drought · coldYear · flood · ash`, all **deterministic functions of + (≈20° region bucket, integer year, seed)** — constant within a year, region-correlated, recomputed + on a step-back (pure, so no extra saved/snapshot state): year-to-year harvests (swing scaled by the + seasonal-amplitude/continentality field), multi-year droughts (a slow noise interpolated across + `civDroughtPeriod`-year epochs, threshold raised by aridity `1−sMoist`), rare cold years (× the cell's + near-freezing-winter exposure), river floods (silt bonus / rare disaster), and the volcano-ash cut. +- **Storms** read live `storms()` (already snapshotted): a system within its `radius` of a town deals + direct deaths `civStormDeathRate·strength·overlap·(hurricane? civHurricaneDeathMult)` — a parked + hurricane can gut a coastal city. +- logistic step + an accelerated `civFamineRate` loss when `K devastates X" +(reuses `weatherEventName`), else "Famine shrinks X to a Town" when `sCivDrought` is high, else tier +up/down/abandon. Markers (3D spheres + 2D dots, sized by tier; city/town 3D labels), a **Civ** tab (7th), +a cell-info line, and a `Habitability` colour mode (key `I`). `buildGeometry()` clears the set on reseed. +Save **v20** stores the settlement records (population included); `sCellSettlement` is rebuilt on load. +Knobs `civ*`. ## Headless testing diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index 9a93a81..3af78a5 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -95,6 +95,14 @@ 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). + 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"))); + } } } // Climate (derived; present once computeClimate() has run). diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index fc8a2d9..1df1753 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -372,6 +372,20 @@ void Viewer::detectLiveEvents(const std::vector& beforeStorms, else if (aliveA && (int)ta > (int)tb) appendEvent(3, 1, liveTime, s.cell, s.id, s.name + " grew into a " + settleTierName(ta), popLine(s)); + else if (aliveA && (int)ta < (int)tb) { // tier DOWN -- attribute the cause + const char* stormName = nullptr; + for (const auto& ws : planet.storms()) { + double ang = std::acos(std::clamp(planet.cells[s.cell].unit.dot(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}), -1.0, 1.0)); + if (ang < ws.radius && ws.strength > 0.35) { stormName = weatherEventName(ws, planet); break; } + } + 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 (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); + appendEvent(3, 2, liveTime, s.cell, s.id, title, detail); + } } } @@ -661,7 +675,7 @@ void Viewer::liveAdvance(double dtClock, double 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); // population grows/declines 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 (vu.breach) refreshView(); else if (vu.recolor || cu.recolor) recolor(); diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index 19e5fc8..b40ecd5 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -9,6 +9,17 @@ #include #include +// Tint a settlement marker by its live environmental condition (1 = thriving, <1 = hardship/drought): +// blend toward a dull withered brown-red and darken as conditions worsen. +static Color witherColor(Color base, double cond) { + double h = std::clamp((0.8 - cond) / 0.6, 0.0, 1.0); // 0 above 0.8 .. 1 at/below 0.2 + if (h <= 0.0) return base; + const unsigned char w[3] = { 130, 80, 70 }; // withered brown-red + double dim = 1.0 - 0.35 * h; + auto L = [&](unsigned char b, unsigned char wc) { return (unsigned char)std::clamp((b * (1.0 - h) + wc * h) * dim, 0.0, 255.0); }; + return Color{ L(base.r, w[0]), L(base.g, w[1]), L(base.b, w[2]), base.a }; +} + // Label style for a geographic feature: font size + colour, returns true if it's a "minor" feature // (peaks/rivers/lakes/seas/small islands) -- those are drawn only when zoomed in, to declutter. static bool labelStyle(const GeoFeature& f, int& font, Color& col) { @@ -211,7 +222,9 @@ void Viewer::renderGlobe3D() { // Settlement markers (civilization): a dot per settlement, sized + coloured by tier; dim for ruins. if (showSettlements && !planet.settlements.empty()) { const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; - for (const Settlement& s : planet.settlements) { + const auto& cond = planet.settlementCondition(); + for (size_t k = 0; k < planet.settlements.size(); ++k) { + const Settlement& s = planet.settlements[k]; if (s.cell < 0 || s.cell >= (int)planet.cells.size()) continue; const Cell& c = planet.cells[s.cell]; float r = visBase + (float)c.elevation * elevExagg + 0.006f; @@ -223,6 +236,7 @@ void Viewer::renderGlobe3D() { : t == SettleTier::City ? Color{250, 220, 110, 255} : t == SettleTier::Town ? Color{225, 170, 90, 255} : Color{210, 130, 85, 255}; + col = witherColor(col, k < cond.size() ? cond[k] : 1.0); // hardship -> withered tint DrawSphere(p, rad, col); if (alive && t != SettleTier::Village) { // a ring marks notable settlements float rr = visBase + (float)c.elevation * elevExagg + 0.01f; @@ -355,8 +369,10 @@ void Viewer::renderMap2D() { } if (showSettlements && !planet.settlements.empty()) { const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; + const auto& cond = planet.settlementCondition(); float zf = (float)std::min(2.0, mapZoom); - for (const Settlement& s : planet.settlements) { + for (size_t k = 0; k < planet.settlements.size(); ++k) { + const Settlement& s = planet.settlements[k]; if (s.cell < 0 || s.cell >= (int)planet.cells.size()) continue; double lon, lat; dirToLonLat(planet.cells[s.cell].unit, lon, lat); Vector2 sp = projLonLat(lon, lat, mapLon, vr); @@ -367,6 +383,7 @@ void Viewer::renderMap2D() { : t == SettleTier::City ? Color{250, 220, 110, 255} : t == SettleTier::Town ? Color{225, 170, 90, 255} : Color{210, 130, 85, 255}; + col = witherColor(col, k < cond.size() ? cond[k] : 1.0); DrawCircleV(sp, rad, col); if (alive && t != SettleTier::Village) DrawCircleLines((int)sp.x, (int)sp.y, rad + 2.0f, Color{255, 245, 210, 180}); } diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index 3a8f5cd..8245ea6 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -62,6 +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(); } void Planet::clearDerivedState() { diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index ee4eab1..5c47368 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -183,10 +183,15 @@ public: // / is abandoned). Saved (v20); step-back restores populations via WeatherSnapshot. void computeHabitability(); void placeSettlements(); - CivUpdate stepCivilization(double dtHours); + CivUpdate stepCivilization(double dtHours, double liveTime); bool settlementsPlaced() const { return !settlements.empty(); } const std::vector& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1) const std::vector& habitability() const { return sHabitability; } // 0..1 per cell (derived) + // Per-settlement live conditions (derived each stepCivilization; not saved). condition = the combined + // environmental multiplier on carrying capacity (1 = normal, <1 = hardship, >1 = boom); drought = + // 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; } // Build a fine-resolution subgrid patch for one macro cell (phase 4/5 hook). std::shared_ptr makeSubGrid(int cellIndex, int res) const; @@ -311,6 +316,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) uint32_t sCivRng = 1; // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the diff --git a/src/sim/PlanetCiv.cpp b/src/sim/PlanetCiv.cpp index 4739bf8..a86ece9 100644 --- a/src/sim/PlanetCiv.cpp +++ b/src/sim/PlanetCiv.cpp @@ -4,6 +4,16 @@ #include #include +// Small deterministic hashes/noise for the environment drivers (mirrors the subgrid value-noise in +// Planet.cpp; kept local). A driver is a pure function of (cell/region, year, seed) so it is constant +// within a year, region-correlated, and recomputed on a step-back -> reversible with no extra state. +namespace { + inline uint32_t civHash(uint32_t a) { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; } + inline double civHashf(uint32_t a) { return (civHash(a) & 0xFFFFFFu) / double(0x1000000); } // [0,1) + inline double civSigned(uint32_t a) { return civHashf(a) * 2.0 - 1.0; } // [-1,1) + inline double civSmooth(double t) { return t * t * (3.0 - 2.0 * t); } +} + // --- Civilization Step 2: settlements & habitability ------------------------- // A per-cell habitability/food score, a one-time placement of settlement point-agents on the best // (well-spaced) cells, and their population growth/decline on the Live World clock toward a @@ -103,32 +113,119 @@ void Planet::placeSettlements() { settlements[k].id = (uint32_t)(k + 1); sCellSettlement[settlements[k].cell] = k; } + sCivCond.assign(settlements.size(), 1.0); + sCivDrought.assign(settlements.size(), 0.0); } -// One live-frame civilization step: each settlement's population moves logistically toward its -// food-driven carrying capacity K = civMaxPopulation * habitability (cut transiently where an active -// volcano ashes the area). Grows below K, declines above it; floored at 1 so an abandoned site can -// revive if K recovers. Pure of any string churn; the set never changes here. -CivUpdate Planet::stepCivilization(double dtHours) { +// One live-frame civilization step. Each settlement's population moves logistically toward a food-driven +// carrying capacity K that varies by the LOCAL environment AND over time -- year-to-year harvests, +// multi-year droughts (worse in arid regions), rare cold years and river floods, plus volcano ash -- and +// growth RATE scales with habitability so fertile cells grow far faster than marginal ones. Storms over a +// town kill people directly (hurricanes worst). All drivers are deterministic functions of (cell, year, +// seed), so a step-back replays them; population is restored from the snapshot. Floored at 1 so an +// abandoned site can revive. Conditions are recomputed even when paused (dtHours == 0) so the view tints. +CivUpdate Planet::stepCivilization(double dtHours, double liveTime) { CivUpdate up; - if (settlements.empty() || dtHours <= 0.0) return up; + if (settlements.empty()) return up; 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); } + const double yearHours = std::max(1.0, cfg.dayLengthHours * cfg.yearLengthDays); - const double dtYears = dtHours / yearHours; - for (Settlement& st : settlements) { + const double dtYears = std::max(0.0, dtHours) / yearHours; + const int year = (int)std::floor(std::max(0.0, liveTime) / yearHours); + const uint32_t base = cfg.seed ? cfg.seed : 1u; + const bool haveMoist = (int)sMoist.size() == n, haveSeason = (int)sTempSummer.size() == n && (int)sTempWinter.size() == n; + const bool haveWinter = (int)sTempWinter.size() == n, haveDisch = (int)sDischarge.size() == n; + + for (int k = 0; k < (int)settlements.size(); ++k) { + Settlement& st = settlements[k]; if (st.cell < 0 || st.cell >= n) continue; - double K = cfg.civMaxPopulation * sHabitability[st.cell]; - for (const Volcano& v : volcanoes) { // active ash plume nearby cuts carrying capacity - if (v.ashTimer <= 0.0 || v.cell < 0 || v.cell >= n) continue; - double ang = std::acos(std::clamp(cells[st.cell].unit.dot(cells[v.cell].unit), -1.0, 1.0)); - if (ang < cfg.volcanoBlastRadius * 1.5) { K *= 0.3; break; } + const int c = st.cell; + // A coarse spatial bucket (~20deg cells) so droughts / harvests vary REGIONALLY -- different parts + // of a continent have different fortunes, instead of every town drought-ing together. + double clat = std::asin(std::clamp(cells[c].unit.y, -1.0, 1.0)); + double clon = std::atan2(cells[c].unit.z, cells[c].unit.x); + int latB = (int)std::floor((clat + 1.5708) / 0.349), lonB = (int)std::floor((clon + 3.14160) / 0.349); + const uint32_t region = civHash((uint32_t)(latB * 131 + lonB) * 2654435761u + 0x9E3779B9u); + const double hab = sHabitability[c]; + const double amp = haveSeason ? std::max(0.0, sTempSummer[c] - sTempWinter[c]) : 8.0; // climate variability + const double aridity = haveMoist ? std::clamp(1.0 - sMoist[c], 0.0, 1.0) : 0.4; + const double winter = haveWinter ? sTempWinter[c] : (((int)sTemp.size() == n) ? sTemp[c] : 10.0); + const bool onRiver = haveDisch && sDischarge[c] > cfg.riverThreshold; + + // Harvest: region-correlated good/bad year, swing scaled by continentality (amp). + double varScale = cfg.civHarvestVar * std::clamp(0.3 + 0.7 * (amp / 30.0), 0.3, 1.6); + double rNoise = civSigned(base ^ civHash((uint32_t)region * 0x9E3779B9u + (uint32_t)year * 2654435761u)); + double cNoise = civSigned(base ^ civHash((uint32_t)c * 2654435761u ^ ((uint32_t)year * 0x85EBCA6Bu))); + double harvest = std::clamp(1.0 + (0.7 * rNoise + 0.3 * cNoise) * varScale, 0.2, 1.7); + + // Drought: a slow signal interpolated across multi-year epochs; arid regions cross the threshold + // more often, so droughts last several years and bite hardest in dry lands. + double ft = (double)year / std::max(1.0, cfg.civDroughtPeriod); + double fe = std::floor(ft); int e0 = (int)fe; double tf = civSmooth(ft - fe); + double d0 = civSigned(base ^ civHash((uint32_t)region * 0x1B873593u + (uint32_t)e0 * 40503u)); + double d1 = civSigned(base ^ civHash((uint32_t)region * 0x1B873593u + (uint32_t)(e0 + 1) * 40503u)); + double dn = d0 + (d1 - d0) * tf; + double thr = cfg.civDroughtThresh + cfg.civDroughtArid * aridity; + double drought = (dn < thr) ? std::clamp(thr - dn, 0.0, 1.0) : 0.0; + double droughtFactor = std::clamp(1.0 - cfg.civDroughtStrength * drought, 0.05, 1.0); + + // Cold year: a rare bad year times how cold-marginal the cell is (near-freezing winter). + double coldExposure = std::clamp((5.0 - winter) / 30.0, 0.0, 1.0); + double cy = civHashf(base ^ civHash((uint32_t)region * 0x55u + (uint32_t)year * 0x9E3779B9u + 7u)); + double coldEvent = (cy > 0.85) ? (cy - 0.85) / 0.15 : 0.0; + double coldFactor = std::clamp(1.0 - cfg.civColdYearStrength * coldEvent * coldExposure, 0.2, 1.0); + + // Floods (river cells): fertile silt most years, a rare destructive flood. + double floodFactor = 1.0; + if (onRiver) { + double f = civHashf(base ^ civHash((uint32_t)c * 0x2545F491u + (uint32_t)year * 19349663u)); + floodFactor = (f > 0.94) ? 0.5 : 1.0 + cfg.civFloodBonus * (0.3 + 0.7 * f); + } + + // Volcano ash nearby (existing). + double ashFactor = 1.0; + for (const Volcano& v : volcanoes) { + if (v.ashTimer <= 0.0 || v.cell < 0 || v.cell >= n) continue; + double ang = std::acos(std::clamp(cells[c].unit.dot(cells[v.cell].unit), -1.0, 1.0)); + if (ang < cfg.volcanoBlastRadius * 1.5) { ashFactor = 0.3; break; } + } + + // Site quality: a settlement's MAX size varies enormously with its location -- a great river + // (discharge spans orders of magnitude) or a coast hosts a metropolis, a dry inland cell a town. + // This is what makes final sizes vary widely instead of all reaching the same cap. + double disch = haveDisch ? sDischarge[c] : 0.0; + bool coast = false; + for (int j : cells[c].neighbors) if (cells[j].elevation <= cfg.seaLevel) { coast = true; break; } + double riverQ = std::log10(1.0 + disch / 20.0); // ~0 tiny .. ~2.5 a continental river + double siteQ = std::clamp(0.45 + cfg.civSiteVariety * ((coast ? 1.2 : 0.0) + riverQ), 0.3, 6.0); + + double cond = harvest * droughtFactor * coldFactor * floodFactor * ashFactor; + double K = cfg.civMaxPopulation * hab * siteQ * cond; + sCivCond[k] = cond; sCivDrought[k] = drought; + + // 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; + for (const WeatherSystem& ws : sStorms) { + double ang = std::acos(std::clamp(cells[c].unit.dot(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}), -1.0, 1.0)); + if (ang >= ws.radius) continue; + double overlap = 1.0 - ang / std::max(1e-6, ws.radius); + bool hur = ws.tropical && ws.strength >= cfg.weatherHurricaneStr; + stormLoss += cfg.civStormDeathRate * ws.strength * overlap * (hur ? cfg.civHurricaneDeathMult : 1.0); + } + + if (dtYears > 0.0) { + double P = st.population; + 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 + 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; + st.population = P; } - double P = st.population; - P += cfg.civGrowthRate * P * (1.0 - P / std::max(1.0, K)) * dtYears; // logistic (declines when K std::max(1.0, st.population * 0.0005)) up.recolor = true; - st.population = P; } return up; } diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 3d69048..9d8e133 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -52,6 +52,9 @@ D(civMinSpacingRadians) D(civMinHabitability) D(civSeedPopulation) D(civGrowthRate) \ D(civMaxPopulation) D(civTownPop) D(civCityPop) D(civAbandonPop) \ D(civHabWaterWeight) D(civHabFoodWeight) D(civHabTempOpt) D(civHabElevPenalty) \ + D(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \ + D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \ + D(civStormDeathRate) D(civHurricaneDeathMult) \ I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ @@ -263,6 +266,18 @@ std::string validateConfig(const PlanetConfig& cfg) { E(rng(cfg.civHabFoodWeight, 0.0, 1.0, "civHabFoodWeight")); E(rng(cfg.civHabTempOpt, -20.0, 50.0, "civHabTempOpt")); E(rng(cfg.civHabElevPenalty, 0.0, 12000.0, "civHabElevPenalty")); + E(rng(cfg.civSiteVariety, 0.0, 4.0, "civSiteVariety")); + E(rng(cfg.civGrowthMin, 0.0, 1.0, "civGrowthMin")); + E(rng(cfg.civHarvestVar, 0.0, 2.0, "civHarvestVar")); + E(rng(cfg.civDroughtStrength, 0.0, 1.0, "civDroughtStrength")); + E(rng(cfg.civDroughtPeriod, 0.1, 1000.0, "civDroughtPeriod")); + E(rng(cfg.civDroughtThresh, -2.0, 2.0, "civDroughtThresh")); + E(rng(cfg.civDroughtArid, 0.0, 4.0, "civDroughtArid")); + E(rng(cfg.civColdYearStrength, 0.0, 1.0, "civColdYearStrength")); + E(rng(cfg.civFloodBonus, 0.0, 2.0, "civFloodBonus")); + 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(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.beltWidth, 1, 12, "beltWidth")); diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index cd4d186..161f2dc 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -395,4 +395,19 @@ struct PlanetConfig { double civHabFoodWeight = 0.40; // habitability weight of food (flora/fauna + ecoregion) double civHabTempOpt = 18.0; // C: most comfortable annual-mean temperature double civHabElevPenalty = 2500.0; // m above which high terrain steeply reduces habitability + // Dynamic environment (PlanetCiv.cpp): growth differs by local conditions and varies over time + // (harvests, droughts, cold years, floods, storms) so settlements aren't static. Deterministic + // functions of (cell, year, seed) -> reversible with the live stepper; no save change. + double civSiteVariety = 1.0; // 0 = flat capacities, 1 = full site-quality spread (big rivers/coasts host large cities) + double civGrowthMin = 0.25; // growth-rate fraction at habitability 0 (1 = at habitability 1) + double civHarvestVar = 0.25; // base year-to-year harvest swing amplitude (scaled by climate variability) + double civDroughtStrength = 0.70; // how hard a full drought cuts a region's carrying capacity + double civDroughtPeriod = 8.0; // years per drought-noise epoch (drought duration scale) + double civDroughtThresh = -0.15; // drought-onset threshold on the slow noise (lower = rarer) + double civDroughtArid = 0.50; // extra drought-proneness in arid regions (× aridity) + double civColdYearStrength = 0.50; // crop loss in a rare cold year, × the cell's cold exposure + double civFloodBonus = 0.25; // fertile-silt bonus on river cells most years (rare flood disaster) + 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 }; diff --git a/test_civ.cpp b/test_civ.cpp index 49bb726..7a73b58 100644 --- a/test_civ.cpp +++ b/test_civ.cpp @@ -79,19 +79,59 @@ int main() { check(capOk, "settlement count within the cap"); check(uniqueNames, "settlement names are unique + non-empty"); - std::printf("Civ: food-driven growth + decline\n"); + std::printf("Civ: environment-driven growth (differentiated + dynamic)\n"); { - int gi = 0; for (size_t k = 0; k < S.size(); ++k) if (p.habitability()[S[k].cell] > p.habitability()[S[gi].cell]) gi = (int)k; - double p0 = p.settlements[gi].population; - for (int k = 0; k < 400; ++k) p.stepCivilization(5.0 * yearH); // ~2000 yr of small steps - check(p.settlements[gi].population > p0 * 2.0, "a high-habitability settlement grows"); - // Decline: push one well over its carrying capacity, then step -> it shrinks. + int gi = 0, lo = 0; + for (size_t k = 0; k < S.size(); ++k) { + if (p.habitability()[S[k].cell] > p.habitability()[S[gi].cell]) gi = (int)k; + if (p.habitability()[S[k].cell] < p.habitability()[S[lo].cell]) lo = (int)k; + } + double g0 = p.settlements[gi].population; + double lt = 0.0; bool anyDecline = false; + std::vector prev(p.settlements.size()); + for (int yr = 0; yr < 600; ++yr) { + for (size_t k = 0; k < p.settlements.size(); ++k) prev[k] = p.settlements[k].population; + lt += 2.0 * yearH; + p.stepCivilization(2.0 * yearH, lt); // advance the clock so harvests/droughts vary + for (size_t k = 0; k < p.settlements.size(); ++k) + if (p.settlements[k].population > p.cfg.civAbandonPop && p.settlements[k].population < prev[k] * 0.999) anyDecline = true; + } + check(p.settlements[gi].population > g0 * 2.0, "a fertile settlement grows strongly"); + // The user's complaint was "they grow the same amount everywhere": now sizes must vary widely. + std::vector pops; + for (const auto& s : p.settlements) if (s.population >= p.cfg.civAbandonPop) pops.push_back(s.population); + std::sort(pops.begin(), pops.end()); + double med = pops.empty() ? 0.0 : pops[pops.size() / 2]; + double mx = pops.empty() ? 0.0 : pops.back(); + std::printf(" alive %d median %.0f max %.0f (max/median %.1f)\n", + (int)pops.size(), med, mx, med > 0 ? mx / med : 0.0); + check(!pops.empty() && mx > med * 3.0, "settlement sizes vary widely (env-driven, not uniform growth)"); + check(p.settlements[gi].population > p.settlements[lo].population, "fertile ends larger than marginal"); + check(anyDecline, "settlements decline in bad years (harvest/drought dynamics, not monotonic)"); + // Over-capacity settlement declines toward its food limit. p.settlements[gi].population = 5.0e7; double over = p.settlements[gi].population; - for (int k = 0; k < 400; ++k) p.stepCivilization(5.0 * yearH); + for (int yr = 0; yr < 200; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); } check(p.settlements[gi].population < over, "an over-capacity settlement declines toward its food limit"); } + std::printf("Civ: a hurricane over a town kills people\n"); + { + Planet w; w.generate(cfg); settle(w); drift(w, 400); w.placeSettlements(); + if (!w.settlements.empty()) { + int si = 0; for (size_t k = 0; k < w.settlements.size(); ++k) if (w.settlements[k].population > w.settlements[si].population) si = (int)k; + w.settlements[si].population = 1.0e5; + WeatherSnapshot snap = w.captureWeather(); + WeatherSystem ws; ws.id = 999; ws.pos = w.cells[w.settlements[si].cell].unit; + ws.radius = 0.3; ws.strength = 1.0; ws.tropical = true; ws.life = 1e9; + snap.storms.push_back(ws); + w.restoreWeather(snap); // inject a stationary hurricane over the town + double before = w.settlements[si].population; + double lt = 0.0; for (int yr = 0; yr < 3; ++yr) { lt += yearH; w.stepCivilization(yearH, lt); } + check(w.settlements[si].population < before * 0.9, "a hurricane parked over a town kills its population"); + } + } + std::printf("Civ: tiers\n"); check(settleTierOf(100.0, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::Village && settleTierOf(p.cfg.civTownPop, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::Town @@ -111,7 +151,7 @@ int main() { for (int k = 0; k < 40; ++k) { double dx = x.cflDtMy(); x.advect(dx); x.step(); x.erode(dx); double dy = y.cflDtMy(); y.advect(dy); y.step(); y.erode(dy); - if (k == 20) { y.placeSettlements(); y.stepCivilization(yearH); } + if (k == 20) { y.placeSettlements(); y.stepCivilization(yearH, yearH); } } bool terrainSame = true; for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) terrainSame = false;