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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TMfyZv91tonDnPJqbaTVJE
262 lines
13 KiB
C++
262 lines
13 KiB
C++
// 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 <cstdio>
|
|
#include <cmath>
|
|
#include <sstream>
|
|
|
|
static int failures = 0;
|
|
static void check(bool cond, const char* what) {
|
|
std::printf(" [%s] %s\n", cond ? "PASS" : "FAIL", what);
|
|
if (!cond) ++failures;
|
|
}
|
|
static void settle(Planet& p, int maxSteps = 800) {
|
|
int run = 0;
|
|
for (int s = 0; s < maxSteps; ++s) { double mc = p.step(); if (mc < 2.0) { if (++run >= 3) break; } else run = 0; }
|
|
p.computeClimate(); p.classifyBiomes();
|
|
}
|
|
static void drift(Planet& p, int iters) {
|
|
p.drifting = true;
|
|
for (int k = 0; k < iters; ++k) { double dt = p.cflDtMy(); p.advect(dt); p.step(); p.erode(dt); 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;
|
|
}
|