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; planet.cfg human-editable key=value config of every PlanetConfig parameter;
auto-created on first run, reload live with F2. Range-checked on auto-created on first run, reload live with F2. Range-checked on
load; an invalid file reverts to safe defaults (not overwritten). load; an invalid file reverts to safe defaults (not overwritten).
planet.save binary snapshot (versioned, currently v13: +Live World clock rate; v12 planet.save binary snapshot (versioned, currently v16: +event log; v15 +stateful
+step-back history (~40 frames, so a load can rewind storms); v11 +weather volcanoes; v13 +Live World clock rate; v12 +step-back history
systems/storms; v10 +weather fields; v9 +moons; v8 +Live World clock; v7 +biota): seed + config + full planet state; F5 (~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 writes it, F9 reloads and resumes deterministically. As of v6
the config is stored as a self-describing key=value block (like the config is stored as a self-describing key=value block (like
planet.cfg), so adding/removing config fields no longer breaks saves 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 weatherSystemRain 1.6 /h rain at a system core
weatherHurricaneStr 0.6 strength above which a tropical system is a hurricane/typhoon 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, 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 then integrated as stateful lifecycle agents: grow, go dormant, explode, puff ash, and regrow
function of liveTime, so the stepper rewinds it). Saved v14. weaker. Step-back snapshots include volcano state. Saved v15+.
volcanoProbRidge 0.55 per-cell placement prob on a young spreading-ridge cell 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 volcanoProbBorder 0.06 per-cell placement prob on a normal plate-border cell
volcanoProbInterior 0.003 per-cell placement prob elsewhere (hotspots) volcanoProbInterior 0.003 per-cell placement prob elsewhere (hotspots)
volcanoMaxCount 60 global cap (reservoir-sampled, ratios preserved) volcanoMaxCount 60 global cap (reservoir-sampled, ratios preserved)
volcanoBuildStep 130 m cone/island growth per eruption pulse volcanoBuildRate 0.02 m/h of growth at activity=1 while growing
volcanoMaxHeight 3200 m max height built above a vent's base elevation volcanoFreeHeight 1000 m absolute height below which vents cannot go dormant
volcanoEruptFreq 0.05 eruption pulses per (hour * activity) -- cadence volcanoInitialBuildMax 2500 m max pre-built height when Live World starts
volcanoAshCloud 0.9 cloud cover injected at the vent per erupting hour 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 volcanoAshCooling 6 C peak local cooling under an active ash plume
## Headless logic test (no display) ## 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 src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# Biota / Live World / Ocean / Weather / Volcano suites: same source list, swap test_logic.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 # 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 Verifies geometry, plate assignment, gradual non-saturating relief and
determinism. Run after changing Planet::step(). 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 - **Volcanoes & volcanic islands** *(done — see `PlanetVolcano.cpp`)* — on entering Live World a
one-time pass (`placeVolcanoes`, separate RNG → tectonic determinism intact) seeds volcanoes by 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 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 plates), **medium** on normal plate borders, **low** elsewhere (hotspots). They are stateful
(`stepVolcanoes`) they **erupt**; submarine vents build their cell up and **breach sea level into lifecycle agents: some start pre-built, growing vents can breach submarine cells into volcanic
new volcanic islands**, land vents grow cones, and each eruption injects a drifting **ash cloud** islands, tall vents can go dormant, dormant vents explode and shave their peak, then puff ash while
(into the weather field) + **local cooling**. Eruption state (built height + intensity) is a **pure regrowing weaker. Volcano state + `sVolRng` are captured in the Live World step-back snapshot, so
function of `liveTime`** (like insolation/tides/seasons), so the live stepper rewinds islands & `,`/`.` reverses height, dormancy, explosions and ash timers. Rendered as growing, dormant and
eruptions for free (no extra snapshot state). Rendered as cone markers + an eruption glow/ash-plume post-explosion cone markers (3D + 2D, key `V`); saved (v15). Knobs `volcano*`.
flare (3D + 2D, key `V`); saved (v14). Knobs `volcano*`.
## Current state ## Current state
@ -441,7 +440,7 @@ Working and verified (logic tested headless):
and dissipate behind the system. A tropical system past `weatherHurricaneStr` is a 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 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, 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. 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 - **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 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 (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 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. 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 - 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 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 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`, (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 / `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 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 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 **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). 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 `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**; tectonic context on entry and run a stateful lifecycle — submarine ones can build into new
`V` toggles the cone/eruption markers. `Y` makes the 3D camera **follow a storm** (cycles by strength, **volcanic islands**; `V` toggles the cone/eruption markers. `Y` makes the 3D camera **follow a storm** (cycles by strength,
off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and** off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and**
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 CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate). 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 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 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 — 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 newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`); rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10 pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
saves spin weather up live; pre-v11 saves load with no active storms (they respawn); pre-v12 saves 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 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 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. saved one.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults), 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 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 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`: its right holding the Live-World tabbed info panel — `liveInfoRect`, `renderLiveInfo`:
per-moon phase discs + a selected coastal tile's tidal phase); right column 30% wide = cell 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 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 3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full
screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan). 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 `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 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). 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 Lifecycle — `volcanoInitialBuildMax` (2500 m) pre-builds some vents on entry, `volcanoBuildRate`
base) set how tall a cone/island gets, `volcanoEruptFreq` (0.05 pulses per hour·activity) the (0.02 m/h at activity 1) grows active vents, `volcanoFreeHeight` (1000 m absolute) protects deep
cadence (raise for faster, more frequent eruptions — at a high live-clock rate islands build in vents from dormancy, and `volcanoMaxHeight` (3200 m built) is the soft height where dormancy becomes
seconds). Eruption FX — `volcanoAshCloud` (0.9, ash cover injected into the weather field per certain. Dormancy/explosions — `volcanoDormancyRate` (1/year scale),
erupting hour) and `volcanoAshCooling` (6 °C, peak local cooling under an active plume). All build `volcanoDormantMinYears`/`MaxYears` (120/1200), `volcanoExplodeDropFrac` (0.20),
+ eruption state is a pure function of `liveTime` (PlanetVolcano.cpp); marker sizes/colours are `volcanoActivityDecay` (0.70), and `volcanoDeadActivity` (0.05). Ash FX —
render constants (ViewerRender.cpp), not config. `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 - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main
knob for how fast/high relief builds. knob for how fast/high relief builds.
- `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks

View File

