Add two more culture-schism triggers: world dominance and war between kin

Reported after a long run left the world under one dominant culture -- the
existing schism only fires for a geographically distant cluster (overseas
colonies), so a single unbroken landmass had nothing to check one culture
quietly absorbing the whole world via border conversion.

Two new independent triggers, both refactored to share the existing schism
machinery via a doSchism() helper:
- Dominance schism: past cultDominanceShare (80%) of the world's living
  population, a per-year chance rising from cultDominanceProbMin (1%) to
  cultDominanceProbMax (99%) at 100% share that the culture fractures on
  its own -- a coin flip splits off half or a fifth of its own members,
  the ones farthest from its own population-weighted core.
- War schism: a war between two realms sharing a culture has
  cultWarSchismChance (50%) odds, rolled once at declaration, that one
  whole side renounces the shared identity and becomes a new people.

Both are pure hashes of (stable id, year, seed), so step-back replays them
exactly, matching the rest of Step 8. Config is the self-describing text
block, so no save-version bump.

Dropped an initial validateConfig() cross-rule requiring
cultDominanceProbMin <= cultDominanceProbMax after finding it would reject
a very natural "disable via Max=0" edit (e.g. from the Main Menu) whenever
Min was left at its default -- silently reverting a player's entire config
to defaults on New World. The interpolation tolerates the reverse fine
since the mechanic is already gated on Max > 0.

test_cultevo.cpp gains two new scenarios (dominance split, war split)
alongside the existing distant-cluster one; quietCulture() now also zeros
the two new rates.

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-31 20:16:51 +02:00
parent 69f3aa2d22
commit b1175c6e54
5 changed files with 260 additions and 45 deletions

View File

