planetsim/src/sim/PlanetCulture.cpp
Jonas Reith 4cb0ef9328 Civ Step 8: cultural evolution (assimilation, conversion, schism; save v23)
Cultures stop being static one-per-continent blocs. Culture identities are
now stateful: the list is append-only (seeded once at the dawn, schism
children appended later, records frozen after creation) and the
per-settlement culture is mutable state.

- stepCulture(year) in the yearly tick (pure hashes, no RNG):
  assimilation (a conquered settlement adopts its ruler's culture, which
  drops the Step-5 revolt cultBonus -> assimilation pacifies provinces),
  border conversion (population x trade-prestige pressure; realm capitals
  exempt), schism (a far-flung coherent cluster -- typically overseas
  colonies -- breaks away as a new people with a local-bank NameGen name
  and ethos/faith re-derived from its own lands).
- computeCultures() became a pure derived refresh (seeds only when the
  list is empty); seedCultures() reproduces the old per-continent peoples
  byte-identically for the dawn and pre-v23 loads.
- Colonies inherit the founder's culture at founding; the Step-3
  mono-cultural vassalage rule is culture-matched (regionId fallback) so
  schism clusters found their own realms -> colonial independence wars.
- Per-cell culture view colours by the owning settlement's culture, so a
  conquered city keeps its colour until it assimilates.
- Save v23: culture identities + sSettleCulture + next-id counter, also in
  the step-back frames; snapshots rewind via truncate-and-replay. Pre-v23
  saves re-seed on the next refresh.
- Kind-7 world events; Cultures tab hides extinct peoples and shows a
  schism child's founding year. Knobs cult* in planet.cfg.
- New test_cultevo.cpp (30 checks); all 15 existing suites still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:02:11 +02:00

443 lines
23 KiB
C++