@ -75,3 +75,11 @@ foreach(test_name logic biota ocean live weather volcano)
target_link_libraries(test_${test_name} PRIVATE planetsim_sim) target_link_libraries(test_${test_name} PRIVATE planetsim_sim)
add_test(NAME ${test_name} COMMAND test_${test_name}) add_test(NAME ${test_name} COMMAND test_${test_name})
endforeach() 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) ## Volcanoes & volcanic islands (Live World)
`PlanetVolcano.cpp`. A `Volcano` is a fixed point on the grid (one `cell`), not a moving agent. On `PlanetVolcano.cpp`. A `Volcano` is a fixed point on the grid (one `cell`) and a stateful lifecycle
**entering Live World** `placeVolcanoes(liveTime)` seeds a set once, by tectonic context: a cell is 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** **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 if a neighbour has a different `plateId`, else **interior**; placement probability is
`volcanoProbRidge``volcanoProbBorder``volcanoProbInterior`, reservoir-sampled to `volcanoProbRidge``volcanoProbBorder``volcanoProbInterior`, reservoir-sampled to
`volcanoMaxCount` (an unbiased subset, ratios preserved). A separate RNG (`sVolRng = cfg.seed ^ `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 The crucial design point is now the opposite of the original v14 implementation: **volcanoes are
insolation/tides/seasons, never an integration). `volcanoBuilt(v,t) = min(maxHeight, floor((ttStart) integrated forward**, not pure functions of `liveTime`. `stepVolcanoes(dt)` grows active vents by
·eruptFreq·activity)·buildStep)` (discrete pulses stepping the cone up, monotonic) and `volcanoBuildRate * activity`, lets tall vents go dormant above `volcanoFreeHeight`, explodes dormant
`volcanoErupting(v,t)` is a flare decaying through each pulse cycle. `stepVolcanoes(dt, liveTime)` vents after a long timer, shaves `volcanoExplodeDropFrac` of built height, starts sustained ash
(called each frame in `liveAdvance`, after `stepWeather`) just reasserts `cells[v.cell].elevation = emission, and decays activity so old volcanoes settle. It always reasserts
baseElev + built` — safe & complete because in Live World nothing else moves elevation. A submarine `cells[v.cell].elevation = baseElev + built`; submarine vents crossing sea level breach into islands
vent crossing sea level **breaches** into an island (`oceanic=false`, `biome=Beach`, viewer and can re-submerge when a restored snapshot has less built height. Explosions stamp a wide local ash
`refreshView`s); crossing back down (on a step back) re-submerges it. Because the state is derived blast into `sCloud`/`sHumidity` and sustained puffs continue while `ashTimer` runs.
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 Because this is stochastic state, **step-back snapshots include volcanoes + `sVolRng`** alongside
`sCloud`/`sHumidity` (drifts downwind via the weather cycle) + subtracts `volcanoAshCooling` from weather. A backward step restores weather, storms, volcano lifecycle state and RNG, then
`sLiveTemp` at the vent — the cloud reverts via the existing weather snapshot, the cooling is itself `stepVolcanoes(0)` reasserts terrain without advancing. Rendered as growing red/orange cones,
re-derived each frame. Rendered as a cone (taller/redder as it builds) + an orange glow/ash-plume dormant grey quiet cones, and bright post-explosion plume markers (3D + 2D, key `V`). **Saved v15**:
flare when erupting (3D `DrawCylinderEx` inside the tilt matrix; 2D `DrawPoly` triangle), key `V`. the stateful `Volcano` set + `sVolRng`; v14's old pure-function block is consumed and discarded so
**Saved v14**: the placed `Volcano` set + `sVolRng` in `writeState`/`readState` (flag-gated like volcanoes reseed on the next Live World entry. Knobs: `volcano*`.
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) ## 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 (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. 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 ## Headless testing
Engine is raylib-free, so logic is tested without a display. Build/run: Engine is raylib-free, so logic is tested without a display. Build/run:

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]))); 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) 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]))); L.push_back(std::string(TextFormat("lake: depth %.0f m", p.lakeDepth()[i])));
// Volcano (Live World): built height = current elevation above the captured baseElev // Volcano (Live World): lifecycle phase and current built height.
// (eruption flares are shown by the marker; the live clock isn't available here).
for (const Volcano& vc : p.volcanoes) { for (const Volcano& vc : p.volcanoes) {
if (vc.cell != i) continue; if (vc.cell != i) continue;
const char* kn = vc.kind == 0 ? "ridge" : vc.kind == 1 ? "border" : "hotspot"; 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; if (vc.ashTimer > 0.0) {
L.push_back(std::string(TextFormat("volcano: %s activity %.0f%% +%.0f m built", L.push_back(std::string(TextFormat("volcano: %s erupting +%.0f m activity %.0f%%",
kn, vc.activity * 100.0, built))); 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; break;
} }
// Biota: density scalars (present after computeBiotaDensity()) + the discrete // Biota: density scalars (present after computeBiotaDensity()) + the discrete

View File

@ -6,6 +6,19 @@
#include <cstring> #include <cstring>
#include <fstream> #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) { bool Viewer::init(int argc, char** argv) {
uint32_t cliSeed = 0; // 0 = no --seed given uint32_t cliSeed = 0; // 0 = no --seed given
for (int a = 1; a < argc; ++a) { for (int a = 1; a < argc; ++a) {
@ -229,7 +242,8 @@ void Viewer::regenWorld() { // after generate(): geometry change
buildMap2D(planet, mapRect, map2D); buildMap2D(planet, mapRect, map2D);
selectedCell = -1; subgrids.clear(); selectedCell = -1; subgrids.clear();
settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false; 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 planet.drifting = false; // Phase 1: original forming behavior
phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy; phase3 = false; phase3Prompt = false; phase3PromptAt = planet.cfg.phase3AfterMy;
rivers.clear(); bigRivers.clear(); 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::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. // F5: write seed + config + full planet state. F9: read it back and resume.
void Viewer::saveGame(const char* path) { void Viewer::saveGame(const char* path) {
std::ofstream os(path, std::ios::binary); 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 os.write(reinterpret_cast<const char*>(&liveRate), sizeof liveRate); // v13: live clock rate
planet.writeState(os); planet.writeState(os);
// v12: persist the most recent step-back frames so a load can rewind storms past the moment. // 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 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); uint32_t hn = (uint32_t)std::min<size_t>(wxUndo.size(), (size_t)wxSaveMax);
os.write((char*)&hn, 4); os.write((char*)&hn, 4);
for (size_t i = wxUndo.size() - hn; i < wxUndo.size(); ++i) { 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); 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))); 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); 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"); 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 is.read(reinterpret_cast<char*>(&lh), sizeof lh); } // v8: Live World clock
if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate if (ver >= 13) is.read(reinterpret_cast<char*>(&lr), sizeof lr); // v13: Live World rate
if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; }
if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14)) { 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 cfg = planet.cfg; // adopt the loaded config
elapsedMy = em; settled = (st != 0); elapsedMy = em; settled = (st != 0);
planet.drifting = settled; // resume drift boosts iff mid-drift planet.drifting = settled; // resume drift boosts iff mid-drift
@ -308,7 +438,10 @@ void Viewer::loadGame(const char* path) {
driftAccum = 0.0; formAccum = 0.0; driftAccum = 0.0; formAccum = 0.0;
wxUndo.clear(); followId = 0; // drop stale step-back history / follow target wxUndo.clear(); followId = 0; // drop stale step-back history / follow target
bool skippedHistory = false; 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; bool historyOk = true;
const uint64_t cellCount = planet.cells.size(); const uint64_t cellCount = planet.cells.size();
auto rD = [&](std::vector<double>& v){ 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 (m) is.read((char*)v.data(), (std::streamsize)(m * sizeof(double)));
if (!is) historyOk = false; 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); uint32_t hn = 0; is.read((char*)&hn, 4);
if (!is || hn > (uint32_t)wxUndoMax) historyOk = false; if (!is || hn > (uint32_t)wxUndoMax) historyOk = false;
for (uint32_t k = 0; k < hn && is; ++k) { 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); f.w.storms.resize((size_t)sc);
if (sc) is.read((char*)f.w.storms.data(), (std::streamsize)(sc * sizeof(WeatherSystem))); 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); 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(); }; auto sized = [&](const std::vector<double>& v) { return v.empty() || v.size() == planet.cells.size(); };
if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain) if (!is || !sized(f.w.humidity) || !sized(f.w.cloud) || !sized(f.w.rain)
|| f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size()) || f.w.humidity.size() != f.w.cloud.size() || f.w.humidity.size() != f.w.rain.size())
@ -339,10 +481,45 @@ void Viewer::loadGame(const char* path) {
|| !std::isfinite(ws.radius) || ws.radius <= 0.0 || !std::isfinite(ws.radius) || ws.radius <= 0.0
|| !std::isfinite(ws.age) || !std::isfinite(ws.life) || !std::isfinite(ws.age) || !std::isfinite(ws.life)
|| !std::isfinite(ws.spin)) historyOk = false; || !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.push_back(std::move(f));
} }
if (!historyOk) { wxUndo.clear(); skippedHistory = true; } 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(); 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 // 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. // 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 }); 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 }); 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); planet.stepWeather(dtWeather);
// Volcanoes: reassert vent elevations = base + built(liveTime) (pure function of the clock, so a // Volcanoes are stateful lifecycle agents; step-back restores their snapshot, then dt=0 here
// backward step rewinds island growth), and inject ash cloud + local cooling. A grown/shrunk cone // reasserts restored terrain/biome state without advancing the lifecycle.
// needs a recolor; a sea-level breach needs a biome reclassify (refreshView). VolcanoUpdate vu = planet.stepVolcanoes(dtWeather);
VolcanoUpdate vu = planet.stepVolcanoes(dtWeather, liveTime); if (dtWeather > 0.0) detectLiveEvents(beforeStorms, beforeVolcanoes);
if (vu.breach) refreshView(); if (vu.breach) refreshView();
else if (vu.recolor) recolor(); else if (vu.recolor) recolor();
rebuildLiveOverlay(); rebuildLiveOverlay();

