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 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 stale territory/culture/war/diplomacy/trade overlay lines on the new world — `regenWorld()` now clears
the civ overlay vectors + toggles.)* the civ overlay vectors + toggles.)*
*Next steps (not yet built): cultural **evolution** (spread along borders, drift, assimilation, schism); - **Cultural evolution (Step 8)** *(done — see `PlanetCulture.cpp` `stepCulture`, save v23)* — cultures
tribute / vassalage treaties; an accumulated treasury (funding armies / buying peace).* 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 ## Current state
@ -661,7 +689,7 @@ src/
PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity) PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity)
PlanetCiv.* computeHabitability/placeSettlements/stepCivilization/stepColonization (settlements + colonies; civ Step 2) PlanetCiv.* computeHabitability/placeSettlements/stepCivilization/stepColonization (settlements + colonies; civ Step 2)
PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3) 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) 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) PlanetTrade.* computeTrade (trade routes + prosperity/wealth feeding growth; civ Step 7, derived)
PlanetIO.cpp config file (text) + binary save/load 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`, (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_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 / `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 suites — same source list. CMake also builds `test_events` for the Civilization / Nation / Culture / Conflict / Diplomacy / Trade / Colony / Cultural-evolution suites — same source list. CMake also builds `test_events` for the
viewer event journal.) viewer event journal.)
Use this to verify tectonics after changing `Planet::step()` without launching 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** (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 (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/ 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 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, 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** 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 CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate). 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 `Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe 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 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 `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** 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** 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 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 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 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 to the step-back frames so a load can rewind conquests, v22 the **diplomacy** block — standing realm
**relations** (alliances / rivalries / truces), likewise per-frame; **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 newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`); 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 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 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 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 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 A load drops any **stale** pre-load `wxUndo` history and reloads the
saved one. saved one.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved **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/ - **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 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. 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` - **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 (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` 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 `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 overlord supply link that keeps colonies alive). Runs in the yearly `stepColonization` tick; raise the
rate/reach for aggressive colonial empires. 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 - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds. knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -78,7 +78,7 @@ if(UNIX AND NOT APPLE)
endif() endif()
enable_testing() 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) add_executable(test_${test_name} test_${test_name}.cpp)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim) target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name}) add_test(NAME ${test_name} COMMAND test_${test_name})

View File

@ -53,6 +53,13 @@ namespace {
if (!validFrameWar(war, settlementCount)) return false; if (!validFrameWar(war, settlementCount)) return false;
if (!warsSeen.insert(std::minmax(war.attacker, war.defender)).second) 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); 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. // 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); { 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))); } 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. // 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); 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 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 (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 (!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 cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0); elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift 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))); } 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 (!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(); }; 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) 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()) || 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); ConflictUpdate wu = planet.stepConflict(y);
for (const WarEvent& e : wu.events) for (const WarEvent& e : wu.events)
appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail); 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) 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); 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 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). // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer { struct Viewer {
// ---- Files / save format ------------------------------------------------ // ---- 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 wxSaveMax = 40; // most recent step-back frames persisted in a save
static constexpr int EVENT_LOG_MAX = 200; static constexpr int EVENT_LOG_MAX = 200;
const char* CONFIG_PATH = "planet.cfg"; const char* CONFIG_PATH = "planet.cfg";

View File

@ -616,7 +616,7 @@ void Viewer::renderLiveInfo() {
: Color{175, 205, 235, 255}; : Color{175, 205, 235, 255};
DrawRectangleRec(row, bg); DrawRectangleRec(row, bg);
DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255}); 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); DrawText(icon, (int)row.x + 7, (int)row.y + 6, 18, fg);
double d = e.timeHours / std::max(0.1, planet.cfg.dayLengthHours); 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}); 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) { for (int ci : idx) {
if (y > (int)(r.y + r.height) - 22) break; if (y > (int)(r.y + r.height) - 22) break;
const Culture& cu = C[ci]; 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). // Representative cell: the largest living settlement of this culture (to fly to).
int repCell = -1; double repPop = -1.0; int repCell = -1; double repPop = -1.0;
for (size_t s = 0; s < planet.settlements.size(); ++s) 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 }; Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f };
eventRowRects.push_back(row); atlasRowCells.push_back(repCell); eventRowRects.push_back(row); atlasRowCells.push_back(repCell);
DrawText(cu.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, cultureColor(ci)); 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)), 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}); (int)(r.x + r.width) - 118, (int)row.y + 3, 11, Color{150, 158, 178, 255});
y += 20; y += 20;

