Civ Step 5: conflict, war & shifting borders (save v21)

Realms stop coexisting peacefully and go to war. Unlike the derived Steps 3-4,
war is stateful & path-dependent, so it adds saved state (allegiance + wars +
a war RNG), extends the step-back snapshot, and bumps the save to v21.

- PlanetConflict.{hpp,cpp}: stepConflict(year) runs once per sim year. Neighbouring
  realms (adjacency by settlement proximity) grow hostile from ambition (size gap)
  + ideology (culture/faith difference) + contested frontier + a yearly streak, and
  declare wars (cap warMaxConcurrent). Each war-year runs a battle (strength =
  totalPop x Warlike bonus x defender home advantage), inflicts casualties on
  frontier cities, and the winner conquers a loser frontier city (allegiance flips
  to the victor) or sacks it (ruins). Conquered foreign/distant cities revolt over
  time; a realm that loses its capital collapses -> empires rise and fall. Separate
  sWarRng keeps tectonics deterministic.
- computeTerritory() honours sSettleAllegiance (overriding the mono-cultural rule)
  with a chain-resolving capital lookup, so borders move as cities change hands.
- Save v21: allegiance + wars + war RNG (new hasConflict readState param + a
  per-frame block in the step-back history); WeatherSnapshot + capture/restoreWeather
  extended, so ,/. rewind conquests + revolts.
- Render: red war-front lines + a red at-war marker & active-wars list in the Realms
  tab over the Territory view (P), a cell-info AT WAR flag, a HUD war count, and
  kind=5 events (declare / capture / sack / revolt / peace).
- war* config knobs; test_conflict.cpp covers wars erupting, conquest moving the
  border, revolts, determinism, RNG isolation, save-v21 + snapshot round-trip.
  All 13 suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-07-01 08:33:38 +02:00
parent 91f1b90a93
commit 4a1f3377ba
19 changed files with 681 additions and 27 deletions

View File

@ -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) E ecoregions colour view (names ecological provinces on first use; Eco tab)
I habitability heat map (where civilization can thrive) I habitability heat map (where civilization can thrive)
U settlements: the dawn of civilization on first press, then toggle markers (Civ tab) 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) 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) 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 . / , 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 civEmpireMinMembers 5 settlements in a realm to count as an empire
civEmpirePop 5e6 total realm population 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 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 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 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/PlanetBiota.cpp \
src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.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/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 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 / # 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. # The CMake build also includes test_events for the viewer event journal.
Verifies geometry, plate assignment, gradual non-saturating relief and Verifies geometry, plate assignment, gradual non-saturating relief and

View File

