diff --git a/BUILD.md b/BUILD.md index f3b77c1..0ff0327 100644 --- a/BUILD.md +++ b/BUILD.md @@ -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. 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 civSeedPopulation 250 initial village population civGrowthRate 0.02 logistic growth rate per year diff --git a/CLAUDE.md b/CLAUDE.md index b981218..176ba92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,8 +132,10 @@ on the Live World clock). **Steps 1–2 of the roadmap are done (plus a derived **habitability/food** score (`computeHabitability`: climate comfort + water access (rivers/lakes/ 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(dtHours, liveTime)` runs each + a fixed set **once** by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`) + 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 **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· @@ -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 fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are constants in NameGen.cpp / ViewerRender.cpp, not config. -- **Civilization / settlements (`civ*` in PlanetConfig / `planet.cfg`):** placement — `civMaxSettlements` - (80, cap), `civMinSpacingRadians` (0.10 rad ≈ 640 km, min gap between sites), `civMinHabitability` - (0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight` +- **Civilization / settlements (`civ*` in PlanetConfig / `planet.cfg`):** placement (habitability-weighted + + clustered) — `civMaxSettlements` (80, cap), `civClusterExp` (3.0, higher = settlements cluster harder + 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` (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 diff --git a/docs/design-notes.md b/docs/design-notes.md index c3ffb21..cbaf14d 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -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 (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") -seeds a **fixed** set once — greedily the highest-habitability cells with a minimum angular spacing -(the ocean-basin farthest-first idiom) — naming each from its continent's `NameGen` bank. +seeds a **fixed** set once by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`) +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 is each settlement's **population** — which is all the step-back snapshot stores (a `vector` in diff --git a/src/sim/PlanetCiv.cpp b/src/sim/PlanetCiv.cpp index a86ece9..0ab3506 100644 --- a/src/sim/PlanetCiv.cpp +++ b/src/sim/PlanetCiv.cpp @@ -71,8 +71,12 @@ void Planet::computeHabitability() { } } -// One-time placement: greedily seed the highest-habitability cells with a minimum angular spacing -// (the ocean-basin seeding idiom). Auto-builds geography/ecoregions first (names + productivity). +// One-time placement: habitability-WEIGHTED RANDOM sampling with soft local suppression, so settlements +// 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() { const int n = (int)cells.size(); if (!geographyBuilt()) generateGeography(); @@ -82,32 +86,43 @@ void Planet::placeSettlements() { sCellSettlement.assign(n, -1); sCivRng = cfg.seed ? (cfg.seed ^ 0x017B1A2Eu) : 0x017B1A2Eu; auto next = [&]() { sCivRng ^= sCivRng << 13; sCivRng ^= sCivRng >> 17; sCivRng ^= sCivRng << 5; return sCivRng; }; + auto rf = [&]() { return (next() & 0xFFFFFFu) / double(0x1000000); }; - std::vector cand; - for (int i = 0; i < n; ++i) if (sHabitability[i] >= cfg.civMinHabitability) cand.push_back(i); - std::sort(cand.begin(), cand.end(), [&](int a, int b) { return sHabitability[a] > sHabitability[b]; }); + const double minHab = cfg.civMinHabitability, cexp = std::max(0.0, cfg.civClusterExp); + std::vector w(n, 0.0); double total = 0.0; + 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); std::set usedNames; - std::vector chosen; - for (int i : cand) { - if ((int)settlements.size() >= cap) break; - bool ok = true; - for (int c : chosen) if (cells[i].unit.dot(cells[c].unit) > sepCos) { ok = false; break; } - if (!ok) continue; - chosen.push_back(i); - int regId = ((int)sCellLand.size() == n) ? sCellLand[i] : -1; + while ((int)settlements.size() < cap && total > 1e-9) { + double r = rf() * total, acc = 0.0; int pick = -1; + for (int i = 0; i < n; ++i) { if (w[i] <= 0.0) continue; acc += w[i]; if (r <= acc) { pick = i; break; } } + if (pick < 0) break; + int regId = ((int)sCellLand.size() == n) ? sCellLand[pick] : -1; int bank = (regId >= 0) ? namegen::bankForRegion(cfg.seed, regId) - : namegen::bankForRegion(cfg.seed, 2000 + i); - uint32_t nameSeed = next() ^ (uint32_t)(i * 2654435761u); + : namegen::bankForRegion(cfg.seed, 2000 + pick); + uint32_t nameSeed = next() ^ (uint32_t)(pick * 2654435761u); std::string nm = namegen::makeName(nameSeed, bank); for (int g = 0; usedNames.count(nm) && g < 128; ++g) nm = namegen::makeName(nameSeed += 0x9E3779B9u, bank); usedNames.insert(nm); - Settlement st; - st.cell = i; st.bank = bank; st.regionId = regId; + Settlement st; st.cell = pick; st.bank = bank; st.regionId = regId; st.population = cfg.civSeedPopulation; st.name = nm; 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) { settlements[k].id = (uint32_t)(k + 1); diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 9d8e133..95124ed 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -49,7 +49,7 @@ D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \ D(volcanoAshPuffCellsPerWeek) D(volcanoAshCloud) D(volcanoAshCooling) \ 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(civHabWaterWeight) D(civHabFoodWeight) D(civHabTempOpt) D(civHabElevPenalty) \ 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.geoOceanSepRadians, 0.05, 3.14159, "geoOceanSepRadians")); 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.civSeedPopulation, 1.0, 1.0e9, "civSeedPopulation")); E(rng(cfg.civGrowthRate, 0.0, 100.0, "civGrowthRate")); diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index 161f2dc..ecdc19d 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -383,7 +383,10 @@ struct PlanetConfig { // Live World clock toward a food-driven carrying capacity. Habitability blends climate comfort, // water access and food (flora/fauna + ecoregion productivity). 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 civSeedPopulation = 250.0; // initial village population at placement double civGrowthRate = 0.02; // logistic growth rate per year (toward carrying capacity) diff --git a/test_civ.cpp b/test_civ.cpp index 7a73b58..734fb0c 100644 --- a/test_civ.cpp +++ b/test_civ.cpp @@ -61,21 +61,34 @@ int main() { const auto& S = p.settlements; check(!S.empty(), "settlements placed"); bool onLand = true, aboveMin = true, capOk = (int)S.size() <= p.cfg.civMaxSettlements; - std::set names; bool uniqueNames = true; - const double sepCos = std::cos(p.cfg.civMinSpacingRadians); - bool spaced = true; + std::set names; bool uniqueNames = true; std::set 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; - for (size_t b = a + 1; b < S.size(); ++b) - if (p.cells[S[a].cell].unit.dot(p.cells[S[b].cell].unit) > sepCos + 1e-9) spaced = false; - if (p.cellSettlement()[S[a].cell] != (int)a) onLand = false; // index consistency + 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 } - std::printf(" %d settlements\n", (int)S.size()); - check(onLand, "settlements sit on land + cellSettlement index is consistent"); + // 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(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(uniqueNames, "settlement names are unique + non-empty");