View File

@ -64,7 +64,7 @@ void Planet::buildGeometry() {
sCellSettlement.assign(cells.size(), -1); sHabitability.clear(); sCellSettlement.assign(cells.size(), -1); sHabitability.clear();
sCivCond.clear(); sCivDrought.clear(); sCivCond.clear(); sCivDrought.clear();
nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.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); sCellSettleOwner.assign(cells.size(), -1);
sProsperity.clear(); sTradeLinks.clear(); sCellWealth.assign(cells.size(), 0.0); sProsperity.clear(); sTradeLinks.clear(); sCellWealth.assign(cells.size(), 0.0);
wars.clear(); sSettleAllegiance.clear(); diplomacy.clear(); 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>& 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) const std::vector<int>& settleNation() const { return sSettleNation; } // nation index per settlement (-1 = dead)
// Cultures, beliefs & governments (PlanetCulture.cpp). computeCultures() groups settlements into // Cultures, beliefs & governments (PlanetCulture.cpp). computeCultures() is the DERIVED refresh:
// peoples (one per inhabited continent), gives each a dominant ethos + religion, assigns each realm // it seeds the cultures once (one per inhabited continent) when none exist, then only recomputes
// a government type (folded into nation.name), and tags cells/settlements with their culture. Runs // tallies, governments (folded into nation.name) and the per-cell culture view. Runs AFTER
// AFTER computeTerritory() (reads nations/sCellNation). Purely derived, recomputed, not saved. // 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(); void computeCultures();
std::vector<WarEvent> stepCulture(long year);
bool culturesBuilt() const { return !cultures.empty(); } bool culturesBuilt() const { return !cultures.empty(); }
const std::vector<Culture>& cultureList() const { return cultures; } const std::vector<Culture>& cultureList() const { return cultures; }
const std::vector<int>& cellCulture() const { return sCellCulture; } // culture index per cell (-1 = none) 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 // 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. // stream carries the ecoregion atlas (save v19+). Older saves regenerate on demand.
// hasSettlements: whether the stream carries the civilization settlements block (save v20+); older // 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 readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true, bool hasMoons = true, bool hasWeather = true, bool hasStorms = true,
bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true, bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true,
bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = 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. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing
@ -292,7 +298,7 @@ private:
bool hasMoons, bool hasWeather, bool hasStorms, bool hasMoons, bool hasWeather, bool hasStorms,
bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography,
bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, 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 clearDerivedState(); // clear geometry-dependent scratch/derived fields
void assignPlates(); void assignPlates();
void seedInitialRelief(); void seedInitialRelief();
@ -382,9 +388,13 @@ private:
// Territory & nations (derived from settlements; not saved). sCellNation: nation index per cell // Territory & nations (derived from settlements; not saved). sCellNation: nation index per cell
// (-1 = wilderness/ocean); sSettleNation: nation index per settlement. // (-1 = wilderness/ocean); sSettleNation: nation index per settlement.
std::vector<int> sCellNation, sSettleNation; std::vector<int> sCellNation, sSettleNation;
// Cultures (derived from settlements + geography; not saved). sCellCulture: culture index per cell // Cultures (civ Step 8: stateful, saved v23). sSettleCulture: culture index per settlement --
// (-1 = none/wilderness/ocean); 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; 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). // 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. // Reused by the trade wealth field so the heat map matches territory exactly.
std::vector<int> sCellSettleOwner; 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 sSettleNation[newIdx] = (int)ni; // the colony is (already) the founder's realm
if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.resize(settlements.size(), -1); if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.resize(settlements.size(), -1);
sSettleAllegiance[newIdx] = cap; // the colony belongs to the founder's realm 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); } 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, ev.push_back(WarEvent{ 1, best, "The " + nat.name + " founds the colony of " + settlements[newIdx].name,
"A new settlement rises on distant land.", 3 }); "A new settlement rises on distant land.", 3 });