@ -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 **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 + 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 geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all
on the Live World clock). **Steps 14 of the roadmap are done (plus a derived ecoregions atlas):** on the Live World clock). **Steps 15 of the roadmap are done (plus a derived ecoregions atlas):**
- **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the - **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the
foundation everything civic references. `Planet::generateGeography()` extracts named features from foundation everything civic references. `Planet::generateGeography()` extracts named features from
the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land), the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land),
@ -184,8 +184,26 @@ on the Live World clock). **Steps 14 of the roadmap are done (plus a derived
government-aware realm labels. Like territory, cultures are a **pure function of the (saved) settlements + 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, 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). 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 += (strAstrB)/(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 34): 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); *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 ## Current state
@ -597,6 +615,7 @@ src/
PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2) PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2)
PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3) PlanetNation.* computeTerritory (realms + per-cell ownership + borders; civ Step 3)
PlanetCulture.* computeCultures (cultures/ethos/faith per continent + governments; civ Step 4) PlanetCulture.* computeCultures (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 PlanetIO.cpp config file (text) + binary save/load
render/ (raylib viewer) render/ (raylib viewer)
Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota) 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/PlanetBiota.cpp \
src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.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/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 src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
``` ```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`, (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
`test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`, `test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`,
`test_nation.cpp` or `test_culture.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / `test_nation.cpp`, `test_culture.cpp` or `test_conflict.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 Civilization / Nation / Culture / Conflict suites — same source list. CMake also builds `test_events` for the
viewer event journal.) viewer event journal.)
Use this to verify tectonics after changing `Planet::step()` without launching Use this to verify tectonics after changing `Planet::step()` without launching
@ -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). 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 `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 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** 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 CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate). file (both applied before the initial load/generate).
@ -743,7 +765,7 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and
`Planet::writeState`/`readState`, resumes deterministically). Config is `Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
defaults (without overwriting your `planet.cfg`) and shows a status message. The defaults (without overwriting your `planet.cfg`) and shows a status message. The
save header is versioned (currently **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 `phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
key=value text block** instead of a raw POD dump, v7 appends the **biota population** key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World** block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
@ -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 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 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 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 reshuffle salt, v19 the **ecoregions** block, v20 the **civilization settlements** block (the
fixed settlement set + per-frame populations in the step-back history); 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 newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`); rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10 pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
@ -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; 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 pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on
demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with
no settlements (re-seeded via `U`). 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 A load drops any **stale** pre-load `wxUndo` history and reloads the
saved one. saved one.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
@ -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 government rules are internal constants in `PlanetCulture.cpp` (ethos-signal threshold 0.34, the
biome→faith map, the tier→government picks) and the culture colours/border colour are render constants. biome→faith map, the tier→government picks) and the culture colours/border colour are render constants.
Cultures are derived (recomputed with territory, not saved). To retune, edit `computeCultures()`. 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 - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds. knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -38,6 +38,7 @@ set(SIM_SOURCES
src/sim/PlanetCiv.cpp src/sim/PlanetCiv.cpp
src/sim/PlanetNation.cpp src/sim/PlanetNation.cpp
src/sim/PlanetCulture.cpp src/sim/PlanetCulture.cpp
src/sim/PlanetConflict.cpp
src/sim/PlanetIO.cpp src/sim/PlanetIO.cpp
) )
@ -76,7 +77,7 @@ if(UNIX AND NOT APPLE)
endif() endif()
enable_testing() enable_testing()
foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture) foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict)
add_executable(test_${test_name} test_${test_name}.cpp) add_executable(test_${test_name} test_${test_name}.cpp)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim) target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name}) add_test(NAME ${test_name} COMMAND test_${test_name})

View File

@ -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). - `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 + - `PlanetCulture.{hpp,cpp}``computeCultures` (civ Step 4: cultures/ethos/faith per continent +
government per realm; derived, not saved). 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. - `PlanetIO.cpp` — text config + binary save/load.
The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration), 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 / `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()`). 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 34, 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 ## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run: 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/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp src/sim/PlanetGeography.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/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 src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography / test_ecoregions / # 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 (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). data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism).

View File

