Cities used to climb a hugely stacked carrying capacity (K up to 20-40M for the best trade hubs -- civMaxPopulation x habitability x siteQuality x conditions x trade) at ~2%/yr for millennia: bounded in principle, endless in practice. Three fixes, all population-only + derived vectors (pure hashes, no RNG, no save-version bump, step-back exact): - Urban crowding: mortality rises with the square of city size (civCrowdingLoss x (P/civMetropolisPop)^2 per year), so the best hubs PLATEAU at a historical metropolis scale (~1-1.5M) instead of chasing K; negligible below ~50k, not a clamp. - Good-year cap (civCondBoomCap): a lucky harvest no longer inflates the K target by 70% (the logistic chased booms at full rate while famine corrected busts slowly -- an upward ratchet); droughts stay uncapped. - Plagues (civPlague*): rare deterministic epidemics (1-3-year waves, 20-40% deaths at full exposure) strike cities (exposure 0 below ~30k), harder when trade-connected -- contagion travels the routes, the historical check on big hubs. Derived sCivPlague + cell-info PLAGUE line + "Plague ravages X" / "Plague shrinks X" kind-3 events. Retuned: civMaxPopulation 2e6 -> 1e6 (it is a capacity SCALE, not a cap -- comment fixed), civEmpirePop 5e6 -> 2.5e6 for the new sizes. test_civ gains a plateau/plague section: with the new model the largest city settles ~1.1M vs 3.7M-and-climbing without it; villages never plague; waves are deterministic, twin-identical and rewind exactly. All 16 suites pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
294 lines
16 KiB
C++
294 lines
16 KiB
C++
// Headless test for civilization Step 2 (habitability + settlements). No display needed.
|
|
//
|
|
// g++ -std=c++17 -O2 -Isrc/sim test_civ.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp
|
|
// src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp
|
|
// src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp
|
|
// src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp
|
|
// src/sim/PlanetVolcano.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp
|
|
// src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp
|
|
// src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp
|
|
// 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;
|
|
// 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 <cstdio>
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
#include <set>
|
|
#include <sstream>
|
|
|
|
static int failures = 0;
|
|
static void check(bool cond, const char* what) {
|
|
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
|
|
if (!cond) ++failures;
|
|
}
|
|
static void settle(Planet& p, int maxSteps = 800) {
|
|
int run = 0;
|
|
for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; }
|
|
p.computeClimate(); p.classifyBiomes();
|
|
}
|
|
static void drift(Planet& p, int iters) {
|
|
p.drifting = true;
|
|
for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); if (k >= iters/2) p.hydrology(dt*0.2); }
|
|
p.computeClimate(); p.classifyBiomes();
|
|
}
|
|
|
|
int main() {
|
|
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 4242;
|
|
Planet p; p.generate(cfg); settle(p); drift(p, 400);
|
|
const int n = (int)p.cells.size();
|
|
const double sea = p.cfg.seaLevel;
|
|
const double yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays;
|
|
|
|
std::printf("Civ: habitability field\n");
|
|
p.computeHabitability();
|
|
const auto& H = p.habitability();
|
|
check((int)H.size() == n, "habitability sized n");
|
|
bool ranged = true, zeroWaterIce = true, anyHabitable = false;
|
|
for (int i = 0; i < n; ++i) {
|
|
if (!(std::isfinite(H[i]) && H[i] >= 0.0 && H[i] <= 1.0)) ranged = false;
|
|
if ((p.cells[i].elevation <= sea || p.cells[i].biome == Biome::Ice) && H[i] != 0.0) zeroWaterIce = false;
|
|
if (H[i] > 0.3) anyHabitable = true;
|
|
}
|
|
check(ranged, "habitability in [0,1]");
|
|
check(zeroWaterIce, "habitability 0 on ocean/ice");
|
|
check(anyHabitable, "some land is habitable");
|
|
|
|
std::printf("Civ: placement\n");
|
|
p.placeSettlements();
|
|
const auto& S = p.settlements;
|
|
check(!S.empty(), "settlements placed");
|
|
bool onLand = true, aboveMin = true, capOk = (int)S.size() <= p.cfg.civMaxSettlements;
|
|
std::set<std::string> names; bool uniqueNames = true; std::set<int> distinctCells; bool distinct = true;
|
|
double placedHabSum = 0.0;
|
|
for (size_t a = 0; a < S.size(); ++a) {
|
|
if (p.cells[S[a].cell].elevation <= sea) onLand = false;
|
|
if (p.habitability()[S[a].cell] < p.cfg.civMinHabitability - 1e-9) aboveMin = false;
|
|
if (!names.insert(S[a].name).second || S[a].name.empty()) uniqueNames = false;
|
|
if (!distinctCells.insert(S[a].cell).second) distinct = false; // one settlement per cell
|
|
placedHabSum += p.habitability()[S[a].cell];
|
|
if (p.cellSettlement()[S[a].cell] != (int)a) onLand = false; // index consistency
|
|
}
|
|
// Nearest-neighbour spacing should VARY (clustered, not an even lattice).
|
|
double nnMin = 1e9, nnMax = 0.0;
|
|
for (size_t a = 0; a < S.size(); ++a) {
|
|
double best = 1e9;
|
|
for (size_t b = 0; b < S.size(); ++b) if (a != b)
|
|
best = std::min(best, std::acos(std::clamp(p.cells[S[a].cell].unit.dot(p.cells[S[b].cell].unit), -1.0, 1.0)));
|
|
if (best < 1e8) { nnMin = std::min(nnMin, best); nnMax = std::max(nnMax, best); }
|
|
}
|
|
double placedHabMean = S.empty() ? 0.0 : placedHabSum / S.size();
|
|
double habMean = 0.0; int habN = 0;
|
|
for (int i = 0; i < n; ++i) if (p.cells[i].elevation > sea && p.habitability()[i] >= p.cfg.civMinHabitability) { habMean += p.habitability()[i]; ++habN; }
|
|
habMean = habN ? habMean / habN : 0.0;
|
|
std::printf(" %d settlements nn-dist %.3f..%.3f rad placed-hab %.2f vs habitable-mean %.2f\n",
|
|
(int)S.size(), nnMin, nnMax, placedHabMean, habMean);
|
|
check(onLand && distinct, "settlements sit on distinct land cells + cellSettlement index consistent");
|
|
check(aboveMin, "settlements only on cells >= civMinHabitability");
|
|
check(nnMax > nnMin * 1.8, "nearest-neighbour spacing varies (clustered, not an even lattice)");
|
|
check(placedHabMean > habMean + 0.02, "placement concentrates on the better land (clustering)");
|
|
check(capOk, "settlement count within the cap");
|
|
check(uniqueNames, "settlement names are unique + non-empty");
|
|
|
|
std::printf("Civ: environment-driven growth (differentiated + dynamic)\n");
|
|
{
|
|
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<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;
|
|
double over = p.settlements[gi].population;
|
|
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
|
|
&& settleTierOf(p.cfg.civCityPop, p.cfg.civTownPop, p.cfg.civCityPop) == SettleTier::City,
|
|
"tier thresholds (village/town/city)");
|
|
|
|
std::printf("Civ: determinism\n");
|
|
Planet q; q.generate(cfg); settle(q); drift(q, 400); q.placeSettlements();
|
|
bool same = (q.settlements.size() == S.size());
|
|
if (same) for (size_t k = 0; k < S.size(); ++k)
|
|
if (q.settlements[k].cell != p.settlements[k].cell || q.settlements[k].name != p.settlements[k].name) { same = false; break; }
|
|
check(same, "placeSettlements is deterministic");
|
|
|
|
std::printf("Civ: RNG isolation from tectonics\n");
|
|
Planet x; x.generate(cfg); settle(x);
|
|
Planet y; y.generate(cfg); settle(y);
|
|
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, 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;
|
|
check(terrainSame, "placeSettlements/stepCivilization never perturb tectonic evolution");
|
|
|
|
std::printf("Civ: population snapshot round-trip\n");
|
|
{
|
|
WeatherSnapshot snap = p.captureWeather();
|
|
for (auto& st : p.settlements) st.population = 12345.0;
|
|
p.restoreWeather(snap);
|
|
bool restored = true;
|
|
for (size_t k = 0; k < p.settlements.size(); ++k) if (std::fabs(p.settlements[k].population - snap.settlementPop[k]) > 1e-9) restored = false;
|
|
check(snap.settlementPop.size() == p.settlements.size() && restored, "captureWeather/restoreWeather round-trips populations");
|
|
}
|
|
|
|
std::printf("Civ: save v20 round-trip\n");
|
|
{
|
|
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
|
|
p.writeState(ss);
|
|
Planet r;
|
|
bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true);
|
|
check(ok, "readState accepts a v20 stream");
|
|
bool match = (r.settlements.size() == p.settlements.size());
|
|
if (match) for (size_t k = 0; k < p.settlements.size(); ++k)
|
|
if (r.settlements[k].cell != p.settlements[k].cell || r.settlements[k].name != p.settlements[k].name
|
|
|| std::fabs(r.settlements[k].population - p.settlements[k].population) > 1e-6) { match = false; break; }
|
|
check(match, "settlements round-trip through save");
|
|
check(r.cellSettlement() == p.cellSettlement(), "cellSettlement index rebuilt on load");
|
|
}
|
|
|
|
std::printf("Civ: growth plateau (crowding + plague keep cities at a historical scale)\n");
|
|
{
|
|
Planet base; base.generate(cfg); settle(base); drift(base, 400);
|
|
base.placeSettlements();
|
|
const double stepH = 2.0 * yearH;
|
|
// One era: fixed cadence (territory/culture/trade every 10 steps so prosperity feeds K, like
|
|
// the viewer's yearly rebuild but cheaper); returns the end clock so eras chain year-correctly.
|
|
auto era = [&](Planet& x, int iters, double lt) {
|
|
for (int it = 0; it < iters; ++it) {
|
|
if (it % 10 == 0) { x.computeTerritory(); x.computeCultures(); x.computeTrade(); }
|
|
lt += stepH;
|
|
x.stepCivilization(stepH, lt);
|
|
}
|
|
return lt;
|
|
};
|
|
Planet A = base; // defaults: crowding + plague + good-year cap
|
|
Planet B = base; // the old unbounded behaviour
|
|
B.cfg.civCrowdingLoss = 0.0; B.cfg.civPlagueRate = 0.0; B.cfg.civCondBoomCap = 100.0;
|
|
// Run A inline so we can watch every step: villages (< 30k) must never see plague.
|
|
bool villagesClean = true;
|
|
double ltA = 0.0;
|
|
for (int it = 0; it < 2000; ++it) { // 4000 years
|
|
if (it % 10 == 0) { A.computeTerritory(); A.computeCultures(); A.computeTrade(); }
|
|
ltA += stepH;
|
|
A.stepCivilization(stepH, ltA);
|
|
for (size_t k = 0; k < A.settlements.size(); ++k)
|
|
if (A.settlements[k].population < 0.3 * A.cfg.civCityPop
|
|
&& k < A.settlementPlague().size() && A.settlementPlague()[k] > 0.0) villagesClean = false;
|
|
}
|
|
era(B, 2000, 0.0);
|
|
double mxA = 0.0, mxB = 0.0;
|
|
for (const auto& s : A.settlements) mxA = std::max(mxA, s.population);
|
|
for (const auto& s : B.settlements) mxB = std::max(mxB, s.population);
|
|
std::printf(" max pop with plateau %.0f without %.0f (x%.1f)\n", mxA, mxB, mxA > 0 ? mxB / mxA : 0.0);
|
|
check(mxA > 4.0e5 && mxA < 3.0e6, "the largest city plateaus at a historical metropolis scale (~1-2M)");
|
|
check(mxB > mxA * 2.0, "without crowding/plague/boom-cap cities grow far larger (the old runaway)");
|
|
check(villagesClean, "small settlements never suffer plague");
|
|
|
|
// A plague wave hits the biggest hub within a few centuries, is deterministic and rewindable.
|
|
int big = 0;
|
|
for (size_t k = 0; k < A.settlements.size(); ++k)
|
|
if (A.settlements[k].population > A.settlements[big].population) big = (int)k;
|
|
bool sawPlague = false, dropInWave = false;
|
|
double ltP = ltA;
|
|
for (int yr = 0; yr < 400; ++yr) {
|
|
double before = A.settlements[big].population;
|
|
if (yr % 10 == 0) { A.computeTerritory(); A.computeCultures(); A.computeTrade(); }
|
|
ltP += yearH;
|
|
A.stepCivilization(yearH, ltP);
|
|
if (A.settlementPlague()[big] > 0.02) {
|
|
sawPlague = true;
|
|
if (A.settlements[big].population < before * 0.98) dropInWave = true;
|
|
}
|
|
}
|
|
std::printf(" plague hit the largest hub: %s\n", sawPlague ? "yes" : "no");
|
|
check(sawPlague, "a plague wave strikes a large trade hub within a few centuries");
|
|
check(dropInWave, "an active plague year visibly shrinks the city");
|
|
|
|
// Determinism: two identical copies evolve identical populations through plague years.
|
|
{
|
|
Planet d1 = base, d2 = base;
|
|
double l1 = era(d1, 200, 0.0), l2 = era(d2, 200, 0.0);
|
|
bool same = (l1 == l2) && d1.settlements.size() == d2.settlements.size();
|
|
for (size_t k = 0; same && k < d1.settlements.size(); ++k)
|
|
if (d1.settlements[k].population != d2.settlements[k].population) same = false;
|
|
check(same, "growth incl. plague/crowding is deterministic (bit-identical twins)");
|
|
}
|
|
// Step-back: capture -> 50 years (across plague waves) -> restore -> replay -> identical.
|
|
{
|
|
WeatherSnapshot snap = A.captureWeather();
|
|
double lt0 = ltP;
|
|
std::vector<double> endPops;
|
|
double lt1 = era(A, 25, lt0); // 50 years
|
|
for (const auto& s : A.settlements) endPops.push_back(s.population);
|
|
A.restoreWeather(snap);
|
|
bool back = true;
|
|
for (size_t k = 0; k < A.settlements.size(); ++k)
|
|
if (A.settlements[k].population != snap.settlementPop[k]) back = false;
|
|
check(back, "restoreWeather rewinds populations across plague years");
|
|
double lt2 = era(A, 25, lt0); // deterministic replay
|
|
bool same = (lt1 == lt2);
|
|
for (size_t k = 0; same && k < A.settlements.size(); ++k)
|
|
if (A.settlements[k].population != endPops[k]) same = false;
|
|
check(same, "replaying the same years reproduces the same populations (plague is stateless)");
|
|
}
|
|
}
|
|
|
|
std::printf("Civ: reseed clears settlements\n");
|
|
p.generate(cfg);
|
|
check(p.settlements.empty() && (p.cellSettlement().empty() || p.cellSettlement()[0] == -1), "reseed clears the settlement set");
|
|
|
|
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CIV CHECKS PASSED\n", failures);
|
|
return failures ? 1 : 0;
|
|
}
|