View File

@ -1,18 +1,93 @@
#include "Planet.hpp" #include "Planet.hpp"
#include "NameGen.hpp" #include "NameGen.hpp"
#include <algorithm> #include <algorithm>
#include <cctype>
#include <cmath> #include <cmath>
#include <unordered_map> #include <unordered_map>
// --- Civilization Step 4: cultures, beliefs & governments -------------------- // --- Civilization Step 4 + Step 8: cultures, beliefs, governments & cultural evolution --------------
// Settlements on the same continent share a CULTURE (a people/language family with an environment-driven // Settlements share a CULTURE (a people/language family with an environment-driven ethos + a religion);
// ethos + a religion); each realm gets a GOVERNMENT type folded into its name. A pure deterministic // each realm gets a GOVERNMENT type folded into its name. Since Step 8 the culture layer is split:
// function of the settlement set + geography + biomes (no RNG -> tectonic stream untouched), computed // - seedCultures() one-time seeding at the dawn (one culture per inhabited continent) -- also runs
// AFTER computeTerritory() so `nations` / `sCellNation` already exist. Recomputed rather than saved. // 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 { namespace {
// A pure hash for deterministic picks -- never touches rngState (mirrors civHash in PlanetCiv.cpp). // 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 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) { const char* cultureEthosName(CultureEthos e) {
@ -55,18 +130,21 @@ const char* govTypeName(GovType g) {
return "Kingdom"; return "Kingdom";
} }
void Planet::computeCultures() { // One-time seeding: group living settlements into cultures, one per inhabited continent (regionId;
const int n = (int)cells.size(); // 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(); cultures.clear();
sCellCulture.assign(n, -1);
sSettleCulture.assign(settlements.size(), -1); sSettleCulture.assign(settlements.size(), -1);
sCultureNextId = 1;
if (settlements.empty()) return; if (settlements.empty()) return;
const int n = (int)cells.size();
const double sea = cfg.seaLevel, abP = cfg.civAbandonPop; const double sea = cfg.seaLevel, abP = cfg.civAbandonPop;
const uint32_t seed = cfg.seed ? cfg.seed : 1u; const uint32_t seed = cfg.seed ? cfg.seed : 1u;
// 1) Group living settlements into cultures, one per inhabited continent (regionId). Settlements // 1) Group living settlements into cultures.
// with no geography (regionId < 0) fall back to a per-language-bank culture.
std::unordered_map<int, int> keyToCulture; std::unordered_map<int, int> keyToCulture;
auto living = [&](const Settlement& s) { auto living = [&](const Settlement& s) {
return s.cell >= 0 && s.cell < n && s.population >= abP; return s.cell >= 0 && s.cell < n && s.population >= abP;
@ -79,7 +157,7 @@ void Planet::computeCultures() {
int ci; int ci;
if (it == keyToCulture.end()) { if (it == keyToCulture.end()) {
ci = (int)cultures.size(); 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); cultures.push_back(cu);
keyToCulture[key] = ci; keyToCulture[key] = ci;
} else ci = it->second; } else ci = it->second;
@ -89,77 +167,68 @@ void Planet::computeCultures() {
} }
if (cultures.empty()) return; if (cultures.empty()) return;
// 2) Environmental tally over each culture's settlement cells -> ethos + a dominant biome for faith. // 2) Environmental tally over each culture's settlement cells.
struct Acc { int cnt = 0, coast = 0, high = 0, arid = 0, fert = 0; int biome[13] = {0}; }; std::vector<CultEnv> acc(cultures.size());
std::vector<Acc> acc(cultures.size());
const bool haveMoist = (int)sMoist.size() == n;
for (size_t k = 0; k < settlements.size(); ++k) { for (size_t k = 0; k < settlements.size(); ++k) {
int ci = sSettleCulture[k]; if (ci < 0) continue; int ci = sSettleCulture[k]; if (ci < 0) continue;
int c = settlements[k].cell; envAdd(acc[ci], cells, sMoist, sea, 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++;
} }
// 3) Per-culture ethos, faith and names (all deterministic hashes of the region + seed). // 3) Per-culture ethos, faith and names (all deterministic hashes of the region + seed).
for (size_t ci = 0; ci < cultures.size(); ++ci) { for (size_t ci = 0; ci < cultures.size(); ++ci) {
Culture& cu = cultures[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; uint32_t rk = (uint32_t)(cu.regionId >= 0 ? cu.regionId : (1000 - cu.bank)) + 1u;
envPick(acc[ci], seed, rk, cu.members, cu.ethos, cu.faith);
// 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.
uint32_t nameSeed = seed ^ cultHash(rk * 2654435761u + 0x50C1A1u); uint32_t nameSeed = seed ^ cultHash(rk * 2654435761u + 0x50C1A1u);
uint32_t faithSeed = seed ^ cultHash(rk * 40503u + 0xFA17Fu); uint32_t faithSeed = seed ^ cultHash(rk * 40503u + 0xFA17Fu);
cu.name = "the " + namegen::makeName(nameSeed, cu.bank); cu.name = "the " + namegen::makeName(nameSeed, cu.bank);
std::string root = namegen::makeName(faithSeed, cu.bank); cu.faithName = makeFaithName(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;
} }
}
// 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. // tag the realm with its capital's culture.
for (Nation& nat : nations) { for (Nation& nat : nations) {
nat.cultureId = (nat.capital >= 0 && nat.capital < (int)sSettleCulture.size()) 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) { for (int i = 0; i < n; ++i) {
int ni = sCellNation[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 // 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 // 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, // religion. Each realm (Step 3 Nation) gets a GOVERNMENT type folded into its name.
// all of this is a DETERMINISTIC function of the settlement set + geography + biomes/climate, computed // Civilization Step 8 (cultural evolution) made culture identities STATEFUL: the culture list is
// with pure hashes (no RNG touched) -- so it is recomputed rather than saved (no save-format change), // append-only (seeded once per continent at the dawn, schism children appended later; a record is
// and the live stepper rewinds it for free. // 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 CultureEthos : uint8_t { Agrarian, Seafaring, Nomadic, Highland, Mercantile, Warlike };
enum class Faith : uint8_t { Sun, Moon, Sea, Sky, Earth, Ancestors, War, Harvest }; 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) int bank = 0; // NameGen "language" bank (shared by the region's places)
CultureEthos ethos = CultureEthos::Agrarian; CultureEthos ethos = CultureEthos::Agrarian;
Faith faith = Faith::Earth; Faith faith = Faith::Earth;
int members = 0; // number of settlements sharing this culture int members = 0; // number of settlements sharing this culture (derived tally)
double totalPop = 0.0; // summed population of those settlements 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 name; // the people / demonym, e.g. "the Velmar"
std::string faithName; // the religion's proper name, e.g. "the Tidewardens" 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(tradeWarBlock) D(tradeAllyBonus) D(tradeDiploBonus) D(tradeTemptWar) \
D(civColonizeRate) D(civColonyMinPop) D(civColonyMinHab) D(civColonyReach) D(civColonySeaReach) \ D(civColonizeRate) D(civColonyMinPop) D(civColonyMinHab) D(civColonyReach) D(civColonySeaReach) \
D(civColonySpacing) D(civColonySupply) \ 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(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
I(volcanoMaxCount) \ I(volcanoMaxCount) \
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \ I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) I(warMaxConcurrent) I(civMaxColonies) \ I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) I(warMaxConcurrent) I(civMaxColonies) \
I(cultSchismMinMembers) I(cultSchismMinCluster) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed) U(seed)
@ -332,6 +335,15 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.civColonySpacing, 0.0, 3.14159, "civColonySpacing")); E(rng(cfg.civColonySpacing, 0.0, 3.14159, "civColonySpacing"));
E(rng(cfg.civColonySupply, 0.0, 100.0, "civColonySupply")); E(rng(cfg.civColonySupply, 0.0, 100.0, "civColonySupply"));
E(irng(cfg.civMaxColonies, 0, 1000000, "civMaxColonies")); 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.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth")); 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.a); writePod(os, t.b); writePod(os, t.attitude);
writePod(os, t.truceUntil); uint8_t k = (uint8_t)t.kind; writePod(os, k); 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 Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes,
bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements,
bool hasConflict, bool hasDiplo) { bool hasConflict, bool hasDiplo, bool hasCultures) {
Planet tmp; Planet tmp;
tmp.drifting = drifting; // readState never serialized this flag; preserve old caller-visible behavior. tmp.drifting = drifting; // readState never serialized this flag; preserve old caller-visible behavior.
if (!tmp.readStateImpl(is, hasBiome, hasBiota, hasMoons, hasWeather, hasStorms, hasVolcanoes, if (!tmp.readStateImpl(is, hasBiome, hasBiota, hasMoons, hasWeather, hasStorms, hasVolcanoes,
hasStatefulVolcanoes, hasGeography, hasGeoSalt, hasEcoregions, hasStatefulVolcanoes, hasGeography, hasGeoSalt, hasEcoregions,
hasSettlements, hasConflict, hasDiplo)) hasSettlements, hasConflict, hasDiplo, hasCultures))
return false; return false;
*this = std::move(tmp); *this = std::move(tmp);
return true; 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 Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes,
bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, 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, // 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. // later blocks cannot exist in that stream even if the default arguments say otherwise.
if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; } 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 (!hasEcoregions) hasSettlements = false; // settlements follow the ecoregion block
if (!hasSettlements) hasConflict = false; // conflict follows the settlement block if (!hasSettlements) hasConflict = false; // conflict follows the settlement block
if (!hasConflict) hasDiplo = false; // diplomacy follows the conflict 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 // 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 // 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; 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 computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is; return (bool)is;
} }

