Add volcanoes & volcanic islands (Live World, save v14)

On entering Live World a one-time pass places volcanoes by tectonic context
(very high prob on young spreading-ridge/"new-plate" cells, medium on plate
borders, low elsewhere). Over the live clock they erupt; submarine vents build
up and breach sea level into new volcanic islands, land vents grow cones, and
each eruption injects a drifting ash cloud + local cooling into the weather.

Design: eruption state (built height + intensity) is a PURE FUNCTION of liveTime
(like insolation/tides/seasons), so the live stepper rewinds islands & eruptions
for free -- no per-cell snapshot, no volcano undo history. The only integrated
side-effect is the ash plume into sCloud (reverts via the weather snapshot).

- src/sim/PlanetVolcano.cpp (new): placeVolcanoes (separate RNG, reservoir-
  sampled to volcanoMaxCount; tectonic determinism intact) + stepVolcanoes
  (reassert elevation = baseElev + built(liveTime); breach/un-breach; ash).
- Volcano struct + volcano* config knobs (PlanetTypes.hpp); Planet members +
  decls; readState gains hasVolcanoes; CONFIG_FIELDS + validateConfig.
- Save bumped to v14: flag-gated volcano block (set + sVolRng) in writeState/
  readState; pre-v14 saves load with none and place on next Live World entry.
- Render: 3D cone + eruption glow/ash-plume (DrawCylinderEx) and 2D triangle
  markers, key V toggle, HUD line, cell-info volcano line. Lazy placement on W
  entry and on loading a live-world save with no volcanoes.
- test_volcano.cpp (new, registered in CMake): determinism, RNG isolation,
  context classification + probability ordering, monotonic build + sea-level
  breach + step-back recede (pure function of liveTime), ash->cloud, v14
  round-trip. All six headless suites pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonas Reith 2026-06-29 12:48:45 +02:00
parent b005969ee1
commit 275511713c
14 changed files with 527 additions and 22 deletions

View File

@ -52,6 +52,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain:
T toggle the tide-coloured coastline (Live World)
O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue)
K toggle weather clouds/rain cover (Live World)
V toggle volcano markers (Live World; cones + eruption glow, build into islands)
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
rewinds weather + storms via an undo history)
@ -229,18 +230,33 @@ saved v10. Evaporate over warm seas -> advect along the wind -> condense -> rain
weatherSystemRain 1.6 /h rain at a system core
weatherHurricaneStr 0.6 strength above which a tropical system is a hurricane/typhoon
Volcanoes (PlanetConfig, Live World, key V): placed by tectonic context on entering Live World,
erupt on the live clock, build submarine vents into new islands (eruption state is a pure
function of liveTime, so the stepper rewinds it). Saved v14.
volcanoProbRidge 0.55 per-cell placement prob on a young spreading-ridge cell
volcanoProbBorder 0.06 per-cell placement prob on a normal plate-border cell
volcanoProbInterior 0.003 per-cell placement prob elsewhere (hotspots)
volcanoMaxCount 60 global cap (reservoir-sampled, ratios preserved)
volcanoBuildStep 130 m cone/island growth per eruption pulse
volcanoMaxHeight 3200 m max height built above a vent's base elevation
volcanoEruptFreq 0.05 eruption pulses per (hour * activity) -- cadence
volcanoAshCloud 0.9 cloud cover injected at the vent per erupting hour
volcanoAshCooling 6 C peak local cooling under an active ash plume
## Headless logic test (no display)
g++ -std=c++17 -O2 -Isrc/sim test_logic.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/PlanetBiota.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/PlanetIO.cpp -o /tmp/t && /tmp/t
# Biota / Live World / Ocean / Weather suites: same source list, swap test_logic.cpp ->
# test_biota.cpp, test_live.cpp, test_ocean.cpp or test_weather.cpp
# Biota / Live World / Ocean / Weather / Volcano suites: same source list, swap test_logic.cpp ->
# test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp or test_volcano.cpp
Verifies geometry, plate assignment, gradual non-saturating relief and
determinism. Run after changing Planet::step().

View File

