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:
parent
91f1b90a93
commit
4a1f3377ba
24
BUILD.md
24
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
|
||||
|
||||
57
CLAUDE.md
57
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
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -79,6 +79,25 @@ void buildCultureBorders(const Planet& p, float radius, std::vector<Vector3>& se
|
||||
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,
|
||||
std::vector<Vector3>& out, std::vector<PlateLabel>& labels) {
|
||||
out.clear(); labels.clear();
|
||||
|
||||
@ -20,6 +20,8 @@ void buildBorders(const Planet& p, float radius,
|
||||
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.
|
||||
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 -------------------------------------------------
|
||||
struct PlateLabel { int id; Vector3 pos; };
|
||||
|
||||
@ -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;
|
||||
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"));
|
||||
}
|
||||
|
||||
@ -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<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
|
||||
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
|
||||
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
|
||||
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20)) { 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<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
|
||||
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|
||||
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
|
||||
@ -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<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();
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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<Vector3> 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<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)
|
||||
|
||||
// World event journal: currently Live World events, shaped to be reused by later phases.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 <vector>
|
||||
#include <memory>
|
||||
@ -26,6 +27,7 @@ public:
|
||||
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<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
|
||||
// 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<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)
|
||||
|
||||
// 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
|
||||
// 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<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
|
||||
// on-demand discrete population (saved). sHasBiota latches once generated/loaded.
|
||||
|
||||
191
src/sim/PlanetConflict.cpp
Normal file
191
src/sim/PlanetConflict.cpp
Normal 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;
|
||||
}
|
||||
26
src/sim/PlanetConflict.hpp
Normal file
26
src/sim/PlanetConflict.hpp
Normal 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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -49,8 +49,15 @@ void Planet::computeTerritory() {
|
||||
return a < b;
|
||||
});
|
||||
std::vector<int> 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<int> 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;
|
||||
|
||||
@ -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<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 {
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
167
test_conflict.cpp
Normal file
167
test_conflict.cpp
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user