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>
This commit is contained in:
Jonas Reith 2026-07-04 14:02:11 +02:00
parent 1090d2d46e
commit 4cb0ef9328
15 changed files with 872 additions and 107 deletions

View File

@ -249,8 +249,36 @@ on the Live World clock). **Steps 17 of the roadmap are done (plus a derived
colony of Y"). Knobs `civColon*`. *(Also fixed: pressing `R` (reseed) after civilization existed left
stale territory/culture/war/diplomacy/trade overlay lines on the new world — `regenWorld()` now clears
the civ overlay vectors + toggles.)*
*Next steps (not yet built): cultural **evolution** (spread along borders, drift, assimilation, schism);
tribute / vassalage treaties; an accumulated treasury (funding armies / buying peace).*
- **Cultural evolution (Step 8)** *(done — see `PlanetCulture.cpp` `stepCulture`, 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 — the same one-per-continent peoples as before — with schism
children appended later; a record is **frozen after creation** so indices/colours stay stable and the
ethos/faith no longer silently re-derive) and the **per-settlement culture is mutable state**.
`stepCulture(year)` runs in the yearly tick (between `stepConflict` and `stepColonization`), all **pure
hashes** of (stable id, year, seed) — no RNG: **assimilation** (a settlement held by a foreign-culture
overlord adopts its ruler's culture, `cultAssimRate` — which drops the Step-5 revolt `cultBonus` on its
own, so assimilation pacifies provinces), **border conversion** (a settlement dwarfed by a nearby foreign
culture's weight = population × trade prestige converts toward it, `cultConvert*`/`cultSpreadRange`/
`cultPrestigeWeight`; **realm capitals are exempt** — they anchor identity), and **schism** (a large
culture's far-flung coherent cluster — typically overseas colonies — breaks away as a **new people**,
`cultSchism*`: a fresh NameGen name from the local language bank, ethos/faith **re-derived from its own
lands** — keeping the parent's faith name if the faith is unchanged — parent id + founding year recorded).
**Colonies now inherit the founder's culture** at founding (the seed for later colonial schisms), and the
Step-3 mono-cultural vassalage rule is **culture-matched** (regionId fallback before the dawn / for
-1 entries) — a schism cluster stops vassalizing to the old capital, founds its own realm, and the
culture/faith diplomacy affinity drifts parent and child toward rivalry → **colonial independence wars
emerge**. The per-cell culture view (`X`) now colours by the owning **settlement's** culture, so a
conquered city keeps its people's colour until it assimilates. `computeCultures()` became a pure derived
refresh (tallies/governments/cell view; seeds only when the list is empty — the dawn / pre-v23-load
path); the state — culture identities + `sSettleCulture` (+ a next-id counter) — is **saved (v23)** and
**snapshotted via truncate-and-replay**: the list is append-only and `stepCulture` is a pure function,
so a step-back frame stores just the per-settlement vector + the list *length*, `,` truncates schism
children and a replay re-creates them identically (the colony-truncation precedent). **Kind=7**
`WorldEvent`s ("X adopts the culture of the Velmar", "X embraces the ways of the Nharos", "The Nharos
break away from the Velmar"); the **Cultures** tab hides extinct peoples (members 0 — slots persist) and
shows a schism child's founding year. Knobs `cult*`.
*Next steps (not yet built): tribute / vassalage treaties; an accumulated treasury (funding armies /
buying peace).*
## Current state
@ -661,7 +689,7 @@ src/
PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity)
PlanetCiv.* computeHabitability/placeSettlements/stepCivilization/stepColonization (settlements + colonies; civ Step 2)
PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3)
PlanetCulture.* computeCultures (cultures/ethos/faith per continent + governments; civ Step 4)
PlanetCulture.* computeCultures (derived refresh + one-time seeding; civ Step 4) + stepCulture (assimilation/conversion/schism; civ Step 8, save v23)
PlanetConflict.* stepConflict (wars/conquest/revolts + diplomacy/alliances/coalitions; civ Steps 5-6, save v21/v22)
PlanetTrade.* computeTrade (trade routes + prosperity/wealth feeding growth; civ Step 7, derived)
PlanetIO.cpp config file (text) + binary save/load
@ -732,8 +760,8 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
`test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`,
`test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp`, `test_diplomacy.cpp`, `test_trade.cpp` or `test_colony.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions /
Civilization / Nation / Culture / Conflict / Diplomacy / Trade / Colony suites — same source list. CMake also builds `test_events` for the
`test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp`, `test_diplomacy.cpp`, `test_trade.cpp`, `test_colony.cpp` or `test_cultevo.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions /
Civilization / Nation / Culture / Conflict / Diplomacy / Trade / Colony / Cultural-evolution suites — same source list. CMake also builds `test_events` for the
viewer event journal.)
Use this to verify tectonics after changing `Planet::step()` without launching
@ -803,11 +831,15 @@ Territory view on (`P`), realms **wage war** each year — red war-fronts appear
(borders move) or fall to ruins, empires fracture as provinces revolt; realms also form **alliances**
(green arcs) and **rivalries** (dark-red arcs), allies **join each other's wars** (coalitions) and never
fight each other, and wars end in **peace treaties**; the **Realms** tab lists each realm's allies/
rivals/wars and the **Events** tab logs it all. `Z` shows the **trade economy** — routes (cyan sea /
rivals/wars and the **Events** tab logs it all. **Cultures evolve** on the same yearly tick (`X` view) —
conquered cities keep their people's colour until they **assimilate** into the ruler's culture, border
towns **convert** under a dominant neighbour's cultural weight, and a far-flung colony cluster can
**schism** into a new people (a new name/colour) that founds its own realm — colonial independence wars
follow. `Z` shows the **trade economy** — routes (cyan sea /
amber land) + a **wealth heat map**; well-connected coastal/river **hubs** grow richer & bigger, a war
cuts the routes between belligerents, and the **Civ** tab shows each settlement's wealth. `Y` makes the 3D camera **follow a storm** (cycles by strength,
off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and**
weather/storms/volcano lifecycle **and wars/conquests** via snapshots). Mouse-wheel over the 2D map zooms (drag pans).
weather/storms/volcano lifecycle **and wars/conquests and cultural shifts** via snapshots). Mouse-wheel over the 2D map zooms (drag pans).
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate).
@ -818,7 +850,7 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
`Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
defaults (without overwriting your `planet.cfg`) and shows a status message. The
save header is versioned (currently **22**; v2 adds the `[`/`]` drift rate, v3 a
save header is versioned (currently **23**; v2 adds the `[`/`]` drift rate, v3 a
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
@ -832,8 +864,11 @@ appends the **geography/atlas** block — named features + per-cell region indic
reshuffle salt, v19 the **ecoregions** block, v20 the **civilization settlements** block (the
fixed settlement set + per-frame populations in the step-back history), v21 the **civilization
conflict** block — per-settlement **allegiance** (conquest) + active **wars** + the war RNG, also added
to the step-back frames so a load can rewind conquests, and v22 the **diplomacy** block — standing realm
**relations** (alliances / rivalries / truces), likewise per-frame;
to the step-back frames so a load can rewind conquests, v22 the **diplomacy** block — standing realm
**relations** (alliances / rivalries / truces), likewise per-frame, and v23 the **culture** block —
stateful culture identities (append-only; schism children) + the per-settlement culture + a next-id
counter (cultural evolution), likewise per-frame (per-settlement vector + the list *length*; a rewind
truncates, a replay re-creates);
newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
@ -844,7 +879,9 @@ entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with o
pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on
demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with
no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin
again as the clock runs); pre-v22 saves load with no diplomacy (relations re-form as the clock runs).
again as the clock runs); pre-v22 saves load with no diplomacy (relations re-form as the clock runs);
pre-v23 saves load with no culture state (re-seeded one-per-continent on the next refresh — the same
peoples as before; evolution starts from there).
A load drops any **stale** pre-load `wxUndo` history and reloads the
saved one.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
@ -1006,7 +1043,8 @@ triangles (plates are fixed in phase 1).
- **Culture, beliefs & governments (civ Step 4, key `X`):** **no config knobs** — the ethos/faith/
government rules are internal constants in `PlanetCulture.cpp` (ethos-signal threshold 0.34, the
biome→faith map, the tier→government picks) and the culture colours/border colour are render constants.
Cultures are derived (recomputed with territory, not saved). To retune, edit `computeCultures()`.
Since Step 8 identities are stateful (seeded once, saved v23); to retune the rules, edit
`envPick()`/`seedCultures()` in `PlanetCulture.cpp` (a reseed applies them).
- **Conflict & war (`war*` in PlanetConfig / `planet.cfg`; civ Step 5, save v21):** `warMaxConcurrent`
(6, simultaneous wars), `warDeclareRate` (0.12, war-declaration chance × hostility), the hostility
weights `warAmbition` (1.0, size gap) / `warIdeology` (0.8, culture+faith difference) / `warBorder`
@ -1043,6 +1081,17 @@ triangles (plates are fixed in phase 1).
`civMaxColonies` (120, cap beyond `civMaxSettlements`), `civColonySupply` (2.0, prosperity from the
overlord supply link that keeps colonies alive). Runs in the yearly `stepColonization` tick; raise the
rate/reach for aggressive colonial empires.
- **Cultural evolution (`cult*` in PlanetConfig / `planet.cfg`; civ Step 8, save v23):** assimilation —
`cultAssimRate` (0.03, per-year chance a conquered settlement adopts its ruler's culture); border
conversion — `cultConvertRate` (0.02, per-year chance scale), `cultConvertDominance` (2.5, foreign
pressure must exceed this × own support — raise to make cultures stickier), `cultSpreadRange` (0.25 rad,
how far a settlement projects cultural pressure), `cultPrestigeWeight` (0.5, trade prosperity's boost to
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" —
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
pre-Step-8 world. Runs in the yearly `stepCulture` tick (pure hashes, no RNG); the culture list is
capped at 64 peoples.
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -78,7 +78,7 @@ if(UNIX AND NOT APPLE)
endif()
enable_testing()
foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy trade colony)
foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy trade colony cultevo)
add_executable(test_${test_name} test_${test_name}.cpp)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name})

View File

@ -53,6 +53,13 @@ namespace {
if (!validFrameWar(war, settlementCount)) return false;
if (!warsSeen.insert(std::minmax(war.attacker, war.defender)).second) return false;
}
// v23: per-settlement culture (indices into the frame's culture-list prefix).
if (w.cultureCount > 100000) return false;
if (!w.settlementCulture.empty()) {
if ((int)w.settlementCulture.size() != settlementCount) return false;
for (int c : w.settlementCulture)
if (c < -1 || c >= (int)w.cultureCount) return false;
}
return sanitizeFrameDiplomacy(w.diplomacy, settlementCount);
}
}
@ -565,6 +572,11 @@ void Viewer::saveGame(const char* path) {
// v22: per-frame diplomacy, so a load can rewind alliances/rivalries past the saved moment.
{ uint64_t m = f.w.diplomacy.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); }
// v23: per-frame culture state (per-settlement culture + culture-list length), so a load can
// rewind conversions/assimilations/schisms past the saved moment.
{ uint64_t m = f.w.settlementCulture.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.settlementCulture.data(), (std::streamsize)(m * sizeof(int))); }
os.write((char*)&f.w.cultureCount, 4); os.write((char*)&f.w.cultureNextId, 4);
}
// v16: persistent world event journal, separate from step-back history.
uint32_t en = (uint32_t)std::min<size_t>(events.size(), (size_t)EVENT_LOG_MAX);
@ -598,7 +610,7 @@ void Viewer::loadGame(const char* path) {
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22, ver >= 23)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy, v23 cultures
cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift
@ -667,6 +679,13 @@ void Viewer::loadGame(const char* path) {
else { f.w.diplomacy.resize((size_t)m); if (m) is.read((char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); }
if (!is) historyOk = false;
}
if (ver >= 23) { // v23: per-frame culture state
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) historyOk = false;
else { f.w.settlementCulture.resize((size_t)m); if (m) is.read((char*)f.w.settlementCulture.data(), (std::streamsize)(m * sizeof(int))); }
is.read((char*)&f.w.cultureCount, 4); is.read((char*)&f.w.cultureNextId, 4);
if (!is) historyOk = false;
}
auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
@ -826,6 +845,10 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
ConflictUpdate wu = planet.stepConflict(y);
for (const WarEvent& e : wu.events)
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
// Step 8 after conflict (assimilation reads this year's fresh allegiance) and before
// colonization (a colony is stamped with its founder's possibly-just-changed culture).
for (const WarEvent& e : planet.stepCulture(y)) // cultural evolution
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
for (const WarEvent& e : planet.stepColonization(y)) // kingdoms found new colonies (appends settlements)
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
if (y < year) rebuildTerritory(); // recompute between years so the next year's war sees it

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer {
// ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 22; // v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
static constexpr uint32_t SAVE_VERSION = 23; // v23: civ cultural evolution; v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save
static constexpr int EVENT_LOG_MAX = 200;
const char* CONFIG_PATH = "planet.cfg";

View File

@ -616,7 +616,7 @@ void Viewer::renderLiveInfo() {
: Color{175, 205, 235, 255};
DrawRectangleRec(row, bg);
DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255});
const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : e.kind == 6 ? "=" : "~";
const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : e.kind == 6 ? "=" : e.kind == 7 ? "@" : "~";
DrawText(icon, (int)row.x + 7, (int)row.y + 6, 18, fg);
double d = e.timeHours / std::max(0.1, planet.cfg.dayLengthHours);
DrawText(TextFormat("D%.1f", d), (int)row.x + 24, (int)row.y + 5, 12, Color{145, 155, 175, 255});
@ -798,6 +798,7 @@ void Viewer::renderLiveInfo() {
for (int ci : idx) {
if (y > (int)(r.y + r.height) - 22) break;
const Culture& cu = C[ci];
if (cu.members <= 0) continue; // extinct people (Step 8): keeps its slot, not listed
// Representative cell: the largest living settlement of this culture (to fly to).
int repCell = -1; double repPop = -1.0;
for (size_t s = 0; s < planet.settlements.size(); ++s)
@ -806,6 +807,10 @@ void Viewer::renderLiveInfo() {
Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f };
eventRowRects.push_back(row); atlasRowCells.push_back(repCell);
DrawText(cu.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, cultureColor(ci));
// Step 8: a schism child shows its founding year (roots have been there since the dawn).
if (cu.foundedYear >= 0)
DrawText(TextFormat("b. yr %ld", cu.foundedYear),
(int)(r.x + r.width) - 180, (int)row.y + 3, 11, Color{135, 142, 160, 255});
DrawText(TextFormat("%s %s", cultureEthosName(cu.ethos), faithFocusName(cu.faith)),
(int)(r.x + r.width) - 118, (int)row.y + 3, 11, Color{150, 158, 178, 255});
y += 20;

View File

@ -64,7 +64,7 @@ void Planet::buildGeometry() {
sCellSettlement.assign(cells.size(), -1); sHabitability.clear();
sCivCond.clear(); sCivDrought.clear();
nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear();
cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear();
cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); sCultureNextId = 1;
sCellSettleOwner.assign(cells.size(), -1);
sProsperity.clear(); sTradeLinks.clear(); sCellWealth.assign(cells.size(), 0.0);
wars.clear(); sSettleAllegiance.clear(); diplomacy.clear();

View File

@ -210,11 +210,15 @@ public:
const std::vector<int>& cellNation() const { return sCellNation; } // nation index per cell (-1 = wilderness/sea)
const std::vector<int>& settleNation() const { return sSettleNation; } // nation index per settlement (-1 = dead)
// Cultures, beliefs & governments (PlanetCulture.cpp). computeCultures() groups settlements into
// peoples (one per inhabited continent), gives each a dominant ethos + religion, assigns each realm
// a government type (folded into nation.name), and tags cells/settlements with their culture. Runs
// AFTER computeTerritory() (reads nations/sCellNation). Purely derived, recomputed, not saved.
// Cultures, beliefs & governments (PlanetCulture.cpp). computeCultures() is the DERIVED refresh:
// it seeds the cultures once (one per inhabited continent) when none exist, then only recomputes
// tallies, governments (folded into nation.name) and the per-cell culture view. Runs AFTER
// computeTerritory() (reads nations/sCellNation/sCellSettleOwner). Culture identities + the
// per-settlement culture are STATEFUL since Step 8 (saved v23 + snapshotted); stepCulture() is
// the once-per-sim-year mutation pass (border conversion / assimilation / schism, pure hashes --
// no RNG touched, so step-back replays it exactly).
void computeCultures();
std::vector<WarEvent> stepCulture(long year);
bool culturesBuilt() const { return !cultures.empty(); }
const std::vector<Culture>& cultureList() const { return cultures; }
const std::vector<int>& cellCulture() const { return sCellCulture; } // culture index per cell (-1 = none)
@ -267,12 +271,14 @@ public:
// whether the stream carries the active reshuffle salt (save v18+); hasEcoregions: whether the
// stream carries the ecoregion atlas (save v19+). Older saves regenerate on demand.
// hasSettlements: whether the stream carries the civilization settlements block (save v20+); older
// saves load with none (re-seeded on demand via the civ key).
// saves load with none (re-seeded on demand via the civ key). hasCultures: whether the stream
// carries the stateful culture block (save v23+); older saves re-seed one culture per continent
// on the next computeCultures().
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true,
bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true,
bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true,
bool hasConflict = true, bool hasDiplo = true);
bool hasConflict = true, bool hasDiplo = true, bool hasCultures = true);
// Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing
@ -292,7 +298,7 @@ private:
bool hasMoons, bool hasWeather, bool hasStorms,
bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography,
bool hasGeoSalt, bool hasEcoregions, bool hasSettlements,
bool hasConflict, bool hasDiplo);
bool hasConflict, bool hasDiplo, bool hasCultures);
void clearDerivedState(); // clear geometry-dependent scratch/derived fields
void assignPlates();
void seedInitialRelief();
@ -382,9 +388,13 @@ private:
// Territory & nations (derived from settlements; not saved). sCellNation: nation index per cell
// (-1 = wilderness/ocean); sSettleNation: nation index per settlement.
std::vector<int> sCellNation, sSettleNation;
// Cultures (derived from settlements + geography; not saved). sCellCulture: culture index per cell
// (-1 = none/wilderness/ocean); sSettleCulture: culture index per settlement.
// Cultures (civ Step 8: stateful, saved v23). sSettleCulture: culture index per settlement --
// mutable identity state (seeded at the dawn, changed only by stepCulture()/colony founding);
// sCellCulture: the derived per-cell view (refreshed by computeCultures(), not saved).
// sCultureNextId: stable id counter for appended (schism) cultures.
std::vector<int> sCellCulture, sSettleCulture;
uint32_t sCultureNextId = 1;
void seedCultures(); // one-time seeding (dawn / pre-v23 load), PlanetCulture.cpp
// Per-cell owning settlement index (filled by computeTerritory alongside sCellNation; -1 = none).
// Reused by the trade wealth field so the heat map matches territory exactly.
std::vector<int> sCellSettleOwner;

View File

@ -319,6 +319,10 @@ std::vector<WarEvent> Planet::stepColonization(long year) {
sSettleNation[newIdx] = (int)ni; // the colony is (already) the founder's realm
if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.resize(settlements.size(), -1);
sSettleAllegiance[newIdx] = cap; // the colony belongs to the founder's realm
// Step 8: the colony carries its FOUNDER's culture (not the target continent's) -- the seed for
// later colonial schisms. The settlement name stays in the local land's language bank.
if (sSettleCulture.size() != settlements.size()) sSettleCulture.resize(settlements.size(), -1);
sSettleCulture[newIdx] = (cap < (int)sSettleCulture.size()) ? sSettleCulture[cap] : -1;
if (sCivCond.size() != settlements.size()) { sCivCond.resize(settlements.size(), 1.0); sCivDrought.resize(settlements.size(), 0.0); }
ev.push_back(WarEvent{ 1, best, "The " + nat.name + " founds the colony of " + settlements[newIdx].name,
"A new settlement rises on distant land.", 3 });

View File

@ -1,18 +1,93 @@
#include "Planet.hpp"
#include "NameGen.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <unordered_map>
// --- Civilization Step 4: cultures, beliefs & governments --------------------
// Settlements on the same continent share a CULTURE (a people/language family with an environment-driven
// ethos + a religion); each realm gets a GOVERNMENT type folded into its name. A pure deterministic
// function of the settlement set + geography + biomes (no RNG -> tectonic stream untouched), computed
// AFTER computeTerritory() so `nations` / `sCellNation` already exist. Recomputed rather than saved.
// --- 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) {
@ -55,18 +130,21 @@ const char* govTypeName(GovType g) {
return "Kingdom";
}
void Planet::computeCultures() {
const int n = (int)cells.size();
// 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();
sCellCulture.assign(n, -1);
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, one per inhabited continent (regionId). Settlements
// with no geography (regionId < 0) fall back to a per-language-bank culture.
// 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;
@ -79,7 +157,7 @@ void Planet::computeCultures() {
int ci;
if (it == keyToCulture.end()) {
ci = (int)cultures.size();
Culture cu; cu.id = (uint32_t)ci + 1; cu.regionId = s.regionId; cu.bank = s.bank;
Culture cu; cu.id = sCultureNextId++; cu.regionId = s.regionId; cu.bank = s.bank;
cultures.push_back(cu);
keyToCulture[key] = ci;
} else ci = it->second;
@ -89,77 +167,68 @@ void Planet::computeCultures() {
}
if (cultures.empty()) return;
// 2) Environmental tally over each culture's settlement cells -> ethos + a dominant biome for faith.
struct Acc { int cnt = 0, coast = 0, high = 0, arid = 0, fert = 0; int biome[13] = {0}; };
std::vector<Acc> acc(cultures.size());
const bool haveMoist = (int)sMoist.size() == n;
// 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;
int c = settlements[k].cell;
Acc& a = acc[ci]; 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 || (haveMoist && sMoist[c] < 0.25)) a.arid++;
if (b == Biome::Grassland || b == Biome::Forest || b == Biome::Wetland || b == Biome::Savanna) a.fert++;
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];
const Acc& a = acc[ci];
double cnt = std::max(1, a.cnt);
double fCoast = a.coast / cnt, fHigh = a.high / cnt, fArid = a.arid / cnt, fFert = a.fert / cnt;
uint32_t rk = (uint32_t)(cu.regionId >= 0 ? cu.regionId : (1000 - cu.bank)) + 1u;
// Ethos: the dominant environmental signal; if none is strong, a deterministic social pick.
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) cu.ethos = best;
else {
uint32_t h = cultHash(seed ^ (rk * 2654435761u) ^ 0xC0FFEEu);
cu.ethos = (cu.members >= 3 && h % 3u == 0u) ? CultureEthos::Mercantile
: (h % 2u ? CultureEthos::Warlike : CultureEthos::Agrarian);
}
// Faith focus: coastal peoples worship the Sea, else the dominant biome sets it, with a rare
// deterministic Moon/War variation.
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;
}
cu.faith = f;
// People (demonym) + the faith's proper name, from the region's language bank.
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);
std::string root = namegen::makeName(faithSeed, cu.bank);
switch (cultHash(faithSeed) % 3u) {
case 0: cu.faithName = "the " + root + " Faith"; break;
case 1: cu.faithName = "Cult of " + root; break;
default: cu.faithName = "the " + root + " Path"; break;
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];
}
// 4) Government per realm (from tier + a deterministic pick) + fold it into the realm's name, and
// 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())
@ -183,9 +252,191 @@ void Planet::computeCultures() {
}
}
// 5) Per-cell culture = the culture of the realm that owns the cell (reuse territory's ownership).
// 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()) sCellCulture[i] = nations[ni].cultureId;
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;
}

View File

@ -5,10 +5,13 @@
// Civilization Step 4: culture, beliefs & governments. Settlements on the same continent share a
// CULTURE -- a people/language family with a dominant ethos (drawn from their environment) and a
// religion. Each realm (Step 3 Nation) gets a GOVERNMENT type folded into its name. Like territory,
// all of this is a DETERMINISTIC function of the settlement set + geography + biomes/climate, computed
// with pure hashes (no RNG touched) -- so it is recomputed rather than saved (no save-format change),
// and the live stepper rewinds it for free.
// religion. Each realm (Step 3 Nation) gets a GOVERNMENT type folded into its name.
// Civilization Step 8 (cultural evolution) made culture identities STATEFUL: the culture list is
// append-only (seeded once per continent at the dawn, schism children appended later; a record is
// immutable after creation so indices/colors stay stable) and the per-settlement culture is mutable
// state (conversion / assimilation / schism / colonial inheritance) -- both saved (v23) and
// snapshotted. members/totalPop tallies, Nation.cultureId/governments and the per-cell culture view
// stay DERIVED (refreshed by computeCultures(), which never mutates identity state).
enum class CultureEthos : uint8_t { Agrarian, Seafaring, Nomadic, Highland, Mercantile, Warlike };
enum class Faith : uint8_t { Sun, Moon, Sea, Sky, Earth, Ancestors, War, Harvest };
@ -19,8 +22,10 @@ struct Culture {
int bank = 0; // NameGen "language" bank (shared by the region's places)
CultureEthos ethos = CultureEthos::Agrarian;
Faith faith = Faith::Earth;
int members = 0; // number of settlements sharing this culture
double totalPop = 0.0; // summed population of those settlements
int members = 0; // number of settlements sharing this culture (derived tally)
double totalPop = 0.0; // summed population of those settlements (derived tally)
int parentId = -1; // (Step 8) culture id this one schismed from (-1 = a root people)
long foundedYear = -1; // (Step 8) sim year of the schism (-1 = present since the dawn)
std::string name; // the people / demonym, e.g. "the Velmar"
std::string faithName; // the religion's proper name, e.g. "the Tidewardens"
};

View File

@ -66,12 +66,15 @@
D(tradeWarBlock) D(tradeAllyBonus) D(tradeDiploBonus) D(tradeTemptWar) \
D(civColonizeRate) D(civColonyMinPop) D(civColonyMinHab) D(civColonyReach) D(civColonySeaReach) \
D(civColonySpacing) D(civColonySupply) \
D(cultAssimRate) D(cultConvertRate) D(cultConvertDominance) D(cultSpreadRange) \
D(cultPrestigeWeight) D(cultSchismRange) D(cultSchismRate) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
I(volcanoMaxCount) \
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) I(warMaxConcurrent) I(civMaxColonies) \
I(cultSchismMinMembers) I(cultSchismMinCluster) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed)
@ -332,6 +335,15 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.civColonySpacing, 0.0, 3.14159, "civColonySpacing"));
E(rng(cfg.civColonySupply, 0.0, 100.0, "civColonySupply"));
E(irng(cfg.civMaxColonies, 0, 1000000, "civMaxColonies"));
E(rng(cfg.cultAssimRate, 0.0, 1.0, "cultAssimRate"));
E(rng(cfg.cultConvertRate, 0.0, 1.0, "cultConvertRate"));
E(rng(cfg.cultConvertDominance, 0.0, 1000.0, "cultConvertDominance"));
E(rng(cfg.cultSpreadRange, 0.0, 3.14159, "cultSpreadRange"));
E(rng(cfg.cultPrestigeWeight, 0.0, 100.0, "cultPrestigeWeight"));
E(irng(cfg.cultSchismMinMembers, 1, 1000000, "cultSchismMinMembers"));
E(rng(cfg.cultSchismRange, 0.0, 3.14159, "cultSchismRange"));
E(irng(cfg.cultSchismMinCluster, 1, 1000000, "cultSchismMinCluster"));
E(rng(cfg.cultSchismRate, 0.0, 1.0, "cultSchismRate"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -553,17 +565,33 @@ void Planet::writeState(std::ostream& os) const {
writePod(os, t.a); writePod(os, t.b); writePod(os, t.attitude);
writePod(os, t.truceUntil); uint8_t k = (uint8_t)t.kind; writePod(os, k);
}
// v23: cultural evolution (civ Step 8). Culture identities are stateful (schism children append;
// frozen names/ethos/faith) and the per-settlement culture is mutable state. members/totalPop are
// derived tallies -> not written (recomputed by computeCultures()).
uint64_t ncu = cultures.size(); writePod(os, ncu);
for (const Culture& cu : cultures) {
writePod(os, cu.id); writePod(os, cu.regionId); writePod(os, cu.bank);
uint8_t e = (uint8_t)cu.ethos; writePod(os, e);
uint8_t f = (uint8_t)cu.faith; writePod(os, f);
writePod(os, cu.parentId); writePod(os, cu.foundedYear);
uint64_t L = cu.name.size(); writePod(os, L);
if (L) os.write(cu.name.data(), (std::streamsize)L);
L = cu.faithName.size(); writePod(os, L);
if (L) os.write(cu.faithName.data(), (std::streamsize)L);
}
writeVec(os, sSettleCulture);
writePod(os, sCultureNextId);
}
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes,
bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements,
bool hasConflict, bool hasDiplo) {
bool hasConflict, bool hasDiplo, bool hasCultures) {
Planet tmp;
tmp.drifting = drifting; // readState never serialized this flag; preserve old caller-visible behavior.
if (!tmp.readStateImpl(is, hasBiome, hasBiota, hasMoons, hasWeather, hasStorms, hasVolcanoes,
hasStatefulVolcanoes, hasGeography, hasGeoSalt, hasEcoregions,
hasSettlements, hasConflict, hasDiplo))
hasSettlements, hasConflict, hasDiplo, hasCultures))
return false;
*this = std::move(tmp);
return true;
@ -572,7 +600,7 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes,
bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements,
bool hasConflict, bool hasDiplo) {
bool hasConflict, bool hasDiplo, bool hasCultures) {
// Save blocks are append-only by version. If a caller asks for an older prefix,
// later blocks cannot exist in that stream even if the default arguments say otherwise.
if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; }
@ -583,6 +611,7 @@ bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool
if (!hasEcoregions) hasSettlements = false; // settlements follow the ecoregion block
if (!hasSettlements) hasConflict = false; // conflict follows the settlement block
if (!hasConflict) hasDiplo = false; // diplomacy follows the conflict block
if (!hasDiplo) hasCultures = false; // culture block follows the diplomacy block
// Read the length-prefixed key=value config block (see writeState). A default
// PlanetConfig is parsed over, so fields absent from an older save keep their
@ -833,6 +862,39 @@ bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool
}
if (!sanitizeDiplo(diplomacy, (int)settlements.size())) return false;
}
// v23: cultural evolution (civ Step 8). Culture identities + the per-settlement culture (pre-v23
// saves keep none -> re-seeded one-per-continent on the next computeCultures()).
if (hasCultures) {
uint64_t ncu = 0; readPod(is, ncu);
if (!is || ncu > 100000) return false;
cultures.resize((size_t)ncu);
uint32_t maxId = 0;
for (Culture& cu : cultures) {
readPod(is, cu.id); readPod(is, cu.regionId); readPod(is, cu.bank);
uint8_t e = 0, f = 0; readPod(is, e); readPod(is, f);
readPod(is, cu.parentId); readPod(is, cu.foundedYear);
if (!is) return false;
if (e > (uint8_t)CultureEthos::Warlike || f > (uint8_t)Faith::Harvest) return false;
cu.ethos = (CultureEthos)e; cu.faith = (Faith)f;
if (cu.parentId < -1 || cu.foundedYear < -1) return false;
uint64_t L = 0; readPod(is, L);
if (!is || L > 256) return false;
cu.name.resize((size_t)L);
if (L) is.read(cu.name.data(), (std::streamsize)L);
readPod(is, L);
if (!is || L > 256) return false;
cu.faithName.resize((size_t)L);
if (L) is.read(cu.faithName.data(), (std::streamsize)L);
if (!is) return false;
if (cu.id > maxId) maxId = cu.id;
}
if (!readVec(is, sSettleCulture, 1000000)) return false;
if (sSettleCulture.size() != settlements.size()) sSettleCulture.assign(settlements.size(), -1);
for (int& c : sSettleCulture) if (c < -1 || c >= (int)cultures.size()) c = -1;
readPod(is, sCultureNextId);
if (!is) return false;
if (sCultureNextId <= maxId) sCultureNextId = maxId + 1; // stale/zero counter -> recompute
}
computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is;
}

View File

@ -51,6 +51,17 @@ void Planet::computeTerritory() {
});
std::vector<int> capitalOf(settlements.size(), -1);
const bool haveAllegiance = (sSettleAllegiance.size() == settlements.size());
// Realms stay mono-cultural (civ Step 4): a settlement only vassalizes to a capital of its own
// CULTURE (Step 8 -- so conversions/schisms redraw realms). When either side has no culture yet
// (before the first computeCultures(), or a -1 entry) fall back to the old same-continent rule.
const bool haveCulture = (sSettleCulture.size() == settlements.size());
auto sameCulture = [&](int a, int b) {
if (haveCulture) {
int ca = sSettleCulture[a], cb = sSettleCulture[b];
if (ca >= 0 && cb >= 0) return ca == cb;
}
return settlements[a].regionId == settlements[b].regionId;
};
for (int s : order) {
if (range[s] <= 0.0) continue;
// Conquest override (civ Step 5): a conquered settlement joins its overlord's realm, crossing
@ -63,7 +74,7 @@ void Planet::computeTerritory() {
for (int c : order) {
if (c == s) break; // order is descending -> rest are smaller
if (capitalOf[c] != c) continue; // candidate must itself be a capital
if (settlements[c].regionId != settlements[s].regionId) continue; // realms stay mono-cultural (civ Step 4)
if (!sameCulture(c, s)) continue; // realms stay mono-cultural
double d = ang(s, c);
if (d < cfg.civVassalRange * range[c] && d < joinAng) { joinAng = d; joinCap = c; }
}

View File

@ -130,6 +130,13 @@ struct WeatherSnapshot {
uint32_t warRng = 0, warNextId = 0;
// Civilization Step 6 (diplomacy): standing realm relations (alliances / rivalries / truces).
std::vector<DiploTie> diplomacy;
// Civilization Step 8 (cultural evolution): per-settlement culture (mutable via conversion /
// assimilation) + the culture-list LENGTH. Culture records are append-only and immutable after
// creation, so a step back TRUNCATES the list (like colonies truncate settlements) and a
// deterministic replay re-creates any schism child identically -- no string-bearing records here
// (the viewer's persisted history frames are written as raw POD).
std::vector<int> settlementCulture;
uint32_t cultureCount = 0, cultureNextId = 0;
};
struct Plate {
@ -500,4 +507,16 @@ struct PlanetConfig {
double civColonySpacing = 0.05; // rad: a colony must be at least this far from every settlement
int civMaxColonies = 120; // cap on colonies founded beyond the initial civMaxSettlements
double civColonySupply = 2.0; // prosperity from the overlord supply link (keeps colonies alive)
// Civilization Step 8: cultural evolution (stateful, saved v23). Cultures spread along borders
// (settlements convert under dominant foreign cultural pressure), conquered settlements assimilate
// into their ruler's culture, and far-flung cultures (overseas colonies) schism into new peoples.
double cultAssimRate = 0.03; // per-year chance a settlement under foreign allegiance adopts its ruler's culture
double cultConvertRate = 0.02; // per-year chance scale for border conversion under dominant foreign pressure
double cultConvertDominance = 2.5; // foreign cultural pressure must exceed this x the own-culture support
double cultSpreadRange = 0.25; // rad: how far a settlement projects cultural pressure (~1600 km)
double cultPrestigeWeight = 0.5; // how much trade prosperity boosts a settlement's cultural weight
int cultSchismMinMembers = 6; // a culture needs at least this many living settlements to schism
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
double cultSchismRate = 0.08; // per-year chance a qualifying distant cluster becomes a new people
};

View File

@ -36,6 +36,10 @@ WeatherSnapshot Planet::captureWeather() const {
s.settlementAllegiance = sSettleAllegiance; // civ Step 5: conquest state
s.wars = wars; s.warRng = sWarRng; s.warNextId = sWarNextId;
s.diplomacy = diplomacy; // civ Step 6: standing realm relations
// Civ Step 8: per-settlement culture + the culture-list length. Records are append-only +
// immutable, so the length is enough -- a restore truncates and replay re-creates schism children.
s.settlementCulture = sSettleCulture;
s.cultureCount = (uint32_t)cultures.size(); s.cultureNextId = sCultureNextId;
return s;
}
@ -58,6 +62,13 @@ void Planet::restoreWeather(const WeatherSnapshot& s) {
if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1);
wars = s.wars; sWarRng = s.warRng ? s.warRng : sWarRng; sWarNextId = s.warNextId ? s.warNextId : sWarNextId;
diplomacy = s.diplomacy; // civ Step 6: restore realm relations
// Civ Step 8: truncate schism children born after the frame (append-only list) + restore the
// per-settlement culture; replay re-creates the same children (stepCulture is a pure function).
if (s.cultureCount <= cultures.size()) cultures.resize(s.cultureCount);
sSettleCulture = s.settlementCulture;
if (sSettleCulture.size() != settlements.size()) sSettleCulture.assign(settlements.size(), -1);
for (int& c : sSettleCulture) if (c < -1 || c >= (int)cultures.size()) c = -1;
sCultureNextId = s.cultureNextId ? s.cultureNextId : sCultureNextId;
sHasWeather = !sHumidity.empty();
}

315
test_cultevo.cpp Normal file
View File

@ -0,0 +1,315 @@
// 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 <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 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<int> 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<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: 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 one-per-continent roots");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CULTURAL-EVOLUTION CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}