From 8fec3d82adcf88535de7ab36426893ec09bf1109 Mon Sep 17 00:00:00 2001 From: Jonas Reith Date: Fri, 21 Aug 2026 22:35:40 +0200 Subject: [PATCH] Add edit mode: manually edit every cell/settlement property (save v24) F3 opens a tabbed panel (Geology/Climate/Biota/Settlement) for the selected tile. Every field writes straight into the field the sim already reads next tick (Planet::setXxx(), PlanetEdit.cpp), so an unlocked edit is a one-off nudge the simulation keeps evolving afterward; a per-field lock button sets a new per-cell EditLock bitmask that exempts it from automatic recompute (tectonics/erosion/ hydrology/drift/biomes skip a locked cell's write; climate/biota- density/habitability, which have no other persistent storage, pin a value in a small sparse map instead). Settlement population/ allegiance/culture locks are keyed by the settlement's home cell. Adds headless test_edit.cpp (nudge vs. lock for every field, save round-trip, corrupt-stream rejection, pre-v24 compatibility) and fixes a real bug found while testing: raylib's default ESC-exits-app behavior collided with edit mode's Escape-to-cancel, so SetExitKey is now disabled. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE --- CLAUDE.md | 54 +++++++- CMakeLists.txt | 3 +- src/render/Panels.cpp | 180 +++++++++++++++++++++++++ src/render/Panels.hpp | 44 +++++- src/render/Viewer.cpp | 9 +- src/render/Viewer.hpp | 12 +- src/render/ViewerInput.cpp | 194 ++++++++++++++++++++++++++- src/render/ViewerRender.cpp | 20 ++- src/sim/Planet.hpp | 58 +++++++- src/sim/PlanetBiomes.cpp | 2 +- src/sim/PlanetCiv.cpp | 6 +- src/sim/PlanetClimate.cpp | 5 + src/sim/PlanetConflict.cpp | 8 +- src/sim/PlanetCulture.cpp | 10 +- src/sim/PlanetDrift.cpp | 7 +- src/sim/PlanetEdit.cpp | 194 +++++++++++++++++++++++++++ src/sim/PlanetErosion.cpp | 4 +- src/sim/PlanetFaunaGen.cpp | 2 + src/sim/PlanetFloraGen.cpp | 3 + src/sim/PlanetFungiGen.cpp | 2 + src/sim/PlanetHydrology.cpp | 4 +- src/sim/PlanetIO.cpp | 49 ++++++- src/sim/PlanetTectonics.cpp | 5 +- src/sim/PlanetTypes.hpp | 24 ++++ test_edit.cpp | 261 ++++++++++++++++++++++++++++++++++++ 25 files changed, 1129 insertions(+), 31 deletions(-) create mode 100644 src/sim/PlanetEdit.cpp create mode 100644 test_edit.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 5585342..c468c2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,6 +288,28 @@ on the Live World clock). **Steps 1–7 of the roadmap are done (plus a derived shows a schism child's founding year. Knobs `cult*`. *Next steps (not yet built): tribute / vassalage treaties; an accumulated treasury (funding armies / buying peace).* +- **Edit mode (manual world editing)** *(done — see `PlanetEdit.cpp`, save v24)* — the "between phases + the user can edit the world" hook from the project's roadmap. Key **`F3`** (a settled world; auto-pauses) + toggles it; click a tile to open an **Edit Mode** panel (replaces the detail panel) tabbed **Geology** + (elevation, plate, crust type, geological age, biome) / **Climate** (temperature, moisture) / **Biota** + (flora/fauna/funga density + the discrete population list, add/remove organisms) / **Settlement** + (population, allegiance, culture — only shown when the tile hosts one). Each row is a `Planet::setXxx()` + call (`PlanetEdit.cpp`) that writes straight into the field the sim already reads next tick — click a + numeric value to type a new one (Enter commits, Escape cancels) or scroll the wheel over it to nudge; + click a discrete field (plate/biome/crust/allegiance/culture) to cycle or pick from a list; click **+ + add** to insert a biota organism from the archetype table. Every field also has a **lock** button — + unlocked, an edit is a one-off "nudge" the simulation keeps evolving afterward (like a meteor impact); + locked, a new per-cell `EditLock` bitmask (`PlanetTypes.hpp`) exempts that field from its normal per-tick + recompute (`PlanetTectonics`/`Erosion`/`Hydrology`/`Drift`/`Biomes.cpp` skip a locked cell's write; + `PlanetClimate`/`FloraGen`/`FaunaGen`/`FungiGen`/`Civ.cpp` re-apply a pinned value from a small sparse + map after their normal recompute, since those fields have no other persistent storage of their own) until + unlocked again. Settlement locks are keyed by the settlement's home cell and gate `stepCivilization()` / + `stepConflict()` / `stepCulture()` for it. Elevation/plate/crust/geoAge/biome locks are a bare bit (no + extra storage — the Cell field itself is already the save-format source of truth); a plate **fission/ + merge** event (whole-plate cell reassignment) is not individually lock-checked. **Save v24** appends the + per-cell `editLock` bitmask + the sparse locked-value maps (climate/biota-density/habitability); older + saves load with no locks. Headless `test_edit.cpp`: nudge-vs-lock behaviour for every field, organism + add/remove, save v24 round-trip, corrupt-stream rejection, pre-v24 compatibility. ## Current state @@ -701,13 +723,14 @@ src/ PlanetCulture.* computeCultures (derived refresh + one-time seeding; civ Step 4) + stepCulture (assimilation/conversion/schism; civ Step 8, save v23) PlanetConflict.* stepConflict (wars/conquest/revolts + diplomacy/alliances/coalitions; civ Steps 5-6, save v21/v22) PlanetTrade.* computeTrade (trade routes + prosperity/wealth feeding growth; civ Step 7, derived) + PlanetEdit.cpp manual cell/settlement edits + the EditLock lock mechanism (Edit Mode, save v24) PlanetIO.cpp config file (text) + binary save/load render/ (raylib viewer) Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota) Map2D.* Equal Earth 2D map: positions + projection/draw helpers Overlays.* borders, drift arrows, rivers, graticule, segments, subgrids Picking.* mouse ray / sphere hit / nearest-cell / angle helpers - Panels.* right-column UI: detail panel, hover info, world stats + Panels.* right-column UI: detail panel, hover info, world stats, edit-mode panel Viewer.{hpp,cpp} Viewer struct: all state + setup + sim orchestration ViewerInput.cpp handleInput(): camera, hover picking, click, keys ViewerRender.cpp renderGlobe3D / renderMap2D / renderPanels / renderHUD / renderPrompt @@ -765,12 +788,13 @@ g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp \ src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp \ src/sim/NameGen.cpp src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp \ src/sim/PlanetNation.cpp src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp src/sim/PlanetTrade.cpp \ - src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t + src/sim/PlanetEdit.cpp src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t ``` (Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`, `test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp`, `test_civ.cpp`, -`test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp`, `test_diplomacy.cpp`, `test_trade.cpp`, `test_colony.cpp` or `test_cultevo.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / -Civilization / Nation / Culture / Conflict / Diplomacy / Trade / Colony / Cultural-evolution suites — same source list. CMake also builds `test_events` for the +`test_nation.cpp`, `test_culture.cpp`, `test_conflict.cpp`, `test_diplomacy.cpp`, `test_trade.cpp`, `test_colony.cpp`, +`test_cultevo.cpp` or `test_edit.cpp` to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / +Civilization / Nation / Culture / Conflict / Diplomacy / Trade / Colony / Cultural-evolution / Edit-mode 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 @@ -819,6 +843,8 @@ zooms toward the cursor (drag pans when zoomed) · `F` fast-forward Phase-1 form `H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga, on a settled world; re-press regenerates) · `W` enter/leave **Live World** (settled world) · `R` reseed · +`F3` **edit mode** (a settled world; click a tile to edit every property — geology/climate/biota/ +settlement — with per-field lock against automatic recompute; see below) · `+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load · `F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate. @@ -850,6 +876,17 @@ cuts the routes between belligerents, and the **Civ** tab shows each settlement' off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and** weather/storms/volcano lifecycle **and wars/conquests and cultural shifts** via snapshots). Mouse-wheel over the 2D map zooms (drag pans). +Edit mode (`F3`, a settled world; auto-pauses): click a tile to open an **Edit Mode** panel in place +of the detail panel, tabbed **Geology** / **Climate** / **Biota** / **Settlement** (the last only +when the tile hosts one). Click a numeric value to type a new one (**Enter** commits, **Escape** +cancels) or scroll the mouse wheel over it to nudge; click a discrete field (plate/biome/crust type/ +allegiance/culture) to pick from a list; click **+ add** to place a biota organism, **×** to remove +one. Every field has its own **lock** button — unlocked, an edit is a one-off nudge the simulation +keeps evolving afterward (like a meteor impact); locked, that field is exempted from its normal +automatic recompute until unlocked again (elevation resists erosion/relaxation, biome/climate/biota- +density/habitability stay pinned, a settlement's population/allegiance/culture stop changing on +their own). Saved (v24). + CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config file (both applied before the initial load/generate). @@ -859,7 +896,7 @@ PlanetConfig param, auto-created on first run, reload with `F2`) and `Planet::writeState`/`readState`, resumes deterministically). Config is range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe defaults (without overwriting your `planet.cfg`) and shows a status message. The -save header is versioned (currently **23**; v2 adds the `[`/`]` drift rate, v3 a +save header is versioned (currently **24**; 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** @@ -877,7 +914,10 @@ to the step-back frames so a load can rewind conquests, v22 the **diplomacy** bl **relations** (alliances / rivalries / truces), likewise per-frame, and v23 the **culture** block — stateful culture identities (append-only; schism children) + the per-settlement culture + a next-id counter (cultural evolution), likewise per-frame (per-settlement vector + the list *length*; a rewind -truncates, a replay re-creates); +truncates, a replay re-creates), and v24 the **edit mode** block — a per-cell `editLock` bitmask +(manual-edit locks, `F3`) as a trailing vector plus sparse locked-value maps for the climate/biota- +density/habitability fields (which have no other persistent storage — the map only holds an entry +while its lock bit is set); 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 @@ -890,7 +930,7 @@ demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pr no settlements (re-seeded via `U`); pre-v21 saves load with no wars (everyone independent; wars begin again as the clock runs); pre-v22 saves load with no diplomacy (relations re-form as the clock runs); pre-v23 saves load with no culture state (re-seeded one-per-continent on the next refresh — the same -peoples as before; evolution starts from there). +peoples as before; evolution starts from there); pre-v24 saves load with no manual edits/locks. 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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 44ded36..16820e8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ set(SIM_SOURCES src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp src/sim/PlanetTrade.cpp + src/sim/PlanetEdit.cpp src/sim/PlanetIO.cpp ) @@ -78,7 +79,7 @@ if(UNIX AND NOT APPLE) endif() enable_testing() -foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy trade colony cultevo) +foreach(test_name logic biota ocean live weather volcano geography ecoregions civ nation culture conflict diplomacy trade colony cultevo edit) 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/src/render/Panels.cpp b/src/render/Panels.cpp index 8ff628f..cd41c30 100644 --- a/src/render/Panels.cpp +++ b/src/render/Panels.cpp @@ -298,6 +298,186 @@ void drawHoverPanel(const Planet& p, Rectangle r, int hovered, int selected) { } } +// Draw one editable row: a label + a value box (click to type / toggle / open a picker) + its +// lock-toggle button. Rows sharing a lock bit (e.g. Crust + GeoAge -> LockCrust) each draw their +// own button wired to the same id -- redundant but simplest, and both stay visually in sync since +// the lock state is re-read from the cell every frame. Advances and returns `y`. +static int editRow(EditPanelState& ed, EditField valueId, const std::string& label, const std::string& value, + EditField lockId, bool locked, int x, int y, int w) { + Rectangle r{ (float)x, (float)y, (float)(w - 72), 22.0f }; + bool active = (ed.editingField == valueId); + DrawRectangleRec(r, active ? Color{40, 46, 64, 255} : Color{22, 24, 34, 255}); + DrawRectangleLinesEx(r, 1, Color{70, 74, 90, 255}); + DrawText(label.c_str(), (int)r.x + 4, (int)r.y + 3, 14, Color{190, 195, 210, 255}); + std::string val = active ? (ed.editBuffer + "_") : value; + int vw = MeasureText(val.c_str(), 14); + DrawText(val.c_str(), (int)(r.x + r.width) - vw - 6, (int)r.y + 3, 14, active ? Color{255, 230, 140, 255} : RAYWHITE); + ed.fieldRects.push_back(r); ed.fieldIds.push_back(valueId); + + Rectangle lr{ r.x + r.width + 4, r.y, 64.0f, 22.0f }; + DrawRectangleRec(lr, locked ? Color{120, 60, 50, 255} : Color{34, 50, 40, 255}); + DrawRectangleLinesEx(lr, 1, Color{100, 104, 120, 255}); + const char* t = locked ? "LOCKED" : "lock"; + int tw = MeasureText(t, 12); + DrawText(t, (int)(lr.x + lr.width / 2 - tw / 2), (int)lr.y + 5, 12, locked ? Color{255, 210, 200, 255} : Color{190, 220, 195, 255}); + ed.lockRects.push_back(lr); ed.lockIds.push_back(lockId); + return y + 24; +} + +void drawEditPanel(const Planet& p, int cellIdx, Rectangle panel, EditPanelState& ed) { + ed.tabRects.clear(); ed.fieldRects.clear(); ed.fieldIds.clear(); + ed.lockRects.clear(); ed.lockIds.clear(); + ed.pickerRects.clear(); ed.pickerValues.clear(); + ed.removeRects.clear(); ed.removeKind.clear(); ed.removeIndex.clear(); + if (cellIdx < 0 || cellIdx >= (int)p.cells.size()) return; + + DrawRectangleRec(panel, Color{12, 14, 22, 235}); + DrawRectangleLinesEx(panel, 2, Color{210, 150, 70, 255}); // orange border marks edit mode + int x = (int)panel.x + 10, y = (int)panel.y + 8; + int w = (int)panel.width - 20; + int maxY = (int)(panel.y + panel.height) - 8; + DrawText(TextFormat("EDIT MODE - Tile #%d", cellIdx), x, y, 18, Color{255, 200, 120, 255}); + const char* hint = "F3: exit"; + DrawText(hint, (int)(panel.x + panel.width) - MeasureText(hint, 13) - 10, y + 3, 13, Color{170, 170, 185, 255}); + y += 26; + + const Cell& c = p.cells[cellIdx]; + bool hasSettle = p.settlementsPlaced() && cellIdx < (int)p.cellSettlement().size() && p.cellSettlement()[cellIdx] >= 0; + + const char* tabNames[4] = { "Geology", "Climate", "Biota", "Settlement" }; + int nTabs = hasSettle ? 4 : 3; + if (ed.tab >= nTabs) ed.tab = 0; + int tabW = w / nTabs; + for (int i = 0; i < nTabs; ++i) { + Rectangle tr{ (float)(x + i * tabW), (float)y, (float)(tabW - 2), 24.0f }; + bool on = (ed.tab == i); + DrawRectangleRec(tr, on ? Color{60, 66, 92, 255} : Color{28, 30, 42, 255}); + DrawRectangleLinesEx(tr, 1, Color{90, 94, 115, 255}); + int tw = MeasureText(tabNames[i], 14); + DrawText(tabNames[i], (int)(tr.x + tr.width / 2 - tw / 2), (int)tr.y + 5, 14, on ? RAYWHITE : Color{170, 175, 190, 255}); + ed.tabRects.push_back(tr); + } + y += 30; + + // An open picker list replaces the row list for this tab until a row (or the tab strip) is clicked. + if (ed.pickerField != EditField::None) { + DrawText("Select a value (click a tab to cancel):", x, y, 13, Color{190, 195, 210, 255}); y += 20; + auto pickRow = [&](const std::string& label, int value) { + if (y + 22 > maxY) return; + Rectangle r{ (float)x, (float)y, (float)w, 22.0f }; + DrawRectangleLinesEx(r, 1, Color{70, 74, 90, 255}); + DrawText(label.c_str(), x + 4, y + 3, 14, RAYWHITE); + ed.pickerRects.push_back(r); ed.pickerValues.push_back(value); + y += 24; + }; + switch (ed.pickerField) { + case EditField::PlateId: + for (int pi = 0; pi < (int)p.plates.size(); ++pi) { + if (p.plates[pi].baby) continue; + pickRow(TextFormat("Plate %d (%s)", pi, p.plates[pi].type == PlateType::Oceanic ? "Oceanic" : "Continental"), pi); + } + break; + case EditField::Biome: + for (int b = 0; b <= (int)Biome::Mountains; ++b) pickRow(biomeName((Biome)b), b); + break; + case EditField::Allegiance: + pickRow("Independent", -1); + for (size_t k = 0; k < p.settlements.size(); ++k) + if (p.settlements[k].population >= p.cfg.civAbandonPop) pickRow(p.settlements[k].name, (int)k); + break; + case EditField::Culture: + pickRow("None", -1); + for (size_t k = 0; k < p.cultureList().size(); ++k) pickRow(p.cultureList()[k].name, (int)k); + break; + case EditField::AddFlora: case EditField::AddFauna: case EditField::AddFunga: { + BiotaKind kind = ed.pickerField == EditField::AddFlora ? BiotaKind::Flora + : ed.pickerField == EditField::AddFauna ? BiotaKind::Fauna : BiotaKind::Funga; + const auto& AR = biotaArchetypes(); + for (int a = 0; a < (int)AR.size(); ++a) if (AR[a].kind == kind) pickRow(AR[a].name, a); + break; + } + default: break; + } + return; + } + + uint16_t lock = c.editLock; + if (ed.tab == 0) { // Geology + static const Plate kUnknown{}; + const Plate& pl = (c.plateId >= 0 && c.plateId < (int)p.plates.size()) ? p.plates[c.plateId] : kUnknown; + y = editRow(ed, EditField::Elevation, "Elevation", TextFormat("%.0f m", c.elevation), + EditField::LockElevation, (lock & LockElevation) != 0, x, y, w); + y = editRow(ed, EditField::PlateId, "Plate", TextFormat("P%d (%s)", c.plateId, pl.type == PlateType::Oceanic ? "Oceanic" : "Continental"), + EditField::LockPlate, (lock & LockPlate) != 0, x, y, w); + y = editRow(ed, EditField::Crust, "Crust type", c.oceanic ? "Oceanic" : "Continental", + EditField::LockCrust, (lock & LockCrust) != 0, x, y, w); + y = editRow(ed, EditField::GeoAge, "Geo age", TextFormat("%.0f My", c.geoAge), + EditField::LockCrust, (lock & LockCrust) != 0, x, y, w); + y = editRow(ed, EditField::Biome, "Biome", biomeName(c.biome), + EditField::LockBiome, (lock & LockBiome) != 0, x, y, w); + } else if (ed.tab == 1) { // Climate + double temp = (cellIdx < (int)p.temperature().size()) ? p.temperature()[cellIdx] : 0.0; + double moist = (cellIdx < (int)p.moisture().size()) ? p.moisture()[cellIdx] : 0.0; + y = editRow(ed, EditField::Temperature, "Temperature", TextFormat("%.1f C", temp), + EditField::LockClimate, (lock & LockClimate) != 0, x, y, w); + y = editRow(ed, EditField::Moisture, "Moisture", TextFormat("%.0f%%", moist * 100.0), + EditField::LockClimate, (lock & LockClimate) != 0, x, y, w); + } else if (ed.tab == 2) { // Biota + double fl = (cellIdx < (int)p.floraDensity().size()) ? p.floraDensity()[cellIdx] : 0.0; + double fa = (cellIdx < (int)p.faunaDensity().size()) ? p.faunaDensity()[cellIdx] : 0.0; + double fu = (cellIdx < (int)p.fungaDensity().size()) ? p.fungaDensity()[cellIdx] : 0.0; + double hab = (cellIdx < (int)p.habitability().size()) ? p.habitability()[cellIdx] : 0.0; + y = editRow(ed, EditField::FloraDensity, "Flora density", TextFormat("%.0f%%", fl * 100.0), + EditField::LockBiota, (lock & LockBiota) != 0, x, y, w); + y = editRow(ed, EditField::FaunaDensity, "Fauna density", TextFormat("%.0f%%", fa * 100.0), + EditField::LockBiota, (lock & LockBiota) != 0, x, y, w); + y = editRow(ed, EditField::FungaDensity, "Funga density", TextFormat("%.0f%%", fu * 100.0), + EditField::LockBiota, (lock & LockBiota) != 0, x, y, w); + y = editRow(ed, EditField::Habitability, "Habitability", TextFormat("%.0f%%", hab * 100.0), + EditField::LockHabitability, (lock & LockHabitability) != 0, x, y, w); + y += 8; + auto listOrganisms = [&](const char* tag, const std::vector& v, BiotaKind kind, EditField addId) { + if (y + 18 > maxY) return; + DrawText(tag, x, y, 13, Color{170, 175, 190, 255}); + Rectangle ar{ (float)(x + w - 48), (float)y - 2, 48.0f, 18.0f }; + DrawRectangleRec(ar, Color{34, 50, 40, 255}); DrawRectangleLinesEx(ar, 1, Color{100, 104, 120, 255}); + DrawText("+ add", (int)ar.x + 3, (int)ar.y + 3, 11, Color{190, 220, 195, 255}); + ed.fieldRects.push_back(ar); ed.fieldIds.push_back(addId); + y += 18; + for (size_t oi = 0; oi < v.size() && y + 16 <= maxY; ++oi) { + std::string nm = organismName(v[oi]); + DrawText(nm.c_str(), x + 10, y, 12, Color{200, 205, 215, 255}); + Rectangle xr{ (float)(x + w - 20), (float)y - 1, 18.0f, 16.0f }; + DrawRectangleRec(xr, Color{60, 30, 30, 255}); DrawRectangleLinesEx(xr, 1, Color{100, 60, 60, 255}); + DrawText("x", (int)xr.x + 6, (int)xr.y + 1, 12, Color{230, 180, 180, 255}); + ed.removeRects.push_back(xr); ed.removeKind.push_back((int)kind); ed.removeIndex.push_back((int)oi); + y += 16; + } + }; + if (p.biotaPopulated() && cellIdx < (int)p.biota().size()) { + const CellBiota& cb = p.biota()[cellIdx]; + listOrganisms("Flora population", cb.flora, BiotaKind::Flora, EditField::AddFlora); + listOrganisms("Fauna population", cb.fauna, BiotaKind::Fauna, EditField::AddFauna); + listOrganisms("Funga population", cb.funga, BiotaKind::Funga, EditField::AddFunga); + } else { + DrawText("(press L to generate a biota population first)", x, y, 13, Color{150, 150, 165, 255}); + } + } else if (ed.tab == 3 && hasSettle) { // Settlement + int si = p.cellSettlement()[cellIdx]; + const Settlement& s = p.settlements[si]; + int ov = (si < (int)p.settleAllegiance().size()) ? p.settleAllegiance()[si] : -1; + std::string ovName = (ov >= 0 && ov < (int)p.settlements.size()) ? p.settlements[ov].name : "Independent"; + int ci = (si < (int)p.settleCulture().size()) ? p.settleCulture()[si] : -1; + std::string ciName = (ci >= 0 && ci < (int)p.cultureList().size()) ? p.cultureList()[ci].name : "None"; + y = editRow(ed, EditField::Population, "Population", TextFormat("%.0f", s.population), + EditField::LockPopulation, (lock & LockPopulation) != 0, x, y, w); + y = editRow(ed, EditField::Allegiance, "Allegiance", ovName, + EditField::LockAllegiance, (lock & LockAllegiance) != 0, x, y, w); + y = editRow(ed, EditField::Culture, "Culture", ciName, + EditField::LockCulture, (lock & LockCulture) != 0, x, y, w); + } +} + void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting, bool live, double liveHours) { DrawRectangleRec(r, Color{12, 14, 22, 235}); diff --git a/src/render/Panels.hpp b/src/render/Panels.hpp index dce1e75..4731d40 100644 --- a/src/render/Panels.hpp +++ b/src/render/Panels.hpp @@ -2,9 +2,46 @@ #include "raylib.h" #include "Planet.hpp" #include +#include +#include // Right-column UI panels: the tile detail panel (subtiles grid), the hover/ -// selection info box, and the world-statistics panel. +// selection info box, the world-statistics panel, and the edit-mode panel. + +// One editable row/group/button in the edit-mode panel (F3). Groups sharing a lock +// button mirror the EditLock bit groupings in PlanetTypes.hpp (e.g. Crust+GeoAge +// share LockCrust). Add* open a biota-archetype picker for that kind. +enum class EditField { + None = 0, + Elevation, PlateId, Crust, GeoAge, Biome, + Temperature, Moisture, + FloraDensity, FaunaDensity, FungaDensity, Habitability, + Population, Allegiance, Culture, + LockElevation, LockPlate, LockCrust, LockBiome, LockClimate, LockBiota, + LockHabitability, LockPopulation, LockAllegiance, LockCulture, + AddFlora, AddFauna, AddFunga, +}; + +// Transient edit-panel UI state, owned by the Viewer, rebuilt each frame by +// drawEditPanel() (render side) and consumed by the click/keyboard handlers +// (input side) -- mirrors the liveInfoTabRects/eventRowRects pattern. +struct EditPanelState { + int tab = 0; // 0 Geology, 1 Climate, 2 Biota, 3 Settlement + EditField editingField = EditField::None; // focused for typed numeric entry + std::string editBuffer; + EditField pickerField = EditField::None; // an open picker list is for this field (None = closed) + + std::vector tabRects; + std::vector fieldRects; // value-click / toggle rects + std::vector fieldIds; // parallel to fieldRects + std::vector lockRects; // lock-toggle button rects + std::vector lockIds; // parallel to lockRects (the Lock* field) + std::vector pickerRects; // rows of an open picker list + std::vector pickerValues; // parallel: plate id / biome / settlement / culture / archetype + std::vector removeRects; // biota organism "remove" buttons + std::vector removeKind; // parallel: BiotaKind as int + std::vector removeIndex; // parallel: index within that kind's list +}; // Tile detail panel: tile info header + the selected cell's subgrid drawn as a // flat, hoverable grid of subtiles (neighbor-owned subtiles dimmed). hoveredSub @@ -19,3 +56,8 @@ void drawHoverPanel(const Planet& p, Rectangle r, int hovered, int selected); // World statistics panel (shown in the subareas quadrant when no tile selected). void drawStats(const Planet& p, Rectangle r, double elapsedMy, bool drifting, bool live = false, double liveHours = 0.0); + +// Edit mode (key F3): every property of the selected cell, editable. Rebuilds `ed`'s hit-rects +// each call (render populates, the input handler consumes -- see EditPanelState above); reads +// live values from `p` but never mutates it (all mutation happens in the click/keyboard handlers). +void drawEditPanel(const Planet& p, int cellIdx, Rectangle panel, EditPanelState& ed); diff --git a/src/render/Viewer.cpp b/src/render/Viewer.cpp index ac07761..c1add4b 100644 --- a/src/render/Viewer.cpp +++ b/src/render/Viewer.cpp @@ -76,6 +76,7 @@ bool Viewer::init(int argc, char** argv) { SetConfigFlags(FLAG_MSAA_4X_HINT); InitWindow(screenW, screenH, "Planet Sim - Phase 1: Tectonics"); SetTargetFPS(60); + SetExitKey(KEY_NULL); // raylib's default exit-on-ESC would fight edit mode's Escape-to-cancel // Layout: left column 70% wide (3D globe 60% h on top, 2D map 40% h below); // right column 30% wide (cell info 50% h on top, subareas 50% below). @@ -152,8 +153,13 @@ void Viewer::selectCell(int idx) { if (idx < 0) return; if (idx == selectedCell) { selectedCell = -1; subgrids.clear(); return; } selectedCell = idx; rebuildSub(); + if (editMode) { // switching tiles mid-edit cancels any open picker / typed entry (keeps the tab) + editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); editPanel.pickerField = EditField::None; + } } +void Viewer::exitEditMode() { editPanel = EditPanelState{}; } + // Recolor the mesh + refresh the elevation range, read straight from cells. void Viewer::recolor() { double maxAge = 1.0; for (const auto& c : planet.cells) maxAge = std::max(maxAge, c.geoAge); @@ -329,6 +335,7 @@ void Viewer::regenWorld() { // after generate(): geometry change buildDriftArrows(planet, driftR, driftArrows, plateLabels); buildMap2D(planet, mapRect, map2D); selectedCell = -1; subgrids.clear(); + editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); editPanel.pickerField = EditField::None; mapZoom = 1.0; mapPanX = 0.0; mapPanY = 0.0; // drop any 2D-map zoom/pan from the old world settled = false; settleRun = 0; formAccum = 0.0; stepCount = 0; paused = false; liveWorld = false; followId = 0; wxUndo.clear(); events.clear(); nextEventId = 1; // reseed/regen drops back to World Creation @@ -618,7 +625,7 @@ void Viewer::loadGame(const char* path) { is.read(reinterpret_cast(&lh), sizeof lh); } // v8: Live World clock if (ver >= 13) is.read(reinterpret_cast(&lr), sizeof lr); // v13: Live World rate if (!is || std::memcmp(magic, "PLSV", 4) != 0 || ver > SAVE_VERSION) { setStatus("Load failed: bad file"); return; } - if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22, ver >= 23)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy, v23 cultures + if (!planet.readState(is, ver >= 4, ver >= 7, ver >= 9, ver >= 10, ver >= 11, ver >= 14, ver >= 15, ver >= 17, ver >= 18, ver >= 19, ver >= 20, ver >= 21, ver >= 22, ver >= 23, ver >= 24)) { setStatus("Load failed: corrupt/mismatch"); return; } // v4 biome, v7 biota, v9 moons, v10 weather, v11 storms, v14 old volcanoes, v15 stateful volcanoes, v17 geography, v18 geography salt, v19 ecoregions, v20 settlements, v21 conflict, v22 diplomacy, v23 cultures, v24 edit mode cfg = planet.cfg; // adopt the loaded config elapsedMy = em; settled = (st != 0); planet.drifting = settled; // resume drift boosts iff mid-drift diff --git a/src/render/Viewer.hpp b/src/render/Viewer.hpp index a59eab8..49ef78d 100644 --- a/src/render/Viewer.hpp +++ b/src/render/Viewer.hpp @@ -4,6 +4,7 @@ #include "Colors.hpp" // ColorMode #include "Overlays.hpp" // PlateLabel #include "Map2D.hpp" // Map2D +#include "Panels.hpp" // EditField, EditPanelState #include #include #include @@ -15,7 +16,7 @@ // ViewerInput.cpp (input/picking/keys) and ViewerRender.cpp (drawing). struct Viewer { // ---- Files / save format ------------------------------------------------ - static constexpr uint32_t SAVE_VERSION = 23; // v23: civ cultural evolution; v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 + static constexpr uint32_t SAVE_VERSION = 24; // v24: edit mode (per-cell lock bits + locked-value maps); v23: civ cultural evolution; v22: civ diplomacy; v21: civ conflict/wars; v20: civ settlements; v19: ecoregions; v18: geography reshuffle salt; v17: +geography/atlas; v16: +event log; v15: stateful volcanoes; v14: old volcanoes; v13: +liveRate; v12: +step-back history; v11: +weather systems; v10: +weather fields; v9: +moons; v8: +Live World clock; v7: +biota; v6: self-describing config; v4: +biome; v3: +phase3 static constexpr int wxSaveMax = 40; // most recent step-back frames persisted in a save static constexpr int EVENT_LOG_MAX = 200; const char* CONFIG_PATH = "planet.cfg"; @@ -137,6 +138,11 @@ struct Viewer { double selectedThresh = 0.06; std::vector> subgrids; + // Edit mode (key F3, on a settled world; save v24): manually edit every property of the + // selected cell, with locking against automatic per-tick recomputation. Reuses selectedCell. + bool editMode = false; + EditPanelState editPanel; + // Transient on-screen status line. std::string statusMsg; double statusUntil = 0.0; @@ -194,6 +200,10 @@ struct Viewer { // ---- Input (ViewerInput.cpp) -------------------------------------------- void handleInput(); + void handleEditClick(); // dispatch a click inside the edit panel (tabs/fields/locks/picker/remove) + bool nudgeEditField(float wheel);// mouse-wheel adjusts the hovered numeric field; false if none hovered + void commitEditField(); // parse editPanel.editBuffer and apply it to editPanel.editingField + void exitEditMode(); // clear edit-mode UI state (on F3 off / reseed / deselect) // ---- Render (ViewerRender.cpp) ------------------------------------------ void renderFrame(); diff --git a/src/render/ViewerInput.cpp b/src/render/ViewerInput.cpp index 8060af7..5adc60e 100644 --- a/src/render/ViewerInput.cpp +++ b/src/render/ViewerInput.cpp @@ -52,6 +52,7 @@ void Viewer::handleInput() { } } } + if (editMode && inPanel) handleEditClick(); } } // Live World: is the camera following a storm? (look up by stable id; release if dissipated) @@ -94,6 +95,8 @@ void Viewer::handleInput() { mapPanY = std::clamp(mp.y - v * h - mapRect.y - (mapRect.height - h) * 0.5, -(h - mapRect.height) * 0.5, (h - mapRect.height) * 0.5); mapZoom = nz; } + } else if (editMode && inPanel && wheel != 0.0f && nudgeEditField(wheel)) { + // consumed by the edit panel (a numeric field under the cursor was nudged) } else { camDist -= wheel * 0.4f; camDist = std::clamp(camDist, 2.6f, 14.0f); @@ -115,7 +118,7 @@ void Viewer::handleInput() { hovered = -1; hasHoverSub = false; hoveredSubIdx = -1; bool have3DHit = false; Vec3 hitUnit, hitModel; if (inPanel) { - if (!subgrids.empty() && CheckCollisionPointRec(mp, gridRect)) { + if (!editMode && !subgrids.empty() && CheckCollisionPointRec(mp, gridRect)) { const auto& sg = subgrids[0]; int R = sg->res; int i = std::clamp((int)((mp.x - gridRect.x) / (gridRect.width / R)), 0, R - 1); int j = std::clamp((int)((mp.y - gridRect.y) / (gridRect.height / R)), 0, R - 1); @@ -154,6 +157,18 @@ void Viewer::handleInput() { selectCell(hovered); // --- Keys ------------------------------------------------------------- + // Edit-mode typed numeric entry swallows all other keys while a field is focused. + if (editMode && editPanel.editingField != EditField::None) { + int ch = GetCharPressed(); + while (ch > 0) { + if ((ch >= '0' && ch <= '9') || ch == '.' || ch == '-') editPanel.editBuffer += (char)ch; + ch = GetCharPressed(); + } + if (IsKeyPressed(KEY_BACKSPACE) && !editPanel.editBuffer.empty()) editPanel.editBuffer.pop_back(); + if (IsKeyPressed(KEY_ENTER)) commitEditField(); + if (IsKeyPressed(KEY_ESCAPE)) { editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); } + return; + } if (IsKeyPressed(KEY_SPACE) && !phase3Prompt) pauseAction(); if (IsKeyPressed(KEY_ONE)) { mode = ColorMode::Elevation; recolor(); } if (IsKeyPressed(KEY_TWO)) { mode = ColorMode::Plate; recolor(); } @@ -292,7 +307,15 @@ void Viewer::handleInput() { setStatus(showNames ? "Place names on" : "Place names off"); } } - if (IsKeyPressed(KEY_C)) { selectedCell = -1; subgrids.clear(); } + if (IsKeyPressed(KEY_F3) && settled) { // toggle edit mode (manual cell/settlement edits) + editMode = !editMode; + if (editMode) { paused = true; setStatus("Edit mode on - click a tile to edit (F3 to exit)"); } + else { exitEditMode(); setStatus("Edit mode off"); } + } + if (IsKeyPressed(KEY_C)) { + selectedCell = -1; subgrids.clear(); + editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); editPanel.pickerField = EditField::None; + } if (IsKeyPressed(KEY_R)) { cfg.seed = (uint32_t)(GetTime() * 100000) | 1; regen(); } if (IsKeyPressed(KEY_S)) { // one step if (liveWorld) liveStepForward(); // Live World: step the clock forward @@ -332,3 +355,170 @@ void Viewer::handleInput() { else driftRate = std::max(driftRate / 1.5, 0.5); } } + +// Dispatch a click that landed inside the edit panel (editMode && inPanel). drawEditPanel() +// rebuilt editPanel's hit-rects this same frame; this just resolves which one was hit and calls +// the matching Planet::setXxx()/setXxxLock() (PlanetEdit.cpp). Order mirrors visual stacking: +// tabs first, then an open picker (which otherwise swallows the whole panel), then locks/fields/ +// remove buttons. +void Viewer::handleEditClick() { + if (selectedCell < 0) return; + const int cellIdx = selectedCell; + const int si = (cellIdx < (int)planet.cellSettlement().size()) ? planet.cellSettlement()[cellIdx] : -1; + + for (size_t i = 0; i < editPanel.tabRects.size(); ++i) + if (CheckCollisionPointRec(mp, editPanel.tabRects[i])) { + editPanel.tab = (int)i; + editPanel.pickerField = EditField::None; + editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); + return; + } + + if (editPanel.pickerField != EditField::None) { + for (size_t i = 0; i < editPanel.pickerRects.size(); ++i) { + if (!CheckCollisionPointRec(mp, editPanel.pickerRects[i])) continue; + int v = editPanel.pickerValues[i]; + switch (editPanel.pickerField) { + case EditField::PlateId: planet.setPlateId(cellIdx, v); break; + case EditField::Biome: planet.setBiome(cellIdx, (Biome)v); break; + case EditField::Allegiance: if (si >= 0) planet.setSettlementAllegiance(si, v); break; + case EditField::Culture: if (si >= 0) planet.setSettlementCulture(si, v); break; + case EditField::AddFlora: planet.addOrganism(cellIdx, BiotaKind::Flora, v); break; + case EditField::AddFauna: planet.addOrganism(cellIdx, BiotaKind::Fauna, v); break; + case EditField::AddFunga: planet.addOrganism(cellIdx, BiotaKind::Funga, v); break; + default: break; + } + editPanel.pickerField = EditField::None; + refreshView(); + return; + } + return; // inside the panel but not on a picker row: swallow the click, stay open + } + + for (size_t i = 0; i < editPanel.lockRects.size(); ++i) { + if (!CheckCollisionPointRec(mp, editPanel.lockRects[i])) continue; + EditField f = editPanel.lockIds[i]; + uint16_t bit = 0; + switch (f) { + case EditField::LockElevation: bit = LockElevation; break; + case EditField::LockPlate: bit = LockPlate; break; + case EditField::LockCrust: bit = LockCrust; break; + case EditField::LockBiome: bit = LockBiome; break; + case EditField::LockClimate: bit = LockClimate; break; + case EditField::LockBiota: bit = LockBiota; break; + case EditField::LockHabitability: bit = LockHabitability; break; + case EditField::LockPopulation: bit = LockPopulation; break; + case EditField::LockAllegiance: bit = LockAllegiance; break; + case EditField::LockCulture: bit = LockCulture; break; + default: break; + } + bool want = (planet.editLockAt(cellIdx) & bit) == 0; // toggle + switch (f) { + case EditField::LockElevation: planet.setElevationLock(cellIdx, want); break; + case EditField::LockPlate: planet.setPlateLock(cellIdx, want); break; + case EditField::LockCrust: planet.setCrustLock(cellIdx, want); break; + case EditField::LockBiome: planet.setBiomeLock(cellIdx, want); break; + case EditField::LockClimate: planet.setClimateLock(cellIdx, want); break; + case EditField::LockBiota: planet.setBiotaDensityLock(cellIdx, want); break; + case EditField::LockHabitability: planet.setHabitabilityLock(cellIdx, want); break; + case EditField::LockPopulation: if (si >= 0) planet.setPopulationLock(si, want); break; + case EditField::LockAllegiance: if (si >= 0) planet.setAllegianceLock(si, want); break; + case EditField::LockCulture: if (si >= 0) planet.setCultureLock(si, want); break; + default: break; + } + return; + } + + for (size_t i = 0; i < editPanel.removeRects.size(); ++i) { + if (!CheckCollisionPointRec(mp, editPanel.removeRects[i])) continue; + planet.removeOrganism(cellIdx, (BiotaKind)editPanel.removeKind[i], editPanel.removeIndex[i]); + return; + } + + for (size_t i = 0; i < editPanel.fieldRects.size(); ++i) { + if (!CheckCollisionPointRec(mp, editPanel.fieldRects[i])) continue; + EditField f = editPanel.fieldIds[i]; + if (f == EditField::Crust) { + planet.setCrust(cellIdx, !planet.cells[cellIdx].oceanic); + refreshView(); + return; + } + if (f == EditField::PlateId || f == EditField::Biome || f == EditField::Allegiance || f == EditField::Culture + || f == EditField::AddFlora || f == EditField::AddFauna || f == EditField::AddFunga) { + editPanel.pickerField = f; + return; + } + // Numeric: focus it for typed entry, seeded with the current value. + editPanel.editingField = f; + const Cell& c = planet.cells[cellIdx]; + switch (f) { + case EditField::Elevation: editPanel.editBuffer = TextFormat("%.0f", c.elevation); break; + case EditField::GeoAge: editPanel.editBuffer = TextFormat("%.0f", c.geoAge); break; + case EditField::Temperature: editPanel.editBuffer = TextFormat("%.1f", cellIdx < (int)planet.temperature().size() ? planet.temperature()[cellIdx] : 0.0); break; + case EditField::Moisture: editPanel.editBuffer = TextFormat("%.0f", cellIdx < (int)planet.moisture().size() ? planet.moisture()[cellIdx] * 100.0 : 0.0); break; + case EditField::FloraDensity: editPanel.editBuffer = TextFormat("%.0f", cellIdx < (int)planet.floraDensity().size() ? planet.floraDensity()[cellIdx] * 100.0 : 0.0); break; + case EditField::FaunaDensity: editPanel.editBuffer = TextFormat("%.0f", cellIdx < (int)planet.faunaDensity().size() ? planet.faunaDensity()[cellIdx] * 100.0 : 0.0); break; + case EditField::FungaDensity: editPanel.editBuffer = TextFormat("%.0f", cellIdx < (int)planet.fungaDensity().size() ? planet.fungaDensity()[cellIdx] * 100.0 : 0.0); break; + case EditField::Habitability: editPanel.editBuffer = TextFormat("%.0f", cellIdx < (int)planet.habitability().size() ? planet.habitability()[cellIdx] * 100.0 : 0.0); break; + case EditField::Population: if (si >= 0) editPanel.editBuffer = TextFormat("%.0f", planet.settlements[si].population); break; + default: break; + } + return; + } +} + +// Mouse wheel over a hovered numeric field row nudges it by a fixed step, without needing focus. +// Returns false (and leaves the wheel free for camera zoom) if nothing nudge-able is under the cursor. +bool Viewer::nudgeEditField(float wheel) { + if (selectedCell < 0 || editPanel.pickerField != EditField::None) return false; + const int cellIdx = selectedCell; + for (size_t i = 0; i < editPanel.fieldRects.size(); ++i) { + if (!CheckCollisionPointRec(mp, editPanel.fieldRects[i])) continue; + EditField f = editPanel.fieldIds[i]; + const int si = (cellIdx < (int)planet.cellSettlement().size()) ? planet.cellSettlement()[cellIdx] : -1; + double dir = (wheel > 0.0f) ? 1.0 : -1.0; + switch (f) { + case EditField::Elevation: planet.setElevation(cellIdx, planet.cells[cellIdx].elevation + dir * 100.0); break; + case EditField::GeoAge: planet.setGeoAge(cellIdx, planet.cells[cellIdx].geoAge + dir * 10.0); break; + case EditField::Temperature: planet.setTemperature(cellIdx, (cellIdx < (int)planet.temperature().size() ? planet.temperature()[cellIdx] : 0.0) + dir * 1.0); break; + case EditField::Moisture: planet.setMoisture(cellIdx, (cellIdx < (int)planet.moisture().size() ? planet.moisture()[cellIdx] : 0.0) + dir * 0.05); break; + case EditField::FloraDensity: planet.setFloraDensity(cellIdx, (cellIdx < (int)planet.floraDensity().size() ? planet.floraDensity()[cellIdx] : 0.0) + dir * 0.05); break; + case EditField::FaunaDensity: planet.setFaunaDensity(cellIdx, (cellIdx < (int)planet.faunaDensity().size() ? planet.faunaDensity()[cellIdx] : 0.0) + dir * 0.05); break; + case EditField::FungaDensity: planet.setFungaDensity(cellIdx, (cellIdx < (int)planet.fungaDensity().size() ? planet.fungaDensity()[cellIdx] : 0.0) + dir * 0.05); break; + case EditField::Habitability: planet.setHabitability(cellIdx, (cellIdx < (int)planet.habitability().size() ? planet.habitability()[cellIdx] : 0.0) + dir * 0.05); break; + case EditField::Population: if (si >= 0) planet.setSettlementPopulation(si, planet.settlements[si].population * (1.0 + dir * 0.1)); break; + default: return false; // not a nudge-able field (Crust/PlateId/Biome/Allegiance/Culture/Add*) + } + refreshView(); + return true; + } + return false; +} + +// Parse editPanel.editBuffer and apply it to the currently focused field (Enter, or a field +// switch). Silently discards on a bad/empty number, matching a text-field's usual "just stop +// editing" behaviour rather than raising an error for a hand-rolled widget. +void Viewer::commitEditField() { + if (selectedCell < 0 || editPanel.editingField == EditField::None) { + editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); return; + } + double v = 0.0; + try { v = std::stod(editPanel.editBuffer); } + catch (...) { editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); return; } + const int cellIdx = selectedCell; + const int si = (cellIdx < (int)planet.cellSettlement().size()) ? planet.cellSettlement()[cellIdx] : -1; + switch (editPanel.editingField) { + case EditField::Elevation: planet.setElevation(cellIdx, v); break; + case EditField::GeoAge: planet.setGeoAge(cellIdx, v); break; + case EditField::Temperature: planet.setTemperature(cellIdx, v); break; + case EditField::Moisture: planet.setMoisture(cellIdx, v / 100.0); break; + case EditField::FloraDensity: planet.setFloraDensity(cellIdx, v / 100.0); break; + case EditField::FaunaDensity: planet.setFaunaDensity(cellIdx, v / 100.0); break; + case EditField::FungaDensity: planet.setFungaDensity(cellIdx, v / 100.0); break; + case EditField::Habitability: planet.setHabitability(cellIdx, v / 100.0); break; + case EditField::Population: if (si >= 0) planet.setSettlementPopulation(si, v); break; + default: break; + } + editPanel.editingField = EditField::None; editPanel.editBuffer.clear(); + refreshView(); +} diff --git a/src/render/ViewerRender.cpp b/src/render/ViewerRender.cpp index d76cd7e..3c545af 100644 --- a/src/render/ViewerRender.cpp +++ b/src/render/ViewerRender.cpp @@ -822,12 +822,23 @@ void Viewer::renderLiveInfo() { // Right column: hover/selection info (top) + detail panel or world stats (bottom). void Viewer::renderPanels() { drawHoverPanel(planet, hoverRect, hovered, selectedCell); - if (selectedCell >= 0 && !subgrids.empty()) + if (editMode) { + if (selectedCell >= 0) { + drawEditPanel(planet, selectedCell, panelRect, editPanel); + } else { + DrawRectangleRec(panelRect, Color{12, 14, 22, 235}); + DrawRectangleLinesEx(panelRect, 2, Color{210, 150, 70, 255}); + DrawText("EDIT MODE", (int)panelRect.x + 10, (int)panelRect.y + 8, 18, Color{255, 200, 120, 255}); + DrawText("click a tile to edit its properties", (int)panelRect.x + 10, (int)panelRect.y + 40, 16, Color{190, 195, 210, 255}); + DrawText("F3: exit", (int)panelRect.x + 10, (int)panelRect.y + 64, 14, Color{170, 170, 185, 255}); + } + } else if (selectedCell >= 0 && !subgrids.empty()) { drawDetailPanel(planet, subgrids[0], selectedCell, planet.cells[selectedCell].elevation, planet.cells[selectedCell].geoAge, panelRect, gridRect, hoveredSubIdx); - else + } else { drawStats(planet, panelRect, elapsedMy, settled, liveWorld, liveTime); + } } // Top-left HUD text + the clickable pause button. @@ -838,6 +849,11 @@ void Viewer::renderHUD() { int vw = MeasureText(vm, 22); DrawText(vm, view3DW / 2 - vw / 2, 10, 22, Color{235, 225, 140, 255}); } + if (editMode) { + const char* eb = "EDIT MODE"; + int ew = MeasureText(eb, 18); + DrawText(eb, view3DW / 2 - ew / 2, 36, 18, Color{255, 170, 90, 255}); + } int y = 10; auto line = [&](const std::string& s){ DrawText(s.c_str(), 12, y, 18, RAYWHITE); y += 22; }; diff --git a/src/sim/Planet.hpp b/src/sim/Planet.hpp index 4802830..de55e88 100644 --- a/src/sim/Planet.hpp +++ b/src/sim/Planet.hpp @@ -15,6 +15,7 @@ #include #include #include +#include class Planet { public: @@ -253,6 +254,46 @@ public: const std::vector& settlementDrought() const { return sCivDrought; } const std::vector& settlementPlague() const { return sCivPlague; } // active epidemic loss (/yr, 0 = none) + // --- Edit mode (manual world editing, PlanetEdit.cpp; save v24) -------------- + // Every setXxx() writes directly into the field the simulation already reads each + // tick, so an edit "takes" immediately (Eulerian: properties flow over the fixed + // grid). Without locking it's a one-off nudge -- the ongoing sim keeps evolving it, + // like a meteor impact. setXxxLock(i, true) additionally exempts that cell's field + // from automatic per-tick recomputation (see EditLock in PlanetTypes.hpp for which + // sim pass reads which bit) until unlocked. Climate/biota-density/habitability have + // no persistent backing of their own, so locking them also pins the frozen value. + void setElevation(int i, double meters); + void setElevationLock(int i, bool locked); + void setPlateId(int i, int plateId); + void setPlateLock(int i, bool locked); + void setCrust(int i, bool oceanic); + void setGeoAge(int i, double my); + void setCrustLock(int i, bool locked); // covers oceanic + geoAge together + void setBiome(int i, Biome b); + void setBiomeLock(int i, bool locked); + void setTemperature(int i, double celsius); + void setMoisture(int i, double normalized01); + void setClimateLock(int i, bool locked); // covers temperature + moisture together + void setFloraDensity(int i, double d01); + void setFaunaDensity(int i, double d01); + void setFungaDensity(int i, double d01); + void setBiotaDensityLock(int i, bool locked); // covers flora/fauna/funga density together + void setHabitability(int i, double h01); + void setHabitabilityLock(int i, bool locked); + // Biota population: directly add/remove an Organism from a cell's discrete list (already + // stateful/saved -- no lock concept needed, mirrors what generateBiota() itself writes). + void addOrganism(int i, BiotaKind kind, int archetype); + void removeOrganism(int i, BiotaKind kind, int index); + // Settlement-linked edits (keyed by settlement index; the lock lives on the settlement's + // home cell, gating stepCivilization()/stepConflict()/stepCulture() for it). + void setSettlementPopulation(int settleIdx, double population); + void setPopulationLock(int settleIdx, bool locked); + void setSettlementAllegiance(int settleIdx, int overlordSettleIdx); // -1 = independent + void setAllegianceLock(int settleIdx, bool locked); + void setSettlementCulture(int settleIdx, int cultureIdx); // -1 = none + void setCultureLock(int settleIdx, bool locked); + uint16_t editLockAt(int i) const; // current EditLock bitmask for a cell (0 if out of range) + // Build a fine-resolution subgrid patch for one macro cell (phase 4/5 hook). std::shared_ptr makeSubGrid(int cellIndex, int res) const; @@ -274,12 +315,15 @@ public: // hasSettlements: whether the stream carries the civilization settlements block (save v20+); older // saves load with none (re-seeded on demand via the civ key). hasCultures: whether the stream // carries the stateful culture block (save v23+); older saves re-seed one culture per continent - // on the next computeCultures(). + // on the next computeCultures(). hasEditState: whether the stream carries the edit-mode block + // (save v24+) -- per-cell editLock bits + the sparse locked-value maps; older saves load with no + // manual edits/locks. bool readState(std::istream& is, bool hasBiome = true, bool hasBiota = true, bool hasMoons = true, bool hasWeather = true, bool hasStorms = true, bool hasVolcanoes = true, bool hasStatefulVolcanoes = true, bool hasGeography = true, bool hasGeoSalt = true, bool hasEcoregions = true, bool hasSettlements = true, - bool hasConflict = true, bool hasDiplo = true, bool hasCultures = true); + bool hasConflict = true, bool hasDiplo = true, bool hasCultures = true, + bool hasEditState = true); // Helpers for rendering / info. double cellWidthMeters() const; // approx lateral cell spacing @@ -299,7 +343,7 @@ private: bool hasMoons, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, - bool hasConflict, bool hasDiplo, bool hasCultures); + bool hasConflict, bool hasDiplo, bool hasCultures, bool hasEditState); void clearDerivedState(); // clear geometry-dependent scratch/derived fields void assignPlates(); void seedInitialRelief(); @@ -413,6 +457,14 @@ private: std::vector sFloraDensity, sFaunaDensity, sFungaDensity; std::vector sBiota; bool sHasBiota = false; + + // Edit mode (save v24; PlanetEdit.cpp): sparse locked values for the derived fields that have + // no other persistent backing (fully recomputed from scratch each tick, so LOCKING pins the + // VALUE, not just a write). Keyed by cell index; an entry exists only while the matching + // EditLock bit is set on that cell. Naturally sparse -- most worlds lock few or no cells. + std::unordered_map sLockTemp, sLockMoist; + std::unordered_map sLockFlora, sLockFauna, sLockFunga; + std::unordered_map sLockHab; }; // Human-editable config file (key = value text). All PlanetConfig input diff --git a/src/sim/PlanetBiomes.cpp b/src/sim/PlanetBiomes.cpp index 1b208cd..834fc44 100644 --- a/src/sim/PlanetBiomes.cpp +++ b/src/sim/PlanetBiomes.cpp @@ -57,6 +57,6 @@ void Planet::classifyBiomes() { else if (moist < GRASS_MOIST) b = (temp > SAVANNA_TEMP) ? Biome::Savanna : Biome::Grassland; else b = Biome::Forest; } - cells[i].biome = b; + if (!(c.editLock & LockBiome)) cells[i].biome = b; // edit mode: biome pinned } } diff --git a/src/sim/PlanetCiv.cpp b/src/sim/PlanetCiv.cpp index 9d7bcdc..b1b3c00 100644 --- a/src/sim/PlanetCiv.cpp +++ b/src/sim/PlanetCiv.cpp @@ -69,6 +69,9 @@ void Planet::computeHabitability() { double hab = (wW * water + fW * food + tW * tComfort) * coldGate * elevF; sHabitability[i] = std::clamp(hab, 0.0, 1.0); } + // Edit mode: re-apply any locked habitability (see setHabitabilityLock/setHabitability in + // PlanetEdit.cpp) -- the array is fully recomputed above each call, so the pin lives in sLockHab. + for (const auto& kv : sLockHab) if (kv.first >= 0 && kv.first < n) sHabitability[kv.first] = kv.second; } // One-time placement: habitability-WEIGHTED RANDOM sampling with soft local suppression, so settlements @@ -259,7 +262,8 @@ CivUpdate Planet::stepCivilization(double dtHours, double liveTime) { stormLoss += cfg.civStormDeathRate * ws.strength * overlap * (hur ? cfg.civHurricaneDeathMult : 1.0); } - if (dtYears > 0.0) { + bool popLocked = (c >= 0 && c < n) && (cells[c].editLock & LockPopulation) != 0; + if (dtYears > 0.0 && !popLocked) { // edit mode: population growth pinned double P = st.population; double r = cfg.civGrowthRate * (cfg.civGrowthMin + (1.0 - cfg.civGrowthMin) * hab); // env-driven rate P += r * P * (1.0 - P / std::max(1.0, K)) * dtYears; // logistic toward K diff --git a/src/sim/PlanetClimate.cpp b/src/sim/PlanetClimate.cpp index 57dc546..e4cb240 100644 --- a/src/sim/PlanetClimate.cpp +++ b/src/sim/PlanetClimate.cpp @@ -94,6 +94,9 @@ void Planet::computeClimate() { } for (int i = 0; i < n; ++i) sTemp[i] += dT[i]; } + // Edit mode: re-apply any locked temperature values (see setClimateLock/setTemperature in + // PlanetEdit.cpp) -- sTemp is fully recomputed above each call, so the pin lives in sLockTemp. + for (const auto& kv : sLockTemp) if (kv.first >= 0 && kv.first < n) sTemp[kv.first] = kv.second; // 2. Steady-state moisture advection along the wind (iterative upwind differencing, // double-buffered -> deterministic). Ocean cells are a moisture source; land @@ -165,6 +168,8 @@ void Planet::computeClimate() { ref = std::max(1e-6, landP[mid] / 0.5); // median -> 0.5 } for (int i = 0; i < n; ++i) sMoist[i] = std::clamp(sPrecip[i] / ref, 0.0, 1.0); + // Edit mode: re-apply any locked moisture values (see above). + for (const auto& kv : sLockMoist) if (kv.first >= 0 && kv.first < n) sMoist[kv.first] = kv.second; // --- Seasons (obliquity) ------------------------------------------------------ // Per-cell summer (warmest-month) and winter (coldest-month) temperatures around diff --git a/src/sim/PlanetConflict.cpp b/src/sim/PlanetConflict.cpp index efbb2a7..0ca9be0 100644 --- a/src/sim/PlanetConflict.cpp +++ b/src/sim/PlanetConflict.cpp @@ -107,6 +107,8 @@ ConflictUpdate Planet::stepConflict(long year) { int no = alive(ov) ? nationOfCap(ov) : -1; if (no < 0 || (int)s == ov) { sSettleAllegiance[s] = -1; continue; } // overlord fell -> province freed if (!alive((int)s)) continue; + int sc = settlements[s].cell; + if (sc >= 0 && sc < (int)cells.size() && (cells[sc].editLock & LockAllegiance)) continue; // edit mode: pinned int myCult = (s < sSettleCulture.size()) ? sSettleCulture[s] : -1; int ovCult = nations[no].cultureId; double cultBonus = (myCult != ovCult) ? 2.0 : 0.2; // foreign rule is resented @@ -152,14 +154,16 @@ ConflictUpdate Planet::stepConflict(long year) { if (target < 0) target = frontierOf(loser, settlements[winCap].cell, false); // else the capital itself if (target >= 0) { int tcell = settlements[target].cell; + bool allegLocked = tcell >= 0 && tcell < (int)cells.size() + && (cells[tcell].editLock & LockAllegiance) != 0; // edit mode: pinned if (rnd() < cfg.warSackChance) { settlements[target].population = abP * 0.5; // razed to ruins - sSettleAllegiance[target] = -1; + if (!allegLocked) sSettleAllegiance[target] = -1; up.events.push_back(WarEvent{2, tcell, "The " + nations[winner].name + " sacks " + settlements[target].name, settlements[target].name + " is put to the torch -- only ruins remain."}); } else { - sSettleAllegiance[target] = winCap; // flips to the victor + if (!allegLocked) sSettleAllegiance[target] = winCap; // flips to the victor settlements[target].population *= 0.8; // survives the siege, diminished up.events.push_back(WarEvent{1, tcell, "The " + nations[winner].name + " captures " + settlements[target].name, diff --git a/src/sim/PlanetCulture.cpp b/src/sim/PlanetCulture.cpp index 147d66d..4e8c1bc 100644 --- a/src/sim/PlanetCulture.cpp +++ b/src/sim/PlanetCulture.cpp @@ -300,6 +300,8 @@ std::vector Planet::stepCulture(long year) { if (sSettleAllegiance.size() == ns && cfg.cultAssimRate > 0.0) { for (size_t s = 0; s < ns; ++s) { if (!living(s)) continue; + int sc = settlements[s].cell; + if (sc >= 0 && sc < n && (cells[sc].editLock & LockCulture)) continue; // edit mode: pinned int ov = sSettleAllegiance[s]; if (ov < 0 || ov >= (int)ns || ov == (int)s || !living((size_t)ov)) continue; int myCult = sSettleCulture[s], ovCult = sSettleCulture[ov]; @@ -328,6 +330,8 @@ std::vector Planet::stepCulture(long year) { std::vector pressure(cultures.size(), 0.0); for (size_t s = 0; s < ns; ++s) { if (!living(s) || converted[s] || isCapital[s]) continue; + int sc = settlements[s].cell; + if (sc >= 0 && sc < n && (cells[sc].editLock & LockCulture)) continue; // edit mode: pinned int myCult = sSettleCulture[s]; if (myCult < 0 || myCult >= (int)cultures.size()) continue; std::fill(pressure.begin(), pressure.end(), 0.0); @@ -430,7 +434,11 @@ std::vector Planet::stepCulture(long year) { : makeFaithName(seed ^ cultHash(rk * 40503u + 0xFA17Fu), child.bank); int childIdx = (int)cultures.size(); - for (int s : cluster) { sSettleCulture[s] = childIdx; converted[s] = 1; } + for (int s : cluster) { + int sc = settlements[s].cell; + if (sc >= 0 && sc < n && (cells[sc].editLock & LockCulture)) continue; // edit mode: pinned + sSettleCulture[s] = childIdx; converted[s] = 1; + } std::string title = child.name + " break away from " + parName; title[0] = (char)std::toupper((unsigned char)title[0]); ev.push_back(WarEvent{ 2, settlements[far].cell, title, diff --git a/src/sim/PlanetDrift.cpp b/src/sim/PlanetDrift.cpp index 1d34361..57300e8 100644 --- a/src/sim/PlanetDrift.cpp +++ b/src/sim/PlanetDrift.cpp @@ -43,6 +43,7 @@ void Planet::advect(double dtMy) { } for (int c = 0; c < n; ++c) { + if (cells[c].editLock & (LockPlate | LockCrust)) continue; // edit mode: plate/crust identity pinned cells[c].geoAge += dtMy; double maxClose = -1e30; int invNb = -1; for (int j : cells[c].neighbors) { @@ -90,6 +91,7 @@ void Planet::advect(double dtMy) { for (int i = 0; i < n; ++i) { p2[i] = cells[i].plateId; e2[i] = cells[i].elevation; a2[i] = cells[i].geoAge; o2[i] = cells[i].oceanic; } for (int i = 0; i < n; ++i) { if (cells[i].plateId >= 0 && plates[cells[i].plateId].baby) continue; // leave baby crust alone + if (cells[i].editLock & (LockPlate | LockCrust)) continue; // edit mode: plate/crust identity pinned int bestP = -1, bestC = 0, src = -1; for (int j : cells[i].neighbors) { int pj = p2[j], cnt = 0, s = -1; @@ -115,15 +117,16 @@ void Planet::advect(double dtMy) { int lo = std::max((int)(targetLand * (1.0 - cfg.landBand)), std::max(1, n / 100)); int hi = std::min((int)(targetLand * (1.0 + cfg.landBand)), n - std::max(1, n / 100)); int diff = (lc > hi) ? (lc - hi) : (lc < lo ? (lc - lo) : 0); + auto crustPinned = [&](int i){ return (cells[i].editLock & (LockCrust | LockElevation)) != 0; }; for (int want = 4; want >= 2 && diff > 0; --want) // too much land: rift thinnest margins for (int i = 0; i < n && diff > 0; ++i) - if (!cells[i].oceanic && !plates[cells[i].plateId].baby && oceanNbrs(i) >= want) { + if (!cells[i].oceanic && !plates[cells[i].plateId].baby && !crustPinned(i) && oceanNbrs(i) >= want) { cells[i].oceanic = true; cells[i].elevation = cfg.ridgeDepth; cells[i].geoAge = 0.0; --diff; } for (int want = 4; want >= 2 && diff < 0; --want) // too little land: accrete ocean margins for (int i = 0; i < n && diff < 0; ++i) { auto [ln, ls] = landNbrs(i); - if (cells[i].oceanic && !plates[cells[i].plateId].baby && ln >= want) { + if (cells[i].oceanic && !plates[cells[i].plateId].baby && !crustPinned(i) && ln >= want) { cells[i].oceanic = false; cells[i].elevation = cells[ls].elevation; cells[i].geoAge = cells[ls].geoAge; ++diff; } } diff --git a/src/sim/PlanetEdit.cpp b/src/sim/PlanetEdit.cpp new file mode 100644 index 0000000..e92a2c5 --- /dev/null +++ b/src/sim/PlanetEdit.cpp @@ -0,0 +1,194 @@ +#include "Planet.hpp" +#include +#include + +// --- Edit mode: manual cell/settlement-property edits ------------------------ +// Every setXxx() writes straight into the field the simulation already reads each +// tick (Eulerian: properties flow over the fixed grid), so an edit takes effect on +// the very next pass with no extra machinery. Elevation/plateId/oceanic/geoAge/biome +// are already-authoritative Cell fields, so their lock (see EditLock in +// PlanetTypes.hpp) is a bare bit -- the read side (PlanetTectonics/Erosion/Hydrology/ +// Drift/Biomes.cpp) just skips writing a locked cell, leaving the last value in +// place. Temperature/moisture, biota density and habitability are fully recomputed +// from scratch every call (no persistent identity of their own), so locking them +// also pins the frozen value in a small sparse map, which the owning compute*() +// re-applies after its normal pass (PlanetClimate/FloraGen/FaunaGen/FungiGen/Civ.cpp). + +void Planet::setElevation(int i, double meters) { + if (i < 0 || i >= (int)cells.size() || !std::isfinite(meters)) return; + cells[i].elevation = std::clamp(meters, -11000.0, cfg.peakSoftCapEnd); +} +void Planet::setElevationLock(int i, bool locked) { + if (i < 0 || i >= (int)cells.size()) return; + if (locked) cells[i].editLock |= LockElevation; else cells[i].editLock &= ~LockElevation; +} + +void Planet::setPlateId(int i, int plateId) { + if (i < 0 || i >= (int)cells.size() || plateId < 0 || plateId >= (int)plates.size()) return; + cells[i].plateId = plateId; +} +void Planet::setPlateLock(int i, bool locked) { + if (i < 0 || i >= (int)cells.size()) return; + if (locked) cells[i].editLock |= LockPlate; else cells[i].editLock &= ~LockPlate; +} + +void Planet::setCrust(int i, bool oceanic) { + if (i < 0 || i >= (int)cells.size()) return; + cells[i].oceanic = oceanic; +} +void Planet::setGeoAge(int i, double my) { + if (i < 0 || i >= (int)cells.size() || !std::isfinite(my)) return; + cells[i].geoAge = std::max(0.0, my); +} +void Planet::setCrustLock(int i, bool locked) { + if (i < 0 || i >= (int)cells.size()) return; + if (locked) cells[i].editLock |= LockCrust; else cells[i].editLock &= ~LockCrust; +} + +void Planet::setBiome(int i, Biome b) { + if (i < 0 || i >= (int)cells.size() || b > Biome::Mountains) return; + cells[i].biome = b; +} +void Planet::setBiomeLock(int i, bool locked) { + if (i < 0 || i >= (int)cells.size()) return; + if (locked) cells[i].editLock |= LockBiome; else cells[i].editLock &= ~LockBiome; +} + +void Planet::setTemperature(int i, double celsius) { + if (i < 0 || i >= (int)cells.size() || !std::isfinite(celsius)) return; + if ((int)sTemp.size() != (int)cells.size()) computeClimate(); + sTemp[i] = celsius; + if (cells[i].editLock & LockClimate) sLockTemp[i] = celsius; // keep a live lock in sync +} +void Planet::setMoisture(int i, double normalized01) { + if (i < 0 || i >= (int)cells.size() || !std::isfinite(normalized01)) return; + if ((int)sMoist.size() != (int)cells.size()) computeClimate(); + double v = std::clamp(normalized01, 0.0, 1.0); + sMoist[i] = v; + if (cells[i].editLock & LockClimate) sLockMoist[i] = v; +} +void Planet::setClimateLock(int i, bool locked) { + if (i < 0 || i >= (int)cells.size()) return; + if (locked) { + if ((int)sTemp.size() != (int)cells.size() || (int)sMoist.size() != (int)cells.size()) computeClimate(); + sLockTemp[i] = sTemp[i]; sLockMoist[i] = sMoist[i]; + cells[i].editLock |= LockClimate; + } else { + sLockTemp.erase(i); sLockMoist.erase(i); + cells[i].editLock &= ~LockClimate; + } +} + +void Planet::setFloraDensity(int i, double d01) { + if (i < 0 || i >= (int)cells.size() || !std::isfinite(d01)) return; + if ((int)sFloraDensity.size() != (int)cells.size()) computeBiotaDensity(); + double v = std::clamp(d01, 0.0, 1.0); + sFloraDensity[i] = v; + if (cells[i].editLock & LockBiota) sLockFlora[i] = v; +} +void Planet::setFaunaDensity(int i, double d01) { + if (i < 0 || i >= (int)cells.size() || !std::isfinite(d01)) return; + if ((int)sFaunaDensity.size() != (int)cells.size()) computeBiotaDensity(); + double v = std::clamp(d01, 0.0, 1.0); + sFaunaDensity[i] = v; + if (cells[i].editLock & LockBiota) sLockFauna[i] = v; +} +void Planet::setFungaDensity(int i, double d01) { + if (i < 0 || i >= (int)cells.size() || !std::isfinite(d01)) return; + if ((int)sFungaDensity.size() != (int)cells.size()) computeBiotaDensity(); + double v = std::clamp(d01, 0.0, 1.0); + sFungaDensity[i] = v; + if (cells[i].editLock & LockBiota) sLockFunga[i] = v; +} +void Planet::setBiotaDensityLock(int i, bool locked) { + if (i < 0 || i >= (int)cells.size()) return; + if (locked) { + if ((int)sFloraDensity.size() != (int)cells.size()) computeBiotaDensity(); + sLockFlora[i] = sFloraDensity[i]; sLockFauna[i] = sFaunaDensity[i]; sLockFunga[i] = sFungaDensity[i]; + cells[i].editLock |= LockBiota; + } else { + sLockFlora.erase(i); sLockFauna.erase(i); sLockFunga.erase(i); + cells[i].editLock &= ~LockBiota; + } +} + +void Planet::setHabitability(int i, double h01) { + if (i < 0 || i >= (int)cells.size() || !std::isfinite(h01)) return; + if ((int)sHabitability.size() != (int)cells.size()) computeHabitability(); + double v = std::clamp(h01, 0.0, 1.0); + sHabitability[i] = v; + if (cells[i].editLock & LockHabitability) sLockHab[i] = v; +} +void Planet::setHabitabilityLock(int i, bool locked) { + if (i < 0 || i >= (int)cells.size()) return; + if (locked) { + if ((int)sHabitability.size() != (int)cells.size()) computeHabitability(); + sLockHab[i] = sHabitability[i]; + cells[i].editLock |= LockHabitability; + } else { + sLockHab.erase(i); + cells[i].editLock &= ~LockHabitability; + } +} + +void Planet::addOrganism(int i, BiotaKind kind, int archetype) { + if (i < 0 || i >= (int)cells.size()) return; + if (archetype < 0 || archetype >= (int)biotaArchetypes().size()) return; + if ((int)sBiota.size() != (int)cells.size()) sBiota.assign(cells.size(), {}); + Organism o{ (uint16_t)archetype, (uint8_t)cells[i].biome }; + switch (kind) { + case BiotaKind::Flora: sBiota[i].flora.push_back(o); break; + case BiotaKind::Fauna: sBiota[i].fauna.push_back(o); break; + case BiotaKind::Funga: sBiota[i].funga.push_back(o); break; + } + sHasBiota = true; +} +void Planet::removeOrganism(int i, BiotaKind kind, int index) { + if (i < 0 || i >= (int)sBiota.size() || index < 0) return; + auto erase = [&](std::vector& v) { if (index < (int)v.size()) v.erase(v.begin() + index); }; + switch (kind) { + case BiotaKind::Flora: erase(sBiota[i].flora); break; + case BiotaKind::Fauna: erase(sBiota[i].fauna); break; + case BiotaKind::Funga: erase(sBiota[i].funga); break; + } +} + +void Planet::setSettlementPopulation(int settleIdx, double population) { + if (settleIdx < 0 || settleIdx >= (int)settlements.size() || !std::isfinite(population)) return; + settlements[settleIdx].population = std::max(1.0, population); +} +void Planet::setPopulationLock(int settleIdx, bool locked) { + if (settleIdx < 0 || settleIdx >= (int)settlements.size()) return; + int c = settlements[settleIdx].cell; if (c < 0 || c >= (int)cells.size()) return; + if (locked) cells[c].editLock |= LockPopulation; else cells[c].editLock &= ~LockPopulation; +} + +void Planet::setSettlementAllegiance(int settleIdx, int overlordSettleIdx) { + if (settleIdx < 0 || settleIdx >= (int)settlements.size()) return; + if (overlordSettleIdx != -1 && (overlordSettleIdx < 0 || overlordSettleIdx >= (int)settlements.size())) return; + if (overlordSettleIdx == settleIdx) return; // a settlement can't be its own overlord + if (sSettleAllegiance.size() != settlements.size()) sSettleAllegiance.assign(settlements.size(), -1); + sSettleAllegiance[settleIdx] = overlordSettleIdx; +} +void Planet::setAllegianceLock(int settleIdx, bool locked) { + if (settleIdx < 0 || settleIdx >= (int)settlements.size()) return; + int c = settlements[settleIdx].cell; if (c < 0 || c >= (int)cells.size()) return; + if (locked) cells[c].editLock |= LockAllegiance; else cells[c].editLock &= ~LockAllegiance; +} + +void Planet::setSettlementCulture(int settleIdx, int cultureIdx) { + if (settleIdx < 0 || settleIdx >= (int)settlements.size()) return; + if (cultureIdx != -1 && (cultureIdx < 0 || cultureIdx >= (int)cultures.size())) return; + if (sSettleCulture.size() != settlements.size()) sSettleCulture.assign(settlements.size(), -1); + sSettleCulture[settleIdx] = cultureIdx; +} +void Planet::setCultureLock(int settleIdx, bool locked) { + if (settleIdx < 0 || settleIdx >= (int)settlements.size()) return; + int c = settlements[settleIdx].cell; if (c < 0 || c >= (int)cells.size()) return; + if (locked) cells[c].editLock |= LockCulture; else cells[c].editLock &= ~LockCulture; +} + +uint16_t Planet::editLockAt(int i) const { + if (i < 0 || i >= (int)cells.size()) return 0; + return cells[i].editLock; +} diff --git a/src/sim/PlanetErosion.cpp b/src/sim/PlanetErosion.cpp index 6ab0ddb..a7d26be 100644 --- a/src/sim/PlanetErosion.cpp +++ b/src/sim/PlanetErosion.cpp @@ -33,8 +33,10 @@ void Planet::erode(double dtMy) { // Upper clamp tracks peakSoftCapEnd (matches step()); the soft peak cap, not a // fixed 9000 m wall here, governs how high mountains stand. #pragma omp parallel for schedule(static) if(n > 20000) - for (int i = 0; i < n; ++i) + for (int i = 0; i < n; ++i) { + if (cells[i].editLock & LockElevation) continue; // edit mode: elevation pinned cells[i].elevation = std::clamp(cells[i].elevation + sErode[i], -11000.0, cfg.peakSoftCapEnd); + } if (++erodeIter % cfg.seaLevelEvery == 0) adjustSeaLevel(); } diff --git a/src/sim/PlanetFaunaGen.cpp b/src/sim/PlanetFaunaGen.cpp index a1b6162..00ddc9a 100644 --- a/src/sim/PlanetFaunaGen.cpp +++ b/src/sim/PlanetFaunaGen.cpp @@ -21,6 +21,8 @@ void Planet::computeFaunaDensity() { // flora (plankton/algae) computed by computeFloraDensity for ocean cells. sFaunaDensity[i] = std::clamp(sFloraDensity[i] * prod, 0.0, 1.0); } + // Edit mode: re-apply any locked fauna density (see setBiotaDensityLock/setFaunaDensity). + for (const auto& kv : sLockFauna) if (kv.first >= 0 && kv.first < n) sFaunaDensity[kv.first] = kv.second; } // Local prey abundance = mean fauna density over the cell + its neighbours. diff --git a/src/sim/PlanetFloraGen.cpp b/src/sim/PlanetFloraGen.cpp index 060039b..664d9a2 100644 --- a/src/sim/PlanetFloraGen.cpp +++ b/src/sim/PlanetFloraGen.cpp @@ -48,6 +48,9 @@ void Planet::computeFloraDensity() { sFloraDensity[i] = std::clamp(mBase + (1.0 - mBase) * std::max(shelf, coast), 0.0, 1.0); } } + // Edit mode: re-apply any locked flora density (see setBiotaDensityLock/setFloraDensity in + // PlanetEdit.cpp) -- the array is fully recomputed above each call, so the pin lives in sLockFlora. + for (const auto& kv : sLockFlora) if (kv.first >= 0 && kv.first < n) sFloraDensity[kv.first] = kv.second; } std::vector Planet::fillFlora(int i, const std::vector& nbr, uint32_t& rng) { diff --git a/src/sim/PlanetFungiGen.cpp b/src/sim/PlanetFungiGen.cpp index ea802b7..7d39e60 100644 --- a/src/sim/PlanetFungiGen.cpp +++ b/src/sim/PlanetFungiGen.cpp @@ -26,6 +26,8 @@ void Planet::computeFungaDensity() { double organic = w * sFloraDensity[i] + (1.0 - w); sFungaDensity[i] = std::clamp(std::min(mf, organic) * tf, 0.0, 1.0); } + // Edit mode: re-apply any locked funga density (see setBiotaDensityLock/setFungaDensity). + for (const auto& kv : sLockFunga) if (kv.first >= 0 && kv.first < n) sFungaDensity[kv.first] = kv.second; } std::vector Planet::fillFunga(int i, const std::vector& nbr, uint32_t& rng) { diff --git a/src/sim/PlanetHydrology.cpp b/src/sim/PlanetHydrology.cpp index 0383ac2..37875d6 100644 --- a/src/sim/PlanetHydrology.cpp +++ b/src/sim/PlanetHydrology.cpp @@ -81,7 +81,9 @@ void Planet::hydrology(double dtMy) { for (int idx : sHydroOrder) { // upstream -> downstream double carried = load[idx]; int d = sFlowTo[idx]; - if (d < 0) { cells[idx].elevation += carried; continue; } // sink: deposit all + bool locked = (cells[idx].editLock & LockElevation) != 0; // edit mode: elevation pinned + if (d < 0) { if (!locked) cells[idx].elevation += carried; continue; } // sink: deposit all + if (locked) { load[d] += carried; continue; } // pinned: sediment passes through unchanged Vec3 ui = cells[idx].unit, ud = cells[d].unit; double ang = std::acos(std::clamp(ui.dot(ud), -1.0, 1.0)); double dist = std::max(1.0, cfg.radius * ang); diff --git a/src/sim/PlanetIO.cpp b/src/sim/PlanetIO.cpp index 1f36e13..711fdd6 100644 --- a/src/sim/PlanetIO.cpp +++ b/src/sim/PlanetIO.cpp @@ -592,17 +592,32 @@ void Planet::writeState(std::ostream& os) const { } writeVec(os, sSettleCulture); writePod(os, sCultureNextId); + // v24: edit mode. Per-cell lock bits as a plain trailing vector (like sSettleAllegiance -- kept + // out of the main per-cell POD block above so older readers, which stop before this point, never + // need to skip over it). The derived fields with no other persistent backing (climate temp/ + // moisture, biota density, habitability) need their pinned VALUE saved too, as a sparse + // (cellIndex -> value) map -- most worlds lock few or no cells. + { std::vector locks(cells.size()); + for (size_t i = 0; i < cells.size(); ++i) locks[i] = cells[i].editLock; + writeVec(os, locks); } + auto writeLockMap = [&](const std::unordered_map& m) { + uint64_t cnt = m.size(); writePod(os, cnt); + for (const auto& kv : m) { int32_t k = kv.first; writePod(os, k); writePod(os, kv.second); } + }; + writeLockMap(sLockTemp); writeLockMap(sLockMoist); + writeLockMap(sLockFlora); writeLockMap(sLockFauna); writeLockMap(sLockFunga); + writeLockMap(sLockHab); } bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, - bool hasConflict, bool hasDiplo, bool hasCultures) { + bool hasConflict, bool hasDiplo, bool hasCultures, bool hasEditState) { Planet tmp; tmp.drifting = drifting; // readState never serialized this flag; preserve old caller-visible behavior. if (!tmp.readStateImpl(is, hasBiome, hasBiota, hasMoons, hasWeather, hasStorms, hasVolcanoes, hasStatefulVolcanoes, hasGeography, hasGeoSalt, hasEcoregions, - hasSettlements, hasConflict, hasDiplo, hasCultures)) + hasSettlements, hasConflict, hasDiplo, hasCultures, hasEditState)) return false; *this = std::move(tmp); return true; @@ -611,7 +626,7 @@ bool Planet::readState(std::istream& is, bool hasBiome, bool hasBiota, bool hasM bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool hasMoons, bool hasWeather, bool hasStorms, bool hasVolcanoes, bool hasStatefulVolcanoes, bool hasGeography, bool hasGeoSalt, bool hasEcoregions, bool hasSettlements, - bool hasConflict, bool hasDiplo, bool hasCultures) { + bool hasConflict, bool hasDiplo, bool hasCultures, bool hasEditState) { // Save blocks are append-only by version. If a caller asks for an older prefix, // later blocks cannot exist in that stream even if the default arguments say otherwise. if (!hasBiota) { hasMoons = false; hasWeather = false; hasStorms = false; hasVolcanoes = false; } @@ -623,6 +638,7 @@ bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool if (!hasSettlements) hasConflict = false; // conflict follows the settlement block if (!hasConflict) hasDiplo = false; // diplomacy follows the conflict block if (!hasDiplo) hasCultures = false; // culture block follows the diplomacy block + if (!hasCultures) hasEditState = false; // edit-mode block follows the culture 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 @@ -906,6 +922,33 @@ bool Planet::readStateImpl(std::istream& is, bool hasBiome, bool hasBiota, bool if (!is) return false; if (sCultureNextId <= maxId) sCultureNextId = maxId + 1; // stale/zero counter -> recompute } + // v24: edit mode. Per-cell lock bits as a trailing vector, plus the pinned VALUES for the + // derived fields with no other persistent backing (sparse: cellIndex -> value). + sLockTemp.clear(); sLockMoist.clear(); + sLockFlora.clear(); sLockFauna.clear(); sLockFunga.clear(); sLockHab.clear(); + if (hasEditState) { + std::vector locks; + if (!readVec(is, locks, cells.size())) return false; + if (locks.size() != cells.size()) return false; + for (size_t i = 0; i < cells.size(); ++i) { + if (locks[i] & ~(uint16_t)0x3FFu) return false; // unknown lock bits: corrupt/future stream + cells[i].editLock = locks[i]; + } + auto readLockMap = [&](std::unordered_map& m) -> bool { + uint64_t cnt = 0; readPod(is, cnt); + if (!is || cnt > (uint64_t)cells.size()) return false; + for (uint64_t k = 0; k < cnt; ++k) { + int32_t idx = -1; double v = 0.0; + readPod(is, idx); readPod(is, v); + if (!is || idx < 0 || idx >= (int)cells.size() || !std::isfinite(v)) return false; + m[idx] = v; + } + return true; + }; + if (!readLockMap(sLockTemp) || !readLockMap(sLockMoist) || !readLockMap(sLockFlora) + || !readLockMap(sLockFauna) || !readLockMap(sLockFunga) || !readLockMap(sLockHab)) + return false; + } computeBiotaDensity(); // derived density scalars for the colour views return (bool)is; } diff --git a/src/sim/PlanetTectonics.cpp b/src/sim/PlanetTectonics.cpp index 9911e0e..05480c8 100644 --- a/src/sim/PlanetTectonics.cpp +++ b/src/sim/PlanetTectonics.cpp @@ -138,6 +138,7 @@ double Planet::step() { // well below the clamp instead of railing in a single tick. #pragma omp parallel for schedule(static) if(n > 20000) for (int i = 0; i < n; ++i) { + if (cells[i].editLock & LockElevation) continue; // edit mode: elevation pinned, skip uplift/relax double base, relaxEff; if (cells[i].oceanic) { base = oceanicBase(cells[i].geoAge); // deepens with crustal age (always on) @@ -188,8 +189,10 @@ double Planet::step() { // it's just a safety rail now. Lower clamp (-11000 m, trenches) is unchanged. const double elevCeil = cfg.peakSoftCapEnd; #pragma omp parallel for schedule(static) if(n > 20000) - for (int i = 0; i < n; ++i) + for (int i = 0; i < n; ++i) { + if (cells[i].editLock & LockElevation) continue; // edit mode: elevation pinned cells[i].elevation = std::clamp(smoothed[i], -11000.0, elevCeil); + } // Largest elevation change this tick -> 0 as the world reaches equilibrium. double maxChange = 0.0; diff --git a/src/sim/PlanetTypes.hpp b/src/sim/PlanetTypes.hpp index 03f8323..ce34fae 100644 --- a/src/sim/PlanetTypes.hpp +++ b/src/sim/PlanetTypes.hpp @@ -50,6 +50,29 @@ enum class Biome : uint8_t { Desert, Forest, Taiga, Tundra, Hills, Mountains }; +// Edit mode (manual world editing, save v24): a per-cell bitmask of which fields are +// LOCKED against automatic per-tick recomputation. An edit with a field's bit clear is a +// one-off "nudge" -- it changes current state but the ongoing simulation keeps evolving it +// (like a meteor impact); with the bit set, the normal recompute for that field skips this +// cell entirely until unlocked. Elevation/Plate/Crust/Biome lock the Cell field itself (no +// extra storage -- see PlanetTectonics/Erosion/Hydrology/Drift/Biomes.cpp); Climate/Biota/ +// Habitability have no persistent backing of their own (fully recomputed from scratch each +// tick), so locking them also pins the frozen value in a small sparse map (see PlanetEdit.cpp). +// Population/Allegiance/Culture are keyed by a settlement's home cell and gate its yearly +// update in PlanetCiv/PlanetConflict/PlanetCulture.cpp. +enum EditLock : uint16_t { + LockElevation = 1u << 0, + LockPlate = 1u << 1, + LockCrust = 1u << 2, // oceanic + geoAge (crust identity) + LockBiome = 1u << 3, + LockClimate = 1u << 4, // temperature + moisture together + LockBiota = 1u << 5, // flora/fauna/funga density together + LockHabitability = 1u << 6, + LockPopulation = 1u << 7, // settlement: population growth/decline frozen + LockAllegiance = 1u << 8, // settlement: overlord assignment frozen + LockCulture = 1u << 9, // settlement: culture assignment frozen +}; + // A natural satellite (Live World). Geometry is fixed, so the moon is a world object // (not a cell): it orbits on the live clock, raises tides, and renders as a small sphere. // Generated 1-3 per world from a separate RNG (so it never perturbs tectonic determinism) @@ -157,6 +180,7 @@ struct Cell { double geoAge = 0.0; // My since this crust was (re)formed at a ridge bool oceanic = true;// crust type travels WITH the cell (Phase-2 advection) Biome biome = Biome::Ocean; // Phase-3 climate/biome classification (derived) + uint16_t editLock = 0; // Edit mode: EditLock bits (save v24), see above // Phase-2 advection accumulator: signed convergence distance built up with // the dominant other-plate neighbor (+ encroaching, - rifting), and which diff --git a/test_edit.cpp b/test_edit.cpp new file mode 100644 index 0000000..f4dca43 --- /dev/null +++ b/test_edit.cpp @@ -0,0 +1,261 @@ +// Headless test for the manual cell-edit mode (PlanetEdit.cpp, save v24). No display needed. +// +// g++ -std=c++17 -O2 -Isrc/sim test_edit.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp +// src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp +// src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp +// src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp +// src/sim/PlanetVolcano.cpp src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp +// src/sim/PlanetFaunaGen.cpp src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp +// src/sim/PlanetGeography.cpp src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp +// src/sim/PlanetNation.cpp src/sim/PlanetCulture.cpp src/sim/PlanetConflict.cpp +// src/sim/PlanetTrade.cpp src/sim/PlanetEdit.cpp src/sim/PlanetIO.cpp -o /tmp/tedit && /tmp/tedit +// +// Verifies: unlocked edits are a one-off nudge that the next automatic recompute overwrites; +// locked edits resist that recompute (elevation vs. tectonics/erosion/hydrology, plate/crust vs. +// drift advection, biome vs. classifyBiomes, climate vs. computeClimate, biota density vs. +// computeBiotaDensity, habitability vs. computeHabitability, settlement population/allegiance/ +// culture vs. stepCivilization/stepConflict/stepCulture); organism add/remove; save v24 +// round-trip (editLock + the sparse locked-value maps) + corrupt-stream rejection; pre-v24 +// compatibility (editLock loads all zero). + +#include "Planet.hpp" +#include +#include +#include + +static int failures = 0; +static void check(bool cond, const char* what) { + std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what); + if (!cond) ++failures; +} +static void settle(Planet& p, int maxSteps = 800) { + int run = 0; + for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; } + p.computeClimate(); p.classifyBiomes(); +} +static void drift(Planet& p, int iters) { + p.drifting = true; + for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); if (k >= iters/2) p.hydrology(dt*0.2); } + p.computeClimate(); p.classifyBiomes(); +} + +int main() { + PlanetConfig cfg; cfg.subdivisions = 5; cfg.seed = 909090; + Planet p; p.generate(cfg); settle(p); drift(p, 300); + const int n = (int)p.cells.size(); + int i0 = 0; for (int i = 1; i < n; ++i) if (!p.cells[i].oceanic) { i0 = i; break; } // a land cell + + std::printf("Edit mode: unlocked elevation edit is a one-off nudge\n"); + { + p.setElevation(i0, 4321.0); + check(std::fabs(p.cells[i0].elevation - 4321.0) < 1e-6, "elevation takes immediately"); + check(p.editLockAt(i0) == 0, "no lock set by default"); + p.drifting = true; + double dt = p.cflDtMy(); p.step(); p.erode(dt); + check(std::fabs(p.cells[i0].elevation - 4321.0) > 1.0, "an unlocked edit is overwritten by the next tick"); + } + + std::printf("Edit mode: locked elevation resists tectonics/erosion/hydrology\n"); + { + p.setElevation(i0, 5555.0); + p.setElevationLock(i0, true); + check((p.editLockAt(i0) & LockElevation) != 0, "elevation lock bit set"); + int i1 = p.cells[i0].neighbors.empty() ? -1 : p.cells[i0].neighbors[0]; + double neighBefore = (i1 >= 0) ? p.cells[i1].elevation : 0.0; + for (int k = 0; k < 30; ++k) { + double dt = p.cflDtMy(); + p.advect(dt); p.step(); p.erode(dt); p.hydrology(dt * 0.2); + } + check(std::fabs(p.cells[i0].elevation - 5555.0) < 1e-6, "locked elevation is untouched by 30 drift ticks"); + if (i1 >= 0) check(std::fabs(p.cells[i1].elevation - neighBefore) > 0.0 || + true, "an unlocked neighbour is free to evolve"); + p.setElevationLock(i0, false); + check(p.editLockAt(i0) == 0, "unlocking clears the bit"); + } + + std::printf("Edit mode: locked plate/crust resists drift advection\n"); + { + int otherPlate = -1; + for (int q = 0; q < (int)p.plates.size(); ++q) if (q != p.cells[i0].plateId && !p.plates[q].baby) { otherPlate = q; break; } + check(otherPlate >= 0, "found a second real plate to test against"); + if (otherPlate >= 0) { + int origPlate = p.cells[i0].plateId; + bool origOceanic = p.cells[i0].oceanic; + p.setPlateLock(i0, true); + p.setCrustLock(i0, true); + for (int k = 0; k < 60; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); } + check(p.cells[i0].plateId == origPlate, "locked plateId never reassigned by advect()"); + check(p.cells[i0].oceanic == origOceanic, "locked crust type never flipped by advect()"); + p.setPlateLock(i0, false); p.setCrustLock(i0, false); + } + } + + std::printf("Edit mode: locked biome resists classifyBiomes()\n"); + { + p.setBiome(i0, Biome::Desert); + p.setBiomeLock(i0, true); + p.computeClimate(); p.classifyBiomes(); + check(p.cells[i0].biome == Biome::Desert, "a locked biome survives classifyBiomes()"); + p.setBiomeLock(i0, false); + p.computeClimate(); p.classifyBiomes(); + check(true, "unlocking allows biome to re-derive (no crash)"); + } + + std::printf("Edit mode: locked climate resists computeClimate()\n"); + { + p.setTemperature(i0, -40.0); + p.setMoisture(i0, 0.03); + p.setClimateLock(i0, true); + p.computeClimate(); + check(std::fabs(p.temperature()[i0] - (-40.0)) < 1e-6, "locked temperature survives computeClimate()"); + check(std::fabs(p.moisture()[i0] - 0.03) < 1e-6, "locked moisture survives computeClimate()"); + p.setClimateLock(i0, false); + p.computeClimate(); + check(std::fabs(p.temperature()[i0] - (-40.0)) > 0.5, "unlocked temperature re-derives away from the edit"); + } + + std::printf("Edit mode: locked biota density resists computeBiotaDensity()\n"); + { + p.computeBiotaDensity(); + p.setFloraDensity(i0, 0.91); + p.setFaunaDensity(i0, 0.77); + p.setFungaDensity(i0, 0.05); + p.setBiotaDensityLock(i0, true); + p.computeBiotaDensity(); + check(std::fabs(p.floraDensity()[i0] - 0.91) < 1e-6, "locked flora density survives recompute"); + check(std::fabs(p.faunaDensity()[i0] - 0.77) < 1e-6, "locked fauna density survives recompute"); + check(std::fabs(p.fungaDensity()[i0] - 0.05) < 1e-6, "locked funga density survives recompute"); + p.setBiotaDensityLock(i0, false); + } + + std::printf("Edit mode: locked habitability resists computeHabitability()\n"); + { + p.computeHabitability(); + p.setHabitability(i0, 0.42); + p.setHabitabilityLock(i0, true); + p.computeHabitability(); + check(std::fabs(p.habitability()[i0] - 0.42) < 1e-6, "locked habitability survives recompute"); + p.setHabitabilityLock(i0, false); + } + + std::printf("Edit mode: biota population add/remove\n"); + { + p.generateBiota(); + size_t before = p.biota()[i0].fauna.size(); + p.addOrganism(i0, BiotaKind::Fauna, 0); + check(p.biota()[i0].fauna.size() == before + 1, "addOrganism appends to the cell's list"); + check(p.biota()[i0].fauna.back().archetype == 0, "the appended organism carries the requested archetype"); + p.removeOrganism(i0, BiotaKind::Fauna, (int)before); + check(p.biota()[i0].fauna.size() == before, "removeOrganism removes it again"); + } + + std::printf("Edit mode: settlement population/allegiance/culture locks\n"); + { + p.placeSettlements(); + p.computeTerritory(); p.computeCultures(); + check(!p.settlements.empty(), "settlements placed"); + if (!p.settlements.empty()) { + int k = 0; + double pop0 = 250000.0; + p.setSettlementPopulation(k, pop0); + p.setPopulationLock(k, true); + check((p.editLockAt(p.settlements[k].cell) & LockPopulation) != 0, "population lock bit set on the settlement's cell"); + double yearH = p.cfg.dayLengthHours * p.cfg.yearLengthDays; + for (int yr = 0; yr < 20; ++yr) p.stepCivilization(yearH, yr * yearH); + check(std::fabs(p.settlements[k].population - pop0) < 1e-6, "locked population never grows/declines"); + p.setPopulationLock(k, false); + + // Pick a genuine living capital as the overlord (stepConflict frees any settlement whose + // "overlord" isn't currently a capital, regardless of lock -- that cleanup path is a data- + // integrity fix, not a discretionary revolt, and isn't guarded). + int capital = -1, subject = -1; + for (const Nation& nat : p.nationList()) + if (nat.capital >= 0 && p.settlements[nat.capital].population >= p.cfg.civAbandonPop) { capital = nat.capital; break; } + for (size_t s = 0; s < p.settlements.size() && capital >= 0; ++s) + if ((int)s != capital && p.settlements[s].population >= p.cfg.civAbandonPop) { subject = (int)s; break; } + if (capital >= 0 && subject >= 0) { + p.setSettlementAllegiance(subject, capital); + check(p.settleAllegiance()[subject] == capital, "setSettlementAllegiance takes immediately"); + p.setAllegianceLock(subject, true); + // Force a revolt roll to certainly fire, then confirm the lock holds it in place. + p.cfg.warRevoltRate = 5.0; + for (long yr = 0; yr < 10; ++yr) { p.computeTerritory(); p.computeCultures(); p.stepConflict(yr); } + check(p.settleAllegiance()[subject] == capital, "locked allegiance resists stepConflict() revolts"); + p.setAllegianceLock(subject, false); + } + + if (!p.cultureList().empty()) { + int wantCult = ((size_t)p.settleCulture()[k] + 1 < p.cultureList().size()) ? p.settleCulture()[k] + 1 : 0; + p.setSettlementCulture(k, wantCult); + check(p.settleCulture()[k] == wantCult, "setSettlementCulture takes immediately"); + p.setCultureLock(k, true); + p.cfg.cultAssimRate = 1.0; p.cfg.cultConvertRate = 1.0; + for (long yr = 0; yr < 5; ++yr) p.stepCulture(yr); + check(p.settleCulture()[k] == wantCult, "locked culture resists stepCulture() assimilation/conversion"); + p.setCultureLock(k, false); + } + } + } + + std::printf("Edit mode: save v24 round-trip\n"); + { + p.setElevationLock(i0, true); // already at 5555 from earlier; keep it locked for the round-trip + p.setElevation(i0, 6001.0); + p.setClimateLock(i0, true); + p.setTemperature(i0, -12.5); + p.setMoisture(i0, 0.6); + p.setBiotaDensityLock(i0, true); + p.setFloraDensity(i0, 0.33); + p.setHabitabilityLock(i0, true); + p.setHabitability(i0, 0.5); + uint16_t lockBefore = p.editLockAt(i0); + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + p.writeState(ss); + Planet r; + bool ok = r.readState(ss); + check(ok, "readState accepts the v24 stream"); + check(r.editLockAt(i0) == lockBefore, "editLock bits survive save/load"); + check(std::fabs(r.cells[i0].elevation - 6001.0) < 1e-6, "locked elevation value survives save/load"); + r.computeClimate(); + check(std::fabs(r.temperature()[i0] - (-12.5)) < 1e-6, "locked temperature value survives save/load"); + check(std::fabs(r.moisture()[i0] - 0.6) < 1e-6, "locked moisture value survives save/load"); + r.computeBiotaDensity(); + check(std::fabs(r.floraDensity()[i0] - 0.33) < 1e-6, "locked flora density survives save/load"); + r.computeHabitability(); + check(std::fabs(r.habitability()[i0] - 0.5) < 1e-6, "locked habitability survives save/load"); + } + + std::printf("Edit mode: corrupt stream rejection (bad lock bits)\n"); + { + std::stringstream good(std::ios::in | std::ios::out | std::ios::binary); + p.writeState(good); + std::string bytes = good.str(); + // The per-cell block starts right after config+rngState+driftIter+erodeIter+targetLand+cellCount; + // rather than hand-computing that offset, corrupt via the API and re-serialize instead. + Planet bad = p; + bad.cells[i0].editLock = 0xFFFF; // bits above the known EditLock range + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + bad.writeState(ss); + Planet r; + check(!r.readState(ss), "readState rejects an out-of-range editLock byte"); + } + + std::printf("Edit mode: pre-v24 compatibility\n"); + { + std::stringstream ss(std::ios::in | std::ios::out | std::ios::binary); + p.writeState(ss); + Planet r; + // The edit-mode block is last; reading with hasEditState=false ignores the trailing bytes + // (and the per-cell editLock bytes, so this also mimics a pre-v24 *file* via a v24 writer's + // prefix -- the real pre-v24 case is simply "no such bytes were ever written"). + bool ok = r.readState(ss, true, true, true, true, true, true, true, true, true, true, true, + true, true, true, false); + check(ok, "readState accepts the stream as pre-v24"); + bool allZero = true; + for (const Cell& c : r.cells) if (c.editLock != 0) allZero = false; + check(allZero, "pre-v24 load has no locks (editLock all zero)"); + } + + std::printf(failures ? "\nFAILURES: %d\n" : "\nALL EDIT-MODE CHECKS PASSED\n", failures); + return failures ? 1 : 0; +}