Civ settlements: environment-driven dynamic growth (droughts, harvests, storms)

Settlements were static -- every town grew with one global rate to the same
habitability cap (frozen in Live World), so they grew "the same amount
everywhere". Now stepCivilization(dtHours, liveTime) is environment-driven and
time-varying. All drivers are deterministic functions of (region, year, seed),
so step-back replays them with no new saved/snapshot state (population is
already snapshotted).

- Differentiated growth: rate scales with habitability (fertile boom, marginal
  crawl); capacity K = maxPop * habitability * siteQuality * conditions.
- Site quality: max city size varies by an order of magnitude with location --
  a great river (log-scaled discharge) or coast hosts a metropolis, a dry inland
  cell a town (civSiteVariety). This spreads final sizes ~8-9x instead of all
  saturating equally.
- Conditions (per ~20deg region, per year): multi-year droughts (worse in arid
  regions), year-to-year harvests (bigger swings inland), rare cold years, river
  floods, and the existing volcano ash. Regional, so different parts of a
  continent have different fortunes.
- Storms kill directly: a system within its radius of a town deals deaths scaled
  by strength (hurricanes x civHurricaneDeathMult) -- a parked hurricane can gut
  a coastal city. Reads the already-snapshotted storms().
- Famine: accelerated loss when food < population; sustained drought / acute
  disaster can collapse a settlement to ruins (revives when conditions return).
- Viewer: markers withered-tinted by hardship (3D + 2D), cell-info "drought/
  conditions" line, and cause-attributed kind=3 events ("Hurricane X devastates
  Y", "Famine shrinks Y to a Town", tier up/down/abandon).

New civ* knobs (self-describing config -> no save bump). test_civ extended:
sizes vary widely (not uniform), bad-year declines, a hurricane over a town
kills its population. All 10 suites pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-30 11:26:56 +02:00
parent 2351fb79a5
commit 3655dc0661
12 changed files with 309 additions and 50 deletions

View File

@ -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) civHabFoodWeight 0.40 habitability weight of food (flora/fauna + ecoregion productivity)
civHabTempOpt 18 C most comfortable annual-mean temperature civHabTempOpt 18 C most comfortable annual-mean temperature
civHabElevPenalty 2500 m high terrain steeply penalised above this 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) ## Headless logic test (no display)

View File