View File

@ -51,6 +51,17 @@ void Planet::computeTerritory() {
}); });
std::vector<int> capitalOf(settlements.size(), -1); std::vector<int> capitalOf(settlements.size(), -1);
const bool haveAllegiance = (sSettleAllegiance.size() == settlements.size()); 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) { for (int s : order) {
if (range[s] <= 0.0) continue; if (range[s] <= 0.0) continue;
// Conquest override (civ Step 5): a conquered settlement joins its overlord's realm, crossing // 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) { for (int c : order) {
if (c == s) break; // order is descending -> rest are smaller if (c == s) break; // order is descending -> rest are smaller
if (capitalOf[c] != c) continue; // candidate must itself be a capital 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); double d = ang(s, c);
if (d < cfg.civVassalRange * range[c] && d < joinAng) { joinAng = d; joinCap = 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; uint32_t warRng = 0, warNextId = 0;
// Civilization Step 6 (diplomacy): standing realm relations (alliances / rivalries / truces). // Civilization Step 6 (diplomacy): standing realm relations (alliances / rivalries / truces).
std::vector<DiploTie> diplomacy; 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 { struct Plate {
@ -500,4 +507,16 @@ struct PlanetConfig {
double civColonySpacing = 0.05; // rad: a colony must be at least this far from every settlement 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 int civMaxColonies = 120; // cap on colonies founded beyond the initial civMaxSettlements
double civColonySupply = 2.0; // prosperity from the overlord supply link (keeps colonies alive) 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.settlementAllegiance = sSettleAllegiance; // civ Step 5: conquest state
s.wars = wars; s.warRng = sWarRng; s.warNextId = sWarNextId; s.wars = wars; s.warRng = sWarRng; s.warNextId = sWarNextId;
s.diplomacy = diplomacy; // civ Step 6: standing realm relations 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; return s;
} }
@ -58,6 +62,13 @@ void Planet::restoreWeather(const WeatherSnapshot& s) {
if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1); 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; wars = s.wars; sWarRng = s.warRng ? s.warRng : sWarRng; sWarNextId = s.warNextId ? s.warNextId : sWarNextId;
diplomacy = s.diplomacy; // civ Step 6: restore realm relations 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(); 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;
}