@ -584,6 +584,33 @@ on the Live World clock). **Steps 17 of the roadmap are done (plus a derived
`planet.cfg`+`F2` or the Main Menu's Settings tab, only take effect through a regenerate — there is `planet.cfg`+`F2` or the Main Menu's Settings tab, only take effect through a regenerate — there is
currently no way to push a single global field like `seaLevel` into an already-running world currently no way to push a single global field like `seaLevel` into an already-running world
without starting over); the practical fix for an affected world is a New World. without starting over); the practical fix for an affected world is a New World.
- **Two more culture-schism triggers: world dominance + war between kin (was: only a distant-cluster
schism)** *(done — see `Planet::stepCulture()`, `src/sim/PlanetCulture.cpp`)* — reported after a
long run left the world under a single "world-dominant" culture (the existing schism only fires for
a geographically distant cluster, e.g. overseas colonies — a single unbroken landmass never
qualified, so one culture could quietly absorb the whole world via border conversion with nothing
to check it). Two new independent triggers, both sharing the existing schism machinery (now
factored into a `doSchism()` lambda so all three read identically): **dominance schism** — once a
culture exceeds `cultDominanceShare` (80%) of the world's living population, a per-year chance
rising from `cultDominanceProbMin` (1%) at the threshold to `cultDominanceProbMax` (99%) at 100%
share that it fractures on its own, **no geography required** — a coin flip picks **half or a
fifth** of its own members, the ones farthest from its own population-weighted cultural core, to
found a new people; **war schism** — a war between two realms that happen to share a culture (a
"civil war") has `cultWarSchismChance` (50%) odds, rolled once right at declaration, that one whole
side (a coin flip between attacker/defender) renounces the shared identity, its entire realm's
settlements becoming a new people — checked against wars with `startYear` equal to the current
year, so it only ever looks at a war on the exact year it was declared. Both are pure hashes of
(stable id, year, seed) like the rest of Step 8, so step-back replays them exactly. New config
fields `cultDominanceShare`/`cultDominanceProbMin`/`cultDominanceProbMax`/`cultWarSchismChance`
(self-describing config, **no save-version bump**). One design pitfall found while adding these:
an initial `validateConfig()` cross-rule requiring `cultDominanceProbMin <= cultDominanceProbMax`
was removed again — the interpolation tolerates the reverse fine (the whole mechanic is already
gated on `cultDominanceProbMax > 0`), and the rule would have rejected the very natural "disable by
setting Max to 0" pattern (e.g. from the Main Menu's Settings tab) whenever `Min` was left at its
nonzero default, silently reverting a player's *entire* config to defaults on New World — a bad
surprise for an edit that looks perfectly reasonable. `test_cultevo.cpp` gained two new scenarios
(force every settlement onto one culture and watch it split near-deterministically; inject a
same-culture war and check the split) alongside the existing distant-cluster one.
## Current state ## Current state
@ -1459,7 +1486,11 @@ triangles (plates are fixed in phase 1).
cultural weight — rich hubs radiate culture); schism — `cultSchismMinMembers` (6, min living settlements cultural weight — rich hubs radiate culture); schism — `cultSchismMinMembers` (6, min living settlements
to schism), `cultSchismRange` (0.55 rad from the population centroid past which members are "distant" — to schism), `cultSchismRange` (0.55 rad from the population centroid past which members are "distant" —
lower = easier colonial breakaways), `cultSchismMinCluster` (2, distant settlements needed to break away lower = easier colonial breakaways), `cultSchismMinCluster` (2, distant settlements needed to break away
together), `cultSchismRate` (0.08, per-year chance per qualifying culture). All rates 0 = the static together), `cultSchismRate` (0.08, per-year chance per qualifying culture); dominance schism —
`cultDominanceShare` (0.80, world population share above which it can trigger), `cultDominanceProbMin`
(0.01, per-year chance right at the threshold) / `cultDominanceProbMax` (0.99, at 100% share — the
chance ramps linearly between the two); war schism — `cultWarSchismChance` (0.50, chance a war
between two same-culture realms splits one side, rolled once at declaration). All rates 0 = the static
pre-Step-8 world. Runs in the yearly `stepCulture` tick (pure hashes, no RNG); the culture list is pre-Step-8 world. Runs in the yearly `stepCulture` tick (pure hashes, no RNG); the culture list is
capped at 64 peoples. Dawn seeding (`Planet::seedCultures()`, before Step 8 evolution runs at all): capped at 64 peoples. Dawn seeding (`Planet::seedCultures()`, before Step 8 evolution runs at all):
`cultDawnSettlementsPerCulture` (5, target dawn settlements per initial culture on a continent — `cultDawnSettlementsPerCulture` (5, target dawn settlements per initial culture on a continent —

View File

@ -14,9 +14,11 @@
// - computeCultures() the DERIVED refresh (tallies, governments, per-cell view). Runs on every // - computeCultures() the DERIVED refresh (tallies, governments, per-cell view). Runs on every
// territory rebuild / load / step-back, so it must never mutate identity state. // territory rebuild / load / step-back, so it must never mutate identity state.
// - stepCulture() the once-per-sim-year mutation pass: assimilation (conquered settlements adopt // - stepCulture() the once-per-sim-year mutation pass: assimilation (conquered settlements adopt
// their ruler's culture), border conversion (dominant foreign cultural pressure) // their ruler's culture), border conversion (dominant foreign cultural pressure),
// and schism (a far-flung cluster becomes a new people). Pure hashes of // and three independent schism triggers -- a far-flung cluster (colonies), a
// (stable id, year, seed) -- no RNG touched, so step-back replays it exactly. // culture that has grown to dominate most of the world's population, and a war
// between two realms of the same culture. Pure hashes of (stable id, year, seed)
// -- no RNG touched, so step-back replays it exactly.
// The culture list is append-only and records are immutable after creation (indices/colors stay // The culture list is append-only and records are immutable after creation (indices/colors stay
// stable; extinct cultures keep their slot); the list + sSettleCulture are saved (v23) + snapshotted. // stable; extinct cultures keep their slot); the list + sSettleCulture are saved (v23) + snapshotted.
@ -350,14 +352,21 @@ void Planet::computeCultures() {
} }
// --- Civilization Step 8: the yearly cultural-evolution pass ---------------------------------------- // --- Civilization Step 8: the yearly cultural-evolution pass ----------------------------------------
// Three passes, all pure hashes of (stable id, year, seed) so a step-back replays them exactly: // All passes are pure hashes of (stable id, year, seed) so a step-back replays them exactly:
// A) assimilation -- a settlement held by a foreign-culture overlord adopts the ruler's culture // A) assimilation -- a settlement held by a foreign-culture overlord adopts the ruler's culture
// (the Step-5 revolt cultBonus then drops on its own: assimilation pacifies provinces); // (the Step-5 revolt cultBonus then drops on its own: assimilation pacifies provinces);
// B) border conversion -- a settlement dwarfed by a nearby foreign culture's weight (population + // B) border conversion -- a settlement dwarfed by a nearby foreign culture's weight (population +
// trade prestige) converts toward it (realm capitals are exempt: they anchor identity); // trade prestige) converts toward it (realm capitals are exempt: they anchor identity);
// C) schism -- a large culture's far-flung coherent cluster (overseas colonies) breaks away as a // C) schism -- a large culture splits off a NEW people (fresh name, ethos/faith re-derived from its
// NEW people: a fresh name from the local language bank, ethos/faith re-derived from its own // own lands, appended to `cultures`; step-back truncates the list and replay re-creates it), via
// lands (appended to `cultures`; step-back truncates the list and replay re-creates it). // any of three independent triggers, all sharing one doSchism() helper:
// C1) a far-flung coherent cluster (overseas colonies) breaks away past cultSchismRange;
// C2) once a culture exceeds cultDominanceShare of the world's population, a rising per-year
// chance (cultDominanceProbMin..Max) that half or a fifth of it (whichever the coin flip
// picks), the members farthest from its own cultural core, secede on their own -- no
// geography required, so one people can't quietly conquer the whole world by attrition;
// C3) a war between two realms that share a culture has cultWarSchismChance odds, checked right
// at declaration, that one whole side renounces the shared identity -- kin stop being kin.
std::vector<WarEvent> Planet::stepCulture(long year) { std::vector<WarEvent> Planet::stepCulture(long year) {
std::vector<WarEvent> ev; std::vector<WarEvent> ev;
const int n = (int)cells.size(); const int n = (int)cells.size();
@ -436,18 +445,81 @@ std::vector<WarEvent> Planet::stepCulture(long year) {
} }
} }
// C) Schism: a distant coherent cluster of a large culture becomes a new people. // C) Schism: a large culture splits into a new people, via three independent triggers -- a
const size_t ncult = cultures.size(); // iterate the pre-pass list (children append) // coherent distant cluster (colonies), overwhelming world dominance, or a war between kin.
if (cfg.cultSchismRate > 0.0 && (int)ncult < 64) { const size_t ncult = cultures.size(); // iterate the pre-pass list (children append, never re-split)
if ((int)ncult < 64 && (cfg.cultSchismRate > 0.0 || cfg.cultDominanceProbMax > 0.0 || cfg.cultWarSchismChance > 0.0)) {
// Shared setup: per-culture living member count + population-weighted centroid, used by both
// C1 (distance from the core) and C2 (farthest members when splitting off by dominance).
std::vector<int> memCount(ncult, 0); std::vector<int> memCount(ncult, 0);
std::vector<Vec3> cSum(ncult); std::vector<Vec3> cSum(ncult);
double totalLivingPop = 0.0;
for (size_t s = 0; s < ns; ++s) { for (size_t s = 0; s < ns; ++s) {
if (!living(s)) continue; if (!living(s)) continue;
int ci = sSettleCulture[s]; if (ci < 0 || ci >= (int)ncult) continue; int ci = sSettleCulture[s]; if (ci < 0 || ci >= (int)ncult) continue;
memCount[ci]++; memCount[ci]++;
cSum[ci] = cSum[ci] + unitOf(s) * std::max(1.0, settlements[s].population); cSum[ci] = cSum[ci] + unitOf(s) * std::max(1.0, settlements[s].population);
totalLivingPop += settlements[s].population;
} }
const double range = std::max(1e-6, cfg.cultSpreadRange); const double range = std::max(1e-6, cfg.cultSpreadRange);
// Creates a new culture from `cluster` (settlement indices, all currently culture `ci`),
// re-deriving ethos/faith from its own lands and a fresh name (keeping the parent's faith name
// if the faith is unchanged); logs a kind=7 event. Shared by all three triggers below.
auto doSchism = [&](int ci, const std::vector<int>& cluster, const std::string& verb,
const std::string& detail, int eventCell) {
// Copy the parent fields we need BEFORE the push_back (it invalidates references).
const uint32_t parId = cultures[ci].id;
const std::string parName = cultures[ci].name;
const Faith parFaith = cultures[ci].faith;
const std::string parFaithName = cultures[ci].faithName;
const int parBank = cultures[ci].bank;
// Majority regionId of the cluster (ties resolve by cluster order -- deterministic).
std::unordered_map<int, int> regCount; int regBest = -1, regBestN = 0;
for (int s : cluster) {
int c = ++regCount[settlements[s].regionId];
if (c > regBestN) { regBestN = c; regBest = settlements[s].regionId; }
}
Culture child;
child.id = sCultureNextId++;
child.parentId = (int)parId;
child.foundedYear = year;
child.regionId = regBest;
child.bank = (regBest >= 0) ? namegen::bankForRegion(cfg.seed, regBest) : parBank;
CultEnv env;
for (int s : cluster) envAdd(env, cells, sMoist, cfg.seaLevel, settlements[s].cell);
uint32_t rk = child.id * 0x9E3779B9u + 0xC41Du;
envPick(env, seed, rk, (int)cluster.size(), child.ethos, child.faith);
uint32_t nameSeed = seed ^ cultHash(rk * 2654435761u + 0x50C1A1u);
std::string root = namegen::makeName(nameSeed, child.bank);
auto usedName = [&](const std::string& r) {
for (const Culture& c : cultures) if (c.name == "the " + r) return true; return false;
};
for (int g = 0; usedName(root) && g < 128; ++g)
root = namegen::makeName(nameSeed += 0x9E3779B9u, child.bank);
child.name = "the " + root;
child.faithName = (child.faith == parFaith)
? parFaithName
: makeFaithName(seed ^ cultHash(rk * 40503u + 0xFA17Fu), child.bank);
int childIdx = (int)cultures.size();
for (int s : cluster) {
int sc = settlements[s].cell;
if (sc >= 0 && sc < n && (cells[sc].editLock & LockCulture)) continue; // edit mode: pinned
sSettleCulture[s] = childIdx; converted[s] = 1;
}
std::string title = child.name + " " + verb + " " + parName;
title[0] = (char)std::toupper((unsigned char)title[0]);
ev.push_back(WarEvent{ 2, eventCell, title, detail, 7 });
cultures.push_back(std::move(child));
};
// C1) A distant coherent cluster of a large culture (typically overseas colonies) breaks away.
if (cfg.cultSchismRate > 0.0) {
for (size_t ci = 0; ci < ncult && (int)cultures.size() < 64; ++ci) { for (size_t ci = 0; ci < ncult && (int)cultures.size() < 64; ++ci) {
if (memCount[ci] < std::max(1, cfg.cultSchismMinMembers)) continue; if (memCount[ci] < std::max(1, cfg.cultSchismMinMembers)) continue;
if (cSum[ci].length() <= 1e-12) continue; if (cSum[ci].length() <= 1e-12) continue;
@ -474,58 +546,75 @@ std::vector<WarEvent> Planet::stepCulture(long year) {
if (ang(unitOf((size_t)s), unitOf((size_t)far)) <= range) cluster.push_back(s); if (ang(unitOf((size_t)s), unitOf((size_t)far)) <= range) cluster.push_back(s);
if ((int)cluster.size() < std::max(1, cfg.cultSchismMinCluster)) continue; if ((int)cluster.size() < std::max(1, cfg.cultSchismMinCluster)) continue;
if ((int)cluster.size() >= memCount[ci]) continue; // a schism splits, it never renames all if ((int)cluster.size() >= memCount[ci]) continue; // a schism splits, it never renames all
doSchism((int)ci, cluster, "break away from",
// Copy the parent fields we need BEFORE the push_back (it invalidates references). "A distant people drifts apart and names itself anew.", settlements[far].cell);
const uint32_t parId = cultures[ci].id; }
const std::string parName = cultures[ci].name;
const Faith parFaith = cultures[ci].faith;
const std::string parFaithName = cultures[ci].faithName;
const int parBank = cultures[ci].bank;
// Majority regionId of the cluster (ties resolve by cluster order -- deterministic).
std::unordered_map<int, int> regCount; int regBest = -1, regBestN = 0;
for (int s : cluster) {
int c = ++regCount[settlements[s].regionId];
if (c > regBestN) { regBestN = c; regBest = settlements[s].regionId; }
} }
Culture child; // C2) Overwhelming world dominance: past cultDominanceShare of the world's living population, a
child.id = sCultureNextId++; // rising per-year chance (cultDominanceProbMin at the threshold, cultDominanceProbMax at 100%)
child.parentId = (int)parId; // that the culture fractures on its own, no geography required -- half or a fifth of its own
child.foundedYear = year; // members (an even coin flip), the farthest-flung from its own population-weighted core, found
child.regionId = regBest; // a new people. Curbs one culture quietly conquering the whole world by attrition.
child.bank = (regBest >= 0) ? namegen::bankForRegion(cfg.seed, regBest) : parBank; if (cfg.cultDominanceProbMax > 0.0 && totalLivingPop > 0.0) {
for (size_t ci = 0; ci < ncult && (int)cultures.size() < 64; ++ci) {
// Ethos/faith re-derived from the cluster's own lands (hash-salted by the child id so the if (memCount[ci] < 2 || cSum[ci].length() <= 1e-12) continue;
// faith can mutate); keep the parent's faith name if the faith itself is unchanged. double share = cultures[ci].totalPop / totalLivingPop;
CultEnv env; if (share < cfg.cultDominanceShare) continue;
for (int s : cluster) envAdd(env, cells, sMoist, cfg.seaLevel, settlements[s].cell); double t = std::clamp((share - cfg.cultDominanceShare)
uint32_t rk = child.id * 0x9E3779B9u + 0xC41Du; / std::max(1e-6, 1.0 - cfg.cultDominanceShare), 0.0, 1.0);
envPick(env, seed, rk, (int)cluster.size(), child.ethos, child.faith); double prob = cfg.cultDominanceProbMin + (cfg.cultDominanceProbMax - cfg.cultDominanceProbMin) * t;
if (cultHashf(cultures[ci].id * 2654435761u ^ (uint32_t)year * 40503u ^ seed ^ 0xD0111Au)
uint32_t nameSeed = seed ^ cultHash(rk * 2654435761u + 0x50C1A1u); >= prob) continue;
std::string root = namegen::makeName(nameSeed, child.bank); double fraction = (cultHashf(cultures[ci].id * 2654435761u ^ (uint32_t)year * 19349663u
auto usedName = [&](const std::string& r) { ^ seed ^ 0xF4AC7u) < 0.5) ? 0.5 : 0.2;
for (const Culture& c : cultures) if (c.name == "the " + r) return true; return false; Vec3 centroid = cSum[ci].normalized();
}; std::vector<std::pair<double, int>> byDist;
for (int g = 0; usedName(root) && g < 128; ++g) for (size_t s = 0; s < ns; ++s) {
root = namegen::makeName(nameSeed += 0x9E3779B9u, child.bank); if (!living(s) || sSettleCulture[s] != (int)ci) continue;
child.name = "the " + root;
child.faithName = (child.faith == parFaith)
? parFaithName
: makeFaithName(seed ^ cultHash(rk * 40503u + 0xFA17Fu), child.bank);
int childIdx = (int)cultures.size();
for (int s : cluster) {
int sc = settlements[s].cell; int sc = settlements[s].cell;
if (sc >= 0 && sc < n && (cells[sc].editLock & LockCulture)) continue; // edit mode: pinned if (sc >= 0 && sc < n && (cells[sc].editLock & LockCulture)) continue;
sSettleCulture[s] = childIdx; converted[s] = 1; byDist.push_back({ ang(unitOf(s), centroid), (int)s });
}
if ((int)byDist.size() < 2) continue;
std::stable_sort(byDist.begin(), byDist.end(),
[](const auto& a, const auto& b) { return a.first > b.first; }); // farthest first
int want = std::clamp((int)std::llround(byDist.size() * fraction), 1, (int)byDist.size() - 1);
std::vector<int> cluster;
for (int k = 0; k < want; ++k) cluster.push_back(byDist[(size_t)k].second);
doSchism((int)ci, cluster, "splits away from",
"Grown too vast to hold together, the people drift into rival identities.",
settlements[cluster.front()].cell);
}
}
// C3) A war between two realms of the same culture: cultWarSchismChance odds that one side's
// whole realm renounces the shared identity right as the war begins -- a war between kin only
// makes sense once they're no longer kin. Checked against wars declared THIS year only.
if (cfg.cultWarSchismChance > 0.0 && sSettleAllegiance.size() == ns) {
for (const War& w : wars) {
if (w.startYear != year || (int)cultures.size() >= 64) continue;
int attCap = w.attacker, defCap = w.defender;
if (attCap < 0 || attCap >= (int)ns || defCap < 0 || defCap >= (int)ns) continue;
int ci = sSettleCulture[attCap];
if (ci < 0 || ci >= (int)cultures.size() || sSettleCulture[defCap] != ci) continue;
if (cultHashf(w.id * 2654435761u ^ (uint32_t)year * 40503u ^ seed ^ 0x5A57A0u)
>= cfg.cultWarSchismChance) continue;
int chosenCap = (cultHashf(w.id * 2654435761u ^ seed ^ 0x51DE5u) < 0.5) ? attCap : defCap;
std::vector<int> cluster; int total = 0;
for (size_t s = 0; s < ns; ++s) {
if (!living(s) || sSettleCulture[s] != ci) continue;
++total;
if ((int)s != chosenCap && sSettleAllegiance[s] != chosenCap) continue;
int sc = settlements[s].cell;
if (sc >= 0 && sc < n && (cells[sc].editLock & LockCulture)) continue;
cluster.push_back((int)s);
}
if (cluster.empty() || (int)cluster.size() >= total) continue;
doSchism(ci, cluster, "renounces kinship with",
"War between kin cracks their shared identity beyond repair.",
settlements[chosenCap].cell);
} }
std::string title = child.name + " break away from " + parName;
title[0] = (char)std::toupper((unsigned char)title[0]);
ev.push_back(WarEvent{ 2, settlements[far].cell, title,
"A distant people drifts apart and names itself anew.", 7 });
cultures.push_back(std::move(child));
} }
} }
return ev; return ev;

View File

@ -71,6 +71,7 @@
D(civColonySpacing) D(civColonySupply) \ D(civColonySpacing) D(civColonySupply) \
D(cultAssimRate) D(cultConvertRate) D(cultConvertDominance) D(cultSpreadRange) \ D(cultAssimRate) D(cultConvertRate) D(cultConvertDominance) D(cultSpreadRange) \
D(cultPrestigeWeight) D(cultSchismRange) D(cultSchismRate) \ D(cultPrestigeWeight) D(cultSchismRange) D(cultSchismRate) \
D(cultDominanceShare) D(cultDominanceProbMin) D(cultDominanceProbMax) D(cultWarSchismChance) \
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) \
@ -268,7 +269,9 @@ static const struct { const char* name; const char* category; } kConfigFieldCate
{"cultConvertDominance", "Cultural evolution"}, {"cultSpreadRange", "Cultural evolution"}, {"cultConvertDominance", "Cultural evolution"}, {"cultSpreadRange", "Cultural evolution"},
{"cultPrestigeWeight", "Cultural evolution"}, {"cultSchismMinMembers", "Cultural evolution"}, {"cultPrestigeWeight", "Cultural evolution"}, {"cultSchismMinMembers", "Cultural evolution"},
{"cultSchismRange", "Cultural evolution"}, {"cultSchismMinCluster", "Cultural evolution"}, {"cultSchismRange", "Cultural evolution"}, {"cultSchismMinCluster", "Cultural evolution"},
{"cultSchismRate", "Cultural evolution"}, {"cultDawnSettlementsPerCulture", "Cultural evolution"}, {"cultSchismRate", "Cultural evolution"}, {"cultDominanceShare", "Cultural evolution"},
{"cultDominanceProbMin", "Cultural evolution"}, {"cultDominanceProbMax", "Cultural evolution"},
{"cultWarSchismChance", "Cultural evolution"}, {"cultDawnSettlementsPerCulture", "Cultural evolution"},
{"cultDawnMaxPerContinent", "Cultural evolution"}, {"cultDawnMaxPerContinent", "Cultural evolution"},
}; };
@ -560,6 +563,10 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.cultSchismRange, 0.0, 3.14159, "cultSchismRange")); E(rng(cfg.cultSchismRange, 0.0, 3.14159, "cultSchismRange"));
E(irng(cfg.cultSchismMinCluster, 1, 1000000, "cultSchismMinCluster")); E(irng(cfg.cultSchismMinCluster, 1, 1000000, "cultSchismMinCluster"));
E(rng(cfg.cultSchismRate, 0.0, 1.0, "cultSchismRate")); E(rng(cfg.cultSchismRate, 0.0, 1.0, "cultSchismRate"));
E(rng(cfg.cultDominanceShare, 0.5, 1.0, "cultDominanceShare"));
E(rng(cfg.cultDominanceProbMin, 0.0, 1.0, "cultDominanceProbMin"));
E(rng(cfg.cultDominanceProbMax, 0.0, 1.0, "cultDominanceProbMax"));
E(rng(cfg.cultWarSchismChance, 0.0, 1.0, "cultWarSchismChance"));
E(irng(cfg.cultDawnSettlementsPerCulture, 1, 1000000, "cultDawnSettlementsPerCulture")); E(irng(cfg.cultDawnSettlementsPerCulture, 1, 1000000, "cultDawnSettlementsPerCulture"));
E(irng(cfg.cultDawnMaxPerContinent, 1, 1000, "cultDawnMaxPerContinent")); E(irng(cfg.cultDawnMaxPerContinent, 1, 1000, "cultDawnMaxPerContinent"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.subdivisions, 0, 7, "subdivisions"));

