From 8cd487a5dcff1fde62ecbb2bb7b4d63d9581e727 Mon Sep 17 00:00:00 2001 From: Jonas Reith Date: Wed, 1 Jul 2026 13:07:10 +0200 Subject: [PATCH] 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 --- BUILD.md | 17 ++++- CLAUDE.md | 48 +++++++++--- CMakeLists.txt | 2 +- docs/design-notes.md | 31 +++++++- src/render/Overlays.cpp | 23 ++++++ src/render/Overlays.hpp | 2 + src/render/Panels.cpp | 6 ++ src/render/Viewer.cpp | 14 +++- src/render/Viewer.hpp | 3 +- src/render/ViewerRender.cpp | 28 ++++++- src/sim/Planet.cpp | 2 +- src/sim/Planet.hpp | 7 +- src/sim/PlanetConflict.cpp | 146 +++++++++++++++++++++++++++++------- src/sim/PlanetConflict.hpp | 1 + src/sim/PlanetIO.cpp | 31 +++++++- src/sim/PlanetTypes.hpp | 26 +++++++ src/sim/PlanetWeather.cpp | 2 + test_diplomacy.cpp | 145 +++++++++++++++++++++++++++++++++++ 18 files changed, 482 insertions(+), 52 deletions(-) create mode 100644 test_diplomacy.cpp diff --git a/BUILD.md b/BUILD.md index 929e8ff..ff7a8e5 100644 --- a/BUILD.md +++ b/BUILD.md @@ -57,7 +57,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain: E ecoregions colour view (names ecological provinces on first use; Eco tab) I habitability heat map (where civilization can thrive) U settlements: the dawn of civilization on first press, then toggle markers (Civ tab) - P territory / realms view + political borders + 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) 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 @@ -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 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 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 @@ -362,7 +375,7 @@ src/sim/PlanetCulture.cpp (computeCultures). 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_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. Verifies geometry, plate assignment, gradual non-saturating relief and diff --git a/CLAUDE.md b/CLAUDE.md index 9b6424f..afde762 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it. **Civilizations (in progress — the long arc after the world is finished):** the eventual goal is people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural + geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all -on the Live World clock). **Steps 1–5 of the roadmap are done (plus a derived ecoregions atlas):** +on the Live World clock). **Steps 1–6 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), @@ -202,8 +202,23 @@ on the Live World clock). **Steps 1–5 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 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*`. +- **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); - alliances / diplomacy / treaties; trade & economy.* + tribute / vassalage treaties; trade & economy.* ## Current state @@ -615,7 +630,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) + PlanetConflict.* stepConflict (wars/conquest/revolts + diplomacy/alliances/coalitions; civ Steps 5-6, save v21/v22) PlanetIO.cpp config file (text) + binary save/load render/ (raylib viewer) 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`, `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 / -Civilization / Nation / Culture / Conflict suites — same source list. CMake also builds `test_events` for the +`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 / Diplomacy 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 @@ -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 **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, +(borders move) or fall to ruins, empires fracture as provinces revolt; realms also form **alliances** +(green arcs) and **rivalries** (dark-red arcs), allies **join each other's wars** (coalitions) and never +fight each other, and wars end in **peace treaties**; the **Realms** tab lists each realm's allies/ +rivals/wars and the **Events** tab logs it all. `Y` makes the 3D camera **follow a storm** (cycles by strength, off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and** weather/storms/volcano lifecycle **and wars/conquests** via snapshots). Mouse-wheel over the 2D map zooms (drag pans). @@ -765,7 +782,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 **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 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** @@ -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 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 -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 -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 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 @@ -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 demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin -again as the clock runs). +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 saved one. **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 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. +- **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 knob for how fast/high relief builds. - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks diff --git a/CMakeLists.txt b/CMakeLists.txt index e6eab85..225d854 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,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 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) target_link_libraries(test_${test_name} PRIVATE planetsim_sim) add_test(NAME ${test_name} COMMAND test_${test_name}) diff --git a/docs/design-notes.md b/docs/design-notes.md index 793f4b5..6009a53 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -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). - `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). +- `PlanetConflict.{hpp,cpp}` — `stepConflict` (civ Steps 5-6: wars/conquest/revolts + diplomacy/ + alliances/coalitions; stateful, saved v21/v22 + snapshotted). - `PlanetIO.cpp` — text config + binary save/load. 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 / 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& se buildLabelBorders(p, radius, p.cellCulture(), segs); } +void buildDiploLinks(const Planet& p, float radius, std::vector& ally, std::vector& 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& segs) { segs.clear(); const std::vector& cn = p.cellNation(); diff --git a/src/render/Overlays.hpp b/src/render/Overlays.hpp index d24c937..9153328 100644 --- a/src/render/Overlays.hpp +++ b/src/render/Overlays.hpp @@ -22,6 +22,8 @@ void buildNationBorders(const Planet& p, float radius, std::vector& seg void buildCultureBorders(const Planet& p, float radius, std::vector& segs); // Frontier segments between two cells whose realms are in an active war (civ Step 5): the war front. void buildWarFrontier(const Planet& p, float radius, std::vector& segs); +// Great-circle arcs between allied (-> ally) and rival (-> rival) realm capitals (civ Step 6). +void buildDiploLinks(const Planet& p, float radius, std::vector& ally, std::vector& rival); // ---- Per-plate drift arrows ------------------------------------------------- struct PlateLabel { int id; Vector3 pos; }; diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index 8560fe6..2d6a3ee 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -114,6 +114,12 @@ static std::vector cellInfo(const Planet& p, int i, double elev, do 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" : "")); + 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) { L.push_back(std::string("realm: wilderness")); } diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 6f06bf0..09c2c90 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -503,6 +503,9 @@ void Viewer::saveGame(const char* path) { { 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); + // 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. uint32_t en = (uint32_t)std::min(events.size(), (size_t)EVENT_LOG_MAX); @@ -536,7 +539,7 @@ void Viewer::loadGame(const char* path) { is.read(reinterpret_cast(&lh), sizeof lh); } // v8: Live World clock if (ver >= 13) is.read(reinterpret_cast(&lr), sizeof lr); // v13: Live World rate if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } - if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21)) { 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 elapsedMy = em; settled = (st != 0); 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); 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& 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()) @@ -756,7 +765,7 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) { for (long y = firstYear; y <= year; ++y) { ConflictUpdate wu = planet.stepConflict(y); 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 } } @@ -779,6 +788,7 @@ void Viewer::rebuildTerritory() { buildNationBorders(planet, borderR, nationBorders); buildCultureBorders(planet, borderR, cultureBorders); 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); lastTerritoryYear = (long)std::floor(liveTime / yearHours); } diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index f5dfe71..1abd60f 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -15,7 +15,7 @@ // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). struct Viewer { // ---- Files / save format ------------------------------------------------ - static constexpr uint32_t SAVE_VERSION = 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 EVENT_LOG_MAX = 200; const char* CONFIG_PATH = "planet.cfg"; @@ -110,6 +110,7 @@ struct Viewer { std::vector cultureBorders; // cultural-region border segments (civ Step 4, rebuilt with territory) bool showCultureBorders = false; // draw cultural-region borders (on with the Culture view) std::vector warFrontier; // red frontier segments between realms currently at war (civ Step 5) + std::vector 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) // World event journal: currently Live World events, shaped to be reused by later phases. diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index f8ffd54..092d276 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -106,6 +106,20 @@ void Viewer::renderGlobe3D() { } 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()) { rlSetLineWidth(2.5f); rlBegin(RL_LINES); rlColor4ub(90, 230, 255, 255); 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 (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 && !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 (liveWorld && showTides && !coastCols.empty()) drawColoredSegments2D(coast, coastCols, 2.0f, 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}; DrawRectangleRec(row, bg); DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255}); - const char* icon = e.kind == 2 ? "^" : e.kind == 3 ? "*" : e.kind == 4 ? "#" : e.kind == 5 ? "!" : "~"; + 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); 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}); @@ -703,6 +719,13 @@ void Viewer::renderLiveInfo() { 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 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 DrawText(TextFormat("Wars: %d", (int)W.size()), x, y, 14, Color{235, 90, 80, 255}); y += 19; @@ -733,6 +756,9 @@ 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); + 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 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; diff --git a/src/sim/Planet.cpp b/src/sim/Planet.cpp index 143194d..9b0a65a 100644 --- a/src/sim/Planet.cpp +++ b/src/sim/Planet.cpp @@ -65,7 +65,7 @@ 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(); + wars.clear(); sSettleAllegiance.clear(); diplomacy.clear(); sWarRng = cfg.seed ? (cfg.seed ^ 0x5A7B0A11u) : 0x5A7B0A11u; sWarNextId = 1; } diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 01eb067..23b2332 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -28,6 +28,7 @@ public: std::vector nations; // realms grouped from settlements (derived each computeTerritory, not saved) std::vector cultures; // peoples/cultures, one per inhabited continent (derived, not saved) std::vector wars; // civ Step 5: active wars between realms (stateful, saved v21) + std::vector diplomacy; // civ Step 6: standing realm relations (stateful, saved v22) // Phase flag: false during Phase-1 forming (modest, original tectonics that // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts @@ -223,6 +224,10 @@ public: const std::vector& warList() const { return wars; } const std::vector& settleAllegiance() const { return sSettleAllegiance; } // overlord capital settlement index (-1 = free) bool realmsAtWar(int nationA, int nationB) const; // are these two nation indices in an active war? + // Diplomacy (civ Step 6). Relations are updated inside stepConflict(); these query them by nation index. + const std::vector& 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 // 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. @@ -253,7 +258,7 @@ public: bool hasMoons = true, bool hasWeather = true, bool hasStorms = true, bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true, bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true, - bool hasConflict = true); + bool hasConflict = true, bool hasDiplo = true); // Helpers for rendering / info. double cellWidthMeters() const; // approx lateral cell spacing diff --git a/src/sim/PlanetConflict.cpp b/src/sim/PlanetConflict.cpp index 7e7d588..41e1ffb 100644 --- a/src/sim/PlanetConflict.cpp +++ b/src/sim/PlanetConflict.cpp @@ -4,12 +4,14 @@ #include #include -// --- 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 // 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. +// INDEX (stable across the yearly recompute). Step 6 adds a diplomacy pass -- per-realm-pair attitudes +// 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 { 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; } +// 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 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); }; bool activity = false; + // Diplomacy ties are keyed on capital settlement index (a 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> 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) --------- for (size_t s = 0; s < settlements.size(); ++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 } } - 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, "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 } keep.push_back(w); } wars.swap(keep); - // --- 3) Declare new wars from neighbouring-realm hostility --------------- - // Territory is influence-limited (wilderness marches between realms), so realm adjacency is by - // SETTLEMENT PROXIMITY -- realms that are merely near each other can contest the land between them. - const double neighborRange = cfg.civTerritoryMax * 1.5; - std::map, std::pair> pinfo; // (loNat,hiNat) -> (minDist, closePairs) - for (size_t a = 0; a < settlements.size(); ++a) { - int na = sSettleNation[a]; if (na < 0 || !alive((int)a)) continue; - for (size_t b = a + 1; b < settlements.size(); ++b) { - int nb = sSettleNation[b]; if (nb < 0 || nb == na || !alive((int)b)) continue; - double d = angCell(settlements[a].cell, settlements[b].cell); - if (d >= neighborRange) continue; - auto key = std::make_pair(std::min(na, nb), std::max(na, nb)); - auto it = pinfo.find(key); - if (it == pinfo.end()) pinfo[key] = {d, 1}; - else { it->second.first = std::min(it->second.first, d); it->second.second++; } + // --- 2.5) Diplomacy: prune dead ties, drift attitudes, form/break relations (civ Step 6) -- + { // drop ties whose endpoints are gone (kept dormant across a temporary capital swap otherwise) + std::vector kd; kd.reserve(diplomacy.size()); + for (const DiploTie& t : diplomacy) if (alive(t.a) && alive(t.b)) kd.push_back(t); + diplomacy.swap(kd); + } + for (auto& kv : pinfo) { + int A = kv.first.first, B = kv.first.second; + int ca = nations[A].capital, cb = nations[B].capital; + int cA = nations[A].cultureId, cB = nations[B].cultureId; + double aff = 0.0; // shared culture + faith -> friendly + if (cA >= 0 && cB >= 0) { + aff += (cA == cB) ? 1.0 : -0.6; + if (cA < (int)cultures.size() && cB < (int)cultures.size()) + 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; }; std::vector cands; for (auto& kv : pinfo) { @@ -160,17 +232,20 @@ ConflictUpdate Planet::stepConflict(long year) { 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; + 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 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 prox = 1.0 - std::min(1.0, minD / neighborRange); 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; + 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}); } 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; 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; + 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; - 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, "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; diff --git a/src/sim/PlanetConflict.hpp b/src/sim/PlanetConflict.hpp index 669413f..1f310e6 100644 --- a/src/sim/PlanetConflict.hpp +++ b/src/sim/PlanetConflict.hpp @@ -17,6 +17,7 @@ struct WarEvent { int cell = -1; // where it happened (for the event's focus jump) std::string title; 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. diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index c54ce17..577081e 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -58,6 +58,8 @@ 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) \ + 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(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ 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.warMinRealmPop, 0.0, 1.0e12, "warMinRealmPop")); 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.plateCount, 1, 100, "plateCount")); 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, 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 hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, 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, // later blocks cannot exist in that stream even if the default arguments say otherwise. 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 (!hasEcoregions) hasSettlements = false; // settlements follow the ecoregion block if (!hasSettlements) hasConflict = false; // conflict follows the settlement block + if (!hasConflict) hasDiplo = false; // diplomacy follows the conflict block // 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 @@ -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 (!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 return (bool)is; } diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index dd5b9fd..916f1f4 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -98,6 +98,20 @@ struct War { int battles = 0; // number of resolved battle-years }; +// Civilization Step 6: diplomacy. A standing relationship between two realms (by capital SETTLEMENT +// INDEX, a settlementAllegiance; std::vector wars; uint32_t warRng = 0, warNextId = 0; + // Civilization Step 6 (diplomacy): standing realm relations (alliances / rivalries / truces). + std::vector diplomacy; }; struct Plate { @@ -452,4 +468,14 @@ struct PlanetConfig { 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 + // 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 }; diff --git a/src/sim/PlanetWeather.cpp b/src/sim/PlanetWeather.cpp index d95703f..7ac1aeb 100644 --- a/src/sim/PlanetWeather.cpp +++ b/src/sim/PlanetWeather.cpp @@ -35,6 +35,7 @@ WeatherSnapshot Planet::captureWeather() const { 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; + s.diplomacy = diplomacy; // civ Step 6: standing realm relations return s; } @@ -47,6 +48,7 @@ void Planet::restoreWeather(const WeatherSnapshot& s) { 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; + diplomacy = s.diplomacy; // civ Step 6: restore realm relations sHasWeather = !sHumidity.empty(); } diff --git a/test_diplomacy.cpp b/test_diplomacy.cpp new file mode 100644 index 0000000..2a97139 --- /dev/null +++ b/test_diplomacy.cpp @@ -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 +#include +#include +#include + +static int failures = 0; +static void check(bool cond, const char* what) { + std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what); + if (!cond) ++failures; +} +static void settle(Planet& p, int maxSteps = 800) { + int run = 0; + for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; } + p.computeClimate(); p.classifyBiomes(); +} +static void drift(Planet& p, int iters) { + p.drifting = true; + for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); if (k >= iters/2) p.hydrology(dt*0.2); } + p.computeClimate(); p.classifyBiomes(); +} +// 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& a, const std::vector& 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 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; +}