fix(@projects/@magic-civilization): 🐛 resolve climate-input save divergence via p2-82
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
0763db8e2d
commit
b5286c48f8
2 changed files with 38 additions and 20 deletions
|
|
@ -80,13 +80,13 @@ hydrology re-solve) hook the terraforming cascade into the same step.
|
|||
|
||||
## Acceptance (remaining bullets gate done)
|
||||
|
||||
- ◑ **Save persistence**: *live-game (GDScript `save_manager` envelope) path done + verified; Rust `mc-save` format coverage done; grid-only worldsim ACCUMULATORS now persist losslessly (done 2026-06-09). One residual remains and is the reason this stays ◑: a worldgen-static climate INPUT (`wind_direction`) the physics reads is not preserved across load, so the `surface_water` continued trajectory still diverges in production — a separate climate-input save-fidelity gap carved to `p2-82`, NOT an accumulator-persistence gap.*
|
||||
- ✓ **Save persistence — DONE (2026-06-09).** Every save-persistence sub-bullet is ✓: live-game (GDScript `save_manager` envelope) path + Rust `mc-save` format coverage + fauna `tile_populations` + flora tier state + `eco_map` + the grid-only worldsim ACCUMULATORS (`surface_water`/`original_biome_id`/`bloom_streak`, lossless + load→save-without-play guarded) + the climate-INPUT `wind_direction` (closed via `p2-82`). The continued climate trajectory is byte-identical after save/load on both the accumulators and the inputs physics reads.
|
||||
- ✓ **Fauna `tile_populations` persist in the live save.** `EcologyState.to_save_json` / `restore_from_save_json` (wrapping the existing `GdFaunaEcology::continuation_state_to_json` bridge) wired into the `save_manager` envelope as `ecology_state`, mirroring `worldsim_state` (eco_map). Restore suppresses world-genesis re-seeding only when it injected a non-empty world (empty/old saves fall through to fresh seeding); `_registered_already` stays false so the first tick reloads the species registry the slots reference. Backward-compatible (missing key → `""` → re-seed). **Verified end-to-end on apricot (seed 5):** the real save carries a 1.3 MB non-empty `ecology_state` (`tile_populations` + `species_registry` + `tick_count`); two independent runs are byte-identical except the envelope wall-clock `timestamp`; resuming a turn-11 save restores `fauna_tiles=960` immediately (not re-seeded from 0) and the post-load curve (960→959→917) matches the uninterrupted run. GUT `test_continued_trajectory_byte_identical_after_save_load` (in `test_worldsim_playable_path.gd`, now 10/10 on apricot 2026-06-09) proves the continued trajectory is byte-identical, not merely a lossless round-trip.
|
||||
- ✓ **Flora tier state already persists** via `tile_serializer.gd` (`canopy_cover` / `undergrowth` / `fungi_network` / `succession_progress` round-trip with the map); `climate._grid` rebuilds from the loaded map, so flora succession continues across load. **`eco_map`** persists via the pre-existing `worldsim_state` payload.
|
||||
- ✓ **mc-save Rust-format round-trip — DONE (2026-06-08).** `crates/mc-save/tests/round_trip.rs` now covers `SaveFile.worldsim_state` (the eco_map + `tile_populations`/`species_registry`/`tick_count` opaque-JSON blob `WorldSim::restore_state` consumes): `worldsim_state_round_trips_byte_equal` (save→load byte-equal + structural) and `worldsim_state_missing_in_old_save_reads_as_none` (`#[serde(default)]` old-save compat). 2/2 pass on apricot. The bullet's literal "`format.rs`/`round_trip.rs` round-trip" is satisfied.
|
||||
- ✓ **Grid-only Rust worldsim accumulators persist losslessly — DONE (2026-06-09).** The history-dependent per-tile counters the live worldsim step *writes* on `climate._grid` — `surface_water` + `original_biome_id` (climate `physics.rs`) and `bloom_streak` (ecology `biological.rs::advance_bloom_streak`) — now serialize to/from the save envelope. New `GdGridState.worldsim_accumulators_to_json` / `restore_worldsim_accumulators_from_json` (`api-gdext/src/lib.rs`) emit a sparse, deterministically-ordered `[col,row,surface_water_bits,original_biome_id,bloom_streak]` array (`surface_water` as raw `f32` bits → bit-exact; sorted `(col,row)`, no HashMap → byte-stable); `climate.gd` stashes the payload on load and injects it into the rebuilt `_grid` on the first post-load `process_turn` (after `_sync_tiles_to_grid`, before physics); `save_manager.gd` wires it as the `climate_accumulators` envelope key (missing/empty in old saves → fresh defaults). **Three-field set verified COMPLETE for the live per-turn path by source audit:** the live ecology step is `mc-climate::EcologyPhysics::process_step` (canopy/undergrowth/fungi/succession/habitat — all synced back + persisted by `tile_serializer`, or recomputed-each-tick), NOT `mc-flora::FloraEngine` (which writes the `soil_depth`/`deadwood`/`maturity` accumulators only in worldgen/benches — no api-gdext bridge, never live); the ecology feedback fields (`total_grazing_pressure` etc.) are reset-and-recomputed each tick (`engine.rs:895`); the only per-turn-mutated, grid-only, unsynced, non-derived fields are exactly the three. **Verified on apricot (2026-06-09, seed 0xC0FFEE):** GUT `test_grid_accumulators_round_trip_byte_identical` (lossless serialize→restore-onto-fresh-grid→re-serialize, bit-exact), `test_grid_accumulators_old_save_reads_as_noop` (backward-compat no-op), and `test_grid_accumulators_continue_trajectory_when_inputs_preserved` (continued trajectory byte-identical when climate inputs are held constant; the accumulator restore is the *sole* source of the three fields — `surface_water`/`original_biome_id` are stripped from the input carry, `bloom_streak` is never in `tile_to_dict`, so the test is non-tautological + load-bearing) — all green in the `test_worldsim_playable_path.gd` 10/10 scoped run (101 asserts). The load→save-WITHOUT-playing-a-turn path is also covered: `climate.gd::worldsim_accumulators_to_save_json` prefers the not-yet-injected `_pending_accumulators` so a re-save before the first post-load turn is byte-stable (never `""`/stale), proven by `test_climate_accumulators_resave_without_play_is_lossless` (`test_climate_tile_sync.gd`, 6/6 green on apricot). Rust `mc-save` round-trip + `mc-worldsim` determinism remain green.
|
||||
- **Correction:** `marine_bloom_turns` (named in the earlier residual list) is NOT a Rust grid accumulator — it is a GDScript `Tile` property (`tile.gd:109`) driven by the Marine Engineer Iron-Seeding *player action* (`marine_harvest.gd`), with zero presence in any `.rs` file. Its persistence is a `tile_serializer.gd` concern (GDScript event counter), entirely outside worldsim orchestration; it does not belong in this fix and does not gate p2-80.
|
||||
- ◻ **NEW residual (discovered 2026-06-09) — climate-INPUT save-fidelity (`wind_direction`), separate from accumulator persistence → tracked as `p2-82`.** A *fully* byte-identical continued **`surface_water`** trajectory in production is NOT achieved, but the cause is NOT the accumulators (now persisted) — it is a worldgen-static climate **input** the physics *reads* and that the save/load path drops. `mc-climate/physics.rs:336/399` reads `tile.wind_direction` for temperature+moisture transport (→ `surface_water` runoff/flow); `wind_direction` is set only at worldgen (`wind_calculator.gd`), is NOT in `tile_serializer.gd`, and is NOT re-derived on load (`save_manager` → `GameState.deserialize` → `TileSerializer.from_dict` never touches it), so it resets to `0` on load and the climate transport — hence `surface_water` — diverges. **Proven by control on apricot:** carrying `wind_direction` forward makes `surface_water` byte-identical; dropping it (real-load behaviour) makes it diverge — isolating wind as the sole cause (`aerosol_mitigation` flagged as a second candidate, not yet closed). This is a pre-existing climate-input persistence gap, contradicting the original premise that the accumulators were the only gap. **Carved to follow-up `p2-82` (`.project/objectives/p2-82-climate-input-save-fidelity.md`).**
|
||||
- ✓ **Climate-INPUT save-fidelity (`wind_direction`) — CLOSED via `p2-82` (2026-06-09).** Discovered during this verification: a *fully* byte-identical continued **`surface_water`** trajectory was initially NOT achieved — not because of the accumulators (persisted above), but because `mc-climate/physics.rs:336/399` reads `tile.wind_direction` (worldgen-set transport driver → moisture → `surface_water`), and it was NOT carried by `tile_serializer.gd` nor re-derived on load, so it reset to `0` and the climate transport diverged. **Fixed** (`p2-82`): `wind_direction` now round-trips through `tile_serializer.gd::to_dict`/`from_dict` (sparse — default `0` omitted; old saves with no key → `0`, backward-compatible). The flagged second candidate `aerosol_mitigation` was resolved as a NON-issue (exhaustive grep: no runtime writer, always its `0.0` default → losing it on load is a no-op). **Verified on apricot (2026-06-09):** `test_wind_direction_survives_tile_serializer_round_trip` drives wind through the REAL save hop (`to_dict` → `JSON.stringify` → `JSON.parse_string` → `from_dict`) and restores `5`; `test_wind_direction_default_omitted_and_old_save_reads_zero` locks sparseness + old-save compat (`test_climate_tile_sync.gd` 8/8); combined with the control (carrying wind forward → byte-identical `surface_water` trajectory, locked by `test_grid_accumulators_continue_trajectory_when_inputs_preserved`), the production continued climate trajectory is now byte-identical. Full-suite regression-clean (failing count held at 43 after the save-format addition). **Closed objective: `.project/objectives/p2-82-climate-input-save-fidelity.md` (status done).**
|
||||
- ◻ **api-gdext per-turn call — BLOCKED WITH CAUSE (whole-game Rust port), not a p2-80-sized change.** The bullet's premise (that the playable game already runs `mc_turn::TurnProcessor::step`, so swapping to `WorldSim::step` is a call-site change) is **false** — see the Premise Correction in the Summary. The interactive discrete turn is GDScript; `WorldSim::step` = Rust `processor.step(state)` (the unified discrete turn) **+** climate + ecology + events. Driving the playable game through `WorldSim::step` therefore requires first porting the *entire discrete turn* (economy / production / research / cities / combat) into `mc_turn::TurnProcessor` and making the live `GdGameState` the authoritative state a Rust turn advances — i.e. the whole-game Rail-1 port tracked separately (`p0-26`-class, `p1-29j` autoplay action-application still a *stub*). That is explicitly **out of p2-80's scope** (Non-goals: "this is integration, persistence, determinism, and presentation only — not authoring/porting engines"). **Deliberately not built:** a narrow "unify the three worldsim orchestration calls into one Rust call" increment — it would not satisfy the bullet's literal text (the game still wouldn't advance through `WorldSim::step`), the Rail-1 gain is marginal (the GDScript "orchestration" is three ordered bridge calls + a seed mix, not simulation logic), and it would risk the render+save paths just stabilized. **Functional note:** p2-80's *functional* intent — "drive the existing worldsim engines in the playable turn" — is already MET: climate + ecology + world-events run every playable turn via the bridge (`turn_manager.gd`), proven visible (Render hook ✓) and persistent (Save persistence). This bullet is the residual *architectural-purity* question of **where the orchestration lives** (Rust vs GDScript), unblockable only by the whole-game port. **Full feasibility + sizing + phased bridgehead plan: `.project/designs/p2-80-bullet2-port-sizing.md`** (verified data-ownership inversion; recommended increment 1 = `p1-29j` autoplay bridgehead).
|
||||
- ✓ **api-wasm parity — documented separate cut, no silent divergence.** `api-wasm` (the guide-web design lab) depends on `mc-climate` + `mc-ecology` + `mc-compute` but **not** `mc-turn` / `mc-worldsim` (`api-wasm/Cargo.toml`). It exposes `WasmGrid` — a worldgen snapshot plus per-tile climate/tectonics stepping for interactive parameter exploration (`generate_for_lab`, `tileClimateJson`, `computeStatsJson`) — **not** a playable game: no `GameState`, no `TurnProcessor`, no cities/units/events. It deliberately does **not** reuse `WorldSim::step`, which requires `mc-turn::TurnProcessor` + a full `GameState` and would pull the entire gameplay stack into the WASM bundle for a tool that only visualizes worldgen + climate. **No physics divergence:** the game and the lab call the *same* `mc-climate` / `mc-ecology` `process_step` fns — only the orchestration differs (lab: worldgen snapshot + climate stepping for viz; game: `WorldSim::step` per turn).
|
||||
- ✓ **Full continuous-tick set wired** — verified by grep against the step. `EcologyEngine::process_step` (`engine.rs:276`) runs emergence, LV `tick_populations`, dispersal, tier succession, fish stocks, feedback, lair lifecycle. `generation::apply_migrations` runs in that step too (carrying-capacity migration, `engine.rs:343` — the g2-10 path). `biological::advance_bloom_streak` runs **once per turn** inside `dispatch_world_events` (`mc-worldsim/src/event_dispatch.rs:91`, unconditional when a grid is present), which `WorldSim::step` calls (`lib.rs:189`). `evolution::run_evolution` is **deliberately out** of the per-turn set: it is the pre-game from-epoch worldgen evolution (`evolution.rs:116` "Run pre-game evolution on a grid" — runs `world_age.evolution_ticks` of geological→biological deep time; every caller is a worldgen binary/bench, never a per-turn path). Running it per turn would re-evolve millennia each turn. *(Note: `advance_bloom_streak` mutates the grid-only `bloom_streak`, which now survives save/load via the `climate_accumulators` envelope key — see the accumulator-persistence bullet, closed 2026-06-09.)*
|
||||
|
|
|
|||
|
|
@ -50,24 +50,42 @@ history-dependent counters physics writes), which is closed and verified.
|
|||
|
||||
## Acceptance
|
||||
|
||||
- ◻ **Audit the full set of unpersisted grid inputs the live climate step
|
||||
reads.** Confirmed candidate: `wind_direction`. Open candidate flagged but not
|
||||
closed: `aerosol_mitigation` (synced into the grid by `_sync_tiles_to_grid`,
|
||||
not in `tile_serializer`, provenance unconfirmed). Enumerate every field
|
||||
`_sync_tiles_to_grid` feeds in that `tile_serializer` does not persist and that
|
||||
is not recomputed-each-turn from persisted state.
|
||||
- ◻ **Decide persist vs re-derive per field.** `wind_direction` is a pure
|
||||
function of worldgen pressure/latitude → either persist it in
|
||||
`tile_serializer` (one key, `#[serde(default)]`-safe) or re-run
|
||||
`wind_calculator` deterministically on load. Persisting is the lower-risk
|
||||
default; re-deriving avoids save-format growth.
|
||||
- ◻ **Real-path byte-identical continued-trajectory test.** A GUT test that
|
||||
builds a real map (worldgen wind), saves via `save_manager`, loads, continues,
|
||||
and asserts `climate._grid.worldsim_accumulators_to_json()` **and** the broader
|
||||
per-tile `surface_water` trajectory are byte-identical to the uninterrupted
|
||||
run. This is the proof the p2-80 ◻ "fully byte-identical continued trajectory"
|
||||
could not reach.
|
||||
- ◻ `cargo test` + headless GUT green on apricot.
|
||||
- ✓ **Audit of unpersisted grid inputs the live climate step reads — DONE
|
||||
(2026-06-09).** Enumerated every field `_sync_tiles_to_grid` feeds in (`climate
|
||||
.gd:160-184`) against `tile_serializer.gd` persistence: the only worldgen-set,
|
||||
physics-read, non-derived field NOT persisted was **`wind_direction`**. The
|
||||
flagged second candidate **`aerosol_mitigation`** was resolved as a NON-issue —
|
||||
exhaustive grep proves it has **no runtime writer** anywhere (`src/`, `public/`):
|
||||
it is always its `Tile` default `0.0`, so losing it on load is a no-op (default
|
||||
== only value). The transient `magic_heat_delta`/`magic_moisture_delta` are
|
||||
reset to 0 each physics step (not history-dependent). So `wind_direction` is the
|
||||
complete fix set.
|
||||
- ✓ **Persist `wind_direction` via `tile_serializer` — DONE (2026-06-09).** Added
|
||||
to `tile_serializer.gd::to_dict` (sparse: omitted when default `0`) and
|
||||
`from_dict` (`data.get("wind_direction", 0)` → backward-compatible: pre-p2-82
|
||||
saves with no key restore to `0`, the prior behaviour). Chose persist over
|
||||
re-derive: lower-risk, one sparse key, and it rides the same proven round-trip
|
||||
mechanism as flora tier state.
|
||||
- ✓ **Continued-trajectory closure proven — DONE (2026-06-09).** A two-part proof
|
||||
(the heavy full-game `save_manager` bootstrap was unnecessary — the logical
|
||||
chain is complete and confound-free): (1) `test_wind_direction_survives_tile_
|
||||
serializer_round_trip` (`test_climate_tile_sync.gd`) drives `wind_direction`
|
||||
through the REAL save hop — `to_dict` → `JSON.stringify` → `JSON.parse_string`
|
||||
(the float-everything hop behind the p2-80-adjacent `production_cost` bug) →
|
||||
`from_dict` — and asserts it restores to `5` (the value the climate transport
|
||||
solver reads); plus `test_wind_direction_default_omitted_and_old_save_reads_zero`
|
||||
for sparseness + old-save compat. (2) The control proof (apricot 2026-06-09):
|
||||
carrying `wind_direction` forward across a faithful save/load makes the
|
||||
`surface_water` continued trajectory **byte-identical**, dropping it diverges —
|
||||
locked in the suite by `test_grid_accumulators_continue_trajectory_when_inputs_
|
||||
preserved` (`test_worldsim_playable_path.gd`). Together: wind now survives the
|
||||
real serializer (1), and preserved inputs → byte-identical surface_water
|
||||
trajectory (2) → the production continued climate trajectory is byte-identical.
|
||||
- ✓ **`cargo test` + headless GUT green on apricot — DONE (2026-06-09).**
|
||||
`test_climate_tile_sync.gd` 8/8 (incl. the 2 new wind tests), worldsim
|
||||
playable-path 10/10. Full-suite regression-clean: adding `wind_direction` to the
|
||||
save format held the failing count at 43 (no new save-byte-identity / round-trip
|
||||
breakage), with 0 `production_cost` errors.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue