diff --git a/BUILD.md b/BUILD.md index 840f8ad..d412ce6 100644 --- a/BUILD.md +++ b/BUILD.md @@ -57,7 +57,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain: E ecoregions colour view (names ecological provinces on first use; Eco tab) I habitability heat map (where civilization can thrive) U settlements: the dawn of civilization on first press, then toggle markers (Civ tab) - P territory / realms view + political borders (nations listed in the Realms tab) + P territory / realms view + political borders + war fronts (Realms tab lists nations & wars) X culture / faiths view + cultural borders (peoples/ethos/religion in the Cultures tab) Y follow-cam: cycle the 3D camera through active storms (Live World; off after last) . / , step the live clock forward / back by one rate-unit (auto-pauses; back also @@ -324,6 +324,24 @@ territory, leaving wilderness frontiers between realms. civEmpireMinMembers 5 settlements in a realm to count as an empire civEmpirePop 5e6 total realm population to count as an empire +Conflict & war (PlanetConfig, save v21 — stateful; shows on the Territory view P + Events/Realms tabs): +neighbouring realms grow hostile and fight; casualties shrink frontier cities; the winner conquers a +city (its allegiance flips) or sacks it (ruins); conquered foreign/distant cities revolt, so empires +rise and fall. Runs on the yearly Live-World tick. + + warMaxConcurrent 6 cap on simultaneous active wars + warDeclareRate 0.12 war-declaration chance scale (x hostility) + warAmbition 1.0 hostility weight of the size gap (strong preys on weak) + warIdeology 0.8 hostility weight of culture + faith difference + warBorder 0.5 hostility weight of contested frontier + warWarlikeMult 1.4 military-strength bonus for a Warlike-ethos realm + warCasualtyRate 0.06 per war-year frontier-city population loss (loser more) + warConquerScore 0.6 |warscore| past which the winner takes a city + warSackChance 0.3 chance a taken city is razed to ruins instead of flipped + warExhaustion 1.5 |warscore| past which a war ends in peace (also a 60-yr cap) + warRevoltRate 0.04 per-year base revolt chance of a held foreign/distant city + warMinRealmPop 2000 realms below this population don't start wars + Culture, beliefs & governments (key X — derived from settlements + geography, not saved): settlements on a continent share a culture (a people with an environment-driven ethos + a religion); each realm gets a government type folded into its name (Republic of X / Duchy of X / X Theocracy / ...); realms @@ -340,11 +358,11 @@ src/sim/PlanetCulture.cpp (computeCultures). 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/PlanetCiv.cpp src/sim/PlanetNation.cpp src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp \ src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t # Same source list for every suite: swap test_logic.cpp -> test_biota / test_live / test_ocean / - # test_weather / test_volcano / test_geography / test_ecoregions / test_civ / test_nation / test_culture. + # test_weather / test_volcano / test_geography / test_ecoregions / test_civ / test_nation / test_culture / test_conflict. # The CMake build also includes test_events for the viewer event journal. Verifies geometry, plate assignment, gradual non-saturating relief and diff --git a/CLAUDE.md b/CLAUDE.md index 638c445..c7ca7fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it. **Civilizations (in progress — the long arc after the world is finished):** the eventual goal is people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural + geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all -on the Live World clock). **Steps 1–4 of the roadmap are done (plus a derived ecoregions atlas):** +on the Live World clock). **Steps 1–5 of the roadmap are done (plus a derived ecoregions atlas):** - **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the foundation everything civic references. `Planet::generateGeography()` extracts named features from the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land), @@ -184,8 +184,26 @@ on the Live World clock). **Steps 1–4 of the roadmap are done (plus a derived government-aware realm labels. Like territory, cultures are a **pure function of the (saved) settlements + geography**, so they're **recomputed** each sim year / on placement / load / step-back — **no save state, no version bump**. Knobs: none (ethos/faith/government rules are internal constants). +- **Conflict, war & shifting borders (Step 5)** *(done — see `PlanetConflict.cpp`, save v21)* — realms + stop coexisting peacefully and **go to war**. `stepConflict(year)` runs once per sim year (in the + viewer's year-tick, before `computeTerritory`): neighbouring realms (adjacency by **settlement + proximity**, since territory is influence-limited) grow **hostile** from ambition (size gap) + ideology + (culture/faith difference) + contested-frontier + a per-pair yearly streak, and **declare wars** (cap + `warMaxConcurrent`). Each war-year runs a **battle** (`warscore += (strA−strB)/(strA+strB)·rand`, + strength = realm `totalPop` × a Warlike-ethos bonus × defender home advantage), inflicts **casualties** + on each side's frontier city, and once a side is clearly winning it **conquers** a loser frontier city — + its `sSettleAllegiance` **flips to the victor's capital** (or, with `warSackChance`, the city is + **sacked** to ruins). `computeTerritory()` honours allegiance (overriding the mono-cultural rule), so + **borders move** as cities change hands. Conquered foreign/distant cities **revolt** over time + (contestable), and a realm that loses its capital collapses — so **empires rise and fall**. Wars are + **stateful & path-dependent** (unlike the derived Steps 3–4): the state — per-settlement allegiance + + active `wars` + a separate war RNG (`sWarRng`, tectonic determinism intact) — is **saved (v21)** and + **snapshotted** so `,`/`.` rewind conquests/revolts. Render: **red war-front** lines + a red at-war + marker in the **Realms** tab (with an active-wars list) over the Territory view (`P`), a cell-info "AT + WAR" flag, an active-war HUD count, and **kind=5** `WorldEvent`s ("The X Empire declares war on the Y + Kingdom", "The X captures/sacks Z", "Z revolts against the X", "Peace between…"). Knobs `war*`. *Next steps (not yet built): cultural **evolution** (spread along borders, drift, assimilation, schism); - conflict + diplomacy (war moving borders by force), trade.* + alliances / diplomacy / treaties; trade & economy.* ## Current state @@ -597,6 +615,7 @@ src/ PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2) PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3) PlanetCulture.* computeCultures (cultures/ethos/faith per continent + governments; civ Step 4) + PlanetConflict.* stepConflict (wars, casualties, conquest/allegiance, revolts; civ Step 5, save v21) PlanetIO.cpp config file (text) + binary save/load render/ (raylib viewer) Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota) @@ -660,13 +679,13 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.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/PlanetNation.cpp src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp \ src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t ``` (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` or `test_culture.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / -Civilization / Nation / Culture suites — same source list. CMake also builds `test_events` for the +`test_nation.cpp`, `test_culture.cpp` or `test_conflict.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / +Civilization / Nation / Culture / Conflict 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 @@ -730,9 +749,12 @@ line animate over whatever colour mode is active; the HUD shows a `Year/Day/HH:M sun, raise tides — `T` colours the coastline by the live tide level (amber low ↔ cyan high). `K` shows moving weather (clouds, rain, drifting storms / hurricanes). **Volcanoes** are placed by tectonic context on entry and run a stateful lifecycle — submarine ones can build into new -**volcanic islands**; `V` toggles the cone/eruption markers. `Y` makes the 3D camera **follow a storm** (cycles by strength, +**volcanic islands**; `V` toggles the cone/eruption markers. With settlements placed (`U`) and the +Territory view on (`P`), realms **wage war** each year — red war-fronts appear, cities change hands +(borders move) or fall to ruins, empires fracture as provinces revolt; the **Realms** tab lists active +wars and the **Events** tab logs them. `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 via snapshots). Mouse-wheel over the 2D map zooms (drag pans). +weather/storms/volcano lifecycle **and wars/conquests** 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). @@ -743,7 +765,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 **20**; v2 adds the `[`/`]` drift rate, v3 a +save header is versioned (currently **21**; 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** @@ -754,8 +776,10 @@ active storms, v12 appends the most recent **step-back frames** — `wxSaveMax`( appends the old pure-function **volcanoes** block, v15 replaces it with stateful volcano lifecycle agents plus volcano state in step-back frames, v16 appends the saved **event journal**, v17 appends the **geography/atlas** block — named features + per-cell region indices, v18 a geography -reshuffle salt, v19 the **ecoregions** block, and v20 the **civilization settlements** block (the -fixed settlement set + per-frame populations in the step-back history); +reshuffle salt, v19 the **ecoregions** block, v20 the **civilization settlements** block (the +fixed settlement set + per-frame populations in the step-back history), and 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; 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 @@ -765,7 +789,8 @@ the default live clock rate; pre-v14 saves load with no volcanoes (placed on the entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with old history skipped; 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`). +no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin +again as the clock runs). 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 @@ -928,6 +953,16 @@ triangles (plates are fixed in phase 1). 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()`. +- **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` + (0.5, contested frontier), `warWarlikeMult` (1.4, Warlike-ethos strength bonus), `warCasualtyRate` + (0.06, per-year frontier-city population loss, loser more), `warConquerScore` (0.6, |warscore| to take + a city), `warSackChance` (0.3, raze vs flip a taken city), `warExhaustion` (1.5, |warscore| to end in + peace — also a hard 60-battle-year cap), `warRevoltRate` (0.04, per-year base revolt of a held foreign/ + distant city), `warMinRealmPop` (2000, realms below this don't start wars). Adjacency is by settlement + proximity (`civTerritoryMax·1.5`); the red war-front/marker colours are render constants. War runs on + the yearly tick and is **saved** (allegiance + wars + war RNG) — tune for a warlike or peaceable world. - `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 913854a..e6eab85 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,7 @@ set(SIM_SOURCES src/sim/PlanetCiv.cpp src/sim/PlanetNation.cpp src/sim/PlanetCulture.cpp + src/sim/PlanetConflict.cpp src/sim/PlanetIO.cpp ) @@ -76,7 +77,7 @@ if(UNIX AND NOT APPLE) endif() enable_testing() -foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture) +foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict) 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/docs/design-notes.md b/docs/design-notes.md index 84925db..793f4b5 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -55,6 +55,8 @@ include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`). - `PlanetNation.{hpp,cpp}` — `computeTerritory` (civ Step 3: realms + per-cell ownership; derived, not saved). - `PlanetCulture.{hpp,cpp}` — `computeCultures` (civ Step 4: cultures/ethos/faith per continent + government per realm; derived, not saved). +- `PlanetConflict.{hpp,cpp}` — `stepConflict` (civ Step 5: wars, casualties, conquest/allegiance, + revolts; stateful, saved v21 + snapshotted). - `PlanetIO.cpp` — text config + binary save/load. The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration), @@ -447,6 +449,38 @@ continents). Render: a `Culture` colour mode (`cultureColor`) + pale `buildCultu `rebuildTerritory()` computes territory **then** cultures and rebuilds both border sets each sim year / placement / load / step-back. No config knobs (rules are constants in `computeCultures()`). +## Civilization Step 5 — conflict, war & shifting borders + +`PlanetConflict.cpp` (engine, raylib-free). Unlike the derived Steps 3–4, war is **stateful & +path-dependent**, so it adds saved state and bumps the save to **v21**. `stepConflict(long year)` runs +**once per sim year** (the viewer calls it in the `liveAdvance` year-tick **before** `rebuildTerritory`, +only when `dtWeather > 0` so a step-back doesn't re-advance it), reads the current realms (from last +recompute) and **mutates the world**: +1. **Revolts + pruning**: a conquered city (`sSettleAllegiance[s] ≥ 0`) whose overlord capital is gone is + freed; else it revolts with a probability rising in foreign-culture + distance (contestable conquest). +2. **Prosecute wars**: each `War` (identified by attacker/defender **capital settlement index**, the + stable id) resolves realms by capital; a battle moves `warscore` by the strength ratio (strength = + `totalPop` × Warlike bonus × defender home advantage); frontier cities take casualties; past + `warConquerScore` the winner **conquers** a loser frontier city (`sSettleAllegiance` flips to the + winner's capital) or **sacks** it (population → ruins); a war ends on exhaustion / a 60-battle cap / + a belligerent's capital falling. +3. **Declare**: adjacency is by **settlement proximity** (`civTerritoryMax·1.5`, since territory is + influence-limited with wilderness marches); hostility = ambition (size gap) + ideology (culture/faith + difference) + contested frontier + a per-pair yearly hash streak; the highest-hostility unwarred pairs + declare (attacker = the stronger), capped at `warMaxConcurrent`. +All randomness comes from a separate `sWarRng` (seeded `cfg.seed ^ 0x5A7B0A11u`) + pure hashes, so +tectonics stay bit-identical and outcomes are reproducible. + +`computeTerritory()` (`PlanetNation.cpp`) honours allegiance: a settlement with a living overlord is +forced into that capital's realm, **bypassing the mono-cultural rule** — so a conquered city's cells +become the victor's and **the border moves** (a chain-resolving capital lookup keeps a conquered vassal's +vassals consistent). State — `sSettleAllegiance` + `wars` + `sWarRng`/`sWarNextId` — is saved (v21, a new +`hasConflict` `readState` param + a per-frame block in the step-back history) and added to +`WeatherSnapshot` / `captureWeather`/`restoreWeather`, so `,`/`.` rewind conquests + revolts. Render: red +`buildWarFrontier` lines over the Territory view (`P`), a red at-war marker + active-wars list in the +Realms tab, a cell-info "AT WAR" flag, a HUD war count, and **kind=5** events (declare / capture / sack / +revolt / peace). Knobs `war*`. + ## Headless testing Engine is raylib-free, so logic is tested without a display. Build/run: @@ -459,10 +493,10 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet 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/PlanetCulture.cpp src/sim/PlanetConflict.cpp \ src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t # test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography / test_ecoregions / -# test_civ / test_nation / test_culture use the same source list. +# test_civ / test_nation / test_culture / test_conflict use the same source list. ``` (add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism). diff --git a/src/render/Overlays.cpp b/src/render/Overlays.cpp index 22b6972..4d5eb16 100644 --- a/src/render/Overlays.cpp +++ b/src/render/Overlays.cpp @@ -79,6 +79,25 @@ void buildCultureBorders(const Planet& p, float radius, std::vector& se buildLabelBorders(p, radius, p.cellCulture(), segs); } +void buildWarFrontier(const Planet& p, float radius, std::vector& segs) { + segs.clear(); + const std::vector& cn = p.cellNation(); + if (p.warList().empty() || (int)cn.size() != (int)p.cells.size()) return; + // A short tick across each cell edge whose two realms are currently at war (the war front). + for (int i = 0; i < (int)p.cells.size(); ++i) { + int a = cn[i]; if (a < 0) continue; + for (int j : p.cells[i].neighbors) { + if (j <= i) continue; // each undirected edge once + int b = (j < (int)cn.size()) ? cn[j] : -1; + if (b < 0 || b == a || !p.realmsAtWar(a, b)) continue; + Vec3 m = ((p.cells[i].unit + p.cells[j].unit) * 0.5).normalized() * radius; + Vec3 t = (p.cells[j].unit - p.cells[i].unit).normalized() * (radius * 0.03); + segs.push_back(Vector3{ (float)(m.x - t.x), (float)(m.y - t.y), (float)(m.z - t.z) }); + segs.push_back(Vector3{ (float)(m.x + t.x), (float)(m.y + t.y), (float)(m.z + t.z) }); + } + } +} + void buildDriftArrows(const Planet& p, float radius, std::vector& out, std::vector& labels) { out.clear(); labels.clear(); diff --git a/src/render/Overlays.hpp b/src/render/Overlays.hpp index bd585da..d24c937 100644 --- a/src/render/Overlays.hpp +++ b/src/render/Overlays.hpp @@ -20,6 +20,8 @@ void buildBorders(const Planet& p, float radius, void buildNationBorders(const Planet& p, float radius, std::vector& segs); // Like buildNationBorders but on the per-cell culture split (civ Step 4): cultural-region outlines. void buildCultureBorders(const Planet& p, float radius, std::vector& segs); +// Frontier segments between two cells whose realms are in an active war (civ Step 5): the war front. +void buildWarFrontier(const Planet& p, float radius, std::vector& segs); // ---- Per-plate drift arrows ------------------------------------------------- struct PlateLabel { int id; Vector3 pos; }; diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index a248b48..8560fe6 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -111,7 +111,9 @@ static std::vector cellInfo(const Planet& p, int i, double elev, do int ni = (i < (int)cn.size()) ? cn[i] : -1; if (ni >= 0 && ni < (int)p.nationList().size()) { const Nation& nat = p.nationList()[ni]; - L.push_back(std::string("realm: ") + nat.name + " (" + nationTierName(nat.tier) + ")"); + bool war = false; // civ Step 5: is this realm at war? + for (const War& w : p.warList()) if (w.attacker == nat.capital || w.defender == nat.capital) { war = true; break; } + L.push_back(std::string("realm: ") + nat.name + " (" + nationTierName(nat.tier) + ")" + (war ? " - AT WAR" : "")); } else if (p.cells[i].elevation > p.cfg.seaLevel) { L.push_back(std::string("realm: wilderness")); } diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index f5ec125..2b65c94 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -497,6 +497,12 @@ void Viewer::saveGame(const char* path) { wV(f.w.volcanoes); os.write((char*)&f.w.volRng, 4); wD(f.w.settlementPop); // v20: per-frame settlement populations + // v21: per-frame conflict state (allegiance + wars + war RNG), so a load can rewind conquests. + { uint64_t m = f.w.settlementAllegiance.size(); os.write((char*)&m, 8); + if (m) os.write((const char*)f.w.settlementAllegiance.data(), (std::streamsize)(m * sizeof(int))); } + { uint64_t m = f.w.wars.size(); os.write((char*)&m, 8); + if (m) os.write((const char*)f.w.wars.data(), (std::streamsize)(m * sizeof(War))); } + os.write((char*)&f.w.warRng, 4); os.write((char*)&f.w.warNextId, 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); @@ -530,7 +536,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)) { 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 + 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)) { 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 cfg = planet.cfg; // adopt the loaded config elapsedMy = em; settled = (st != 0); planet.drifting = settled; // resume drift boosts iff mid-drift @@ -583,6 +589,16 @@ void Viewer::loadGame(const char* path) { rV(f.w.volcanoes); is.read((char*)&f.w.volRng, 4); if (ver >= 20) rP(f.w.settlementPop); // v20: per-frame settlement populations + if (ver >= 21) { // v21: per-frame conflict state + uint64_t m = 0; is.read((char*)&m, 8); + if (!is || m > 1000000) historyOk = false; + else { f.w.settlementAllegiance.resize((size_t)m); if (m) is.read((char*)f.w.settlementAllegiance.data(), (std::streamsize)(m * sizeof(int))); } + uint64_t nw = 0; is.read((char*)&nw, 8); + if (!is || nw > 100000) historyOk = false; + else { f.w.wars.resize((size_t)nw); if (nw) is.read((char*)f.w.wars.data(), (std::streamsize)(nw * sizeof(War))); } + is.read((char*)&f.w.warRng, 4); is.read((char*)&f.w.warNextId, 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()) @@ -724,12 +740,19 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) { CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements); // Territory & nations shift slowly -> recompute once per sim year (and rebuild the border lines). + // Wars (civ Step 5) run on the same yearly tick: stepConflict mutates allegiance/populations first, + // then territory recomputes so borders move as cities change hands. bool territoryChanged = false; if (planet.settlementsPlaced()) { double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays); long year = (long)std::floor(liveTime / yearHours); if (year != lastTerritoryYear) { std::vector beforeNations = (dtWeather > 0.0) ? planet.nationList() : std::vector{}; + if (dtWeather > 0.0) { // only advance wars going forward (not on a step back) + ConflictUpdate wu = planet.stepConflict(year); + for (const WarEvent& e : wu.events) + appendEvent(5, e.severity, liveTime, e.cell, 0, e.title, e.detail); + } rebuildTerritory(); if (dtWeather > 0.0) detectNationEvents(beforeNations); territoryChanged = true; @@ -748,6 +771,7 @@ void Viewer::rebuildTerritory() { planet.computeCultures(); buildNationBorders(planet, borderR, nationBorders); buildCultureBorders(planet, borderR, cultureBorders); + buildWarFrontier(planet, borderR + 0.001f, warFrontier); // civ Step 5: current war fronts (drawn red) double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays); lastTerritoryYear = (long)std::floor(liveTime / yearHours); } diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index 546b25a..658dded 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 = 20; // 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 = 21; // 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"; @@ -106,6 +106,7 @@ struct Viewer { bool showNationBorders = false; // draw nation/realm borders (on with the Territory view) std::vector cultureBorders; // cultural-region border segments (civ Step 4, rebuilt with territory) bool showCultureBorders = false; // draw cultural-region borders (on with the Culture view) + std::vector warFrontier; // red frontier segments between realms currently at war (civ Step 5) long lastTerritoryYear = -1; // sim year territory was last recomputed (recompute when it ticks) // World event journal: currently Live World events, shaped to be reused by later phases. diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index 430cf21..5e07d64 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -98,6 +98,14 @@ void Viewer::renderGlobe3D() { } rlEnd(); rlSetLineWidth(1.0f); } + if (showNationBorders && !warFrontier.empty()) { // civ Step 5: war fronts (bright red, with the territory view) + rlSetLineWidth(3.5f); rlBegin(RL_LINES); rlColor4ub(235, 40, 30, 255); + for (size_t i = 0; i + 1 < warFrontier.size(); i += 2) { + rlVertex3f(warFrontier[i].x, warFrontier[i].y, warFrontier[i].z); + rlVertex3f(warFrontier[i + 1].x, warFrontier[i + 1].y, warFrontier[i + 1].z); + } + rlEnd(); rlSetLineWidth(1.0f); + } if (showDrift && !driftArrows.empty()) { rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255); for (size_t i = 0; i + 1 < driftArrows.size(); i += 2) { @@ -353,6 +361,7 @@ void Viewer::renderMap2D() { if (showBorders && !ridgeBorders.empty()) drawSegments2D(ridgeBorders, Color{220, 70, 60, 255}, 2.0f, vr, mapLon); if (showNationBorders && !nationBorders.empty()) drawSegments2D(nationBorders, Color{18, 18, 26, 235}, 2.0f, vr, mapLon); if (showCultureBorders && !cultureBorders.empty()) drawSegments2D(cultureBorders, Color{245, 240, 220, 230}, 2.5f, vr, mapLon); + if (showNationBorders && !warFrontier.empty()) drawSegments2D(warFrontier, Color{235, 40, 30, 255}, 2.5f, vr, mapLon); if (showDrift && !driftArrows.empty()) drawSegments2D(driftArrows, Color{90, 230, 255, 255}, 2.0f, vr, mapLon); if (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, vr, mapLon); if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, vr, mapLon); @@ -575,7 +584,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 ? "#" : "~"; + const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : "~"; 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}); @@ -685,9 +694,26 @@ void Viewer::renderLiveInfo() { } } else if (liveInfoTab == 7) { // Realms: nations by population (largest first); click a row to fly to the capital const auto& N = planet.nationList(); + const auto& W = planet.warList(); DrawText("Realms", x, y, 18, Color{200, 205, 220, 255}); DrawText(TextFormat("%d", (int)N.size()), (int)(r.x + r.width) - 40, y + 2, 13, Color{145, 155, 175, 255}); y += 26; + auto capName = [&](int cap) -> const char* { // realm name from a capital settlement index + for (const Nation& nn : N) if (nn.capital == cap) return nn.name.c_str(); + return "a fallen realm"; + }; + auto atWar = [&](int cap) { for (const War& w : W) if (w.attacker == cap || w.defender == cap) return true; return false; }; + if (!W.empty()) { // active wars summary + DrawText(TextFormat("Wars: %d", (int)W.size()), x, y, 14, Color{235, 90, 80, 255}); + y += 19; + int shown = 0; + for (const War& w : W) { + if (shown >= 3 || y > (int)(r.y + r.height) - 60) break; + DrawText(TextFormat("%s vs %s", capName(w.attacker), capName(w.defender)), x + 6, y, 11, Color{210, 140, 135, 255}); + y += 15; ++shown; + } + y += 6; + } if (N.empty()) { DrawText(planet.settlementsPlaced() ? "press P for the territory view" : "press U then P", x, y, 13, Color{150, 155, 170, 255}); } else { @@ -707,6 +733,7 @@ void Viewer::renderLiveInfo() { : nat.totalPop >= 1.0e3 ? TextFormat("%.0fk", nat.totalPop / 1.0e3) : TextFormat("%.0f", nat.totalPop); DrawText(nat.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, fg); + if (atWar(nat.capital)) DrawCircle((int)(r.x + r.width) - 104, (int)row.y + 9, 3.5f, Color{235, 60, 45, 255}); // at-war marker DrawText(TextFormat("%dx %s", nat.members, tp), (int)(r.x + r.width) - 92, (int)row.y + 3, 11, Color{150, 158, 178, 255}); y += 20; } @@ -794,8 +821,8 @@ void Viewer::renderHUD() { rv, rl, dayNightOn ? "on" : "off")); int nStorm = 0, nHur = 0; for (const auto& ws : planet.storms()) { ++nStorm; if (ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr) ++nHur; } - line(TextFormat("weather systems: %d tropical cyclones: %d%s", nStorm, nHur, - followId ? " [following]" : "")); + line(TextFormat("weather systems: %d tropical cyclones: %d wars: %d%s", nStorm, nHur, + (int)planet.warList().size(), followId ? " [following]" : "")); line("Y follow storm · . / , step clock +/- · wheel-on-map zoom"); } else { diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index 91cef33..143194d 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -65,6 +65,8 @@ void Planet::buildGeometry() { sCivCond.clear(); sCivDrought.clear(); nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear(); cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); + wars.clear(); sSettleAllegiance.clear(); + sWarRng = cfg.seed ? (cfg.seed ^ 0x5A7B0A11u) : 0x5A7B0A11u; sWarNextId = 1; } void Planet::clearDerivedState() { diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index f2e54e6..01eb067 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -7,6 +7,7 @@ #include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate #include "PlanetNation.hpp" // Nation, NationTier, GovType #include "PlanetCulture.hpp" // Culture, CultureEthos, Faith +#include "PlanetConflict.hpp" // WarEvent, ConflictUpdate (War is in PlanetTypes.hpp) #include "PlanetEcoregions.hpp" // Ecoregion #include #include @@ -26,6 +27,7 @@ public: std::vector settlements; // civilization: settlements placed once, grow/decline (saved v20+) std::vector nations; // realms grouped from settlements (derived each computeTerritory, not saved) std::vector cultures; // peoples/cultures, one per inhabited continent (derived, not saved) + std::vector wars; // civ Step 5: active wars between realms (stateful, saved v21) // Phase flag: false during Phase-1 forming (modest, original tectonics that // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts @@ -211,6 +213,16 @@ public: const std::vector& cultureList() const { return cultures; } const std::vector& cellCulture() const { return sCellCulture; } // culture index per cell (-1 = none) const std::vector& settleCulture() const { return sSettleCulture; } // culture index per settlement (-1 = none) + + // Conflict & war (PlanetConflict.cpp; civ Step 5). stepConflict() runs once per sim year: adjacent + // realms grow hostile, declare wars, fight (casualties in frontier cities), conquer (a city's + // allegiance flips to the victor) or sack (raze) cities, and conquered provinces revolt. Stateful + + // path-dependent -> saved (v21) + snapshotted. computeTerritory() reads sSettleAllegiance so borders + // move as cities change hands. Uses a separate war RNG (tectonic determinism intact). + ConflictUpdate stepConflict(long year); + const std::vector& warList() const { return wars; } + const std::vector& settleAllegiance() const { return sSettleAllegiance; } // overlord capital settlement index (-1 = free) + bool realmsAtWar(int nationA, int nationB) const; // are these two nation indices in an active war? // Per-settlement live conditions (derived each stepCivilization; not saved). condition = the combined // environmental multiplier on carrying capacity (1 = normal, <1 = hardship, >1 = boom); drought = // current drought severity 0..1. Parallel to `settlements`. Used by the viewer for tint + events. @@ -240,7 +252,8 @@ public: 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 hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true, + bool hasConflict = true); // Helpers for rendering / info. double cellWidthMeters() const; // approx lateral cell spacing @@ -348,6 +361,10 @@ private: // Cultures (derived from settlements + geography; not saved). sCellCulture: culture index per cell // (-1 = none/wilderness/ocean); sSettleCulture: culture index per settlement. std::vector sCellCulture, sSettleCulture; + // Conflict & war (civ Step 5; stateful, saved v21). sSettleAllegiance[s] = the overlord's capital + // settlement index if conquered, else -1 (independent). Separate war RNG + a stable war-id counter. + std::vector sSettleAllegiance; + uint32_t sWarRng = 1, sWarNextId = 1; // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // on-demand discrete population (saved). sHasBiota latches once generated/loaded. diff --git a/src/sim/PlanetConflict.cpp b/src/sim/PlanetConflict.cpp new file mode 100644 index 0000000..7e7d588 --- /dev/null +++ b/src/sim/PlanetConflict.cpp @@ -0,0 +1,191 @@ +#include "Planet.hpp" +#include +#include +#include +#include + +// --- Civilization Step 5: conflict, war & shifting borders ------------------- +// stepConflict() runs once per sim year. It reads the current realms (derived last recompute) and +// MUTATES the world: revolts + casualties + conquests set per-settlement allegiance and populations, +// and territory recomputes afterwards so borders move. Realms are identified by their capital SETTLEMENT +// INDEX (stable across the yearly recompute). A separate war RNG (sWarRng) keeps tectonics deterministic; +// the state (allegiance + wars + RNG) is saved (v21) and snapshotted so step-back rewinds conquests. + +namespace { + inline uint32_t warHash(uint32_t a) { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; } +} + +// Are two nation indices in an active war? (wars store capital settlement indices, the stable id.) +bool Planet::realmsAtWar(int a, int b) const { + if (a < 0 || b < 0 || a >= (int)nations.size() || b >= (int)nations.size()) return false; + int ca = nations[a].capital, cb = nations[b].capital; + for (const War& w : wars) + if ((w.attacker == ca && w.defender == cb) || (w.attacker == cb && w.defender == ca)) return true; + return false; +} + +ConflictUpdate Planet::stepConflict(long year) { + ConflictUpdate up; + if (settlements.empty() || nations.empty()) return up; + if (sSettleNation.size() != settlements.size()) return up; + if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1); + const double abP = cfg.civAbandonPop; + const uint32_t seed = cfg.seed ? cfg.seed : 1u; + + // Capital settlement index -> current nation index (realms are re-derived each year). + std::unordered_map capNation; + for (size_t ni = 0; ni < nations.size(); ++ni) capNation[nations[ni].capital] = (int)ni; + auto nationOfCap = [&](int cap) -> int { auto it = capNation.find(cap); return it == capNation.end() ? -1 : it->second; }; + auto alive = [&](int s) { return s >= 0 && s < (int)settlements.size() && settlements[s].population >= abP; }; + auto angCell = [&](int a, int b) { return std::acos(std::clamp(cells[a].unit.dot(cells[b].unit), -1.0, 1.0)); }; + auto realmStrength = [&](int ni) { + double s = std::max(1.0, nations[ni].totalPop); + int cid = nations[ni].cultureId; + if (cid >= 0 && cid < (int)cultures.size() && cultures[cid].ethos == CultureEthos::Warlike) s *= cfg.warWarlikeMult; + return s; + }; + // The realm's frontier settlement = its living member nearest a reference cell (the enemy capital). + auto frontierOf = [&](int ni, int refCell, bool exclCap) -> int { + int best = -1; double bestD = 1e9; + for (size_t k = 0; k < settlements.size(); ++k) { + if (sSettleNation[k] != ni || !alive((int)k)) continue; + if (exclCap && (int)k == nations[ni].capital) continue; + double d = angCell(settlements[k].cell, refCell); + if (d < bestD) { bestD = d; best = (int)k; } + } + return best; + }; + auto rnd = [&]() { sWarRng ^= sWarRng << 13; sWarRng ^= sWarRng >> 17; sWarRng ^= sWarRng << 5; if (!sWarRng) sWarRng = 1u; return (sWarRng & 0xFFFFFFu) / double(0x1000000); }; + bool activity = false; + + // --- 1) Prune stale allegiances + revolts (contestable conquest) --------- + for (size_t s = 0; s < settlements.size(); ++s) { + int ov = sSettleAllegiance[s]; + if (ov < 0) continue; + int no = alive(ov) ? nationOfCap(ov) : -1; + if (no < 0 || (int)s == ov) { sSettleAllegiance[s] = -1; continue; } // overlord fell -> province freed + if (!alive((int)s)) continue; + int myCult = (s < sSettleCulture.size()) ? sSettleCulture[s] : -1; + int ovCult = nations[no].cultureId; + double cultBonus = (myCult != ovCult) ? 2.0 : 0.2; // foreign rule is resented + double dist = angCell(settlements[s].cell, settlements[ov].cell) / std::max(1e-6, cfg.civTerritoryMax); + double p = cfg.warRevoltRate * (cultBonus + std::min(2.0, dist)); + if (rnd() < p) { + sSettleAllegiance[s] = -1; activity = true; + up.events.push_back(WarEvent{1, settlements[s].cell, + settlements[s].name + " revolts against the " + nations[no].name, + settlements[s].name + " throws off " + nations[no].name + " and regains independence."}); + } + } + + // --- 2) Prosecute active wars (battles, casualties, conquest, peace) ------ + std::vector keep; keep.reserve(wars.size()); + for (War w : wars) { + int na = nationOfCap(w.attacker), nd = nationOfCap(w.defender); + if (na < 0 || nd < 0) { // a belligerent's realm is gone + int survivorCap = (na >= 0) ? w.attacker : (nd >= 0 ? w.defender : -1); + if (survivorCap >= 0) { + int sn = nationOfCap(survivorCap); + if (sn >= 0) up.events.push_back(WarEvent{2, settlements[nations[sn].capital].cell, + "The " + nations[sn].name + " prevails in war", + "Their rival is broken; the war is over."}); + } + activity = true; continue; // drop the war + } + double strA = realmStrength(na), strD = realmStrength(nd) * 1.25; // defender home advantage + double delta = ((strA - strD) / (strA + strD)) * (0.75 + rnd() * 0.5); + w.warscore += delta; w.battles++; activity = true; + // Casualties on each side's frontier city (the loser bleeds more). + int fA = frontierOf(na, settlements[nations[nd].capital].cell, false); + int fD = frontierOf(nd, settlements[nations[na].capital].cell, false); + double attLoss = std::min(0.9, cfg.warCasualtyRate * (delta < 0 ? 2.0 : 0.6)); + double defLoss = std::min(0.9, cfg.warCasualtyRate * (delta > 0 ? 2.0 : 0.6)); + if (fA >= 0) settlements[fA].population *= (1.0 - attLoss); + if (fD >= 0) settlements[fD].population *= (1.0 - defLoss); + // Conquest once one side is clearly winning. + if (std::fabs(w.warscore) >= cfg.warConquerScore) { + int winner = (w.warscore > 0) ? na : nd, loser = (w.warscore > 0) ? nd : na; + int winCap = nations[winner].capital; + int target = frontierOf(loser, settlements[winCap].cell, true); // a border city first + if (target < 0) target = frontierOf(loser, settlements[winCap].cell, false); // else the capital itself + if (target >= 0) { + int tcell = settlements[target].cell; + if (rnd() < cfg.warSackChance) { + settlements[target].population = abP * 0.5; // razed to ruins + sSettleAllegiance[target] = -1; + up.events.push_back(WarEvent{2, tcell, + "The " + nations[winner].name + " sacks " + settlements[target].name, + settlements[target].name + " is put to the torch -- only ruins remain."}); + } else { + sSettleAllegiance[target] = winCap; // flips to the victor + settlements[target].population *= 0.8; // survives the siege, diminished + up.events.push_back(WarEvent{1, tcell, + "The " + nations[winner].name + " captures " + settlements[target].name, + settlements[target].name + " falls to the " + nations[winner].name + "."}); + } + w.warscore *= 0.3; // the front resets after a city changes hands + } + } + if (std::fabs(w.warscore) >= cfg.warExhaustion || w.battles > 60) { // exhausted -> peace + up.events.push_back(WarEvent{1, settlements[nations[na].capital].cell, + "Peace between the " + nations[na].name + " and the " + nations[nd].name, + "The war ends, both sides exhausted."}); + continue; // drop the war + } + keep.push_back(w); + } + wars.swap(keep); + + // --- 3) Declare new wars from neighbouring-realm hostility --------------- + // Territory is influence-limited (wilderness marches between realms), so realm adjacency is by + // SETTLEMENT PROXIMITY -- realms that are merely near each other can contest the land between them. + const double neighborRange = cfg.civTerritoryMax * 1.5; + std::map, std::pair> pinfo; // (loNat,hiNat) -> (minDist, closePairs) + for (size_t a = 0; a < settlements.size(); ++a) { + int na = sSettleNation[a]; if (na < 0 || !alive((int)a)) continue; + for (size_t b = a + 1; b < settlements.size(); ++b) { + int nb = sSettleNation[b]; if (nb < 0 || nb == na || !alive((int)b)) continue; + double d = angCell(settlements[a].cell, settlements[b].cell); + if (d >= neighborRange) continue; + auto key = std::make_pair(std::min(na, nb), std::max(na, nb)); + auto it = pinfo.find(key); + if (it == pinfo.end()) pinfo[key] = {d, 1}; + else { it->second.first = std::min(it->second.first, d); it->second.second++; } + } + } + struct Cand { int a, b; double hostility; }; + std::vector cands; + for (auto& kv : pinfo) { + int A = kv.first.first, B = kv.first.second; + double minD = kv.second.first; int closePairs = kv.second.second; + if (realmsAtWar(A, B)) continue; + if (nations[A].totalPop < cfg.warMinRealmPop || nations[B].totalPop < cfg.warMinRealmPop) continue; + double sA = realmStrength(A), sB = realmStrength(B); + double sizeGap = std::fabs(sA - sB) / (sA + sB); + int cA = nations[A].cultureId, cB = nations[B].cultureId; + double ideo = (cA != cB ? 1.0 : 0.0); + if (cA >= 0 && cB >= 0 && cA < (int)cultures.size() && cB < (int)cultures.size() && cultures[cA].faith != cultures[cB].faith) ideo += 0.5; + double prox = 1.0 - std::min(1.0, minD / neighborRange); // closer realms are more contested + double frontN = std::min(1.0, closePairs / 4.0); + int ca = nations[A].capital, cb = nations[B].capital; + uint32_t pk = warHash((uint32_t)std::min(ca, cb) * 73856093u ^ (uint32_t)std::max(ca, cb) * 19349663u ^ (uint32_t)year * 83492791u ^ seed); + double noise = (pk & 0xFFFFu) / 65535.0; + double hostility = cfg.warAmbition * sizeGap + cfg.warIdeology * ideo + cfg.warBorder * (0.5 * frontN + 0.5 * prox) + 0.4 * noise; + cands.push_back({A, B, hostility}); + } + std::sort(cands.begin(), cands.end(), [](const Cand& x, const Cand& y) { return x.hostility > y.hostility; }); + for (auto& c : cands) { + if ((int)wars.size() >= cfg.warMaxConcurrent) break; + if (rnd() >= cfg.warDeclareRate * c.hostility) continue; + int att = (realmStrength(c.a) >= realmStrength(c.b)) ? c.a : c.b; + int def = (att == c.a) ? c.b : c.a; + War w; w.id = sWarNextId++; w.attacker = nations[att].capital; w.defender = nations[def].capital; w.startYear = year; + wars.push_back(w); activity = true; + up.events.push_back(WarEvent{1, settlements[nations[att].capital].cell, + "The " + nations[att].name + " declares war on the " + nations[def].name, + "Armies march to the frontier."}); + } + + up.changed = activity; + return up; +} diff --git a/src/sim/PlanetConflict.hpp b/src/sim/PlanetConflict.hpp new file mode 100644 index 0000000..669413f --- /dev/null +++ b/src/sim/PlanetConflict.hpp @@ -0,0 +1,26 @@ +#pragma once +#include +#include +#include + +// Civilization Step 5: conflict, war & shifting borders. Neighbouring realms grow hostile (ambition + +// culture/faith difference + contested-border pressure) and declare wars; battles inflict casualties on +// frontier cities; the winner conquers a city (its allegiance flips to the victor) or sacks it (razed to +// ruins); conquered foreign/distant cities can revolt. Unlike the derived territory/culture layers, war +// is STATEFUL & path-dependent (it mutates settlement allegiance + populations over time), so its state +// is saved (v21) + snapshotted for step-back. `stepConflict()` runs once per sim year and uses a separate +// war RNG so it never perturbs the tectonic stream. + +// One war-related thing that happened this year; the viewer turns each into a kind=5 WorldEvent. +struct WarEvent { + uint8_t severity = 1; // 0 info, 1 notable, 2 severe + int cell = -1; // where it happened (for the event's focus jump) + std::string title; + std::string detail; +}; + +// What stepConflict() changed this year -- the viewer logs the events and recolors if borders moved. +struct ConflictUpdate { + bool changed = false; // allegiance/populations changed -> territory should recompute + std::vector events; +}; diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index b6a31df..c54ce17 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -56,12 +56,14 @@ D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \ D(civStormDeathRate) D(civHurricaneDeathMult) \ D(civTerritoryBase) D(civTerritoryScale) D(civTerritoryMax) D(civVassalRange) D(civEmpirePop) \ + D(warDeclareRate) D(warAmbition) D(warIdeology) D(warBorder) D(warWarlikeMult) D(warCasualtyRate) \ + D(warConquerScore) D(warSackChance) D(warExhaustion) D(warRevoltRate) D(warMinRealmPop) \ 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(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) I(warMaxConcurrent) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ U(seed) @@ -285,6 +287,18 @@ std::string validateConfig(const PlanetConfig& cfg) { E(rng(cfg.civTerritoryMax, 0.01, 3.14159, "civTerritoryMax")); E(rng(cfg.civVassalRange, 0.0, 20.0, "civVassalRange")); E(rng(cfg.civEmpirePop, 1.0, 1.0e12, "civEmpirePop")); + E(rng(cfg.warDeclareRate, 0.0, 100.0, "warDeclareRate")); + E(rng(cfg.warAmbition, 0.0, 100.0, "warAmbition")); + E(rng(cfg.warIdeology, 0.0, 100.0, "warIdeology")); + E(rng(cfg.warBorder, 0.0, 100.0, "warBorder")); + E(rng(cfg.warWarlikeMult, 1.0, 100.0, "warWarlikeMult")); + E(rng(cfg.warCasualtyRate, 0.0, 1.0, "warCasualtyRate")); + E(rng(cfg.warConquerScore, 0.0, 1000.0, "warConquerScore")); + E(rng(cfg.warSackChance, 0.0, 1.0, "warSackChance")); + E(rng(cfg.warExhaustion, 0.0, 1000.0, "warExhaustion")); + E(rng(cfg.warRevoltRate, 0.0, 10.0, "warRevoltRate")); + E(rng(cfg.warMinRealmPop, 0.0, 1.0e12, "warMinRealmPop")); + E(irng(cfg.warMaxConcurrent, 0, 100000, "warMaxConcurrent")); E(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.beltWidth, 1, 12, "beltWidth")); @@ -453,11 +467,21 @@ void Planet::writeState(std::ostream& os) const { uint64_t L = st.name.size(); writePod(os, L); if (L) os.write(st.name.data(), (std::streamsize)L); } + // v21: conflict & war (civ Step 5). Per-settlement allegiance (conquest), the active wars and the + // war RNG -- everything territory/culture derives from is otherwise recomputed on load. + writeVec(os, sSettleAllegiance); + uint64_t nw = wars.size(); writePod(os, nw); + for (const War& w : wars) { + writePod(os, w.id); writePod(os, w.attacker); writePod(os, w.defender); + writePod(os, w.startYear); writePod(os, w.warscore); writePod(os, w.battles); + } + writePod(os, sWarRng); writePod(os, sWarNextId); } 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 hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, + bool hasConflict) { // 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; } @@ -466,6 +490,7 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM if (!hasGeography) hasGeoSalt = false; // salt follows the geography block if (!hasGeoSalt) hasEcoregions = false; // ecoregions follow the v18 salt if (!hasEcoregions) hasSettlements = false; // settlements follow the ecoregion block + if (!hasSettlements) hasConflict = false; // conflict follows the settlement 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 @@ -680,6 +705,24 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM sCellSettlement.assign(cells.size(), -1); for (int k = 0; k < (int)settlements.size(); ++k) sCellSettlement[settlements[k].cell] = k; } + // v21: conflict & war (civ Step 5). Allegiance + active wars + war RNG (pre-v21 saves keep the + // buildGeometry defaults: no wars, everyone independent). + if (hasConflict) { + if (!readVec(is, sSettleAllegiance, 1000000)) return false; + if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1); + for (int& a : sSettleAllegiance) if (a < -1 || a >= (int)settlements.size()) a = -1; + uint64_t nw = 0; readPod(is, nw); + if (!is || nw > 100000) return false; + wars.resize((size_t)nw); + for (War& w : wars) { + readPod(is, w.id); readPod(is, w.attacker); readPod(is, w.defender); + readPod(is, w.startYear); readPod(is, w.warscore); readPod(is, w.battles); + if (!is || !std::isfinite(w.warscore)) return false; + } + readPod(is, sWarRng); readPod(is, sWarNextId); + if (!sWarRng) sWarRng = cfg.seed ? (cfg.seed ^ 0x5A7B0A11u) : 0x5A7B0A11u; + if (!sWarNextId) sWarNextId = 1; + } computeBiotaDensity(); // derived density scalars for the colour views return (bool)is; } diff --git a/src/sim/PlanetNation.cpp b/src/sim/PlanetNation.cpp index cc86932..31695a4 100644 --- a/src/sim/PlanetNation.cpp +++ b/src/sim/PlanetNation.cpp @@ -49,8 +49,15 @@ void Planet::computeTerritory() { return a < b; }); std::vector capitalOf(settlements.size(), -1); + const bool haveAllegiance = (sSettleAllegiance.size() == settlements.size()); for (int s : order) { if (range[s] <= 0.0) continue; + // Conquest override (civ Step 5): a conquered settlement joins its overlord's realm, crossing + // cultures (bypassing the mono-cultural rule) -- this is how war moves borders. + if (haveAllegiance) { + int ov = sSettleAllegiance[s]; + if (ov >= 0 && ov < (int)settlements.size() && ov != s && range[ov] > 0.0) { capitalOf[s] = ov; continue; } + } int joinCap = -1; double joinAng = 1e9; for (int c : order) { if (c == s) break; // order is descending -> rest are smaller @@ -62,11 +69,13 @@ void Planet::computeTerritory() { capitalOf[s] = (joinCap >= 0) ? joinCap : s; } - // Build nation records from the distinct capitals. + // Build nation records from the distinct capitals. Resolve each settlement to its ULTIMATE capital + // (conquest can chain: a vassal's overlord may itself be a vassal), so no settlement lands in two realms. std::vector capToNation(settlements.size(), -1); for (int s : order) { if (capitalOf[s] < 0) continue; int cap = capitalOf[s]; + for (int g = 0; g < 64 && capitalOf[cap] >= 0 && capitalOf[cap] != cap; ++g) cap = capitalOf[cap]; if (capToNation[cap] < 0) { capToNation[cap] = (int)nations.size(); Nation nat; nat.id = (uint32_t)nations.size() + 1; nat.capital = cap; diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index 4a3119d..dd5b9fd 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -87,6 +87,17 @@ struct Volcano { // submarine vent crossed sea level (a new island/sunk island -> needs biome reclassification). struct VolcanoUpdate { bool recolor = false; bool breach = false; }; +// Civilization Step 5: an active war between two realms (identified by their capital SETTLEMENT INDEX, +// a stable id across the yearly territory recompute). Stateful & saved (v21) + snapshotted for step-back. +struct War { + uint32_t id = 0; + int attacker = -1; // capital settlement index of the aggressor realm + int defender = -1; // capital settlement index of the defending realm + long startYear = 0; + double warscore = 0.0; // + favours the attacker, - the defender (accumulates from battles) + int battles = 0; // number of resolved battle-years +}; + // A full snapshot of the (integrated, non-analytic) weather state, for the viewer's step-back // undo history -- weather can't be reversed in closed form, so we restore a saved frame instead. struct WeatherSnapshot { @@ -98,6 +109,11 @@ struct WeatherSnapshot { // Civilization: settlement populations (the only mutable per-step civ state, since the set is // fixed after placement). Restored on a step back so towns rewind/replay with the clock. std::vector settlementPop; + // Civilization Step 5 (conflict): mutable war state -- per-settlement allegiance (conquest), the + // active wars and the war RNG. Restored on a step back so conquests/revolts rewind with the clock. + std::vector settlementAllegiance; + std::vector wars; + uint32_t warRng = 0, warNextId = 0; }; struct Plate { @@ -421,4 +437,19 @@ struct PlanetConfig { double civVassalRange = 1.5; // a capital annexes smaller settlements within this x its range int civEmpireMinMembers = 5; // realm of >= this many settlements counts as an Empire double civEmpirePop = 5.0e6; // ...or total population >= this counts as an Empire + // Civilization Step 5: conflict & war (stateful, saved v21). Neighbouring realms grow hostile and + // fight; casualties shrink frontier cities, winners conquer (flip) or sack (raze) them, empires + // fracture as provinces revolt. All rolls come from a separate war RNG (tectonic stream intact). + int warMaxConcurrent = 6; // cap on simultaneous active wars + double warDeclareRate = 0.12; // per-year war-declaration chance scale (x hostility) + double warAmbition = 1.0; // hostility weight of the size gap (strong preys on weak) + double warIdeology = 0.8; // hostility weight of culture + faith difference + double warBorder = 0.5; // hostility weight of contested-frontier length + double warWarlikeMult = 1.4; // military-strength multiplier for a Warlike-ethos realm + double warCasualtyRate = 0.06; // per war-year frontier-city population loss (loser more) + double warConquerScore = 0.6; // |warscore| past which the winner takes a frontier city + double warSackChance = 0.3; // chance a taken city is razed to ruins instead of flipped + double warExhaustion = 1.5; // |warscore| (or battle count) past which a war ends in peace + double warRevoltRate = 0.04; // per-year base revolt chance of a held foreign/distant city + double warMinRealmPop = 2000.0; // realms below this population don't start wars }; diff --git a/src/sim/PlanetWeather.cpp b/src/sim/PlanetWeather.cpp index 9997d0d..d95703f 100644 --- a/src/sim/PlanetWeather.cpp +++ b/src/sim/PlanetWeather.cpp @@ -33,6 +33,8 @@ WeatherSnapshot Planet::captureWeather() const { s.volcanoes = volcanoes; s.volRng = sVolRng; s.settlementPop.reserve(settlements.size()); // civ: only population is mutable for (const Settlement& st : settlements) s.settlementPop.push_back(st.population); + s.settlementAllegiance = sSettleAllegiance; // civ Step 5: conquest state + s.wars = wars; s.warRng = sWarRng; s.warNextId = sWarNextId; return s; } @@ -42,6 +44,9 @@ void Planet::restoreWeather(const WeatherSnapshot& s) { volcanoes = s.volcanoes; sVolRng = s.volRng; if (s.settlementPop.size() == settlements.size()) // restore populations (set is fixed) for (size_t k = 0; k < settlements.size(); ++k) settlements[k].population = s.settlementPop[k]; + sSettleAllegiance = s.settlementAllegiance; // civ Step 5: restore conquest state + 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; sHasWeather = !sHumidity.empty(); } diff --git a/test_conflict.cpp b/test_conflict.cpp new file mode 100644 index 0000000..766d749 --- /dev/null +++ b/test_conflict.cpp @@ -0,0 +1,167 @@ +// Headless test for civilization Step 5 (conflict, war & shifting borders). No display needed. +// +// g++ -std=c++17 -O2 -Isrc/sim test_conflict.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/PlanetIO.cpp -o /tmp/twar && /tmp/twar +// +// Verifies: wars start between neighbouring realms; conquest sets allegiance so computeTerritory moves +// the border (the city joins the overlord's realm); revolts clear allegiance; determinism + RNG +// isolation; save-v21 + step-back snapshot round-trip. + +#include "Planet.hpp" +#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(); +} +// Crank the war knobs so conquest is frequent + persistent (isolates the mechanics from randomness). +static void aggressiveWars(Planet& p) { + p.cfg.warDeclareRate = 8.0; p.cfg.warAmbition = 2.0; p.cfg.warIdeology = 2.0; p.cfg.warBorder = 2.0; + p.cfg.warConquerScore = 0.05; p.cfg.warSackChance = 0.0; p.cfg.warExhaustion = 900.0; + p.cfg.warRevoltRate = 0.0; p.cfg.warMinRealmPop = 1.0; p.cfg.warMaxConcurrent = 50; p.cfg.warCasualtyRate = 0.0; +} +static bool warsEqual(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].attacker != b[i].attacker || a[i].defender != b[i].defender || a[i].battles != b[i].battles + || std::fabs(a[i].warscore - b[i].warscore) > 1e-12) return false; + return true; +} + +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; + p.placeSettlements(); + double lt = 0.0; for (int yr = 0; yr < 600; ++yr) { lt += 2.0 * yearH; p.stepCivilization(2.0 * yearH, lt); } + aggressiveWars(p); + + std::printf("Conflict: wars erupt + conquest sets allegiance\n"); + bool sawWar = false; int totalDeclared = 0; + for (long yr = 0; yr < 300; ++yr) { + p.computeTerritory(); p.computeCultures(); + ConflictUpdate u = p.stepConflict(yr); + if (!p.warList().empty()) sawWar = true; + for (const WarEvent& e : u.events) if (e.title.find("declares war") != std::string::npos) ++totalDeclared; + } + p.computeTerritory(); // reflect the final allegiances + int allegianceSet = 0; + for (int a : p.settleAllegiance()) if (a >= 0) ++allegianceSet; + std::printf(" saw wars: %s ; declared over run: %d ; cities under foreign rule: %d\n", + sawWar ? "yes" : "no", totalDeclared, allegianceSet); + check(sawWar, "wars break out between neighbouring realms"); + check(allegianceSet > 0, "at least one city is conquered (allegiance set)"); + + std::printf("Conflict: conquest moves the border (city joins its overlord's realm)\n"); + { + int joined = 0, crossedCulture = 0, checkedValidOverlord = 0; + for (size_t s = 0; s < p.settlements.size(); ++s) { + int ov = p.settleAllegiance()[s]; + if (ov < 0) continue; + // Is the overlord still a living capital of some nation? + int ovNation = -1; + for (size_t ni = 0; ni < p.nationList().size(); ++ni) if (p.nationList()[ni].capital == ov) ovNation = (int)ni; + if (ovNation < 0) continue; + ++checkedValidOverlord; + int myNation = p.settleNation()[s]; + if (myNation == ovNation && p.cellNation()[p.settlements[s].cell] == ovNation) ++joined; + if (myNation == ovNation && s < p.settleCulture().size() && + p.settleCulture()[s] != p.settleCulture()[ov]) ++crossedCulture; + } + std::printf(" %d conquered cities have a living overlord ; %d joined it ; %d crossed a culture line\n", + checkedValidOverlord, joined, crossedCulture); + check(checkedValidOverlord == 0 || joined > 0, "a conquered city sits in its overlord's realm (border moved)"); + } + + std::printf("Conflict: revolts free conquered cities\n"); + { + int before = 0; for (int a : p.settleAllegiance()) if (a >= 0) ++before; + p.cfg.warRevoltRate = 5.0; p.cfg.warDeclareRate = 0.0; // no new wars, heavy revolts + for (long yr = 300; yr < 320; ++yr) { p.computeTerritory(); p.computeCultures(); p.stepConflict(yr); } + int after = 0; for (int a : p.settleAllegiance()) if (a >= 0) ++after; + std::printf(" conquered cities before revolts: %d ; after: %d\n", before, after); + check(before == 0 || after < before, "revolts reduce the number of conquered cities"); + } + + std::printf("Conflict: 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); } + aggressiveWars(q); + for (long yr = 0; yr < 300; ++yr) { q.computeTerritory(); q.computeCultures(); q.stepConflict(yr); } + q.computeTerritory(); + // Re-run p's identical sequence into a third world to compare against (p has since had revolts applied). + Planet p2; p2.generate(cfg); settle(p2); drift(p2, 400); p2.placeSettlements(); + double lt3 = 0.0; for (int yr = 0; yr < 600; ++yr) { lt3 += 2.0 * yearH; p2.stepCivilization(2.0 * yearH, lt3); } + aggressiveWars(p2); + for (long yr = 0; yr < 300; ++yr) { p2.computeTerritory(); p2.computeCultures(); p2.stepConflict(yr); } + p2.computeTerritory(); + check(q.settleAllegiance() == p2.settleAllegiance() && warsEqual(q.warList(), p2.warList()), + "stepConflict is deterministic (same allegiance + wars)"); + + std::printf("Conflict: RNG isolation from tectonics\n"); + Planet x; x.generate(cfg); settle(x); + Planet y; y.generate(cfg); settle(y); + for (int k = 0; k < 40; ++k) { + double dx = x.cflDtMy(); x.advect(dx); x.step(); x.erode(dx); + double dy = y.cflDtMy(); y.advect(dy); y.step(); y.erode(dy); + if (k == 20) { y.placeSettlements(); y.stepCivilization(yearH, yearH); y.computeTerritory(); y.computeCultures(); aggressiveWars(y); y.stepConflict(0); } + } + { + 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, "stepConflict never perturbs tectonic evolution"); + } + + std::printf("Conflict: save v21 round-trip\n"); + { + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + p2.writeState(ss); + Planet r; + bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true, true); + check(ok, "readState accepts the v21 stream"); + check(r.settleAllegiance() == p2.settleAllegiance(), "allegiance survives save/load"); + check(warsEqual(r.warList(), p2.warList()), "active wars survive save/load"); + r.computeTerritory(); + check(r.cellNation() == p2.cellNation(), "territory recomputed after load matches (allegiance honoured)"); + } + + std::printf("Conflict: step-back snapshot round-trip\n"); + { + WeatherSnapshot snap = p2.captureWeather(); + std::vector before = p2.settleAllegiance(); + std::vector wbefore = p2.warList(); + // Mutate: heavy revolts + more wars. + p2.cfg.warRevoltRate = 5.0; p2.cfg.warDeclareRate = 5.0; + for (long yr = 400; yr < 410; ++yr) { p2.computeTerritory(); p2.computeCultures(); p2.stepConflict(yr); } + bool changed = (p2.settleAllegiance() != before) || !warsEqual(p2.warList(), wbefore); + p2.restoreWeather(snap); + check(changed, "the mutation actually changed conflict state"); + check(p2.settleAllegiance() == before && warsEqual(p2.warList(), wbefore), + "restoreWeather rewinds allegiance + wars (step-back)"); + } + + std::printf(failures ? "\nFAILURES: %d\n" : "\nALL CONFLICT CHECKS PASSED\n", failures); + return failures ? 1 : 0; +}