Civ Step 6: diplomacy, alliances & coalitions (save v22)

Wars stop being isolated 1-v-1 grudges. Each pair of nearby realms carries an
attitude that drifts over time, crystallising into alliances / non-aggression
pacts / rivalries. Allies don't fight and join each other's wars (coalitions),
and wars end in real peace treaties. Extends the Step-5 conflict subsystem
in place (same stepConflict tick, same war RNG), saved v22 + snapshotted.

- PlanetTypes.hpp: DiploKind + DiploTie {a,b capital indices, attitude, truceUntil,
  kind}; diplo* config knobs; WeatherSnapshot carries diplomacy.
- PlanetConflict.cpp: a diplomacy pass in stepConflict (revolts -> prosecute ->
  diplomacy -> declare). Attitude drifts from culture/faith affinity + a war
  penalty + truce recovery + per-pair noise; reclassified by threshold with
  hysteresis (kind-6 events on change). War-declare skips allied/non-aggression/
  truced pairs; hostility now rises as attitude falls (rivals fight); a defender's
  allies join by declaring their own war on the aggressor; a war end sets a truce
  + grudge. diploBetween/realmsAllied helpers.
- Save v22 + step-back: allegiance/wars block joined by a diplomacy block (new
  hasDiplo readState param + per-frame history block); captureWeather/restoreWeather
  carry the ties.
- Render: green alliance / dark-red rivalry great-circle arcs over the Territory
  view (P), per-realm ally/rival counts + active-wars list in the Realms tab, a
  cell-info allies/rivals line, kind-6 events (= icon).
- test_diplomacy.cpp: alliances/rivalries form, allies never war, coalitions form,
  truces after peace, determinism, RNG isolation, save-v22 + snapshot round-trip.
  All 14 suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-07-01 13:07:10 +02:00
parent cc6813e211
commit 8cd487a5dc
18 changed files with 482 additions and 52 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 + war fronts (Realms tab lists nations & wars) P territory / realms view + borders + war fronts + alliance/rivalry arcs (Realms tab)
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
@ -342,6 +342,19 @@ rise and fall. Runs on the yearly Live-World tick.
warRevoltRate 0.04 per-year base revolt chance of a held foreign/distant city warRevoltRate 0.04 per-year base revolt chance of a held foreign/distant city
warMinRealmPop 2000 realms below this population don't start wars warMinRealmPop 2000 realms below this population don't start wars
Diplomacy (PlanetConfig, save v22 — on the Territory view P): realm-pair attitudes drift into alliances /
rivalries; allies never fight + join each other's wars (coalitions); wars end in truces. Runs in the same
yearly stepConflict tick; green arcs = alliances, dark-red arcs = rivalries.
diploDriftRate 0.06 per-year attitude change scale
diploAffinity 1.0 attitude pull from shared culture + faith (vs difference)
diploWarPenalty 0.5 extra per-year attitude drop while two realms are at war
diploTruceYears 12 post-war truce length (no new war between the two)
diploWarGrudge 0.4 one-off attitude drop when a war between them ends (a scar)
diploAllyThreshold 0.5 attitude at/above which two realms are allied
diploNonAggThreshold 0.2 attitude at/above which they sign a non-aggression pact
diploRivalThreshold -0.5 attitude at/below which they become rivals
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
@ -362,7 +375,7 @@ src/sim/PlanetCulture.cpp (computeCultures).
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_conflict. # test_weather / test_volcano / test_geography / test_ecoregions / test_civ / test_nation / test_culture / test_conflict / test_diplomacy.
# 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 15 of the roadmap are done (plus a derived ecoregions atlas):** on the Live World clock). **Steps 16 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),
@ -202,8 +202,23 @@ on the Live World clock). **Steps 15 of the roadmap are done (plus a derived
marker in the **Realms** tab (with an active-wars list) over the Territory view (`P`), a cell-info "AT 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 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*`. Kingdom", "The X captures/sacks Z", "Z revolts against the X", "Peace between…"). Knobs `war*`.
- **Diplomacy, alliances & coalitions (Step 6)** *(done — see `PlanetConflict.cpp`, save v22)* — wars stop
being isolated 1-v-1 grudges. Each pair of nearby realms carries an **attitude** (1..+1) that **drifts**
each year in the diplomacy pass of `stepConflict` (shared vs different culture/faith affinity, plus a
penalty while at war, plus recovery under a truce, plus a per-pair personality noise), crystallising via
thresholds (hysteresis) into a **`DiploTie`** = Alliance / Non-aggression / Rival. **Allies never fight**
(war declaration skips allied/non-aggression/truced pairs) and **join each other's wars** — when A
attacks B, each ally of B declares its own war on A (a **coalition**, via the existing 1-v-1 machinery),
so an aggressor is worn down on many fronts. Hostility now rises as **attitude falls** (rivals war), and
a war ends in a real **peace treaty** (a `truceUntil` + a lasting attitude **grudge**). Ties are keyed on
**capital settlement index** (the same stable realm id wars use) and, like war, are **stateful** — saved
(**v22**) + snapshotted (step-back rewinds alliances/rivalries). Render: **alliance (green) / rivalry
(dark-red) arcs** between capitals over the Territory view (`P`), an allies/rivals summary per row + the
active-wars list in the **Realms** tab, a cell-info "allies/rivals" line, and **kind=6** `WorldEvent`s
("The X and the Y form an alliance", "…become rivals", "…sign a non-aggression pact", "Z joins the war
against W"). Knobs `diplo*`.
*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);
alliances / diplomacy / treaties; trade & economy.* tribute / vassalage treaties; trade & economy.*
## Current state ## Current state
@ -615,7 +630,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) PlanetConflict.* stepConflict (wars/conquest/revolts + diplomacy/alliances/coalitions; civ Steps 5-6, save v21/v22)
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)
@ -684,8 +699,8 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \
``` ```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`, (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
`test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`, `test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`,
`test_nation.cpp`, `test_culture.cpp` or `test_conflict.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / `test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp` or `test_diplomacy.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 Civilization / Nation / Culture / Conflict / Diplomacy 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
@ -751,8 +766,10 @@ sun, raise tides — `T` colours the coastline by the live tide level (amber low
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. With settlements placed (`U`) and the **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 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 (borders move) or fall to ruins, empires fracture as provinces revolt; realms also form **alliances**
wars and the **Events** tab logs them. `Y` makes the 3D camera **follow a storm** (cycles by strength, (green arcs) and **rivalries** (dark-red arcs), allies **join each other's wars** (coalitions) and never
fight each other, and wars end in **peace treaties**; the **Realms** tab lists each realm's allies/
rivals/wars and the **Events** tab logs it all. `Y` makes the 3D camera **follow a storm** (cycles by strength,
off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and** off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and**
weather/storms/volcano lifecycle **and wars/conquests** via snapshots). Mouse-wheel over the 2D map zooms (drag pans). weather/storms/volcano lifecycle **and wars/conquests** via snapshots). Mouse-wheel over the 2D map zooms (drag pans).
@ -765,7 +782,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 **21**; v2 adds the `[`/`]` drift rate, v3 a save header is versioned (currently **22**; 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**
@ -777,9 +794,10 @@ appends the old pure-function **volcanoes** block, v15 replaces it with stateful
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, 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), and v21 the **civilization fixed settlement set + per-frame populations in the step-back history), v21 the **civilization
conflict** block — per-settlement **allegiance** (conquest) + active **wars** + the war RNG, also added conflict** block — per-settlement **allegiance** (conquest) + active **wars** + the war RNG, also added
to the step-back frames so a load can rewind conquests; to the step-back frames so a load can rewind conquests, and v22 the **diplomacy** block — standing realm
**relations** (alliances / rivalries / truces), likewise per-frame;
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
@ -790,7 +808,7 @@ entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with o
pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on
demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with
no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin
again as the clock runs). again as the clock runs); pre-v22 saves load with no diplomacy (relations re-form 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
@ -963,6 +981,14 @@ triangles (plates are fixed in phase 1).
distant city), `warMinRealmPop` (2000, realms below this don't start wars). Adjacency is by settlement 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 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. the yearly tick and is **saved** (allegiance + wars + war RNG) — tune for a warlike or peaceable world.
- **Diplomacy (`diplo*` in PlanetConfig / `planet.cfg`; civ Step 6, save v22):** realm-pair attitude drift
→ alliances / rivalries / coalitions. `diploDriftRate` (0.06, how fast attitudes move), `diploAffinity`
(1.0, pull from shared culture + faith vs difference), `diploWarPenalty` (0.5, extra attitude drop while
at war), `diploTruceYears` (12, post-war truce length), `diploWarGrudge` (0.4, one-off attitude drop when
a war ends), and the kind thresholds `diploAllyThreshold` (0.5) / `diploNonAggThreshold` (0.2) /
`diploRivalThreshold` (0.5). Ally/rival arc colours are render constants (ViewerRender/Overlays). Runs
in the same yearly `stepConflict` tick; raise `diploDriftRate`/`diploAffinity` for a more alliance-heavy
world, lower the thresholds' spread for touchier politics.
- `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

