Civ: cluster settlement placement (habitability-weighted, irregular)

Placement used greedy farthest-first with a hard minimum spacing, so settlements
came out as a near-uniform lattice (unrealistic). Now placeSettlements() does
habitability-weighted random sampling (weight = habitability^civClusterExp) with
a soft Gaussian suppression (civMinSpacingRadians) around each pick, so towns
cluster on good land (rivers/coasts/fertile valleys) at irregular spacing and
leave empty stretches between. Nearest-neighbour distances now span ~0.04..0.39
rad (was ~uniform) and placement concentrates on the better cells.

- New knob civClusterExp (3.0; higher = tighter clustering on the best land);
  civMinSpacingRadians repurposed as the soft suppression scale (0.10 -> 0.06).
- Separate sCivRng + deterministic, so determinism / RNG isolation hold.
- test_civ: replaced the hard-spacing assertion with clustering checks
  (nearest-neighbour spacing varies; placed cells beat the habitable mean).

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 13:25:25 +02:00
parent 3655dc0661
commit 868cc90667
7 changed files with 75 additions and 37 deletions

View File

@ -284,7 +284,8 @@ Civilization (PlanetConfig, key U): settlements placed once on the best fertile
population grows/declines on the Live World clock toward a food-driven carrying capacity. Saved v20. population grows/declines on the Live World clock toward a food-driven carrying capacity. Saved v20.
civMaxSettlements 80 cap on settlement sites civMaxSettlements 80 cap on settlement sites
civMinSpacingRadians 0.10 min angular gap between sites (~640 km) civMinSpacingRadians 0.06 soft suppression scale around each pick (lower = tighter clusters)
civClusterExp 3.0 habitability weighting for placement (higher = clusters on the best land)
civMinHabitability 0.22 don't place a settlement below this habitability civMinHabitability 0.22 don't place a settlement below this habitability
civSeedPopulation 250 initial village population civSeedPopulation 250 initial village population
civGrowthRate 0.02 logistic growth rate per year civGrowthRate 0.02 logistic growth rate per year

View File

