diff --git a/BUILD.md b/BUILD.md index 5985f71..d985b6c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -52,6 +52,7 @@ the full ~2.8x speedup; the default uses all cores for no extra gain: T toggle the tide-coloured coastline (Live World) O toggle ocean-current arrows (warm = poleward/red, cold = equatorward/blue) K toggle weather clouds/rain cover (Live World) + V toggle volcano markers (Live World; cones + eruption glow, build into islands) Y follow-cam: cycle the 3D camera through active storms (Live World; off after last) . / , step the live clock forward / back by one rate-unit (auto-pauses; back also rewinds weather + storms via an undo history) @@ -229,18 +230,33 @@ saved v10. Evaporate over warm seas -> advect along the wind -> condense -> rain weatherSystemRain 1.6 /h rain at a system core weatherHurricaneStr 0.6 strength above which a tropical system is a hurricane/typhoon +Volcanoes (PlanetConfig, Live World, key V): placed by tectonic context on entering Live World, +erupt on the live clock, build submarine vents into new islands (eruption state is a pure +function of liveTime, so the stepper rewinds it). Saved v14. + + volcanoProbRidge 0.55 per-cell placement prob on a young spreading-ridge cell + volcanoProbBorder 0.06 per-cell placement prob on a normal plate-border cell + volcanoProbInterior 0.003 per-cell placement prob elsewhere (hotspots) + volcanoMaxCount 60 global cap (reservoir-sampled, ratios preserved) + volcanoBuildStep 130 m cone/island growth per eruption pulse + volcanoMaxHeight 3200 m max height built above a vent's base elevation + volcanoEruptFreq 0.05 eruption pulses per (hour * activity) -- cadence + volcanoAshCloud 0.9 cloud cover injected at the vent per erupting hour + volcanoAshCooling 6 C peak local cooling under an active ash plume + ## Headless logic test (no display) g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \ src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \ src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \ - src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetBiota.cpp \ + src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetVolcano.cpp \ + src/sim/PlanetBiota.cpp \ src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \ src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t - # Biota / Live World / Ocean / Weather suites: same source list, swap test_logic.cpp -> - # test_biota.cpp, test_live.cpp, test_ocean.cpp or test_weather.cpp + # Biota / Live World / Ocean / Weather / Volcano suites: same source list, swap test_logic.cpp -> + # test_biota.cpp, test_live.cpp, test_ocean.cpp, test_weather.cpp or test_volcano.cpp Verifies geometry, plate assignment, gradual non-saturating relief and determinism. Run after changing Planet::step(). diff --git a/CLAUDE.md b/CLAUDE.md index 61409f3..17988cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,16 @@ the fixed-grid Eulerian model + the climate fields are the groundwork for it. tropical ones are **hurricanes/typhoons** (spin by hemisphere, eye + animated spiral marker). Saved (v11, so a load resumes active storms). This makes the weather visibly move (the base field alone relaxes to a static pattern). +- **Volcanoes & volcanic islands** *(done — see `PlanetVolcano.cpp`)* — on entering Live World a + one-time pass (`placeVolcanoes`, separate RNG → tectonic determinism intact) seeds volcanoes by + tectonic context: **very high** probability on young spreading-ridge / "new-plate" cells (baby + plates), **medium** on normal plate borders, **low** elsewhere (hotspots). On the live clock + (`stepVolcanoes`) they **erupt**; submarine vents build their cell up and **breach sea level into + new volcanic islands**, land vents grow cones, and each eruption injects a drifting **ash cloud** + (into the weather field) + **local cooling**. Eruption state (built height + intensity) is a **pure + function of `liveTime`** (like insolation/tides/seasons), so the live stepper rewinds islands & + eruptions for free (no extra snapshot state). Rendered as cone markers + an eruption glow/ash-plume + flare (3D + 2D, key `V`); saved (v14). Knobs `volcano*`. ## Current state @@ -488,6 +498,7 @@ src/ PlanetLive.cpp computeInsolation/computeLiveSeason (Live World: day/night + live seasons) PlanetOcean.cpp moons (generate/orbit) + computeTides + computeOceanCurrents PlanetWeather.cpp stepWeather (Live World dynamic clouds & rain cycle) + PlanetVolcano.cpp placeVolcanoes/stepVolcanoes (Live World volcanoes + islands) PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate) PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity @@ -553,12 +564,14 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ src/sim/Planet.cpp src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp \ src/sim/PlanetErosion.cpp src/sim/PlanetHydrology.cpp \ src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp src/sim/PlanetLive.cpp \ - src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetBiota.cpp \ + src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp src/sim/PlanetVolcano.cpp \ + src/sim/PlanetBiota.cpp \ src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \ src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t ``` -(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp` or -`test_weather.cpp` to run the Biota / Live World / Ocean / Weather suites — same source list.) +(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`, +`test_weather.cpp` or `test_volcano.cpp` to run the Biota / Live World / Ocean / Weather / +Volcano suites — same source list.) Use this to verify tectonics after changing `Planet::step()` without launching the window (the engine lives in `src/sim` and is raylib-free, so it links without @@ -590,6 +603,7 @@ active mode shown top-center of the globe) · `B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3, all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) · `O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) · +`V` volcano markers (Live World) · `SPACE` or on-screen button pause · `[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) · `S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock @@ -614,9 +628,11 @@ moving day/night terminator (`N`), a live seasonal temperature cycle and a movin line animate over whatever colour mode is active; the HUD shows a `Year/Day/HH:MM` calendar. **1–3 moons** orbit (sun-lit phases, orbit rings, solar/lunar eclipses) and, with the distant sun, raise tides — `T` colours the coastline by the live tide level (amber low ↔ cyan high). -`K` shows moving weather (clouds, rain, drifting storms / hurricanes). `Y` makes the 3D camera -**follow a storm** (cycles by strength, off after the last); `.`/`,` step the clock forward/back -by one rate-unit (back rewinds the sky only). Mouse-wheel over the 2D map zooms (drag pans). +`K` shows moving weather (clouds, rain, drifting storms / hurricanes). **Volcanoes** are placed by +tectonic context on entry and erupt on the clock — submarine ones build into new **volcanic islands**; +`V` toggles the cone/eruption markers. `Y` makes the 3D camera **follow a storm** (cycles by strength, +off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and** +volcanoes/islands, which are pure functions of the clock). Mouse-wheel over the 2D map zooms (drag pans). CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config file (both applied before the initial load/generate). @@ -627,20 +643,22 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and `Planet::writeState`/`readState`, resumes deterministically). Config is range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe defaults (without overwriting your `planet.cfg`) and shows a status message. The -save header is versioned (currently **13**; v2 adds the `[`/`]` drift rate, v3 a +save header is versioned (currently **14**; v2 adds the `[`/`]` drift rate, v3 a `phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing key=value text block** instead of a raw POD dump, v7 appends the **biota population** block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World** clock — a flag byte + `liveTime`, v9 appends the **moons** block, v10 appends the **weather** block — humidity/cloud/rain, flag-gated, v11 also persists the **weather systems** + RNG so a load resumes active storms, v12 appends the most recent **step-back frames** — `wxSaveMax`(40) weather snapshots -— so a load can rewind storms past the saved moment, v13 appends the Live World clock rate); +— so a load can rewind storms past the saved moment, v13 appends the Live World clock rate, v14 +appends the **volcanoes** block — the placed `Volcano` set + its RNG, gated by a flag byte); newer-than-supported is rejected. Older saves (no biota block) load fine with an empty population (press `L`); pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10 saves spin weather up live; pre-v11 saves load with no active storms (they respawn); pre-v12 saves load with no step-back history (you can still step forward then back); pre-v13 saves resume with -the default live clock rate. A load drops any **stale** pre-load `wxUndo` history and reloads the +the default live clock rate; pre-v14 saves load with no volcanoes (placed on the next Live World +entry). A load drops any **stale** pre-load `wxUndo` history and reloads the saved one. **As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults), @@ -747,6 +765,17 @@ triangles (plates are fixed in phase 1). per-moon orbit/period/inclination/mass are randomized in `generateMoons()` (PlanetOcean.cpp); the 3D sun distance/size, moon orbit-render band and eclipse angles are render constants (ViewerRender.cpp / `rebuildLiveOverlay`), not config. +- **Volcanoes (`volcano*` in PlanetConfig / `planet.cfg`):** placement by tectonic context — + `volcanoProbRidge` (0.55), `volcanoProbBorder` (0.06), `volcanoProbInterior` (0.003) are the + per-cell placement probabilities for young-ridge / plate-border / interior cells (raise for more + vents of that kind), `volcanoMaxCount` (60) caps the total (reservoir-sampled so the ratios hold). + Eruption/island growth — `volcanoBuildStep` (130 m/pulse) + `volcanoMaxHeight` (3200 m cap above + base) set how tall a cone/island gets, `volcanoEruptFreq` (0.05 pulses per hour·activity) the + cadence (raise for faster, more frequent eruptions — at a high live-clock rate islands build in + seconds). Eruption FX — `volcanoAshCloud` (0.9, ash cover injected into the weather field per + erupting hour) and `volcanoAshCooling` (6 °C, peak local cooling under an active plume). All build + + eruption state is a pure function of `liveTime` (PlanetVolcano.cpp); marker sizes/colours are + render constants (ViewerRender.cpp), not config. - `upliftGain` (PlanetConfig) — m/tick per unit convergence stress; main knob for how fast/high relief builds. - `relax` (PlanetConfig) — isostatic relaxation toward base elevation. Peaks diff --git a/CMakeLists.txt b/CMakeLists.txt index 43711a2..ddc07d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,7 @@ set(SIM_SOURCES src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp + src/sim/PlanetVolcano.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp @@ -69,7 +70,7 @@ if(UNIX AND NOT APPLE) endif() enable_testing() -foreach(test_name logic biota ocean live weather) +foreach(test_name logic biota ocean live weather volcano) add_executable(test_${test_name} test_${test_name}.cpp) target_link_libraries(test_${test_name} PRIVATE planetsim_sim) add_test(NAME ${test_name} COMMAND test_${test_name}) diff --git a/docs/design-notes.md b/docs/design-notes.md index 7de3293..b865ef9 100644 --- a/docs/design-notes.md +++ b/docs/design-notes.md @@ -226,6 +226,34 @@ can rewind storms past the saved moment. v13 also saves the Live World clock rat integrated/path-dependent, so reversing it past a save is only possible via this stored history — it can't be re-derived from the loaded moment. +## Volcanoes & volcanic islands (Live World) + +`PlanetVolcano.cpp`. A `Volcano` is a fixed point on the grid (one `cell`), not a moving agent. On +**entering Live World** `placeVolcanoes(liveTime)` seeds a set once, by tectonic context: a cell is +**ridge** if its plate is `baby` or a neighbour's is (the `buildBorders` baby test), else **border** +if a neighbour has a different `plateId`, else **interior**; placement probability is +`volcanoProbRidge` ≫ `volcanoProbBorder` ≫ `volcanoProbInterior`, reservoir-sampled to +`volcanoMaxCount` (an unbiased subset, ratios preserved). A separate RNG (`sVolRng = cfg.seed ^ +0x70C4F12A`) keeps the tectonic stream untouched. Each vent stores its pre-live `baseElev` + `tStart`. + +The crucial design point: **eruption state is a pure function of `liveTime`** (like +insolation/tides/seasons, never an integration). `volcanoBuilt(v,t) = min(maxHeight, floor((t−tStart) +·eruptFreq·activity)·buildStep)` (discrete pulses stepping the cone up, monotonic) and +`volcanoErupting(v,t)` is a flare decaying through each pulse cycle. `stepVolcanoes(dt, liveTime)` +(called each frame in `liveAdvance`, after `stepWeather`) just reasserts `cells[v.cell].elevation = +baseElev + built` — safe & complete because in Live World nothing else moves elevation. A submarine +vent crossing sea level **breaches** into an island (`oceanic=false`, `biome=Beach`, viewer +`refreshView`s); crossing back down (on a step back) re-submerges it. Because the state is derived +from `liveTime`, the **live stepper rewinds islands & eruptions for free** — no per-cell snapshot, no +volcano undo history. The only integrated side-effect is the **ash plume**: an eruption adds cloud to +`sCloud`/`sHumidity` (drifts downwind via the weather cycle) + subtracts `volcanoAshCooling` from +`sLiveTemp` at the vent — the cloud reverts via the existing weather snapshot, the cooling is itself +re-derived each frame. Rendered as a cone (taller/redder as it builds) + an orange glow/ash-plume +flare when erupting (3D `DrawCylinderEx` inside the tilt matrix; 2D `DrawPoly` triangle), key `V`. +**Saved v14**: the placed `Volcano` set + `sVolRng` in `writeState`/`readState` (flag-gated like +moons/weather); pre-v14 saves load with none and place them on the next Live World entry. Knobs: +`volcano*`. + ## Live World viewer controls (follow-cam, 2D zoom, clock stepper) Three viewer-only controls over the Live World sim: diff --git a/src/render/Panels.cpp b/src/render/Panels.cpp index f22d144..78f5e60 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -75,6 +75,16 @@ static std::vector cellInfo(const Planet& p, int i, double elev, do L.push_back(std::string(TextFormat("river: discharge %.0f", p.discharge()[i]))); if (sized(p.lakeDepth()) && p.lakeDepth()[i] > p.cfg.biomeLakeMinDepth && elev > p.cfg.seaLevel) L.push_back(std::string(TextFormat("lake: depth %.0f m", p.lakeDepth()[i]))); + // Volcano (Live World): built height = current elevation above the captured baseElev + // (eruption flares are shown by the marker; the live clock isn't available here). + for (const Volcano& vc : p.volcanoes) { + if (vc.cell != i) continue; + const char* kn = vc.kind == 0 ? "ridge" : vc.kind == 1 ? "border" : "hotspot"; + double built = c.elevation - vc.baseElev; if (built < 0.0) built = 0.0; + L.push_back(std::string(TextFormat("volcano: %s activity %.0f%% +%.0f m built", + kn, vc.activity * 100.0, built))); + break; + } // Biota: density scalars (present after computeBiotaDensity()) + the discrete // population list (present once generateBiota()/L has run). if (sized(p.floraDensity()) && sized(p.faunaDensity()) && sized(p.fungaDensity())) diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index 9be7821..5cf501f 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -295,7 +295,7 @@ void Viewer::loadGame(const char* path) { is.read(reinterpret_cast(&lh), sizeof lh); } // v8: Live World clock if (ver >= 13) is.read(reinterpret_cast(&lr), sizeof lr); // v13: Live World rate if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } - if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms + if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 volcanoes cfg = planet.cfg; // adopt the loaded config elapsedMy = em; settled = (st != 0); planet.drifting = settled; // resume drift boosts iff mid-drift @@ -344,6 +344,9 @@ void Viewer::loadGame(const char* path) { if (!historyOk) { wxUndo.clear(); skippedHistory = true; } } paused = true; selectedCell = -1; subgrids.clear(); + // Pre-v14 save already in Live World: it has no volcano block, so place a set now (v14+ saves + // restore their own). A non-live save places them when the user first presses W. + if (liveWorld && planet.volcanoes.empty()) planet.placeVolcanoes(liveTime); buildBorders(planet, borderR, borders, ridgeBorders); buildDriftArrows(planet, driftR, driftArrows, plateLabels); buildMap2D(planet, mapRect, map2D); @@ -416,6 +419,12 @@ void Viewer::liveAdvance(double dtClock, double dtWeather) { moonNormals.push_back(Vector3{ (float)mn.x, (float)mn.y, (float)mn.z }); } planet.stepWeather(dtWeather); + // Volcanoes: reassert vent elevations = base + built(liveTime) (pure function of the clock, so a + // backward step rewinds island growth), and inject ash cloud + local cooling. A grown/shrunk cone + // needs a recolor; a sea-level breach needs a biome reclassify (refreshView). + VolcanoUpdate vu = planet.stepVolcanoes(dtWeather, liveTime); + if (vu.breach) refreshView(); + else if (vu.recolor) recolor(); rebuildLiveOverlay(); } diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index 011e0a9..613dcc6 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -15,7 +15,7 @@ // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). struct Viewer { // ---- Files / save format ------------------------------------------------ - static constexpr uint32_t SAVE_VERSION = 13; // v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 + static constexpr uint32_t SAVE_VERSION = 14; // v14: +volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save const char* CONFIG_PATH = "planet.cfg"; const char* SAVE_PATH = "planet.save"; @@ -97,6 +97,7 @@ struct Viewer { std::vector currentSegs; std::vector currentCols; // ocean-current arrows bool showCurrents = false; // ocean current arrows, warm/cold (key O) bool showClouds = true; // Live World cloud/rain cover overlay (key K) + bool showVolcanoes = true; // Live World volcano markers (cones + eruption glow, key V) // Selection + subgrid (phase 4/5 preview). int selectedCell = -1; diff --git a/src/render/ViewerInput.cpp b/src/render/ViewerInput.cpp index e125e1a..08682d1 100644 --- a/src/render/ViewerInput.cpp +++ b/src/render/ViewerInput.cpp @@ -173,6 +173,7 @@ void Viewer::handleInput() { liveWorld = !liveWorld; if (liveWorld) { phase3Prompt = false; paused = false; wxUndo.clear(); + if (planet.volcanoes.empty()) planet.placeVolcanoes(liveTime); // one-time tectonic-context placement refreshView(); // fresh base colours; overlay builds in stepSim setStatus("Live World started"); } else { @@ -201,6 +202,7 @@ void Viewer::handleInput() { if (IsKeyPressed(KEY_T)) showTides = !showTides; // toggle tide-coloured coastline if (IsKeyPressed(KEY_O)) showCurrents = !showCurrents; // toggle ocean current arrows if (IsKeyPressed(KEY_K)) showClouds = !showClouds; // toggle weather cloud/rain cover + if (IsKeyPressed(KEY_V)) showVolcanoes = !showVolcanoes; // toggle volcano markers (Live World) if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); } if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); } if (IsKeyPressed(KEY_S)) { // one step diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index fed7cc3..30145c3 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -159,6 +159,35 @@ void Viewer::renderGlobe3D() { if (hur) { Vec3 e = p * (double)SR; DrawSphere(Vector3{(float)e.x,(float)e.y,(float)e.z}, 0.02f, Color{255,240,200,255}); } } } + // Live World volcano markers: a small cone at each vent (taller + redder as it builds), with an + // orange eruption glow + radial ash-plume flare when erupting -- additive so it reads on the + // night side too. Inside the tilted matrix, so it tracks the leaning globe. + if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) { + const double maxH = std::max(1.0, planet.cfg.volcanoMaxHeight); + for (const Volcano& vc : planet.volcanoes) { + if (vc.cell < 0 || vc.cell >= (int)planet.cells.size()) continue; + const Cell& c = planet.cells[vc.cell]; + Vec3 u = c.unit; + float r = visBase + (float)c.elevation * elevExagg; + double bf = std::clamp(planet.volcanoBuilt(vc, liveTime) / maxH, 0.0, 1.0); + double er = planet.volcanoErupting(vc, liveTime); + float coneH = 0.022f + 0.045f * (float)bf; + float coneR = 0.015f + 0.018f * (float)bf; + Vector3 b { (float)(u.x * r), (float)(u.y * r), (float)(u.z * r) }; + Vector3 apex{ (float)(u.x * (r + coneH)), (float)(u.y * (r + coneH)), (float)(u.z * (r + coneH)) }; + Color cone{ (unsigned char)(110 + 100 * er), (unsigned char)(70 - 20 * er), (unsigned char)(55 - 15 * er), 255 }; + DrawCylinderEx(b, apex, coneR, coneR * 0.25f, 8, cone); + if (er > 0.12) { + unsigned char a = (unsigned char)std::clamp(60.0 + 195.0 * er, 0.0, 255.0); + DrawSphere(apex, 0.02f + 0.05f * (float)er, Color{255, 140, 40, a}); + float ph = coneH + 0.12f * (float)er; + Vector3 top{ (float)(u.x * (r + ph)), (float)(u.y * (r + ph)), (float)(u.z * (r + ph)) }; + rlSetLineWidth(2.0f); rlBegin(RL_LINES); rlColor4ub(255, 170, 70, a); + rlVertex3f(apex.x, apex.y, apex.z); rlVertex3f(top.x, top.y, top.z); + rlEnd(); rlSetLineWidth(1.0f); + } + } + } if (showGrat) drawGraticule3D(graticule, gratR); // Markers: selected (orange), hovered cell (yellow), hovered subcell (white). if (selectedCell >= 0) { @@ -265,6 +294,20 @@ void Viewer::renderMap2D() { DrawCircleV(sp, 2.0f, c); } } + if (liveWorld && showVolcanoes && !planet.volcanoes.empty()) { + for (const Volcano& vc : planet.volcanoes) { + if (vc.cell < 0 || vc.cell >= (int)planet.cells.size()) continue; + double er = planet.volcanoErupting(vc, liveTime); + double lon, lat; dirToLonLat(planet.cells[vc.cell].unit, lon, lat); + Vector2 sp = projLonLat(lon, lat, mapLon, vr); + float s = (5.0f + 3.0f * (float)er) * (float)std::min(2.0, mapZoom); + Color tri = er > 0.12 ? Color{235, 110, 40, 255} : Color{150, 75, 55, 255}; + DrawPoly(sp, 3, s, -90.0f, tri); // filled up-pointing triangle (cone) + if (er > 0.12) + DrawCircleLines((int)sp.x, (int)sp.y, s + 3.0f, + Color{255, 170, 70, (unsigned char)std::clamp(90.0 + 150.0 * er, 0.0, 255.0)}); + } + } if (phase3 && showRivers) { drawSegments2D(rivers, Color{80, 170, 235, 255}, 1.5f, vr, mapLon); drawSegments2D(bigRivers, Color{80, 170, 235, 255}, 3.0f, vr, mapLon); @@ -469,8 +512,8 @@ void Viewer::renderHUD() { y += 8; line("hover: cell info | click tile: open detail panel | C close"); line("1 elev 2 plates 3 age 4 crust 5 biome 6 temp* 7 precip 8 flora 9 fauna 0 funga (*6 cycles mean/summer/winter/season)"); - line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s]", - showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off")); + line(TextFormat("B borders [%s] | D vectors [%s] | G grid [%s] | J rivers [%s] | N day/night [%s] | T tides [%s] | O currents [%s] | K clouds [%s] | V volcanoes [%s]", + showBorders ? "on" : "off", showDrift ? "on" : "off", showGrat ? "on" : "off", showRivers ? "on" : "off", dayNightOn ? "on" : "off", showTides ? "on" : "off", showCurrents ? "on" : "off", showClouds ? "on" : "off", showVolcanoes ? "on" : "off")); line(TextFormat("SPACE pause | [ / ] speed | S step | F fast-fwd | H hydrology [%s] | L biota [%s] | W live [%s] | R reseed | +/-", phase3 ? "on" : "off", planet.biotaPopulated() ? "on" : "off", liveWorld ? "on" : "off")); line("F5 save | F9 load | F12 screenshot | F2 reload planet.cfg"); diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 89a30d5..4383a74 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -14,7 +14,8 @@ public: PlanetConfig cfg; std::vector cells; std::vector plates; - std::vector moons; // Live World: 1-3 natural satellites (generated + saved) + std::vector moons; // Live World: 1-3 natural satellites (generated + saved) + std::vector volcanoes; // Live World: volcanoes placed on entry by tectonic context (saved v14) // Phase flag: false during Phase-1 forming (modest, original tectonics that // settle), true during Phase-2 drift. Gates the increment-4 orogeny boosts @@ -105,6 +106,18 @@ public: WeatherSnapshot captureWeather() const; void restoreWeather(const WeatherSnapshot& s); + // Volcanoes (Live World, PlanetVolcano.cpp). placeVolcanoes() runs once on entering Live World: + // it seeds a set by tectonic context (high on young ridges, medium on borders, low elsewhere) + // from a separate RNG (tectonic determinism intact), capturing each vent's baseElev. stepVolcanoes() + // runs each live frame: it reasserts each vent's cell elevation = baseElev + built(liveTime) (build + // + eruption intensity are PURE FUNCTIONS of liveTime, so the stepper rewinds them), breaches + // submarine vents into islands, and injects ash cloud + local cooling into the weather/climate + // fields. Saved (v14). volcanoErupting(i)/volcanoBuilt(i) report a vent's current state for rendering. + void placeVolcanoes(double liveTime = 0.0); + VolcanoUpdate stepVolcanoes(double dtHours, double liveTime); + double volcanoBuilt(const Volcano& v, double liveTime) const; // m built above baseElev at liveTime + double volcanoErupting(const Volcano& v, double liveTime) const; // 0..1 current eruption intensity + // Phase 3 (biomes): classify every cell into a Biome from elevation + the climate // fields (temperature + normalized precipitation). Derived + written back into // cell.biome (saved). Assumes computeClimate() ran this tick. Re-run as terrain evolves. @@ -142,8 +155,11 @@ public: // hasMoons: whether the stream carries the moons block (save v9+); older saves // synthesize moons from the seed instead. hasWeather: the weather block (save v10+); // older saves leave weather to spin up on entering Live World. + // hasVolcanoes: whether the stream carries the volcano block (save v14+); older saves load + // with no volcanoes (they are placed on the next Live World entry). bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, - bool hasMoons = true, bool hasWeather = true, bool hasStorms = true); + bool hasMoons = true, bool hasWeather = true, bool hasStorms = true, + bool hasVolcanoes = true); // Helpers for rendering / info. double cellWidthMeters() const; // approx lateral cell spacing @@ -226,6 +242,9 @@ private: std::vector sStorms; uint32_t sWeatherRng = 1; uint32_t sStormNextId = 1; // monotonic id for follow-cam tracking + // Volcanoes (Live World; saved v14). Separate RNG (seeded from cfg.seed in placeVolcanoes) + // keeps tectonic determinism intact; eruptions are pure functions of liveTime (no per-step RNG). + uint32_t sVolRng = 1; // Biota: derived density scalars (0..1; recomputed each tick, not saved) and the // on-demand discrete population (saved). sHasBiota latches once generated/loaded. diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 7c4b2e2..f6725cf 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -42,9 +42,13 @@ D(weatherCloudDissip) \ D(weatherSpawnRate) D(weatherSystemSpeed) D(weatherTropicalSST) D(weatherSystemRadius) \ D(weatherSystemCloud) D(weatherSystemRain) D(weatherHurricaneStr) \ + D(volcanoProbRidge) D(volcanoProbBorder) D(volcanoProbInterior) \ + D(volcanoBuildStep) D(volcanoMaxHeight) D(volcanoEruptFreq) \ + D(volcanoAshCloud) D(volcanoAshCooling) \ I(subdivisions) I(plateCount) I(beltWidth) I(splitCheckEvery) I(stalemateWindows) \ I(miniPlateCells) I(fuseMinPlates) I(babyMinCells) I(seaLevelEvery) \ I(climateWindPasses) I(climateMoistureSmooth) I(seasonContinentRings) I(weatherSystemMax) \ + I(volcanoMaxCount) \ I(bioFloraSlots) I(bioFaunaSlots) I(bioFungaSlots) \ I(bioFloraPoints) I(bioFaunaPoints) I(bioFungaPoints) I(bioMarineCoastRings) \ U(seed) @@ -215,6 +219,14 @@ std::string validateConfig(const PlanetConfig& cfg) { E(rng(cfg.weatherSystemCloud, 0.0, 20.0, "weatherSystemCloud")); E(rng(cfg.weatherSystemRain, 0.0, 20.0, "weatherSystemRain")); E(rng(cfg.weatherHurricaneStr, 0.0, 1.0, "weatherHurricaneStr")); + E(rng(cfg.volcanoProbRidge, 0.0, 1.0, "volcanoProbRidge")); + E(rng(cfg.volcanoProbBorder, 0.0, 1.0, "volcanoProbBorder")); + E(rng(cfg.volcanoProbInterior, 0.0, 1.0, "volcanoProbInterior")); + E(rng(cfg.volcanoBuildStep, 0.0, 5000.0, "volcanoBuildStep")); + E(rng(cfg.volcanoMaxHeight, 0.0, 12000.0, "volcanoMaxHeight")); + E(rng(cfg.volcanoEruptFreq, 0.0, 100.0, "volcanoEruptFreq")); + E(rng(cfg.volcanoAshCloud, 0.0, 10.0, "volcanoAshCloud")); + E(rng(cfg.volcanoAshCooling, 0.0, 40.0, "volcanoAshCooling")); E(irng(cfg.subdivisions, 0, 7, "subdivisions")); E(irng(cfg.plateCount, 1, 100, "plateCount")); E(irng(cfg.beltWidth, 1, 12, "beltWidth")); @@ -228,6 +240,7 @@ std::string validateConfig(const PlanetConfig& cfg) { E(irng(cfg.climateMoistureSmooth, 0, 100, "climateMoistureSmooth")); E(irng(cfg.seasonContinentRings, 1, 100, "seasonContinentRings")); E(irng(cfg.weatherSystemMax, 0, 1000, "weatherSystemMax")); + E(irng(cfg.volcanoMaxCount, 0, 100000, "volcanoMaxCount")); E(irng(cfg.bioFloraSlots, 1, 1000, "bioFloraSlots")); E(irng(cfg.bioFaunaSlots, 1, 1000, "bioFaunaSlots")); E(irng(cfg.bioFungaSlots, 1, 1000, "bioFungaSlots")); @@ -321,14 +334,19 @@ void Planet::writeState(std::ostream& os) const { writeVec(os, sHumidity); writeVec(os, sCloud); writeVec(os, sRain); writeVec(os, sStorms); writePod(os, sWeatherRng); writePod(os, sStormNextId); // v11 } + // v14: Live World volcanoes (placed by tectonic context on entry; eruption state is a pure + // function of liveTime, so only the placed set + its RNG need saving). Always written from v14; + // older readers stop before this block. + writeVec(os, volcanoes); + writePod(os, sVolRng); } bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, - bool hasWeather, bool hasStorms) { + bool hasWeather, bool hasStorms, bool hasVolcanoes) { // Save blocks are append-only by version. If a caller asks for an older prefix, // later blocks cannot exist in that stream even if the default arguments say otherwise. - if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; } - if (!hasWeather) hasStorms = false; + if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; } + if (!hasWeather) { hasStorms = false; hasVolcanoes = false; } // volcano block follows the weather block // Read the length-prefixed key=value config block (see writeState). A default // PlanetConfig is parsed over, so fields absent from an older save keep their @@ -432,6 +450,17 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM } } } + // v14: Live World volcanoes. Older saves load with none (placed on next Live World entry). + volcanoes.clear(); sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au; + if (hasVolcanoes) { + if (!readVec(is, volcanoes, 100000)) return false; + readPod(is, sVolRng); + if (!is) return false; + const int nc2 = (int)cells.size(); + for (const Volcano& v : volcanoes) + if (v.cell < 0 || v.cell >= nc2 || !std::isfinite(v.activity) + || !std::isfinite(v.baseElev) || !std::isfinite(v.tStart)) return false; + } computeBiotaDensity(); // derived density scalars for the colour views return (bool)is; } diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index 65c0451..7f3a620 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -64,6 +64,26 @@ struct Moon { double dispRadius = 0.10; // display sphere radius (visual size) }; +// A volcano (Live World): a fixed point on the grid (one cell) placed by tectonic context when the +// world enters Live World -- high probability on young spreading ridges ("new plate"), medium on +// plate borders, low elsewhere (hotspots). Over the live clock it erupts; submarine ones build their +// cell up into new islands. Eruption state is a PURE FUNCTION of liveTime (built height + intensity +// recomputed each frame, never integrated) so the live stepper rewinds it for free. Saved (v14). +struct Volcano { + uint32_t id = 0; // stable id (markers / cell-info) + int cell = -1; // the grid cell it sits on (fixed geometry) + uint8_t kind = 2; // 0 = ridge (new plate), 1 = plate border, 2 = hotspot/interior + uint8_t submarine = 0; // 1 if its baseElev is below sea level (can build an island) + double activity = 0.5; // 0..1 eruption vigour (drives cadence + build rate) + double baseElev = 0.0; // m: cell elevation captured at placement (build adds on top) + double tStart = 0.0; // liveTime (h) at placement; built height is f(liveTime - tStart) +}; + +// Result of one stepVolcanoes() call, telling the viewer how much of the view to rebuild: +// `recolor` if any vent's cell elevation changed (cone/island grew/shrank), `breach` if a +// submarine vent crossed sea level (a new island/sunk island -> needs biome reclassification). +struct VolcanoUpdate { bool recolor = false; bool breach = false; }; + // A full snapshot of the (integrated, non-analytic) weather state, for the viewer's step-back // undo history -- weather can't be reversed in closed form, so we restore a saved frame instead. struct WeatherSnapshot { @@ -309,4 +329,18 @@ struct PlanetConfig { double weatherSystemCloud = 1.2; // /h: cloud stamped at a system's core (scaled by strength) double weatherSystemRain = 1.6; // /h: rain intensity at a system's core double weatherHurricaneStr= 0.6; // strength above which a tropical system is a hurricane/typhoon + + // --- Volcanoes (Live World) -- see PlanetVolcano.cpp ------------------------ + // Placed once on entering Live World by tectonic context, then erupt on the live clock + // (build height + eruption intensity are pure functions of liveTime, so step-back reverses + // them). Submarine volcanoes build up to breach sea level into new volcanic islands. + double volcanoProbRidge = 0.55; // per-cell placement prob on a young spreading-ridge cell + double volcanoProbBorder = 0.06; // per-cell placement prob on a normal plate-border cell + double volcanoProbInterior = 0.003; // per-cell placement prob elsewhere (intraplate hotspots) + int volcanoMaxCount = 60; // global cap on placed volcanoes + double volcanoBuildStep = 130.0; // m of cone/island growth per eruption pulse + double volcanoMaxHeight = 3200.0;// m: max height a volcano builds above its baseElev + double volcanoEruptFreq = 0.05; // eruption pulses per (hour * activity) -- cadence + double volcanoAshCloud = 0.9; // cloud cover injected at the vent per erupting hour (ash plume) + double volcanoAshCooling = 6.0; // C: peak local cooling under an active ash plume }; diff --git a/src/sim/PlanetVolcano.cpp b/src/sim/PlanetVolcano.cpp new file mode 100644 index 0000000..0386253 --- /dev/null +++ b/src/sim/PlanetVolcano.cpp @@ -0,0 +1,121 @@ +#include "Planet.hpp" +#include +#include + +// --- Volcanoes (Live World) -------------------------------------------------- +// A volcano is a fixed point on the grid (one cell), placed ONCE when the world +// enters Live World by tectonic context: very high probability on young spreading +// ridges ("new plate" / baby plates), medium on normal plate borders, low elsewhere +// (intraplate hotspots). Over the live clock it erupts; submarine vents build their +// cell up until it breaches sea level into a new volcanic island. +// +// Determinism note: placement draws a SEPARATE RNG (sVolRng, seeded from cfg.seed) +// so it never perturbs the tectonic stream. The eruption state -- built height and +// eruption intensity -- is a PURE FUNCTION of liveTime (no integration, no per-step +// RNG), exactly like insolation/tides/seasons (PlanetLive.cpp). That is what lets the +// live stepper rewind volcanoes for free: a smaller liveTime recomputes a smaller +// island and un-does eruptions; the ash already mixed into the (integrated) weather +// field reverts via the existing weather snapshot. + +// Built height (m above baseElev) at a clock time. Discrete eruption "pulses" step the +// cone up, capped at volcanoMaxHeight -- monotonic and a pure function of liveTime. +double Planet::volcanoBuilt(const Volcano& v, double liveTime) const { + double age = liveTime - v.tStart; + if (age <= 0.0) return 0.0; + double pulses = std::floor(age * cfg.volcanoEruptFreq * std::max(0.0, v.activity)); + return std::min(cfg.volcanoMaxHeight, pulses * cfg.volcanoBuildStep); +} + +// Eruption intensity (0..1) at a clock time: a flare right after each pulse boundary, +// decaying through the cycle -- so a vent mostly smoulders and briefly erupts. Pure +// function of liveTime. +double Planet::volcanoErupting(const Volcano& v, double liveTime) const { + double age = liveTime - v.tStart; + if (age < 0.0) return 0.0; + double prog = age * cfg.volcanoEruptFreq * std::max(0.0, v.activity); + double frac = prog - std::floor(prog); // 0 just after a pulse .. 1 just before the next + return std::exp(-frac * 4.0); // ~1 at frac 0, ~0.13 at frac 0.5 +} + +// Place the volcano set by tectonic context. Reservoir-sampled to volcanoMaxCount so the +// kept set is an unbiased random subset of all cells that pass their context probability. +void Planet::placeVolcanoes(double liveTime) { + volcanoes.clear(); + sVolRng = cfg.seed ? (cfg.seed ^ 0x70C4F12Au) : 0x70C4F12Au; + auto next = [&]() { sVolRng ^= sVolRng << 13; sVolRng ^= sVolRng >> 17; sVolRng ^= sVolRng << 5; return sVolRng; }; + auto rf = [&]() { return (next() & 0xFFFFFFu) / double(0x1000000); }; + const int n = (int)cells.size(); + const double sea = cfg.seaLevel; + const int cap = std::max(0, cfg.volcanoMaxCount); + auto isBaby = [&](int pid) { return pid >= 0 && pid < (int)plates.size() && plates[pid].baby; }; + int passed = 0; + for (int i = 0; i < n; ++i) { + int pid = cells[i].plateId; + bool ridge = isBaby(pid), border = false; + for (int j : cells[i].neighbors) { + int pj = cells[j].plateId; + if (pj != pid) border = true; + if (isBaby(pj)) ridge = true; + } + int kind; double prob; + if (ridge) { kind = 0; prob = cfg.volcanoProbRidge; } + else if (border) { kind = 1; prob = cfg.volcanoProbBorder; } + else { kind = 2; prob = cfg.volcanoProbInterior; } + if (rf() >= prob) continue; + Volcano v; + v.cell = i; + v.kind = (uint8_t)kind; + v.submarine = (cells[i].elevation <= sea) ? 1 : 0; + v.baseElev = cells[i].elevation; + v.tStart = liveTime; + double base = (kind == 0) ? 0.70 : (kind == 1) ? 0.50 : 0.35; // ridges more vigorous + v.activity = std::clamp(base + (rf() - 0.5) * 0.4, 0.05, 1.0); + ++passed; + if ((int)volcanoes.size() < cap) volcanoes.push_back(v); + else if (cap > 0) { uint32_t r = next() % (uint32_t)passed; if ((int)r < cap) volcanoes[r] = v; } + } + for (int k = 0; k < (int)volcanoes.size(); ++k) volcanoes[k].id = (uint32_t)(k + 1); +} + +// One live-frame volcano update. Reasserts each vent's cell elevation = baseElev + +// built(liveTime) (in Live World nothing else moves elevation, so this is safe & +// complete), breaches submarine vents into islands (and un-breaches them on a step +// back), and injects ash cloud + local cooling into the weather/climate fields. +VolcanoUpdate Planet::stepVolcanoes(double dtHours, double liveTime) { + VolcanoUpdate up; + if (volcanoes.empty()) return up; + const int n = (int)cells.size(); + const double sea = cfg.seaLevel; + for (Volcano& v : volcanoes) { + if (v.cell < 0 || v.cell >= n) continue; + double newElev = v.baseElev + volcanoBuilt(v, liveTime); + double& e = cells[v.cell].elevation; + if (std::fabs(newElev - e) > 0.5) up.recolor = true; + e = newElev; + // Submarine vent crossing sea level -> a new island (or, on a step back, re-submerged). + if (v.submarine) { + bool land = newElev > sea; + if (land && cells[v.cell].oceanic) { + cells[v.cell].oceanic = false; cells[v.cell].biome = Biome::Beach; up.breach = true; + } else if (!land && !cells[v.cell].oceanic) { + cells[v.cell].oceanic = true; cells[v.cell].biome = Biome::Ocean; up.breach = true; + } + } + double intensity = volcanoErupting(v, liveTime); + if (intensity > 0.05) { + // Ash plume -> the integrated weather field (advects downwind, reverts on step-back via + // the weather snapshot). Only on a forward step (dtHours > 0). + if (dtHours > 0.0 && !sCloud.empty()) { + int c = v.cell; + sCloud[c] = std::min(2.0, sCloud[c] + cfg.volcanoAshCloud * intensity * dtHours); + if (!sHumidity.empty()) + sHumidity[c] = std::min(2.0, sHumidity[c] + 0.3 * cfg.volcanoAshCloud * intensity * dtHours); + } + // Local cooling under the plume: sLiveTemp is re-derived each frame, so subtracting here + // is itself a pure function of liveTime (consistent forward and backward). + if (!sLiveTemp.empty()) + sLiveTemp[v.cell] -= cfg.volcanoAshCooling * intensity; + } + } + return up; +} diff --git a/test_volcano.cpp b/test_volcano.cpp new file mode 100644 index 0000000..8a7999b --- /dev/null +++ b/test_volcano.cpp @@ -0,0 +1,163 @@ +// Headless test for Live World volcanoes (placement by tectonic context + eruption / island +// building). No display needed. +// +// g++ -std=c++17 -O2 -Isrc/sim test_volcano.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \ +// src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \ +// src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \ +// src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \ +// src/sim/PlanetVolcano.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp \ +// src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/tv && /tmp/tv +// +// Verifies: placement is deterministic + isolated from the tectonic RNG; the context classification +// (ridge / border / interior) drives where vents land and respects the probabilities; a submarine +// vent's built height is a monotonic PURE FUNCTION of liveTime that breaches sea level into an island +// and recedes when the clock steps back; and an eruption injects ash cloud at the vent. + +#include "Planet.hpp" +#include +#include +#include +#include +#include + +static int failures = 0; +static void check(bool cond, const char* what) { + std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what); + if (!cond) ++failures; +} + +static void settle(Planet& p, int maxSteps = 800) { + int run = 0; + for (int s = 0; s < maxSteps; ++s) { + double mc = p.step(); + if (mc < 2.0) { if (++run >= 3) break; } else run = 0; + } + p.computeClimate(); + p.classifyBiomes(); +} +static void drift(Planet& p, int iters) { + p.drifting = true; + for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); } +} + +// Replicate placeVolcanoes()'s context classification: 0 = ridge (baby plate / neighbour baby), +// 1 = plate border (a differing-plate neighbour), 2 = interior. +static int classify(const Planet& p, int i) { + auto isBaby = [&](int pid) { return pid >= 0 && pid < (int)p.plates.size() && p.plates[pid].baby; }; + int pid = p.cells[i].plateId; + bool ridge = isBaby(pid), border = false; + for (int j : p.cells[i].neighbors) { int pj = p.cells[j].plateId; if (pj != pid) border = true; if (isBaby(pj)) ridge = true; } + return ridge ? 0 : (border ? 1 : 2); +} +static bool sameVolcanoes(const std::vector& a, const std::vector& b) { + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); ++i) + if (a[i].id != b[i].id || a[i].cell != b[i].cell || a[i].kind != b[i].kind + || a[i].submarine != b[i].submarine || a[i].activity != b[i].activity + || a[i].baseElev != b[i].baseElev || a[i].tStart != b[i].tStart) return false; + return true; +} + +int main() { + PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 9090; + Planet p; p.generate(cfg); settle(p); drift(p, 120); + const int n = (int)p.cells.size(); + + std::printf("Volcanoes: determinism\n"); + p.placeVolcanoes(0.0); std::vector first = p.volcanoes; + p.placeVolcanoes(0.0); + check(!first.empty(), "placeVolcanoes places a non-empty set"); + check(sameVolcanoes(first, p.volcanoes), "placeVolcanoes is deterministic (re-run identical)"); + + std::printf("Volcanoes: RNG isolation from tectonics\n"); + Planet a; a.generate(cfg); settle(a); + Planet b; b.generate(cfg); settle(b); + for (int k = 0; k < 40; ++k) { + double dta = a.cflDtMy(); a.advect(dta); a.step(); a.erode(dta); + double dtb = b.cflDtMy(); b.advect(dtb); b.step(); b.erode(dtb); + if (k == 20) b.placeVolcanoes(0.0); // must not touch the tectonic RNG stream + } + bool terrainSame = true; + for (int i = 0; i < n; ++i) if (std::fabs(a.cells[i].elevation - b.cells[i].elevation) > 1e-9) terrainSame = false; + check(terrainSame, "placeVolcanoes never perturbs tectonic evolution"); + + std::printf("Volcanoes: context classification\n"); + int eligRidge = 0, eligBorder = 0, eligInterior = 0; + for (int i = 0; i < n; ++i) { int k = classify(p, i); if (k == 0) ++eligRidge; else if (k == 1) ++eligBorder; else ++eligInterior; } + std::printf(" eligible cells: ridge %d, border %d, interior %d\n", eligRidge, eligBorder, eligInterior); + // probs ridge=border=1, interior=0, no cap -> exactly the ridge+border cells, none interior. + p.cfg.volcanoProbRidge = 1.0; p.cfg.volcanoProbBorder = 1.0; p.cfg.volcanoProbInterior = 0.0; + p.cfg.volcanoMaxCount = 1000000; + p.placeVolcanoes(0.0); + bool noInterior = true; for (const Volcano& v : p.volcanoes) if (v.kind == 2) noInterior = false; + check(noInterior, "interior prob 0 places no interior vents"); + check((int)p.volcanoes.size() == eligRidge + eligBorder, "prob 1 fills exactly the ridge+border cells"); + + std::printf("Volcanoes: probability ordering (border > interior)\n"); + p.cfg.volcanoProbRidge = 1.0; p.cfg.volcanoProbBorder = 0.30; p.cfg.volcanoProbInterior = 0.05; + p.placeVolcanoes(0.0); + int gotRidge = 0, gotBorder = 0, gotInterior = 0; + for (const Volcano& v : p.volcanoes) { if (v.kind == 0) ++gotRidge; else if (v.kind == 1) ++gotBorder; else ++gotInterior; } + double rB = eligBorder ? (double)gotBorder / eligBorder : 0.0; + double rI = eligInterior ? (double)gotInterior / eligInterior : 0.0; + std::printf(" placement rate: border %.3f, interior %.3f\n", rB, rI); + check(rB > rI, "border cells are far likelier to host a volcano than interior cells"); + if (eligRidge > 0) { + double rR = (double)gotRidge / eligRidge; + std::printf(" placement rate: ridge %.3f\n", rR); + check(rR >= rB, "young-ridge cells are the likeliest of all"); + } else std::printf(" (no young-ridge cells this seed -- ridge rate not asserted)\n"); + + std::printf("Volcanoes: build is a pure function of liveTime; submarine vent breaches into an island\n"); + Planet q; q.generate(cfg); settle(q); drift(q, 120); + q.cfg.volcanoMaxHeight = 9000.0; q.cfg.volcanoBuildStep = 130.0; q.cfg.volcanoEruptFreq = 0.05; + q.placeVolcanoes(0.0); + int vi = -1; double best = -1e18; + for (size_t k = 0; k < q.volcanoes.size(); ++k) + if (q.volcanoes[k].submarine && q.volcanoes[k].baseElev > best) { best = q.volcanoes[k].baseElev; vi = (int)k; } + check(vi >= 0, "at least one submarine volcano was placed"); + if (vi >= 0) { + const Volcano v = q.volcanoes[vi]; + double b0 = q.volcanoBuilt(v, 0.0), b1 = q.volcanoBuilt(v, 5000.0), + b2 = q.volcanoBuilt(v, 50000.0), b3 = q.volcanoBuilt(v, 500000.0); + check(b0 <= b1 && b1 <= b2 && b2 <= b3, "built height is monotonic in liveTime"); + check(b3 > b0, "a submarine vent builds up over time"); + check(q.volcanoBuilt(v, 5000.0) == b1, "volcanoBuilt is deterministic (pure function of t)"); + q.stepVolcanoes(1.0, 500000.0); + check(q.cells[v.cell].elevation > q.cfg.seaLevel, "submarine volcano breaches sea level into an island"); + check(!q.cells[v.cell].oceanic, "the breached island is land crust"); + // Step the clock back to the start: the island must recede (pure function of liveTime). + q.stepVolcanoes(0.0, 0.0); + check(q.cells[v.cell].elevation <= q.cfg.seaLevel + 1e-6, "stepping the clock back recedes the island"); + check(std::fabs(q.cells[v.cell].elevation - (v.baseElev + q.volcanoBuilt(v, 0.0))) < 1e-6, + "vent elevation = baseElev + built(liveTime)"); + } + + std::printf("Volcanoes: an eruption injects ash cloud\n"); + Planet w; w.generate(cfg); settle(w); + w.initWeather(); + w.computeInsolation(0.25, 0.3); + w.placeVolcanoes(0.0); // tStart = 0 -> at liveTime 0 every vent is at peak eruption intensity + check(!w.volcanoes.empty(), "volcanoes placed for the ash test"); + if (!w.volcanoes.empty()) { + std::vector before = w.cloud(); + w.stepVolcanoes(1.0, 0.0); // dtHours > 0 -> inject ash + bool rose = false; + for (const Volcano& vv : w.volcanoes) + if (w.cloud()[vv.cell] > before[vv.cell] + 1e-9) rose = true; + check(rose, "an erupting vent thickens the cloud at its cell"); + } + + std::printf("Volcanoes: save v14 round-trip\n"); + { + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + q.writeState(ss); + Planet r; + bool ok = r.readState(ss, true, true, true, true, true, true); + check(ok, "readState accepts a v14 stream"); + check(sameVolcanoes(q.volcanoes, r.volcanoes), "volcano set round-trips through save"); + } + + std::printf(failures ? "\nFAILURES: %d\n" : "\nALL VOLCANO CHECKS PASSED\n", failures); + return failures ? 1 : 0; +}