@ -133,13 +133,22 @@ on the Live World clock). **Steps 12 of the roadmap are done (plus a derived
coast) + food (flora/fauna density + ecoregion productivity), gated by freezing winters / high 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 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 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: `sCivRng`; named from the continent's `NameGen` bank). `stepCivilization(dtHours, liveTime)` runs each
each settlement's population moves **logistically toward a food-driven carrying capacity** `K = live frame: population moves **logistically toward a food-driven carrying capacity**, but everything is
civMaxPopulation·habitability` (cut transiently where an active volcano ashes the area), so it **environment-driven and dynamic** (not the old "grow the same everywhere"): the growth **rate** scales
**grows / declines / is abandoned** (kept in the set, can revive). Tiers village→town→city by with habitability (fertile cells boom, marginal crawl); the capacity `K = civMaxPopulation·habitability·
population; markers (3D + 2D) sized by tier + a **Civ** tab + cell-info line + kind=3 `WorldEvent`s **siteQuality**·conditions` where **siteQuality** spreads max size by orders of magnitude (a great river
("X grew into a city", "X was abandoned"). Since the set is fixed, the step-back snapshot only `discharge` is log-scaled — or a coast hosts a metropolis, a dry inland cell a town); and
restores the per-settlement **population** vector. Saved (**v20**). Knobs `civ*`. **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 <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
**population** vector; conditions recompute. Saved (**v20**). Knobs `civ*`.
*Next steps (not yet built): territory + borders, kingdoms/empires, culture + beliefs, conflict + *Next steps (not yet built): territory + borders, kingdoms/empires, culture + beliefs, conflict +
diplomacy.* diplomacy.*
@ -858,9 +867,15 @@ triangles (plates are fixed in phase 1).
(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` (2e6, the carrying capacity at
habitability 1), tier thresholds `civTownPop` (5000) / `civCityPop` (100000), `civAbandonPop` (50, 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 below = abandoned/ruins but can revive). **Dynamics**`civSiteVariety` (1.0; 0 = flat capacities,
settlement's carrying capacity (the "ashed-out region" decline). Marker sizes/colours are render higher = big rivers/coasts host far larger cities → wide size spread), `civGrowthMin` (0.25, growth-rate
constants (ViewerRender.cpp). 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 - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds. knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -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 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<double>` in is each settlement's **population** — which is all the step-back snapshot stores (a `vector<double>` in
`WeatherSnapshot`, restored in `restoreWeather`; no per-frame string churn). `stepCivilization(dtHours)` `WeatherSnapshot`, restored in `restoreWeather`; no per-frame string churn). `stepCivilization(dtHours,
runs in `liveAdvance` after `stepVolcanoes`: each population moves logistically toward a carrying liveTime)` runs in `liveAdvance` after `stepVolcanoes`. It is **environment-driven and dynamic** (the
capacity `K = civMaxPopulation·habitability(cell)`, cut transiently where an active volcano's ash plume first cut grew every town uniformly to the same cap):
(`ashTimer>0`, within ~1.5× blast radius) overlaps — so towns **grow, decline, and are abandoned** - growth **rate** `r = civGrowthRate·(civGrowthMin + (1civGrowthMin)·habitability)` so fertile cells
(floored at 1 so a site can revive when K recovers). Tier (village/town/city) is derived from grow far faster than marginal ones;
population. The viewer compares before/after populations in `detectLiveEvents` to log kind=3 - carrying capacity `K = civMaxPopulation · habitability · siteQuality · conditions`, where
`WorldEvent`s (tier crossings, abandonment), draws markers (3D spheres + 2D dots, sized by tier; city/ **siteQuality** = `0.45 + civSiteVariety·(coastBonus + log10(1+discharge/20))` makes max city size vary
town names as 3D labels), adds a **Civ** tab (7th) and a cell-info line, and a `Habitability` colour by an order of magnitude (a continental river or coast → a metropolis, a dry inland cell → a town —
mode (key `I`). `buildGeometry()` clears the set on reseed (like geography/volcanoes). Save **v20** this is what spreads final sizes instead of all saturating equally);
appends the settlement records (population included); `sCellSettlement` is rebuilt on load. Knobs `civ*`. - **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 `1sMoist`), 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<P` + the storm deaths; floored at 1 so a
site revives. So towns **grow, fluctuate, shrink in droughts, and collapse/abandon** under sustained
famine or an acute disaster.
Per-settlement derived `sCivCond` (combined multiplier) + `sCivDrought` (severity) drive the viewer:
markers are **withered-tinted** by hardship, cell-info shows "drought/conditions", and `detectLiveEvents`
logs kind=3 events with the **cause** — a storm over the shrunk town → "Hurricane <name> 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 ## Headless testing

View File

@ -95,6 +95,14 @@ 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).
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). // Climate (derived; present once computeClimate() has run).

View File

