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
204 lines
11 KiB
C++
204 lines
11 KiB
C++
// Headless test for civilization Step 4 (cultures, beliefs & governments). No display needed.
|
|
//
|
|
// g++ -std=c++17 -O2 -Isrc/sim test_culture.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/PlanetIO.cpp -o /tmp/tc && /tmp/tc
|
|
//
|
|
// 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.
|
|
|
|
#include "Planet.hpp"
|
|
#include "NameGen.hpp"
|
|
#include <cstdio>
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
#include <set>
|
|
#include <map>
|
|
#include <sstream>
|
|
#include <unordered_set>
|
|
|
|
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();
|
|
}
|
|
// Grouping key matching computeCultures: continent if geography is present, else a per-bank fallback.
|
|
static int cultKey(const Settlement& s) { return (s.regionId >= 0) ? s.regionId : (-1 - s.bank); }
|
|
|
|
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 abP = p.cfg.civAbandonPop, yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays;
|
|
p.placeSettlements();
|
|
double lt = 0.0; for (int yr = 0; yr < 600; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); }
|
|
|
|
std::printf("Culture: extraction\n");
|
|
p.computeTerritory();
|
|
p.computeCultures();
|
|
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: continents seed 1..cultDawnMaxPerContinent cultures, never crossing continents\n");
|
|
{
|
|
// 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);
|
|
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
|
|
}
|
|
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(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");
|
|
{
|
|
bool ethosOk = true, faithOk = true, membersOk = true, namesOk = true;
|
|
for (const Culture& c : p.cultureList()) {
|
|
if ((int)c.ethos < 0 || (int)c.ethos > 5) ethosOk = false;
|
|
if ((int)c.faith < 0 || (int)c.faith > 7) faithOk = false;
|
|
if (c.members < 1 || c.totalPop <= 0.0) membersOk = false;
|
|
if (c.name.empty() || c.faithName.empty()) namesOk = false;
|
|
}
|
|
check(ethosOk, "every culture ethos is a valid enum");
|
|
check(faithOk, "every culture faith is a valid enum");
|
|
check(membersOk, "every culture has >=1 member and positive population");
|
|
check(namesOk, "every culture has a people name + a faith name");
|
|
}
|
|
|
|
std::printf("Culture: governments plausible per tier + reflected in realm name\n");
|
|
{
|
|
// Mirrors PlanetCulture.cpp's private cultHash() exactly, to rebuild the independently
|
|
// generated realm "place name" (deliberately NOT the capital settlement's own name -- a
|
|
// kingdom and the city it's ruled from read as two distinct places).
|
|
auto cultHash = [](uint32_t a) -> uint32_t { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; };
|
|
bool govOk = true, nameOk = true, cultOk = true, distinctOk = true;
|
|
std::unordered_set<std::string> usedRealmPlaces;
|
|
const uint32_t seed = p.cfg.seed ? p.cfg.seed : 1u;
|
|
for (const Nation& nat : p.nationList()) {
|
|
bool tierOk =
|
|
(nat.tier == NationTier::CityState && (nat.gov == GovType::CityRepublic || nat.gov == GovType::Tribe || nat.gov == GovType::Theocracy)) ||
|
|
(nat.tier == NationTier::Kingdom && (nat.gov == GovType::Kingdom || nat.gov == GovType::Duchy || nat.gov == GovType::Theocracy)) ||
|
|
(nat.tier == NationTier::Empire && (nat.gov == GovType::Empire || nat.gov == GovType::Autocracy || nat.gov == GovType::Confederation));
|
|
if (!tierOk) govOk = false;
|
|
// Rebuild the expected government-aware name (independent place name, not the capital's
|
|
// own) and compare.
|
|
const std::string& cap = p.settlements[nat.capital].name;
|
|
uint32_t capId = p.settlements[nat.capital].id;
|
|
int capBank = p.settlements[nat.capital].bank;
|
|
uint32_t placeSeed = seed ^ cultHash(capId * 2654435761u ^ 0x5EA1C1Cu);
|
|
std::string place = namegen::makeName(placeSeed, capBank);
|
|
for (int g = 0; usedRealmPlaces.count(place) && g < 128; ++g)
|
|
place = namegen::makeName(placeSeed += 0x9E3779B9u, capBank);
|
|
usedRealmPlaces.insert(place);
|
|
if (place == cap) distinctOk = false; // extremely unlikely, but the realm name must read as its own place
|
|
std::string exp;
|
|
switch (nat.gov) {
|
|
case GovType::Tribe: exp = "Chiefdom of " + place; break;
|
|
case GovType::CityRepublic: exp = "Republic of " + place; break;
|
|
case GovType::Duchy: exp = "Duchy of " + place; break;
|
|
case GovType::Kingdom: exp = "Kingdom of " + place; break;
|
|
case GovType::Theocracy: exp = place + " Theocracy"; break;
|
|
case GovType::Confederation: exp = place + " Confederation"; break;
|
|
case GovType::Empire: exp = place + " Empire"; break;
|
|
case GovType::Autocracy: exp = place + " Dominion"; break;
|
|
}
|
|
if (nat.name != exp) nameOk = false;
|
|
if (nat.cultureId < 0 || nat.cultureId >= (int)p.cultureList().size()) cultOk = false;
|
|
}
|
|
check(govOk, "each realm's government fits its tier");
|
|
check(nameOk, "each realm name reflects its government");
|
|
check(cultOk, "each realm is tagged with a valid culture");
|
|
check(distinctOk, "each realm's place name differs from its capital's own name");
|
|
}
|
|
|
|
std::printf("Culture: realms are mono-cultural (no realm spans two continents)\n");
|
|
{
|
|
std::map<int,int> nationCulture; // nation index -> the single culture of its members
|
|
bool mono = true;
|
|
for (size_t k = 0; k < p.settlements.size(); ++k) {
|
|
int ni = p.settleNation()[k]; if (ni < 0) continue;
|
|
int ci = p.settleCulture()[k];
|
|
auto it = nationCulture.find(ni);
|
|
if (it == nationCulture.end()) nationCulture[ni] = ci;
|
|
else if (it->second != ci) mono = false;
|
|
}
|
|
check(mono, "every settlement in a realm shares one culture");
|
|
}
|
|
|
|
std::printf("Culture: 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();
|
|
{
|
|
bool same = (q.cellCulture() == p.cellCulture()) && (q.cultureList().size() == p.cultureList().size());
|
|
if (same) for (size_t i = 0; i < q.cultureList().size(); ++i)
|
|
if (q.cultureList()[i].name != p.cultureList()[i].name ||
|
|
q.cultureList()[i].ethos != p.cultureList()[i].ethos ||
|
|
q.cultureList()[i].faith != p.cultureList()[i].faith) same = false;
|
|
check(same, "computeCultures is deterministic");
|
|
}
|
|
|
|
std::printf("Culture: 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.computeTerritory(); y.computeCultures(); }
|
|
}
|
|
{
|
|
bool terrainSame = true;
|
|
for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) terrainSame = false;
|
|
check(terrainSame, "computeCultures never perturbs tectonic evolution");
|
|
}
|
|
|
|
std::printf("Culture: save -> load -> recompute parity\n");
|
|
{
|
|
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
|
|
p.writeState(ss);
|
|
Planet r;
|
|
bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true);
|
|
check(ok, "readState accepts the v20 stream");
|
|
r.computeTerritory(); r.computeCultures(); // cultures are derived, not saved
|
|
check(r.cellCulture() == p.cellCulture(), "culture recomputed after load matches (derived, not saved)");
|
|
}
|
|
|
|
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CULTURE CHECKS PASSED\n", failures);
|
|
return failures ? 1 : 0;
|
|
}
|