@ -79,6 +79,25 @@ void buildCultureBorders(const Planet& p, float radius, std::vector<Vector3>& se
buildLabelBorders(p, radius, p.cellCulture(), segs); buildLabelBorders(p, radius, p.cellCulture(), segs);
} }
void buildWarFrontier(const Planet& p, float radius, std::vector<Vector3>& segs) {
segs.clear();
const std::vector<int>& 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, void buildDriftArrows(const Planet& p, float radius,
std::vector<Vector3>& out, std::vector<PlateLabel>& labels) { std::vector<Vector3>& out, std::vector<PlateLabel>& labels) {
out.clear(); labels.clear(); out.clear(); labels.clear();

View File

@ -20,6 +20,8 @@ void buildBorders(const Planet& p, float radius,
void buildNationBorders(const Planet& p, float radius, std::vector<Vector3>& segs); void buildNationBorders(const Planet& p, float radius, std::vector<Vector3>& segs);
// Like buildNationBorders but on the per-cell culture split (civ Step 4): cultural-region outlines. // Like buildNationBorders but on the per-cell culture split (civ Step 4): cultural-region outlines.
void buildCultureBorders(const Planet& p, float radius, std::vector<Vector3>& segs); void buildCultureBorders(const Planet& p, float radius, std::vector<Vector3>& 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<Vector3>& segs);
// ---- Per-plate drift arrows ------------------------------------------------- // ---- Per-plate drift arrows -------------------------------------------------
struct PlateLabel { int id; Vector3 pos; }; struct PlateLabel { int id; Vector3 pos; };

View File

@ -111,7 +111,9 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
int ni = (i < (int)cn.size()) ? cn[i] : -1; int ni = (i < (int)cn.size()) ? cn[i] : -1;
if (ni >= 0 && ni < (int)p.nationList().size()) { if (ni >= 0 && ni < (int)p.nationList().size()) {
const Nation& nat = p.nationList()[ni]; 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) { } else if (p.cells[i].elevation > p.cfg.seaLevel) {
L.push_back(std::string("realm: wilderness")); L.push_back(std::string("realm: wilderness"));
} }

View File

@ -497,6 +497,12 @@ void Viewer::saveGame(const char* path) {
wV(f.w.volcanoes); wV(f.w.volcanoes);
os.write((char*)&f.w.volRng, 4); os.write((char*)&f.w.volRng, 4);
wD(f.w.settlementPop); // v20: per-frame settlement populations 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. // v16: persistent world event journal, separate from step-back history.
uint32_t en = (uint32_t)std::min<size_t>(events.size(), (size_t)EVENT_LOG_MAX); uint32_t en = (uint32_t)std::min<size_t>(events.size(), (size_t)EVENT_LOG_MAX);
@ -530,7 +536,7 @@ void Viewer::loadGame(const char* path) {
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20)) { 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 cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0); elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift planet.drifting = settled; // resume drift boosts iff mid-drift
@ -583,6 +589,16 @@ void Viewer::loadGame(const char* path) {
rV(f.w.volcanoes); rV(f.w.volcanoes);
is.read((char*)&f.w.volRng, 4); is.read((char*)&f.w.volRng, 4);
if (ver >= 20) rP(f.w.settlementPop); // v20: per-frame settlement populations 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<double>& v) { return v.empty() || v.size() == planet.cells.size(); }; auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain) if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size()) || f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
@ -724,12 +740,19 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock CivUpdate cu = planet.stepCivilization(dtWeather, liveTime); // env-driven growth/decline on the clock
if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements); if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes, beforeSettlements);
// Territory & nations shift slowly -> recompute once per sim year (and rebuild the border lines). // 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; bool territoryChanged = false;
if (planet.settlementsPlaced()) { if (planet.settlementsPlaced()) {
double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays); double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays);
long year = (long)std::floor(liveTime / yearHours); long year = (long)std::floor(liveTime / yearHours);
if (year != lastTerritoryYear) { if (year != lastTerritoryYear) {
std::vector<Nation> beforeNations = (dtWeather > 0.0) ? planet.nationList() : std::vector<Nation>{}; std::vector<Nation> beforeNations = (dtWeather > 0.0) ? planet.nationList() : std::vector<Nation>{};
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(); rebuildTerritory();
if (dtWeather > 0.0) detectNationEvents(beforeNations); if (dtWeather > 0.0) detectNationEvents(beforeNations);
territoryChanged = true; territoryChanged = true;
@ -748,6 +771,7 @@ void Viewer::rebuildTerritory() {
planet.computeCultures(); planet.computeCultures();
buildNationBorders(planet, borderR, nationBorders); buildNationBorders(planet, borderR, nationBorders);
buildCultureBorders(planet, borderR, cultureBorders); 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); double yearHours = std::max(1.0, planet.cfg.dayLengthHours * planet.cfg.yearLengthDays);
lastTerritoryYear = (long)std::floor(liveTime / yearHours); lastTerritoryYear = (long)std::floor(liveTime / yearHours);
} }

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer { struct Viewer {
// ---- Files / save format ------------------------------------------------ // ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 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 wxSaveMax = 40; // most recent step-back frames persisted in a save
static constexpr int EVENT_LOG_MAX = 200; static constexpr int EVENT_LOG_MAX = 200;
const char* CONFIG_PATH = "planet.cfg"; const char* CONFIG_PATH = "planet.cfg";
@ -106,6 +106,7 @@ struct Viewer {
bool showNationBorders = false; // draw nation/realm borders (on with the Territory view) bool showNationBorders = false; // draw nation/realm borders (on with the Territory view)
std::vector<Vector3> cultureBorders; // cultural-region border segments (civ Step 4, rebuilt with territory) std::vector<Vector3> cultureBorders; // cultural-region border segments (civ Step 4, rebuilt with territory)
bool showCultureBorders = false; // draw cultural-region borders (on with the Culture view) bool showCultureBorders = false; // draw cultural-region borders (on with the Culture view)
std::vector<Vector3> 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) 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. // World event journal: currently Live World events, shaped to be reused by later phases.

View File

@ -98,6 +98,14 @@ void Viewer::renderGlobe3D() {
} }
rlEnd(); rlSetLineWidth(1.0f); 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()) { if (showDrift && !driftArrows.empty()) {
rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255); rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255);
for (size_t i = 0; i + 1 < driftArrows.size(); i += 2) { 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 (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 (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 (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 (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 (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, vr, mapLon);
if (showCurrents && !currentCols.empty()) drawColoredSegments2D(currentSegs, currentCols, 1.6f, 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}; : Color{175, 205, 235, 255};
DrawRectangleRec(row, bg); DrawRectangleRec(row, bg);
DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255}); DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255});
const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : "~"; 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); DrawText(icon, (int)row.x + 7, (int)row.y + 6, 18, fg);
double d = e.timeHours / std::max(0.1, planet.cfg.dayLengthHours); double d = e.timeHours / std::max(0.1, planet.cfg.dayLengthHours);
DrawText(TextFormat("D%.1f", d), (int)row.x + 24, (int)row.y + 5, 12, Color{145, 155, 175, 255}); DrawText(TextFormat("D%.1f", d), (int)row.x + 24, (int)row.y + 5, 12, Color{145, 155, 175, 255});
@ -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 } 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& N = planet.nationList();
const auto& W = planet.warList();
DrawText("Realms", x, y, 18, Color{200, 205, 220, 255}); 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}); DrawText(TextFormat("%d", (int)N.size()), (int)(r.x + r.width) - 40, y + 2, 13, Color{145, 155, 175, 255});
y += 26; 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()) { if (N.empty()) {
DrawText(planet.settlementsPlaced() ? "press P for the territory view" : "press U then P", x, y, 13, Color{150, 155, 170, 255}); DrawText(planet.settlementsPlaced() ? "press P for the territory view" : "press U then P", x, y, 13, Color{150, 155, 170, 255});
} else { } else {
@ -707,6 +733,7 @@ void Viewer::renderLiveInfo() {
: nat.totalPop >= 1.0e3 ? TextFormat("%.0fk", nat.totalPop / 1.0e3) : nat.totalPop >= 1.0e3 ? TextFormat("%.0fk", nat.totalPop / 1.0e3)
: TextFormat("%.0f", nat.totalPop); : TextFormat("%.0f", nat.totalPop);
DrawText(nat.name.c_str(), (int)row.x + 6, (int)row.y + 2, 14, fg); 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}); 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; y += 20;
} }
@ -794,8 +821,8 @@ void Viewer::renderHUD() {
rv, rl, dayNightOn ? "on" : "off")); rv, rl, dayNightOn ? "on" : "off"));
int nStorm = 0, nHur = 0; int nStorm = 0, nHur = 0;
for (const auto& ws : planet.storms()) { ++nStorm; if (ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr) ++nHur; } 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, line(TextFormat("weather systems: %d tropical cyclones: %d wars: %d%s", nStorm, nHur,
followId ? " [following]" : "")); (int)planet.warList().size(), followId ? " [following]" : ""));
line("Y follow storm · . / , step clock +/- · wheel-on-map zoom"); line("Y follow storm · . / , step clock +/- · wheel-on-map zoom");
} }
else { else {

View File

@ -65,6 +65,8 @@ void Planet::buildGeometry() {
sCivCond.clear(); sCivDrought.clear(); sCivCond.clear(); sCivDrought.clear();
nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear(); nations.clear(); sCellNation.assign(cells.size(), -1); sSettleNation.clear();
cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear(); cultures.clear(); sCellCulture.assign(cells.size(), -1); sSettleCulture.clear();
wars.clear(); sSettleAllegiance.clear();
sWarRng = cfg.seed ? (cfg.seed ^ 0x5A7B0A11u) : 0x5A7B0A11u; sWarNextId = 1;
} }
void Planet::clearDerivedState() { void Planet::clearDerivedState() {

View File

@ -7,6 +7,7 @@
#include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate #include "PlanetCiv.hpp" // Settlement, SettleTier, CivUpdate
#include "PlanetNation.hpp" // Nation, NationTier, GovType #include "PlanetNation.hpp" // Nation, NationTier, GovType
#include "PlanetCulture.hpp" // Culture, CultureEthos, Faith #include "PlanetCulture.hpp" // Culture, CultureEthos, Faith
#include "PlanetConflict.hpp" // WarEvent, ConflictUpdate (War is in PlanetTypes.hpp)
#include "PlanetEcoregions.hpp" // Ecoregion #include "PlanetEcoregions.hpp" // Ecoregion
#include <vector> #include <vector>
#include <memory> #include <memory>
@ -26,6 +27,7 @@ public:
std::vector<Settlement> settlements; // civilization: settlements placed once, grow/decline (saved v20+) std::vector<Settlement> settlements; // civilization: settlements placed once, grow/decline (saved v20+)
std::vector<Nation> nations; // realms grouped from settlements (derived each computeTerritory, not saved) std::vector<Nation> nations; // realms grouped from settlements (derived each computeTerritory, not saved)
std::vector<Culture> cultures; // peoples/cultures, one per inhabited continent (derived, not saved) std::vector<Culture> cultures; // peoples/cultures, one per inhabited continent (derived, not saved)
std::vector<War> wars; // civ Step 5: active wars between realms (stateful, saved v21)
// Phase flag: false during Phase-1 forming (modest, original tectonics that // Phase flag: false during Phase-1 forming (modest, original tectonics that
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
@ -211,6 +213,16 @@ public:
const std::vector<Culture>& cultureList() const { return cultures; } const std::vector<Culture>& cultureList() const { return cultures; }
const std::vector<int>& cellCulture() const { return sCellCulture; } // culture index per cell (-1 = none) const std::vector<int>& cellCulture() const { return sCellCulture; } // culture index per cell (-1 = none)
const std::vector<int>& settleCulture() const { return sSettleCulture; } // culture index per settlement (-1 = none) const std::vector<int>& 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<War>& warList() const { return wars; }
const std::vector<int>& 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 // Per-settlement live conditions (derived each stepCivilization; not saved). condition = the combined
// environmental multiplier on carrying capacity (1 = normal, <1 = hardship, >1 = boom); drought = // 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. // 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 readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true, bool hasMoons = true, bool hasWeather = true, bool hasStorms = true,
bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true, bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true,
bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true); bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true,
bool hasConflict = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing
@ -348,6 +361,10 @@ private:
// Cultures (derived from settlements + geography; not saved). sCellCulture: culture index per cell // Cultures (derived from settlements + geography; not saved). sCellCulture: culture index per cell
// (-1 = none/wilderness/ocean); sSettleCulture: culture index per settlement. // (-1 = none/wilderness/ocean); sSettleCulture: culture index per settlement.
std::vector<int> sCellCulture, sSettleCulture; std::vector<int> 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<int> sSettleAllegiance;
uint32_t sWarRng = 1, sWarNextId = 1;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded. // on-demand discrete population (saved). sHasBiota latches once generated/loaded.

191
src/sim/PlanetConflict.cpp Normal file
View File

@ -0,0 +1,191 @@
#include "Planet.hpp"
#include <algorithm>
#include <cmath>
#include <map>
#include <unordered_map>
// --- 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<int, int> 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<War> 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<int, int>, std::pair<double, int>> 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<Cand> 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;
}

View File

@ -0,0 +1,26 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
// 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<WarEvent> events;
};