View File

@ -568,6 +568,14 @@ struct PlanetConfig {
double cultSchismRange = 0.55; // rad from the culture's population centroid past which members are "distant" 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 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 double cultSchismRate = 0.08; // per-year chance a qualifying distant cluster becomes a new people
// Two more schism triggers, independent of the distant-cluster one above: a culture that swallows
// most of the world fractures on its own (no geography needed), and a war between same-culture
// realms can crack their shared identity right at the outbreak. Both curb one people quietly
// conquering the whole world by attrition.
double cultDominanceShare = 0.80; // world population share above which dominance-schism can trigger
double cultDominanceProbMin = 0.01; // per-year schism chance right at cultDominanceShare
double cultDominanceProbMax = 0.99; // per-year schism chance at 100% world population share
double cultWarSchismChance = 0.50; // chance a war between same-culture realms splits one side away
// Dawn seeding (seedCultures()): a populous continent starts as SEVERAL distinct peoples instead // 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 // 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. // to erode a single per-continent culture into one super-dominant people across the whole world.

View File

@ -12,8 +12,10 @@
// //
// Verifies: seeding parity (one culture per continent, frozen identities); colonies inherit the // Verifies: seeding parity (one culture per continent, frozen identities); colonies inherit the
// founder's culture; assimilation under foreign allegiance; border conversion (capitals exempt); // founder's culture; assimilation under foreign allegiance; border conversion (capitals exempt);
// schism (a distant cluster becomes a new people); determinism + RNG isolation; snapshot // three independent schism triggers -- a distant cluster becomes a new people, an overwhelmingly
// truncate-and-replay; save-v23 round-trip + corrupt-stream rejection; pre-v23 compatibility. // dominant culture splits on its own (half or a fifth of its members), a war between same-culture
// realms splits one side; determinism + RNG isolation; snapshot truncate-and-replay; save-v23
// round-trip + corrupt-stream rejection; pre-v23 compatibility.
#include "Planet.hpp" #include "Planet.hpp"
#include <cstdio> #include <cstdio>
@ -40,6 +42,10 @@ static void drift(Planet& p, int iters) {
} }
static void quietCulture(Planet& p) { // no evolution: state should then never change static void quietCulture(Planet& p) { // no evolution: state should then never change
p.cfg.cultAssimRate = 0.0; p.cfg.cultConvertRate = 0.0; p.cfg.cultSchismRate = 0.0; p.cfg.cultAssimRate = 0.0; p.cfg.cultConvertRate = 0.0; p.cfg.cultSchismRate = 0.0;
// Both, not just Max: the gate check only needs Max == 0, but leaving Min at its default (0.01)
// violates validateConfig()'s cultDominanceProbMin <= cultDominanceProbMax cross-rule.
p.cfg.cultDominanceProbMin = 0.0; p.cfg.cultDominanceProbMax = 0.0;
p.cfg.cultWarSchismChance = 0.0;
} }
static bool culturesEqual(const std::vector<Culture>& a, const std::vector<Culture>& b) { static bool culturesEqual(const std::vector<Culture>& a, const std::vector<Culture>& b) {
if (a.size() != b.size()) return false; if (a.size() != b.size()) return false;
@ -214,6 +220,80 @@ int main() {
check(p.cultureList().size() <= 64, "the culture list stays bounded"); check(p.cultureList().size() <= 64, "the culture list stays bounded");
} }
std::printf("Cultural evolution: dominance schism (a culture holding most of the world splits)\n");
{
Planet dom = p;
quietCulture(dom);
// Always trigger once eligible (min==max==1) so the test doesn't depend on the probability ramp.
dom.cfg.cultDominanceShare = 0.80; dom.cfg.cultDominanceProbMin = 1.0; dom.cfg.cultDominanceProbMax = 1.0;
dom.computeTerritory(); dom.computeCultures();
// Force every living settlement onto one culture so it starts at ~100% world population share --
// dominance-schism has no distance/cluster requirement, so this alone should trigger it.
int targetCi = -1;
for (size_t k = 0; k < dom.settlements.size(); ++k) {
if (dom.settlements[k].population < dom.cfg.civAbandonPop) continue;
if (targetCi < 0) targetCi = dom.settleCulture()[k];
if (targetCi >= 0) break;
}
check(targetCi >= 0, "a living settlement with a seeded culture exists to consolidate onto");
if (targetCi >= 0) {
for (size_t k = 0; k < dom.settlements.size(); ++k)
if (dom.settlements[k].population >= dom.cfg.civAbandonPop) dom.setSettlementCulture((int)k, targetCi);
dom.computeTerritory(); dom.computeCultures(); // retally members/totalPop after consolidation
int beforeMembers = dom.cultureList()[(size_t)targetCi].members;
size_t beforeCultCount = dom.cultureList().size();
bool splitHappened = false;
for (long yr = 0; yr < 10 && !splitHappened; ++yr) {
for (const WarEvent& e : civYear(dom, yr))
if (e.kind == 7 && e.title.find("splits away from") != std::string::npos) splitHappened = true;
}
check(splitHappened, "an overwhelmingly dominant culture eventually splits on its own");
check(dom.cultureList().size() > beforeCultCount, "the split appends a new culture");
if (splitHappened && dom.cultureList().size() > beforeCultCount) {
const Culture& child = dom.cultureList().back();
check(child.parentId == (int)dom.cultureList()[(size_t)targetCi].id,
"the child records the dominant culture as parent");
int adopted = 0;
for (int c : dom.settleCulture()) if (c == (int)dom.cultureList().size() - 1) ++adopted;
double frac = (double)adopted / std::max(1, beforeMembers);
check(std::fabs(frac - 0.5) < 0.05 || std::fabs(frac - 0.2) < 0.05,
"the breakaway is close to half or a fifth of the dominant culture's prior members");
}
}
}
std::printf("Cultural evolution: war schism (a war between same-culture realms can split one side)\n");
{
Planet warp = p;
quietCulture(warp);
warp.cfg.cultWarSchismChance = 1.0; // always trigger once a same-culture war is seen
warp.computeTerritory(); warp.computeCultures();
int capA = -1, capB = -1;
for (size_t k = 0; k < warp.settlements.size() && capB < 0; ++k) {
if (warp.settlements[k].population < warp.cfg.civAbandonPop) continue;
if (capA < 0) capA = (int)k; else capB = (int)k;
}
check(capA >= 0 && capB >= 0, "at least two living settlements exist to test a war schism");
if (capA >= 0 && capB >= 0) {
int ci = warp.settleCulture()[(size_t)capA];
if (ci < 0) ci = 0;
warp.setSettlementCulture(capA, ci);
warp.setSettlementCulture(capB, ci);
warp.computeTerritory(); warp.computeCultures();
long yr = 0;
warp.stepConflict(yr); // sizes/refreshes sSettleAllegiance; any real wars it declares are harmless extras
War w; w.id = 999999; w.attacker = capA; w.defender = capB; w.startYear = yr;
warp.wars.push_back(w);
size_t before = warp.cultureList().size();
std::vector<WarEvent> ev = warp.stepCulture(yr);
bool splitHappened = false;
for (const WarEvent& e : ev)
if (e.kind == 7 && e.title.find("renounces kinship with") != std::string::npos) splitHappened = true;
check(splitHappened, "a war between same-culture realms splits one side's culture");
check(warp.cultureList().size() > before, "the war-triggered split appends a new culture");
}
}
std::printf("Cultural evolution: determinism\n"); std::printf("Cultural evolution: determinism\n");
{ {
Planet q; q.generate(cfg); settle(q); drift(q, 400); Planet q; q.generate(cfg); settle(q); drift(q, 400);