// 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 #include #include #include #include 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& a, const std::vector& 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 civYear(Planet& p, long yr, bool wars = false, bool colonize = false) { p.computeTerritory(); p.computeCultures(); if (wars) p.stepConflict(yr); std::vector 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 one-per-continent 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 keys; 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)); } check(p.cultureList().size() == keys.size(), "one culture per inhabited continent"); 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 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 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 caps; for (const Nation& nat : b.nationList()) caps.push_back(nat.capital); std::vector 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: 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 cBefore = p.cultureList(); std::vector 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 cAfter = p.cultureList(); std::vector 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 one-per-continent roots"); } std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CULTURAL-EVOLUTION CHECKS PASSED\n", failures); return failures ? 1 : 0; }