View File

@ -56,12 +56,14 @@
D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \ D(civDroughtArid) D(civColdYearStrength) D(civFloodBonus) D(civFamineRate) \
D(civStormDeathRate) D(civHurricaneDeathMult) \ D(civStormDeathRate) D(civHurricaneDeathMult) \
D(civTerritoryBase) D(civTerritoryScale) D(civTerritoryMax) D(civVassalRange) D(civEmpirePop) \ 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(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
I(volcanoMaxCount) \ I(volcanoMaxCount) \
I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \ I(geoContinentMinCells) I(geoSeaMaxCells) I(geoRangeMinCells) I(geoMaxRivers) I(geoMaxPeaks) \
I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) \ I(geoOceanDeep) I(civMaxSettlements) I(civEmpireMinMembers) I(warMaxConcurrent) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed) U(seed)
@ -285,6 +287,18 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.civTerritoryMax, 0.01, 3.14159, "civTerritoryMax")); E(rng(cfg.civTerritoryMax, 0.01, 3.14159, "civTerritoryMax"));
E(rng(cfg.civVassalRange, 0.0, 20.0, "civVassalRange")); E(rng(cfg.civVassalRange, 0.0, 20.0, "civVassalRange"));
E(rng(cfg.civEmpirePop, 1.0, 1.0e12, "civEmpirePop")); 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.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth")); E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -453,11 +467,21 @@ void Planet::writeState(std::ostream& os) const {
uint64_t L = st.name.size(); writePod(os, L); uint64_t L = st.name.size(); writePod(os, L);
if (L) os.write(st.name.data(), (std::streamsize)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 Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes,
bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements) { bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements,
bool hasConflict) {
// Save blocks are append-only by version. If a caller asks for an older prefix, // Save blocks are append-only by version. If a caller asks for an older prefix,
// later blocks cannot exist in that stream even if the default arguments say otherwise. // later blocks cannot exist in that stream even if the default arguments say otherwise.
if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; } if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; }
@ -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 (!hasGeography) hasGeoSalt = false; // salt follows the geography block
if (!hasGeoSalt) hasEcoregions = false; // ecoregions follow the v18 salt if (!hasGeoSalt) hasEcoregions = false; // ecoregions follow the v18 salt
if (!hasEcoregions) hasSettlements = false; // settlements follow the ecoregion block if (!hasEcoregions) hasSettlements = false; // settlements follow the ecoregion block
if (!hasSettlements) hasConflict = false; // conflict follows the settlement block
// Read the length-prefixed key=value config block (see writeState). A default // Read the length-prefixed key=value config block (see writeState). A default
// PlanetConfig is parsed over, so fields absent from an older save keep their // PlanetConfig is parsed over, so fields absent from an older save keep their
@ -680,6 +705,24 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
sCellSettlement.assign(cells.size(), -1); sCellSettlement.assign(cells.size(), -1);
for (int k = 0; k < (int)settlements.size(); ++k) sCellSettlement[settlements[k].cell] = k; 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 computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is; return (bool)is;
} }

