One commit for two tickets whose changes share the bridge/plugin plumbing files. T-1169 connects the three dormant feature-name pieces: atlas_feature_names populated at regen (17,891 rows — 15,190 mountain, 2,701 river — via populate_atlas_feature_names mirroring the city-names importer; systems.db regenerated, stamp fresh), attach_feature_names wired into the cascade's Topography block with name pools threaded DB-free through AnalyzeBody (D-225 pattern) and assignments stored on Layer1Output/BodyWorldState for future consumers, and a FeatureNamesRequest/Response read proxy as the bridge's 7th tagged envelope (D-236 pattern, both SimBridge impls). Client label DRAW is deliberately NOT here — implementation proved both river and mountain labels need a wire-carried position (the pool is position-free; course polylines aren't correlated with the named attractors by construction) — deferred to T-1195's single design pass. cascade_layer1 golden re-pinned (additive feature_names field). T-1159 retires the legacy u32 granularity field fully shadowed by window_granularity_v2: AtlasLayerRequest.window_granularity, DistrictWindowLayer.granularity echo, the u32::MAX sentinel, and resolve_window_granularity are gone server-side; client encode paths and the caller-less atlas_window_cache legacy key component dropped; msgpack fixtures regenerated; the T-1150 aliasing regression test now drives through the surviving enum field. The district_window carrier itself survives byte-compatible per D-255(c). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
12 KiB
Rust
223 lines
12 KiB
Rust
//! 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<u8>` 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<FeatureNameAssignment>` 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(),
|
||
);
|
||
}
|
||
}
|