@ -132,8 +132,10 @@ on the Live World clock). **Steps 12 of the roadmap are done (plus a derived
**habitability/food** score (`computeHabitability`: climate comfort + water access (rivers/lakes/ **habitability/food** score (`computeHabitability`: climate comfort + water access (rivers/lakes/
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** by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`)
`sCivRng`; named from the continent's `NameGen` bank). `stepCivilization(dtHours, liveTime)` runs each with a **soft Gaussian suppression** (`civMinSpacingRadians`) around each pick — so settlements
**cluster** on good land (rivers/coasts/fertile valleys) at irregular spacing rather than an even
lattice (separate `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 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 **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· with habitability (fertile cells boom, marginal crawl); the capacity `K = civMaxPopulation·habitability·
@ -860,9 +862,10 @@ triangles (plates are fixed in phase 1).
a new volcanic island is named on the fly (`Planet::nameNewLand`, joins an adjacent landmass or mints a new volcanic island is named on the fly (`Planet::nameNewLand`, joins an adjacent landmass or mints
a fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are a fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are
constants in NameGen.cpp / ViewerRender.cpp, not config. constants in NameGen.cpp / ViewerRender.cpp, not config.
- **Civilization / settlements (`civ*` in PlanetConfig / `planet.cfg`):** placement — `civMaxSettlements` - **Civilization / settlements (`civ*` in PlanetConfig / `planet.cfg`):** placement (habitability-weighted
(80, cap), `civMinSpacingRadians` (0.10 rad ≈ 640 km, min gap between sites), `civMinHabitability` + clustered) — `civMaxSettlements` (80, cap), `civClusterExp` (3.0, higher = settlements cluster harder
(0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight` on the best land), `civMinSpacingRadians` (0.06 rad, soft suppression scale around each pick — lower =
tighter clusters), `civMinHabitability` (0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight`
(0.40, the rest is temperature comfort), `civHabTempOpt` (18 °C, most comfortable mean), `civHabElevPenalty` (0.40, the rest is temperature comfort), `civHabTempOpt` (18 °C, most comfortable mean), `civHabElevPenalty`
(2500 m, high terrain steeply penalised above this). Population — `civSeedPopulation` (250, initial (2500 m, high terrain steeply penalised above this). Population — `civSeedPopulation` (250, initial
village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (2e6, the carrying capacity at village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (2e6, the carrying capacity at

View File

@ -361,8 +361,10 @@ load with none and regenerate on demand.
derived per-cell food/livability score (0..1): a weighted blend of temperature comfort, water access derived per-cell food/livability score (0..1): a weighted blend of temperature comfort, water access
(river `discharge`, adjacent lake, coast) and food (`floraDensity`+`faunaDensity`+the cell's ecoregion (river `discharge`, adjacent lake, coast) and food (`floraDensity`+`faunaDensity`+the cell's ecoregion
productivity), gated by freezing winters and high elevation. `placeSettlements()` (key `U`, "the dawn") productivity), gated by freezing winters and high elevation. `placeSettlements()` (key `U`, "the dawn")
seeds a **fixed** set once — greedily the highest-habitability cells with a minimum angular spacing seeds a **fixed** set once by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`)
(the ocean-basin farthest-first idiom) — naming each from its continent's `NameGen` bank. with a **soft Gaussian suppression** (`civMinSpacingRadians`) softening nearby weights after each pick — so
settlements **cluster** on good land at irregular spacing instead of an even lattice (the earlier hard
farthest-first looked like a grid). Each is named from its continent's `NameGen` bank.
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

View File

@ -71,8 +71,12 @@ void Planet::computeHabitability() {
} }
} }
// One-time placement: greedily seed the highest-habitability cells with a minimum angular spacing // One-time placement: habitability-WEIGHTED RANDOM sampling with soft local suppression, so settlements
// (the ocean-basin seeding idiom). Auto-builds geography/ecoregions first (names + productivity). // CLUSTER on good land (rivers/coasts/fertile valleys) and leave irregular gaps -- not the even lattice a
// hard farthest-first spacing produced. Each habitable cell's weight is habitability^civClusterExp; after a
// site is chosen, the weight of nearby cells is multiplied by a Gaussian falloff (0 at the site -> ~1 far),
// so neighbours are unlikely but a fertile region can still host several towns at irregular spacing.
// Auto-builds geography/ecoregions first (names + productivity). Separate RNG -> tectonic determinism intact.
void Planet::placeSettlements() { void Planet::placeSettlements() {
const int n = (int)cells.size(); const int n = (int)cells.size();
if (!geographyBuilt()) generateGeography(); if (!geographyBuilt()) generateGeography();
@ -82,32 +86,43 @@ void Planet::placeSettlements() {
sCellSettlement.assign(n, -1); sCellSettlement.assign(n, -1);
sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu; sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu;
auto next = [&]() { sCivRng ^= sCivRng << 13; sCivRng ^= sCivRng >> 17; sCivRng ^= sCivRng << 5; return sCivRng; }; auto next = [&]() { sCivRng ^= sCivRng << 13; sCivRng ^= sCivRng >> 17; sCivRng ^= sCivRng << 5; return sCivRng; };
auto rf = [&]() { return (next() & 0xFFFFFFu) / double(0x1000000); };
std::vector<int> cand; const double minHab = cfg.civMinHabitability, cexp = std::max(0.0, cfg.civClusterExp);
for (int i = 0; i < n; ++i) if (sHabitability[i] >= cfg.civMinHabitability) cand.push_back(i); std::vector<double> w(n, 0.0); double total = 0.0;
std::sort(cand.begin(), cand.end(), [&](int a, int b) { return sHabitability[a] > sHabitability[b]; }); for (int i = 0; i < n; ++i)
if (cells[i].elevation > cfg.seaLevel && sHabitability[i] >= minHab) {
w[i] = std::pow(std::max(1e-6, sHabitability[i]), cexp); total += w[i];
}
const double sepCos = std::cos(std::max(0.01, cfg.civMinSpacingRadians)); const double R = std::max(0.005, cfg.civMinSpacingRadians);
const double invR2 = 1.0 / (R * R), cutCos = std::cos(std::min(3.0 * R, 3.14159));
const int cap = std::max(0, cfg.civMaxSettlements); const int cap = std::max(0, cfg.civMaxSettlements);
std::set<std::string> usedNames; std::set<std::string> usedNames;
std::vector<int> chosen; while ((int)settlements.size() < cap && total > 1e-9) {
for (int i : cand) { double r = rf() * total, acc = 0.0; int pick = -1;
if ((int)settlements.size() >= cap) break; for (int i = 0; i < n; ++i) { if (w[i] <= 0.0) continue; acc += w[i]; if (r <= acc) { pick = i; break; } }
bool ok = true; if (pick < 0) break;
for (int c : chosen) if (cells[i].unit.dot(cells[c].unit) > sepCos) { ok = false; break; } int regId = ((int)sCellLand.size() == n) ? sCellLand[pick] : -1;
if (!ok) continue;
chosen.push_back(i);
int regId = ((int)sCellLand.size() == n) ? sCellLand[i] : -1;
int bank = (regId >= 0) ? namegen::bankForRegion(cfg.seed, regId) int bank = (regId >= 0) ? namegen::bankForRegion(cfg.seed, regId)
: namegen::bankForRegion(cfg.seed, 2000 + i); : namegen::bankForRegion(cfg.seed, 2000 + pick);
uint32_t nameSeed = next() ^ (uint32_t)(i * 2654435761u); uint32_t nameSeed = next() ^ (uint32_t)(pick * 2654435761u);
std::string nm = namegen::makeName(nameSeed, bank); std::string nm = namegen::makeName(nameSeed, bank);
for (int g = 0; usedNames.count(nm) && g < 128; ++g) nm = namegen::makeName(nameSeed += 0x9E3779B9u, bank); for (int g = 0; usedNames.count(nm) && g < 128; ++g) nm = namegen::makeName(nameSeed += 0x9E3779B9u, bank);
usedNames.insert(nm); usedNames.insert(nm);
Settlement st; Settlement st; st.cell = pick; st.bank = bank; st.regionId = regId;
st.cell = i; st.bank = bank; st.regionId = regId;
st.population = cfg.civSeedPopulation; st.name = nm; st.population = cfg.civSeedPopulation; st.name = nm;
settlements.push_back(std::move(st)); settlements.push_back(std::move(st));
// Soft suppression: soften nearby weights (organic clustering + irregular spacing).
for (int i = 0; i < n; ++i) {
if (w[i] <= 0.0) continue;
double cosang = cells[pick].unit.dot(cells[i].unit);
if (cosang < cutCos) continue; // far -> untouched
double ang = std::acos(std::clamp(cosang, -1.0, 1.0));
double nw = w[i] * (1.0 - std::exp(-ang * ang * invR2)); // 0 at the site -> ~1 far away
total += (nw - w[i]); w[i] = nw;
}
if (w[pick] > 0.0) { total -= w[pick]; w[pick] = 0.0; }
} }
for (int k = 0; k < (int)settlements.size(); ++k) { for (int k = 0; k < (int)settlements.size(); ++k) {
settlements[k].id = (uint32_t)(k + 1); settlements[k].id = (uint32_t)(k + 1);

View File

@ -49,7 +49,7 @@
D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \ D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \
D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \ D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \
D(geoMountainElev) D(geoRiverMinDischarge) D(geoOceanSepRadians) \ D(geoMountainElev) D(geoRiverMinDischarge) D(geoOceanSepRadians) \
D(civMinSpacingRadians) D(civMinHabitability) D(civSeedPopulation) D(civGrowthRate) \ D(civMinSpacingRadians) D(civClusterExp) 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(civSiteVariety) D(civGrowthMin) D(civHarvestVar) D(civDroughtStrength) D(civDroughtPeriod) D(civDroughtThresh) \
@ -255,6 +255,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge")); E(rng(cfg.geoRiverMinDischarge, 0.0, 1.0e9, "geoRiverMinDischarge"));
E(rng(cfg.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians")); E(rng(cfg.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians"));
E(rng(cfg.civMinSpacingRadians, 0.001, 3.14159, "civMinSpacingRadians")); E(rng(cfg.civMinSpacingRadians, 0.001, 3.14159, "civMinSpacingRadians"));
E(rng(cfg.civClusterExp, 0.0, 12.0, "civClusterExp"));
E(rng(cfg.civMinHabitability, 0.0, 1.0, "civMinHabitability")); E(rng(cfg.civMinHabitability, 0.0, 1.0, "civMinHabitability"));
E(rng(cfg.civSeedPopulation, 1.0, 1.0e9, "civSeedPopulation")); E(rng(cfg.civSeedPopulation, 1.0, 1.0e9, "civSeedPopulation"));
E(rng(cfg.civGrowthRate, 0.0, 100.0, "civGrowthRate")); E(rng(cfg.civGrowthRate, 0.0, 100.0, "civGrowthRate"));

View File

@ -383,7 +383,10 @@ struct PlanetConfig {
// Live World clock toward a food-driven carrying capacity. Habitability blends climate comfort, // Live World clock toward a food-driven carrying capacity. Habitability blends climate comfort,
// water access and food (flora/fauna + ecoregion productivity). // water access and food (flora/fauna + ecoregion productivity).
int civMaxSettlements = 80; // cap on settlement sites int civMaxSettlements = 80; // cap on settlement sites
double civMinSpacingRadians = 0.10; // min angular separation between settlement sites (~640 km) double civMinSpacingRadians = 0.06; // soft suppression scale: a new settlement softens the weight of
// cells within ~this angle (organic clustering, not a hard grid)
double civClusterExp = 3.0; // habitability weighting exponent for placement (higher = settlements
// cluster harder on the best land; 1 = mild, 0 = uniform among habitable)
double civMinHabitability = 0.22; // don't place a settlement below this habitability double civMinHabitability = 0.22; // don't place a settlement below this habitability
double civSeedPopulation = 250.0; // initial village population at placement double civSeedPopulation = 250.0; // initial village population at placement
double civGrowthRate = 0.02; // logistic growth rate per year (toward carrying capacity) double civGrowthRate = 0.02; // logistic growth rate per year (toward carrying capacity)

View File

@ -61,21 +61,34 @@ int main() {
const auto& S = p.settlements; const auto& S = p.settlements;
check(!S.empty(), "settlements placed"); check(!S.empty(), "settlements placed");
bool onLand = true, aboveMin = true, capOk = (int)S.size() <= p.cfg.civMaxSettlements; bool onLand = true, aboveMin = true, capOk = (int)S.size() <= p.cfg.civMaxSettlements;
std::set<std::string> names; bool uniqueNames = true; std::set<std::string> names; bool uniqueNames = true; std::set<int> distinctCells; bool distinct = true;
const double sepCos = std::cos(p.cfg.civMinSpacingRadians); double placedHabSum = 0.0;
bool spaced = true;
for (size_t a = 0; a < S.size(); ++a) { for (size_t a = 0; a < S.size(); ++a) {
if (p.cells[S[a].cell].elevation <= sea) onLand = false; if (p.cells[S[a].cell].elevation <= sea) onLand = false;
if (p.habitability()[S[a].cell] < p.cfg.civMinHabitability - 1e-9) aboveMin = 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 (!names.insert(S[a].name).second || S[a].name.empty()) uniqueNames = false;
for (size_t b = a + 1; b < S.size(); ++b) if (!distinctCells.insert(S[a].cell).second) distinct = false; // one settlement per cell
if (p.cells[S[a].cell].unit.dot(p.cells[S[b].cell].unit) > sepCos + 1e-9) spaced = false; placedHabSum += p.habitability()[S[a].cell];
if (p.cellSettlement()[S[a].cell] != (int)a) onLand = false; // index consistency if (p.cellSettlement()[S[a].cell] != (int)a) onLand = false; // index consistency
} }
std::printf(" %d settlements\n", (int)S.size()); // Nearest-neighbour spacing should VARY (clustered, not an even lattice).
check(onLand, "settlements sit on land + cellSettlement index is consistent"); 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(aboveMin, "settlements only on cells >= civMinHabitability");
check(spaced, "settlements respect the minimum spacing"); 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(capOk, "settlement count within the cap");
check(uniqueNames, "settlement names are unique + non-empty"); check(uniqueNames, "settlement names are unique + non-empty");