@ -372,6 +372,20 @@ void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
else if (aliveA && (int)ta > (int)tb) else if (aliveA && (int)ta > (int)tb)
appendEvent(3, 1, liveTime, s.cell, s.id, appendEvent(3, 1, liveTime, s.cell, s.id,
s.name + " grew into a " + settleTierName(ta), popLine(s)); 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 // 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); // 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 (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements);
if (vu.breach) refreshView(); if (vu.breach) refreshView();
else if (vu.recolor || cu.recolor) recolor(); else if (vu.recolor || cu.recolor) recolor();

View File

@ -9,6 +9,17 @@
#include <cmath> #include <cmath>
#include <vector> #include <vector>
// 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 // 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. // (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) { 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. // Settlement markers (civilization): a dot per settlement, sized + coloured by tier; dim for ruins.
if (showSettlements && !planet.settlements.empty()) { if (showSettlements && !planet.settlements.empty()) {
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; 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; if (s.cell < 0 || s.cell >= (int)planet.cells.size()) continue;
const Cell& c = planet.cells[s.cell]; const Cell& c = planet.cells[s.cell];
float r = visBase + (float)c.elevation * elevExagg + 0.006f; 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::City ? Color{250, 220, 110, 255}
: t == SettleTier::Town ? Color{225, 170, 90, 255} : t == SettleTier::Town ? Color{225, 170, 90, 255}
: Color{210, 130, 85, 255}; : Color{210, 130, 85, 255};
col = witherColor(col, k < cond.size() ? cond[k] : 1.0); // hardship -> withered tint
DrawSphere(p, rad, col); DrawSphere(p, rad, col);
if (alive && t != SettleTier::Village) { // a ring marks notable settlements if (alive && t != SettleTier::Village) { // a ring marks notable settlements
float rr = visBase + (float)c.elevation * elevExagg + 0.01f; float rr = visBase + (float)c.elevation * elevExagg + 0.01f;
@ -355,8 +369,10 @@ void Viewer::renderMap2D() {
} }
if (showSettlements && !planet.settlements.empty()) { if (showSettlements && !planet.settlements.empty()) {
const double townP = planet.cfg.civTownPop, cityP = planet.cfg.civCityPop, abP = planet.cfg.civAbandonPop; 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); 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; if (s.cell < 0 || s.cell >= (int)planet.cells.size()) continue;
double lon, lat; dirToLonLat(planet.cells[s.cell].unit, lon, lat); double lon, lat; dirToLonLat(planet.cells[s.cell].unit, lon, lat);
Vector2 sp = projLonLat(lon, lat, mapLon, vr); Vector2 sp = projLonLat(lon, lat, mapLon, vr);
@ -367,6 +383,7 @@ void Viewer::renderMap2D() {
: t == SettleTier::City ? Color{250, 220, 110, 255} : t == SettleTier::City ? Color{250, 220, 110, 255}
: t == SettleTier::Town ? Color{225, 170, 90, 255} : t == SettleTier::Town ? Color{225, 170, 90, 255}
: Color{210, 130, 85, 255}; : Color{210, 130, 85, 255};
col = witherColor(col, k < cond.size() ? cond[k] : 1.0);
DrawCircleV(sp, rad, col); DrawCircleV(sp, rad, col);
if (alive && t != SettleTier::Village) DrawCircleLines((int)sp.x, (int)sp.y, rad + 2.0f, Color{255, 245, 210, 180}); if (alive && t != SettleTier::Village) DrawCircleLines((int)sp.x, (int)sp.y, rad + 2.0f, Color{255, 245, 210, 180});
} }

View File

@ -62,6 +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();
} }
void Planet::clearDerivedState() { void Planet::clearDerivedState() {

View File

@ -183,10 +183,15 @@ public:
// / is abandoned). Saved (v20); step-back restores populations via WeatherSnapshot. // / is abandoned). Saved (v20); step-back restores populations via WeatherSnapshot.
void computeHabitability(); void computeHabitability();
void placeSettlements(); void placeSettlements();
CivUpdate stepCivilization(double dtHours); CivUpdate stepCivilization(double dtHours, double liveTime);
bool settlementsPlaced() const { return !settlements.empty(); } bool settlementsPlaced() const { return !settlements.empty(); }
const std::vector<int>& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1) const std::vector<int>& cellSettlement() const { return sCellSettlement; } // settlement index per cell (-1)
const std::vector<double>& habitability() const { return sHabitability; } // 0..1 per cell (derived) const std::vector<double>& 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<double>& settlementCondition() const { return sCivCond; }
const std::vector<double>& settlementDrought() const { return sCivDrought; }
// 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;
@ -311,6 +316,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)
uint32_t sCivRng = 1; uint32_t sCivRng = 1;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the

View File

@ -4,6 +4,16 @@
#include <cmath> #include <cmath>
#include <set> #include <set>
// 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 ------------------------- // --- Civilization Step 2: settlements & habitability -------------------------
// A per-cell habitability/food score, a one-time placement of settlement point-agents on the best // 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 // (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); settlements[k].id = (uint32_t)(k + 1);
sCellSettlement[settlements[k].cell] = k; 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 // One live-frame civilization step. Each settlement's population moves logistically toward a food-driven
// food-driven carrying capacity K = civMaxPopulation * habitability (cut transiently where an active // carrying capacity K that varies by the LOCAL environment AND over time -- year-to-year harvests,
// volcano ashes the area). Grows below K, declines above it; floored at 1 so an abandoned site can // multi-year droughts (worse in arid regions), rare cold years and river floods, plus volcano ash -- and
// revive if K recovers. Pure of any string churn; the set never changes here. // growth RATE scales with habitability so fertile cells grow far faster than marginal ones. Storms over a
CivUpdate Planet::stepCivilization(double dtHours) { // 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; CivUpdate up;
if (settlements.empty() || dtHours <= 0.0) return up; if (settlements.empty()) return up;
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); }
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 = dtHours / yearHours; const double dtYears = std::max(0.0, dtHours) / yearHours;
for (Settlement& st : settlements) { 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; if (st.cell < 0 || st.cell >= n) continue;
double K = cfg.civMaxPopulation * sHabitability[st.cell]; const int c = st.cell;
for (const Volcano& v : volcanoes) { // active ash plume nearby cuts carrying capacity // A coarse spatial bucket (~20deg cells) so droughts / harvests vary REGIONALLY -- different parts
if (v.ashTimer <= 0.0 || v.cell < 0 || v.cell >= n) continue; // of a continent have different fortunes, instead of every town drought-ing together.
double ang = std::acos(std::clamp(cells[st.cell].unit.dot(cells[v.cell].unit), -1.0, 1.0)); double clat = std::asin(std::clamp(cells[c].unit.y, -1.0, 1.0));
if (ang < cfg.volcanoBlastRadius * 1.5) { K *= 0.3; break; } 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<P)
P = std::max(1.0, P); // keep a seed so an abandoned site can revive
if (std::fabs(P - st.population) > std::max(1.0, st.population * 0.0005)) up.recolor = true;
st.population = P;
} }
return up; return up;
} }

