Add stateful volcano lifecycle and event log

This commit is contained in:
Jonas Reith 2026-06-29 14:35:51 +02:00
parent 275511713c
commit 0c8dbed3d7
16 changed files with 845 additions and 290 deletions

View File

@ -77,9 +77,10 @@ CLI flags (applied before the first load/generate):
planet.cfg human-editable key=value config of every PlanetConfig parameter;
auto-created on first run, reload live with F2. Range-checked on
load; an invalid file reverts to safe defaults (not overwritten).
planet.save binary snapshot (versioned, currently v13: +Live World clock rate; v12
+step-back history (~40 frames, so a load can rewind storms); v11 +weather
systems/storms; v10 +weather fields; v9 +moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5
planet.save binary snapshot (versioned, currently v16: +event log; v15 +stateful
volcanoes; v13 +Live World clock rate; v12 +step-back history
(~40 frames, so a load can rewind storms); v11 +weather systems/storms;
v10 +weather fields; v9 +moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5
writes it, F9 reloads and resumes deterministically. As of v6
the config is stored as a self-describing key=value block (like
planet.cfg), so adding/removing config fields no longer breaks saves
@ -230,18 +231,34 @@ 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
Live info tabs (Live World): the panel beside the 2D map has Sky, Tides, Weather and Events.
Events are a saved, capped journal (newest 200) for storm genesis/intensification, volcano
dormancy, eruptions and volcanic-island breaches; clicking an event selects and centres its cell.
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.
then integrated as stateful lifecycle agents: grow, go dormant, explode, puff ash, and regrow
weaker. Step-back snapshots include volcano state. Saved v15+.
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
volcanoBuildRate 0.02 m/h of growth at activity=1 while growing
volcanoFreeHeight 1000 m absolute height below which vents cannot go dormant
volcanoInitialBuildMax 2500 m max pre-built height when Live World starts
volcanoMaxHeight 3200 m built height where dormancy becomes certain
volcanoDormancyRate 1.0 per-year dormancy hazard scale above free height
volcanoDormantMinYears 120 minimum dormancy before explosion
volcanoDormantMaxYears 1200 maximum dormancy before explosion
volcanoExplodeDropFrac 0.20 fraction of built height shaved by explosion
volcanoActivityDecay 0.70 activity multiplier after each explosion
volcanoDeadActivity 0.05 growth stops at/below this activity
volcanoBlastRadius 0.09 radian radius of the instant explosion ash blast
volcanoBlastCloud 1.5 cloud cover added inside the blast
volcanoAshMinYears 0.5 minimum sustained ash emission after explosion
volcanoAshMaxYears 3.0 maximum sustained ash emission after explosion
volcanoAshPuffCellsPerWeek 2.0 average local cells puffed per week
volcanoAshCloud 0.9 cloud cover injected by sustained ash
volcanoAshCooling 6 C peak local cooling under an active ash plume
## Headless logic test (no display)
@ -256,7 +273,8 @@ function of liveTime, so the stepper rewinds it). Saved v14.
src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# 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
# test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp or test_volcano.cpp.
# The CMake build also includes test_events for the viewer event journal.
Verifies geometry, plate assignment, gradual non-saturating relief and
determinism. Run after changing Planet::step().

View File

@ -98,13 +98,12 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it.
- **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*`.
plates), **medium** on normal plate borders, **low** elsewhere (hotspots). They are stateful
lifecycle agents: some start pre-built, growing vents can breach submarine cells into volcanic
islands, tall vents can go dormant, dormant vents explode and shave their peak, then puff ash while
regrowing weaker. Volcano state + `sVolRng` are captured in the Live World step-back snapshot, so
`,`/`.` reverses height, dormancy, explosions and ash timers. Rendered as growing, dormant and
post-explosion cone markers (3D + 2D, key `V`); saved (v15). Knobs `volcano*`.
## Current state
@ -441,7 +440,7 @@ Working and verified (logic tested headless):
and dissipate behind the system. A tropical system past `weatherHurricaneStr` is a
hurricane/typhoon. Render: an animated cyclonic **spiral marker** per system (red + eye for
cyclones, blue lows; spins with `liveTime`·hemisphere) in 3D + 2D, HUD system/cyclone counts,
and a storm list (basin-named) in the Sky & tides panel — all under `K`. `test_weather.cpp`
and a storm list (basin-named) in the Live info `Weather` tab — all under `K`. `test_weather.cpp`
adds: systems spawn, move between steps, thicken cloud, RNG isolation, determinism.
- **Live World viewer controls — storm follow-cam, 2D map zoom, clock stepper:** (1) **`Y`** cycles
the 3D camera to **follow a storm** (by descending strength, off after the last). Tracked by a
@ -464,6 +463,11 @@ Working and verified (logic tested headless):
(v12)** so a load can rewind storms past the saved moment; a load also drops any stale pre-load
history. With no recorded past (e.g. immediately after a pre-v12 load) `,` rewinds the sky only and
says so. `S` in Live World aliases the forward step.
- **Live World event log:** `liveInfoRect` is a tabbed panel (`Sky`, `Tides`, `Weather`, `Events`).
The viewer-owned event journal is saved in v16, capped to the newest 200 entries, and records storm
formation/intensification plus volcano dormancy, eruptions and island breaches. Clicking an event
selects its cell, releases storm follow-cam, rotates the 3D view to it, and centres the 2D map at
the current zoom.
- Mouse hover (in either view) shows per-cell info. Clicking a tile opens a
right-side detail panel: tile info header + the tile's subgrid drawn as a
flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res
@ -571,7 +575,7 @@ 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` or `test_volcano.cpp` to run the Biota / Live World / Ocean / Weather /
Volcano suites — same source list.)
Volcano 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
the window (the engine lives in `src/sim` and is raylib-free, so it links without
@ -629,10 +633,10 @@ line animate over whatever colour mode is active; the HUD shows a `Year/Day/HH:M
**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). **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,
tectonic context on entry and run a stateful lifecycle — submarine ones can build into new
**volcanic islands**; `V` toggles the cone/eruption markers. `Y` makes the 3D camera **follow a storm** (cycles by strength,
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).
weather/storms/volcano lifecycle via snapshots). Mouse-wheel over the 2D map zooms (drag pans).
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate).
@ -651,14 +655,17 @@ clock — a flag byte + `liveTime`, v9 appends the **moons** block, v10 appends
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, v14
appends the **volcanoes** block — the placed `Volcano` set + its RNG, gated by a flag byte);
appends the old pure-function **volcanoes** block, v15 replaces it with stateful volcano lifecycle
agents plus volcano state in step-back frames, and v16 appends the saved **event journal**;
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; 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
entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with old history skipped;
pre-v16 saves load with an empty event journal.
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),
@ -668,8 +675,9 @@ a one-time break; a length guard makes that fail gracefully.) `writeConfigFields
Layout (1920x1080): left column 70% wide = 3D globe (top, 60% h, RenderTexture
1344x648) + 2D Equal Earth map (bottom, 40% h, **left-aligned**, with the freed space at
its right holding the Live-World **"Sky & tides" panel** — `liveInfoRect`, `renderLiveInfo`:
per-moon phase discs + a selected coastal tile's tidal phase); right column 30% wide = cell
its right holding the Live-World tabbed info panel — `liveInfoRect`, `renderLiveInfo`:
Sky moon phase discs, selected coastal-tile tide phase, active weather systems, and the clickable
saved event journal); right column 30% wide = cell
info (top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the
3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full
screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan).
@ -769,13 +777,15 @@ triangles (plates are fixed in phase 1).
`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.
Lifecycle — `volcanoInitialBuildMax` (2500 m) pre-builds some vents on entry, `volcanoBuildRate`
(0.02 m/h at activity 1) grows active vents, `volcanoFreeHeight` (1000 m absolute) protects deep
vents from dormancy, and `volcanoMaxHeight` (3200 m built) is the soft height where dormancy becomes
certain. Dormancy/explosions — `volcanoDormancyRate` (1/year scale),
`volcanoDormantMinYears`/`MaxYears` (120/1200), `volcanoExplodeDropFrac` (0.20),
`volcanoActivityDecay` (0.70), and `volcanoDeadActivity` (0.05). Ash FX —
`volcanoBlastRadius` (0.09 rad), `volcanoBlastCloud` (1.5), `volcanoAshMinYears`/`MaxYears`
(0.5/3), `volcanoAshPuffCellsPerWeek` (2), `volcanoAshCloud` (0.9), and
`volcanoAshCooling` (6 °C). Marker sizes/colours are render constants (ViewerRender.cpp).
- `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

@ -75,3 +75,11 @@ foreach(test_name logic biota ocean live weather volcano)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name})
endforeach()
add_executable(test_events test_events.cpp ${RENDER_SOURCES})
target_include_directories(test_events PRIVATE src/sim src/render)
target_link_libraries(test_events PRIVATE planetsim_sim raylib)
if(UNIX AND NOT APPLE)
target_link_libraries(test_events PRIVATE m pthread dl)
endif()
add_test(NAME events COMMAND test_events)