View File

@ -49,8 +49,15 @@ void Planet::computeTerritory() {
return a < b; return a < b;
}); });
std::vector<int> capitalOf(settlements.size(), -1); std::vector<int> capitalOf(settlements.size(), -1);
const bool haveAllegiance = (sSettleAllegiance.size() == settlements.size());
for (int s : order) { for (int s : order) {
if (range[s] <= 0.0) continue; if (range[s] <= 0.0) continue;
// Conquest override (civ Step 5): a conquered settlement joins its overlord's realm, crossing
// 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; int joinCap = -1; double joinAng = 1e9;
for (int c : order) { for (int c : order) {
if (c == s) break; // order is descending -> rest are smaller if (c == s) break; // order is descending -> rest are smaller
@ -62,11 +69,13 @@ void Planet::computeTerritory() {
capitalOf[s] = (joinCap >= 0) ? joinCap : s; 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<int> capToNation(settlements.size(), -1); std::vector<int> capToNation(settlements.size(), -1);
for (int s : order) { for (int s : order) {
if (capitalOf[s] < 0) continue; if (capitalOf[s] < 0) continue;
int cap = capitalOf[s]; int cap = capitalOf[s];
for (int g = 0; g < 64 && capitalOf[cap] >= 0 && capitalOf[cap] != cap; ++g) cap = capitalOf[cap];
if (capToNation[cap] < 0) { if (capToNation[cap] < 0) {
capToNation[cap] = (int)nations.size(); capToNation[cap] = (int)nations.size();
Nation nat; nat.id = (uint32_t)nations.size() + 1; nat.capital = cap; Nation nat; nat.id = (uint32_t)nations.size() + 1; nat.capital = cap;

View File

@ -87,6 +87,17 @@ struct Volcano {
// submarine vent crossed sea level (a new island/sunk island -> needs biome reclassification). // submarine vent crossed sea level (a new island/sunk island -> needs biome reclassification).
struct VolcanoUpdate { bool recolor = false; bool breach = false; }; 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 // 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. // undo history -- weather can't be reversed in closed form, so we restore a saved frame instead.
struct WeatherSnapshot { struct WeatherSnapshot {
@ -98,6 +109,11 @@ struct WeatherSnapshot {
// Civilization: settlement populations (the only mutable per-step civ state, since the set is // 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. // fixed after placement). Restored on a step back so towns rewind/replay with the clock.
std::vector<double> settlementPop; std::vector<double> 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<int> settlementAllegiance;
std::vector<War> wars;
uint32_t warRng = 0, warNextId = 0;
}; };
struct Plate { struct Plate {
@ -421,4 +437,19 @@ struct PlanetConfig {
double civVassalRange = 1.5; // a capital annexes smaller settlements within this x its range 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 int civEmpireMinMembers = 5; // realm of >= this many settlements counts as an Empire
double civEmpirePop = 5.0e6; // ...or total population >= this 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
}; };

View File

@ -33,6 +33,8 @@ WeatherSnapshot Planet::captureWeather() const {
s.volcanoes = volcanoes; s.volRng = sVolRng; s.volcanoes = volcanoes; s.volRng = sVolRng;
s.settlementPop.reserve(settlements.size()); // civ: only population is mutable s.settlementPop.reserve(settlements.size()); // civ: only population is mutable
for (const Settlement& st : settlements) s.settlementPop.push_back(st.population); 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; return s;
} }
@ -42,6 +44,9 @@ void Planet::restoreWeather(const WeatherSnapshot& s) {
volcanoes = s.volcanoes; sVolRng = s.volRng; volcanoes = s.volcanoes; sVolRng = s.volRng;
if (s.settlementPop.size() == settlements.size()) // restore populations (set is fixed) 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]; 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(); sHasWeather = !sHumidity.empty();
} }

167
test_conflict.cpp Normal file
View File

@ -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 <cstdio>
#include <cmath>
#include <algorithm>
#include <sstream>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
static void settle(Planet& p, int maxSteps = 800) {
int run = 0;
for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; }
p.computeClimate(); p.classifyBiomes();
}
static void drift(Planet& p, int iters) {
p.drifting = true;
for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); if (k >= iters/2) p.hydrology(dt*0.2); }
p.computeClimate(); p.classifyBiomes();
}
// 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<War>& a, const std::vector<War>& 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<int> before = p2.settleAllegiance();
std::vector<War> 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;
}