@ -95,6 +95,16 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
tropical ones are **hurricanes/typhoons** (spin by hemisphere, eye + animated spiral marker).
Saved (v11, so a load resumes active storms). This makes the weather visibly move (the base field
alone relaxes to a static pattern).
- **Volcanoes & volcanic islands** *(done — see `PlanetVolcano.cpp`)* — on entering Live World a
one-time pass (`placeVolcanoes`, separate RNG → tectonic determinism intact) seeds volcanoes by
tectonic context: **very high** probability on young spreading-ridge / "new-plate" cells (baby
plates), **medium** on normal plate borders, **low** elsewhere (hotspots). On the live clock
(`stepVolcanoes`) they **erupt**; submarine vents build their cell up and **breach sea level into
new volcanic islands**, land vents grow cones, and each eruption injects a drifting **ash cloud**
(into the weather field) + **local cooling**. Eruption state (built height + intensity) is a **pure
function of `liveTime`** (like insolation/tides/seasons), so the live stepper rewinds islands &
eruptions for free (no extra snapshot state). Rendered as cone markers + an eruption glow/ash-plume
flare (3D + 2D, key `V`); saved (v14). Knobs `volcano*`.
## Current state
@ -488,6 +498,7 @@ src/
PlanetLive.cpp computeInsolation/computeLiveSeason (Live World: day/night + live seasons)
PlanetOcean.cpp moons (generate/orbit) + computeTides + computeOceanCurrents
PlanetWeather.cpp stepWeather (Live World dynamic clouds & rain cycle)
PlanetVolcano.cpp placeVolcanoes/stepVolcanoes (Live World volcanoes + islands)
PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate)
PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls
PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity
@ -553,12 +564,14 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.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/PlanetBiota.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/PlanetIO.cpp -o /tmp/t && /tmp/t
```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp` or
`test_weather.cpp` to run the Biota / Live World / Ocean / Weather suites — same source list.)
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
`test_weather.cpp` or `test_volcano.cpp` to run the Biota / Live World / Ocean / Weather /
Volcano suites — same source list.)
Use this to verify tectonics after changing `Planet::step()` without launching
the window (the engine lives in `src/sim` and is raylib-free, so it links without
@ -590,6 +603,7 @@ active mode shown top-center of the globe) ·
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) ·
`O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) ·
`V` volcano markers (Live World) ·
`SPACE` or on-screen button pause ·
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
`S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock
@ -614,9 +628,11 @@ moving day/night terminator (`N`), a live seasonal temperature cycle and a movin
line animate over whatever colour mode is active; the HUD shows a `Year/Day/HH:MM` calendar.
**13 moons** orbit (sun-lit phases, orbit rings, solar/lunar eclipses) and, with the distant
sun, raise tides — `T` colours the coastline by the live tide level (amber low ↔ cyan high).
`K` shows moving weather (clouds, rain, drifting storms / hurricanes). `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 only). Mouse-wheel over the 2D map zooms (drag pans).
`K` shows moving weather (clouds, rain, drifting storms / hurricanes). **Volcanoes** are placed by
tectonic context on entry and erupt on the clock — submarine ones build into new **volcanic islands**;
`V` toggles the cone/eruption markers. `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**
volcanoes/islands, which are pure functions of the clock). Mouse-wheel over the 2D map zooms (drag pans).
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate).
@ -627,20 +643,22 @@ 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 **13**; v2 adds the `[`/`]` drift rate, v3 a
save header is versioned (currently **14**; 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**
clock — a flag byte + `liveTime`, v9 appends the **moons** block, v10 appends the **weather** block —
humidity/cloud/rain, flag-gated, v11 also persists the **weather systems** + RNG so a load resumes
active storms, v12 appends the most recent **step-back frames**`wxSaveMax`(40) weather snapshots
— so a load can rewind storms past the saved moment, v13 appends the Live World clock rate);
— so a load can rewind storms past the saved moment, v13 appends the Live World clock rate, v14
appends the **volcanoes** block — the placed `Volcano` set + its RNG, gated by a flag byte);
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
saves spin weather up live; pre-v11 saves load with no active storms (they respawn); pre-v12 saves
load with no step-back history (you can still step forward then back); pre-v13 saves resume with
the default live clock rate. A load drops any **stale** pre-load `wxUndo` history and reloads the
the default live clock rate; pre-v14 saves load with no volcanoes (placed on the next Live World
entry). 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
config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults),
@ -747,6 +765,17 @@ triangles (plates are fixed in phase 1).
per-moon orbit/period/inclination/mass are randomized in `generateMoons()` (PlanetOcean.cpp);
the 3D sun distance/size, moon orbit-render band and eclipse angles are render constants
(ViewerRender.cpp / `rebuildLiveOverlay`), not config.
- **Volcanoes (`volcano*` in PlanetConfig / `planet.cfg`):** placement by tectonic context —
`volcanoProbRidge` (0.55), `volcanoProbBorder` (0.06), `volcanoProbInterior` (0.003) are the
per-cell placement probabilities for young-ridge / plate-border / interior cells (raise for more
vents of that kind), `volcanoMaxCount` (60) caps the total (reservoir-sampled so the ratios hold).
Eruption/island growth — `volcanoBuildStep` (130 m/pulse) + `volcanoMaxHeight` (3200 m cap above
base) set how tall a cone/island gets, `volcanoEruptFreq` (0.05 pulses per hour·activity) the
cadence (raise for faster, more frequent eruptions — at a high live-clock rate islands build in
seconds). Eruption FX — `volcanoAshCloud` (0.9, ash cover injected into the weather field per
erupting hour) and `volcanoAshCooling` (6 °C, peak local cooling under an active plume). All build
+ eruption state is a pure function of `liveTime` (PlanetVolcano.cpp); marker sizes/colours are
render constants (ViewerRender.cpp), not config.
- `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -27,6 +27,7 @@ set(SIM_SOURCES
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
@ -69,7 +70,7 @@ if(UNIX AND NOT APPLE)
endif()
enable_testing()
foreach(test_name logic biota ocean live weather)
foreach(test_name logic biota ocean live weather volcano)
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})

View File

@ -226,6 +226,34 @@ can rewind storms past the saved moment. v13 also saves the Live World clock rat
integrated/path-dependent, so reversing it past a save is only possible via this stored history — it
can't be re-derived from the loaded moment.
## Volcanoes & volcanic islands (Live World)
`PlanetVolcano.cpp`. A `Volcano` is a fixed point on the grid (one `cell`), not a moving agent. On
**entering Live World** `placeVolcanoes(liveTime)` seeds a set once, by tectonic context: a cell is
**ridge** if its plate is `baby` or a neighbour's is (the `buildBorders` baby test), else **border**
if a neighbour has a different `plateId`, else **interior**; placement probability is
`volcanoProbRidge``volcanoProbBorder``volcanoProbInterior`, reservoir-sampled to
`volcanoMaxCount` (an unbiased subset, ratios preserved). A separate RNG (`sVolRng = cfg.seed ^
0x70C4F12A`) keeps the tectonic stream untouched. Each vent stores its pre-live `baseElev` + `tStart`.
The crucial design point: **eruption state is a pure function of `liveTime`** (like
insolation/tides/seasons, never an integration). `volcanoBuilt(v,t) = min(maxHeight, floor((ttStart)
·eruptFreq·activity)·buildStep)` (discrete pulses stepping the cone up, monotonic) and
`volcanoErupting(v,t)` is a flare decaying through each pulse cycle. `stepVolcanoes(dt, liveTime)`
(called each frame in `liveAdvance`, after `stepWeather`) just reasserts `cells[v.cell].elevation =
baseElev + built` — safe & complete because in Live World nothing else moves elevation. A submarine
vent crossing sea level **breaches** into an island (`oceanic=false`, `biome=Beach`, viewer
`refreshView`s); crossing back down (on a step back) re-submerges it. Because the state is derived
from `liveTime`, the **live stepper rewinds islands & eruptions for free** — no per-cell snapshot, no
volcano undo history. The only integrated side-effect is the **ash plume**: an eruption adds cloud to
`sCloud`/`sHumidity` (drifts downwind via the weather cycle) + subtracts `volcanoAshCooling` from
`sLiveTemp` at the vent — the cloud reverts via the existing weather snapshot, the cooling is itself
re-derived each frame. Rendered as a cone (taller/redder as it builds) + an orange glow/ash-plume
flare when erupting (3D `DrawCylinderEx` inside the tilt matrix; 2D `DrawPoly` triangle), key `V`.
**Saved v14**: the placed `Volcano` set + `sVolRng` in `writeState`/`readState` (flag-gated like
moons/weather); pre-v14 saves load with none and place them on the next Live World entry. Knobs:
`volcano*`.
## Live World viewer controls (follow-cam, 2D zoom, clock stepper)
Three viewer-only controls over the Live World sim:

View File

@ -75,6 +75,16 @@ static std::vector<std::string> cellInfo(const Planet& p, int i, double elev, do
L.push_back(std::string(TextFormat("river: discharge %.0f", p.discharge()[i])));
if (sized(p.lakeDepth()) && p.lakeDepth()[i] > p.cfg.biomeLakeMinDepth && elev > p.cfg.seaLevel)
L.push_back(std::string(TextFormat("lake: depth %.0f m", p.lakeDepth()[i])));
// Volcano (Live World): built height = current elevation above the captured baseElev
// (eruption flares are shown by the marker; the live clock isn't available here).
for (const Volcano& vc : p.volcanoes) {
if (vc.cell != i) continue;
const char* kn = vc.kind == 0 ? "ridge" : vc.kind == 1 ? "border" : "hotspot";
double built = c.elevation - vc.baseElev; if (built < 0.0) built = 0.0;
L.push_back(std::string(TextFormat("volcano: %s activity %.0f%% +%.0f m built",
kn, vc.activity * 100.0, built)));
break;
}
// Biota: density scalars (present after computeBiotaDensity()) + the discrete
// population list (present once generateBiota()/L has run).
if (sized(p.floraDensity()) && sized(p.faunaDensity()) && sized(p.fungaDensity()))

View File

@ -295,7 +295,7 @@ void Viewer::loadGame(const char* path) {
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 volcanoes
cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift
@ -344,6 +344,9 @@ void Viewer::loadGame(const char* path) {
if (!historyOk) { wxUndo.clear(); skippedHistory = true; }
}
paused = true; selectedCell = -1; subgrids.clear();
// Pre-v14 save already in Live World: it has no volcano block, so place a set now (v14+ saves
// restore their own). A non-live save places them when the user first presses W.
if (liveWorld && planet.volcanoes.empty()) planet.placeVolcanoes(liveTime);
buildBorders(planet, borderR, borders, ridgeBorders);
buildDriftArrows(planet, driftR, driftArrows, plateLabels);
buildMap2D(planet, mapRect, map2D);
@ -416,6 +419,12 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
moonNormals.push_back(Vector3{ (float)mn.x, (float)mn.y, (float)mn.z });
}
planet.stepWeather(dtWeather);
// Volcanoes: reassert vent elevations = base + built(liveTime) (pure function of the clock, so a
// backward step rewinds island growth), and inject ash cloud + local cooling. A grown/shrunk cone
// needs a recolor; a sea-level breach needs a biome reclassify (refreshView).
VolcanoUpdate vu = planet.stepVolcanoes(dtWeather, liveTime);
if (vu.breach) refreshView();
else if (vu.recolor) recolor();
rebuildLiveOverlay();
}

View File

@ -15,7 +15,7 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer {
// ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 13; // 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 = 14; // v14: +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
const char* CONFIG_PATH = "planet.cfg";
const char* SAVE_PATH = "planet.save";
@ -97,6 +97,7 @@ struct Viewer {
std::vector<Vector3> currentSegs; std::vector<Color> currentCols; // ocean-current arrows
bool showCurrents = false; // ocean current arrows, warm/cold (key O)
bool showClouds = true; // Live World cloud/rain cover overlay (key K)
bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V)
// Selection + subgrid (phase 4/5 preview).
int selectedCell = -1;

View File

@ -173,6 +173,7 @@ void Viewer::handleInput() {
liveWorld = !liveWorld;
if (liveWorld) {
phase3Prompt = false; paused = false; wxUndo.clear();
if (planet.volcanoes.empty()) planet.placeVolcanoes(liveTime); // one-time tectonic-context placement
refreshView(); // fresh base colours; overlay builds in stepSim
setStatus("Live World started");
} else {
@ -201,6 +202,7 @@ void Viewer::handleInput() {
if (IsKeyPressed(KEY_T)) showTides = !showTides; // toggle tide-coloured coastline
if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows
if (IsKeyPressed(KEY_K)) showClouds = !showClouds; // toggle weather cloud/rain cover
if (IsKeyPressed(KEY_V)) showVolcanoes = !showVolcanoes; // toggle volcano markers (Live World)
if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); }
if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); }
if (IsKeyPressed(KEY_S)) { // one step

View File

@ -159,6 +159,35 @@ void Viewer::renderGlobe3D() {
if (hur) { Vec3 e = p * (double)SR; DrawSphere(Vector3{(float)e.x,(float)e.y,(float)e.z}, 0.02f, Color{255,240,200,255}); }
}
}
// Live World volcano markers: a small cone at each vent (taller + redder as it builds), with an
// orange eruption glow + radial ash-plume flare when erupting -- additive so it reads on the
// night side too. Inside the tilted matrix, so it tracks the leaning globe.
if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) {
const double maxH = std::max(1.0, planet.cfg.volcanoMaxHeight);
for (const Volcano& vc : planet.volcanoes) {
if (vc.cell < 0 || vc.cell >= (int)planet.cells.size()) continue;
const Cell& c = planet.cells[vc.cell];
Vec3 u = c.unit;
float r = visBase + (float)c.elevation * elevExagg;
double bf = std::clamp(planet.volcanoBuilt(vc, liveTime) / maxH, 0.0, 1.0);
double er = planet.volcanoErupting(vc, liveTime);
float coneH = 0.022f + 0.045f * (float)bf;
float coneR = 0.015f + 0.018f * (float)bf;
Vector3 b { (float)(u.x * r), (float)(u.y * r), (float)(u.z * r) };
Vector3 apex{ (float)(u.x * (r + coneH)), (float)(u.y * (r + coneH)), (float)(u.z * (r + coneH)) };
Color cone{ (unsigned char)(110 + 100 * er), (unsigned char)(70 - 20 * er), (unsigned char)(55 - 15 * er), 255 };
DrawCylinderEx(b, apex, coneR, coneR * 0.25f, 8, cone);
if (er > 0.12) {
unsigned char a = (unsigned char)std::clamp(60.0 + 195.0 * er, 0.0, 255.0);
DrawSphere(apex, 0.02f + 0.05f * (float)er, Color{255, 140, 40, a});
float ph = coneH + 0.12f * (float)er;
Vector3 top{ (float)(u.x * (r + ph)), (float)(u.y * (r + ph)), (float)(u.z * (r + ph)) };
rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlColor4ub(255, 170, 70, a);
rlVertex3f(apex.x, apex.y, apex.z); rlVertex3f(top.x, top.y, top.z);
rlEnd(); rlSetLineWidth(1.0f);
}
}
}
if (showGrat) drawGraticule3D(graticule, gratR);
// Markers: selected (orange), hovered cell (yellow), hovered subcell (white).
if (selectedCell >= 0) {
@ -265,6 +294,20 @@ void Viewer::renderMap2D() {
DrawCircleV(sp, 2.0f, c);
}
}
if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) {
for (const Volcano& vc : planet.volcanoes) {
if (vc.cell < 0 || vc.cell >= (int)planet.cells.size()) continue;
double er = planet.volcanoErupting(vc, liveTime);
double lon, lat; dirToLonLat(planet.cells[vc.cell].unit, lon, lat);
Vector2 sp = projLonLat(lon, lat, mapLon, vr);
float s = (5.0f + 3.0f * (float)er) * (float)std::min(2.0, mapZoom);
Color tri = er > 0.12 ? Color{235, 110, 40, 255} : Color{150, 75, 55, 255};
DrawPoly(sp, 3, s, -90.0f, tri); // filled up-pointing triangle (cone)
if (er > 0.12)
DrawCircleLines((int)sp.x, (int)sp.y, s + 3.0f,
Color{255, 170, 70, (unsigned char)std::clamp(90.0 + 150.0 * er, 0.0, 255.0)});
}
}
if (phase3 && showRivers) {
drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, vr, mapLon);
drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, vr, mapLon);
@ -469,8 +512,8 @@ void Viewer::renderHUD() {
y += 8;
line("hover: cell info | click tile: open detail panel | C close");
line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)");
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off"));
line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s] | V volcanoes [%s]",
showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off", showVolcanoes ? "on" : "off"));
line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-",
phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off"));
line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg");

View File

@ -14,7 +14,8 @@ public:
PlanetConfig cfg;
std::vector<Cell> cells;
std::vector<Plate> plates;
std::vector<Moon> moons; // Live World: 1-3 natural satellites (generated + saved)
std::vector<Moon> moons; // Live World: 1-3 natural satellites (generated + saved)
std::vector<Volcano> volcanoes; // Live World: volcanoes placed on entry by tectonic context (saved v14)
// Phase flag: false during Phase-1 forming (modest, original tectonics that
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
@ -105,6 +106,18 @@ public:
WeatherSnapshot captureWeather() const;
void restoreWeather(const WeatherSnapshot& s);
// Volcanoes (Live World, PlanetVolcano.cpp). placeVolcanoes() runs once on entering Live World:
// it seeds a set by tectonic context (high on young ridges, medium on borders, low elsewhere)
// from a separate RNG (tectonic determinism intact), capturing each vent's baseElev. stepVolcanoes()
// runs each live frame: it reasserts each vent's cell elevation = baseElev + built(liveTime) (build
// + eruption intensity are PURE FUNCTIONS of liveTime, so the stepper rewinds them), breaches
// submarine vents into islands, and injects ash cloud + local cooling into the weather/climate
// fields. Saved (v14). volcanoErupting(i)/volcanoBuilt(i) report a vent's current state for rendering.
void placeVolcanoes(double liveTime = 0.0);
VolcanoUpdate stepVolcanoes(double dtHours, double liveTime);
double volcanoBuilt(const Volcano& v, double liveTime) const; // m built above baseElev at liveTime
double volcanoErupting(const Volcano& v, double liveTime) const; // 0..1 current eruption intensity
// Phase 3 (biomes): classify every cell into a Biome from elevation + the climate
// fields (temperature + normalized precipitation). Derived + written back into
// cell.biome (saved). Assumes computeClimate() ran this tick. Re-run as terrain evolves.
@ -142,8 +155,11 @@ public:
// hasMoons: whether the stream carries the moons block (save v9+); older saves
// synthesize moons from the seed instead. hasWeather: the weather block (save v10+);
// older saves leave weather to spin up on entering Live World.
// hasVolcanoes: whether the stream carries the volcano block (save v14+); older saves load
// with no volcanoes (they are placed on the next Live World entry).
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true);
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true,
bool hasVolcanoes = true);
// Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing
@ -226,6 +242,9 @@ private:
std::vector<WeatherSystem> sStorms;
uint32_t sWeatherRng = 1;
uint32_t sStormNextId = 1; // monotonic id for follow-cam tracking
// Volcanoes (Live World; saved v14). Separate RNG (seeded from cfg.seed in placeVolcanoes)
// keeps tectonic determinism intact; eruptions are pure functions of liveTime (no per-step RNG).
uint32_t sVolRng = 1;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the
// on-demand discrete population (saved). sHasBiota latches once generated/loaded.

View File

@ -42,9 +42,13 @@
D(weatherCloudDissip) \
D(weatherSpawnRate) D(weatherSystemSpeed) D(weatherTropicalSST) D(weatherSystemRadius) \
D(weatherSystemCloud) D(weatherSystemRain) D(weatherHurricaneStr) \
D(volcanoProbRidge) D(volcanoProbBorder) D(volcanoProbInterior) \
D(volcanoBuildStep) D(volcanoMaxHeight) D(volcanoEruptFreq) \
D(volcanoAshCloud) D(volcanoAshCooling) \
I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
I(volcanoMaxCount) \
I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \
I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \
U(seed)
@ -215,6 +219,14 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.weatherSystemCloud, 0.0, 20.0, "weatherSystemCloud"));
E(rng(cfg.weatherSystemRain, 0.0, 20.0, "weatherSystemRain"));
E(rng(cfg.weatherHurricaneStr, 0.0, 1.0, "weatherHurricaneStr"));
E(rng(cfg.volcanoProbRidge, 0.0, 1.0, "volcanoProbRidge"));
E(rng(cfg.volcanoProbBorder, 0.0, 1.0, "volcanoProbBorder"));
E(rng(cfg.volcanoProbInterior, 0.0, 1.0, "volcanoProbInterior"));
E(rng(cfg.volcanoBuildStep, 0.0, 5000.0, "volcanoBuildStep"));
E(rng(cfg.volcanoMaxHeight, 0.0, 12000.0, "volcanoMaxHeight"));
E(rng(cfg.volcanoEruptFreq, 0.0, 100.0, "volcanoEruptFreq"));
E(rng(cfg.volcanoAshCloud, 0.0, 10.0, "volcanoAshCloud"));
E(rng(cfg.volcanoAshCooling, 0.0, 40.0, "volcanoAshCooling"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions"));
E(irng(cfg.plateCount, 1, 100, "plateCount"));
E(irng(cfg.beltWidth, 1, 12, "beltWidth"));
@ -228,6 +240,7 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(irng(cfg.climateMoistureSmooth, 0, 100, "climateMoistureSmooth"));
E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings"));
E(irng(cfg.weatherSystemMax, 0, 1000, "weatherSystemMax"));
E(irng(cfg.volcanoMaxCount, 0, 100000, "volcanoMaxCount"));
E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots"));
E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots"));
E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots"));
@ -321,14 +334,19 @@ void Planet::writeState(std::ostream& os) const {
writeVec(os, sHumidity); writeVec(os, sCloud); writeVec(os, sRain);
writeVec(os, sStorms); writePod(os, sWeatherRng); writePod(os, sStormNextId); // v11
}
// v14: Live World volcanoes (placed by tectonic context on entry; eruption state is a pure
// function of liveTime, so only the placed set + its RNG need saving). Always written from v14;
// older readers stop before this block.
writeVec(os, volcanoes);
writePod(os, sVolRng);
}
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms) {
bool hasWeather, bool hasStorms, bool hasVolcanoes) {
// 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; }
if (!hasWeather) hasStorms = false;
if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; }
if (!hasWeather) { hasStorms = false; hasVolcanoes = false; } // volcano block follows the weather 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
@ -432,6 +450,17 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM
}
}
}
// v14: Live World volcanoes. Older saves load with none (placed on next Live World entry).
volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au;
if (hasVolcanoes) {
if (!readVec(is, volcanoes, 100000)) return false;
readPod(is, sVolRng);
if (!is) return false;
const int nc2 = (int)cells.size();
for (const Volcano& v : volcanoes)
if (v.cell < 0 || v.cell >= nc2 || !std::isfinite(v.activity)
|| !std::isfinite(v.baseElev) || !std::isfinite(v.tStart)) return false;
}
computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is;
}

View File

@ -64,6 +64,26 @@ struct Moon {
double dispRadius = 0.10; // display sphere radius (visual size)
};
// A volcano (Live World): a fixed point on the grid (one cell) placed by tectonic context when the
// world enters Live World -- high probability on young spreading ridges ("new plate"), medium on
// plate borders, low elsewhere (hotspots). Over the live clock it erupts; submarine ones build their
// cell up into new islands. Eruption state is a PURE FUNCTION of liveTime (built height + intensity
// recomputed each frame, never integrated) so the live stepper rewinds it for free. Saved (v14).
struct Volcano {
uint32_t id = 0; // stable id (markers / cell-info)
int cell = -1; // the grid cell it sits on (fixed geometry)
uint8_t kind = 2; // 0 = ridge (new plate), 1 = plate border, 2 = hotspot/interior
uint8_t submarine = 0; // 1 if its baseElev is below sea level (can build an island)
double activity = 0.5; // 0..1 eruption vigour (drives cadence + build rate)
double baseElev = 0.0; // m: cell elevation captured at placement (build adds on top)
double tStart = 0.0; // liveTime (h) at placement; built height is f(liveTime - tStart)
};
// Result of one stepVolcanoes() call, telling the viewer how much of the view to rebuild:
// `recolor` if any vent's cell elevation changed (cone/island grew/shrank), `breach` if a
// submarine vent crossed sea level (a new island/sunk island -> needs biome reclassification).
struct VolcanoUpdate { bool recolor = false; bool breach = false; };
// A full snapshot of the (integrated, non-analytic) weather state, for the viewer's step-back
// undo history -- weather can't be reversed in closed form, so we restore a saved frame instead.
struct WeatherSnapshot {
@ -309,4 +329,18 @@ struct PlanetConfig {
double weatherSystemCloud = 1.2; // /h: cloud stamped at a system's core (scaled by strength)
double weatherSystemRain = 1.6; // /h: rain intensity at a system's core
double weatherHurricaneStr= 0.6; // strength above which a tropical system is a hurricane/typhoon
// --- Volcanoes (Live World) -- see PlanetVolcano.cpp ------------------------
// Placed once on entering Live World by tectonic context, then erupt on the live clock
// (build height + eruption intensity are pure functions of liveTime, so step-back reverses
// them). Submarine volcanoes build up to breach sea level into new volcanic islands.
double volcanoProbRidge = 0.55; // per-cell placement prob on a young spreading-ridge cell
double volcanoProbBorder = 0.06; // per-cell placement prob on a normal plate-border cell
double volcanoProbInterior = 0.003; // per-cell placement prob elsewhere (intraplate hotspots)
int volcanoMaxCount = 60; // global cap on placed volcanoes
double volcanoBuildStep = 130.0; // m of cone/island growth per eruption pulse
double volcanoMaxHeight = 3200.0;// m: max height a volcano builds above its baseElev
double volcanoEruptFreq = 0.05; // eruption pulses per (hour * activity) -- cadence
double volcanoAshCloud = 0.9; // cloud cover injected at the vent per erupting hour (ash plume)
double volcanoAshCooling = 6.0; // C: peak local cooling under an active ash plume
};

121
src/sim/PlanetVolcano.cpp Normal file
View File

@ -0,0 +1,121 @@
#include "Planet.hpp"
#include <algorithm>
#include <cmath>
// --- Volcanoes (Live World) --------------------------------------------------
// A volcano is a fixed point on the grid (one cell), placed ONCE when the world
// enters Live World by tectonic context: very high probability on young spreading
// ridges ("new plate" / baby plates), medium on normal plate borders, low elsewhere
// (intraplate hotspots). Over the live clock it erupts; submarine vents build their
// cell up until it breaches sea level into a new volcanic island.
//
// Determinism note: placement draws a SEPARATE RNG (sVolRng, seeded from cfg.seed)
// so it never perturbs the tectonic stream. The eruption state -- built height and
// eruption intensity -- is a PURE FUNCTION of liveTime (no integration, no per-step
// RNG), exactly like insolation/tides/seasons (PlanetLive.cpp). That is what lets the
// live stepper rewind volcanoes for free: a smaller liveTime recomputes a smaller
// island and un-does eruptions; the ash already mixed into the (integrated) weather
// field reverts via the existing weather snapshot.
// Built height (m above baseElev) at a clock time. Discrete eruption "pulses" step the
// cone up, capped at volcanoMaxHeight -- monotonic and a pure function of liveTime.
double Planet::volcanoBuilt(const Volcano& v, double liveTime) const {
double age = liveTime - v.tStart;
if (age <= 0.0) return 0.0;
double pulses = std::floor(age * cfg.volcanoEruptFreq * std::max(0.0, v.activity));
return std::min(cfg.volcanoMaxHeight, pulses * cfg.volcanoBuildStep);
}
// Eruption intensity (0..1) at a clock time: a flare right after each pulse boundary,
// decaying through the cycle -- so a vent mostly smoulders and briefly erupts. Pure
// function of liveTime.
double Planet::volcanoErupting(const Volcano& v, double liveTime) const {
double age = liveTime - v.tStart;
if (age < 0.0) return 0.0;
double prog = age * cfg.volcanoEruptFreq * std::max(0.0, v.activity);
double frac = prog - std::floor(prog); // 0 just after a pulse .. 1 just before the next
return std::exp(-frac * 4.0); // ~1 at frac 0, ~0.13 at frac 0.5
}
// Place the volcano set by tectonic context. Reservoir-sampled to volcanoMaxCount so the
// kept set is an unbiased random subset of all cells that pass their context probability.
void Planet::placeVolcanoes(double liveTime) {
volcanoes.clear();
sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au;
auto next = [&]() { sVolRng ^= sVolRng << 13; sVolRng ^= sVolRng >> 17; sVolRng ^= sVolRng << 5; return sVolRng; };
auto rf = [&]() { return (next() & 0xFFFFFFu) / double(0x1000000); };
const int n = (int)cells.size();
const double sea = cfg.seaLevel;
const int cap = std::max(0, cfg.volcanoMaxCount);
auto isBaby = [&](int pid) { return pid >= 0 && pid < (int)plates.size() && plates[pid].baby; };
int passed = 0;
for (int i = 0; i < n; ++i) {
int pid = cells[i].plateId;
bool ridge = isBaby(pid), border = false;
for (int j : cells[i].neighbors) {
int pj = cells[j].plateId;
if (pj != pid) border = true;
if (isBaby(pj)) ridge = true;
}
int kind; double prob;
if (ridge) { kind = 0; prob = cfg.volcanoProbRidge; }
else if (border) { kind = 1; prob = cfg.volcanoProbBorder; }
else { kind = 2; prob = cfg.volcanoProbInterior; }
if (rf() >= prob) continue;
Volcano v;
v.cell = i;
v.kind = (uint8_t)kind;
v.submarine = (cells[i].elevation <= sea) ? 1 : 0;
v.baseElev = cells[i].elevation;
v.tStart = liveTime;
double base = (kind == 0) ? 0.70 : (kind == 1) ? 0.50 : 0.35; // ridges more vigorous
v.activity = std::clamp(base + (rf() - 0.5) * 0.4, 0.05, 1.0);
++passed;
if ((int)volcanoes.size() < cap) volcanoes.push_back(v);
else if (cap > 0) { uint32_t r = next() % (uint32_t)passed; if ((int)r < cap) volcanoes[r] = v; }
}
for (int k = 0; k < (int)volcanoes.size(); ++k) volcanoes[k].id = (uint32_t)(k + 1);
}
// One live-frame volcano update. Reasserts each vent's cell elevation = baseElev +
// built(liveTime) (in Live World nothing else moves elevation, so this is safe &
// complete), breaches submarine vents into islands (and un-breaches them on a step
// back), and injects ash cloud + local cooling into the weather/climate fields.
VolcanoUpdate Planet::stepVolcanoes(double dtHours, double liveTime) {
VolcanoUpdate up;
if (volcanoes.empty()) return up;
const int n = (int)cells.size();
const double sea = cfg.seaLevel;
for (Volcano& v : volcanoes) {
if (v.cell < 0 || v.cell >= n) continue;
double newElev = v.baseElev + volcanoBuilt(v, liveTime);
double& e = cells[v.cell].elevation;
if (std::fabs(newElev - e) > 0.5) up.recolor = true;
e = newElev;
// Submarine vent crossing sea level -> a new island (or, on a step back, re-submerged).
if (v.submarine) {
bool land = newElev > sea;
if (land && cells[v.cell].oceanic) {
cells[v.cell].oceanic = false; cells[v.cell].biome = Biome::Beach; up.breach = true;
} else if (!land && !cells[v.cell].oceanic) {
cells[v.cell].oceanic = true; cells[v.cell].biome = Biome::Ocean; up.breach = true;
}
}
double intensity = volcanoErupting(v, liveTime);
if (intensity > 0.05) {
// Ash plume -> the integrated weather field (advects downwind, reverts on step-back via
// the weather snapshot). Only on a forward step (dtHours > 0).
if (dtHours > 0.0 && !sCloud.empty()) {
int c = v.cell;
sCloud[c] = std::min(2.0, sCloud[c] + cfg.volcanoAshCloud * intensity * dtHours);
if (!sHumidity.empty())
sHumidity[c] = std::min(2.0, sHumidity[c] + 0.3 * cfg.volcanoAshCloud * intensity * dtHours);
}
// Local cooling under the plume: sLiveTemp is re-derived each frame, so subtracting here
// is itself a pure function of liveTime (consistent forward and backward).
if (!sLiveTemp.empty())
sLiveTemp[v.cell] -= cfg.volcanoAshCooling * intensity;
}
}
return up;
}

163
test_volcano.cpp Normal file
View File

@ -0,0 +1,163 @@
// Headless test for Live World volcanoes (placement by tectonic context + eruption / island
// building). No display needed.
//
// g++ -std=c++17 -O2 -Isrc/sim test_volcano.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/PlanetIO.cpp -o /tmp/tv && /tmp/tv
//
// Verifies: placement is deterministic + isolated from the tectonic RNG; the context classification
// (ridge / border / interior) drives where vents land and respects the probabilities; a submarine
// vent's built height is a monotonic PURE FUNCTION of liveTime that breaches sea level into an island
// and recedes when the clock steps back; and an eruption injects ash cloud at the vent.
#include "Planet.hpp"
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <vector>
#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); }
}
// Replicate placeVolcanoes()'s context classification: 0 = ridge (baby plate / neighbour baby),
// 1 = plate border (a differing-plate neighbour), 2 = interior.
static int classify(const Planet& p, int i) {
auto isBaby = [&](int pid) { return pid >= 0 && pid < (int)p.plates.size() && p.plates[pid].baby; };
int pid = p.cells[i].plateId;
bool ridge = isBaby(pid), border = false;
for (int j : p.cells[i].neighbors) { int pj = p.cells[j].plateId; if (pj != pid) border = true; if (isBaby(pj)) ridge = true; }
return ridge ? 0 : (border ? 1 : 2);
}
static bool sameVolcanoes(const std::vector<Volcano>& a, const std::vector<Volcano>& b) {
if (a.size() != b.size()) return false;
for (size_t i = 0; i < a.size(); ++i)
if (a[i].id != b[i].id || a[i].cell != b[i].cell || a[i].kind != b[i].kind
|| a[i].submarine != b[i].submarine || a[i].activity != b[i].activity
|| a[i].baseElev != b[i].baseElev || a[i].tStart != b[i].tStart) return false;
return true;
}
int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 9090;
Planet p; p.generate(cfg); settle(p); drift(p, 120);
const int n = (int)p.cells.size();
std::printf("Volcanoes: determinism\n");
p.placeVolcanoes(0.0); std::vector<Volcano> first = p.volcanoes;
p.placeVolcanoes(0.0);
check(!first.empty(), "placeVolcanoes places a non-empty set");
check(sameVolcanoes(first, p.volcanoes), "placeVolcanoes is deterministic (re-run identical)");
std::printf("Volcanoes: RNG isolation from tectonics\n");
Planet a; a.generate(cfg); settle(a);
Planet b; b.generate(cfg); settle(b);
for (int k = 0; k < 40; ++k) {
double dta = a.cflDtMy(); a.advect(dta); a.step(); a.erode(dta);
double dtb = b.cflDtMy(); b.advect(dtb); b.step(); b.erode(dtb);
if (k == 20) b.placeVolcanoes(0.0); // must not touch the tectonic RNG stream
}
bool terrainSame = true;
for (int i = 0; i < n; ++i) if (std::fabs(a.cells[i].elevation - b.cells[i].elevation) > 1e-9) terrainSame = false;
check(terrainSame, "placeVolcanoes never perturbs tectonic evolution");
std::printf("Volcanoes: context classification\n");
int eligRidge = 0, eligBorder = 0, eligInterior = 0;
for (int i = 0; i < n; ++i) { int k = classify(p, i); if (k == 0) ++eligRidge; else if (k == 1) ++eligBorder; else ++eligInterior; }
std::printf(" eligible cells: ridge %d, border %d, interior %d\n", eligRidge, eligBorder, eligInterior);
// probs ridge=border=1, interior=0, no cap -> exactly the ridge+border cells, none interior.
p.cfg.volcanoProbRidge = 1.0; p.cfg.volcanoProbBorder = 1.0; p.cfg.volcanoProbInterior = 0.0;
p.cfg.volcanoMaxCount = 1000000;
p.placeVolcanoes(0.0);
bool noInterior = true; for (const Volcano& v : p.volcanoes) if (v.kind == 2) noInterior = false;
check(noInterior, "interior prob 0 places no interior vents");
check((int)p.volcanoes.size() == eligRidge + eligBorder, "prob 1 fills exactly the ridge+border cells");
std::printf("Volcanoes: probability ordering (border > interior)\n");
p.cfg.volcanoProbRidge = 1.0; p.cfg.volcanoProbBorder = 0.30; p.cfg.volcanoProbInterior = 0.05;
p.placeVolcanoes(0.0);
int gotRidge = 0, gotBorder = 0, gotInterior = 0;
for (const Volcano& v : p.volcanoes) { if (v.kind == 0) ++gotRidge; else if (v.kind == 1) ++gotBorder; else ++gotInterior; }
double rB = eligBorder ? (double)gotBorder / eligBorder : 0.0;
double rI = eligInterior ? (double)gotInterior / eligInterior : 0.0;
std::printf(" placement rate: border %.3f, interior %.3f\n", rB, rI);
check(rB > rI, "border cells are far likelier to host a volcano than interior cells");
if (eligRidge > 0) {
double rR = (double)gotRidge / eligRidge;
std::printf(" placement rate: ridge %.3f\n", rR);
check(rR >= rB, "young-ridge cells are the likeliest of all");
} else std::printf(" (no young-ridge cells this seed -- ridge rate not asserted)\n");
std::printf("Volcanoes: build is a pure function of liveTime; submarine vent breaches into an island\n");
Planet q; q.generate(cfg); settle(q); drift(q, 120);
q.cfg.volcanoMaxHeight = 9000.0; q.cfg.volcanoBuildStep = 130.0; q.cfg.volcanoEruptFreq = 0.05;
q.placeVolcanoes(0.0);
int vi = -1; double best = -1e18;
for (size_t k = 0; k < q.volcanoes.size(); ++k)
if (q.volcanoes[k].submarine && q.volcanoes[k].baseElev > best) { best = q.volcanoes[k].baseElev; vi = (int)k; }
check(vi >= 0, "at least one submarine volcano was placed");
if (vi >= 0) {
const Volcano v = q.volcanoes[vi];
double b0 = q.volcanoBuilt(v, 0.0), b1 = q.volcanoBuilt(v, 5000.0),
b2 = q.volcanoBuilt(v, 50000.0), b3 = q.volcanoBuilt(v, 500000.0);
check(b0 <= b1 && b1 <= b2 && b2 <= b3, "built height is monotonic in liveTime");
check(b3 > b0, "a submarine vent builds up over time");
check(q.volcanoBuilt(v, 5000.0) == b1, "volcanoBuilt is deterministic (pure function of t)");
q.stepVolcanoes(1.0, 500000.0);
check(q.cells[v.cell].elevation > q.cfg.seaLevel, "submarine volcano breaches sea level into an island");
check(!q.cells[v.cell].oceanic, "the breached island is land crust");
// Step the clock back to the start: the island must recede (pure function of liveTime).
q.stepVolcanoes(0.0, 0.0);
check(q.cells[v.cell].elevation <= q.cfg.seaLevel + 1e-6, "stepping the clock back recedes the island");
check(std::fabs(q.cells[v.cell].elevation - (v.baseElev + q.volcanoBuilt(v, 0.0))) < 1e-6,
"vent elevation = baseElev + built(liveTime)");
}
std::printf("Volcanoes: an eruption injects ash cloud\n");
Planet w; w.generate(cfg); settle(w);
w.initWeather();
w.computeInsolation(0.25, 0.3);
w.placeVolcanoes(0.0); // tStart = 0 -> at liveTime 0 every vent is at peak eruption intensity
check(!w.volcanoes.empty(), "volcanoes placed for the ash test");
if (!w.volcanoes.empty()) {
std::vector<double> before = w.cloud();
w.stepVolcanoes(1.0, 0.0); // dtHours > 0 -> inject ash
bool rose = false;
for (const Volcano& vv : w.volcanoes)
if (w.cloud()[vv.cell] > before[vv.cell] + 1e-9) rose = true;
check(rose, "an erupting vent thickens the cloud at its cell");
}
std::printf("Volcanoes: save v14 round-trip\n");
{
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
q.writeState(ss);
Planet r;
bool ok = r.readState(ss, true, true, true, true, true, true);
check(ok, "readState accepts a v14 stream");
check(sameVolcanoes(q.volcanoes, r.volcanoes), "volcano set round-trips through save");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL VOLCANO CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}