View File

@ -15,8 +15,9 @@
// ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing).
struct Viewer { struct Viewer {
// ---- Files / save format ------------------------------------------------ // ---- Files / save format ------------------------------------------------
static constexpr uint32_t SAVE_VERSION = 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 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* CONFIG_PATH = "planet.cfg";
const char* SAVE_PATH = "planet.save"; const char* SAVE_PATH = "planet.save";
std::string configPath = "planet.cfg"; // initial config (--config overrides) 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 showClouds = true; // Live World cloud/rain cover overlay (key K)
bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V) 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). // Selection + subgrid (phase 4/5 preview).
int selectedCell = -1; int selectedCell = -1;
double selectedThresh = 0.06; double selectedThresh = 0.06;
@ -149,6 +167,11 @@ struct Viewer {
void liveStepBack(); // step everything back one frame (restores weather/storms) void liveStepBack(); // step everything back one frame (restores weather/storms)
void wxPushSnapshot(); // push the current weather state onto the step-back ring 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) 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) -------------------------------------------- // ---- Input (ViewerInput.cpp) --------------------------------------------
void handleInput(); void handleInput();

View File

@ -14,6 +14,7 @@ void Viewer::handleInput() {
bool in3D = (mp.x < view3DW && mp.y < view3DH); // top-left quadrant bool in3D = (mp.x < view3DW && mp.y < view3DH); // top-left quadrant
bool inMap = CheckCollisionPointRec(mp, mapRect); bool inMap = CheckCollisionPointRec(mp, mapRect);
bool inPanel = (selectedCell >= 0) && CheckCollisionPointRec(mp, panelRect); bool inPanel = (selectedCell >= 0) && CheckCollisionPointRec(mp, panelRect);
bool inLiveInfo = liveWorld && CheckCollisionPointRec(mp, liveInfoRect);
onPause = CheckCollisionPointRec(mp, pauseBtn); onPause = CheckCollisionPointRec(mp, pauseBtn);
// --- Camera input (LMB drag orbits; tracks drag distance for clicks) -- // --- Camera input (LMB drag orbits; tracks drag distance for clicks) --
@ -30,8 +31,21 @@ void Viewer::handleInput() {
} }
} else { } else {
dragDist = 0.0f; 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 (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) // 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) hitModel = rotateZ(hitUnit, -planet.cfg.axialTilt); // world -> model (undo tilt)
hovered = nearestCell(planet, hitModel); hovered = nearestCell(planet, hitModel);
} }
} else if (inMap) { } else if (inMap && !inLiveInfo) {
Rectangle vr = mapViewRect(); // account for 2D zoom/pan Rectangle vr = mapViewRect(); // account for 2D zoom/pan
double nx = (mp.x - vr.x) / vr.width, ny = (mp.y - vr.y) / vr.height; double nx = (mp.x - vr.x) / vr.width, ny = (mp.y - vr.y) / vr.height;
double X = (nx * 2.0 - 1.0) * EqualEarth::halfWidth(); 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) - // --- 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); selectCell(hovered);
// --- Keys ------------------------------------------------------------- // --- 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}); } 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 // Live World volcano markers: growing vents glow red/orange, dormant vents go quiet/grey,
// orange eruption glow + radial ash-plume flare when erupting -- additive so it reads on the // and post-explosion ash vents flare bright. Inside the tilted matrix, so it tracks the globe.
// night side too. Inside the tilted matrix, so it tracks the leaning globe.
if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) { if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) {
const double maxH = std::max(1.0, planet.cfg.volcanoMaxHeight); const double maxH = std::max(1.0, planet.cfg.volcanoMaxHeight);
for (const Volcano& vc : planet.volcanoes) { for (const Volcano& vc : planet.volcanoes) {
@ -169,20 +168,23 @@ void Viewer::renderGlobe3D() {
const Cell& c = planet.cells[vc.cell]; const Cell& c = planet.cells[vc.cell];
Vec3 u = c.unit; Vec3 u = c.unit;
float r = visBase + (float)c.elevation * elevExagg; float r = visBase + (float)c.elevation * elevExagg;
double bf = std::clamp(planet.volcanoBuilt(vc, liveTime) / maxH, 0.0, 1.0); double bf = std::clamp(planet.volcanoBuilt(vc) / maxH, 0.0, 1.0);
double er = planet.volcanoErupting(vc, liveTime); double er = planet.volcanoErupting(vc);
float coneH = 0.022f + 0.045f * (float)bf; float coneH = 0.022f + 0.045f * (float)bf;
float coneR = 0.015f + 0.018f * (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 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)) }; 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); 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); 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}); Color glow = vc.ashTimer > 0.0 ? Color{255, 210, 95, a} : Color{255, 140, 40, a};
float ph = coneH + 0.12f * (float)er; 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)) }; 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); rlVertex3f(apex.x, apex.y, apex.z); rlVertex3f(top.x, top.y, top.z);
rlEnd(); rlSetLineWidth(1.0f); rlEnd(); rlSetLineWidth(1.0f);
} }
@ -297,13 +299,15 @@ void Viewer::renderMap2D() {
if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) { if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) {
for (const Volcano& vc : planet.volcanoes) { for (const Volcano& vc : planet.volcanoes) {
if (vc.cell < 0 || vc.cell >= (int)planet.cells.size()) continue; 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); double lon, lat; dirToLonLat(planet.cells[vc.cell].unit, lon, lat);
Vector2 sp = projLonLat(lon, lat, mapLon, vr); Vector2 sp = projLonLat(lon, lat, mapLon, vr);
float s = (5.0f + 3.0f * (float)er) * (float)std::min(2.0, mapZoom); 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) 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, 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)}); 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}); (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: // Live World tabbed info 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.
void Viewer::renderLiveInfo() { void Viewer::renderLiveInfo() {
liveInfoTabRects.clear(); eventRowRects.clear(); eventRowIndices.clear();
if (!liveWorld) return; if (!liveWorld) return;
Rectangle r = liveInfoRect; Rectangle r = liveInfoRect;
DrawRectangleRec(r, Color{10, 12, 20, 235}); DrawRectangleRec(r, Color{10, 12, 20, 235});
DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255}); DrawRectangleLinesEx(r, 1, Color{90, 90, 110, 255});
int x = (int)r.x + 14, y = (int)r.y + 10; 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). // Sky geometry at the current clock (recomputed here so the panel is self-contained).
const double dayH = planet.cfg.dayLengthHours, yrD = planet.cfg.yearLengthDays; const double dayH = planet.cfg.dayLengthHours, yrD = planet.cfg.yearLengthDays;
@ -371,71 +389,91 @@ void Viewer::renderLiveInfo() {
}; };
const auto& mns = planet.getMoons(); const auto& mns = planet.getMoons();
for (size_t m = 0; m < mns.size(); ++m) { if (liveInfoTab == 0) {
Vec3 md = planet.moonDirection((int)m, tod, days); for (size_t m = 0; m < mns.size(); ++m) {
Vec3 md2 = planet.moonDirection((int)m, tod2, days2); Vec3 md = planet.moonDirection((int)m, tod, days);
double f = illumFrac(md, sun); Vec3 md2 = planet.moonDirection((int)m, tod2, days2);
bool wax = illumFrac(md2, sun2) >= f; double f = illumFrac(md, sun);
float cy = (float)y + 20.0f; bool wax = illumFrac(md2, sun2) >= f;
drawPhase((float)x + 22.0f, cy, 20.0f, f, wax); float cy = (float)y + 20.0f;
DrawText(TextFormat("Moon %d: %s", (int)m + 1, phaseName(f, wax)), x + 52, y + 6, 17, Color{210, 215, 225, 255}); drawPhase((float)x + 22.0f, cy, 20.0f, f, wax);
DrawText(TextFormat("%.0f%% lit period %.0f d", f * 100.0, mns[m].periodDays), x + 52, y + 27, 15, Color{150, 160, 175, 255}); DrawText(TextFormat("Moon %d: %s", (int)m + 1, phaseName(f, wax)), x + 52, y + 6, 17, Color{210, 215, 225, 255});
y += 50; 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; } }
if (mns.empty()) DrawText("(no moons)", x, y, 16, Color{150, 155, 170, 255});
// Tidal phase for a selected coastal tile (placeholder: high/low + rising/falling). } else if (liveInfoTab == 1) {
y += 8; DrawText("Tidal phase", x, y, 18, Color{200, 205, 220, 255}); y += 28;
DrawText("Tidal phase", x, y, 18, Color{200, 205, 220, 255}); y += 26; if (selectedCell >= 0 && selectedCell < (int)planet.cells.size()) {
if (selectedCell >= 0 && selectedCell < (int)planet.cells.size()) { const Cell& c = planet.cells[selectedCell];
const Cell& c = planet.cells[selectedCell]; const double sea = planet.cfg.seaLevel;
const double sea = planet.cfg.seaLevel; bool selLand = c.elevation > sea, coastal = false;
bool selLand = c.elevation > sea, coastal = false; for (int nb : c.neighbors) if ((planet.cells[nb].elevation > sea) != selLand) { coastal = true; break; }
for (int nb : c.neighbors) if ((planet.cells[nb].elevation > sea) != selLand) { coastal = true; break; } if (coastal) {
if (coastal) { auto cellTide = [&](double dy, double td, double dd) {
// Single-cell tide now vs a step ahead -> rising/falling (the field itself is the double h = 0.0;
// equilibrium tide; a richer coastal/resonant model is future work). for (int mm = 0; mm < (int)mns.size(); ++mm) {
auto cellTide = [&](double dy, double td, double dd) { double cc = c.unit.dot(planet.moonDirection(mm, td, dd));
double h = 0.0; h += mns[mm].tideWeight * (cc * cc - 1.0 / 3.0);
for (int mm = 0; mm < (int)mns.size(); ++mm) { }
double cc = c.unit.dot(planet.moonDirection(mm, td, dd)); double cs = c.unit.dot(planet.sunDirection(dy, td));
h += mns[mm].tideWeight * (cc * cc - 1.0 / 3.0); h += planet.cfg.tideSunFactor * (cs * cs - 1.0 / 3.0);
} return h * planet.cfg.tideAmplitude;
double cs = c.unit.dot(planet.sunDirection(dy, td)); };
h += planet.cfg.tideSunFactor * (cs * cs - 1.0 / 3.0); bool rising = cellTide(doy2, tod2, days2) >= cellTide(doy, tod, days);
return h * planet.cfg.tideAmplitude; double lvl = ((int)planet.tide().size() == (int)planet.cells.size()) ? planet.tide()[selectedCell]
}; : cellTide(doy, tod, days);
bool rising = cellTide(doy2, tod2, days2) >= cellTide(doy, tod, days); // direction DrawText(TextFormat("coastal cell #%d", selectedCell), x, y, 15, Color{160, 170, 185, 255}); y += 22;
// Level from the actual tide field (so the enclosed-sea cap is reflected here too). DrawText(TextFormat("%+.2f m %s, %s", lvl, lvl >= 0.0 ? "high" : "low", rising ? "rising" : "falling"),
double lvl = ((int)planet.tide().size() == (int)planet.cells.size()) ? planet.tide()[selectedCell] x, y, 17, tideColor(lvl, std::max(0.05, std::fabs(lvl)))); y += 25;
: cellTide(doy, tod, days); DrawText("(equilibrium model - placeholder)", x, y, 13, Color{120, 125, 140, 255});
DrawText(TextFormat("coastal cell #%d", selectedCell), x, y, 15, Color{160, 170, 185, 255}); y += 21; } else DrawText("selected tile is inland", x, y, 15, Color{150, 155, 170, 255});
DrawText(TextFormat("%+.2f m %s, %s", lvl, lvl >= 0.0 ? "high" : "low", rising ? "rising" : "falling"), } else DrawText("click a coastal tile", x, y, 15, Color{150, 155, 170, 255});
x, y, 17, tideColor(lvl, std::max(0.05, std::fabs(lvl)))); y += 24; } else if (liveInfoTab == 2) {
DrawText("(equilibrium model - placeholder)", x, y, 13, Color{120, 125, 140, 255}); DrawText("Weather systems", x, y, 18, Color{200, 205, 220, 255}); y += 28;
} else { const auto& storms = planet.storms();
DrawText("selected tile is inland", 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 >= 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")
: 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 { } else {
DrawText("click a coastal tile", x, y, 15, Color{150, 155, 170, 255}); 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;
// Active weather systems (lows / tropical cyclones), named by basin. if (events.empty()) {
y += 12; DrawText("(no events yet)", x, y, 15, Color{150, 155, 170, 255});
DrawText("Weather systems", x, y, 18, Color{200, 205, 220, 255}); y += 26; } else {
const auto& storms = planet.storms(); for (int ei = (int)events.size() - 1; ei >= 0; --ei) {
if (storms.empty()) DrawText("(calm — none active)", x, y, 15, Color{150, 155, 170, 255}); if (y > (int)(r.y + r.height) - 42) break;
int shown = 0; const WorldEvent& e = events[ei];
for (const auto& ws : storms) { Rectangle row{ r.x + 8.0f, (float)y - 3.0f, r.width - 16.0f, 40.0f };
if (shown >= 6 || y > (int)(r.y + r.height) - 22) break; eventRowRects.push_back(row); eventRowIndices.push_back(ei);
double lon, lat; dirToLonLat(Vec3{ws.pos.x, ws.pos.y, ws.pos.z}, lon, lat); Color bg = e.severity >= 2 ? Color{58, 30, 34, 210}
bool hur = ws.tropical && ws.strength >= planet.cfg.weatherHurricaneStr; : e.severity == 1 ? Color{42, 42, 34, 205}
const char* kind = hur ? (lon > -0.5 && lon < 2.4 ? "Typhoon" : "Hurricane") // W Pacific vs rest : Color{18, 22, 34, 205};
: ws.tropical ? "Tropical low" : "Low"; Color fg = e.severity >= 2 ? Color{250, 130, 95, 255}
Color c = hur ? Color{240, 90, 80, 255} : Color{170, 200, 230, 255}; : e.severity == 1 ? Color{230, 190, 95, 255}
DrawText(TextFormat("%s %.0f%% @ %+.0f,%+.0f", kind, ws.strength * 100.0, : Color{175, 205, 235, 255};
lat * 180.0 / M_PI, lon * 180.0 / M_PI), x, y, 15, c); DrawRectangleRec(row, bg);
y += 21; ++shown; 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;
}
}
} }
} }

