planetsim/test_cultevo.cpp
Jonas Reith cd268167b0 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
2026-08-30 22:15:12 +02:00

337 lines
18 KiB
C++

// Headless test for civilization Step 8 (cultural evolution). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_cultevo.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp
// src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp
// src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp
// src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp
// src/sim/PlanetVolcano.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp
// src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp
// src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp
// src/sim/PlanetNation.cpp src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp
// src/sim/PlanetTrade.cpp src/sim/PlanetIO.cpp -o /tmp/tcev && /tmp/tcev
//
// Verifies: seeding parity (one culture per continent, frozen identities); colonies inherit the
// founder's culture; assimilation under foreign allegiance; border conversion (capitals exempt);
// schism (a distant cluster becomes a new people); determinism + RNG isolation; snapshot
// truncate-and-replay; save-v23 round-trip + corrupt-stream rejection; pre-v23 compatibility.
#include "Planet.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <set>
#include <map>
#include <sstream>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
static void settle(Planet& p, int maxSteps = 800) {
int run = 0;
for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; }
p.computeClimate(); p.classifyBiomes();
}
static void drift(Planet& p, int iters) {
p.drifting = true;
for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); if (k >= iters/2) p.hydrology(dt*0.2); }
p.computeClimate(); p.classifyBiomes();
}
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;
}
static bool culturesEqual(const std::vector<Culture>& a, const std::vector<Culture>& b) {
if (a.size() != b.size()) return false;
for (size_t i = 0; i < a.size(); ++i)
if (a[i].id != b[i].id || a[i].regionId != b[i].regionId || a[i].bank != b[i].bank
|| a[i].ethos != b[i].ethos || a[i].faith != b[i].faith
|| a[i].parentId != b[i].parentId || a[i].foundedYear != b[i].foundedYear
|| a[i].name != b[i].name || a[i].faithName != b[i].faithName) return false;
return true;
}
// The shared yearly civ sequence (mirrors the viewer's year tick, minus weather).
static std::vector<WarEvent> civYear(Planet& p, long yr, bool wars = false, bool colonize = false) {
p.computeTerritory(); p.computeCultures();
if (wars) p.stepConflict(yr);
std::vector<WarEvent> ev = p.stepCulture(yr);
if (colonize) for (WarEvent& e : p.stepColonization(yr)) ev.push_back(e);
return ev;
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 4242;
Planet p; p.generate(cfg); settle(p); drift(p, 400);
const int n = (int)p.cells.size();
const double yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays;
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");
// 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;
int ci = p.settleCulture()[k]; if (ci < 0) continue;
keyToCults[s.regionId >= 0 ? s.regionId : (-1 - s.bank)].insert(ci);
}
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];
if (cu.parentId != -1 || cu.foundedYear != -1 || cu.name.empty() || cu.faithName.empty()) rootsOk = false;
if (cu.id != (uint32_t)i + 1) idsOk = false;
}
check(rootsOk, "all seeded cultures are roots (no parent, present since the dawn)");
check(idsOk, "culture ids are dense from 1");
// A quiet world (all rates 0) never mutates culture state.
Planet q0 = p; quietCulture(q0);
std::vector<int> sc0 = q0.settleCulture();
for (long yr = 0; yr < 30; ++yr) civYear(q0, yr);
check(q0.settleCulture() == sc0 && q0.cultureList().size() == p.cultureList().size(),
"no evolution with all cult* rates 0 (Steps 3-7 behaviour preserved)");
}
std::printf("Cultural evolution: colonies inherit the founder's culture\n");
{
Planet c = p; quietCulture(c);
c.computeHabitability();
c.cfg.civColonizeRate = 1.0; c.cfg.civColonyMinPop = 1000.0; c.cfg.civColonyMinHab = 0.1;
size_t before = c.settlements.size();
for (long yr = 0; yr < 40 && c.settlements.size() < before + 6; ++yr) civYear(c, yr, false, true);
std::printf(" %zu colonies founded\n", c.settlements.size() - before);
check(c.settlements.size() > before, "colonies are founded");
bool inherit = c.settlements.size() > before;
for (size_t k = before; k < c.settlements.size(); ++k) {
int ov = c.settleAllegiance()[k];
if (ov < 0 || c.settleCulture()[k] != c.settleCulture()[ov]) inherit = false;
}
check(inherit, "every colony carries its founder capital's culture");
}
std::printf("Cultural evolution: assimilation under foreign rule\n");
{
Planet a = p; quietCulture(a); a.cfg.cultAssimRate = 1.0;
// Force a conquest scenario: a living settlement held by a living foreign-culture capital
// (allegiance + culture are snapshot fields, so a hacked snapshot injects the state cleanly).
int s = -1, ov = -1;
const auto& sc = a.settleCulture();
for (size_t i = 0; i < a.settlements.size() && s < 0; ++i)
for (size_t j = 0; j < a.settlements.size(); ++j)
if (i != j && a.settlements[i].population >= a.cfg.civAbandonPop
&& a.settlements[j].population >= a.cfg.civAbandonPop
&& sc[i] >= 0 && sc[j] >= 0 && sc[i] != sc[j]) { s = (int)i; ov = (int)j; break; }
if (s < 0) { std::printf(" (single-culture world: skipping)\n"); }
else {
WeatherSnapshot snap = a.captureWeather();
if (snap.settlementAllegiance.size() != a.settlements.size()) // no war has sized it yet
snap.settlementAllegiance.assign(a.settlements.size(), -1);
snap.settlementAllegiance[s] = ov;
a.restoreWeather(snap);
int want = a.settleCulture()[ov];
std::vector<WarEvent> ev = a.stepCulture(1);
bool flipped = a.settleCulture()[s] == want;
bool logged = false;
for (const WarEvent& e : ev)
if (e.kind == 7 && e.title.find("adopts the culture of") != std::string::npos) logged = true;
check(flipped, "a conquered settlement assimilates into its ruler's culture");
check(logged, "assimilation emits a kind-7 event");
}
}
std::printf("Cultural evolution: border conversion (capitals exempt)\n");
{
Planet b = p; quietCulture(b);
b.cfg.cultConvertRate = 1.0; b.cfg.cultConvertDominance = 0.01; b.cfg.cultSpreadRange = 3.0;
b.computeTerritory(); b.computeCultures();
std::vector<int> caps;
for (const Nation& nat : b.nationList()) caps.push_back(nat.capital);
std::vector<int> capCultBefore;
for (int c : caps) capCultBefore.push_back(b.settleCulture()[c]);
int conversions = 0;
for (long yr = 0; yr < 5; ++yr)
for (const WarEvent& e : civYear(b, yr))
if (e.kind == 7 && e.title.find("embraces the ways of") != std::string::npos) ++conversions;
std::printf(" %d conversions in 5 years\n", conversions);
check(conversions > 0, "settlements convert under dominant foreign cultural pressure");
bool capsStable = true; // capitals anchor identity: only assimilation/schism may move them (both off/limited here)
for (size_t i = 0; i < caps.size(); ++i)
if (b.settleCulture()[caps[i]] != capCultBefore[i]) capsStable = false;
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;
{
quietCulture(p);
p.cfg.cultSchismRate = 1.0; p.cfg.cultSchismRange = 0.10; p.cfg.cultSchismMinMembers = 3;
p.cfg.cultSchismMinCluster = 1;
size_t before = p.cultureList().size();
for (long yr = 0; yr < 20 && p.cultureList().size() == before; ++yr) {
for (const WarEvent& e : civYear(p, yr))
if (e.kind == 7 && e.title.find("break away from") != std::string::npos) schismYear = yr;
}
check(p.cultureList().size() > before, "a schism appends a new culture");
if (p.cultureList().size() > before) {
const Culture& child = p.cultureList().back();
bool parentOk = false;
for (size_t i = 0; i < before; ++i) if ((int)p.cultureList()[i].id == child.parentId) parentOk = true;
check(parentOk, "the child records its parent culture");
check(child.foundedYear >= 0 && child.foundedYear == schismYear, "the child records its founding year");
bool nameUnique = !child.name.empty();
for (size_t i = 0; i + 1 < p.cultureList().size(); ++i)
if (p.cultureList()[i].name == child.name) nameUnique = false;
check(nameUnique, "the child's people name is unique");
int adopted = 0;
for (int c : p.settleCulture()) if (c == (int)p.cultureList().size() - 1) ++adopted;
check(adopted >= 1, "the breakaway cluster adopted the new culture");
p.computeTerritory(); p.computeCultures(); // the child cluster may found its own realm
check((int)p.cultureList().back().members == adopted, "derived tallies pick the child up");
}
check(p.cultureList().size() <= 64, "the culture list stays bounded");
}
std::printf("Cultural evolution: determinism\n");
{
Planet q; q.generate(cfg); settle(q); drift(q, 400);
q.placeSettlements();
double lt2 = 0.0; for (int yr = 0; yr < 600; ++yr) { lt2 += 2.0 * yearH; q.stepCivilization(2.0 * yearH, lt2); }
q.computeTerritory(); q.computeCultures();
quietCulture(q);
q.cfg.cultSchismRate = 1.0; q.cfg.cultSchismRange = 0.10; q.cfg.cultSchismMinMembers = 3;
q.cfg.cultSchismMinCluster = 1;
size_t before = q.cultureList().size();
for (long yr = 0; yr < 20 && q.cultureList().size() == before; ++yr) civYear(q, yr);
check(culturesEqual(q.cultureList(), p.cultureList()) && q.settleCulture() == p.settleCulture(),
"two identical worlds evolve identical cultures");
}
std::printf("Cultural evolution: RNG isolation from tectonics\n");
{
Planet x; x.generate(cfg); settle(x);
Planet y; y.generate(cfg); settle(y);
for (int k = 0; k < 40; ++k) {
double dx = x.cflDtMy(); x.advect(dx); x.step(); x.erode(dx);
double dy = y.cflDtMy(); y.advect(dy); y.step(); y.erode(dy);
if (k == 20) {
y.placeSettlements(); y.stepCivilization(yearH, yearH);
y.cfg.cultConvertRate = 1.0; y.cfg.cultConvertDominance = 0.01; y.cfg.cultSpreadRange = 3.0;
for (long yr = 0; yr < 3; ++yr) civYear(y, yr);
}
}
bool same = true;
for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) same = false;
check(same, "stepCulture never perturbs tectonic evolution");
}
{
// Interleaved no-op stepCulture calls must not disturb the war RNG stream.
Planet w1 = p, w2 = p; quietCulture(w1); quietCulture(w2);
w1.cfg.warDeclareRate = 2.0; w1.cfg.warMinRealmPop = 1.0;
w2.cfg.warDeclareRate = 2.0; w2.cfg.warMinRealmPop = 1.0;
for (long yr = 100; yr < 130; ++yr) {
w1.computeTerritory(); w1.computeCultures(); w1.stepConflict(yr);
w2.computeTerritory(); w2.computeCultures(); w2.stepConflict(yr); w2.stepCulture(yr);
}
bool warsSame = w1.warList().size() == w2.warList().size()
&& w1.settleAllegiance() == w2.settleAllegiance();
check(warsSame, "stepCulture uses no RNG (interleaving leaves wars identical)");
}
std::printf("Cultural evolution: snapshot truncate-and-replay\n");
{
WeatherSnapshot snap = p.captureWeather();
std::vector<Culture> cBefore = p.cultureList();
std::vector<int> scBefore = p.settleCulture();
// Mutate: aggressive conversion + schism for 15 years.
p.cfg.cultConvertRate = 1.0; p.cfg.cultConvertDominance = 0.01; p.cfg.cultSpreadRange = 3.0;
p.cfg.cultSchismRate = 1.0; p.cfg.cultSchismRange = 0.10;
for (long yr = 50; yr < 65; ++yr) civYear(p, yr);
std::vector<Culture> cAfter = p.cultureList();
std::vector<int> scAfter = p.settleCulture();
bool changed = !culturesEqual(cAfter, cBefore) || scAfter != scBefore;
p.restoreWeather(snap);
p.computeTerritory(); p.computeCultures(); // the derived refresh after a step-back
check(changed, "the mutation actually changed the culture state");
check(p.cultureList().size() == cBefore.size() && p.settleCulture() == scBefore,
"restoreWeather truncates schism children + rewinds settlement cultures");
// Deterministic replay re-creates the same children + conversions.
for (long yr = 50; yr < 65; ++yr) civYear(p, yr);
check(culturesEqual(p.cultureList(), cAfter) && p.settleCulture() == scAfter,
"replaying the same years re-creates identical schisms/conversions");
// Out-of-range snapshot entries are clamped on restore.
WeatherSnapshot odd = p.captureWeather();
if (!odd.settlementCulture.empty()) {
odd.settlementCulture[0] = 9999;
p.restoreWeather(odd);
check(p.settleCulture()[0] == -1, "restoreWeather clamps out-of-range culture entries");
odd.settlementCulture[0] = -1; // leave p in a sane (clamped) state for the save tests
}
}
std::printf("Cultural evolution: save v23 round-trip\n");
p.computeTerritory(); p.computeCultures();
{
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
p.writeState(ss);
Planet r;
bool ok = r.readState(ss);
check(ok, "readState accepts the v23 stream");
check(culturesEqual(r.cultureList(), p.cultureList()), "culture identities survive save/load");
check(r.settleCulture() == p.settleCulture(), "per-settlement culture survives save/load");
}
{
Planet bad = p;
if (!bad.cultures.empty()) {
bad.cultures[0].ethos = (CultureEthos)200;
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
bad.writeState(ss);
Planet r;
check(!r.readState(ss), "readState rejects an out-of-range culture ethos");
}
}
std::printf("Cultural evolution: pre-v23 compatibility\n");
{
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
p.writeState(ss);
Planet r;
// The culture block is the last one; reading with hasCultures=false ignores the trailing bytes.
bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true,
true, true, false);
check(ok, "readState accepts the stream as pre-v23");
check(r.cultureList().empty(), "pre-v23 load has no culture state");
r.computeTerritory(); r.computeCultures();
check(!r.cultureList().empty(), "cultures re-seed on the next refresh");
bool allRoots = true;
for (const Culture& cu : r.cultureList())
if (cu.parentId != -1 || cu.foundedYear != -1) allRoots = false;
check(allRoots, "re-seeded cultures are roots (dawn peoples, no parent)");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CULTURAL-EVOLUTION CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}