Seed multiple cultures per continent instead of always exactly one

A continent used to always seed exactly one culture at the dawn
(seedCultures(), grouped by regionId), which -- combined with the existing
border-conversion/backfill dynamics -- tended to erode into one
super-dominant culture covering the entire world over a long simulated run.

seedCultures() now splits each continent into 1..cultDawnMaxPerContinent
initial peoples via deterministic farthest-point seeding (a settlement is
assigned to its nearest seed point, giving geographically coherent,
Voronoi-like cultural regions instead of a checkerboard). The target count
scales with the continent's settlement population via the new
cultDawnSettlementsPerCulture config knob, capped by cultDawnMaxPerContinent.
Per-culture ethos/faith/name hashes now fold in Culture.id so multiple
cultures sharing a continent (and therefore the same regionId/bank) get
distinct identities instead of colliding.

This surfaced a real, previously-latent ordering bug: computeTerritory()
(realm/vassalage grouping, which prefers matching by actual culture and only
falls back to same-continent when culture data doesn't exist yet) ran BEFORE
computeCultures() populated per-settlement culture, so on every fresh
recompute realms formed by continent for one full pass before the next
recompute split them correctly. Harmless before this change (one culture per
continent made the two equivalent), but would have left temporarily
multi-cultural realms now. Fixed at the root: split computeCultures() into
refreshSettlementCultures() (the settlement-level seed/backfill/tally slice,
no dependency on `nations`) and had computeTerritory() call it internally
before grouping -- every caller (production and the dozens of existing test
call sites) gets correct behaviour automatically, no call-site changes needed
beyond the two tests whose assertions encoded the old exact one-per-continent
invariant.

test_culture.cpp and test_cultevo.cpp updated to assert the new bounded
(1..cultDawnMaxPerContinent) invariant instead of exact equality, plus that a
single culture never itself spans two continents (still enforced).

Verified live: a single continent now shows six distinct peoples ("the
Thisur", "the Bomebro", "the Draordaes", etc.) instead of one dominant
culture, while realms remain correctly mono-cultural.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE
This commit is contained in:
Jonas Reith 2026-08-30 22:15:12 +02:00
parent a9ea113f97
commit cd268167b0
8 changed files with 176 additions and 57 deletions

View File

@ -892,7 +892,7 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
// Recompute realms/territory + cultures from the (derived) settlement set and rebuild the border
// segments (political + cultural). Cultures depend on territory, so compute them right after.
void Viewer::rebuildTerritory() {
planet.computeTerritory();
planet.computeTerritory(); // refreshes per-settlement culture itself before grouping into realms
planet.computeCultures();
planet.computeTrade(); // civ Step 7: trade links + prosperity (derived)
buildNationBorders(planet, borderR, nationBorders);

View File

@ -212,12 +212,22 @@ public:
const std::vector<int>& settleNation() const { return sSettleNation; } // nation index per settlement (-1 = dead)
// Cultures, beliefs & governments (PlanetCulture.cpp). computeCultures() is the DERIVED refresh:
// it seeds the cultures once (one per inhabited continent) when none exist, then only recomputes
// tallies, governments (folded into nation.name) and the per-cell culture view. Runs AFTER
// computeTerritory() (reads nations/sCellNation/sCellSettleOwner). Culture identities + the
// per-settlement culture are STATEFUL since Step 8 (saved v23 + snapshotted); stepCulture() is
// the once-per-sim-year mutation pass (border conversion / assimilation / schism, pure hashes --
// no RNG touched, so step-back replays it exactly).
// it seeds the cultures once (a continent starts as 1..cultDawnMaxPerContinent peoples, split via
// deterministic farthest-point seeding) when none exist, then only recomputes tallies, governments
// (folded into nation.name) and the per-cell culture view. Runs AFTER computeTerritory() (reads
// nations/sCellNation/sCellSettleOwner). Culture identities + the per-settlement culture are
// STATEFUL since Step 8 (saved v23 + snapshotted); stepCulture() is the once-per-sim-year mutation
// pass (border conversion / assimilation / schism, pure hashes -- no RNG touched, so step-back
// replays it exactly).
// refreshSettlementCultures() is just the settlement-level slice of computeCultures() (seed once
// + sanitize/backfill sSettleCulture + tally) -- no dependency on `nations`. Call it BEFORE
// computeTerritory() so realm/vassalage grouping (which prefers matching by actual culture, only
// falling back to same-continent when culture data doesn't exist yet) sees up-to-date per-
// settlement culture on every call, not just from the second recompute onward -- otherwise a
// continent seeded with several peoples would form one realm per continent for one full pass
// before the next recompute split it correctly. computeCultures() calls this itself too (so it
// stays correct when called on its own), making a second call from here a harmless no-op.
void refreshSettlementCultures();
void computeCultures();
std::vector<WarEvent> stepCulture(long year);
bool culturesBuilt() const { return !cultures.empty(); }

View File

@ -145,26 +145,73 @@ void Planet::seedCultures() {
const double sea = cfg.seaLevel, abP = cfg.civAbandonPop;
const uint32_t seed = cfg.seed ? cfg.seed : 1u;
// 1) Group living settlements into cultures.
std::unordered_map<int, int> keyToCulture;
// 1) Group living settlements by continent, then split each continent into 1..cultDawnMaxPerContinent
// initial peoples via deterministic farthest-point seeding (geographically coherent, like a
// Voronoi partition) instead of always one culture per continent -- without this, a populous
// continent starts as a single culture that years of border conversion + the settlement-culture
// backfill (both pre-existing mechanics) tend to erode into one super-dominant people covering
// the whole world, leaving little cultural diversity.
auto living = [&](const Settlement& s) {
return s.cell >= 0 && s.cell < n && s.population >= abP;
};
std::unordered_map<int, std::vector<size_t>> continentMembers;
for (size_t k = 0; k < settlements.size(); ++k) {
const Settlement& s = settlements[k];
if (!living(s)) continue;
int key = (s.regionId >= 0) ? s.regionId : (-1 - s.bank);
auto it = keyToCulture.find(key);
continentMembers[key].push_back(k);
}
if (continentMembers.empty()) return;
// Process continents in a deterministic (sorted-key) order -- unordered_map iteration order is
// not guaranteed, and culture creation order feeds sCultureNextId/cultures indices, which must
// stay reproducible for a given world/seed.
std::vector<int> contKeys; contKeys.reserve(continentMembers.size());
for (const auto& kv : continentMembers) contKeys.push_back(kv.first);
std::sort(contKeys.begin(), contKeys.end());
auto angDist = [&](int ca, int cb) {
return std::acos(std::clamp(cells[ca].unit.dot(cells[cb].unit), -1.0, 1.0));
};
const int perCulture = std::max(1, cfg.cultDawnSettlementsPerCulture);
const int maxPerCont = std::max(1, cfg.cultDawnMaxPerContinent);
for (int key : contKeys) {
const std::vector<size_t>& members = continentMembers[key];
int target = std::clamp((int)std::llround((double)members.size() / perCulture), 1, maxPerCont);
// Farthest-point seeding: first seed is a deterministic hash pick, then each further seed is
// whichever remaining member maximises its distance to the nearest already-chosen seed --
// spreads the initial peoples out across the continent instead of clustering them together.
std::vector<size_t> seeds;
seeds.push_back(members[cultHash((uint32_t)key * 2654435761u ^ seed ^ 0x5EED0u) % members.size()]);
while ((int)seeds.size() < target && (int)seeds.size() < (int)members.size()) {
size_t best = members[0]; double bestD = -1.0;
for (size_t m : members) {
if (std::find(seeds.begin(), seeds.end(), m) != seeds.end()) continue;
double dMin = 1e18;
for (size_t sd : seeds) dMin = std::min(dMin, angDist(settlements[m].cell, settlements[sd].cell));
if (dMin > bestD) { bestD = dMin; best = m; }
}
seeds.push_back(best);
}
// Assign every member to its nearest seed -> one geographically coherent culture per seed.
std::unordered_map<size_t, int> seedToCulture;
for (size_t m : members) {
size_t bestSeed = seeds[0]; double bestD = 1e18;
for (size_t sd : seeds) {
double d = angDist(settlements[m].cell, settlements[sd].cell);
if (d < bestD) { bestD = d; bestSeed = sd; }
}
auto it = seedToCulture.find(bestSeed);
int ci;
if (it == keyToCulture.end()) {
if (it == seedToCulture.end()) {
ci = (int)cultures.size();
Culture cu; cu.id = sCultureNextId++; cu.regionId = s.regionId; cu.bank = s.bank;
Culture cu; cu.id = sCultureNextId++; cu.regionId = (key >= 0) ? key : -1;
cu.bank = settlements[bestSeed].bank;
cultures.push_back(cu);
keyToCulture[key] = ci;
seedToCulture[bestSeed] = ci;
} else ci = it->second;
sSettleCulture[k] = ci;
sSettleCulture[m] = ci;
cultures[ci].members++;
cultures[ci].totalPop += s.population;
cultures[ci].totalPop += settlements[m].population;
}
}
if (cultures.empty()) return;
@ -175,10 +222,13 @@ void Planet::seedCultures() {
envAdd(acc[ci], cells, sMoist, sea, settlements[k].cell);
}
// 3) Per-culture ethos, faith and names (all deterministic hashes of the region + seed).
// 3) Per-culture ethos, faith and names (all deterministic hashes of the region + seed). `cu.id`
// is folded into the region key so multiple cultures sharing the same continent (and therefore
// the same regionId/bank) still get distinct ethos/faith/name hashes instead of identical ones.
for (size_t ci = 0; ci < cultures.size(); ++ci) {
Culture& cu = cultures[ci];
uint32_t rk = (uint32_t)(cu.regionId >= 0 ? cu.regionId : (1000 - cu.bank)) + 1u;
uint32_t rk = cultHash(((uint32_t)(cu.regionId >= 0 ? cu.regionId : (1000 - cu.bank)) + 1u)
^ (cu.id * 2654435761u));
envPick(acc[ci], seed, rk, cu.members, cu.ethos, cu.faith);
uint32_t nameSeed = seed ^ cultHash(rk * 2654435761u + 0x50C1A1u);
uint32_t faithSeed = seed ^ cultHash(rk * 40503u + 0xFA17Fu);
@ -187,19 +237,19 @@ void Planet::seedCultures() {
}
}
// The DERIVED refresh: seeds once when no cultures exist, then only recomputes tallies, governments
// and the per-cell culture view from the (stateful) per-settlement culture. Safe to call on every
// territory rebuild / load / step-back -- it never reassigns a settlement's culture (only sanitizes
// out-of-range entries and backfills unset living ones from the nearest cultured neighbour).
void Planet::computeCultures() {
// The settlement-level slice of the derived refresh: seeds once when no cultures exist, then only
// sanitizes/backfills sSettleCulture and retallies member counts -- no dependency on `nations`, so
// it's safe to call before computeTerritory() (see the Planet.hpp comment for why that ordering
// matters now that a continent can seed several cultures instead of always exactly one). Never
// reassigns a settlement's existing culture (only sanitizes out-of-range entries and backfills
// unset living ones from the nearest cultured neighbour).
void Planet::refreshSettlementCultures() {
const int n = (int)cells.size();
sCellCulture.assign(n, -1);
if (settlements.empty()) { sSettleCulture.clear(); return; }
if (cultures.empty()) seedCultures();
if (cultures.empty()) return; // no living settlement yet -> seeded on a later refresh
const double abP = cfg.civAbandonPop;
const uint32_t seed = cfg.seed ? cfg.seed : 1u;
auto living = [&](size_t k) {
const Settlement& s = settlements[k];
return s.cell >= 0 && s.cell < n && s.population >= abP;
@ -228,6 +278,19 @@ void Planet::computeCultures() {
cultures[ci].members++;
cultures[ci].totalPop += settlements[k].population;
}
}
// The DERIVED refresh: recomputes governments and the per-cell culture view from the (stateful)
// per-settlement culture (refreshed via refreshSettlementCultures() -- called here too, so this
// stays correct when called on its own, e.g. from tests). Safe to call on every territory rebuild /
// load / step-back.
void Planet::computeCultures() {
const int n = (int)cells.size();
sCellCulture.assign(n, -1);
refreshSettlementCultures();
if (settlements.empty() || cultures.empty()) return;
const uint32_t seed = cfg.seed ? cfg.seed : 1u;
// Government per realm (from tier + a deterministic pick) + fold it into the realm's name, and
// tag the realm with its capital's culture. The realm's "place name" is independently generated

View File

@ -77,6 +77,7 @@
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) I(warMaxConcurrent) I(civMaxColonies) \
I(cultSchismMinMembers) I(cultSchismMinCluster) \
I(cultDawnSettlementsPerCulture) I(cultDawnMaxPerContinent) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed)
@ -355,6 +356,8 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.cultSchismRange, 0.0, 3.14159, "cultSchismRange"));
E(irng(cfg.cultSchismMinCluster, 1, 1000000, "cultSchismMinCluster"));
E(rng(cfg.cultSchismRate, 0.0, 1.0, "cultSchismRate"));
E(irng(cfg.cultDawnSettlementsPerCulture, 1, 1000000, "cultDawnSettlementsPerCulture"));
E(irng(cfg.cultDawnMaxPerContinent, 1, 1000, "cultDawnMaxPerContinent"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -385,6 +388,13 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.bioFungaPoints, 1, 100000, "bioFungaPoints"));
E(irng(cfg.bioMarineCoastRings, 1, 100, "bioMarineCoastRings"));
if (cfg.subdivisions >= 0 && cfg.subdivisions <= 7) {
size_t cellCount = 10;
for (int i = 0; i < cfg.subdivisions; ++i) cellCount *= 4;
cellCount += 2;
if (cfg.plateCount > (int)cellCount)
bad.push_back("plateCount exceeds the number of cells at this subdivision level");
}
if (cfg.oceanBase >= cfg.continentBase)
bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)");
if (cfg.peakSoftCapStart >= cfg.peakSoftCapEnd)

View File

@ -19,6 +19,15 @@ const char* nationTierName(NationTier t) {
}
void Planet::computeTerritory() {
// Refresh per-settlement culture BEFORE grouping into realms below -- vassalage grouping prefers
// matching by actual culture (only falling back to same-continent when culture data doesn't
// exist yet), and since a continent can seed several distinct cultures (PlanetCulture.cpp), this
// needs up-to-date sSettleCulture on every call, not just from the second recompute onward, or a
// freshly-seeded multi-culture continent would form one realm per continent for a full pass
// before the next recompute split it correctly. No dependency the other way: this only touches
// settlements/cultures, never `nations`, so it's safe to run before the rest of this function
// rebuilds `nations` from scratch.
refreshSettlementCultures();
const int n = (int)cells.size();
nations.clear();
sCellNation.assign(n, -1);

View File

@ -402,9 +402,9 @@ struct PlanetConfig {
double weatherHurricaneStr= 0.6; // strength above which a tropical system is a hurricane/typhoon
// --- Volcanoes (Live World) -- see PlanetVolcano.cpp ------------------------
// Placed once on entering Live World by tectonic context, then erupt on the live clock
// (build height + eruption intensity are pure functions of liveTime, so step-back reverses
// them). Submarine volcanoes build up to breach sea level into new volcanic islands.
// Placed once on entering Live World by tectonic context, then evolve statefully on the live
// clock (step-back snapshots preserve/reverse that state). Submarine volcanoes build up to
// breach sea level into new volcanic islands.
double volcanoProbRidge = 0.55; // per-cell placement prob on a young spreading-ridge cell
double volcanoProbBorder = 0.06; // per-cell placement prob on a normal plate-border cell
double volcanoProbInterior = 0.003; // per-cell placement prob elsewhere (intraplate hotspots)
@ -557,4 +557,9 @@ struct PlanetConfig {
double cultSchismRange = 0.55; // rad from the culture's population centroid past which members are "distant"
int cultSchismMinCluster = 2; // distant settlements needed to break away together
double cultSchismRate = 0.08; // per-year chance a qualifying distant cluster becomes a new people
// Dawn seeding (seedCultures()): a populous continent starts as SEVERAL distinct peoples instead
// of one continent-spanning culture -- without this, decades of border conversion/backfill tend
// to erode a single per-continent culture into one super-dominant people across the whole world.
int cultDawnSettlementsPerCulture = 5; // roughly this many dawn settlements per initial culture on a continent
int cultDawnMaxPerContinent = 6; // hard cap on how many initial peoples one continent can seed
};

View File

@ -20,6 +20,7 @@
#include <cmath>
#include <algorithm>
#include <set>
#include <map>
#include <sstream>
static int failures = 0;
@ -64,20 +65,25 @@ int main() {
const int n = (int)p.cells.size();
const double yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays;
std::printf("Cultural evolution: seeding parity (frozen one-per-continent identities)\n");
std::printf("Cultural evolution: seeding parity (frozen dawn identities)\n");
p.placeSettlements();
double lt = 0.0; for (int yr = 0; yr < 600; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); }
p.computeTerritory(); p.computeCultures();
{
check(!p.cultureList().empty(), "cultures seeded at the dawn");
// One culture per distinct key (continent regionId / -1-bank fallback) among living settlements.
std::set<int> keys;
// Each continent (regionId / -1-bank fallback) seeds 1..cultDawnMaxPerContinent cultures now,
// not always exactly one -- check the count stays within that bound instead.
std::map<int, std::set<int>> keyToCults;
for (size_t k = 0; k < p.settlements.size(); ++k) {
const Settlement& s = p.settlements[k];
if (s.population < p.cfg.civAbandonPop) continue;
keys.insert(s.regionId >= 0 ? s.regionId : (-1 - s.bank));
int ci = p.settleCulture()[k]; if (ci < 0) continue;
keyToCults[s.regionId >= 0 ? s.regionId : (-1 - s.bank)].insert(ci);
}
check(p.cultureList().size() == keys.size(), "one culture per inhabited continent");
bool boundedDiversity = true;
for (auto& kv : keyToCults)
if ((int)kv.second.size() < 1 || (int)kv.second.size() > p.cfg.cultDawnMaxPerContinent) boundedDiversity = false;
check(boundedDiversity, "each continent seeds 1..cultDawnMaxPerContinent cultures");
bool rootsOk = true, idsOk = true;
for (size_t i = 0; i < p.cultureList().size(); ++i) {
const Culture& cu = p.cultureList()[i];
@ -162,6 +168,21 @@ int main() {
check(capsStable, "realm capitals never convert via border pressure");
}
std::printf("Cultural evolution: locked clusters cannot create empty schisms\n");
{
Planet locked = p; quietCulture(locked);
locked.cfg.cultSchismRate = 1.0; locked.cfg.cultSchismRange = 0.10;
locked.cfg.cultSchismMinMembers = 3; locked.cfg.cultSchismMinCluster = 1;
for (int s = 0; s < (int)locked.settlements.size(); ++s) locked.setCultureLock(s, true);
size_t before = locked.cultureList().size();
int breakEvents = 0;
for (long yr = 0; yr < 20; ++yr)
for (const WarEvent& e : civYear(locked, yr))
if (e.kind == 7 && e.title.find("break away from") != std::string::npos) ++breakEvents;
check(locked.cultureList().size() == before && breakEvents == 0,
"an entirely locked cluster creates neither a child culture nor a false event");
}
std::printf("Cultural evolution: schism (a distant cluster becomes a new people)\n");
long schismYear = -1;
{
@ -307,7 +328,7 @@ int main() {
bool allRoots = true;
for (const Culture& cu : r.cultureList())
if (cu.parentId != -1 || cu.foundedYear != -1) allRoots = false;
check(allRoots, "re-seeded cultures are one-per-continent roots");
check(allRoots, "re-seeded cultures are roots (dawn peoples, no parent)");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CULTURAL-EVOLUTION CHECKS PASSED\n", failures);

View File

@ -9,7 +9,8 @@
// src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp
// src/sim/PlanetNation.cpp src/sim/PlanetCulture.cpp src/sim/PlanetIO.cpp -o /tmp/tc && /tmp/tc
//
// Verifies: one culture per inhabited continent; every living settlement has a culture; valid
// Verifies: each continent seeds 1..cultDawnMaxPerContinent distinct cultures (never spanning two
// continents); every living settlement has a culture; valid
// ethos/faith; governments plausible per tier + reflected in the realm name; realms are mono-cultural;
// determinism + RNG isolation; save->load->recompute parity.
@ -55,33 +56,33 @@ int main() {
check(!p.cultureList().empty(), "computeCultures produces cultures");
check((int)p.cellCulture().size() == n && (int)p.settleCulture().size() == (int)p.settlements.size(), "index arrays sized");
std::printf("Culture: one culture per inhabited continent\n");
std::printf("Culture: continents seed 1..cultDawnMaxPerContinent cultures, never crossing continents\n");
{
// The set of distinct grouping keys among living settlements == the number of cultures, and
// every living settlement's key maps to exactly one culture (and vice versa).
std::set<int> keys;
std::map<int,int> keyToCult; // grouping key -> culture index (must be consistent)
bool consistent = true, allLiving = true;
// A continent (grouping key) may now seed several distinct cultures (farthest-point seeding,
// bounded by cultDawnMaxPerContinent) instead of always exactly one -- but a single culture
// must never itself span two continents.
std::map<int, std::set<int>> keyToCults; // continent key -> set of culture indices seen there
std::map<int, int> cultToKey; // culture index -> the one continent key it belongs to
bool singleContinent = true, allLiving = true;
for (size_t k = 0; k < p.settlements.size(); ++k) {
const Settlement& s = p.settlements[k];
if (s.population < abP) continue;
int ci = p.settleCulture()[k];
if (ci < 0 || ci >= (int)p.cultureList().size()) { allLiving = false; continue; }
int key = cultKey(s);
keys.insert(key);
auto it = keyToCult.find(key);
if (it == keyToCult.end()) keyToCult[key] = ci;
else if (it->second != ci) consistent = false; // same continent -> must be same culture
keyToCults[key].insert(ci);
auto it = cultToKey.find(ci);
if (it == cultToKey.end()) cultToKey[ci] = key;
else if (it->second != key) singleContinent = false; // a culture must stay on one continent
}
// No two distinct keys share a culture.
std::set<int> usedCults;
bool distinct = true;
for (auto& kv : keyToCult) { if (usedCults.count(kv.second)) distinct = false; usedCults.insert(kv.second); }
std::printf(" %d cultures, %d inhabited continents/keys\n", (int)p.cultureList().size(), (int)keys.size());
bool boundedDiversity = true;
for (auto& kv : keyToCults)
if ((int)kv.second.size() < 1 || (int)kv.second.size() > p.cfg.cultDawnMaxPerContinent) boundedDiversity = false;
std::printf(" %d cultures across %d inhabited continents/keys\n",
(int)p.cultureList().size(), (int)keyToCults.size());
check(allLiving, "every living settlement has a valid culture");
check(consistent, "settlements on the same continent share one culture");
check(distinct, "no two continents share a culture");
check((int)p.cultureList().size() == (int)keys.size(), "exactly one culture per inhabited continent");
check(singleContinent, "every culture's members all belong to one continent");
check(boundedDiversity, "each continent seeds 1..cultDawnMaxPerContinent cultures");
}
std::printf("Culture: valid ethos / faith / members\n");