View File

@ -228,31 +228,30 @@ 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
`PlanetVolcano.cpp`. A `Volcano` is a fixed point on the grid (one `cell`) and a stateful lifecycle
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`.
0x70C4F12A`) keeps the tectonic stream untouched. Each vent stores `baseElev`, current `built`
height, `phase` (growing/dormant), dormancy and ash timers, ash carry, and decaying `activity`.
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*`.
The crucial design point is now the opposite of the original v14 implementation: **volcanoes are
integrated forward**, not pure functions of `liveTime`. `stepVolcanoes(dt)` grows active vents by
`volcanoBuildRate * activity`, lets tall vents go dormant above `volcanoFreeHeight`, explodes dormant
vents after a long timer, shaves `volcanoExplodeDropFrac` of built height, starts sustained ash
emission, and decays activity so old volcanoes settle. It always reasserts
`cells[v.cell].elevation = baseElev + built`; submarine vents crossing sea level breach into islands
and can re-submerge when a restored snapshot has less built height. Explosions stamp a wide local ash
blast into `sCloud`/`sHumidity` and sustained puffs continue while `ashTimer` runs.
Because this is stochastic state, **step-back snapshots include volcanoes + `sVolRng`** alongside
weather. A backward step restores weather, storms, volcano lifecycle state and RNG, then
`stepVolcanoes(0)` reasserts terrain without advancing. Rendered as growing red/orange cones,
dormant grey quiet cones, and bright post-explosion plume markers (3D + 2D, key `V`). **Saved v15**:
the stateful `Volcano` set + `sVolRng`; v14's old pure-function block is consumed and discarded so
volcanoes reseed on the next Live World entry. Knobs: `volcano*`.
## Live World viewer controls (follow-cam, 2D zoom, clock stepper)
@ -281,6 +280,20 @@ Three viewer-only controls over the Live World sim:
(the ring is bounded, ~one snapshot per real second since the interval scales with `liveRate`). The
restored snapshot includes the storm RNG, so re-stepping forward replays deterministically.
## Live World event journal
`Viewer` owns a saved, bounded event journal (`WorldEvent`, newest 200) shown in the tabbed
`liveInfoRect` panel beside the 2D map (`Sky` / `Tides` / `Weather` / `Events`). It is intentionally
viewer-level state: the sim emits no UI strings, and the log is **not** part of step-back history.
Rewinding restores weather/storms/volcanoes, but the journal remains the observer's record.
Events are detected in `Viewer::liveAdvance` by comparing before/after Live World state: weather
system formation, tropical systems crossing hurricane/typhoon strength, volcano dormancy, dormant
volcano eruptions, and submarine volcanoes breaching into islands. Clicking an event calls
`focusCell`: select/rebuild the cell detail, release storm follow-cam, rotate the 3D camera to the
cell using the same axial-tilt convention as picking, and centre the 2D map at the current zoom.
Save **v16** appends the event log; pre-v16 saves load with an empty journal.
## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run:

View File

@ -75,14 +75,20 @@ 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).
// Volcano (Live World): lifecycle phase and current built height.
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)));
if (vc.ashTimer > 0.0) {
L.push_back(std::string(TextFormat("volcano: %s erupting +%.0f m activity %.0f%%",
kn, vc.built, vc.activity * 100.0)));
} else if (vc.phase == 1) {
L.push_back(std::string(TextFormat("volcano: %s dormant %.0f y +%.0f m",
kn, vc.timer / (24.0 * 365.25), vc.built)));
} else {
L.push_back(std::string(TextFormat("volcano: %s growing +%.0f m activity %.0f%%",
kn, vc.built, vc.activity * 100.0)));
}
break;
}
// Biota: density scalars (present after computeBiotaDensity()) + the discrete

View File

@ -6,6 +6,19 @@
#include <cstring>
#include <fstream>
namespace {
const char* weatherEventName(const WeatherSystem& ws, const Planet& p) {
double lon = 0.0, lat = 0.0;
dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat);
if (ws.tropical && ws.strength >= p.cfg.weatherHurricaneStr)
return (lon > -0.5 && lon < 2.4) ? "Typhoon" : "Hurricane";
return ws.tropical ? "Tropical low" : "Low";
}
int eventSeverityForWeather(const WeatherSystem& ws, const Planet& p) {
return (ws.tropical && ws.strength >= p.cfg.weatherHurricaneStr) ? 2 : (ws.tropical ? 1 : 0);
}
}
bool Viewer::init(int argc, char** argv) {
uint32_t cliSeed = 0; // 0 = no --seed given
for (int a = 1; a < argc; ++a) {
@ -229,7 +242,8 @@ void Viewer::regenWorld() { // after generate(): geometry change
buildMap2D(planet, mapRect, map2D);
selectedCell = -1; subgrids.clear();
settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false;
liveWorld = false; followId = 0; wxUndo.clear(); // reseed/regen drops back to World Creation
liveWorld = false; followId = 0; wxUndo.clear(); events.clear(); nextEventId = 1; // reseed/regen drops back to World Creation
liveInfoTab = 0; eventRowRects.clear(); eventRowIndices.clear();
planet.drifting = false; // Phase 1: original forming behavior
phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy;
rivers.clear(); bigRivers.clear();
@ -249,6 +263,103 @@ void Viewer::pauseAction() { paused = !paused; } // pause/resume forming or dr
void Viewer::setStatus(const std::string& m) { statusMsg = m; statusUntil = GetTime() + 3.0; }
void Viewer::appendEvent(uint8_t kind, uint8_t severity, double timeHours, int cell, uint32_t sourceId,
const std::string& title, const std::string& detail) {
if (cell < 0 || cell >= (int)planet.cells.size()) return;
WorldEvent e;
e.id = nextEventId++;
e.kind = kind;
e.severity = severity;
e.timeHours = timeHours;
e.cell = cell;
e.sourceId = sourceId;
e.title = title;
e.detail = detail;
events.push_back(std::move(e));
if ((int)events.size() > EVENT_LOG_MAX)
events.erase(events.begin(), events.begin() + ((int)events.size() - EVENT_LOG_MAX));
}
void Viewer::detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes) {
auto beforeStorm = [&](uint32_t id) -> const WeatherSystem* {
for (const WeatherSystem& ws : beforeStorms) if (ws.id == id) return &ws;
return nullptr;
};
for (const WeatherSystem& ws : planet.storms()) {
const WeatherSystem* old = beforeStorm(ws.id);
int cell = nearestCell(planet, Vec3{ws.pos.x, ws.pos.y, ws.pos.z});
double lon = 0.0, lat = 0.0; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat);
std::string loc = std::string(TextFormat("%+.0f lat, %+.0f lon", lat * 180.0 / M_PI, lon * 180.0 / M_PI));
if (!old) {
const char* name = weatherEventName(ws, planet);
appendEvent(1, (uint8_t)eventSeverityForWeather(ws, planet), liveTime, cell, ws.id,
std::string(name) + " formed",
std::string(TextFormat("%.0f%% strength, %s", ws.strength * 100.0, loc.c_str())));
} else if (ws.tropical && old->strength < planet.cfg.weatherHurricaneStr
&& ws.strength >= planet.cfg.weatherHurricaneStr) {
const char* name = weatherEventName(ws, planet);
appendEvent(1, 2, liveTime, cell, ws.id,
std::string(name) + " intensified",
std::string(TextFormat("%.0f%% strength, %s", ws.strength * 100.0, loc.c_str())));
}
}
auto beforeVolcano = [&](uint32_t id) -> const Volcano* {
for (const Volcano& v : beforeVolcanoes) if (v.id == id) return &v;
return nullptr;
};
for (const Volcano& v : planet.volcanoes) {
const Volcano* old = beforeVolcano(v.id);
if (!old || v.cell < 0 || v.cell >= (int)planet.cells.size()) continue;
const char* kind = v.kind == 0 ? "Ridge volcano" : v.kind == 1 ? "Border volcano" : "Hotspot volcano";
double oldElev = old->baseElev + old->built;
double newElev = v.baseElev + v.built;
if (old->submarine && oldElev <= planet.cfg.seaLevel && newElev > planet.cfg.seaLevel) {
appendEvent(2, 1, liveTime, v.cell, v.id, "Volcanic island formed",
std::string(TextFormat("%s breached sea level (+%.0f m built)", kind, v.built)));
}
if (old->phase != 1 && v.phase == 1) {
appendEvent(2, 1, liveTime, v.cell, v.id, "Volcano went dormant",
std::string(TextFormat("%s, +%.0f m built", kind, v.built)));
}
if (old->phase == 1 && v.phase == 0 && v.ashTimer > 0.0) {
appendEvent(2, 2, liveTime, v.cell, v.id, "Volcano erupted",
std::string(TextFormat("%s exploded, +%.0f m remains", kind, v.built)));
}
}
}
void Viewer::focusCell(int idx, const std::string& status) {
if (idx < 0 || idx >= (int)planet.cells.size()) return;
selectedCell = idx;
rebuildSub();
followId = 0;
Vec3 wd = rotateZ(planet.cells[idx].unit, planet.cfg.axialTilt);
camPitch = std::clamp((float)std::asin(std::clamp(wd.y, -1.0, 1.0)), -1.5f, 1.5f);
camYaw = (float)std::atan2(wd.x, wd.z);
cam.position = { camDist * cosf(camPitch) * sinf(camYaw),
camDist * sinf(camPitch),
camDist * cosf(camPitch) * cosf(camYaw) };
double lon = map2D.lon.empty() ? 0.0 : map2D.lon[idx];
double lat = map2D.lat.empty() ? 0.0 : map2D.lat[idx];
mapLon = wrapPi(-lon);
if (mapZoom <= 1.0001) {
mapPanX = mapPanY = 0.0;
} else {
double x = 0.0, y = 0.0;
EqualEarth::forward(0.0, lat, x, y);
double hh = EqualEarth::halfHeight();
double h = mapRect.height * mapZoom;
double targetY = mapRect.y + (0.5 - y / hh * 0.5) * h + (mapRect.height - h) * 0.5;
mapPanY = std::clamp(mapRect.y + mapRect.height * 0.5 - targetY,
-(h - mapRect.height) * 0.5, (h - mapRect.height) * 0.5);
double w = mapRect.width * mapZoom;
mapPanX = std::clamp(0.0, -(w - mapRect.width) * 0.5, (w - mapRect.width) * 0.5);
}
if (!status.empty()) setStatus(status);
}
// F5: write seed + config + full planet state. F9: read it back and resume.
void Viewer::saveGame(const char* path) {
std::ofstream os(path, std::ios::binary);
@ -266,7 +377,10 @@ void Viewer::saveGame(const char* path) {
os.write(reinterpret_cast<const char*>(&liveRate), sizeof liveRate); // v13: live clock rate
planet.writeState(os);
// v12: persist the most recent step-back frames so a load can rewind storms past the moment.
// v15: frames also carry stateful volcano agents + their RNG.
auto wD = [&](const std::vector<double>& v){ uint64_t m = v.size(); os.write((char*)&m, 8); if (m) os.write((const char*)v.data(), (std::streamsize)(m * sizeof(double))); };
auto wV = [&](const std::vector<Volcano>& v){ uint64_t m = v.size(); os.write((char*)&m, 8); if (m) os.write((const char*)v.data(), (std::streamsize)(m * sizeof(Volcano))); };
auto wS = [&](const std::string& s){ uint64_t m = s.size(); os.write((char*)&m, 8); if (m) os.write(s.data(), (std::streamsize)m); };
uint32_t hn = (uint32_t)std::min<size_t>(wxUndo.size(), (size_t)wxSaveMax);
os.write((char*)&hn, 4);
for (size_t i = wxUndo.size() - hn; i < wxUndo.size(); ++i) {
@ -276,6 +390,22 @@ void Viewer::saveGame(const char* path) {
uint64_t sc = f.w.storms.size(); os.write((char*)&sc, 8);
if (sc) os.write((const char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem)));
os.write((char*)&f.w.rng, 4); os.write((char*)&f.w.nextId, 4);
wV(f.w.volcanoes);
os.write((char*)&f.w.volRng, 4);
}
// v16: persistent world event journal, separate from step-back history.
uint32_t en = (uint32_t)std::min<size_t>(events.size(), (size_t)EVENT_LOG_MAX);
os.write((char*)&nextEventId, 4);
os.write((char*)&en, 4);
for (size_t i = events.size() - en; i < events.size(); ++i) {
const WorldEvent& e = events[i];
os.write((char*)&e.id, 4);
os.write((char*)&e.kind, 1);
os.write((char*)&e.severity, 1);
os.write((char*)&e.timeHours, 8);
os.write((char*)&e.cell, 4);
os.write((char*)&e.sourceId, 4);
wS(e.title); wS(e.detail);
}
setStatus(os ? std::string("Saved ") + path : "Save failed");
}
@ -295,7 +425,7 @@ void Viewer::loadGame(const char* path) {
is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 volcanoes
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes
cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift
@ -308,7 +438,10 @@ void Viewer::loadGame(const char* path) {
driftAccum = 0.0; formAccum = 0.0;
wxUndo.clear(); followId = 0; // drop stale step-back history / follow target
bool skippedHistory = false;
if (ver >= 12) { // v12: restore the saved step-back frames (rewind past load)
if (ver >= 12 && ver < 15) {
skippedHistory = true; // old frames lack stateful volcanoes; do not restore them
}
if (ver >= 15) { // v15: restore saved step-back frames, including volcano state
bool historyOk = true;
const uint64_t cellCount = planet.cells.size();
auto rD = [&](std::vector<double>& v){
@ -318,6 +451,13 @@ void Viewer::loadGame(const char* path) {
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double)));
if (!is) historyOk = false;
};
auto rV = [&](std::vector<Volcano>& v){
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 100000) { historyOk = false; v.clear(); return; }
v.resize((size_t)m);
if (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(Volcano)));
if (!is) historyOk = false;
};
uint32_t hn = 0; is.read((char*)&hn, 4);
if (!is || hn > (uint32_t)wxUndoMax) historyOk = false;
for (uint32_t k = 0; k < hn && is; ++k) {
@ -328,6 +468,8 @@ void Viewer::loadGame(const char* path) {
f.w.storms.resize((size_t)sc);
if (sc) is.read((char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem)));
is.read((char*)&f.w.rng, 4); is.read((char*)&f.w.nextId, 4);
rV(f.w.volcanoes);
is.read((char*)&f.w.volRng, 4);
auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
@ -339,10 +481,45 @@ void Viewer::loadGame(const char* path) {
|| !std::isfinite(ws.radius) || ws.radius <= 0.0
|| !std::isfinite(ws.age) || !std::isfinite(ws.life)
|| !std::isfinite(ws.spin)) historyOk = false;
for (const Volcano& v : f.w.volcanoes)
if (v.cell < 0 || v.cell >= (int)planet.cells.size() || v.phase > 1
|| !std::isfinite(v.activity) || !std::isfinite(v.baseElev)
|| !std::isfinite(v.built) || !std::isfinite(v.timer)
|| !std::isfinite(v.ashTimer) || !std::isfinite(v.ashCarry)) historyOk = false;
if (historyOk) wxUndo.push_back(std::move(f));
}
if (!historyOk) { wxUndo.clear(); skippedHistory = true; }
}
events.clear(); nextEventId = 1; liveInfoTab = 0; eventRowRects.clear(); eventRowIndices.clear();
if (ver >= 16) {
bool eventsOk = true;
auto rS = [&](std::string& s) {
uint64_t m = 0; is.read((char*)&m, 8);
if (!is || m > 4096) { eventsOk = false; s.clear(); return; }
s.assign((size_t)m, '\0');
if (m) is.read(&s[0], (std::streamsize)m);
if (!is) eventsOk = false;
};
uint32_t en = 0;
is.read((char*)&nextEventId, 4);
is.read((char*)&en, 4);
if (!is || en > (uint32_t)EVENT_LOG_MAX) eventsOk = false;
for (uint32_t k = 0; k < en && is; ++k) {
WorldEvent e;
is.read((char*)&e.id, 4);
is.read((char*)&e.kind, 1);
is.read((char*)&e.severity, 1);
is.read((char*)&e.timeHours, 8);
is.read((char*)&e.cell, 4);
is.read((char*)&e.sourceId, 4);
rS(e.title); rS(e.detail);
if (e.cell < 0 || e.cell >= (int)planet.cells.size()
|| !std::isfinite(e.timeHours) || e.kind == 0 || e.kind > 32 || e.severity > 3)
eventsOk = false;
if (eventsOk) events.push_back(std::move(e));
}
if (!eventsOk) { events.clear(); nextEventId = 1; 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.
@ -418,11 +595,17 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) {
moonDirs.push_back(Vector3{ (float)md.x, (float)md.y, (float)md.z });
moonNormals.push_back(Vector3{ (float)mn.x, (float)mn.y, (float)mn.z });
}
std::vector<WeatherSystem> beforeStorms;
std::vector<Volcano> beforeVolcanoes;
if (dtWeather > 0.0) {
beforeStorms = planet.storms();
beforeVolcanoes = planet.volcanoes;
}
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);
// Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here
// reasserts restored terrain/biome state without advancing the lifecycle.
VolcanoUpdate vu = planet.stepVolcanoes(dtWeather);
if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes);
if (vu.breach) refreshView();
else if (vu.recolor) recolor();
rebuildLiveOverlay();

View File

@ -15,8 +15,9 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer {
// ---- Files / save format ------------------------------------------------
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 uint32_t SAVE_VERSION = 16; // 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";
const char* SAVE_PATH = "planet.save";
std::string configPath = "planet.cfg"; // initial config (--config overrides)
@ -99,6 +100,23 @@ struct Viewer {
bool showClouds = true; // Live World cloud/rain cover overlay (key K)
bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V)
// World event journal: currently Live World events, shaped to be reused by later phases.
struct WorldEvent {
uint32_t id = 0;
uint8_t kind = 0; // 1 weather, 2 volcano, later phases can append new kinds
uint8_t severity = 0; // 0 info, 1 notable, 2 severe
double timeHours = 0.0;
int cell = -1;
uint32_t sourceId = 0;
std::string title, detail;
};
std::vector<WorldEvent> events;
uint32_t nextEventId = 1;
int liveInfoTab = 0; // 0 Sky, 1 Tides, 2 Weather, 3 Events
std::vector<Rectangle> liveInfoTabRects;
std::vector<Rectangle> eventRowRects;
std::vector<int> eventRowIndices; // indices into events for visible event rows
// Selection + subgrid (phase 4/5 preview).
int selectedCell = -1;
double selectedThresh = 0.06;
@ -149,6 +167,11 @@ struct Viewer {
void liveStepBack(); // step everything back one frame (restores weather/storms)
void wxPushSnapshot(); // push the current weather state onto the step-back ring
Rectangle mapViewRect() const; // 2D map projection rect after zoom/pan (scissor stays mapRect)
void appendEvent(uint8_t kind, uint8_t severity, double timeHours, int cell, uint32_t sourceId,
const std::string& title, const std::string& detail);
void detectLiveEvents(const std::vector<WeatherSystem>& beforeStorms,
const std::vector<Volcano>& beforeVolcanoes);
void focusCell(int idx, const std::string& status = "");
// ---- Input (ViewerInput.cpp) --------------------------------------------
void handleInput();

View File

@ -14,6 +14,7 @@ void Viewer::handleInput() {
bool in3D = (mp.x < view3DW && mp.y < view3DH); // top-left quadrant
bool inMap = CheckCollisionPointRec(mp, mapRect);
bool inPanel = (selectedCell >= 0) && CheckCollisionPointRec(mp, panelRect);
bool inLiveInfo = liveWorld && CheckCollisionPointRec(mp, liveInfoRect);
onPause = CheckCollisionPointRec(mp, pauseBtn);
// --- Camera input (LMB drag orbits; tracks drag distance for clicks) --
@ -30,8 +31,21 @@ void Viewer::handleInput() {
}
} else {
dragDist = 0.0f;
pressInMap = inMap; // drag started on the map -> pan it
pressInMap = inMap && !inLiveInfo; // drag started on the map -> pan it
if (onPause) pauseAction(); // clickable pause / re-evolve button
if (inLiveInfo) {
for (size_t i = 0; i < liveInfoTabRects.size(); ++i)
if (CheckCollisionPointRec(mp, liveInfoTabRects[i])) { liveInfoTab = (int)i; break; }
if (liveInfoTab == 3) {
for (size_t i = 0; i < eventRowRects.size() && i < eventRowIndices.size(); ++i) {
if (!CheckCollisionPointRec(mp, eventRowRects[i])) continue;
int ei = eventRowIndices[i];
if (ei >= 0 && ei < (int)events.size())
focusCell(events[ei].cell, events[ei].title);
break;
}
}
}
}
}
// Live World: is the camera following a storm? (look up by stable id; release if dissipated)
@ -111,7 +125,7 @@ void Viewer::handleInput() {
hitModel = rotateZ(hitUnit, -planet.cfg.axialTilt); // world -> model (undo tilt)
hovered = nearestCell(planet, hitModel);
}
} else if (inMap) {
} else if (inMap && !inLiveInfo) {
Rectangle vr = mapViewRect(); // account for 2D zoom/pan
double nx = (mp.x - vr.x) / vr.width, ny = (mp.y - vr.y) / vr.height;
double X = (nx * 2.0 - 1.0) * EqualEarth::halfWidth();
@ -130,7 +144,7 @@ void Viewer::handleInput() {
}
// --- Click = select a tile (ignored over panel/button / while dragging) -
if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT) && dragDist < 6.0f && !inPanel && !onPause && !phase3Prompt && hovered >= 0)
if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT) && dragDist < 6.0f && !inPanel && !inLiveInfo && !onPause && !phase3Prompt && hovered >= 0)
selectCell(hovered);
// --- Keys -------------------------------------------------------------

View File

@ -159,9 +159,8 @@ 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.
// Live World volcano markers: growing vents glow red/orange, dormant vents go quiet/grey,
// and post-explosion ash vents flare bright. Inside the tilted matrix, so it tracks the globe.
if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) {
const double maxH = std::max(1.0, planet.cfg.volcanoMaxHeight);
for (const Volcano& vc : planet.volcanoes) {
@ -169,20 +168,23 @@ void Viewer::renderGlobe3D() {
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);
double bf = std::clamp(planet.volcanoBuilt(vc) / maxH, 0.0, 1.0);
double er = planet.volcanoErupting(vc);
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 };
bool dormant = vc.phase == 1;
Color cone = dormant ? Color{105, 100, 95, 255}
: Color{ (unsigned char)(115 + 95 * er), (unsigned char)(65 + 20 * bf), 45, 255 };
DrawCylinderEx(b, apex, coneR, coneR * 0.25f, 8, cone);
if (er > 0.12) {
if (er > 0.12 && !dormant) {
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;
Color glow = vc.ashTimer > 0.0 ? Color{255, 210, 95, a} : Color{255, 140, 40, a};
DrawSphere(apex, 0.02f + 0.05f * (float)er, glow);
float ph = coneH + (vc.ashTimer > 0.0 ? 0.20f : 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);
rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlColor4ub(255, 180, 80, a);
rlVertex3f(apex.x, apex.y, apex.z); rlVertex3f(top.x, top.y, top.z);
rlEnd(); rlSetLineWidth(1.0f);
}
@ -297,13 +299,15 @@ void Viewer::renderMap2D() {
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 er = planet.volcanoErupting(vc);
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};
Color tri = vc.phase == 1 ? Color{125, 120, 115, 255}
: vc.ashTimer > 0.0 ? Color{245, 170, 55, 255}
: Color{170, 75, 50, 255};
DrawPoly(sp, 3, s, -90.0f, tri); // filled up-pointing triangle (cone)
if (er > 0.12)
if (er > 0.12 && vc.phase != 1)
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)});
}
@ -330,15 +334,29 @@ void Viewer::renderMap2D() {
(int)mapRect.x + 6, (int)mapRect.y + 4, 14, Color{200, 200, 210, 255});
}
// Live World "Sky & tides" panel in the freed space right of the (left-aligned) 2D map:
// the current phase of every moon, and the tidal phase of a selected coastal tile.
// Live World tabbed info panel in the freed space right of the (left-aligned) 2D map.
void Viewer::renderLiveInfo() {
liveInfoTabRects.clear(); eventRowRects.clear(); eventRowIndices.clear();
if (!liveWorld) return;
Rectangle r = liveInfoRect;
DrawRectangleRec(r, Color{10, 12, 20, 235});
DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255});
int x = (int)r.x + 14, y = (int)r.y + 10;
DrawText("Sky & tides", x, y, 20, RAYWHITE); y += 30;
DrawText("Live info", x, y, 20, RAYWHITE);
const char* tabs[4] = { "Sky", "Tides", "Weather", "Events" };
float tx = r.x + 10.0f, ty = r.y + 38.0f;
for (int i = 0; i < 4; ++i) {
float tw = (r.width - 20.0f) / 4.0f;
Rectangle tr{ tx + i * tw, ty, tw - 4.0f, 24.0f };
liveInfoTabRects.push_back(tr);
bool on = liveInfoTab == i;
DrawRectangleRec(tr, on ? Color{42, 48, 68, 255} : Color{18, 22, 34, 255});
DrawRectangleLinesEx(tr, 1, on ? Color{125, 145, 190, 255} : Color{65, 70, 90, 255});
int w = MeasureText(tabs[i], 14);
DrawText(tabs[i], (int)(tr.x + (tr.width - w) * 0.5f), (int)tr.y + 5, 14,
on ? RAYWHITE : Color{155, 165, 185, 255});
}
y = (int)r.y + 72;
// Sky geometry at the current clock (recomputed here so the panel is self-contained).
const double dayH = planet.cfg.dayLengthHours, yrD = planet.cfg.yearLengthDays;
@ -371,6 +389,7 @@ void Viewer::renderLiveInfo() {
};
const auto& mns = planet.getMoons();
if (liveInfoTab == 0) {
for (size_t m = 0; m < mns.size(); ++m) {
Vec3 md = planet.moonDirection((int)m, tod, days);
Vec3 md2 = planet.moonDirection((int)m, tod2, days2);
@ -382,19 +401,15 @@ void Viewer::renderLiveInfo() {
DrawText(TextFormat("%.0f%% lit period %.0f d", f * 100.0, mns[m].periodDays), x + 52, y + 27, 15, Color{150, 160, 175, 255});
y += 50;
}
if (mns.empty()) { DrawText("(no moons)", x, y, 16, Color{150, 155, 170, 255}); y += 24; }
// Tidal phase for a selected coastal tile (placeholder: high/low + rising/falling).
y += 8;
DrawText("Tidal phase", x, y, 18, Color{200, 205, 220, 255}); y += 26;
if (mns.empty()) DrawText("(no moons)", x, y, 16, Color{150, 155, 170, 255});
} else if (liveInfoTab == 1) {
DrawText("Tidal phase", x, y, 18, Color{200, 205, 220, 255}); y += 28;
if (selectedCell >= 0 && selectedCell < (int)planet.cells.size()) {
const Cell& c = planet.cells[selectedCell];
const double sea = planet.cfg.seaLevel;
bool selLand = c.elevation > sea, coastal = false;
for (int nb : c.neighbors) if ((planet.cells[nb].elevation > sea) != selLand) { coastal = true; break; }
if (coastal) {
// Single-cell tide now vs a step ahead -> rising/falling (the field itself is the
// equilibrium tide; a richer coastal/resonant model is future work).
auto cellTide = [&](double dy, double td, double dd) {
double h = 0.0;
for (int mm = 0; mm < (int)mns.size(); ++mm) {
@ -405,38 +420,61 @@ void Viewer::renderLiveInfo() {
h += planet.cfg.tideSunFactor * (cs * cs - 1.0 / 3.0);
return h * planet.cfg.tideAmplitude;
};
bool rising = cellTide(doy2, tod2, days2) >= cellTide(doy, tod, days); // direction
// Level from the actual tide field (so the enclosed-sea cap is reflected here too).
bool rising = cellTide(doy2, tod2, days2) >= cellTide(doy, tod, days);
double lvl = ((int)planet.tide().size() == (int)planet.cells.size()) ? planet.tide()[selectedCell]
: cellTide(doy, tod, days);
DrawText(TextFormat("coastal cell #%d", selectedCell), x, y, 15, Color{160, 170, 185, 255}); y += 21;
DrawText(TextFormat("coastal cell #%d", selectedCell), x, y, 15, Color{160, 170, 185, 255}); y += 22;
DrawText(TextFormat("%+.2f m %s, %s", lvl, lvl >= 0.0 ? "high" : "low", rising ? "rising" : "falling"),
x, y, 17, tideColor(lvl, std::max(0.05, std::fabs(lvl)))); y += 24;
x, y, 17, tideColor(lvl, std::max(0.05, std::fabs(lvl)))); y += 25;
DrawText("(equilibrium model - placeholder)", x, y, 13, Color{120, 125, 140, 255});
} else {
DrawText("selected tile is inland", x, y, 15, Color{150, 155, 170, 255});
}
} else {
DrawText("click a coastal tile", x, y, 15, Color{150, 155, 170, 255});
}
// Active weather systems (lows / tropical cyclones), named by basin.
y += 12;
DrawText("Weather systems", x, y, 18, Color{200, 205, 220, 255}); y += 26;
} else DrawText("selected tile is inland", x, y, 15, Color{150, 155, 170, 255});
} else DrawText("click a coastal tile", x, y, 15, Color{150, 155, 170, 255});
} else if (liveInfoTab == 2) {
DrawText("Weather systems", x, y, 18, Color{200, 205, 220, 255}); y += 28;
const auto& storms = planet.storms();
if (storms.empty()) DrawText("(calm none active)", x, y, 15, Color{150, 155, 170, 255});
if (storms.empty()) DrawText("(calm - none active)", x, y, 15, Color{150, 155, 170, 255});
int shown = 0;
for (const auto& ws : storms) {
if (shown >= 6 || y > (int)(r.y + r.height) - 22) break;
if (shown >= 10 || y > (int)(r.y + r.height) - 22) break;
double lon, lat; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat);
bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr;
const char* kind = hur ? (lon > -0.5 && lon < 2.4 ? "Typhoon" : "Hurricane") // W Pacific vs rest
const char* kind = hur ? (lon > -0.5 && lon < 2.4 ? "Typhoon" : "Hurricane")
: ws.tropical ? "Tropical low" : "Low";
Color c = hur ? Color{240, 90, 80, 255} : Color{170, 200, 230, 255};
DrawText(TextFormat("%s %.0f%% @ %+.0f,%+.0f", kind, ws.strength * 100.0,
lat * 180.0 / M_PI, lon * 180.0 / M_PI), x, y, 15, c);
y += 21; ++shown;
}
} else {
DrawText("World events", x, y, 18, Color{200, 205, 220, 255});
DrawText(TextFormat("%d saved", (int)events.size()), (int)(r.x + r.width) - 74, y + 2, 13, Color{145, 155, 175, 255});
y += 28;
if (events.empty()) {
DrawText("(no events yet)", x, y, 15, Color{150, 155, 170, 255});
} else {
for (int ei = (int)events.size() - 1; ei >= 0; --ei) {
if (y > (int)(r.y + r.height) - 42) break;
const WorldEvent& e = events[ei];
Rectangle row{ r.x + 8.0f, (float)y - 3.0f, r.width - 16.0f, 40.0f };
eventRowRects.push_back(row); eventRowIndices.push_back(ei);
Color bg = e.severity >= 2 ? Color{58, 30, 34, 210}
: e.severity == 1 ? Color{42, 42, 34, 205}
: Color{18, 22, 34, 205};
Color fg = e.severity >= 2 ? Color{250, 130, 95, 255}
: e.severity == 1 ? Color{230, 190, 95, 255}
: Color{175, 205, 235, 255};
DrawRectangleRec(row, bg);
DrawRectangleLinesEx(row, 1, Color{70, 75, 92, 255});
const char* icon = e.kind == 2 ? "^" : "~";
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});
DrawText(e.title.c_str(), (int)row.x + 68, (int)row.y + 4, 14, fg);
DrawText(e.detail.c_str(), (int)row.x + 68, (int)row.y + 21, 12, Color{165, 170, 185, 255});
y += 43;
}
}
}
}
// Right column: hover/selection info (top) + detail panel or world stats (bottom).

View File

@ -15,7 +15,7 @@ public:
std::vector<Cell> cells;
std::vector<Plate> plates;
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)
std::vector<Volcano> volcanoes; // Live World: stateful lifecycle volcanoes (saved v15)
// Phase flag: false during Phase-1 forming (modest, original tectonics that
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
@ -108,15 +108,14 @@ public:
// 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.
// from a separate RNG (tectonic determinism intact), capturing each vent's baseElev and initial
// built height. stepVolcanoes() integrates growth/dormancy/explosions forward, reasserts vent
// elevations, breaches submarine vents into islands, and injects ash cloud + local cooling.
// Saved (v15); step-back restores them via WeatherSnapshot.
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
VolcanoUpdate stepVolcanoes(double dtHours);
double volcanoBuilt(const Volcano& v) const; // m built above baseElev
double volcanoErupting(const Volcano& v) const; // 0..1 current visual eruption intensity
// Phase 3 (biomes): classify every cell into a Biome from elevation + the climate
// fields (temperature + normalized precipitation). Derived + written back into
@ -155,11 +154,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).
// hasVolcanoes: whether the stream carries a volcano block (save v14+). hasStatefulVolcanoes
// means v15+ lifecycle volcanoes; v14's old pure-function block is consumed and discarded.
bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true,
bool hasMoons = true, bool hasWeather = true, bool hasStorms = true,
bool hasVolcanoes = true);
bool hasVolcanoes = true, bool hasStatefulVolcanoes = true);
// Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing
@ -242,8 +241,8 @@ 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).
// Volcanoes (Live World; saved v15). Separate RNG (seeded from cfg.seed in placeVolcanoes)
// keeps tectonic determinism intact while lifecycle rolls happen during Live World.
uint32_t sVolRng = 1;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the

View File

@ -43,8 +43,11 @@
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) \
D(volcanoBuildRate) D(volcanoFreeHeight) D(volcanoInitialBuildMax) D(volcanoMaxHeight) \
D(volcanoDormancyRate) D(volcanoDormantMinYears) D(volcanoDormantMaxYears) \
D(volcanoExplodeDropFrac) D(volcanoActivityDecay) D(volcanoDeadActivity) \
D(volcanoBlastRadius) D(volcanoBlastCloud) D(volcanoAshMinYears) D(volcanoAshMaxYears) \
D(volcanoAshPuffCellsPerWeek) 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) \
@ -222,9 +225,21 @@ std::string validateConfig(const PlanetConfig& cfg) {
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.volcanoBuildRate, 0.0, 1000.0, "volcanoBuildRate"));
E(rng(cfg.volcanoFreeHeight, -11000.0, 12000.0, "volcanoFreeHeight"));
E(rng(cfg.volcanoInitialBuildMax, 0.0, 12000.0, "volcanoInitialBuildMax"));
E(rng(cfg.volcanoMaxHeight, 1.0, 12000.0, "volcanoMaxHeight"));
E(rng(cfg.volcanoDormancyRate, 0.0, 100.0, "volcanoDormancyRate"));
E(rng(cfg.volcanoDormantMinYears, 0.0, 1.0e6, "volcanoDormantMinYears"));
E(rng(cfg.volcanoDormantMaxYears, 0.0, 1.0e6, "volcanoDormantMaxYears"));
E(rng(cfg.volcanoExplodeDropFrac, 0.0, 1.0, "volcanoExplodeDropFrac"));
E(rng(cfg.volcanoActivityDecay, 0.0, 1.0, "volcanoActivityDecay"));
E(rng(cfg.volcanoDeadActivity, 0.0, 1.0, "volcanoDeadActivity"));
E(rng(cfg.volcanoBlastRadius, 0.0, 3.2, "volcanoBlastRadius"));
E(rng(cfg.volcanoBlastCloud, 0.0, 10.0, "volcanoBlastCloud"));
E(rng(cfg.volcanoAshMinYears, 0.0, 1.0e6, "volcanoAshMinYears"));
E(rng(cfg.volcanoAshMaxYears, 0.0, 1.0e6, "volcanoAshMaxYears"));
E(rng(cfg.volcanoAshPuffCellsPerWeek, 0.0, 1000.0, "volcanoAshPuffCellsPerWeek"));
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"));
@ -253,6 +268,10 @@ std::string validateConfig(const PlanetConfig& cfg) {
bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)");
if (cfg.peakSoftCapStart >= cfg.peakSoftCapEnd)
bad.push_back("peakSoftCapStart >= peakSoftCapEnd (grow probability must span a band)");
if (cfg.volcanoDormantMinYears > cfg.volcanoDormantMaxYears)
bad.push_back("volcanoDormantMinYears > volcanoDormantMaxYears");
if (cfg.volcanoAshMinYears > cfg.volcanoAshMaxYears)
bad.push_back("volcanoAshMinYears > volcanoAshMaxYears");
if (bad.empty()) return {};
std::string msg = "Bad config:";
@ -261,6 +280,16 @@ std::string validateConfig(const PlanetConfig& cfg) {
}
namespace {
struct LegacyVolcano {
uint32_t id = 0;
int cell = -1;
uint8_t kind = 2;
uint8_t submarine = 0;
double activity = 0.5;
double baseElev = 0.0;
double tStart = 0.0;
};
template <class T> void writePod(std::ostream& os, const T& v) {
static_assert(std::is_trivially_copyable<T>::value, "writePod needs a POD type");
os.write(reinterpret_cast<const char*>(&v), sizeof(T));
@ -334,15 +363,14 @@ 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;
// v15: Live World volcanoes are stateful lifecycle agents. Always written from v15;
// 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 hasVolcanoes) {
bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes) {
// 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; }
@ -450,16 +478,29 @@ 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).
// v14 had pure-function volcanoes with the old struct layout; v15 has stateful lifecycle
// volcanoes. Consume the old block so the stream stays aligned, but discard it.
volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au;
if (hasVolcanoes) {
if (!hasStatefulVolcanoes) {
std::vector<LegacyVolcano> legacy;
if (!readVec(is, legacy, 100000)) return false;
readPod(is, sVolRng);
sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au;
volcanoes.clear();
if (!is) return false;
computeBiotaDensity();
return true;
}
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;
|| !std::isfinite(v.baseElev) || !std::isfinite(v.built)
|| !std::isfinite(v.timer) || !std::isfinite(v.ashTimer)
|| !std::isfinite(v.ashCarry) || v.phase > 1) return false;
}
computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is;

View File

@ -66,17 +66,20 @@ struct Moon {
// 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).
// plate borders, low elsewhere (hotspots). It is a small stateful lifecycle agent: it grows, can
// go dormant, explodes after dormancy, then regrows weaker. Saved (v15).
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)
uint8_t phase = 0; // 0 = growing, 1 = dormant
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)
double built = 0.0; // m: current height built above baseElev
double timer = 0.0; // h: dormancy countdown
double ashTimer = 0.0; // h: sustained ash emission after an explosion
double ashCarry = 0.0; // fractional ash-puff accumulator
};
// Result of one stepVolcanoes() call, telling the viewer how much of the view to rebuild:
@ -89,7 +92,9 @@ struct VolcanoUpdate { bool recolor = false; bool breach = false; };
struct WeatherSnapshot {
std::vector<double> humidity, cloud, rain;
std::vector<WeatherSystem> storms;
std::vector<Volcano> volcanoes;
uint32_t rng = 0, nextId = 0;
uint32_t volRng = 0;
};
struct Plate {
@ -338,9 +343,21 @@ struct PlanetConfig {
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 volcanoBuildRate = 0.02; // m/h at activity=1 while growing
double volcanoFreeHeight = 1000.0;// m absolute elevation below which vents cannot go dormant
double volcanoInitialBuildMax = 2500.0; // m: max pre-built height on Live World entry
double volcanoMaxHeight = 3200.0;// m built height where dormancy becomes certain
double volcanoDormancyRate = 1.0; // /year hazard scale once above volcanoFreeHeight
double volcanoDormantMinYears = 120.0; // min dormancy before explosion
double volcanoDormantMaxYears = 1200.0; // max dormancy before explosion
double volcanoExplodeDropFrac = 0.20; // fraction of built height shaved by an explosion
double volcanoActivityDecay = 0.70; // activity multiplier after each explosion
double volcanoDeadActivity = 0.05; // activity floor below which growth stops
double volcanoBlastRadius = 0.09; // rad: wide ash blast radius around the vent
double volcanoBlastCloud = 1.5; // cloud added inside the explosion blast
double volcanoAshMinYears = 0.5; // min sustained ash emission after explosion
double volcanoAshMaxYears = 3.0; // max sustained ash emission after explosion
double volcanoAshPuffCellsPerWeek = 2.0;// average local cells puffed per week while ashTimer runs
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
};

View File

@ -1,49 +1,53 @@
#include "Planet.hpp"
#include <algorithm>
#include <cmath>
#include <vector>
// --- 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.
// Volcanoes are fixed vents placed once by tectonic context when the world enters
// Live World. They are stateful agents: growing vents build height, tall vents can
// go dormant, dormant vents eventually explode, shave their peak, emit ash, and
// regrow weaker. The state is saved and included in the Live World step-back
// snapshot, while all random rolls use sVolRng so tectonic determinism is untouched.
// 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);
namespace {
constexpr double YEAR_HOURS = 24.0 * 365.25;
uint32_t volNext(uint32_t& rng) {
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; return rng;
}
double volRf(uint32_t& rng) {
return (volNext(rng) & 0xFFFFFFu) / double(0x1000000);
}
}
// 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
double Planet::volcanoBuilt(const Volcano& v) const {
return std::max(0.0, v.built);
}
double Planet::volcanoErupting(const Volcano& v) const {
if (v.ashTimer > 0.0) return 1.0;
if (v.phase == 1) return 0.0;
if (v.activity <= cfg.volcanoDeadActivity) return 0.05;
double vigor = std::clamp(v.activity, 0.0, 1.0);
double heightGlow = std::clamp(v.built / std::max(1.0, cfg.volcanoMaxHeight), 0.0, 1.0);
return std::clamp(0.12 + 0.35 * vigor + 0.15 * heightGlow, 0.0, 0.65);
}
// 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) {
(void)liveTime; // Lifecycle volcanoes carry state directly; the clock no longer defines height.
for (const Volcano& v : volcanoes) {
if (v.cell < 0 || v.cell >= (int)cells.size()) continue;
cells[v.cell].elevation = v.baseElev;
if (v.submarine) {
cells[v.cell].oceanic = true;
cells[v.cell].biome = Biome::Ocean;
}
}
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);
@ -61,38 +65,53 @@ void Planet::placeVolcanoes(double liveTime) {
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;
if (volRf(sVolRng) >= prob) continue;
Volcano v;
v.cell = i;
v.kind = (uint8_t)kind;
v.submarine = (cells[i].elevation <= sea) ? 1 : 0;
v.phase = 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);
v.activity = std::clamp(base + (volRf(sVolRng) - 0.5) * 0.4, 0.05, 1.0);
double skew = std::pow(volRf(sVolRng), 1.7); // mostly young, some pre-built
v.built = std::max(0.0, cfg.volcanoInitialBuildMax) * skew;
v.timer = v.ashTimer = v.ashCarry = 0.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; }
else if (cap > 0) { uint32_t r = volNext(sVolRng) % (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);
stepVolcanoes(0.0); // show pre-built islands immediately on Live World entry.
}
// 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 Planet::stepVolcanoes(double dtHours) {
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);
const double minDormYears = std::min(cfg.volcanoDormantMinYears, cfg.volcanoDormantMaxYears);
const double maxDormYears = std::max(cfg.volcanoDormantMinYears, cfg.volcanoDormantMaxYears);
const double minAshYears = std::min(cfg.volcanoAshMinYears, cfg.volcanoAshMaxYears);
const double maxAshYears = std::max(cfg.volcanoAshMinYears, cfg.volcanoAshMaxYears);
auto addAshCell = [&](int c, double cloud, double cooling) {
if (c < 0 || c >= n) return;
if (!sCloud.empty()) {
sCloud[c] = std::min(2.0, sCloud[c] + cloud);
if (!sHumidity.empty())
sHumidity[c] = std::min(2.0, sHumidity[c] + 0.3 * cloud);
}
if (!sLiveTemp.empty())
sLiveTemp[c] -= cooling;
};
auto reassertVent = [&](Volcano& v) {
if (v.cell < 0 || v.cell >= n) return;
v.built = std::max(0.0, v.built);
double newElev = v.baseElev + v.built;
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) {
@ -101,21 +120,70 @@ VolcanoUpdate Planet::stepVolcanoes(double dtHours, double liveTime) {
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);
};
auto blastAsh = [&](const Volcano& v) {
if (v.cell < 0 || v.cell >= n) return;
double cosR = std::cos(std::max(0.0, cfg.volcanoBlastRadius));
Vec3 center = cells[v.cell].unit;
for (int i = 0; i < n; ++i) {
double d = center.dot(cells[i].unit);
if (d < cosR) continue;
double t = (1.0 - cosR > 1e-9) ? std::clamp((d - cosR) / (1.0 - cosR), 0.0, 1.0) : 1.0;
addAshCell(i, cfg.volcanoBlastCloud * (0.35 + 0.65 * t), cfg.volcanoAshCooling * t);
}
// 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;
};
auto sustainedAsh = [&](Volcano& v) {
if (v.cell < 0 || v.cell >= n) return;
addAshCell(v.cell, cfg.volcanoAshCloud * dtHours, cfg.volcanoAshCooling);
v.ashCarry += cfg.volcanoAshPuffCellsPerWeek * dtHours / (24.0 * 7.0);
std::vector<int> candidates;
candidates.push_back(v.cell);
for (int j : cells[v.cell].neighbors) candidates.push_back(j);
int puffs = (int)std::floor(v.ashCarry);
v.ashCarry -= puffs;
if (volRf(sVolRng) < v.ashCarry) { ++puffs; v.ashCarry = 0.0; }
for (int k = 0; k < puffs && !candidates.empty(); ++k) {
int c = candidates[volNext(sVolRng) % (uint32_t)candidates.size()];
addAshCell(c, cfg.volcanoAshCloud, cfg.volcanoAshCooling * 0.5);
}
};
for (Volcano& v : volcanoes) {
if (v.cell < 0 || v.cell >= n) continue;
if (dtHours > 0.0) {
if (v.ashTimer > 0.0) {
sustainedAsh(v);
v.ashTimer = std::max(0.0, v.ashTimer - dtHours);
}
if (v.phase == 1) {
v.timer -= dtHours;
if (v.timer <= 0.0) {
v.built *= std::clamp(1.0 - cfg.volcanoExplodeDropFrac, 0.0, 1.0);
double ashYears = minAshYears + (maxAshYears - minAshYears) * volRf(sVolRng);
v.ashTimer = ashYears * YEAR_HOURS;
v.ashCarry = 0.0;
blastAsh(v);
v.activity = std::clamp(v.activity * cfg.volcanoActivityDecay, 0.0, 1.0);
v.phase = 0;
v.timer = 0.0;
}
} else {
if (v.activity > cfg.volcanoDeadActivity)
v.built += std::max(0.0, cfg.volcanoBuildRate) * v.activity * dtHours;
double absElev = v.baseElev + v.built;
if (absElev > cfg.volcanoFreeHeight) {
double heightP = std::clamp(v.built / std::max(1.0, cfg.volcanoMaxHeight), 0.01, 1.0);
double hazard = heightP * std::max(0.0, cfg.volcanoDormancyRate) * dtHours / YEAR_HOURS;
double pStep = 1.0 - std::exp(-hazard);
if (volRf(sVolRng) < pStep) {
double dormYears = minDormYears + (maxDormYears - minDormYears) * volRf(sVolRng);
v.phase = 1;
v.timer = dormYears * YEAR_HOURS;
}
}
}
}
reassertVent(v);
}
return up;
}

View File

@ -30,12 +30,14 @@ WeatherSnapshot Planet::captureWeather() const {
WeatherSnapshot s;
s.humidity = sHumidity; s.cloud = sCloud; s.rain = sRain;
s.storms = sStorms; s.rng = sWeatherRng; s.nextId = sStormNextId;
s.volcanoes = volcanoes; s.volRng = sVolRng;
return s;
}
void Planet::restoreWeather(const WeatherSnapshot& s) {
sHumidity = s.humidity; sCloud = s.cloud; sRain = s.rain;
sStorms = s.storms; sWeatherRng = s.rng; sStormNextId = s.nextId;
volcanoes = s.volcanoes; sVolRng = s.volRng;
sHasWeather = !sHumidity.empty();
}

50
test_events.cpp Normal file
View File

@ -0,0 +1,50 @@
// Headless checks for the viewer event journal (no window needed).
#include "Viewer.hpp"
#include <cstdio>
#include <fstream>
#include <string>
static int failures = 0;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
}
int main() {
std::printf("Events: cap and save/load\n");
Viewer v;
PlanetConfig cfg; cfg.subdivisions = 2; cfg.seed = 4242;
v.cfg = cfg;
v.planet.generate(cfg);
for (int i = 0; i < 205; ++i) {
int cell = i % (int)v.planet.cells.size();
v.appendEvent(1, (uint8_t)(i % 3), (double)i, cell, (uint32_t)i,
"event " + std::to_string(i), "detail " + std::to_string(i));
}
check((int)v.events.size() == Viewer::EVENT_LOG_MAX, "event journal keeps the newest 200 entries");
check(v.events.front().title == "event 5", "oldest entries are trimmed first");
check(v.events.back().title == "event 204", "newest event is retained");
const char* path = "/tmp/fanworgen_event_test.save";
v.saveGame(path);
Viewer r;
r.loadGame(path);
check((int)r.events.size() == Viewer::EVENT_LOG_MAX, "v16 save/load restores event count");
check(!r.events.empty() && r.events.front().title == "event 5" && r.events.back().title == "event 204",
"v16 save/load restores event contents");
check(r.nextEventId == v.nextEventId, "v16 save/load restores next event id");
{
std::fstream fs(path, std::ios::in | std::ios::out | std::ios::binary);
uint32_t oldVer = 15;
fs.seekp(4);
fs.write((char*)&oldVer, 4);
}
Viewer old;
old.loadGame(path);
check(old.events.empty(), "pre-v16 saves load with an empty event journal");
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL EVENT CHECKS PASSED\n", failures);
return failures ? 1 : 0;
}

View File

@ -1,5 +1,5 @@
// Headless test for Live World volcanoes (placement by tectonic context + eruption / island
// building). No display needed.
// Headless test for Live World volcanoes (stateful growth, dormancy, explosions, ash,
// rewind snapshots and save/load). 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 \
@ -7,11 +7,6 @@
// 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>
@ -21,6 +16,8 @@
#include <sstream>
static int failures = 0;
static constexpr double YEAR_HOURS = 24.0 * 365.25;
static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures;
@ -49,21 +46,32 @@ static int classify(const Planet& p, int i) {
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;
|| a[i].submarine != b[i].submarine || a[i].phase != b[i].phase
|| a[i].activity != b[i].activity || a[i].baseElev != b[i].baseElev
|| a[i].built != b[i].built || a[i].timer != b[i].timer
|| a[i].ashTimer != b[i].ashTimer || a[i].ashCarry != b[i].ashCarry) return false;
return true;
}
static int cloudRaisedCells(const Planet& p, const std::vector<double>& before, double eps = 1e-9) {
int raised = 0;
const auto& cloud = p.cloud();
for (size_t i = 0; i < cloud.size() && i < before.size(); ++i)
if (cloud[i] > before[i] + eps) ++raised;
return raised;
}
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");
std::printf("Volcanoes: deterministic placement\n");
p.placeVolcanoes(0.0); std::vector<Volcano> first = p.volcanoes;
p.placeVolcanoes(0.0);
check(!first.empty(), "placeVolcanoes places a non-empty set");
@ -72,6 +80,7 @@ int main() {
std::printf("Volcanoes: RNG isolation from tectonics\n");
Planet a; a.generate(cfg); settle(a);
Planet b; b.generate(cfg); settle(b);
b.cfg.volcanoMaxCount = 0; // isolate RNG stream without intentionally changing terrain
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);
@ -85,9 +94,8 @@ int main() {
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.cfg.volcanoMaxCount = 1000000; p.cfg.volcanoInitialBuildMax = 0.0;
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");
@ -108,54 +116,111 @@ int main() {
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: initial built height can make islands immediately\n");
Planet pre; pre.generate(cfg); settle(pre); drift(pre, 120);
pre.cfg.volcanoProbRidge = pre.cfg.volcanoProbBorder = pre.cfg.volcanoProbInterior = 1.0;
pre.cfg.volcanoMaxCount = 1000000;
pre.cfg.volcanoInitialBuildMax = 10000.0;
pre.placeVolcanoes(0.0);
bool someBuilt = false, instantIsland = false;
for (const Volcano& v : pre.volcanoes) {
if (v.built > 0.0) someBuilt = true;
if (v.submarine && pre.cells[v.cell].elevation > pre.cfg.seaLevel && !pre.cells[v.cell].oceanic)
instantIsland = true;
}
check(someBuilt, "placement assigns nonzero pre-built height");
check(instantIsland, "a pre-built submarine vent can breach into an island on entry");
std::printf("Volcanoes: forward stepping grows statefully\n");
Planet g; g.generate(cfg); settle(g); drift(g, 80);
g.cfg.volcanoProbRidge = g.cfg.volcanoProbBorder = g.cfg.volcanoProbInterior = 1.0;
g.cfg.volcanoMaxCount = 1; g.cfg.volcanoInitialBuildMax = 0.0;
g.cfg.volcanoBuildRate = 10.0; g.cfg.volcanoFreeHeight = 1e9; g.cfg.volcanoDeadActivity = 0.0;
g.placeVolcanoes(0.0);
check(!g.volcanoes.empty(), "one growth-test volcano placed");
if (!g.volcanoes.empty()) {
g.volcanoes[0].activity = 1.0;
double b0 = g.volcanoes[0].built;
g.stepVolcanoes(2.0);
check(g.volcanoes[0].built > b0 + 19.9, "growing vent integrates built height forward");
check(std::fabs(g.cells[g.volcanoes[0].cell].elevation - (g.volcanoes[0].baseElev + g.volcanoes[0].built)) < 1e-6,
"vent elevation is reasserted from baseElev + built");
}
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: forced dormancy, explosion, ash blast and activity decay\n");
Planet x; x.generate(cfg); settle(x); drift(x, 80);
x.initWeather();
x.computeInsolation(0.25, 0.3);
x.computeLiveSeason(0.25);
x.cfg.volcanoProbRidge = x.cfg.volcanoProbBorder = x.cfg.volcanoProbInterior = 1.0;
x.cfg.volcanoMaxCount = 1; x.cfg.volcanoInitialBuildMax = 0.0;
x.cfg.volcanoFreeHeight = -1e9; x.cfg.volcanoMaxHeight = 1.0;
x.cfg.volcanoDormancyRate = YEAR_HOURS * 1000.0;
x.cfg.volcanoDormantMinYears = x.cfg.volcanoDormantMaxYears = 0.0;
x.cfg.volcanoExplodeDropFrac = 0.20; x.cfg.volcanoActivityDecay = 0.70;
x.cfg.volcanoAshMinYears = x.cfg.volcanoAshMaxYears = 0.01;
x.cfg.volcanoBlastRadius = 0.09; x.cfg.volcanoBlastCloud = 1.5;
x.cfg.volcanoAshPuffCellsPerWeek = 100.0;
x.placeVolcanoes(0.0);
check(!x.volcanoes.empty(), "one lifecycle-test volcano placed");
if (!x.volcanoes.empty()) {
x.volcanoes[0].built = 2000.0;
x.volcanoes[0].activity = 1.0;
x.volcanoes[0].phase = 0;
x.stepVolcanoes(0.0);
x.stepVolcanoes(1.0);
check(x.volcanoes[0].phase == 1, "tall growing vent can go dormant");
double beforeBuilt = x.volcanoes[0].built;
double beforeActivity = x.volcanoes[0].activity;
std::vector<double> beforeCloud = x.cloud();
x.stepVolcanoes(1.0);
check(x.volcanoes[0].phase == 0, "dormant vent explodes and returns to growing");
check(x.volcanoes[0].built < beforeBuilt * 0.81, "explosion shaves the peak");
check(x.volcanoes[0].ashTimer > 0.0, "explosion starts sustained ash emission");
check(x.volcanoes[0].activity < beforeActivity, "explosion decays activity");
check(cloudRaisedCells(x, beforeCloud) >= 20, "explosion blasts ash over a wide cell radius");
beforeCloud = x.cloud();
x.stepVolcanoes(24.0 * 7.0);
check(cloudRaisedCells(x, beforeCloud) > 0, "post-explosion ashTimer keeps puffing ash");
}
std::printf("Volcanoes: save v14 round-trip\n");
std::printf("Volcanoes: snapshot restore reverses lifecycle state\n");
if (!x.volcanoes.empty()) {
WeatherSnapshot snap = x.captureWeather();
std::vector<Volcano> saved = x.volcanoes;
x.volcanoes[0].built += 500.0;
x.volcanoes[0].phase = 1;
x.volcanoes[0].timer = 123.0;
x.stepVolcanoes(0.0);
x.restoreWeather(snap);
x.stepVolcanoes(0.0);
check(sameVolcanoes(saved, x.volcanoes), "captureWeather/restoreWeather round-trips volcano state");
check(std::fabs(x.cells[x.volcanoes[0].cell].elevation - (x.volcanoes[0].baseElev + x.volcanoes[0].built)) < 1e-6,
"restored volcano state reasserts terrain");
}
std::printf("Volcanoes: save v15 round-trip and v14 discard path\n");
{
x.cfg.volcanoDormancyRate = 1.0; // keep saved config inside normal validation bounds
x.cfg.volcanoFreeHeight = 1000.0;
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
q.writeState(ss);
x.writeState(ss);
ss.seekg(0);
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");
bool ok = r.readState(ss, true, true, true, true, true, true, true);
check(ok, "readState accepts a v15 stream");
check(sameVolcanoes(x.volcanoes, r.volcanoes), "stateful volcanoes round-trip through save");
}
{
Planet old; old.generate(cfg); settle(old);
old.volcanoes.clear(); // empty old block is layout-compatible and still exercises discard.
std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
old.writeState(ss);
ss.seekg(0);
Planet r;
bool ok = r.readState(ss, true, true, true, true, true, true, false);
check(ok, "readState consumes a v14 volcano block");
check(r.volcanoes.empty(), "v14 volcanoes are discarded for lifecycle reseeding");
}
std::printf(failures ? "\nFAILURES: %d\n" : "\nALL VOLCANO CHECKS PASSED\n", failures);