#include "Planet.hpp"
#include "NameGen.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <unordered_map>
// --- Civilization Step 4 + Step 8: cultures, beliefs, governments & cultural evolution --------------
// Settlements share a CULTURE (a people/language family with an environment-driven ethos + a religion);
// each realm gets a GOVERNMENT type folded into its name. Since Step 8 the culture layer is split:
// - seedCultures() one-time seeding at the dawn (one culture per inhabited continent) -- also runs
// for pre-v23 loads. Identities (name/ethos/faith) are FROZEN at creation.
// - computeCultures() the DERIVED refresh (tallies, governments, per-cell view). Runs on every
// territory rebuild / load / step-back, so it must never mutate identity state.
// - stepCulture() the once-per-sim-year mutation pass: assimilation (conquered settlements adopt
// their ruler's culture), border conversion (dominant foreign cultural pressure)
// and schism (a far-flung cluster becomes a new people). 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
// stable; extinct cultures keep their slot); the list + sSettleCulture are saved (v23) + snapshotted.
namespace {
// A pure hash for deterministic picks -- never touches rngState (mirrors civHash in PlanetCiv.cpp).
inline uint32_t cultHash(uint32_t a) { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; }
inline double cultHashf(uint32_t a) { return (cultHash(a) & 0xFFFFFFu) / double(0x1000000); } // [0,1)
// Environmental tally over a set of settlement cells -> the signals that pick an ethos + faith.
// Factored out of the old computeCultures() so schism children re-derive theirs the same way.
struct CultEnv { int cnt = 0, coast = 0, high = 0, arid = 0, fert = 0; int biome[13] = {0}; };
void envAdd(CultEnv& a, const std::vector<Cell>& cells, const std::vector<double>& moist,
double sea, int c) {
if (c < 0 || c >= (int)cells.size()) return;
a.cnt++;
bool coast = false;
for (int j : cells[c].neighbors) if (cells[j].elevation <= sea) { coast = true; break; }
if (coast) a.coast++;
Biome b = cells[c].biome;
if ((int)b >= 0 && (int)b < 13) a.biome[(int)b]++;
if (b == Biome::Mountains || b == Biome::Hills) a.high++;
if (b == Biome::Desert || (moist.size() == cells.size() && moist[c] < 0.25)) a.arid++;
if (b == Biome::Grassland || b == Biome::Forest || b == Biome::Wetland || b == Biome::Savanna) a.fert++;
}
// Ethos: the dominant environmental signal; if none is strong, a deterministic social pick.
// Faith: coastal peoples worship the Sea, else the dominant biome sets it, with a rare variation.
void envPick(const CultEnv& a, uint32_t seed, uint32_t rk, int members,
CultureEthos& ethos, Faith& faith) {
double cnt = std::max(1, a.cnt);
double fCoast = a.coast / cnt, fHigh = a.high / cnt, fArid = a.arid / cnt, fFert = a.fert / cnt;
CultureEthos best = CultureEthos::Agrarian; double bestF = fFert;
if (fCoast > bestF) { bestF = fCoast; best = CultureEthos::Seafaring; }
if (fHigh > bestF) { bestF = fHigh; best = CultureEthos::Highland; }
if (fArid > bestF) { bestF = fArid; best = CultureEthos::Nomadic; }
if (bestF >= 0.34) ethos = best;
else {
uint32_t h = cultHash(seed ^ (rk * 2654435761u) ^ 0xC0FFEEu);
ethos = (members >= 3 && h % 3u == 0u) ? CultureEthos::Mercantile
: (h % 2u ? CultureEthos::Warlike : CultureEthos::Agrarian);
}
Faith f;
if (fCoast >= 0.5) f = Faith::Sea;
else {
int dom = 0; for (int bi = 1; bi < 13; ++bi) if (a.biome[bi] > a.biome[dom]) dom = bi;
switch ((Biome)dom) {
case Biome::Mountains: case Biome::Hills: f = Faith::Sky; break;
case Biome::Desert: case Biome::Savanna: f = Faith::Sun; break;
case Biome::Tundra: case Biome::Taiga: case Biome::Ice: f = Faith::Ancestors; break;
case Biome::Forest: case Biome::Wetland: f = Faith::Harvest; break;
case Biome::Beach: case Biome::Lake: case Biome::Ocean:f = Faith::Sea; break;
default: f = Faith::Earth; break;
}
uint32_t h = cultHash(seed ^ (rk * 40503u) ^ 0xFA17u);
if (h % 7u == 0u) f = Faith::Moon;
else if (h % 13u == 0u) f = Faith::War;
}
faith = f;
}
// The faith's proper name from a seed + language bank ("the X Faith" / "Cult of X" / "the X Path").
std::string makeFaithName(uint32_t faithSeed, int bank) {
std::string root = namegen::makeName(faithSeed, bank);
switch (cultHash(faithSeed) % 3u) {
case 0: return "the " + root + " Faith";
case 1: return "Cult of " + root;
default: return "the " + root + " Path";
}
}
}
const char* cultureEthosName(CultureEthos e) {
switch (e) {
case CultureEthos::Agrarian: return "Agrarian";
case CultureEthos::Seafaring: return "Seafaring";
case CultureEthos::Nomadic: return "Nomadic";
case CultureEthos::Highland: return "Highland";
case CultureEthos::Mercantile: return "Mercantile";
case CultureEthos::Warlike: return "Warlike";
}
return "Agrarian";
}
const char* faithFocusName(Faith f) {
switch (f) {
case Faith::Sun: return "Sun";
case Faith::Moon: return "Moon";
case Faith::Sea: return "Sea";
case Faith::Sky: return "Sky";
case Faith::Earth: return "Earth";
case Faith::Ancestors: return "Ancestors";
case Faith::War: return "War";
case Faith::Harvest: return "Harvest";
}
return "Earth";
}
const char* govTypeName(GovType g) {
switch (g) {
case GovType::Tribe: return "Chiefdom";
case GovType::CityRepublic: return "Republic";
case GovType::Duchy: return "Duchy";
case GovType::Kingdom: return "Kingdom";
case GovType::Theocracy: return "Theocracy";
case GovType::Confederation: return "Confederation";
case GovType::Empire: return "Empire";
case GovType::Autocracy: return "Autocracy";
}
return "Kingdom";
}
// One-time seeding: group living settlements into cultures, one per inhabited continent (regionId;
// settlements with no geography fall back to a per-language-bank culture), then freeze each culture's
// ethos/faith/names. Byte-identical to the pre-Step-8 derivation, so a pre-v23 load or the dawn
// produces the same peoples as before.
void Planet::seedCultures() {
cultures.clear();
sSettleCulture.assign(settlements.size(), -1);
sCultureNextId = 1;
if (settlements.empty()) return;
const int n = (int)cells.size();
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;
auto living = [&](const Settlement& s) {
return s.cell >= 0 && s.cell < n && s.population >= abP;
};
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);
int ci;
if (it == keyToCulture.end()) {
ci = (int)cultures.size();
Culture cu; cu.id = sCultureNextId++; cu.regionId = s.regionId; cu.bank = s.bank;
cultures.push_back(cu);
keyToCulture[key] = ci;
} else ci = it->second;
sSettleCulture[k] = ci;
cultures[ci].members++;
cultures[ci].totalPop += s.population;
}
if (cultures.empty()) return;
// 2) Environmental tally over each culture's settlement cells.
std::vector<CultEnv> acc(cultures.size());
for (size_t k = 0; k < settlements.size(); ++k) {
int ci = sSettleCulture[k]; if (ci < 0) continue;
envAdd(acc[ci], cells, sMoist, sea, settlements[k].cell);
}
// 3) Per-culture ethos, faith and names (all deterministic hashes of the region + seed).
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;
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);
cu.name = "the " + namegen::makeName(nameSeed, cu.bank);
cu.faithName = makeFaithName(faithSeed, cu.bank);
}
}
// 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() {
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;
};
// Per-settlement culture is STATE (Step 8): resize/sanitize + backfill only, never reassign.
if (sSettleCulture.size() != settlements.size()) sSettleCulture.resize(settlements.size(), -1);
for (int& c : sSettleCulture) if (c < -1 || c >= (int)cultures.size()) c = -1;
for (size_t k = 0; k < settlements.size(); ++k) {
if (!living(k) || sSettleCulture[k] >= 0) continue;
int bestS = -1; double bestD = 1e9; // nearest living cultured settlement (deterministic)
for (size_t j = 0; j < settlements.size(); ++j) {
if (j == k || !living(j) || sSettleCulture[j] < 0) continue;
double d = std::acos(std::clamp(
cells[settlements[k].cell].unit.dot(cells[settlements[j].cell].unit), -1.0, 1.0));
if (d < bestD) { bestD = d; bestS = (int)j; }
}
if (bestS >= 0) sSettleCulture[k] = sSettleCulture[bestS];
}
// Derived tallies (living settlements only; extinct cultures keep their slot with members = 0).
for (Culture& cu : cultures) { cu.members = 0; cu.totalPop = 0.0; }
for (size_t k = 0; k < settlements.size(); ++k) {
int ci = sSettleCulture[k];
if (ci < 0 || !living(k)) continue;
cultures[ci].members++;
cultures[ci].totalPop += settlements[k].population;
}
// Government per realm (from tier + a deterministic pick) + fold it into the realm's name, and
// tag the realm with its capital's culture.
for (Nation& nat : nations) {
nat.cultureId = (nat.capital >= 0 && nat.capital < (int)sSettleCulture.size())
? sSettleCulture[nat.capital] : -1;
uint32_t gh = cultHash((nat.id * 2654435761u) ^ seed ^ 0x604Fu) % 3u;
switch (nat.tier) {
case NationTier::CityState: nat.gov = (gh == 0) ? GovType::CityRepublic : (gh == 1) ? GovType::Tribe : GovType::Theocracy; break;
case NationTier::Kingdom: nat.gov = (gh == 0) ? GovType::Kingdom : (gh == 1) ? GovType::Duchy : GovType::Theocracy; break;
case NationTier::Empire: nat.gov = (gh == 0) ? GovType::Empire : (gh == 1) ? GovType::Autocracy: GovType::Confederation; break;
}
const std::string& capName = settlements[nat.capital].name;
switch (nat.gov) {
case GovType::Tribe: nat.name = "Chiefdom of " + capName; break;
case GovType::CityRepublic: nat.name = "Republic of " + capName; break;
case GovType::Duchy: nat.name = "Duchy of " + capName; break;
case GovType::Kingdom: nat.name = "Kingdom of " + capName; break;
case GovType::Theocracy: nat.name = capName + " Theocracy"; break;
case GovType::Confederation: nat.name = capName + " Confederation"; break;
case GovType::Empire: nat.name = capName + " Empire"; break;
case GovType::Autocracy: nat.name = capName + " Dominion"; break;
}
}
// Per-cell culture: prefer the owning SETTLEMENT's culture (Step 8 -- a conquered city keeps its
// people's colour inside the conqueror's realm until it assimilates); fall back to the realm's.
const bool haveOwner = (int)sCellSettleOwner.size() == n;
const bool haveNation = (int)sCellNation.size() == n;
if (!haveNation) return;
for (int i = 0; i < n; ++i) {
int ni = sCellNation[i];
if (ni < 0 || ni >= (int)nations.size()) continue;
int cu = -1;
if (haveOwner) {
int ow = sCellSettleOwner[i];
if (ow >= 0 && ow < (int)sSettleCulture.size()) cu = sSettleCulture[ow];
}
sCellCulture[i] = (cu >= 0) ? cu : nations[ni].cultureId;
}
}
// --- 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:
// 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);
// 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);
// C) schism -- a large culture's far-flung coherent cluster (overseas colonies) breaks away as a
// NEW people: a fresh name from the local language bank, ethos/faith re-derived from its own
// lands (appended to `cultures`; step-back truncates the list and replay re-creates it).
std::vector<WarEvent> Planet::stepCulture(long year) {
std::vector<WarEvent> ev;
const int n = (int)cells.size();
if (settlements.empty() || cultures.empty()) return ev;
if (sSettleCulture.size() != settlements.size()) return ev; // refresh hasn't run yet
const double abP = cfg.civAbandonPop;
const uint32_t seed = cfg.seed ? cfg.seed : 1u;
const size_t ns = settlements.size();
auto living = [&](size_t k) {
const Settlement& s = settlements[k];
return s.cell >= 0 && s.cell < n && s.population >= abP;
};
auto ang = [&](const Vec3& a, const Vec3& b) {
return std::acos(std::clamp(a.dot(b), -1.0, 1.0));
};
auto unitOf = [&](size_t k) -> const Vec3& { return cells[settlements[k].cell].unit; };
std::vector<char> converted(ns, 0); // at most one culture change per settlement per year
// A) Assimilation under foreign rule.
if (sSettleAllegiance.size() == ns && cfg.cultAssimRate > 0.0) {
for (size_t s = 0; s < ns; ++s) {
if (!living(s)) continue;
int ov = sSettleAllegiance[s];
if (ov < 0 || ov >= (int)ns || ov == (int)s || !living((size_t)ov)) continue;
int myCult = sSettleCulture[s], ovCult = sSettleCulture[ov];
if (ovCult < 0 || ovCult >= (int)cultures.size() || myCult == ovCult) continue;
if (cultHashf(settlements[s].id * 2654435761u ^ (uint32_t)year * 40503u ^ seed ^ 0xA5513Au)
>= cfg.cultAssimRate) continue;
sSettleCulture[s] = ovCult; converted[s] = 1;
ev.push_back(WarEvent{ 1, settlements[s].cell,
settlements[s].name + " adopts the culture of " + cultures[ovCult].name,
"Generations under foreign rule erode the old ways.", 7 });
}
}
// B) Border conversion under dominant foreign cultural pressure.
if (cfg.cultConvertRate > 0.0) {
std::vector<char> isCapital(ns, 0);
for (const Nation& nat : nations)
if (nat.capital >= 0 && nat.capital < (int)ns) isCapital[nat.capital] = 1;
const bool haveProsp = sProsperity.size() == ns;
std::vector<double> w(ns, 0.0); // cultural weight: population x (1 + prestige from trade)
for (size_t k = 0; k < ns; ++k)
if (living(k))
w[k] = settlements[k].population
* (1.0 + cfg.cultPrestigeWeight * (haveProsp ? sProsperity[k] : 0.0));
const double range = std::max(1e-6, cfg.cultSpreadRange);
std::vector<double> pressure(cultures.size(), 0.0);
for (size_t s = 0; s < ns; ++s) {
if (!living(s) || converted[s] || isCapital[s]) continue;
int myCult = sSettleCulture[s];
if (myCult < 0 || myCult >= (int)cultures.size()) continue;
std::fill(pressure.begin(), pressure.end(), 0.0);
for (size_t j = 0; j < ns; ++j) {
if (j == s || !living(j)) continue;
int cj = sSettleCulture[j]; if (cj < 0 || cj >= (int)cultures.size()) continue;
double d = ang(unitOf(s), unitOf(j));
if (d < range) pressure[cj] += w[j] * (1.0 - d / range);
}
double own = pressure[myCult] + w[s];
int cBest = -1; double pBest = 0.0;
for (size_t c = 0; c < cultures.size(); ++c)
if ((int)c != myCult && pressure[c] > pBest) { pBest = pressure[c]; cBest = (int)c; }
if (cBest < 0 || own <= 0.0 || pBest <= cfg.cultConvertDominance * own) continue;
double rate = cfg.cultConvertRate * std::min(3.0, pBest / (cfg.cultConvertDominance * own));
if (cultHashf(settlements[s].id * 2654435761u ^ (uint32_t)year * 19349663u ^ seed ^ 0xB07DE4u)
>= rate) continue;
sSettleCulture[s] = cBest; converted[s] = 1;
ev.push_back(WarEvent{ 1, settlements[s].cell,
settlements[s].name + " embraces the ways of " + cultures[cBest].name,
"Kinship and trade draw the town into a foreign sphere.", 7 });
}
}
// C) Schism: a distant coherent cluster of a large culture becomes a new people.
const size_t ncult = cultures.size(); // iterate the pre-pass list (children append)
if (cfg.cultSchismRate > 0.0 && (int)ncult < 64) {
std::vector<int> memCount(ncult, 0);
std::vector<Vec3> cSum(ncult);
for (size_t s = 0; s < ns; ++s) {
if (!living(s)) continue;
int ci = sSettleCulture[s]; if (ci < 0 || ci >= (int)ncult) continue;
memCount[ci]++;
cSum[ci] = cSum[ci] + unitOf(s) * std::max(1.0, settlements[s].population);
}
const double range = std::max(1e-6, cfg.cultSpreadRange);
for (size_t ci = 0; ci < ncult && (int)cultures.size() < 64; ++ci) {
if (memCount[ci] < std::max(1, cfg.cultSchismMinMembers)) continue;
if (cSum[ci].length() <= 1e-12) continue;
Vec3 centroid = cSum[ci].normalized(); // population-weighted cultural core
// One deterministic roll per culture-year (cheap early exit; pure hash, no state consumed).
if (cultHashf(cultures[ci].id * 2654435761u ^ (uint32_t)year * 40503u ^ seed ^ 0x5C1531u)
>= cfg.cultSchismRate) continue;
// Members far from the core; the breakaway cluster = the farthest one + distant members
// near it (a coherent region / overseas colony group, not scattered strays).
std::vector<int> distant; int far = -1; double farD = 0.0;
for (size_t s = 0; s < ns; ++s) {
if (!living(s) || sSettleCulture[s] != (int)ci) continue;
double d = ang(unitOf(s), centroid);
if (d > cfg.cultSchismRange) {
distant.push_back((int)s);
if (d > farD) { farD = d; far = (int)s; }
}
}
if (far < 0 || (int)distant.size() < std::max(1, cfg.cultSchismMinCluster)) continue;
std::vector<int> cluster;
for (int s : distant)
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() >= memCount[ci]) continue; // a schism splits, it never renames all
// 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;
// Ethos/faith re-derived from the cluster's own lands (hash-salted by the child id so the
// faith can mutate); keep the parent's faith name if the faith itself is unchanged.
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) { sSettleCulture[s] = childIdx; converted[s] = 1; }
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;
}