View File

@ -52,6 +52,9 @@
D(civMinSpacingRadians) D(civMinHabitability) D(civSeedPopulation) D(civGrowthRate) \ D(civMinSpacingRadians) D(civMinHabitability) D(civSeedPopulation) D(civGrowthRate) \
D(civMaxPopulation) D(civTownPop) D(civCityPop) D(civAbandonPop) \ D(civMaxPopulation) D(civTownPop) D(civCityPop) D(civAbandonPop) \
D(civHabWaterWeight) D(civHabFoodWeight) D(civHabTempOpt) D(civHabElevPenalty) \ 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(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ 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.civHabFoodWeight, 0.0, 1.0, "civHabFoodWeight"));
E(rng(cfg.civHabTempOpt, -20.0, 50.0, "civHabTempOpt")); E(rng(cfg.civHabTempOpt, -20.0, 50.0, "civHabTempOpt"));
E(rng(cfg.civHabElevPenalty, 0.0, 12000.0, "civHabElevPenalty")); 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.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth")); E(irng(cfg.beltWidth, 1, 12, "beltWidth"));

View File

@ -395,4 +395,19 @@ struct PlanetConfig {
double civHabFoodWeight = 0.40; // habitability weight of food (flora/fauna + ecoregion) double civHabFoodWeight = 0.40; // habitability weight of food (flora/fauna + ecoregion)
double civHabTempOpt = 18.0; // C: most comfortable annual-mean temperature double civHabTempOpt = 18.0; // C: most comfortable annual-mean temperature
double civHabElevPenalty = 2500.0; // m above which high terrain steeply reduces habitability 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
}; };

View File

@ -79,19 +79,59 @@ int main() {
check(capOk, "settlement count within the cap"); check(capOk, "settlement count within the cap");
check(uniqueNames, "settlement names are unique + non-empty"); 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; int gi = 0, lo = 0;
double p0 = p.settlements[gi].population; for (size_t k = 0; k < S.size(); ++k) {
for (int k = 0; k < 400; ++k) p.stepCivilization(5.0 * yearH); // ~2000 yr of small steps if (p.habitability()[S[k].cell] > p.habitability()[S[gi].cell]) gi = (int)k;
check(p.settlements[gi].population > p0 * 2.0, "a high-habitability settlement grows"); if (p.habitability()[S[k].cell] < p.habitability()[S[lo].cell]) lo = (int)k;
// Decline: push one well over its carrying capacity, then step -> it shrinks. }
double g0 = p.settlements[gi].population;
double lt = 0.0; bool anyDecline = false;
std::vector<double> 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<double> 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; p.settlements[gi].population = 5.0e7;
double over = p.settlements[gi].population; 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"); 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"); std::printf("Civ: tiers\n");
check(settleTierOf(100.0, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::Village check(settleTierOf(100.0, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::Village
&& settleTierOf(p.cfg.civTownPop, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::Town && 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) { for (int k = 0; k < 40; ++k) {
double dx = x.cflDtMy(); x.advect(dx); x.step(); x.erode(dx); double dx = x.cflDtMy(); x.advect(dx); x.step(); x.erode(dx);
double dy = y.cflDtMy(); y.advect(dy); y.step(); y.erode(dy); 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; 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; for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) terrainSame = false;