View File

@ -15,7 +15,7 @@ public:
std::vector<Cell> cells; std::vector<Cell> cells;
std::vector<Plate> plates; 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) std::vector<Volcano> volcanoes; // Live World: stateful lifecycle volcanoes (saved v15)
// Phase flag: false during Phase-1 forming (modest, original tectonics that // Phase flag: false during Phase-1 forming (modest, original tectonics that
// settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts
@ -108,15 +108,14 @@ public:
// Volcanoes (Live World, PlanetVolcano.cpp). placeVolcanoes() runs once on entering Live World: // 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) // 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() // from a separate RNG (tectonic determinism intact), capturing each vent's baseElev and initial
// runs each live frame: it reasserts each vent's cell elevation = baseElev + built(liveTime) (build // built height. stepVolcanoes() integrates growth/dormancy/explosions forward, reasserts vent
// + eruption intensity are PURE FUNCTIONS of liveTime, so the stepper rewinds them), breaches // elevations, breaches submarine vents into islands, and injects ash cloud + local cooling.
// submarine vents into islands, and injects ash cloud + local cooling into the weather/climate // Saved (v15); step-back restores them via WeatherSnapshot.
// fields. Saved (v14). volcanoErupting(i)/volcanoBuilt(i) report a vent's current state for rendering.
void placeVolcanoes(double liveTime = 0.0); void placeVolcanoes(double liveTime = 0.0);
VolcanoUpdate stepVolcanoes(double dtHours, double liveTime); VolcanoUpdate stepVolcanoes(double dtHours);
double volcanoBuilt(const Volcano& v, double liveTime) const; // m built above baseElev at liveTime double volcanoBuilt(const Volcano& v) const; // m built above baseElev
double volcanoErupting(const Volcano& v, double liveTime) const; // 0..1 current eruption intensity 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 // Phase 3 (biomes): classify every cell into a Biome from elevation + the climate
// fields (temperature + normalized precipitation). Derived + written back into // 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 // hasMoons: whether the stream carries the moons block (save v9+); older saves
// synthesize moons from the seed instead. hasWeather: the weather block (save v10+); // synthesize moons from the seed instead. hasWeather: the weather block (save v10+);
// older saves leave weather to spin up on entering Live World. // older saves leave weather to spin up on entering Live World.
// hasVolcanoes: whether the stream carries the volcano block (save v14+); older saves load // hasVolcanoes: whether the stream carries a volcano block (save v14+). hasStatefulVolcanoes
// with no volcanoes (they are placed on the next Live World entry). // 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 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); bool hasVolcanoes = true, bool hasStatefulVolcanoes = true);
// Helpers for rendering / info. // Helpers for rendering / info.
double cellWidthMeters() const; // approx lateral cell spacing double cellWidthMeters() const; // approx lateral cell spacing
@ -242,8 +241,8 @@ private:
std::vector<WeatherSystem> sStorms; std::vector<WeatherSystem> sStorms;
uint32_t sWeatherRng = 1; uint32_t sWeatherRng = 1;
uint32_t sStormNextId = 1; // monotonic id for follow-cam tracking uint32_t sStormNextId = 1; // monotonic id for follow-cam tracking
// Volcanoes (Live World; saved v14). Separate RNG (seeded from cfg.seed in placeVolcanoes) // Volcanoes (Live World; saved v15). Separate RNG (seeded from cfg.seed in placeVolcanoes)
// keeps tectonic determinism intact; eruptions are pure functions of liveTime (no per-step RNG). // keeps tectonic determinism intact while lifecycle rolls happen during Live World.
uint32_t sVolRng = 1; uint32_t sVolRng = 1;
// Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // 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(weatherSpawnRate) D(weatherSystemSpeed) D(weatherTropicalSST) D(weatherSystemRadius) \
D(weatherSystemCloud) D(weatherSystemRain) D(weatherHurricaneStr) \ D(weatherSystemCloud) D(weatherSystemRain) D(weatherHurricaneStr) \
D(volcanoProbRidge) D(volcanoProbBorder) D(volcanoProbInterior) \ D(volcanoProbRidge) D(volcanoProbBorder) D(volcanoProbInterior) \
D(volcanoBuildStep) D(volcanoMaxHeight) D(volcanoEruptFreq) \ D(volcanoBuildRate) D(volcanoFreeHeight) D(volcanoInitialBuildMax) D(volcanoMaxHeight) \
D(volcanoAshCloud) D(volcanoAshCooling) \ 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(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \
I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \
I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \
@ -222,9 +225,21 @@ std::string validateConfig(const PlanetConfig& cfg) {
E(rng(cfg.volcanoProbRidge, 0.0, 1.0, "volcanoProbRidge")); E(rng(cfg.volcanoProbRidge, 0.0, 1.0, "volcanoProbRidge"));
E(rng(cfg.volcanoProbBorder, 0.0, 1.0, "volcanoProbBorder")); E(rng(cfg.volcanoProbBorder, 0.0, 1.0, "volcanoProbBorder"));
E(rng(cfg.volcanoProbInterior, 0.0, 1.0, "volcanoProbInterior")); E(rng(cfg.volcanoProbInterior, 0.0, 1.0, "volcanoProbInterior"));
E(rng(cfg.volcanoBuildStep, 0.0, 5000.0, "volcanoBuildStep")); E(rng(cfg.volcanoBuildRate, 0.0, 1000.0, "volcanoBuildRate"));
E(rng(cfg.volcanoMaxHeight, 0.0, 12000.0, "volcanoMaxHeight")); E(rng(cfg.volcanoFreeHeight, -11000.0, 12000.0, "volcanoFreeHeight"));
E(rng(cfg.volcanoEruptFreq, 0.0, 100.0, "volcanoEruptFreq")); 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.volcanoAshCloud, 0.0, 10.0, "volcanoAshCloud"));
E(rng(cfg.volcanoAshCooling, 0.0, 40.0, "volcanoAshCooling")); E(rng(cfg.volcanoAshCooling, 0.0, 40.0, "volcanoAshCooling"));
E(irng(cfg.subdivisions, 0, 7, "subdivisions")); 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)"); bad.push_back("oceanBase >= continentBase (ocean floor must be below continents)");
if (cfg.peakSoftCapStart >= cfg.peakSoftCapEnd) if (cfg.peakSoftCapStart >= cfg.peakSoftCapEnd)
bad.push_back("peakSoftCapStart >= peakSoftCapEnd (grow probability must span a band)"); 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 {}; if (bad.empty()) return {};
std::string msg = "Bad config:"; std::string msg = "Bad config:";
@ -261,6 +280,16 @@ std::string validateConfig(const PlanetConfig& cfg) {
} }
namespace { 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) { template <class T> void writePod(std::ostream& os, const T& v) {
static_assert(std::is_trivially_copyable<T>::value, "writePod needs a POD type"); static_assert(std::is_trivially_copyable<T>::value, "writePod needs a POD type");
os.write(reinterpret_cast<const char*>(&v), sizeof(T)); 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, sHumidity); writeVec(os, sCloud); writeVec(os, sRain);
writeVec(os, sStorms); writePod(os, sWeatherRng); writePod(os, sStormNextId); // v11 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 // v15: Live World volcanoes are stateful lifecycle agents. Always written from v15;
// function of liveTime, so only the placed set + its RNG need saving). Always written from v14;
// older readers stop before this block. // older readers stop before this block.
writeVec(os, volcanoes); writeVec(os, volcanoes);
writePod(os, sVolRng); writePod(os, sVolRng);
} }
bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons,
bool hasWeather, bool hasStorms, bool hasVolcanoes) { bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes) {
// Save blocks are append-only by version. If a caller asks for an older prefix, // Save blocks are append-only by version. If a caller asks for an older prefix,
// later blocks cannot exist in that stream even if the default arguments say otherwise. // later blocks cannot exist in that stream even if the default arguments say otherwise.
if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; } if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; }
@ -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; volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au;
if (hasVolcanoes) { 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; if (!readVec(is, volcanoes, 100000)) return false;
readPod(is, sVolRng); readPod(is, sVolRng);
if (!is) return false; if (!is) return false;
const int nc2 = (int)cells.size(); const int nc2 = (int)cells.size();
for (const Volcano& v : volcanoes) for (const Volcano& v : volcanoes)
if (v.cell < 0 || v.cell >= nc2 || !std::isfinite(v.activity) 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 computeBiotaDensity(); // derived density scalars for the colour views
return (bool)is; 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 // 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 // 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 // plate borders, low elsewhere (hotspots). It is a small stateful lifecycle agent: it grows, can
// cell up into new islands. Eruption state is a PURE FUNCTION of liveTime (built height + intensity // go dormant, explodes after dormancy, then regrows weaker. Saved (v15).
// recomputed each frame, never integrated) so the live stepper rewinds it for free. Saved (v14).
struct Volcano { struct Volcano {
uint32_t id = 0; // stable id (markers / cell-info) uint32_t id = 0; // stable id (markers / cell-info)
int cell = -1; // the grid cell it sits on (fixed geometry) 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 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 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 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 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: // 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 { struct WeatherSnapshot {
std::vector<double> humidity, cloud, rain; std::vector<double> humidity, cloud, rain;
std::vector<WeatherSystem> storms; std::vector<WeatherSystem> storms;
std::vector<Volcano> volcanoes;
uint32_t rng = 0, nextId = 0; uint32_t rng = 0, nextId = 0;
uint32_t volRng = 0;
}; };
struct Plate { struct Plate {
@ -338,9 +343,21 @@ struct PlanetConfig {
double volcanoProbBorder = 0.06; // per-cell placement prob on a normal plate-border 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) double volcanoProbInterior = 0.003; // per-cell placement prob elsewhere (intraplate hotspots)
int volcanoMaxCount = 60; // global cap on placed volcanoes int volcanoMaxCount = 60; // global cap on placed volcanoes
double volcanoBuildStep = 130.0; // m of cone/island growth per eruption pulse double volcanoBuildRate = 0.02; // m/h at activity=1 while growing
double volcanoMaxHeight = 3200.0;// m: max height a volcano builds above its baseElev double volcanoFreeHeight = 1000.0;// m absolute elevation below which vents cannot go dormant
double volcanoEruptFreq = 0.05; // eruption pulses per (hour * activity) -- cadence 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 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 double volcanoAshCooling = 6.0; // C: peak local cooling under an active ash plume
}; };

View File

@ -1,49 +1,53 @@
#include "Planet.hpp" #include "Planet.hpp"
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <vector>
// --- Volcanoes (Live World) -------------------------------------------------- // --- Volcanoes (Live World) --------------------------------------------------
// A volcano is a fixed point on the grid (one cell), placed ONCE when the world // Volcanoes are fixed vents placed once by tectonic context when the world enters
// enters Live World by tectonic context: very high probability on young spreading // Live World. They are stateful agents: growing vents build height, tall vents can
// ridges ("new plate" / baby plates), medium on normal plate borders, low elsewhere // go dormant, dormant vents eventually explode, shave their peak, emit ash, and
// (intraplate hotspots). Over the live clock it erupts; submarine vents build their // regrow weaker. The state is saved and included in the Live World step-back
// cell up until it breaches sea level into a new volcanic island. // snapshot, while all random rolls use sVolRng so tectonic determinism is untouched.
//
// 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 namespace {
// cone up, capped at volcanoMaxHeight -- monotonic and a pure function of liveTime. constexpr double YEAR_HOURS = 24.0 * 365.25;
double Planet::volcanoBuilt(const Volcano& v, double liveTime) const {
double age = liveTime - v.tStart; uint32_t volNext(uint32_t& rng) {
if (age <= 0.0) return 0.0; rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5; return rng;
double pulses = std::floor(age * cfg.volcanoEruptFreq * std::max(0.0, v.activity)); }
return std::min(cfg.volcanoMaxHeight, pulses * cfg.volcanoBuildStep); 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, double Planet::volcanoBuilt(const Volcano& v) const {
// decaying through the cycle -- so a vent mostly smoulders and briefly erupts. Pure return std::max(0.0, v.built);
// function of liveTime. }
double Planet::volcanoErupting(const Volcano& v, double liveTime) const {
double age = liveTime - v.tStart; double Planet::volcanoErupting(const Volcano& v) const {
if (age < 0.0) return 0.0; if (v.ashTimer > 0.0) return 1.0;
double prog = age * cfg.volcanoEruptFreq * std::max(0.0, v.activity); if (v.phase == 1) return 0.0;
double frac = prog - std::floor(prog); // 0 just after a pulse .. 1 just before the next if (v.activity <= cfg.volcanoDeadActivity) return 0.05;
return std::exp(-frac * 4.0); // ~1 at frac 0, ~0.13 at frac 0.5 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 // 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. // kept set is an unbiased random subset of all cells that pass their context probability.
void Planet::placeVolcanoes(double liveTime) { 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(); volcanoes.clear();
sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au; 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 int n = (int)cells.size();
const double sea = cfg.seaLevel; const double sea = cfg.seaLevel;
const int cap = std::max(0, cfg.volcanoMaxCount); const int cap = std::max(0, cfg.volcanoMaxCount);
@ -61,38 +65,53 @@ void Planet::placeVolcanoes(double liveTime) {
if (ridge) { kind = 0; prob = cfg.volcanoProbRidge; } if (ridge) { kind = 0; prob = cfg.volcanoProbRidge; }
else if (border) { kind = 1; prob = cfg.volcanoProbBorder; } else if (border) { kind = 1; prob = cfg.volcanoProbBorder; }
else { kind = 2; prob = cfg.volcanoProbInterior; } else { kind = 2; prob = cfg.volcanoProbInterior; }
if (rf() >= prob) continue; if (volRf(sVolRng) >= prob) continue;
Volcano v; Volcano v;
v.cell = i; v.cell = i;
v.kind = (uint8_t)kind; v.kind = (uint8_t)kind;
v.submarine = (cells[i].elevation <= sea) ? 1 : 0; v.submarine = (cells[i].elevation <= sea) ? 1 : 0;
v.phase = 0;
v.baseElev = cells[i].elevation; v.baseElev = cells[i].elevation;
v.tStart = liveTime;
double base = (kind == 0) ? 0.70 : (kind == 1) ? 0.50 : 0.35; // ridges more vigorous 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; ++passed;
if ((int)volcanoes.size() < cap) volcanoes.push_back(v); 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); 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 + VolcanoUpdate Planet::stepVolcanoes(double dtHours) {
// 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; VolcanoUpdate up;
if (volcanoes.empty()) return up; if (volcanoes.empty()) return up;
const int n = (int)cells.size(); const int n = (int)cells.size();
const double sea = cfg.seaLevel; const double sea = cfg.seaLevel;
for (Volcano& v : volcanoes) { const double minDormYears = std::min(cfg.volcanoDormantMinYears, cfg.volcanoDormantMaxYears);
if (v.cell < 0 || v.cell >= n) continue; const double maxDormYears = std::max(cfg.volcanoDormantMinYears, cfg.volcanoDormantMaxYears);
double newElev = v.baseElev + volcanoBuilt(v, liveTime); 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; double& e = cells[v.cell].elevation;
if (std::fabs(newElev - e) > 0.5) up.recolor = true; if (std::fabs(newElev - e) > 0.5) up.recolor = true;
e = newElev; e = newElev;
// Submarine vent crossing sea level -> a new island (or, on a step back, re-submerged).
if (v.submarine) { if (v.submarine) {
bool land = newElev > sea; bool land = newElev > sea;
if (land && cells[v.cell].oceanic) { 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; cells[v.cell].oceanic = true; cells[v.cell].biome = Biome::Ocean; up.breach = true;
} }
} }
double intensity = volcanoErupting(v, liveTime); };
if (intensity > 0.05) { auto blastAsh = [&](const Volcano& v) {
// Ash plume -> the integrated weather field (advects downwind, reverts on step-back via if (v.cell < 0 || v.cell >= n) return;
// the weather snapshot). Only on a forward step (dtHours > 0). double cosR = std::cos(std::max(0.0, cfg.volcanoBlastRadius));
if (dtHours > 0.0 && !sCloud.empty()) { Vec3 center = cells[v.cell].unit;
int c = v.cell; for (int i = 0; i < n; ++i) {
sCloud[c] = std::min(2.0, sCloud[c] + cfg.volcanoAshCloud * intensity * dtHours); double d = center.dot(cells[i].unit);
if (!sHumidity.empty()) if (d < cosR) continue;
sHumidity[c] = std::min(2.0, sHumidity[c] + 0.3 * cfg.volcanoAshCloud * intensity * dtHours); 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; return up;
} }

View File

@ -30,12 +30,14 @@ WeatherSnapshot Planet::captureWeather() const {
WeatherSnapshot s; WeatherSnapshot s;
s.humidity = sHumidity; s.cloud = sCloud; s.rain = sRain; s.humidity = sHumidity; s.cloud = sCloud; s.rain = sRain;
s.storms = sStorms; s.rng = sWeatherRng; s.nextId = sStormNextId; s.storms = sStorms; s.rng = sWeatherRng; s.nextId = sStormNextId;
s.volcanoes = volcanoes; s.volRng = sVolRng;
return s; return s;
} }
void Planet::restoreWeather(const WeatherSnapshot& s) { void Planet::restoreWeather(const WeatherSnapshot& s) {
sHumidity = s.humidity; sCloud = s.cloud; sRain = s.rain; sHumidity = s.humidity; sCloud = s.cloud; sRain = s.rain;
sStorms = s.storms; sWeatherRng = s.rng; sStormNextId = s.nextId; sStorms = s.storms; sWeatherRng = s.rng; sStormNextId = s.nextId;
volcanoes = s.volcanoes; sVolRng = s.volRng;
sHasWeather = !sHumidity.empty(); 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 // Headless test for Live World volcanoes (stateful growth, dormancy, explosions, ash,
// building). No display needed. // 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 \ // 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/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/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \
// src/sim/PlanetVolcano.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.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 // 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 "Planet.hpp"
#include <cstdio> #include <cstdio>
@ -21,6 +16,8 @@
#include <sstream> #include <sstream>
static int failures = 0; static int failures = 0;
static constexpr double YEAR_HOURS = 24.0 * 365.25;
static void check(bool cond, const char* what) { static void check(bool cond, const char* what) {
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what); std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
if (!cond) ++failures; 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; } 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); return ridge ? 0 : (border ? 1 : 2);
} }
static bool sameVolcanoes(const std::vector<Volcano>& a, const std::vector<Volcano>& b) { static bool sameVolcanoes(const std::vector<Volcano>& a, const std::vector<Volcano>& b) {
if (a.size() != b.size()) return false; if (a.size() != b.size()) return false;
for (size_t i = 0; i < a.size(); ++i) 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 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].submarine != b[i].submarine || a[i].phase != b[i].phase
|| a[i].baseElev != b[i].baseElev || a[i].tStart != b[i].tStart) return false; || 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; 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() { int main() {
PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 9090; PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 9090;
Planet p; p.generate(cfg); settle(p); drift(p, 120); Planet p; p.generate(cfg); settle(p); drift(p, 120);
const int n = (int)p.cells.size(); 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); std::vector<Volcano> first = p.volcanoes;
p.placeVolcanoes(0.0); p.placeVolcanoes(0.0);
check(!first.empty(), "placeVolcanoes places a non-empty set"); check(!first.empty(), "placeVolcanoes places a non-empty set");
@ -72,6 +80,7 @@ int main() {
std::printf("Volcanoes: RNG isolation from tectonics\n"); std::printf("Volcanoes: RNG isolation from tectonics\n");
Planet a; a.generate(cfg); settle(a); Planet a; a.generate(cfg); settle(a);
Planet b; b.generate(cfg); settle(b); 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) { for (int k = 0; k < 40; ++k) {
double dta = a.cflDtMy(); a.advect(dta); a.step(); a.erode(dta); double dta = a.cflDtMy(); a.advect(dta); a.step(); a.erode(dta);
double dtb = b.cflDtMy(); b.advect(dtb); b.step(); b.erode(dtb); 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; 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; } 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); 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.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); p.placeVolcanoes(0.0);
bool noInterior = true; for (const Volcano& v : p.volcanoes) if (v.kind == 2) noInterior = false; 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(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"); 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"); } 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"); std::printf("Volcanoes: initial built height can make islands immediately\n");
Planet q; q.generate(cfg); settle(q); drift(q, 120); Planet pre; pre.generate(cfg); settle(pre); drift(pre, 120);
q.cfg.volcanoMaxHeight = 9000.0; q.cfg.volcanoBuildStep = 130.0; q.cfg.volcanoEruptFreq = 0.05; pre.cfg.volcanoProbRidge = pre.cfg.volcanoProbBorder = pre.cfg.volcanoProbInterior = 1.0;
q.placeVolcanoes(0.0); pre.cfg.volcanoMaxCount = 1000000;
int vi = -1; double best = -1e18; pre.cfg.volcanoInitialBuildMax = 10000.0;
for (size_t k = 0; k < q.volcanoes.size(); ++k) pre.placeVolcanoes(0.0);
if (q.volcanoes[k].submarine && q.volcanoes[k].baseElev > best) { best = q.volcanoes[k].baseElev; vi = (int)k; } bool someBuilt = false, instantIsland = false;
check(vi >= 0, "at least one submarine volcano was placed"); for (const Volcano& v : pre.volcanoes) {
if (vi >= 0) { if (v.built > 0.0) someBuilt = true;
const Volcano v = q.volcanoes[vi]; if (v.submarine && pre.cells[v.cell].elevation > pre.cfg.seaLevel && !pre.cells[v.cell].oceanic)
double b0 = q.volcanoBuilt(v, 0.0), b1 = q.volcanoBuilt(v, 5000.0), instantIsland = true;
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(someBuilt, "placement assigns nonzero pre-built height");
check(b3 > b0, "a submarine vent builds up over time"); check(instantIsland, "a pre-built submarine vent can breach into an island on entry");
check(q.volcanoBuilt(v, 5000.0) == b1, "volcanoBuilt is deterministic (pure function of t)");
q.stepVolcanoes(1.0, 500000.0); std::printf("Volcanoes: forward stepping grows statefully\n");
check(q.cells[v.cell].elevation > q.cfg.seaLevel, "submarine volcano breaches sea level into an island"); Planet g; g.generate(cfg); settle(g); drift(g, 80);
check(!q.cells[v.cell].oceanic, "the breached island is land crust"); g.cfg.volcanoProbRidge = g.cfg.volcanoProbBorder = g.cfg.volcanoProbInterior = 1.0;
// Step the clock back to the start: the island must recede (pure function of liveTime). g.cfg.volcanoMaxCount = 1; g.cfg.volcanoInitialBuildMax = 0.0;
q.stepVolcanoes(0.0, 0.0); g.cfg.volcanoBuildRate = 10.0; g.cfg.volcanoFreeHeight = 1e9; g.cfg.volcanoDeadActivity = 0.0;
check(q.cells[v.cell].elevation <= q.cfg.seaLevel + 1e-6, "stepping the clock back recedes the island"); g.placeVolcanoes(0.0);
check(std::fabs(q.cells[v.cell].elevation - (v.baseElev + q.volcanoBuilt(v, 0.0))) < 1e-6, check(!g.volcanoes.empty(), "one growth-test volcano placed");
"vent elevation = baseElev + built(liveTime)"); 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"); std::printf("Volcanoes: forced dormancy, explosion, ash blast and activity decay\n");
Planet w; w.generate(cfg); settle(w); Planet x; x.generate(cfg); settle(x); drift(x, 80);
w.initWeather(); x.initWeather();
w.computeInsolation(0.25, 0.3); x.computeInsolation(0.25, 0.3);
w.placeVolcanoes(0.0); // tStart = 0 -> at liveTime 0 every vent is at peak eruption intensity x.computeLiveSeason(0.25);
check(!w.volcanoes.empty(), "volcanoes placed for the ash test"); x.cfg.volcanoProbRidge = x.cfg.volcanoProbBorder = x.cfg.volcanoProbInterior = 1.0;
if (!w.volcanoes.empty()) { x.cfg.volcanoMaxCount = 1; x.cfg.volcanoInitialBuildMax = 0.0;
std::vector<double> before = w.cloud(); x.cfg.volcanoFreeHeight = -1e9; x.cfg.volcanoMaxHeight = 1.0;
w.stepVolcanoes(1.0, 0.0); // dtHours > 0 -> inject ash x.cfg.volcanoDormancyRate = YEAR_HOURS * 1000.0;
bool rose = false; x.cfg.volcanoDormantMinYears = x.cfg.volcanoDormantMaxYears = 0.0;
for (const Volcano& vv : w.volcanoes) x.cfg.volcanoExplodeDropFrac = 0.20; x.cfg.volcanoActivityDecay = 0.70;
if (w.cloud()[vv.cell] > before[vv.cell] + 1e-9) rose = true; x.cfg.volcanoAshMinYears = x.cfg.volcanoAshMaxYears = 0.01;
check(rose, "an erupting vent thickens the cloud at its cell"); 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); std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary);
q.writeState(ss); x.writeState(ss);
ss.seekg(0);
Planet r; Planet r;
bool ok = r.readState(ss, true, true, true, true, true, true); bool ok = r.readState(ss, true, true, true, true, true, true, true);
check(ok, "readState accepts a v14 stream"); check(ok, "readState accepts a v15 stream");
check(sameVolcanoes(q.volcanoes, r.volcanoes), "volcano set round-trips through save"); 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); std::printf(failures ? "\nFAILURES: %d\n" : "\nALL VOLCANO CHECKS PASSED\n", failures);