@ -77,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 conflict) foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy)
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,8 +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, - `PlanetConflict.{hpp,cpp}``stepConflict` (civ Steps 5-6: wars/conquest/revolts + diplomacy/
revolts; stateful, saved v21 + snapshotted). alliances/coalitions; stateful, saved v21/v22 + 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),
@ -481,6 +481,31 @@ vassals consistent). State — `sSettleAllegiance` + `wars` + `sWarRng`/`sWarNex
Realms tab, a cell-info "AT WAR" flag, a HUD war count, and **kind=5** events (declare / capture / sack / Realms tab, a cell-info "AT WAR" flag, a HUD war count, and **kind=5** events (declare / capture / sack /
revolt / peace). Knobs `war*`. revolt / peace). Knobs `war*`.
## Civilization Step 6 — diplomacy, alliances & coalitions
Same file (`PlanetConflict.cpp`), same yearly `stepConflict` tick, same `sWarRng` — diplomacy is the war
subsystem's politics layer. A **`DiploTie`** { `a`, `b` (capital settlement indices, a<b), `attitude`
∈[1,1], `truceUntil`, `DiploKind` } per interacting realm pair; the vector `diplomacy` is **stateful**,
saved (**v22**) + snapshotted (a per-frame block in the step-back history, like the v21 war block). New
yearly order inside `stepConflict`: **revolts → prosecute → diplomacy → declare**.
- **Prosecute → peace treaty**: when a war ends by exhaustion, the pair's tie gets a
`truceUntil = year + diploTruceYears` and an attitude drop of `diploWarGrudge` (a scar).
- **Diplomacy pass**: prune ties whose endpoint settlement died; for each near realm pair (the
settlement-proximity adjacency, now built once at the top and shared with war-declare) drift `attitude`
toward a target set by culture+faith affinity (`diploAffinity`) plus a per-pair personality noise, minus
a war penalty, plus truce recovery; reclassify into Alliance / Non-aggression / Rival by threshold
(`diploAllyThreshold`/`diploNonAggThreshold`/`diploRivalThreshold`, with a ±0.08 hysteresis) and emit a
**kind-6** event on a kind change.
- **Declare**: skip allied / non-aggression / under-truce pairs; hostility's ideology term is now
`max(0, attitude)` (rivals fight); after A declares on B, every **Alliance** tie of B makes that ally
declare its own war on A (a **coalition**, reusing the 1-v-1 war path), capped by `warMaxConcurrent`.
Query helpers `diploBetween(nationA,nationB)` / `realmsAllied(...)` map nation index → capital → tie.
Render: `buildDiploLinks` (great-circle **arcs** between allied/rival capitals — green/dark-red) over the
Territory view (`P`); the Realms tab shows per-realm ally/rival counts + the active-wars list; cell-info
an allies/rivals line; kind-6 events (`=` icon). `captureWeather`/`restoreWeather` carry `diplomacy`.
Knobs `diplo*`.
## 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:
@ -496,7 +521,7 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet
src/sim/PlanetCulture.cpp src/sim/PlanetConflict.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 / test_conflict use the same source list. # test_civ / test_nation / test_culture / test_conflict / test_diplomacy 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,29 @@ void buildCultureBorders(const Planet& p, float radius, std::vector<Vector3>& se
buildLabelBorders(p, radius, p.cellCulture(), segs); buildLabelBorders(p, radius, p.cellCulture(), segs);
} }
void buildDiploLinks(const Planet& p, float radius, std::vector<Vector3>& ally, std::vector<Vector3>& rival) {
ally.clear(); rival.clear();
const auto& ties = p.diploList();
if (ties.empty()) return;
const double abP = p.cfg.civAbandonPop;
auto alive = [&](int s) { return s >= 0 && s < (int)p.settlements.size() && p.settlements[s].population >= abP; };
const int steps = 10; // segments per arc
for (const DiploTie& t : ties) {
if (t.kind != DiploKind::Alliance && t.kind != DiploKind::Rival) continue;
if (!alive(t.a) || !alive(t.b)) continue;
Vec3 A = p.cells[p.settlements[t.a].cell].unit, B = p.cells[p.settlements[t.b].cell].unit;
auto& out = (t.kind == DiploKind::Alliance) ? ally : rival;
Vector3 prev{};
for (int i = 0; i <= steps; ++i) {
double u = (double)i / steps;
Vec3 m = (A * (1.0 - u) + B * u).normalized() * (radius * 1.03); // raised so it arcs over the surface
Vector3 cur{ (float)m.x, (float)m.y, (float)m.z };
if (i > 0) { out.push_back(prev); out.push_back(cur); }
prev = cur;
}
}
}
void buildWarFrontier(const Planet& p, float radius, std::vector<Vector3>& segs) { void buildWarFrontier(const Planet& p, float radius, std::vector<Vector3>& segs) {
segs.clear(); segs.clear();
const std::vector<int>& cn = p.cellNation(); const std::vector<int>& cn = p.cellNation();

View File

@ -22,6 +22,8 @@ void buildNationBorders(const Planet& p, float radius, std::vector<Vector3>& seg
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. // 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); void buildWarFrontier(const Planet& p, float radius, std::vector<Vector3>& segs);
// Great-circle arcs between allied (-> ally) and rival (-> rival) realm capitals (civ Step 6).
void buildDiploLinks(const Planet& p, float radius, std::vector<Vector3>& ally, std::vector<Vector3>& rival);
// ---- Per-plate drift arrows ------------------------------------------------- // ---- Per-plate drift arrows -------------------------------------------------
struct PlateLabel { int id; Vector3 pos; }; struct PlateLabel { int id; Vector3 pos; };

View File

@ -114,6 +114,12 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
bool war = false; // civ Step 5: is this realm at war? 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; } 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" : "")); L.push_back(std::string("realm: ") + nat.name + " (" + nationTierName(nat.tier) + ")" + (war ? " - AT WAR" : ""));
int al = 0, rv = 0; // civ Step 6: standing relations
for (const DiploTie& t : p.diploList()) {
if (t.a != nat.capital && t.b != nat.capital) continue;
if (t.kind == DiploKind::Alliance) ++al; else if (t.kind == DiploKind::Rival) ++rv;
}
if (al > 0 || rv > 0) L.push_back("allies: " + std::to_string(al) + " / rivals: " + std::to_string(rv));
} 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

@ -503,6 +503,9 @@ void Viewer::saveGame(const char* path) {
{ uint64_t m = f.w.wars.size(); os.write((char*)&m, 8); { 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))); } 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); os.write((char*)&f.w.warRng, 4); os.write((char*)&f.w.warNextId, 4);
// v22: per-frame diplomacy, so a load can rewind alliances/rivalries past the saved moment.
{ uint64_t m = f.w.diplomacy.size(); os.write((char*)&m, 8);
if (m) os.write((const char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); }
} }
// 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);
@ -536,7 +539,7 @@ void Viewer::loadGame(const char* path) {
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21)) { 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 if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy
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
@ -599,6 +602,12 @@ void Viewer::loadGame(const char* path) {
is.read((char*)&f.w.warRng, 4); is.read((char*)&f.w.warNextId, 4); is.read((char*)&f.w.warRng, 4); is.read((char*)&f.w.warNextId, 4);
if (!is) historyOk = false; if (!is) historyOk = false;
} }
if (ver >= 22) { // v22: per-frame diplomacy
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 1000000) historyOk = false;
else { f.w.diplomacy.resize((size_t)m); if (m) is.read((char*)f.w.diplomacy.data(), (std::streamsize)(m * sizeof(DiploTie))); }
if (!is) historyOk = false;
}
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())
@ -756,7 +765,7 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
for (long y = firstYear; y <= year; ++y) { for (long y = firstYear; y <= year; ++y) {
ConflictUpdate wu = planet.stepConflict(y); ConflictUpdate wu = planet.stepConflict(y);
for (const WarEvent& e : wu.events) for (const WarEvent& e : wu.events)
appendEvent(5, e.severity, liveTime, e.cell, 0, e.title, e.detail); appendEvent(e.kind, e.severity, liveTime, e.cell, 0, e.title, e.detail);
if (y < year) rebuildTerritory(); // recompute between years so the next year's war sees it if (y < year) rebuildTerritory(); // recompute between years so the next year's war sees it
} }
} }
@ -779,6 +788,7 @@ void Viewer::rebuildTerritory() {
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) buildWarFrontier(planet, borderR + 0.001f, warFrontier); // civ Step 5: current war fronts (drawn red)
buildDiploLinks(planet, borderR, allyLinks, rivalLinks); // civ Step 6: alliance/rivalry arcs
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 = 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 uint32_t SAVE_VERSION = 22; // v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3
static constexpr 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";
@ -110,6 +110,7 @@ struct Viewer {
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) std::vector<Vector3> warFrontier; // red frontier segments between realms currently at war (civ Step 5)
std::vector<Vector3> allyLinks, rivalLinks; // civ Step 6: capital-to-capital arcs (allies green, rivals red)
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

@ -106,6 +106,20 @@ void Viewer::renderGlobe3D() {
} }
rlEnd(); rlSetLineWidth(1.0f); rlEnd(); rlSetLineWidth(1.0f);
} }
if (showNationBorders) { // civ Step 6: diplomacy arcs (allies green, rivals dark red)
rlSetLineWidth(2.0f); rlBegin(RL_LINES);
rlColor4ub(70, 220, 120, 200);
for (size_t i = 0; i + 1 < allyLinks.size(); i += 2) {
rlVertex3f(allyLinks[i].x, allyLinks[i].y, allyLinks[i].z);
rlVertex3f(allyLinks[i + 1].x, allyLinks[i + 1].y, allyLinks[i + 1].z);
}
rlColor4ub(150, 40, 60, 200);
for (size_t i = 0; i + 1 < rivalLinks.size(); i += 2) {
rlVertex3f(rivalLinks[i].x, rivalLinks[i].y, rivalLinks[i].z);
rlVertex3f(rivalLinks[i + 1].x, rivalLinks[i + 1].y, rivalLinks[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) {
@ -362,6 +376,8 @@ void Viewer::renderMap2D() {
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 (showNationBorders && !warFrontier.empty()) drawSegments2D(warFrontier, Color{235, 40, 30, 255}, 2.5f, vr, mapLon);
if (showNationBorders && !allyLinks.empty()) drawSegments2D(allyLinks, Color{70, 220, 120, 220}, 1.5f, vr, mapLon);
if (showNationBorders && !rivalLinks.empty()) drawSegments2D(rivalLinks, Color{150, 40, 60, 220}, 1.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);
@ -584,7 +600,7 @@ void Viewer::renderLiveInfo() {
: Color{175, 205, 235, 255}; : Color{175, 205, 235, 255};
DrawRectangleRec(row, bg); DrawRectangleRec(row, bg);
DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255}); DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255});
const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : "~"; const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : e.kind == 6 ? "=" : "~";
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});
@ -703,6 +719,13 @@ void Viewer::renderLiveInfo() {
return "a fallen realm"; return "a fallen realm";
}; };
auto atWar = [&](int cap) { for (const War& w : W) if (w.attacker == cap || w.defender == cap) return true; return false; }; auto atWar = [&](int cap) { for (const War& w : W) if (w.attacker == cap || w.defender == cap) return true; return false; };
auto tieCounts = [&](int cap, int& allies, int& rivals) { // Step 6: standing relations of a realm
allies = 0; rivals = 0;
for (const DiploTie& t : planet.diploList()) {
if (t.a != cap && t.b != cap) continue;
if (t.kind == DiploKind::Alliance) ++allies; else if (t.kind == DiploKind::Rival) ++rivals;
}
};
if (!W.empty()) { // active wars summary if (!W.empty()) { // active wars summary
DrawText(TextFormat("Wars: %d", (int)W.size()), x, y, 14, Color{235, 90, 80, 255}); DrawText(TextFormat("Wars: %d", (int)W.size()), x, y, 14, Color{235, 90, 80, 255});
y += 19; y += 19;
@ -733,6 +756,9 @@ 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);
int al = 0, rv = 0; tieCounts(nat.capital, al, rv);
if (al > 0) DrawText(TextFormat("+%d", al), (int)(r.x + r.width) - 152, (int)row.y + 3, 11, Color{90, 210, 130, 255}); // allies
if (rv > 0) DrawText(TextFormat("-%d", rv), (int)(r.x + r.width) - 128, (int)row.y + 3, 11, Color{225, 95, 95, 255}); // rivals
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 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;

View File

@ -65,7 +65,7 @@ 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(); wars.clear(); sSettleAllegiance.clear(); diplomacy.clear();
sWarRng = cfg.seed ? (cfg.seed ^ 0x5A7B0A11u) : 0x5A7B0A11u; sWarNextId = 1; sWarRng = cfg.seed ? (cfg.seed ^ 0x5A7B0A11u) : 0x5A7B0A11u; sWarNextId = 1;
} }

View File

@ -28,6 +28,7 @@ public:
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) std::vector<War> wars; // civ Step 5: active wars between realms (stateful, saved v21)
std::vector<DiploTie> diplomacy; // civ Step 6: standing realm relations (stateful, saved v22)
// 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
@ -223,6 +224,10 @@ public:
const std::vector<War>& warList() const { return wars; } const std::vector<War>& warList() const { return wars; }
const std::vector<int>& settleAllegiance() const { return sSettleAllegiance; } // overlord capital settlement index (-1 = free) 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? bool realmsAtWar(int nationA, int nationB) const; // are these two nation indices in an active war?
// Diplomacy (civ Step 6). Relations are updated inside stepConflict(); these query them by nation index.
const std::vector<DiploTie>& diploList() const { return diplomacy; }
DiploKind diploBetween(int nationA, int nationB) const; // relation kind between two nation indices
bool realmsAllied(int nationA, int nationB) const; // are these two realms allied?
// 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.
@ -253,7 +258,7 @@ public:
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true, bool hasMoons = true, bool hasWeather = true, bool hasStorms = true,
bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true, bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true,
bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true, bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true,
bool hasConflict = true); bool hasConflict = true, bool hasDiplo = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing

View File

@ -4,12 +4,14 @@
#include <map> #include <map>
#include <unordered_map> #include <unordered_map>
// --- Civilization Step 5: conflict, war & shifting borders ------------------- // --- Civilization Steps 5-6: conflict, war & diplomacy -----------------------
// stepConflict() runs once per sim year. It reads the current realms (derived last recompute) and // 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, // 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 // 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; // INDEX (stable across the yearly recompute). Step 6 adds a diplomacy pass -- per-realm-pair attitudes
// the state (allegiance + wars + RNG) is saved (v21) and snapshotted so step-back rewinds conquests. // drift into alliances/rivalries, allies don't fight + join each other's wars (coalitions), and wars end
// in truces. A separate war RNG (sWarRng) keeps tectonics deterministic; the state (allegiance + wars +
// diplomacy + RNG) is saved (v21/v22) and snapshotted so step-back rewinds conquests + relations.
namespace { namespace {
inline uint32_t warHash(uint32_t a) { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; } inline uint32_t warHash(uint32_t a) { a ^= a << 13; a ^= a >> 17; a ^= a << 5; return a ? a : 1u; }
@ -24,6 +26,15 @@ bool Planet::realmsAtWar(int a, int b) const {
return false; return false;
} }
// Diplomatic relation between two nation indices (ties are keyed on capital settlement index).
DiploKind Planet::diploBetween(int a, int b) const {
if (a < 0 || b < 0 || a >= (int)nations.size() || b >= (int)nations.size()) return DiploKind::Neutral;
int lo = std::min(nations[a].capital, nations[b].capital), hi = std::max(nations[a].capital, nations[b].capital);
for (const DiploTie& t : diplomacy) if (t.a == lo && t.b == hi) return t.kind;
return DiploKind::Neutral;
}
bool Planet::realmsAllied(int a, int b) const { return diploBetween(a, b) == DiploKind::Alliance; }
ConflictUpdate Planet::stepConflict(long year) { ConflictUpdate Planet::stepConflict(long year) {
ConflictUpdate up; ConflictUpdate up;
if (settlements.empty() || nations.empty()) return up; if (settlements.empty() || nations.empty()) return up;
@ -58,6 +69,36 @@ ConflictUpdate Planet::stepConflict(long year) {
auto rnd = [&]() { sWarRng ^= sWarRng << 13; sWarRng ^= sWarRng >> 17; sWarRng ^= sWarRng << 5; if (!sWarRng) sWarRng = 1u; return (sWarRng & 0xFFFFFFu) / double(0x1000000); }; auto rnd = [&]() { sWarRng ^= sWarRng << 13; sWarRng ^= sWarRng >> 17; sWarRng ^= sWarRng << 5; if (!sWarRng) sWarRng = 1u; return (sWarRng & 0xFFFFFFu) / double(0x1000000); };
bool activity = false; bool activity = false;
// Diplomacy ties are keyed on capital settlement index (a<b). Find / find-or-create a tie.
auto tieIdx = [&](int ca, int cb) -> int {
int lo = std::min(ca, cb), hi = std::max(ca, cb);
for (size_t i = 0; i < diplomacy.size(); ++i) if (diplomacy[i].a == lo && diplomacy[i].b == hi) return (int)i;
return -1;
};
auto tieRef = [&](int ca, int cb) -> DiploTie& {
int i = tieIdx(ca, cb);
if (i >= 0) return diplomacy[(size_t)i];
DiploTie t; t.a = std::min(ca, cb); t.b = std::max(ca, cb); diplomacy.push_back(t);
return diplomacy.back();
};
// Realm adjacency by SETTLEMENT PROXIMITY (territory is influence-limited with wilderness marches),
// built once and reused by both the diplomacy pass and war declaration.
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++; }
}
}
// --- 1) Prune stale allegiances + revolts (contestable conquest) --------- // --- 1) Prune stale allegiances + revolts (contestable conquest) ---------
for (size_t s = 0; s < settlements.size(); ++s) { for (size_t s = 0; s < settlements.size(); ++s) {
int ov = sSettleAllegiance[s]; int ov = sSettleAllegiance[s];
@ -126,33 +167,64 @@ ConflictUpdate Planet::stepConflict(long year) {
w.warscore *= 0.3; // the front resets after a city changes hands w.warscore *= 0.3; // the front resets after a city changes hands
} }
} }
if (std::fabs(w.warscore) >= cfg.warExhaustion || w.battles > 60) { // exhausted -> peace if (std::fabs(w.warscore) >= cfg.warExhaustion || w.battles > 60) { // exhausted -> peace treaty
DiploTie& t = tieRef(w.attacker, w.defender); // a truce + a lasting scar
t.truceUntil = year + (long)cfg.diploTruceYears;
t.attitude = std::clamp(t.attitude - cfg.diploWarGrudge, -1.0, 1.0);
up.events.push_back(WarEvent{1, settlements[nations[na].capital].cell, up.events.push_back(WarEvent{1, settlements[nations[na].capital].cell,
"Peace between the " + nations[na].name + " and the " + nations[nd].name, "Peace between the " + nations[na].name + " and the " + nations[nd].name,
"The war ends, both sides exhausted."}); "The war ends in a truce, both sides exhausted."});
continue; // drop the war continue; // drop the war
} }
keep.push_back(w); keep.push_back(w);
} }
wars.swap(keep); wars.swap(keep);
// --- 3) Declare new wars from neighbouring-realm hostility --------------- // --- 2.5) Diplomacy: prune dead ties, drift attitudes, form/break relations (civ Step 6) --
// Territory is influence-limited (wilderness marches between realms), so realm adjacency is by { // drop ties whose endpoints are gone (kept dormant across a temporary capital swap otherwise)
// SETTLEMENT PROXIMITY -- realms that are merely near each other can contest the land between them. std::vector<DiploTie> kd; kd.reserve(diplomacy.size());
const double neighborRange = cfg.civTerritoryMax * 1.5; for (const DiploTie& t : diplomacy) if (alive(t.a) && alive(t.b)) kd.push_back(t);
std::map<std::pair<int, int>, std::pair<double, int>> pinfo; // (loNat,hiNat) -> (minDist, closePairs) diplomacy.swap(kd);
for (size_t a = 0; a < settlements.size(); ++a) { }
int na = sSettleNation[a]; if (na < 0 || !alive((int)a)) continue; for (auto& kv : pinfo) {
for (size_t b = a + 1; b < settlements.size(); ++b) { int A = kv.first.first, B = kv.first.second;
int nb = sSettleNation[b]; if (nb < 0 || nb == na || !alive((int)b)) continue; int ca = nations[A].capital, cb = nations[B].capital;
double d = angCell(settlements[a].cell, settlements[b].cell); int cA = nations[A].cultureId, cB = nations[B].cultureId;
if (d >= neighborRange) continue; double aff = 0.0; // shared culture + faith -> friendly
auto key = std::make_pair(std::min(na, nb), std::max(na, nb)); if (cA >= 0 && cB >= 0) {
auto it = pinfo.find(key); aff += (cA == cB) ? 1.0 : -0.6;
if (it == pinfo.end()) pinfo[key] = {d, 1}; if (cA < (int)cultures.size() && cB < (int)cultures.size())
else { it->second.first = std::min(it->second.first, d); it->second.second++; } aff += (cultures[cA].faith == cultures[cB].faith) ? 0.5 : -0.3;
}
uint32_t pk = warHash((uint32_t)std::min(ca, cb) * 2654435761u ^ (uint32_t)std::max(ca, cb) * 40503u ^ (uint32_t)year * 19349663u ^ seed ^ 0xD1D0u);
double noise = (pk & 0xFFFFu) / 65535.0 * 2.0 - 1.0; // [-1,1] per-pair personality
double target = std::clamp(cfg.diploAffinity * 0.5 * aff + 0.25 * noise, -1.0, 1.0);
DiploTie& t = tieRef(ca, cb);
double drift = cfg.diploDriftRate * (target - t.attitude);
if (realmsAtWar(A, B)) drift -= cfg.diploWarPenalty * cfg.diploDriftRate * 4.0; // war sours relations
if (t.truceUntil > year) drift += (0.0 - t.attitude) * cfg.diploDriftRate * 0.5; // truce eases tempers
t.attitude = std::clamp(t.attitude + drift, -1.0, 1.0);
DiploKind old = t.kind, nk; // classify with hysteresis
if (t.attitude >= cfg.diploAllyThreshold) nk = DiploKind::Alliance;
else if (t.attitude <= cfg.diploRivalThreshold) nk = DiploKind::Rival;
else if (t.attitude >= cfg.diploNonAggThreshold) nk = DiploKind::NonAggression;
else nk = DiploKind::Neutral;
if (old == DiploKind::Alliance && t.attitude >= cfg.diploAllyThreshold - 0.08) nk = DiploKind::Alliance;
if (old == DiploKind::Rival && t.attitude <= cfg.diploRivalThreshold + 0.08) nk = DiploKind::Rival;
if (nk != old) {
t.kind = nk; activity = true;
const std::string& nA = nations[A].name; const std::string& nB = nations[B].name;
int cell = settlements[ca].cell; std::string title, detail;
if (nk == DiploKind::Alliance) { title = "The " + nA + " and the " + nB + " form an alliance"; detail = "A pact of mutual defence."; }
else if (nk == DiploKind::Rival) { title = "The " + nA + " and the " + nB + " become rivals"; detail = "Enmity hardens between them."; }
else if (nk == DiploKind::NonAggression) { title = "The " + nA + " and the " + nB + " sign a non-aggression pact"; detail = "They agree to keep the peace."; }
else { title = (old == DiploKind::Alliance) ? "The alliance of the " + nA + " and the " + nB + " dissolves"
: "The " + nA + " and the " + nB + " normalise relations"; detail = ""; }
up.events.push_back(WarEvent{1, cell, title, detail, 6});
} }
} }
// --- 3) Declare new wars (alliance-gated; rivals fight; allies form coalitions) -----------
struct Cand { int a, b; double hostility; }; struct Cand { int a, b; double hostility; };
std::vector<Cand> cands; std::vector<Cand> cands;
for (auto& kv : pinfo) { for (auto& kv : pinfo) {
@ -160,17 +232,20 @@ ConflictUpdate Planet::stepConflict(long year) {
double minD = kv.second.first; int closePairs = kv.second.second; double minD = kv.second.first; int closePairs = kv.second.second;
if (realmsAtWar(A, B)) continue; if (realmsAtWar(A, B)) continue;
if (nations[A].totalPop < cfg.warMinRealmPop || nations[B].totalPop < cfg.warMinRealmPop) continue; if (nations[A].totalPop < cfg.warMinRealmPop || nations[B].totalPop < cfg.warMinRealmPop) continue;
int ca = nations[A].capital, cb = nations[B].capital;
int ti = tieIdx(ca, cb);
if (ti >= 0) { // allies/pacts/truces don't fight
if (diplomacy[(size_t)ti].kind == DiploKind::Alliance || diplomacy[(size_t)ti].kind == DiploKind::NonAggression) continue;
if (diplomacy[(size_t)ti].truceUntil > year) continue;
}
double att = (ti >= 0) ? diplomacy[(size_t)ti].attitude : 0.0;
double sA = realmStrength(A), sB = realmStrength(B); double sA = realmStrength(A), sB = realmStrength(B);
double sizeGap = std::fabs(sA - sB) / (sA + sB); double sizeGap = std::fabs(sA - sB) / (sA + sB);
int cA = nations[A].cultureId, cB = nations[B].cultureId; double prox = 1.0 - std::min(1.0, minD / neighborRange);
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); 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); 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 noise = (pk & 0xFFFFu) / 65535.0;
double hostility = cfg.warAmbition * sizeGap + cfg.warIdeology * ideo + cfg.warBorder * (0.5 * frontN + 0.5 * prox) + 0.4 * noise; double hostility = cfg.warAmbition * sizeGap + cfg.warIdeology * std::max(0.0, -att) + cfg.warBorder * (0.5 * frontN + 0.5 * prox) + 0.4 * noise;
cands.push_back({A, B, hostility}); cands.push_back({A, B, hostility});
} }
std::sort(cands.begin(), cands.end(), [](const Cand& x, const Cand& y) { return x.hostility > y.hostility; }); std::sort(cands.begin(), cands.end(), [](const Cand& x, const Cand& y) { return x.hostility > y.hostility; });
@ -179,11 +254,26 @@ ConflictUpdate Planet::stepConflict(long year) {
if (rnd() >= cfg.warDeclareRate * c.hostility) continue; if (rnd() >= cfg.warDeclareRate * c.hostility) continue;
int att = (realmStrength(c.a) >= realmStrength(c.b)) ? c.a : c.b; int att = (realmStrength(c.a) >= realmStrength(c.b)) ? c.a : c.b;
int def = (att == c.a) ? c.b : c.a; 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; int attCap = nations[att].capital, defCap = nations[def].capital;
War w; w.id = sWarNextId++; w.attacker = attCap; w.defender = defCap; w.startYear = year;
wars.push_back(w); activity = true; wars.push_back(w); activity = true;
up.events.push_back(WarEvent{1, settlements[nations[att].capital].cell, up.events.push_back(WarEvent{1, settlements[attCap].cell,
"The " + nations[att].name + " declares war on the " + nations[def].name, "The " + nations[att].name + " declares war on the " + nations[def].name,
"Armies march to the frontier."}); "Armies march to the frontier."});
// Coalition: the defender's allies join the fight against the aggressor.
for (const DiploTie& t : diplomacy) {
if (t.kind != DiploKind::Alliance) continue;
int allyCap = (t.a == defCap) ? t.b : (t.b == defCap) ? t.a : -1;
if (allyCap < 0 || !alive(allyCap)) continue;
int allyNat = nationOfCap(allyCap);
if (allyNat < 0 || allyNat == att || realmsAtWar(allyNat, att)) continue;
if ((int)wars.size() >= cfg.warMaxConcurrent) break;
War cw; cw.id = sWarNextId++; cw.attacker = allyCap; cw.defender = attCap; cw.startYear = year;
wars.push_back(cw);
up.events.push_back(WarEvent{1, settlements[allyCap].cell,
"The " + nations[allyNat].name + " joins the war against the " + nations[att].name,
"Honouring its alliance with the " + nations[def].name + ".", 6});
}
} }
up.changed = activity; up.changed = activity;

View File

@ -17,6 +17,7 @@ struct WarEvent {
int cell = -1; // where it happened (for the event's focus jump) int cell = -1; // where it happened (for the event's focus jump)
std::string title; std::string title;
std::string detail; std::string detail;
uint8_t kind = 5; // WorldEvent kind: 5 war (default), 6 diplomacy
}; };
// What stepConflict() changed this year -- the viewer logs the events and recolors if borders moved. // What stepConflict() changed this year -- the viewer logs the events and recolors if borders moved.

View File

@ -58,6 +58,8 @@
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(warDeclareRate) D(warAmbition) D(warIdeology) D(warBorder) D(warWarlikeMult) D(warCasualtyRate) \
D(warConquerScore) D(warSackChance) D(warExhaustion) D(warRevoltRate) D(warMinRealmPop) \ D(warConquerScore) D(warSackChance) D(warExhaustion) D(warRevoltRate) D(warMinRealmPop) \
D(diploDriftRate) D(diploAffinity) D(diploWarPenalty) D(diploTruceYears) D(diploWarGrudge) \
D(diploAllyThreshold) D(diploNonAggThreshold) D(diploRivalThreshold) \
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) \
@ -299,6 +301,14 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.warRevoltRate, 0.0, 10.0, "warRevoltRate")); E(rng(cfg.warRevoltRate, 0.0, 10.0, "warRevoltRate"));
E(rng(cfg.warMinRealmPop, 0.0, 1.0e12, "warMinRealmPop")); E(rng(cfg.warMinRealmPop, 0.0, 1.0e12, "warMinRealmPop"));
E(irng(cfg.warMaxConcurrent, 0, 100000, "warMaxConcurrent")); E(irng(cfg.warMaxConcurrent, 0, 100000, "warMaxConcurrent"));
E(rng(cfg.diploDriftRate, 0.0, 10.0, "diploDriftRate"));
E(rng(cfg.diploAffinity, 0.0, 100.0, "diploAffinity"));
E(rng(cfg.diploWarPenalty, 0.0, 100.0, "diploWarPenalty"));
E(rng(cfg.diploTruceYears, 0.0, 1.0e6, "diploTruceYears"));
E(rng(cfg.diploWarGrudge, 0.0, 2.0, "diploWarGrudge"));
E(rng(cfg.diploAllyThreshold, -1.0, 1.0, "diploAllyThreshold"));
E(rng(cfg.diploNonAggThreshold, -1.0, 1.0, "diploNonAggThreshold"));
E(rng(cfg.diploRivalThreshold, -1.0, 1.0, "diploRivalThreshold"));
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"));
@ -476,12 +486,18 @@ void Planet::writeState(std::ostream& os) const {
writePod(os, w.startYear); writePod(os, w.warscore); writePod(os, w.battles); writePod(os, w.startYear); writePod(os, w.warscore); writePod(os, w.battles);
} }
writePod(os, sWarRng); writePod(os, sWarNextId); writePod(os, sWarRng); writePod(os, sWarNextId);
// v22: diplomacy (civ Step 6). Standing realm relations (alliances / rivalries / truces).
uint64_t nd = diplomacy.size(); writePod(os, nd);
for (const DiploTie& t : diplomacy) {
writePod(os, t.a); writePod(os, t.b); writePod(os, t.attitude);
writePod(os, t.truceUntil); uint8_t k = (uint8_t)t.kind; writePod(os, k);
}
} }
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes,
bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements,
bool hasConflict) { bool hasConflict, bool hasDiplo) {
// 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; }
@ -491,6 +507,7 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
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 if (!hasSettlements) hasConflict = false; // conflict follows the settlement block
if (!hasConflict) hasDiplo = false; // diplomacy follows the conflict 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
@ -723,6 +740,18 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
if (!sWarRng) sWarRng = cfg.seed ? (cfg.seed ^ 0x5A7B0A11u) : 0x5A7B0A11u; if (!sWarRng) sWarRng = cfg.seed ? (cfg.seed ^ 0x5A7B0A11u) : 0x5A7B0A11u;
if (!sWarNextId) sWarNextId = 1; if (!sWarNextId) sWarNextId = 1;
} }
// v22: diplomacy (civ Step 6). Standing realm relations (pre-v22 saves keep none; they re-form live).
if (hasDiplo) {
uint64_t nd = 0; readPod(is, nd);
if (!is || nd > 1000000) return false;
diplomacy.resize((size_t)nd);
for (DiploTie& t : diplomacy) {
readPod(is, t.a); readPod(is, t.b); readPod(is, t.attitude);
readPod(is, t.truceUntil); uint8_t k = 0; readPod(is, k);
t.kind = (k <= (uint8_t)DiploKind::Rival) ? (DiploKind)k : DiploKind::Neutral;
if (!is || !std::isfinite(t.attitude)) return false;
}
}
computeBiotaDensity(); // derived density scalars for the colour views computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is; return (bool)is;
} }

View File

@ -98,6 +98,20 @@ struct War {
int battles = 0; // number of resolved battle-years int battles = 0; // number of resolved battle-years
}; };
// Civilization Step 6: diplomacy. A standing relationship between two realms (by capital SETTLEMENT
// INDEX, a<b -- the same stable id wars use). attitude drifts from culture/faith affinity, proximity and
// the scars of past wars; it crystallises into an alliance / non-aggression pact / rivalry. Stateful,
// saved (v22) + snapshotted.
enum class DiploKind : uint8_t { Neutral, NonAggression, Alliance, Rival };
struct DiploTie {
int a = -1; // capital settlement index of one realm (a < b)
int b = -1; // capital settlement index of the other realm
double attitude = 0.0; // -1 hostile .. +1 friendly
long truceUntil = -1; // no war between them until this sim year (-1 = none)
DiploKind kind = DiploKind::Neutral;
};
// 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 {
@ -114,6 +128,8 @@ struct WeatherSnapshot {
std::vector<int> settlementAllegiance; std::vector<int> settlementAllegiance;
std::vector<War> wars; std::vector<War> wars;
uint32_t warRng = 0, warNextId = 0; uint32_t warRng = 0, warNextId = 0;
// Civilization Step 6 (diplomacy): standing realm relations (alliances / rivalries / truces).
std::vector<DiploTie> diplomacy;
}; };
struct Plate { struct Plate {
@ -452,4 +468,14 @@ struct PlanetConfig {
double warExhaustion = 1.5; // |warscore| (or battle count) past which a war ends in peace 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 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 double warMinRealmPop = 2000.0; // realms below this population don't start wars
// Civilization Step 6: diplomacy (stateful, saved v22). Realm attitudes drift into alliances /
// rivalries; allies don't fight + join each other's wars; wars end in truces.
double diploDriftRate = 0.06; // per-year attitude change scale
double diploAffinity = 1.0; // attitude pull from shared culture + faith (vs difference)
double diploWarPenalty = 0.5; // extra per-year attitude drop while two realms are at war
double diploTruceYears = 12.0; // post-war truce length (no new war between the two)
double diploWarGrudge = 0.4; // one-off attitude drop when a war between them ends (a scar)
double diploAllyThreshold = 0.5; // attitude at/above which two realms are allied
double diploNonAggThreshold = 0.2; // attitude at/above which they sign a non-aggression pact
double diploRivalThreshold = -0.5; // attitude at/below which they become rivals
}; };

View File

@ -35,6 +35,7 @@ WeatherSnapshot Planet::captureWeather() const {
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.settlementAllegiance = sSettleAllegiance; // civ Step 5: conquest state
s.wars = wars; s.warRng = sWarRng; s.warNextId = sWarNextId; s.wars = wars; s.warRng = sWarRng; s.warNextId = sWarNextId;
s.diplomacy = diplomacy; // civ Step 6: standing realm relations
return s; return s;
} }
@ -47,6 +48,7 @@ void Planet::restoreWeather(const WeatherSnapshot& s) {
sSettleAllegiance = s.settlementAllegiance; // civ Step 5: restore conquest state sSettleAllegiance = s.settlementAllegiance; // civ Step 5: restore conquest state
if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1); if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1);
wars = s.wars; sWarRng = s.warRng ? s.warRng : sWarRng; sWarNextId = s.warNextId ? s.warNextId : sWarNextId; wars = s.wars; sWarRng = s.warRng ? s.warRng : sWarRng; sWarNextId = s.warNextId ? s.warNextId : sWarNextId;
diplomacy = s.diplomacy; // civ Step 6: restore realm relations
sHasWeather = !sHumidity.empty(); sHasWeather = !sHumidity.empty();
} }

145
test_diplomacy.cpp Normal file
View File

@ -0,0 +1,145 @@
// Headless test for civilization Step 6 (diplomacy, alliances & coalitions). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_diplomacy.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/tdip && /tmp/tdip
//
// Verifies: alliances + rivalries form; allies never war on each other; coalitions form (allies join a
// war); wars end in truces; determinism + RNG isolation; save-v22 + 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();
}
// Encourage lively diplomacy AND wars (so alliances/rivalries/coalitions/truces all get exercised).
static void livelyDiplo(Planet& p) {
p.cfg.diploDriftRate = 0.5; p.cfg.diploTruceYears = 20.0;
p.cfg.warDeclareRate = 2.0; p.cfg.warAmbition = 2.0; p.cfg.warBorder = 1.5;
p.cfg.warConquerScore = 5.0; p.cfg.warExhaustion = 1.0; // wars end by EXHAUSTION -> a truce (not conquest)
p.cfg.warMinRealmPop = 1.0; p.cfg.warMaxConcurrent = 50; p.cfg.warSackChance = 0.0;
}
static bool tiesEqual(const std::vector<DiploTie>& a, const std::vector<DiploTie>& b) {
if (a.size() != b.size()) return false;
for (size_t i = 0; i < a.size(); ++i)
if (a[i].a != b[i].a || a[i].b != b[i].b || a[i].kind != b[i].kind || a[i].truceUntil != b[i].truceUntil
|| std::fabs(a[i].attitude - b[i].attitude) > 1e-12) return false;
return true;
}
// Run the full civ+diplomacy sequence into p; return via out-params what was observed.
static void runWorld(Planet& p, int& maxAllies, int& maxRivals, bool& alliedAtWar, bool& sawTruce,
int& joinEvents, int& maxSimulWars) {
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); }
livelyDiplo(p);
maxAllies = maxRivals = joinEvents = maxSimulWars = 0; alliedAtWar = false; sawTruce = false;
for (long yr = 0; yr < 400; ++yr) {
p.computeTerritory(); p.computeCultures();
ConflictUpdate u = p.stepConflict(yr);
for (const WarEvent& e : u.events) if (e.title.find("joins the war") != std::string::npos) ++joinEvents;
int nA = 0, nR = 0;
for (const DiploTie& t : p.diploList()) {
if (t.kind == DiploKind::Alliance) ++nA; else if (t.kind == DiploKind::Rival) ++nR;
if (t.truceUntil > yr) sawTruce = true;
}
maxAllies = std::max(maxAllies, nA); maxRivals = std::max(maxRivals, nR);
// Allies must never be in an active war with each other.
for (const War& w : p.warList()) {
int lo = std::min(w.attacker, w.defender), hi = std::max(w.attacker, w.defender);
for (const DiploTie& t : p.diploList())
if (t.a == lo && t.b == hi && t.kind == DiploKind::Alliance) alliedAtWar = true;
}
// Any realm fighting on >1 front this year (coalitions produce this).
for (size_t ni = 0; ni < p.nationList().size(); ++ni) {
int cap = p.nationList()[ni].capital, cnt = 0;
for (const War& w : p.warList()) if (w.attacker == cap || w.defender == cap) ++cnt;
maxSimulWars = std::max(maxSimulWars, cnt);
}
}
p.computeTerritory();
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 4242;
Planet p; p.generate(cfg); settle(p); drift(p, 400);
const int n = (int)p.cells.size();
const double yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays;
std::printf("Diplomacy: alliances, rivalries, allies-at-peace, truces, coalitions\n");
int maxAllies, maxRivals, joinEvents, maxSimulWars; bool alliedAtWar, sawTruce;
runWorld(p, maxAllies, maxRivals, alliedAtWar, sawTruce, joinEvents, maxSimulWars);
std::printf(" peak alliances %d ; peak rivalries %d ; truces seen %s ; join-events %d ; max fronts for a realm %d\n",
maxAllies, maxRivals, sawTruce ? "yes" : "no", joinEvents, maxSimulWars);
check(maxAllies >= 1, "alliances form between realms");
check(maxRivals >= 1, "rivalries form between realms");
check(!alliedAtWar, "allies are never at war with each other");
check(sawTruce, "a war ends in a truce (no immediate re-declaration)");
check(joinEvents > 0 || maxSimulWars >= 2, "coalitions form (an ally joins / a realm fights multiple fronts)");
std::printf("Diplomacy: determinism\n");
Planet q; q.generate(cfg); settle(q); drift(q, 400);
int a2, r2, j2, m2; bool aw2, tr2; runWorld(q, a2, r2, aw2, tr2, j2, m2);
check(tiesEqual(q.diploList(), p.diploList()), "diplomacy is deterministic (identical ties)");
std::printf("Diplomacy: 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(); livelyDiplo(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, "diplomacy never perturbs tectonic evolution");
}
std::printf("Diplomacy: save v22 round-trip\n");
{
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
p.writeState(ss);
Planet r;
bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true, true, true);
check(ok, "readState accepts the v22 stream");
check(tiesEqual(r.diploList(), p.diploList()), "diplomacy survives save/load");
}
std::printf("Diplomacy: step-back snapshot round-trip\n");
{
WeatherSnapshot snap = p.captureWeather();
std::vector<DiploTie> before = p.diploList();
p.cfg.diploDriftRate = 1.0;
for (long yr = 400; yr < 415; ++yr) { p.computeTerritory(); p.computeCultures(); p.stepConflict(yr); }
bool changed = !tiesEqual(p.diploList(), before);
p.restoreWeather(snap);
check(changed, "the mutation actually changed the diplomacy state");
check(tiesEqual(p.diploList(), before), "restoreWeather rewinds the diplomacy (step-back)");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL DIPLOMACY CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}