//! Golden-seed regression for the Layer 0→1 generation cascade (#952, D-200, D-224). //! //! Pins two artifacts for one fixed input heightmap, in a single diffable JSON //! golden (matching the `golden_suite.rs` convention): //! - **Layer 0** — SHA-256 of the source `heightmap.png` bytes. Flips if the //! Python heightmap generator (or the committed file) changes. //! - **Layer 1** — the serialized `Layer1Output` of the cascade run on a //! downsampled copy. Flips if the Rust drainage / feature / sub-biome code //! changes. //! //! The input is a real committed body heightmap (the #963 bake output), so this //! is an end-to-end determinism guard. The cascade is downsampled to keep the //! golden small while still exercising the full Layer-1 pipeline. //! //! Regenerate after an intended change: //! UPDATE_GOLDEN=1 cargo test --test cascade_golden //! //! Golden captured on x86_64. The downsample and sub-biome cost use f32, so a //! different architecture could in principle round differently — regenerate //! per-arch if CI ever moves off x86_64. //! //! **Deliberate re-pin (T-1170 Ruling 2a/2c/3f, A1):** `RiverNetwork` gained //! an additive `river_downstream: Vec` field (per-`river_cells`-entry D8 //! downstream pointer + MOUTH/EDGE_DRAIN sentinel), and `extract_river_network` //! stopped classifying pole-edge D8 exits (flow running off the grid's //! top/bottom row) as `mouths` — they are grid artifacts, not river-meets-sea //! events (Ruling 3f, Jeroen's capture question). On this fixture (GJ1c, //! 256×128 downsample) that drops `mouths` from 19 to 3: 16 of the 19 were //! pole-edge exits (now `RIVER_DOWNSTREAM_EDGE_DRAIN`), leaving the 3 real //! sea-adjacent mouths (`RIVER_DOWNSTREAM_MOUTH`). `river_cells`/`attractors` //! counts are unchanged (93/256) — this is a pure re-classification + one new //! additive field, not a drainage-algorithm change. //! //! **Attractor cascade correction (T-1170 PR #197 review, Hoshe #4):** the A1 //! commit's "pure reclassification" framing overclaimed — `attractors` is //! count-PARITY (256/256), not byte-identical. Mouths dropping 19→3 shrinks //! `TerrainAnalysis::water_dist`'s seed set (`compute_water_dist` seeds from //! `river_network.mouths`), which shifts the water-distance field, which //! feeds `RawAttractor::strength` scoring in `features::extract_attractors`. //! This is a principled, in-scope cascade (the pole-edge cells genuinely //! aren't water-distance sources anymore) — not a bug — but it is a REAL //! field-level change, not a no-op re-tagging. Restated accurately here so //! the record doesn't imply byte-identical attractor output. //! //! **Second deliberate re-pin (T-1170 PR #197 review, Hoshe #1):** //! `RiverNetwork` gained a second additive field, `river_seaward: //! Vec<(u16,u16)>` — the real seaward neighbor position for MOUTH-sentinel //! river cells, captured in the SAME `extract_river_network` pass (the //! elevation check that already computes `(nr, nc)` to decide the MOUTH //! sentinel). Fixes a second instance of the discard-then-need-it-later //! anti-pattern Ruling 2b's `river_downstream` field fixed for interior D8 //! pointers: `river_course::build_edges` needs a real seaward cell to give //! Mouth edges a non-degenerate chord (see `river_course.rs`'s //! `build_edges` doc and `layer_proxy::tests:: //! all_real_gj1c_mouths_resolve_to_mouth_terminus_not_none` for the full //! bug/fix story). `river_cells`/`mouths`/`attractors` counts unchanged by //! this second re-pin (93/3/256) — purely the new parallel array, 3 non- //! placeholder entries (one per real mouth). //! //! **Third deliberate re-pin (T-1185, D-227 amendment (4) — basin-outlet → //! D8 river-network wiring):** `run_layer1`/`run_layer1_with_moisture` now //! extends the D8-extracted `RiverNetwork` with every `BasinOutcome:: //! Overflow` basin's `outlet_path` as new river cells (the endorheic cue: //! outflow-course PRESENCE). `attractors` (256) and `drainage_basins` are //! UNCHANGED — the extension runs strictly after //! `features::extract_attractors`, by design, so basin-outlet cells never //! perturb geographic attractor placement. `mouths`/`confluences` also //! unchanged (T-1185 never rewrites those arrays). //! //! **This golden pins the FALLBACK moisture path, not production — stated //! explicitly so nobody cites its basin-outcome split as what a player //! actually sees (PR #202 review, Hoshe finding 2).** //! `run_cascade_from_heightmap`'s `body_params: None` argument here means //! `run_layer1`'s `DEFAULT_HYDROLOGY_MOISTURE_Q = 55` fallback is what //! solves this fixture's hydrology, not GJ1c's REAL body params //! (`wiki/star-systems/GJ-1/bodies/GJ1c/index.md`: `hydrosphere: //! liquid_water`, `atmosphere: standard` → `derive_moisture_ceiling_q` //! yields **moisture_q=80**, well above `ENDORHEIC_MOISTURE_CEILING=60`). //! At the fallback `moisture_q=55` this fixture's 53 real working-grid //! basins split 51 Overflow / 2 Endorheic; verified directly (a scratch //! probe, not committed) that at the PRODUCTION `moisture_q=80` the SAME //! 53 basins are **53/53 all-Overflow — zero Endorheic** (moisture is the //! only thing that moves; `filled_scaled`/basin geometry itself is //! moisture-independent, per `run_layer1_with_moisture_changes_ //! endorheic_split_not_lake_extent`'s own invariant). A materially //! different picture: on the real body, every one of these basins shows an //! exit river on the map — this fixture happening to include 2 Endorheic //! basins is an artifact of testing at the body-agnostic fallback //! constant, not a fact about GJ1c itself. //! //! **Cell count (post PR #202 review fix — the i==1/spill-collision //! regression, Hoshe finding 1):** `river_cells` 93→192 (+99). Every one of //! the 51 real `Overflow` basins now has its spill cell wired as a real //! river-network entry, unconditionally — the earlier landing (143, now //! superseded) silently dropped a basin's ENTIRE outlet whenever //! `outlet_path[1]` collided with a pre-existing river cell (the loop broke //! on its first iteration having pushed nothing), which on THIS fixture //! happened for 1 basin outright and would have made it visually //! indistinguishable from Endorheic — exactly the cue this ticket must //! never break. Verified directly against this fixture (scratch probe, not //! committed): all 51 real `Overflow` basins now build into a real, //! readable `RiverEdge` via `river_course::build_edges` — zero basins //! missing an outlet edge. One pre-existing baseline cell (`(28, 14)`) is //! overwritten in place (its `river_downstream` sentinel changes from //! `RIVER_DOWNSTREAM_EDGE_DRAIN` to a real D8 direction) rather than //! duplicated — the other real spill-cell collision on this fixture lands //! among the newly-appended range, not the original 93-cell baseline. //! Confirmed by direct position-identity diff (not raw array-index //! comparison, which is misleading once the fix reshuffles positions //! within each basin's block): zero positions are ever REMOVED between the //! pre-fix and post-fix goldens, only added-or-overwritten — the fix is //! additive at the position level, exactly as designed. No duplicate //! `(row, col)` positions exist in the final array (verified — the //! `edge_ids_are_unique` invariant `build_edges` depends on holds). //! //! **Fourth deliberate re-pin (T-1169, D-223):** `Layer1Output` gained an //! additive `feature_names: Vec` field (river-mouth/ //! alpine-peak name assignments from the new `attach_feature_names` cascade //! wiring). This fixture calls `run_cascade_from_heightmap` with empty //! `river_names`/`mountain_names` pools (no DB access from this test), so //! the field serializes as `[]` — a pure additive-shape change, zero effect //! on every pre-existing field. use std::path::PathBuf; use serde_json::{json, Value}; use settled_reach_server::atlas::cascade::{run_cascade_from_heightmap, CascadeLayer}; use settled_reach_server::atlas::heightmap::load_heightmap_png; use settled_reach_server::seed::SeedChain; use sha2::{Digest, Sha256}; /// Source heightmap — a real committed body, relative to the server manifest dir. const SOURCE_HEIGHTMAP: &str = "../wiki/star-systems/GJ-1/bodies/GJ1c/heightmap.png"; /// Downsample target: large enough that GJ1c's drainage produces a real river /// network (not just attractors), small enough for a compact golden. const DOWNSAMPLE: (u32, u32) = (256, 128); /// World seed for the run. Cosmetic here — Layers 0–1 are RNG-free, so changing /// it does not change the golden; it is carried only to exercise the SeedChain /// contract end-to-end (D-224). Seed-sensitivity gets pinned once Layer 3+ lands. const WORLD_SEED: u64 = 42; const GOLDEN: &str = "tests/golden/cascade_layer1.json"; #[test] fn cascade_layer0_to_1_matches_golden() { let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let src = manifest.join(SOURCE_HEIGHTMAP); // ── Layer 0 — load + hash the source heightmap.png. ───────────────────── let png_bytes = std::fs::read(&src) .unwrap_or_else(|e| panic!("read source heightmap {}: {e}", src.display())); let mut hasher = Sha256::new(); hasher.update(&png_bytes); let sha = format!("{:x}", hasher.finalize()); let heightmap = load_heightmap_png(&src, "GJ1c", 0.3).expect("decode source heightmap"); // ── Layer 1 — downsample, then run the cascade to topography. ─────────── let small = heightmap.downsample(DOWNSAMPLE.0, DOWNSAMPLE.1); let body_seed = SeedChain::for_body(WORLD_SEED, "GJ1c"); let snapshot = run_cascade_from_heightmap( body_seed, small, &[], None, None, &[], &[], CascadeLayer::Topography, ); let layer1 = snapshot.layer1.expect("Layer 1 ran"); let actual = json!({ "source_heightmap": SOURCE_HEIGHTMAP, "source_sha256": sha, "downsample": [DOWNSAMPLE.0, DOWNSAMPLE.1], "layer1": serde_json::to_value(&layer1).expect("serialize Layer1Output"), }); let actual_json = serde_json::to_string_pretty(&actual).expect("format JSON") + "\n"; let golden_path = manifest.join(GOLDEN); if std::env::var("UPDATE_GOLDEN").is_ok() { std::fs::create_dir_all(golden_path.parent().unwrap()).expect("mkdir golden"); std::fs::write(&golden_path, &actual_json).expect("write golden"); eprintln!( "Golden written: {} ({} bytes)", golden_path.display(), actual_json.len() ); return; } let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| { panic!( "Golden not found: {}. Run: UPDATE_GOLDEN=1 cargo test --test cascade_golden\n{e}", golden_path.display() ) }); // Compare as parsed JSON so whitespace/formatting never causes spurious diffs. let actual_v: Value = serde_json::from_str(&actual_json).expect("reparse actual JSON"); let golden_v: Value = serde_json::from_str(&golden_json).expect("parse golden JSON"); if actual_v != golden_v { let count = |v: &Value, ptr: &str| v.pointer(ptr).and_then(Value::as_array).map_or(0, Vec::len); panic!( "Cascade golden mismatch.\n \ source_sha256: golden={} actual={}\n \ river_cells: golden={} actual={}\n \ attractors: golden={} actual={}\n\ To update: UPDATE_GOLDEN=1 cargo test --test cascade_golden\n Golden: {}", golden_v["source_sha256"], actual_v["source_sha256"], count(&golden_v, "/layer1/river_network/river_cells"), count(&actual_v, "/layer1/river_network/river_cells"), count(&golden_v, "/layer1/attractors"), count(&actual_v, "/layer1/attractors"), golden_path.display(), ); } }