diff --git a/CLAUDE.md b/CLAUDE.md index e0b3d86..0d033f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,8 +249,36 @@ on the Live World clock). **Steps 1–7 of the roadmap are done (plus a derived colony of Y"). Knobs `civColon*`. *(Also fixed: pressing `R` (reseed) after civilization existed left stale territory/culture/war/diplomacy/trade overlay lines on the new world — `regenWorld()` now clears the civ overlay vectors + toggles.)* - *Next steps (not yet built): cultural **evolution** (spread along borders, drift, assimilation, schism); - tribute / vassalage treaties; an accumulated treasury (funding armies / buying peace).* +- **Cultural evolution (Step 8)** *(done — see `PlanetCulture.cpp` `stepCulture`, save v23)* — cultures + stop being static one-per-continent blocs. Culture identities are now **stateful**: the list is + **append-only** (seeded once at the dawn — the same one-per-continent peoples as before — with schism + children appended later; a record is **frozen after creation** so indices/colours stay stable and the + ethos/faith no longer silently re-derive) and the **per-settlement culture is mutable state**. + `stepCulture(year)` runs in the yearly tick (between `stepConflict` and `stepColonization`), all **pure + hashes** of (stable id, year, seed) — no RNG: **assimilation** (a settlement held by a foreign-culture + overlord adopts its ruler's culture, `cultAssimRate` — which drops the Step-5 revolt `cultBonus` on its + own, so assimilation pacifies provinces), **border conversion** (a settlement dwarfed by a nearby foreign + culture's weight = population × trade prestige converts toward it, `cultConvert*`/`cultSpreadRange`/ + `cultPrestigeWeight`; **realm capitals are exempt** — they anchor identity), and **schism** (a large + culture's far-flung coherent cluster — typically overseas colonies — breaks away as a **new people**, + `cultSchism*`: a fresh NameGen name from the local language bank, ethos/faith **re-derived from its own + lands** — keeping the parent's faith name if the faith is unchanged — parent id + founding year recorded). + **Colonies now inherit the founder's culture** at founding (the seed for later colonial schisms), and the + Step-3 mono-cultural vassalage rule is **culture-matched** (regionId fallback before the dawn / for + -1 entries) — a schism cluster stops vassalizing to the old capital, founds its own realm, and the + culture/faith diplomacy affinity drifts parent and child toward rivalry → **colonial independence wars + emerge**. The per-cell culture view (`X`) now colours by the owning **settlement's** culture, so a + conquered city keeps its people's colour until it assimilates. `computeCultures()` became a pure derived + refresh (tallies/governments/cell view; seeds only when the list is empty — the dawn / pre-v23-load + path); the state — culture identities + `sSettleCulture` (+ a next-id counter) — is **saved (v23)** and + **snapshotted via truncate-and-replay**: the list is append-only and `stepCulture` is a pure function, + so a step-back frame stores just the per-settlement vector + the list *length*, `,` truncates schism + children and a replay re-creates them identically (the colony-truncation precedent). **Kind=7** + `WorldEvent`s ("X adopts the culture of the Velmar", "X embraces the ways of the Nharos", "The Nharos + break away from the Velmar"); the **Cultures** tab hides extinct peoples (members 0 — slots persist) and + shows a schism child's founding year. Knobs `cult*`. + *Next steps (not yet built): tribute / vassalage treaties; an accumulated treasury (funding armies / + buying peace).* ## Current state @@ -661,7 +689,7 @@ src/ PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity) PlanetCiv.* computeHabitability/placeSettlements/stepCivilization/stepColonization (settlements + colonies; civ Step 2) PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3) - PlanetCulture.* computeCultures (cultures/ethos/faith per continent + governments; civ Step 4) + PlanetCulture.* computeCultures (derived refresh + one-time seeding; civ Step 4) + stepCulture (assimilation/conversion/schism; civ Step 8, save v23) PlanetConflict.* stepConflict (wars/conquest/revolts + diplomacy/alliances/coalitions; civ Steps 5-6, save v21/v22) PlanetTrade.* computeTrade (trade routes + prosperity/wealth feeding growth; civ Step 7, derived) PlanetIO.cpp config file (text) + binary save/load @@ -732,8 +760,8 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ ``` (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`, `test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`, -`test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp`, `test_diplomacy.cpp`, `test_trade.cpp` or `test_colony.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / -Civilization / Nation / Culture / Conflict / Diplomacy / Trade / Colony suites — same source list. CMake also builds `test_events` for the +`test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp`, `test_diplomacy.cpp`, `test_trade.cpp`, `test_colony.cpp` or `test_cultevo.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / +Civilization / Nation / Culture / Conflict / Diplomacy / Trade / Colony / Cultural-evolution suites — same source list. CMake also builds `test_events` for the viewer event journal.) Use this to verify tectonics after changing `Planet::step()` without launching @@ -803,11 +831,15 @@ Territory view on (`P`), realms **wage war** each year — red war-fronts appear (borders move) or fall to ruins, empires fracture as provinces revolt; realms also form **alliances** (green arcs) and **rivalries** (dark-red arcs), allies **join each other's wars** (coalitions) and never fight each other, and wars end in **peace treaties**; the **Realms** tab lists each realm's allies/ -rivals/wars and the **Events** tab logs it all. `Z` shows the **trade economy** — routes (cyan sea / +rivals/wars and the **Events** tab logs it all. **Cultures evolve** on the same yearly tick (`X` view) — +conquered cities keep their people's colour until they **assimilate** into the ruler's culture, border +towns **convert** under a dominant neighbour's cultural weight, and a far-flung colony cluster can +**schism** into a new people (a new name/colour) that founds its own realm — colonial independence wars +follow. `Z` shows the **trade economy** — routes (cyan sea / amber land) + a **wealth heat map**; well-connected coastal/river **hubs** grow richer & bigger, a war cuts the routes between belligerents, and the **Civ** tab shows each settlement's wealth. `Y` makes the 3D camera **follow a storm** (cycles by strength, off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and** -weather/storms/volcano lifecycle **and wars/conquests** via snapshots). Mouse-wheel over the 2D map zooms (drag pans). +weather/storms/volcano lifecycle **and wars/conquests and cultural shifts** via snapshots). Mouse-wheel over the 2D map zooms (drag pans). CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config file (both applied before the initial load/generate). @@ -818,7 +850,7 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and `Planet::writeState`/`readState`, resumes deterministically). Config is range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe defaults (without overwriting your `planet.cfg`) and shows a status message. The -save header is versioned (currently **22**; v2 adds the `[`/`]` drift rate, v3 a +save header is versioned (currently **23**; v2 adds the `[`/`]` drift rate, v3 a `phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing key=value text block** instead of a raw POD dump, v7 appends the **biota population** block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World** @@ -832,8 +864,11 @@ appends the **geography/atlas** block — named features + per-cell region indic reshuffle salt, v19 the **ecoregions** block, v20 the **civilization settlements** block (the fixed settlement set + per-frame populations in the step-back history), v21 the **civilization conflict** block — per-settlement **allegiance** (conquest) + active **wars** + the war RNG, also added -to the step-back frames so a load can rewind conquests, and v22 the **diplomacy** block — standing realm -**relations** (alliances / rivalries / truces), likewise per-frame; +to the step-back frames so a load can rewind conquests, v22 the **diplomacy** block — standing realm +**relations** (alliances / rivalries / truces), likewise per-frame, and v23 the **culture** block — +stateful culture identities (append-only; schism children) + the per-settlement culture + a next-id +counter (cultural evolution), likewise per-frame (per-settlement vector + the list *length*; a rewind +truncates, a replay re-creates); newer-than-supported is rejected. Older saves (no biota block) load fine with an empty population (press `L`); pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10 @@ -844,7 +879,9 @@ entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with o pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin -again as the clock runs); pre-v22 saves load with no diplomacy (relations re-form as the clock runs). +again as the clock runs); pre-v22 saves load with no diplomacy (relations re-form as the clock runs); +pre-v23 saves load with no culture state (re-seeded one-per-continent on the next refresh — the same +peoples as before; evolution starts from there). A load drops any **stale** pre-load `wxUndo` history and reloads the saved one. **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved @@ -1006,7 +1043,8 @@ triangles (plates are fixed in phase 1). - **Culture, beliefs & governments (civ Step 4, key `X`):** **no config knobs** — the ethos/faith/ government rules are internal constants in `PlanetCulture.cpp` (ethos-signal threshold 0.34, the biome→faith map, the tier→government picks) and the culture colours/border colour are render constants. - Cultures are derived (recomputed with territory, not saved). To retune, edit `computeCultures()`. + Since Step 8 identities are stateful (seeded once, saved v23); to retune the rules, edit + `envPick()`/`seedCultures()` in `PlanetCulture.cpp` (a reseed applies them). - **Conflict & war (`war*` in PlanetConfig / `planet.cfg`; civ Step 5, save v21):** `warMaxConcurrent` (6, simultaneous wars), `warDeclareRate` (0.12, war-declaration chance × hostility), the hostility weights `warAmbition` (1.0, size gap) / `warIdeology` (0.8, culture+faith difference) / `warBorder` @@ -1043,6 +1081,17 @@ triangles (plates are fixed in phase 1). `civMaxColonies` (120, cap beyond `civMaxSettlements`), `civColonySupply` (2.0, prosperity from the overlord supply link that keeps colonies alive). Runs in the yearly `stepColonization` tick; raise the rate/reach for aggressive colonial empires. +- **Cultural evolution (`cult*` in PlanetConfig / `planet.cfg`; civ Step 8, save v23):** assimilation — + `cultAssimRate` (0.03, per-year chance a conquered settlement adopts its ruler's culture); border + conversion — `cultConvertRate` (0.02, per-year chance scale), `cultConvertDominance` (2.5, foreign + pressure must exceed this × own support — raise to make cultures stickier), `cultSpreadRange` (0.25 rad, + how far a settlement projects cultural pressure), `cultPrestigeWeight` (0.5, trade prosperity's boost to + cultural weight — rich hubs radiate culture); schism — `cultSchismMinMembers` (6, min living settlements + to schism), `cultSchismRange` (0.55 rad from the population centroid past which members are "distant" — + lower = easier colonial breakaways), `cultSchismMinCluster` (2, distant settlements needed to break away + together), `cultSchismRate` (0.08, per-year chance per qualifying culture). All rates 0 = the static + pre-Step-8 world. Runs in the yearly `stepCulture` tick (pure hashes, no RNG); the culture list is + capped at 64 peoples. - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main knob for how fast/high relief builds. - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks diff --git a/CMakeLists.txt b/CMakeLists.txt index f0e7eaa..44ded36 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,7 +78,7 @@ if(UNIX AND NOT APPLE) endif() enable_testing() -foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy trade colony) +foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy trade colony cultevo) add_executable(test_${test_name} test_${test_name}.cpp) target_link_libraries(test_${test_name} PRIVATE planetsim_sim) add_test(NAME ${test_name} COMMAND test_${test_name}) diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 91596b3..0c39c38 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -53,6 +53,13 @@ namespace { if (!validFrameWar(war, settlementCount)) return false; if (!warsSeen.insert(std::minmax(war.attacker, war.defender)).second) return false; } + // v23: per-settlement culture (indices into the frame's culture-list prefix). + if (w.cultureCount > 100000) return false; + if (!w.settlementCulture.empty()) { + if ((int)w.settlementCulture.size() != settlementCount) return false; + for (int c : w.settlementCulture) + if (c < -1 || c >= (int)w.cultureCount) return false; + } return sanitizeFrameDiplomacy(w.diplomacy, settlementCount); } } @@ -565,6 +572,11 @@ void Viewer::saveGame(const char* path) { // v22: per-frame diplomacy, so a load can rewind alliances/rivalries past the saved moment. { uint64_t m = f.w.diplomacy.size(); os.write((char*)&m, 8); if (m) os.write((const char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); } + // v23: per-frame culture state (per-settlement culture + culture-list length), so a load can + // rewind conversions/assimilations/schisms past the saved moment. + { uint64_t m = f.w.settlementCulture.size(); os.write((char*)&m, 8); + if (m) os.write((const char*)f.w.settlementCulture.data(), (std::streamsize)(m * sizeof(int))); } + os.write((char*)&f.w.cultureCount, 4); os.write((char*)&f.w.cultureNextId, 4); } // v16: persistent world event journal, separate from step-back history. uint32_t en = (uint32_t)std::min(events.size(), (size_t)EVENT_LOG_MAX); @@ -598,7 +610,7 @@ void Viewer::loadGame(const char* path) { is.read(reinterpret_cast(&lh), sizeof lh); } // v8: Live World clock if (ver >= 13) is.read(reinterpret_cast(&lr), sizeof lr); // v13: Live World rate if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } - if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy + if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22, ver >= 23)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy, v23 cultures cfg = planet.cfg; // adopt the loaded config elapsedMy = em; settled = (st != 0); planet.drifting = settled; // resume drift boosts iff mid-drift @@ -667,6 +679,13 @@ void Viewer::loadGame(const char* path) { else { f.w.diplomacy.resize((size_t)m); if (m) is.read((char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); } if (!is) historyOk = false; } + if (ver >= 23) { // v23: per-frame culture state + uint64_t m = 0; is.read((char*)&m, 8); + if (!is || m > 1000000) historyOk = false; + else { f.w.settlementCulture.resize((size_t)m); if (m) is.read((char*)f.w.settlementCulture.data(), (std::streamsize)(m * sizeof(int))); } + is.read((char*)&f.w.cultureCount, 4); is.read((char*)&f.w.cultureNextId, 4); + if (!is) historyOk = false; + } auto sized = [&](const std::vector& v) { return v.empty() || v.size() == planet.cells.size(); }; if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain) || f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size()) @@ -826,6 +845,10 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) { ConflictUpdate wu = planet.stepConflict(y); for (const WarEvent& e : wu.events) appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail); + // Step 8 after conflict (assimilation reads this year's fresh allegiance) and before + // colonization (a colony is stamped with its founder's possibly-just-changed culture). + for (const WarEvent& e : planet.stepCulture(y)) // cultural evolution + appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail); for (const WarEvent& e : planet.stepColonization(y)) // kingdoms found new colonies (appends settlements) appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail); if (y < year) rebuildTerritory(); // recompute between years so the next year's war sees it diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index 81111fa..d9e658b 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -15,7 +15,7 @@ // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). struct Viewer { // ---- Files / save format ------------------------------------------------ - static constexpr uint32_t SAVE_VERSION = 22; // v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 + static constexpr uint32_t SAVE_VERSION = 23; // v23: civ cultural evolution; v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save static constexpr int EVENT_LOG_MAX = 200; const char* CONFIG_PATH = "planet.cfg"; diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index 4e9e63d..d76cd7e 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -616,7 +616,7 @@ void Viewer::renderLiveInfo() { : Color{175, 205, 235, 255}; DrawRectangleRec(row, bg); DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255}); - const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : e.kind == 6 ? "=" : "~"; + const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : e.kind == 6 ? "=" : e.kind == 7 ? "@" : "~"; DrawText(icon, (int)row.x + 7, (int)row.y + 6, 18, fg); double d = e.timeHours / std::max(0.1, planet.cfg.dayLengthHours); DrawText(TextFormat("D%.1f", d), (int)row.x + 24, (int)row.y + 5, 12, Color{145, 155, 175, 255}); @@ -798,6 +798,7 @@ void Viewer::renderLiveInfo() { for (int ci : idx) { if (y > (int)(r.y + r.height) - 22) break; const Culture& cu = C[ci]; + if (cu.members <= 0) continue; // extinct people (Step 8): keeps its slot, not listed // Representative cell: the largest living settlement of this culture (to fly to). int repCell = -1; double repPop = -1.0; for (size_t s = 0; s < planet.settlements.size(); ++s) @@ -806,6 +807,10 @@ void Viewer::renderLiveInfo() { Rectangle row{ r.x + 10.0f, (float)y - 2.0f, r.width - 20.0f, 19.0f }; eventRowRects.push_back(row); atlasRowCells.push_back(repCell); DrawText(cu.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, cultureColor(ci)); + // Step 8: a schism child shows its founding year (roots have been there since the dawn). + if (cu.foundedYear >= 0) + DrawText(TextFormat("b. yr %ld", cu.foundedYear), + (int)(r.x + r.width) - 180, (int)row.y + 3, 11, Color{135, 142, 160, 255}); DrawText(TextFormat("%s %s", cultureEthosName(cu.ethos), faithFocusName(cu.faith)), (int)(r.x + r.width) - 118, (int)row.y + 3, 11, Color{150, 158, 178, 255}); y += 20; diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index f998649..d0d76dd 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -64,7 +64,7 @@ void Planet::buildGeometry() { sCellSettlement.assign(cells.size(), -1); sHabitability.clear(); sCivCond.clear(); sCivDrought.clear(); nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear(); - cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); + cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); sCultureNextId = 1; sCellSettleOwner.assign(cells.size(), -1); sProsperity.clear(); sTradeLinks.clear(); sCellWealth.assign(cells.size(), 0.0); wars.clear(); sSettleAllegiance.clear(); diplomacy.clear(); diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 929d5da..3b4442f 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -210,11 +210,15 @@ public: const std::vector& cellNation() const { return sCellNation; } // nation index per cell (-1 = wilderness/sea) const std::vector& settleNation() const { return sSettleNation; } // nation index per settlement (-1 = dead) - // Cultures, beliefs & governments (PlanetCulture.cpp). computeCultures() groups settlements into - // peoples (one per inhabited continent), gives each a dominant ethos + religion, assigns each realm - // a government type (folded into nation.name), and tags cells/settlements with their culture. Runs - // AFTER computeTerritory() (reads nations/sCellNation). Purely derived, recomputed, not saved. + // Cultures, beliefs & governments (PlanetCulture.cpp). computeCultures() is the DERIVED refresh: + // it seeds the cultures once (one per inhabited continent) when none exist, then only recomputes + // tallies, governments (folded into nation.name) and the per-cell culture view. Runs AFTER + // computeTerritory() (reads nations/sCellNation/sCellSettleOwner). Culture identities + the + // per-settlement culture are STATEFUL since Step 8 (saved v23 + snapshotted); stepCulture() is + // the once-per-sim-year mutation pass (border conversion / assimilation / schism, pure hashes -- + // no RNG touched, so step-back replays it exactly). void computeCultures(); + std::vector stepCulture(long year); bool culturesBuilt() const { return !cultures.empty(); } const std::vector& cultureList() const { return cultures; } const std::vector& cellCulture() const { return sCellCulture; } // culture index per cell (-1 = none) @@ -267,12 +271,14 @@ public: // whether the stream carries the active reshuffle salt (save v18+); hasEcoregions: whether the // stream carries the ecoregion atlas (save v19+). Older saves regenerate on demand. // hasSettlements: whether the stream carries the civilization settlements block (save v20+); older - // saves load with none (re-seeded on demand via the civ key). + // saves load with none (re-seeded on demand via the civ key). hasCultures: whether the stream + // carries the stateful culture block (save v23+); older saves re-seed one culture per continent + // on the next computeCultures(). bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, bool hasMoons = true, bool hasWeather = true, bool hasStorms = true, bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true, bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true, - bool hasConflict = true, bool hasDiplo = true); + bool hasConflict = true, bool hasDiplo = true, bool hasCultures = true); // Helpers for rendering / info. double cellWidthMeters() const; // approx lateral cell spacing @@ -292,7 +298,7 @@ private: bool hasMoons, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, - bool hasConflict, bool hasDiplo); + bool hasConflict, bool hasDiplo, bool hasCultures); void clearDerivedState(); // clear geometry-dependent scratch/derived fields void assignPlates(); void seedInitialRelief(); @@ -382,9 +388,13 @@ private: // Territory & nations (derived from settlements; not saved). sCellNation: nation index per cell // (-1 = wilderness/ocean); sSettleNation: nation index per settlement. std::vector sCellNation, sSettleNation; - // Cultures (derived from settlements + geography; not saved). sCellCulture: culture index per cell - // (-1 = none/wilderness/ocean); sSettleCulture: culture index per settlement. + // Cultures (civ Step 8: stateful, saved v23). sSettleCulture: culture index per settlement -- + // mutable identity state (seeded at the dawn, changed only by stepCulture()/colony founding); + // sCellCulture: the derived per-cell view (refreshed by computeCultures(), not saved). + // sCultureNextId: stable id counter for appended (schism) cultures. std::vector sCellCulture, sSettleCulture; + uint32_t sCultureNextId = 1; + void seedCultures(); // one-time seeding (dawn / pre-v23 load), PlanetCulture.cpp // Per-cell owning settlement index (filled by computeTerritory alongside sCellNation; -1 = none). // Reused by the trade wealth field so the heat map matches territory exactly. std::vector sCellSettleOwner; diff --git a/src/sim/PlanetCiv.cpp b/src/sim/PlanetCiv.cpp index deb358f..948e8db 100644 --- a/src/sim/PlanetCiv.cpp +++ b/src/sim/PlanetCiv.cpp @@ -319,6 +319,10 @@ std::vector Planet::stepColonization(long year) { sSettleNation[newIdx] = (int)ni; // the colony is (already) the founder's realm if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.resize(settlements.size(), -1); sSettleAllegiance[newIdx] = cap; // the colony belongs to the founder's realm + // Step 8: the colony carries its FOUNDER's culture (not the target continent's) -- the seed for + // later colonial schisms. The settlement name stays in the local land's language bank. + if (sSettleCulture.size() != settlements.size()) sSettleCulture.resize(settlements.size(), -1); + sSettleCulture[newIdx] = (cap < (int)sSettleCulture.size()) ? sSettleCulture[cap] : -1; if (sCivCond.size() != settlements.size()) { sCivCond.resize(settlements.size(), 1.0); sCivDrought.resize(settlements.size(), 0.0); } ev.push_back(WarEvent{ 1, best, "The " + nat.name + " founds the colony of " + settlements[newIdx].name, "A new settlement rises on distant land.", 3 }); diff --git a/src/sim/PlanetCulture.cpp b/src/sim/PlanetCulture.cpp index 4dd5de8..147d66d 100644 --- a/src/sim/PlanetCulture.cpp +++ b/src/sim/PlanetCulture.cpp @@ -1,18 +1,93 @@ #include "Planet.hpp" #include "NameGen.hpp" #include +#include #include #include -// --- Civilization Step 4: cultures, beliefs & governments -------------------- -// Settlements on the same continent share a CULTURE (a people/language family with an environment-driven -// ethos + a religion); each realm gets a GOVERNMENT type folded into its name. A pure deterministic -// function of the settlement set + geography + biomes (no RNG -> tectonic stream untouched), computed -// AFTER computeTerritory() so `nations` / `sCellNation` already exist. Recomputed rather than saved. +// --- Civilization Step 4 + Step 8: cultures, beliefs, governments & cultural evolution -------------- +// Settlements share a CULTURE (a people/language family with an environment-driven ethos + a religion); +// each realm gets a GOVERNMENT type folded into its name. Since Step 8 the culture layer is split: +// - seedCultures() one-time seeding at the dawn (one culture per inhabited continent) -- also runs +// for pre-v23 loads. Identities (name/ethos/faith) are FROZEN at creation. +// - computeCultures() the DERIVED refresh (tallies, governments, per-cell view). Runs on every +// territory rebuild / load / step-back, so it must never mutate identity state. +// - stepCulture() the once-per-sim-year mutation pass: assimilation (conquered settlements adopt +// their ruler's culture), border conversion (dominant foreign cultural pressure) +// and schism (a far-flung cluster becomes a new people). Pure hashes of +// (stable id, year, seed) -- no RNG touched, so step-back replays it exactly. +// The culture list is append-only and records are immutable after creation (indices/colors stay +// stable; extinct cultures keep their slot); the list + sSettleCulture are saved (v23) + snapshotted. namespace { // A pure hash for deterministic picks -- never touches rngState (mirrors civHash in PlanetCiv.cpp). inline uint32_t cultHash(uint32_t a) { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; } + inline double cultHashf(uint32_t a) { return (cultHash(a) & 0xFFFFFFu) / double(0x1000000); } // [0,1) + + // Environmental tally over a set of settlement cells -> the signals that pick an ethos + faith. + // Factored out of the old computeCultures() so schism children re-derive theirs the same way. + struct CultEnv { int cnt = 0, coast = 0, high = 0, arid = 0, fert = 0; int biome[13] = {0}; }; + + void envAdd(CultEnv& a, const std::vector& cells, const std::vector& moist, + double sea, int c) { + if (c < 0 || c >= (int)cells.size()) return; + a.cnt++; + bool coast = false; + for (int j : cells[c].neighbors) if (cells[j].elevation <= sea) { coast = true; break; } + if (coast) a.coast++; + Biome b = cells[c].biome; + if ((int)b >= 0 && (int)b < 13) a.biome[(int)b]++; + if (b == Biome::Mountains || b == Biome::Hills) a.high++; + if (b == Biome::Desert || (moist.size() == cells.size() && moist[c] < 0.25)) a.arid++; + if (b == Biome::Grassland || b == Biome::Forest || b == Biome::Wetland || b == Biome::Savanna) a.fert++; + } + + // Ethos: the dominant environmental signal; if none is strong, a deterministic social pick. + // Faith: coastal peoples worship the Sea, else the dominant biome sets it, with a rare variation. + void envPick(const CultEnv& a, uint32_t seed, uint32_t rk, int members, + CultureEthos& ethos, Faith& faith) { + double cnt = std::max(1, a.cnt); + double fCoast = a.coast / cnt, fHigh = a.high / cnt, fArid = a.arid / cnt, fFert = a.fert / cnt; + + CultureEthos best = CultureEthos::Agrarian; double bestF = fFert; + if (fCoast > bestF) { bestF = fCoast; best = CultureEthos::Seafaring; } + if (fHigh > bestF) { bestF = fHigh; best = CultureEthos::Highland; } + if (fArid > bestF) { bestF = fArid; best = CultureEthos::Nomadic; } + if (bestF >= 0.34) ethos = best; + else { + uint32_t h = cultHash(seed ^ (rk * 2654435761u) ^ 0xC0FFEEu); + ethos = (members >= 3 && h % 3u == 0u) ? CultureEthos::Mercantile + : (h % 2u ? CultureEthos::Warlike : CultureEthos::Agrarian); + } + + Faith f; + if (fCoast >= 0.5) f = Faith::Sea; + else { + int dom = 0; for (int bi = 1; bi < 13; ++bi) if (a.biome[bi] > a.biome[dom]) dom = bi; + switch ((Biome)dom) { + case Biome::Mountains: case Biome::Hills: f = Faith::Sky; break; + case Biome::Desert: case Biome::Savanna: f = Faith::Sun; break; + case Biome::Tundra: case Biome::Taiga: case Biome::Ice: f = Faith::Ancestors; break; + case Biome::Forest: case Biome::Wetland: f = Faith::Harvest; break; + case Biome::Beach: case Biome::Lake: case Biome::Ocean:f = Faith::Sea; break; + default: f = Faith::Earth; break; + } + uint32_t h = cultHash(seed ^ (rk * 40503u) ^ 0xFA17u); + if (h % 7u == 0u) f = Faith::Moon; + else if (h % 13u == 0u) f = Faith::War; + } + faith = f; + } + + // The faith's proper name from a seed + language bank ("the X Faith" / "Cult of X" / "the X Path"). + std::string makeFaithName(uint32_t faithSeed, int bank) { + std::string root = namegen::makeName(faithSeed, bank); + switch (cultHash(faithSeed) % 3u) { + case 0: return "the " + root + " Faith"; + case 1: return "Cult of " + root; + default: return "the " + root + " Path"; + } + } } const char* cultureEthosName(CultureEthos e) { @@ -55,18 +130,21 @@ const char* govTypeName(GovType g) { return "Kingdom"; } -void Planet::computeCultures() { - const int n = (int)cells.size(); +// One-time seeding: group living settlements into cultures, one per inhabited continent (regionId; +// settlements with no geography fall back to a per-language-bank culture), then freeze each culture's +// ethos/faith/names. Byte-identical to the pre-Step-8 derivation, so a pre-v23 load or the dawn +// produces the same peoples as before. +void Planet::seedCultures() { cultures.clear(); - sCellCulture.assign(n, -1); sSettleCulture.assign(settlements.size(), -1); + sCultureNextId = 1; if (settlements.empty()) return; + const int n = (int)cells.size(); const double sea = cfg.seaLevel, abP = cfg.civAbandonPop; const uint32_t seed = cfg.seed ? cfg.seed : 1u; - // 1) Group living settlements into cultures, one per inhabited continent (regionId). Settlements - // with no geography (regionId < 0) fall back to a per-language-bank culture. + // 1) Group living settlements into cultures. std::unordered_map keyToCulture; auto living = [&](const Settlement& s) { return s.cell >= 0 && s.cell < n && s.population >= abP; @@ -79,7 +157,7 @@ void Planet::computeCultures() { int ci; if (it == keyToCulture.end()) { ci = (int)cultures.size(); - Culture cu; cu.id = (uint32_t)ci + 1; cu.regionId = s.regionId; cu.bank = s.bank; + Culture cu; cu.id = sCultureNextId++; cu.regionId = s.regionId; cu.bank = s.bank; cultures.push_back(cu); keyToCulture[key] = ci; } else ci = it->second; @@ -89,78 +167,69 @@ void Planet::computeCultures() { } if (cultures.empty()) return; - // 2) Environmental tally over each culture's settlement cells -> ethos + a dominant biome for faith. - struct Acc { int cnt = 0, coast = 0, high = 0, arid = 0, fert = 0; int biome[13] = {0}; }; - std::vector acc(cultures.size()); - const bool haveMoist = (int)sMoist.size() == n; + // 2) Environmental tally over each culture's settlement cells. + std::vector acc(cultures.size()); for (size_t k = 0; k < settlements.size(); ++k) { int ci = sSettleCulture[k]; if (ci < 0) continue; - int c = settlements[k].cell; - Acc& a = acc[ci]; a.cnt++; - bool coast = false; - for (int j : cells[c].neighbors) if (cells[j].elevation <= sea) { coast = true; break; } - if (coast) a.coast++; - Biome b = cells[c].biome; - if ((int)b >= 0 && (int)b < 13) a.biome[(int)b]++; - if (b == Biome::Mountains || b == Biome::Hills) a.high++; - if (b == Biome::Desert || (haveMoist && sMoist[c] < 0.25)) a.arid++; - if (b == Biome::Grassland || b == Biome::Forest || b == Biome::Wetland || b == Biome::Savanna) a.fert++; + envAdd(acc[ci], cells, sMoist, sea, settlements[k].cell); } // 3) Per-culture ethos, faith and names (all deterministic hashes of the region + seed). for (size_t ci = 0; ci < cultures.size(); ++ci) { Culture& cu = cultures[ci]; - const Acc& a = acc[ci]; - double cnt = std::max(1, a.cnt); - double fCoast = a.coast / cnt, fHigh = a.high / cnt, fArid = a.arid / cnt, fFert = a.fert / cnt; uint32_t rk = (uint32_t)(cu.regionId >= 0 ? cu.regionId : (1000 - cu.bank)) + 1u; - - // Ethos: the dominant environmental signal; if none is strong, a deterministic social pick. - CultureEthos best = CultureEthos::Agrarian; double bestF = fFert; - if (fCoast > bestF) { bestF = fCoast; best = CultureEthos::Seafaring; } - if (fHigh > bestF) { bestF = fHigh; best = CultureEthos::Highland; } - if (fArid > bestF) { bestF = fArid; best = CultureEthos::Nomadic; } - if (bestF >= 0.34) cu.ethos = best; - else { - uint32_t h = cultHash(seed ^ (rk * 2654435761u) ^ 0xC0FFEEu); - cu.ethos = (cu.members >= 3 && h % 3u == 0u) ? CultureEthos::Mercantile - : (h % 2u ? CultureEthos::Warlike : CultureEthos::Agrarian); - } - - // Faith focus: coastal peoples worship the Sea, else the dominant biome sets it, with a rare - // deterministic Moon/War variation. - Faith f; - if (fCoast >= 0.5) f = Faith::Sea; - else { - int dom = 0; for (int bi = 1; bi < 13; ++bi) if (a.biome[bi] > a.biome[dom]) dom = bi; - switch ((Biome)dom) { - case Biome::Mountains: case Biome::Hills: f = Faith::Sky; break; - case Biome::Desert: case Biome::Savanna: f = Faith::Sun; break; - case Biome::Tundra: case Biome::Taiga: case Biome::Ice: f = Faith::Ancestors; break; - case Biome::Forest: case Biome::Wetland: f = Faith::Harvest; break; - case Biome::Beach: case Biome::Lake: case Biome::Ocean:f = Faith::Sea; break; - default: f = Faith::Earth; break; - } - uint32_t h = cultHash(seed ^ (rk * 40503u) ^ 0xFA17u); - if (h % 7u == 0u) f = Faith::Moon; - else if (h % 13u == 0u) f = Faith::War; - } - cu.faith = f; - - // People (demonym) + the faith's proper name, from the region's language bank. + envPick(acc[ci], seed, rk, cu.members, cu.ethos, cu.faith); uint32_t nameSeed = seed ^ cultHash(rk * 2654435761u + 0x50C1A1u); uint32_t faithSeed = seed ^ cultHash(rk * 40503u + 0xFA17Fu); cu.name = "the " + namegen::makeName(nameSeed, cu.bank); - std::string root = namegen::makeName(faithSeed, cu.bank); - switch (cultHash(faithSeed) % 3u) { - case 0: cu.faithName = "the " + root + " Faith"; break; - case 1: cu.faithName = "Cult of " + root; break; - default: cu.faithName = "the " + root + " Path"; break; + cu.faithName = makeFaithName(faithSeed, cu.bank); + } +} + +// The DERIVED refresh: seeds once when no cultures exist, then only recomputes tallies, governments +// and the per-cell culture view from the (stateful) per-settlement culture. Safe to call on every +// territory rebuild / load / step-back -- it never reassigns a settlement's culture (only sanitizes +// out-of-range entries and backfills unset living ones from the nearest cultured neighbour). +void Planet::computeCultures() { + const int n = (int)cells.size(); + sCellCulture.assign(n, -1); + if (settlements.empty()) { sSettleCulture.clear(); return; } + if (cultures.empty()) seedCultures(); + if (cultures.empty()) return; // no living settlement yet -> seeded on a later refresh + + const double abP = cfg.civAbandonPop; + const uint32_t seed = cfg.seed ? cfg.seed : 1u; + auto living = [&](size_t k) { + const Settlement& s = settlements[k]; + return s.cell >= 0 && s.cell < n && s.population >= abP; + }; + + // Per-settlement culture is STATE (Step 8): resize/sanitize + backfill only, never reassign. + if (sSettleCulture.size() != settlements.size()) sSettleCulture.resize(settlements.size(), -1); + for (int& c : sSettleCulture) if (c < -1 || c >= (int)cultures.size()) c = -1; + for (size_t k = 0; k < settlements.size(); ++k) { + if (!living(k) || sSettleCulture[k] >= 0) continue; + int bestS = -1; double bestD = 1e9; // nearest living cultured settlement (deterministic) + for (size_t j = 0; j < settlements.size(); ++j) { + if (j == k || !living(j) || sSettleCulture[j] < 0) continue; + double d = std::acos(std::clamp( + cells[settlements[k].cell].unit.dot(cells[settlements[j].cell].unit), -1.0, 1.0)); + if (d < bestD) { bestD = d; bestS = (int)j; } } + if (bestS >= 0) sSettleCulture[k] = sSettleCulture[bestS]; } - // 4) Government per realm (from tier + a deterministic pick) + fold it into the realm's name, and - // tag the realm with its capital's culture. + // Derived tallies (living settlements only; extinct cultures keep their slot with members = 0). + for (Culture& cu : cultures) { cu.members = 0; cu.totalPop = 0.0; } + for (size_t k = 0; k < settlements.size(); ++k) { + int ci = sSettleCulture[k]; + if (ci < 0 || !living(k)) continue; + cultures[ci].members++; + cultures[ci].totalPop += settlements[k].population; + } + + // Government per realm (from tier + a deterministic pick) + fold it into the realm's name, and + // tag the realm with its capital's culture. for (Nation& nat : nations) { nat.cultureId = (nat.capital >= 0 && nat.capital < (int)sSettleCulture.size()) ? sSettleCulture[nat.capital] : -1; @@ -183,9 +252,191 @@ void Planet::computeCultures() { } } - // 5) Per-cell culture = the culture of the realm that owns the cell (reuse territory's ownership). + // Per-cell culture: prefer the owning SETTLEMENT's culture (Step 8 -- a conquered city keeps its + // people's colour inside the conqueror's realm until it assimilates); fall back to the realm's. + const bool haveOwner = (int)sCellSettleOwner.size() == n; + const bool haveNation = (int)sCellNation.size() == n; + if (!haveNation) return; for (int i = 0; i < n; ++i) { int ni = sCellNation[i]; - if (ni >= 0 && ni < (int)nations.size()) sCellCulture[i] = nations[ni].cultureId; + if (ni < 0 || ni >= (int)nations.size()) continue; + int cu = -1; + if (haveOwner) { + int ow = sCellSettleOwner[i]; + if (ow >= 0 && ow < (int)sSettleCulture.size()) cu = sSettleCulture[ow]; + } + sCellCulture[i] = (cu >= 0) ? cu : nations[ni].cultureId; } } + +// --- Civilization Step 8: the yearly cultural-evolution pass ---------------------------------------- +// Three passes, all pure hashes of (stable id, year, seed) so a step-back replays them exactly: +// A) assimilation -- a settlement held by a foreign-culture overlord adopts the ruler's culture +// (the Step-5 revolt cultBonus then drops on its own: assimilation pacifies provinces); +// B) border conversion -- a settlement dwarfed by a nearby foreign culture's weight (population + +// trade prestige) converts toward it (realm capitals are exempt: they anchor identity); +// C) schism -- a large culture's far-flung coherent cluster (overseas colonies) breaks away as a +// NEW people: a fresh name from the local language bank, ethos/faith re-derived from its own +// lands (appended to `cultures`; step-back truncates the list and replay re-creates it). +std::vector Planet::stepCulture(long year) { + std::vector 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 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 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 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 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 memCount(ncult, 0); + std::vector 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 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 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 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; +} diff --git a/src/sim/PlanetCulture.hpp b/src/sim/PlanetCulture.hpp index ad84fe1..fa28977 100644 --- a/src/sim/PlanetCulture.hpp +++ b/src/sim/PlanetCulture.hpp @@ -5,10 +5,13 @@ // Civilization Step 4: culture, beliefs & governments. Settlements on the same continent share a // CULTURE -- a people/language family with a dominant ethos (drawn from their environment) and a -// religion. Each realm (Step 3 Nation) gets a GOVERNMENT type folded into its name. Like territory, -// all of this is a DETERMINISTIC function of the settlement set + geography + biomes/climate, computed -// with pure hashes (no RNG touched) -- so it is recomputed rather than saved (no save-format change), -// and the live stepper rewinds it for free. +// religion. Each realm (Step 3 Nation) gets a GOVERNMENT type folded into its name. +// Civilization Step 8 (cultural evolution) made culture identities STATEFUL: the culture list is +// append-only (seeded once per continent at the dawn, schism children appended later; a record is +// immutable after creation so indices/colors stay stable) and the per-settlement culture is mutable +// state (conversion / assimilation / schism / colonial inheritance) -- both saved (v23) and +// snapshotted. members/totalPop tallies, Nation.cultureId/governments and the per-cell culture view +// stay DERIVED (refreshed by computeCultures(), which never mutates identity state). enum class CultureEthos : uint8_t { Agrarian, Seafaring, Nomadic, Highland, Mercantile, Warlike }; enum class Faith : uint8_t { Sun, Moon, Sea, Sky, Earth, Ancestors, War, Harvest }; @@ -19,8 +22,10 @@ struct Culture { int bank = 0; // NameGen "language" bank (shared by the region's places) CultureEthos ethos = CultureEthos::Agrarian; Faith faith = Faith::Earth; - int members = 0; // number of settlements sharing this culture - double totalPop = 0.0; // summed population of those settlements + int members = 0; // number of settlements sharing this culture (derived tally) + double totalPop = 0.0; // summed population of those settlements (derived tally) + int parentId = -1; // (Step 8) culture id this one schismed from (-1 = a root people) + long foundedYear = -1; // (Step 8) sim year of the schism (-1 = present since the dawn) std::string name; // the people / demonym, e.g. "the Velmar" std::string faithName; // the religion's proper name, e.g. "the Tidewardens" }; diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 3ed055d..39fa519 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -66,12 +66,15 @@ D(tradeWarBlock) D(tradeAllyBonus) D(tradeDiploBonus) D(tradeTemptWar) \ D(civColonizeRate) D(civColonyMinPop) D(civColonyMinHab) D(civColonyReach) D(civColonySeaReach) \ D(civColonySpacing) D(civColonySupply) \ + D(cultAssimRate) D(cultConvertRate) D(cultConvertDominance) D(cultSpreadRange) \ + D(cultPrestigeWeight) D(cultSchismRange) D(cultSchismRate) \ I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(volcanoMaxCount) \ I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \ I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) I(warMaxConcurrent) I(civMaxColonies) \ + I(cultSchismMinMembers) I(cultSchismMinCluster) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ U(seed) @@ -332,6 +335,15 @@ std::string validateConfig(const PlanetConfig& cfg) { E(rng(cfg.civColonySpacing, 0.0, 3.14159, "civColonySpacing")); E(rng(cfg.civColonySupply, 0.0, 100.0, "civColonySupply")); E(irng(cfg.civMaxColonies, 0, 1000000, "civMaxColonies")); + E(rng(cfg.cultAssimRate, 0.0, 1.0, "cultAssimRate")); + E(rng(cfg.cultConvertRate, 0.0, 1.0, "cultConvertRate")); + E(rng(cfg.cultConvertDominance, 0.0, 1000.0, "cultConvertDominance")); + E(rng(cfg.cultSpreadRange, 0.0, 3.14159, "cultSpreadRange")); + E(rng(cfg.cultPrestigeWeight, 0.0, 100.0, "cultPrestigeWeight")); + E(irng(cfg.cultSchismMinMembers, 1, 1000000, "cultSchismMinMembers")); + E(rng(cfg.cultSchismRange, 0.0, 3.14159, "cultSchismRange")); + E(irng(cfg.cultSchismMinCluster, 1, 1000000, "cultSchismMinCluster")); + E(rng(cfg.cultSchismRate, 0.0, 1.0, "cultSchismRate")); E(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.beltWidth, 1, 12, "beltWidth")); @@ -553,17 +565,33 @@ void Planet::writeState(std::ostream& os) const { writePod(os, t.a); writePod(os, t.b); writePod(os, t.attitude); writePod(os, t.truceUntil); uint8_t k = (uint8_t)t.kind; writePod(os, k); } + // v23: cultural evolution (civ Step 8). Culture identities are stateful (schism children append; + // frozen names/ethos/faith) and the per-settlement culture is mutable state. members/totalPop are + // derived tallies -> not written (recomputed by computeCultures()). + uint64_t ncu = cultures.size(); writePod(os, ncu); + for (const Culture& cu : cultures) { + writePod(os, cu.id); writePod(os, cu.regionId); writePod(os, cu.bank); + uint8_t e = (uint8_t)cu.ethos; writePod(os, e); + uint8_t f = (uint8_t)cu.faith; writePod(os, f); + writePod(os, cu.parentId); writePod(os, cu.foundedYear); + uint64_t L = cu.name.size(); writePod(os, L); + if (L) os.write(cu.name.data(), (std::streamsize)L); + L = cu.faithName.size(); writePod(os, L); + if (L) os.write(cu.faithName.data(), (std::streamsize)L); + } + writeVec(os, sSettleCulture); + writePod(os, sCultureNextId); } bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, - bool hasConflict, bool hasDiplo) { + bool hasConflict, bool hasDiplo, bool hasCultures) { Planet tmp; tmp.drifting = drifting; // readState never serialized this flag; preserve old caller-visible behavior. if (!tmp.readStateImpl(is, hasBiome, hasBiota, hasMoons, hasWeather, hasStorms, hasVolcanoes, hasStatefulVolcanoes, hasGeography, hasGeoSalt, hasEcoregions, - hasSettlements, hasConflict, hasDiplo)) + hasSettlements, hasConflict, hasDiplo, hasCultures)) return false; *this = std::move(tmp); return true; @@ -572,7 +600,7 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, - bool hasConflict, bool hasDiplo) { + bool hasConflict, bool hasDiplo, bool hasCultures) { // Save blocks are append-only by version. If a caller asks for an older prefix, // later blocks cannot exist in that stream even if the default arguments say otherwise. if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; } @@ -583,6 +611,7 @@ bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool if (!hasEcoregions) hasSettlements = false; // settlements follow the ecoregion block if (!hasSettlements) hasConflict = false; // conflict follows the settlement block if (!hasConflict) hasDiplo = false; // diplomacy follows the conflict block + if (!hasDiplo) hasCultures = false; // culture block follows the diplomacy block // Read the length-prefixed key=value config block (see writeState). A default // PlanetConfig is parsed over, so fields absent from an older save keep their @@ -833,6 +862,39 @@ bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool } if (!sanitizeDiplo(diplomacy, (int)settlements.size())) return false; } + // v23: cultural evolution (civ Step 8). Culture identities + the per-settlement culture (pre-v23 + // saves keep none -> re-seeded one-per-continent on the next computeCultures()). + if (hasCultures) { + uint64_t ncu = 0; readPod(is, ncu); + if (!is || ncu > 100000) return false; + cultures.resize((size_t)ncu); + uint32_t maxId = 0; + for (Culture& cu : cultures) { + readPod(is, cu.id); readPod(is, cu.regionId); readPod(is, cu.bank); + uint8_t e = 0, f = 0; readPod(is, e); readPod(is, f); + readPod(is, cu.parentId); readPod(is, cu.foundedYear); + if (!is) return false; + if (e > (uint8_t)CultureEthos::Warlike || f > (uint8_t)Faith::Harvest) return false; + cu.ethos = (CultureEthos)e; cu.faith = (Faith)f; + if (cu.parentId < -1 || cu.foundedYear < -1) return false; + uint64_t L = 0; readPod(is, L); + if (!is || L > 256) return false; + cu.name.resize((size_t)L); + if (L) is.read(cu.name.data(), (std::streamsize)L); + readPod(is, L); + if (!is || L > 256) return false; + cu.faithName.resize((size_t)L); + if (L) is.read(cu.faithName.data(), (std::streamsize)L); + if (!is) return false; + if (cu.id > maxId) maxId = cu.id; + } + if (!readVec(is, sSettleCulture, 1000000)) return false; + if (sSettleCulture.size() != settlements.size()) sSettleCulture.assign(settlements.size(), -1); + for (int& c : sSettleCulture) if (c < -1 || c >= (int)cultures.size()) c = -1; + readPod(is, sCultureNextId); + if (!is) return false; + if (sCultureNextId <= maxId) sCultureNextId = maxId + 1; // stale/zero counter -> recompute + } computeBiotaDensity(); // derived density scalars for the colour views return (bool)is; } diff --git a/src/sim/PlanetNation.cpp b/src/sim/PlanetNation.cpp index 7d361a6..c340c74 100644 --- a/src/sim/PlanetNation.cpp +++ b/src/sim/PlanetNation.cpp @@ -51,6 +51,17 @@ void Planet::computeTerritory() { }); std::vector capitalOf(settlements.size(), -1); const bool haveAllegiance = (sSettleAllegiance.size() == settlements.size()); + // Realms stay mono-cultural (civ Step 4): a settlement only vassalizes to a capital of its own + // CULTURE (Step 8 -- so conversions/schisms redraw realms). When either side has no culture yet + // (before the first computeCultures(), or a -1 entry) fall back to the old same-continent rule. + const bool haveCulture = (sSettleCulture.size() == settlements.size()); + auto sameCulture = [&](int a, int b) { + if (haveCulture) { + int ca = sSettleCulture[a], cb = sSettleCulture[b]; + if (ca >= 0 && cb >= 0) return ca == cb; + } + return settlements[a].regionId == settlements[b].regionId; + }; for (int s : order) { if (range[s] <= 0.0) continue; // Conquest override (civ Step 5): a conquered settlement joins its overlord's realm, crossing @@ -63,7 +74,7 @@ void Planet::computeTerritory() { for (int c : order) { if (c == s) break; // order is descending -> rest are smaller if (capitalOf[c] != c) continue; // candidate must itself be a capital - if (settlements[c].regionId != settlements[s].regionId) continue; // realms stay mono-cultural (civ Step 4) + if (!sameCulture(c, s)) continue; // realms stay mono-cultural double d = ang(s, c); if (d < cfg.civVassalRange * range[c] && d < joinAng) { joinAng = d; joinCap = c; } } diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index c7bc267..189529a 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -130,6 +130,13 @@ struct WeatherSnapshot { uint32_t warRng = 0, warNextId = 0; // Civilization Step 6 (diplomacy): standing realm relations (alliances / rivalries / truces). std::vector 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 settlementCulture; + uint32_t cultureCount = 0, cultureNextId = 0; }; struct Plate { @@ -500,4 +507,16 @@ struct PlanetConfig { double civColonySpacing = 0.05; // rad: a colony must be at least this far from every settlement int civMaxColonies = 120; // cap on colonies founded beyond the initial civMaxSettlements double civColonySupply = 2.0; // prosperity from the overlord supply link (keeps colonies alive) + // Civilization Step 8: cultural evolution (stateful, saved v23). Cultures spread along borders + // (settlements convert under dominant foreign cultural pressure), conquered settlements assimilate + // into their ruler's culture, and far-flung cultures (overseas colonies) schism into new peoples. + double cultAssimRate = 0.03; // per-year chance a settlement under foreign allegiance adopts its ruler's culture + double cultConvertRate = 0.02; // per-year chance scale for border conversion under dominant foreign pressure + double cultConvertDominance = 2.5; // foreign cultural pressure must exceed this x the own-culture support + double cultSpreadRange = 0.25; // rad: how far a settlement projects cultural pressure (~1600 km) + double cultPrestigeWeight = 0.5; // how much trade prosperity boosts a settlement's cultural weight + int cultSchismMinMembers = 6; // a culture needs at least this many living settlements to schism + double cultSchismRange = 0.55; // rad from the culture's population centroid past which members are "distant" + int cultSchismMinCluster = 2; // distant settlements needed to break away together + double cultSchismRate = 0.08; // per-year chance a qualifying distant cluster becomes a new people }; diff --git a/src/sim/PlanetWeather.cpp b/src/sim/PlanetWeather.cpp index c7be9c1..46ef3a6 100644 --- a/src/sim/PlanetWeather.cpp +++ b/src/sim/PlanetWeather.cpp @@ -36,6 +36,10 @@ WeatherSnapshot Planet::captureWeather() const { s.settlementAllegiance = sSettleAllegiance; // civ Step 5: conquest state s.wars = wars; s.warRng = sWarRng; s.warNextId = sWarNextId; s.diplomacy = diplomacy; // civ Step 6: standing realm relations + // Civ Step 8: per-settlement culture + the culture-list length. Records are append-only + + // immutable, so the length is enough -- a restore truncates and replay re-creates schism children. + s.settlementCulture = sSettleCulture; + s.cultureCount = (uint32_t)cultures.size(); s.cultureNextId = sCultureNextId; return s; } @@ -58,6 +62,13 @@ void Planet::restoreWeather(const WeatherSnapshot& s) { if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1); wars = s.wars; sWarRng = s.warRng ? s.warRng : sWarRng; sWarNextId = s.warNextId ? s.warNextId : sWarNextId; diplomacy = s.diplomacy; // civ Step 6: restore realm relations + // Civ Step 8: truncate schism children born after the frame (append-only list) + restore the + // per-settlement culture; replay re-creates the same children (stepCulture is a pure function). + if (s.cultureCount <= cultures.size()) cultures.resize(s.cultureCount); + sSettleCulture = s.settlementCulture; + if (sSettleCulture.size() != settlements.size()) sSettleCulture.assign(settlements.size(), -1); + for (int& c : sSettleCulture) if (c < -1 || c >= (int)cultures.size()) c = -1; + sCultureNextId = s.cultureNextId ? s.cultureNextId : sCultureNextId; sHasWeather = !sHumidity.empty(); } diff --git a/test_cultevo.cpp b/test_cultevo.cpp new file mode 100644 index 0000000..69e0704 --- /dev/null +++ b/test_cultevo.cpp @@ -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 +#include +#include +#include +#include + +static int failures = 0; +static void check(bool cond, const char* what) { + std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what); + if (!cond) ++failures; +} +static void settle(Planet& p, int maxSteps = 800) { + int run = 0; + for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; } + p.computeClimate(); p.classifyBiomes(); +} +static void drift(Planet& p, int iters) { + p.drifting = true; + for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); if (k >= iters/2) p.hydrology(dt*0.2); } + p.computeClimate(); p.classifyBiomes(); +} +static void quietCulture(Planet& p) { // no evolution: state should then never change + p.cfg.cultAssimRate = 0.0; p.cfg.cultConvertRate = 0.0; p.cfg.cultSchismRate = 0.0; +} +static bool culturesEqual(const std::vector& a, const std::vector& b) { + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); ++i) + if (a[i].id != b[i].id || a[i].regionId != b[i].regionId || a[i].bank != b[i].bank + || a[i].ethos != b[i].ethos || a[i].faith != b[i].faith + || a[i].parentId != b[i].parentId || a[i].foundedYear != b[i].foundedYear + || a[i].name != b[i].name || a[i].faithName != b[i].faithName) return false; + return true; +} +// The shared yearly civ sequence (mirrors the viewer's year tick, minus weather). +static std::vector civYear(Planet& p, long yr, bool wars = false, bool colonize = false) { + p.computeTerritory(); p.computeCultures(); + if (wars) p.stepConflict(yr); + std::vector ev = p.stepCulture(yr); + if (colonize) for (WarEvent& e : p.stepColonization(yr)) ev.push_back(e); + return ev; +} + +int main() { + PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 4242; + Planet p; p.generate(cfg); settle(p); drift(p, 400); + const int n = (int)p.cells.size(); + const double yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays; + + std::printf("Cultural evolution: seeding parity (frozen one-per-continent identities)\n"); + p.placeSettlements(); + double lt = 0.0; for (int yr = 0; yr < 600; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); } + p.computeTerritory(); p.computeCultures(); + { + check(!p.cultureList().empty(), "cultures seeded at the dawn"); + // One culture per distinct key (continent regionId / -1-bank fallback) among living settlements. + std::set keys; + for (size_t k = 0; k < p.settlements.size(); ++k) { + const Settlement& s = p.settlements[k]; + if (s.population < p.cfg.civAbandonPop) continue; + keys.insert(s.regionId >= 0 ? s.regionId : (-1 - s.bank)); + } + check(p.cultureList().size() == keys.size(), "one culture per inhabited continent"); + bool rootsOk = true, idsOk = true; + for (size_t i = 0; i < p.cultureList().size(); ++i) { + const Culture& cu = p.cultureList()[i]; + if (cu.parentId != -1 || cu.foundedYear != -1 || cu.name.empty() || cu.faithName.empty()) rootsOk = false; + if (cu.id != (uint32_t)i + 1) idsOk = false; + } + check(rootsOk, "all seeded cultures are roots (no parent, present since the dawn)"); + check(idsOk, "culture ids are dense from 1"); + // A quiet world (all rates 0) never mutates culture state. + Planet q0 = p; quietCulture(q0); + std::vector sc0 = q0.settleCulture(); + for (long yr = 0; yr < 30; ++yr) civYear(q0, yr); + check(q0.settleCulture() == sc0 && q0.cultureList().size() == p.cultureList().size(), + "no evolution with all cult* rates 0 (Steps 3-7 behaviour preserved)"); + } + + std::printf("Cultural evolution: colonies inherit the founder's culture\n"); + { + Planet c = p; quietCulture(c); + c.computeHabitability(); + c.cfg.civColonizeRate = 1.0; c.cfg.civColonyMinPop = 1000.0; c.cfg.civColonyMinHab = 0.1; + size_t before = c.settlements.size(); + for (long yr = 0; yr < 40 && c.settlements.size() < before + 6; ++yr) civYear(c, yr, false, true); + std::printf(" %zu colonies founded\n", c.settlements.size() - before); + check(c.settlements.size() > before, "colonies are founded"); + bool inherit = c.settlements.size() > before; + for (size_t k = before; k < c.settlements.size(); ++k) { + int ov = c.settleAllegiance()[k]; + if (ov < 0 || c.settleCulture()[k] != c.settleCulture()[ov]) inherit = false; + } + check(inherit, "every colony carries its founder capital's culture"); + } + + std::printf("Cultural evolution: assimilation under foreign rule\n"); + { + Planet a = p; quietCulture(a); a.cfg.cultAssimRate = 1.0; + // Force a conquest scenario: a living settlement held by a living foreign-culture capital + // (allegiance + culture are snapshot fields, so a hacked snapshot injects the state cleanly). + int s = -1, ov = -1; + const auto& sc = a.settleCulture(); + for (size_t i = 0; i < a.settlements.size() && s < 0; ++i) + for (size_t j = 0; j < a.settlements.size(); ++j) + if (i != j && a.settlements[i].population >= a.cfg.civAbandonPop + && a.settlements[j].population >= a.cfg.civAbandonPop + && sc[i] >= 0 && sc[j] >= 0 && sc[i] != sc[j]) { s = (int)i; ov = (int)j; break; } + if (s < 0) { std::printf(" (single-culture world: skipping)\n"); } + else { + WeatherSnapshot snap = a.captureWeather(); + if (snap.settlementAllegiance.size() != a.settlements.size()) // no war has sized it yet + snap.settlementAllegiance.assign(a.settlements.size(), -1); + snap.settlementAllegiance[s] = ov; + a.restoreWeather(snap); + int want = a.settleCulture()[ov]; + std::vector ev = a.stepCulture(1); + bool flipped = a.settleCulture()[s] == want; + bool logged = false; + for (const WarEvent& e : ev) + if (e.kind == 7 && e.title.find("adopts the culture of") != std::string::npos) logged = true; + check(flipped, "a conquered settlement assimilates into its ruler's culture"); + check(logged, "assimilation emits a kind-7 event"); + } + } + + std::printf("Cultural evolution: border conversion (capitals exempt)\n"); + { + Planet b = p; quietCulture(b); + b.cfg.cultConvertRate = 1.0; b.cfg.cultConvertDominance = 0.01; b.cfg.cultSpreadRange = 3.0; + b.computeTerritory(); b.computeCultures(); + std::vector caps; + for (const Nation& nat : b.nationList()) caps.push_back(nat.capital); + std::vector capCultBefore; + for (int c : caps) capCultBefore.push_back(b.settleCulture()[c]); + int conversions = 0; + for (long yr = 0; yr < 5; ++yr) + for (const WarEvent& e : civYear(b, yr)) + if (e.kind == 7 && e.title.find("embraces the ways of") != std::string::npos) ++conversions; + std::printf(" %d conversions in 5 years\n", conversions); + check(conversions > 0, "settlements convert under dominant foreign cultural pressure"); + bool capsStable = true; // capitals anchor identity: only assimilation/schism may move them (both off/limited here) + for (size_t i = 0; i < caps.size(); ++i) + if (b.settleCulture()[caps[i]] != capCultBefore[i]) capsStable = false; + check(capsStable, "realm capitals never convert via border pressure"); + } + + std::printf("Cultural evolution: schism (a distant cluster becomes a new people)\n"); + long schismYear = -1; + { + quietCulture(p); + p.cfg.cultSchismRate = 1.0; p.cfg.cultSchismRange = 0.10; p.cfg.cultSchismMinMembers = 3; + p.cfg.cultSchismMinCluster = 1; + size_t before = p.cultureList().size(); + for (long yr = 0; yr < 20 && p.cultureList().size() == before; ++yr) { + for (const WarEvent& e : civYear(p, yr)) + if (e.kind == 7 && e.title.find("break away from") != std::string::npos) schismYear = yr; + } + check(p.cultureList().size() > before, "a schism appends a new culture"); + if (p.cultureList().size() > before) { + const Culture& child = p.cultureList().back(); + bool parentOk = false; + for (size_t i = 0; i < before; ++i) if ((int)p.cultureList()[i].id == child.parentId) parentOk = true; + check(parentOk, "the child records its parent culture"); + check(child.foundedYear >= 0 && child.foundedYear == schismYear, "the child records its founding year"); + bool nameUnique = !child.name.empty(); + for (size_t i = 0; i + 1 < p.cultureList().size(); ++i) + if (p.cultureList()[i].name == child.name) nameUnique = false; + check(nameUnique, "the child's people name is unique"); + int adopted = 0; + for (int c : p.settleCulture()) if (c == (int)p.cultureList().size() - 1) ++adopted; + check(adopted >= 1, "the breakaway cluster adopted the new culture"); + p.computeTerritory(); p.computeCultures(); // the child cluster may found its own realm + check((int)p.cultureList().back().members == adopted, "derived tallies pick the child up"); + } + check(p.cultureList().size() <= 64, "the culture list stays bounded"); + } + + std::printf("Cultural evolution: determinism\n"); + { + Planet q; q.generate(cfg); settle(q); drift(q, 400); + q.placeSettlements(); + double lt2 = 0.0; for (int yr = 0; yr < 600; ++yr) { lt2 += 2.0 * yearH; q.stepCivilization(2.0 * yearH, lt2); } + q.computeTerritory(); q.computeCultures(); + quietCulture(q); + q.cfg.cultSchismRate = 1.0; q.cfg.cultSchismRange = 0.10; q.cfg.cultSchismMinMembers = 3; + q.cfg.cultSchismMinCluster = 1; + size_t before = q.cultureList().size(); + for (long yr = 0; yr < 20 && q.cultureList().size() == before; ++yr) civYear(q, yr); + check(culturesEqual(q.cultureList(), p.cultureList()) && q.settleCulture() == p.settleCulture(), + "two identical worlds evolve identical cultures"); + } + + std::printf("Cultural evolution: RNG isolation from tectonics\n"); + { + Planet x; x.generate(cfg); settle(x); + Planet y; y.generate(cfg); settle(y); + for (int k = 0; k < 40; ++k) { + double dx = x.cflDtMy(); x.advect(dx); x.step(); x.erode(dx); + double dy = y.cflDtMy(); y.advect(dy); y.step(); y.erode(dy); + if (k == 20) { + y.placeSettlements(); y.stepCivilization(yearH, yearH); + y.cfg.cultConvertRate = 1.0; y.cfg.cultConvertDominance = 0.01; y.cfg.cultSpreadRange = 3.0; + for (long yr = 0; yr < 3; ++yr) civYear(y, yr); + } + } + bool same = true; + for (int i = 0; i < n; ++i) if (std::fabs(x.cells[i].elevation - y.cells[i].elevation) > 1e-9) same = false; + check(same, "stepCulture never perturbs tectonic evolution"); + } + { + // Interleaved no-op stepCulture calls must not disturb the war RNG stream. + Planet w1 = p, w2 = p; quietCulture(w1); quietCulture(w2); + w1.cfg.warDeclareRate = 2.0; w1.cfg.warMinRealmPop = 1.0; + w2.cfg.warDeclareRate = 2.0; w2.cfg.warMinRealmPop = 1.0; + for (long yr = 100; yr < 130; ++yr) { + w1.computeTerritory(); w1.computeCultures(); w1.stepConflict(yr); + w2.computeTerritory(); w2.computeCultures(); w2.stepConflict(yr); w2.stepCulture(yr); + } + bool warsSame = w1.warList().size() == w2.warList().size() + && w1.settleAllegiance() == w2.settleAllegiance(); + check(warsSame, "stepCulture uses no RNG (interleaving leaves wars identical)"); + } + + std::printf("Cultural evolution: snapshot truncate-and-replay\n"); + { + WeatherSnapshot snap = p.captureWeather(); + std::vector cBefore = p.cultureList(); + std::vector scBefore = p.settleCulture(); + // Mutate: aggressive conversion + schism for 15 years. + p.cfg.cultConvertRate = 1.0; p.cfg.cultConvertDominance = 0.01; p.cfg.cultSpreadRange = 3.0; + p.cfg.cultSchismRate = 1.0; p.cfg.cultSchismRange = 0.10; + for (long yr = 50; yr < 65; ++yr) civYear(p, yr); + std::vector cAfter = p.cultureList(); + std::vector scAfter = p.settleCulture(); + bool changed = !culturesEqual(cAfter, cBefore) || scAfter != scBefore; + p.restoreWeather(snap); + p.computeTerritory(); p.computeCultures(); // the derived refresh after a step-back + check(changed, "the mutation actually changed the culture state"); + check(p.cultureList().size() == cBefore.size() && p.settleCulture() == scBefore, + "restoreWeather truncates schism children + rewinds settlement cultures"); + // Deterministic replay re-creates the same children + conversions. + for (long yr = 50; yr < 65; ++yr) civYear(p, yr); + check(culturesEqual(p.cultureList(), cAfter) && p.settleCulture() == scAfter, + "replaying the same years re-creates identical schisms/conversions"); + // Out-of-range snapshot entries are clamped on restore. + WeatherSnapshot odd = p.captureWeather(); + if (!odd.settlementCulture.empty()) { + odd.settlementCulture[0] = 9999; + p.restoreWeather(odd); + check(p.settleCulture()[0] == -1, "restoreWeather clamps out-of-range culture entries"); + odd.settlementCulture[0] = -1; // leave p in a sane (clamped) state for the save tests + } + } + + std::printf("Cultural evolution: save v23 round-trip\n"); + p.computeTerritory(); p.computeCultures(); + { + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + p.writeState(ss); + Planet r; + bool ok = r.readState(ss); + check(ok, "readState accepts the v23 stream"); + check(culturesEqual(r.cultureList(), p.cultureList()), "culture identities survive save/load"); + check(r.settleCulture() == p.settleCulture(), "per-settlement culture survives save/load"); + } + { + Planet bad = p; + if (!bad.cultures.empty()) { + bad.cultures[0].ethos = (CultureEthos)200; + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + bad.writeState(ss); + Planet r; + check(!r.readState(ss), "readState rejects an out-of-range culture ethos"); + } + } + + std::printf("Cultural evolution: pre-v23 compatibility\n"); + { + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + p.writeState(ss); + Planet r; + // The culture block is the last one; reading with hasCultures=false ignores the trailing bytes. + bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true, + true, true, false); + check(ok, "readState accepts the stream as pre-v23"); + check(r.cultureList().empty(), "pre-v23 load has no culture state"); + r.computeTerritory(); r.computeCultures(); + check(!r.cultureList().empty(), "cultures re-seed on the next refresh"); + bool allRoots = true; + for (const Culture& cu : r.cultureList()) + if (cu.parentId != -1 || cu.foundedYear != -1) allRoots = false; + check(allRoots, "re-seeded cultures are one-per-continent roots"); + } + + std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CULTURAL-EVOLUTION CHECKS PASSED\n", failures); + return failures ? 1 : 0; +}