diff --git a/server/src/atlas/region_profile.rs b/server/src/atlas/region_profile.rs index 0d1c88d08..3434a7f8d 100644 --- a/server/src/atlas/region_profile.rs +++ b/server/src/atlas/region_profile.rs @@ -496,7 +496,7 @@ pub fn derive_river_threshold(tectonic: TectonicClass, precip: PrecipitationClas /// - BraidedPlain (§6) is NOT emitted at region scale — distinguishing it from Delta needs a lithology signal (§8 Gravel→braided) RegionProfile lacks; deferred to ChunkContext (see the D-239 §6 implementation note). /// /// D-010: all gates are integer comparisons. No float arithmetic in this function. -fn derive_morphology_zone( +pub fn derive_morphology_zone( tectonic: TectonicClass, glaciation: GlaciationGrade, slope_q: i32, diff --git a/server/tests/derivation_harness.rs b/server/tests/derivation_harness.rs new file mode 100644 index 000000000..46835c0b9 --- /dev/null +++ b/server/tests/derivation_harness.rs @@ -0,0 +1,1503 @@ +//! D-239 Tile-Derivation Verification Harness (T-1031). +//! +//! Capstone test for the T-974 tile-derivation epic. Covers four domains: +//! +//! **1. Golden-seed determinism regression (D-239 §1 / D-010).** +//! Pins (seed, region, chunk_pos, tile_pos) → VoxelColumn bindings as a JSON +//! golden. Any future change to the derivation chain is caught immediately. +//! Pattern mirrors cascade_golden.rs. Regenerate: +//! `UPDATE_GOLDEN=1 cargo test --test derivation_harness` +//! +//! **2. §8 binding-law assertions (D-239 §8).** +//! Sweeps representative `RegionProfile` inputs and asserts the four +//! believability laws hold across the parameter space: +//! - Drainage monotonicity (channel tiles at or below surrounding terrain) +//! - Lithology→landform (family emits its mandated TerrainMaterial) +//! - Glaciation→form (FjordWall only at GlaciationGrade ≥ 2; grade 0 ≠ fjord) +//! - Climate→vegetation (Forest→Scrub→Barren no-skip; riparian band present) +//! +//! **3. Per-family <5 ms/chunk budget (D-239 §10).** +//! Derives a full 64×64 chunk (4096 voxels) for each of the 8 families. +//! Hard gate (< 5 ms) is activated by `BUDGET_ASSERT=1` env var (release build). +//! Debug builds print timings without failing. VoxelCache is exercised to +//! measure LRU eviction overhead (O(capacity) scan, noted on T-1031). +//! +//! **4. Validation body cases (D-239 §1).** +//! Lore-anchored bodies verified from wiki body params (DB-free). Bodies absent +//! from the wiki are skipped with an explicit logged note. +//! +//! - Kallast (GJ144d): alluvial plain / Soil — VALIDATED +//! - Glødberg (GJ581c): volcanic immature drainage — VALIDATED (Cygni-B proxy) +//! - Marevna (GJ447c): ocean island / OpenOcean — VALIDATED (Ross 128) +//! - Velen (tidal-flat/dune coast): NOT IN WIKI — SKIPPED +//! - Gruenfeld (marginal Gravel/Scrub): NOT IN WIKI — SKIPPED +//! +//! Run all: `cargo test --test derivation_harness` +//! Update golden: `UPDATE_GOLDEN=1 cargo test --test derivation_harness` +//! Budget gate: `BUDGET_ASSERT=1 cargo test --test derivation_harness -- budget` + +use std::path::PathBuf; +use std::time::Instant; + +use settled_reach_server::atlas::chunk_context::derive_chunk_context; +use settled_reach_server::atlas::drainage; +use settled_reach_server::atlas::features::TerrainAnalysis; +use settled_reach_server::atlas::heightmap::BodyHeightmap; +use settled_reach_server::atlas::region_profile::{ + derive_morphology_zone, derive_precipitation_class_from_climate, derive_region_profile, + derive_river_threshold, derive_vegetation, BodyParams, ClimateConstants, GlaciationGrade, + RegionProfile, TectonicClass, VegetationClass, +}; +use settled_reach_server::atlas::voxel::{derive_voxel_column, TerrainMaterial, VoxelCache, Water}; +use settled_reach_server::seed::{SeedChain, SeedDomain}; +use settled_reach_server::simulation::generator::MorphologyZone; + +// --------------------------------------------------------------------------- +// §1 — Golden-seed determinism regression +// --------------------------------------------------------------------------- + +const GOLDEN_FILE: &str = "tests/golden/derivation_harness.json"; + +/// Compact representation of a VoxelColumn for golden pinning. +/// All fields are their integer-discriminant u8 values (D-010) or i32 elevation. +#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq, Clone)] +struct GoldenEntry { + label: String, + seed: u64, + body_id: String, + tile_x: i32, + tile_y: i32, + terrain: u8, + vegetation: u8, + water: u8, + elevation_m: i32, + cover: u8, +} + +/// Derive a GoldenEntry for a fixed (seed, region, chunk_pos, tile_pos) tuple. +fn derive_golden( + label: &str, + seed: u64, + body_id: &str, + region: &RegionProfile, + chunk_pos: (i32, i32), + tile_x: i32, + tile_y: i32, +) -> GoldenEntry { + let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); + let col = derive_voxel_column(seed, body_id, region, &chunk, tile_x, tile_y); + GoldenEntry { + label: label.to_string(), + seed, + body_id: body_id.to_string(), + tile_x, + tile_y, + terrain: col.terrain as u8, + vegetation: col.vegetation as u8, + water: col.water as u8, + elevation_m: col.elevation_m, + cover: col.cover as u8, + } +} + +/// The three fixed golden inputs. Varied families and climate states. +fn golden_cases() -> Vec<( + &'static str, + u64, + &'static str, + RegionProfile, + (i32, i32), + i32, + i32, +)> { + vec![ + // Case A: AlluvialPlain — temperate forest, active channel. + ( + "alluvial_forest_active_channel", + 0xdeadbeef_cafebabe_u64, + "GJ144d", + make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Active, + GlaciationGrade::None, + 6, + 22, + 18, + 68, + Some(12.0), + VegetationClass::Forest, + ), + (10, 20), + 640, + 1280, + ), + // Case B: LavaField — barren volcanic, no channel. + ( + "lava_field_barren", + 0x12345678_90abcdef_u64, + "GJ581c", + make_region( + MorphologyZone::Volcanic, + TectonicClass::Volcanic, + GlaciationGrade::None, + 12, + 38, + 0, + 22, + Some(45.0), + VegetationClass::Barren, + ), + (5, 7), + 320, + 448, + ), + // Case C: FjordWall — glaciated, rocky walls, deep water in trough. + ( + "fjord_wall_glaciated", + 0xfeedface_0badc0de_u64, + "GJ447c", + make_region( + MorphologyZone::Fjord, + TectonicClass::Active, + GlaciationGrade::Moderate, + 55, + 60, + 28, + 55, + Some(-8.0), + VegetationClass::Barren, + ), + (14, 8), + // Centre of the chunk (fjord trough) — should be Deep water, Rock. + 14 * 64 + 32, + 8 * 64 + 32, + ), + ] +} + +#[test] +fn golden_seed_determinism_regression() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let golden_path = manifest.join(GOLDEN_FILE); + + // Derive once, then derive again — the two must be identical before we + // compare against the golden. This is the core D-010 determinism contract. + let run1: Vec = golden_cases() + .into_iter() + .map(|(lbl, seed, body, region, cp, tx, ty)| { + derive_golden(lbl, seed, body, ®ion, cp, tx, ty) + }) + .collect(); + let run2: Vec = golden_cases() + .into_iter() + .map(|(lbl, seed, body, region, cp, tx, ty)| { + derive_golden(lbl, seed, body, ®ion, cp, tx, ty) + }) + .collect(); + + assert_eq!( + run1, run2, + "double-derivation mismatch — determinism is broken (D-010)" + ); + + let actual_json = serde_json::to_string_pretty(&run1).expect("serialize") + "\n"; + + 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 file not found: {}.\n\ + First run: UPDATE_GOLDEN=1 cargo test --test derivation_harness\n{e}", + golden_path.display() + ) + }); + + let actual_v: serde_json::Value = serde_json::from_str(&actual_json).expect("reparse actual"); + let golden_v: serde_json::Value = serde_json::from_str(&golden_json).expect("parse golden"); + + if actual_v != golden_v { + panic!( + "Derivation golden mismatch — derivation chain changed.\n\ + Update: UPDATE_GOLDEN=1 cargo test --test derivation_harness\n\ + Golden: {}\nActual: {}", + golden_json.trim(), + actual_json.trim() + ); + } +} + +// --------------------------------------------------------------------------- +// §2 — §8 Binding-law assertions +// --------------------------------------------------------------------------- + +// ── §8 Law 1: Drainage monotonicity ───────────────────────────────────────── +// +// Channel / water tiles must sit at or below surrounding dry terrain. +// D-239 §8: "drainage monotonicity: tributaries join upstream; mouths at sea +// level; BraidedDelta/CliffCoast/FjordWall floors at sea level." +// Tested by deriving a full 64×64 chunk and checking min(wet_elev) ≤ max(dry_elev). + +/// Returns `true` if the monotonicity assertion was actually exercised (the chunk +/// produced both wet and dry tiles). A `false` return means the chunk had no wet +/// tiles, so the law was trivially satisfied without checking anything — callers +/// sweep several chunks and assert at least one returned `true`, so a derivation +/// regression that silently zeroes all channels fails loudly instead of passing. +#[must_use] +fn assert_drainage_monotonicity( + seed: u64, + body_id: &str, + label: &str, + region: &RegionProfile, + chunk_pos: (i32, i32), +) -> bool { + let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); + if !chunk.has_active_channel { + return false; // No channel → monotonicity trivially satisfied. + } + + let mut max_dry_elev = i32::MIN; + let mut min_wet_elev = i32::MAX; + let mut wet_count = 0usize; + let mut dry_count = 0usize; + + let base_x = chunk_pos.0 * 64; + let base_y = chunk_pos.1 * 64; + for dy in 0..64i32 { + for dx in 0..64i32 { + let col = derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); + match col.water { + Water::Dry => { + max_dry_elev = max_dry_elev.max(col.elevation_m); + dry_count += 1; + } + Water::Shallow | Water::Deep => { + min_wet_elev = min_wet_elev.min(col.elevation_m); + wet_count += 1; + } + } + } + } + + if wet_count > 0 && dry_count > 0 { + // Tolerance +3 m: meander levees are elevated above the floodplain + // (D-239 §9 ElevationDelta), so a channel tile adjacent to a levee tile + // will appear "slightly below" but the levee reads higher. We allow a + // small tolerance to avoid false positives at the levee-channel boundary. + let tolerance = 3; + assert!( + min_wet_elev <= max_dry_elev + tolerance, + "§8 drainage monotonicity VIOLATED in '{label}' chunk ({},{}): \ + min wet-tile elevation {min_wet_elev} m > max dry-tile elevation \ + {max_dry_elev} m (tolerance +{tolerance} m).", + chunk_pos.0, + chunk_pos.1 + ); + return true; + } + false +} + +#[test] +fn law_drainage_monotonicity_alluvial_sweep() { + let region = make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Stable, + GlaciationGrade::None, + 5, + 20, + 18, + 60, + Some(15.0), + VegetationClass::Forest, + ); + let mut checked = false; + for (cx, cy) in [(0, 0), (1, 0), (0, 1), (4, 4), (8, 3)] { + checked |= assert_drainage_monotonicity(42, "GJ144d", "AlluvialPlain", ®ion, (cx, cy)); + } + assert!( + checked, + "AlluvialPlain sweep exercised no wet tiles — the monotonicity law was never \ + actually checked; a derivation regression could silently pass this test." + ); +} + +#[test] +fn law_drainage_monotonicity_meander_sweep() { + let region = make_region( + MorphologyZone::MeanderReach, + TectonicClass::Stable, + GlaciationGrade::None, + 8, + 18, + 20, + 65, + Some(14.0), + VegetationClass::Forest, + ); + let mut checked = false; + for (cx, cy) in [(0, 0), (2, 1), (5, 5)] { + checked |= assert_drainage_monotonicity(99, "GJ447c", "MeanderReach", ®ion, (cx, cy)); + } + assert!( + checked, + "MeanderReach sweep exercised no wet tiles — the monotonicity law was never \ + actually checked; a derivation regression could silently pass this test." + ); +} + +#[test] +fn law_drainage_monotonicity_fjord_floor_at_sea_level() { + // D-239 §8: fjord/cliff/delta floors at sea level (drainage monotonicity). + // FjordWall deep-water channel must be near elevation 0. + let region = make_region( + MorphologyZone::Fjord, + TectonicClass::Active, + GlaciationGrade::Moderate, + 55, + 60, + 28, + 55, + Some(-8.0), + VegetationClass::Barren, + ); + let chunk = derive_chunk_context(42, "fjord_body", ®ion, (0, 0)); + let mut deep_elevs: Vec = vec![]; + let mut dry_elevs: Vec = vec![]; + + // Scan the full 64×64 chunk, not a single row — the deep-water trough axis is + // not guaranteed to intersect any fixed Y, so a single-row probe could miss it + // entirely and silently pass without ever checking the sea-level claim. + for dy in 0..64i32 { + for dx in 0..64i32 { + let col = derive_voxel_column(42, "fjord_body", ®ion, &chunk, dx, dy); + match col.water { + Water::Deep => deep_elevs.push(col.elevation_m), + Water::Dry => dry_elevs.push(col.elevation_m), + _ => {} + } + } + } + + // A glaciated fjord chunk MUST carve a deep-water trough — if it doesn't, the + // derivation regressed and the sea-level law below would never run. Fail loudly. + assert!( + !deep_elevs.is_empty(), + "§8 FjordWall: no deep-water tiles found in the fjord chunk — the trough \ + derivation regressed; the sea-level floor law was never exercised." + ); + assert!( + !dry_elevs.is_empty(), + "§8 FjordWall: no dry wall tiles found in the fjord chunk." + ); + + let max_deep = *deep_elevs.iter().max().unwrap(); + let min_dry = *dry_elevs.iter().min().unwrap(); + // Fjord floor (deep water) must be below wall elevation. + assert!( + max_deep <= min_dry, + "§8 FjordWall drainage: deep-water max elev {max_deep} m must be \ + ≤ dry wall min elev {min_dry} m" + ); + // Fjord floor must be near sea level (D-239 §8). + assert!( + max_deep <= 5, + "§8 FjordWall: deep-water floor elev {max_deep} m must be near sea level (≤5 m)" + ); +} + +#[test] +fn law_drainage_monotonicity_braided_delta() { + let region = make_region( + MorphologyZone::Delta, + TectonicClass::Active, + GlaciationGrade::None, + 3, + 8, + 25, + 50, + Some(18.0), + VegetationClass::Scrub, + ); + let mut checked = false; + for (cx, cy) in [(0, 0), (1, 1)] { + checked |= + assert_drainage_monotonicity(17, "delta_body", "BraidedDelta", ®ion, (cx, cy)); + } + assert!( + checked, + "BraidedDelta sweep exercised no wet tiles — the monotonicity law was never \ + actually checked; a derivation regression could silently pass this test." + ); +} + +// ── §8 Law 2: Lithology → Landform ────────────────────────────────────────── +// +// D-239 §8: Lava→Lava; Cliff/Fjord/Gorge→Rock; Sand→Dune; Gravel→Braided; +// Soil→Meander/Alluvial; Wetland→Wetland. + +/// Derive 64 tile samples (every 8th tile in a 64×64 chunk) and assert all +/// have the expected TerrainMaterial. +fn assert_all_terrain_is( + seed: u64, + body_id: &str, + region: &RegionProfile, + chunk_pos: (i32, i32), + expected: TerrainMaterial, + label: &str, +) { + let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); + let base_x = chunk_pos.0 * 64; + let base_y = chunk_pos.1 * 64; + for dy in (0..64i32).step_by(8) { + for dx in (0..64i32).step_by(8) { + let col = derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); + assert_eq!( + col.terrain, + expected, + "§8 lithology VIOLATED for '{label}': voxel ({},{}) returned {:?}, expected {:?}", + base_x + dx, + base_y + dy, + col.terrain, + expected + ); + } + } +} + +#[test] +fn law_lithology_lava_emits_lava() { + // D-239 §8: Lava family → TerrainMaterial::Lava everywhere. + let region = make_region( + MorphologyZone::Volcanic, + TectonicClass::Volcanic, + GlaciationGrade::None, + 15, + 35, + 0, + 20, + Some(40.0), + VegetationClass::Barren, + ); + assert_all_terrain_is( + 42, + "GJ581c", + ®ion, + (3, 3), + TerrainMaterial::Lava, + "LavaField", + ); +} + +#[test] +fn law_lithology_fjord_emits_rock() { + let region = make_region( + MorphologyZone::Fjord, + TectonicClass::Active, + GlaciationGrade::Moderate, + 55, + 60, + 28, + 55, + Some(-8.0), + VegetationClass::Barren, + ); + assert_all_terrain_is( + 42, + "fjord_body", + ®ion, + (0, 0), + TerrainMaterial::Rock, + "FjordWall", + ); +} + +#[test] +fn law_lithology_cliff_coast_emits_rock() { + let region = make_region( + MorphologyZone::CliffCoast, + TectonicClass::Active, + GlaciationGrade::None, + 60, + 40, + 20, + 30, + Some(10.0), + VegetationClass::Scrub, + ); + assert_all_terrain_is( + 42, + "cliff_body", + ®ion, + (0, 0), + TerrainMaterial::Rock, + "CliffCoast", + ); +} + +#[test] +fn law_lithology_incised_gorge_emits_rock() { + // D-239 §8: IncisedGorge/MountainPass → TerrainMaterial::Rock. + let region = make_region( + MorphologyZone::MountainPass, + TectonicClass::Active, + GlaciationGrade::None, + 50, + 65, + 5, + 40, + Some(5.0), + VegetationClass::Scrub, + ); + assert_all_terrain_is( + 42, + "gorge_body", + ®ion, + (0, 0), + TerrainMaterial::Rock, + "IncisedGorge", + ); +} + +#[test] +fn law_lithology_dune_strand_emits_sand() { + // D-239 §8: DuneStrand → TerrainMaterial::Sand (≤32° angle of repose). + let region = make_region( + MorphologyZone::DuneStrand, + TectonicClass::Stable, + GlaciationGrade::None, + 12, + 15, + 20, + 25, + Some(22.0), + VegetationClass::Barren, + ); + assert_all_terrain_is( + 42, + "dune_body", + ®ion, + (0, 0), + TerrainMaterial::Sand, + "DuneStrand", + ); +} + +#[test] +fn law_lithology_braided_delta_emits_gravel() { + // D-239 §8: BraidedDelta (Gravel→braided channels/fans) → TerrainMaterial::Gravel. + let region = make_region( + MorphologyZone::Delta, + TectonicClass::Active, + GlaciationGrade::None, + 3, + 8, + 25, + 50, + Some(18.0), + VegetationClass::Scrub, + ); + assert_all_terrain_is( + 42, + "delta_body", + ®ion, + (0, 0), + TerrainMaterial::Gravel, + "BraidedDelta", + ); +} + +#[test] +fn law_lithology_alluvial_plain_emits_soil() { + // D-239 §8: AlluvialPlain → Soil (non-wetland params: slope_q>5, moisture_q<60). + let region = make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Stable, + GlaciationGrade::None, + 10, + 25, + 12, + 45, + Some(16.0), + VegetationClass::Forest, + ); + assert_all_terrain_is( + 42, + "alluvial_body", + ®ion, + (0, 0), + TerrainMaterial::Soil, + "AlluvialPlain", + ); +} + +#[test] +fn law_lithology_meander_reach_emits_soil() { + // D-239 §8: MeanderReach (Soil→rolling/floodplain) → TerrainMaterial::Soil. + let region = make_region( + MorphologyZone::MeanderReach, + TectonicClass::Stable, + GlaciationGrade::None, + 8, + 18, + 20, + 55, + Some(14.0), + VegetationClass::Forest, + ); + assert_all_terrain_is( + 42, + "meander_body", + ®ion, + (0, 0), + TerrainMaterial::Soil, + "MeanderReach", + ); +} + +// ── §8 Law 3: Glaciation → Form ────────────────────────────────────────────── +// +// D-239 §5/§8: FjordWall requires GlaciationGrade ≥ 2 (Moderate). +// Grade 0 must NEVER produce Fjord regardless of slope/coastal params. +// Grade 1 (Light) also must NOT produce Fjord (gate is ≥ 2, not ≥ 1). + +#[test] +fn law_glaciation_grade_0_never_produces_fjord() { + // Sweep slope_q and ocean_fraction_q at grade 0. No combination should yield Fjord. + for slope_q in [30, 40, 50, 55, 60, 70] { + for ocean_q in [15, 25, 35, 45, 55] { + let zone = derive_morphology_zone( + TectonicClass::Active, + GlaciationGrade::None, // grade 0 + slope_q, + 60, + ocean_q, + 55, + ); + assert_ne!( + zone, + MorphologyZone::Fjord, + "§8 glaciation law VIOLATED: GlaciationGrade::None + slope_q={slope_q} \ + + ocean_q={ocean_q} produced Fjord zone. Gate requires ≥ 2." + ); + } + } +} + +#[test] +fn law_glaciation_grade_1_never_produces_fjord() { + // Grade 1 (Light) is below the fjord gate. Must also not produce Fjord. + for slope_q in [40, 55, 70] { + for ocean_q in [20, 35] { + let zone = derive_morphology_zone( + TectonicClass::Active, + GlaciationGrade::Light, // grade 1 — gate does NOT open + slope_q, + 60, + ocean_q, + 55, + ); + assert_ne!( + zone, + MorphologyZone::Fjord, + "§8 glaciation law VIOLATED: GlaciationGrade::Light (grade 1) + \ + slope_q={slope_q} + ocean_q={ocean_q} produced Fjord. Gate requires ≥ 2." + ); + } + } +} + +#[test] +fn law_glaciation_grade_2_enables_fjord_with_correct_params() { + // Grade 2 (Moderate): with the correct slope + coastal params, the fjord gate opens. + // This is the positive test — the gate MUST open at exactly grade 2. + let zone = derive_morphology_zone( + TectonicClass::Active, + GlaciationGrade::Moderate, // grade 2 — gate opens + 55, // steep enough + 60, + 25, // coastal + 55, + ); + assert_eq!( + zone, + MorphologyZone::Fjord, + "§8 glaciation law: GlaciationGrade::Moderate (grade 2) with slope_q=55 + \ + ocean_q=25 should produce Fjord zone — gate should be open at grade 2." + ); +} + +#[test] +fn law_glaciation_grade_4_also_enables_fjord() { + // IceCap (grade 4): also above the gate. Should produce Fjord with correct params. + let zone = derive_morphology_zone( + TectonicClass::Active, + GlaciationGrade::IceCap, + 55, + 60, + 25, + 55, + ); + assert_eq!( + zone, + MorphologyZone::Fjord, + "§8 glaciation law: GlaciationGrade::IceCap (grade 4) should also enable Fjord gate" + ); +} + +// ── §8 Law 4: Climate → Vegetation ────────────────────────────────────────── +// +// D-239 §8: "treeline Forest→Scrub→Barren, no skip." +// Rule: as temperature decreases (or elevation increases), the sequence must +// pass through Scrub before reaching Barren. A direct Forest→Barren jump is +// forbidden. Riparian variants are permitted anywhere they're produced. + +#[test] +fn law_climate_vegetation_no_skip_temperature_sweep() { + // Sweep temperature from +30°C to -60°C at fixed mid-elevation. + // The sequence must have no Forest→Barren jump. + let moisture_q = 50; + let elev_q = 40; + let mut last: Option = None; + for t_i in (-60i32..=30).rev().step_by(5) { + let temp = Some(t_i as f32); + let vc = derive_vegetation(temp, moisture_q, elev_q, false); + if let Some(prev) = last { + if prev == VegetationClass::Forest && vc == VegetationClass::Barren { + panic!( + "§8 vegetation no-skip VIOLATED: Forest→Barren at temp={t_i}°C \ + (elev_q={elev_q}, moisture_q={moisture_q})" + ); + } + } + last = Some(vc); + } +} + +#[test] +fn law_climate_vegetation_no_skip_elevation_sweep() { + // Sweep elevation from 0 to 100 at fixed warm temperature. + let temp = Some(15.0f32); + let moisture_q = 55; + let mut last: Option = None; + for elev_q in (0i32..=100).step_by(5) { + let vc = derive_vegetation(temp, moisture_q, elev_q, false); + if let Some(prev) = last { + if prev == VegetationClass::Forest && vc == VegetationClass::Barren { + panic!( + "§8 vegetation no-skip VIOLATED: Forest→Barren at elev_q={elev_q} \ + (temp=+15°C, moisture_q={moisture_q})" + ); + } + } + last = Some(vc); + } +} + +#[test] +fn law_climate_vegetation_full_grid_no_skip() { + // Full grid sweep: all (moisture_q, elev_q) combinations, temperature from warm to cold. + // At each slice, verify no Forest→Barren jump as temperature drops. + let temps_desc: Vec> = [ + Some(30.0), + Some(20.0), + Some(10.0), + Some(5.0), + Some(0.0), + Some(-5.0), + Some(-10.0), + Some(-20.0), + Some(-30.0), + Some(-40.0), + Some(-55.0), + None, + ] + .to_vec(); + + for moisture_q in (0i32..=100).step_by(10) { + for elev_q in (0i32..=100).step_by(10) { + let mut last: Option = None; + for &temp in &temps_desc { + let vc = derive_vegetation(temp, moisture_q, elev_q, false); + if let Some(prev) = last { + if prev == VegetationClass::Forest && vc == VegetationClass::Barren { + panic!( + "§8 full-grid no-skip VIOLATED: Forest→Barren at \ + temp={temp:?}, moisture_q={moisture_q}, elev_q={elev_q}" + ); + } + } + last = Some(vc); + } + } + } +} + +#[test] +fn law_climate_vegetation_riparian_near_perennial_water() { + // D-239 §8: "riparian Thicket/Scrub 1–3 tiles along perennial waterways." + // near_perennial_water=true must produce a Riparian variant in viable zones. + + // Forest zone → RiparianThicket. + let vc_forest = derive_vegetation(Some(18.0), 60, 20, true); + assert!( + matches!( + vc_forest, + VegetationClass::RiparianThicket | VegetationClass::RiparianScrub + ), + "§8 riparian: Forest zone near water should → Riparian variant, got {:?}", + vc_forest + ); + + // Scrub zone → RiparianScrub. + let vc_scrub = derive_vegetation(Some(5.0), 30, 50, true); + assert!( + matches!( + vc_scrub, + VegetationClass::RiparianScrub | VegetationClass::RiparianThicket + ), + "§8 riparian: Scrub zone near water should → Riparian variant, got {:?}", + vc_scrub + ); + + // Hyper-arid Barren zone + perennial water → RiparianScrub oasis (D-239 §8). + let vc_arid = derive_vegetation(Some(20.0), 3, 10, true); + assert_eq!( + vc_arid, + VegetationClass::RiparianScrub, + "§8 riparian: hyper-arid zone (moisture_q=3) near perennial water → \ + RiparianScrub oasis, got {:?}", + vc_arid + ); +} + +#[test] +fn law_climate_vegetation_airless_always_absent() { + // D-239 §2: airless body (temperature_c = None) → VegetationClass::Absent always. + for moisture_q in [0, 30, 70, 100] { + for elev_q in [0, 50, 100] { + for near_water in [false, true] { + let vc = derive_vegetation(None, moisture_q, elev_q, near_water); + assert_eq!( + vc, + VegetationClass::Absent, + "§8 vegetation: airless body must produce Absent, got {:?} \ + (moisture_q={moisture_q}, elev_q={elev_q})", + vc + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// §3 — Per-family <5 ms/chunk budget (D-239 §10) +// --------------------------------------------------------------------------- + +/// Derive all 4096 voxels in a 64×64 chunk and return (count, elapsed_µs). +fn derive_chunk_timed( + seed: u64, + body_id: &str, + region: &RegionProfile, + chunk_pos: (i32, i32), +) -> (usize, u128) { + let chunk = derive_chunk_context(seed, body_id, region, chunk_pos); + let base_x = chunk_pos.0 * 64; + let base_y = chunk_pos.1 * 64; + let t0 = Instant::now(); + let mut count = 0usize; + for dy in 0..64i32 { + for dx in 0..64i32 { + let _ = derive_voxel_column(seed, body_id, region, &chunk, base_x + dx, base_y + dy); + count += 1; + } + } + (count, t0.elapsed().as_micros()) +} + +/// All 8 family inputs for the budget sweep. +fn budget_families() -> Vec<(&'static str, RegionProfile)> { + vec![ + ( + "AlluvialPlain", + make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Stable, + GlaciationGrade::None, + 5, + 20, + 18, + 60, + Some(15.0), + VegetationClass::Forest, + ), + ), + ( + "LavaField", + make_region( + MorphologyZone::Volcanic, + TectonicClass::Volcanic, + GlaciationGrade::None, + 12, + 35, + 0, + 20, + Some(40.0), + VegetationClass::Barren, + ), + ), + ( + "FjordWall", + make_region( + MorphologyZone::Fjord, + TectonicClass::Active, + GlaciationGrade::Moderate, + 55, + 60, + 28, + 55, + Some(-8.0), + VegetationClass::Barren, + ), + ), + ( + "CliffCoast", + make_region( + MorphologyZone::CliffCoast, + TectonicClass::Active, + GlaciationGrade::None, + 60, + 40, + 20, + 30, + Some(10.0), + VegetationClass::Scrub, + ), + ), + ( + "BraidedDelta", + make_region( + MorphologyZone::Delta, + TectonicClass::Active, + GlaciationGrade::None, + 3, + 8, + 25, + 50, + Some(18.0), + VegetationClass::Scrub, + ), + ), + ( + "DuneStrand", + make_region( + MorphologyZone::DuneStrand, + TectonicClass::Stable, + GlaciationGrade::None, + 12, + 15, + 20, + 25, + Some(22.0), + VegetationClass::Barren, + ), + ), + ( + "IncisedGorge", + make_region( + MorphologyZone::MountainPass, + TectonicClass::Active, + GlaciationGrade::None, + 50, + 65, + 5, + 40, + Some(5.0), + VegetationClass::Scrub, + ), + ), + ( + "MeanderReach", + make_region( + MorphologyZone::MeanderReach, + TectonicClass::Stable, + GlaciationGrade::None, + 8, + 18, + 20, + 55, + Some(14.0), + VegetationClass::Forest, + ), + ), + ] +} + +#[test] +fn budget_per_family_chunk_derivation() { + let seed = 0xdeadbeef_12345678_u64; + let body_id = "budget_test_body"; + let hard_gate = std::env::var("BUDGET_ASSERT").is_ok(); + + // Warm up with the fallback family first (also establishes baseline). + let (_, _) = derive_chunk_timed(seed, body_id, &budget_families()[0].1, (0, 0)); + let (_, baseline_us) = derive_chunk_timed(seed, body_id, &budget_families()[0].1, (0, 0)); + eprintln!( + "[budget] AlluvialPlain baseline: {} µs / 4096 voxels", + baseline_us + ); + + let mut results: Vec<(&str, u128)> = vec![]; + + for (label, region) in budget_families() { + let (cnt, us) = derive_chunk_timed(seed, body_id, ®ion, (1, 1)); + let ms = us as f64 / 1000.0; + eprintln!("[budget] {label}: {ms:.2} ms / {cnt} voxels"); + + assert_eq!(cnt, 4096, "chunk must produce exactly 4096 voxels"); + + // Hard gate: < 5 ms. Only enforced when BUDGET_ASSERT=1 (release build). + // D-239 §10 target: ~2.2–4.2 ms/chunk. + if hard_gate { + assert!( + ms < 5.0, + "§10 budget EXCEEDED for '{label}': {ms:.2} ms > 5 ms. \ + (Ensure release mode: cargo test --test derivation_harness --release)" + ); + } + + results.push((label, us)); + } + + // Relative assertion: FjordWall/IncisedGorge are noted as costlier than + // AlluvialPlain (D-239 §10), but must not be pathologically worse (>40×). + // In debug builds this catches O(n²) algorithmic defects regardless of + // absolute timing. + for (label, us) in &results { + if *label == "FjordWall" || *label == "IncisedGorge" { + let ratio = *us as f64 / baseline_us.max(1) as f64; + assert!( + ratio < 40.0, + "§10 relative budget: '{label}' is {ratio:.1}× slower than AlluvialPlain \ + ({:.2} ms vs {:.2} ms baseline). Check for algorithmic regression.", + *us as f64 / 1000.0, + baseline_us as f64 / 1000.0 + ); + } + } +} + +// ── VoxelCache LRU eviction scan ───────────────────────────────────────────── +// +// D-239 §10 / T-1031 note: VoxelCache uses an O(capacity) LRU eviction scan +// (linear BTreeMap iteration for min access_gen). This is accepted behaviour +// for Phase 4 (no production render loop yet). We measure the overhead here +// to document it empirically. +// +// T-1031 baseline: ~40–55× overhead in debug builds at capacity=1024, 4096 voxels. +// The gate is 200× (catastrophic regression detection only). +// Phase 5 upgrade: replace the BTreeMap linear scan with a min-heap or +// generation-indexed secondary structure to achieve <5× overhead. + +#[test] +fn budget_voxel_cache_lru_overhead() { + let seed = 0xfeedface_12345678_u64; + let body_id = "cache_budget_body"; + let region = make_region( + MorphologyZone::AlluvialPlain, + TectonicClass::Stable, + GlaciationGrade::None, + 5, + 20, + 18, + 60, + Some(15.0), + VegetationClass::Forest, + ); + let chunk_pos = (2, 2); + let chunk = derive_chunk_context(seed, body_id, ®ion, chunk_pos); + let base_x = chunk_pos.0 * 64; + let base_y = chunk_pos.1 * 64; + + // Adversarial capacity: one-quarter of a chunk (1024) forces frequent + // LRU evictions on every sequential pass through the full 4096 voxels. + let capacity = 1024; + let mut cache = VoxelCache::new(capacity); + + let t0 = Instant::now(); + for dy in 0..64i32 { + for dx in 0..64i32 { + let _ = cache.get_or_derive(seed, body_id, ®ion, &chunk, base_x + dx, base_y + dy); + } + } + let cache_us = t0.elapsed().as_micros(); + + // Direct derivation for comparison. + let (_, direct_us) = derive_chunk_timed(seed, body_id, ®ion, chunk_pos); + + let overhead_ratio = cache_us as f64 / direct_us.max(1) as f64; + eprintln!( + "[cache_lru] direct: {direct_us} µs | cache (cap={capacity}): {cache_us} µs | \ + overhead: {overhead_ratio:.1}× \ + [T-1031 note: O(capacity) BTreeMap scan; accepted for Phase 4, ~40–55× measured]" + ); + + // Measured overhead in debug builds: ~40–55× (capacity=1024, 4096 voxels). + // Root cause: BTreeMap linear scan for min access_gen on every eviction — O(n) per + // insert when cache is full. Accepted for Phase 4 (no production render loop). + // + // Gate at 200× to catch catastrophic regressions only (e.g. nested scans, + // quadratic growth). The 40–55× figure is intentionally documented here as the + // T-1031 baseline so future maintainers know what "acceptable" looks like. + // When a render loop is wired (Phase 5), upgrade to a min-heap or generation + // bitmap and lower this gate to <5×. + assert!( + overhead_ratio < 200.0, + "VoxelCache LRU overhead {overhead_ratio:.1}× exceeds 200×. \ + The O({capacity}) BTreeMap scan has regressed beyond the T-1031 Phase 4 baseline. \ + Consider upgrading the eviction strategy." + ); +} + +// --------------------------------------------------------------------------- +// §4 — Validation body cases (D-239 §1) +// --------------------------------------------------------------------------- +// +// Body params are sourced directly from wiki frontmatter (DB-free). +// If a body is absent from the wiki, the test SKIPS with a logged note. + +/// Kallast (GJ144d) params — wiki: star-systems/GJ-144/bodies/GJ144d/index.md +fn kallast_body_params() -> BodyParams { + // planet_class: temperate | atmosphere: standard | hydrosphere: ocean | tectonics: active + // (orbit/star data is non-canonical per D-240 and no longer feeds temperature) + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("standard".into()), + planet_class: Some("temperate".into()), + tectonic_activity: Some("active".into()), + region_latitude_deg: 0.0, + elevation_km: 0.0, + } +} + +/// Glødberg (GJ581c) params — wiki: star-systems/GJ-581/bodies/GJ581c/index.md +fn gloedberg_body_params() -> BodyParams { + // planet_class: volcanic | atmosphere: standard | hydrosphere: subsurface_liquid | tectonics: extreme + // (orbit/star data is non-canonical per D-240 and no longer feeds temperature) + BodyParams { + hydrosphere: Some("subsurface".into()), + atmosphere: Some("standard".into()), + planet_class: Some("volcanic".into()), + tectonic_activity: Some("volcanic".into()), + region_latitude_deg: 0.0, + elevation_km: 0.5, + } +} + +/// Marevna (GJ447c) params — wiki: star-systems/GJ-447/bodies/GJ447c/index.md +/// GJ-447 is the real-world identifier for Ross 128. +fn marevna_body_params() -> BodyParams { + // planet_class: oceanic | atmosphere: standard | hydrosphere: ocean | tectonics: active + // (orbit/star data is non-canonical per D-240 and no longer feeds temperature) + BodyParams { + hydrosphere: Some("ocean".into()), + atmosphere: Some("standard".into()), + planet_class: Some("oceanic".into()), + tectonic_activity: Some("active".into()), + region_latitude_deg: 0.0, + elevation_km: 0.0, + } +} + +/// Derive a RegionProfile for a body at given latitude/elevation, +/// using the full derive_region_profile pipeline over a minimal dry-land heightmap. +/// +/// Uses a heightmap where all cells are above sea level (data = 0.5, sea_level = 0.3), +/// so ocean_fraction_q = 0 at all positions. This ensures the morphology classifier +/// reaches the tectonic/glaciation gates without being short-circuited by the Tier-0 +/// ocean check (which fires at ocean_fraction_q ≥ 60). +fn derive_profile_for_body(params: &BodyParams) -> RegionProfile { + let climate = ClimateConstants::default(); + let seed = SeedChain::root(42).derive(SeedDomain::Body, 1); + let ta = make_dry_terrain_analysis(); + derive_region_profile(seed, params, &ta, (0, 0), 8, &climate) +} + +#[test] +fn validation_kallast_alluvial_plain() { + // D-239 §1: Kallast = alluvial plain / Soil. GJ144d, K-star, temperate. + eprintln!("[validation] Kallast (GJ144d): checking alluvial plain / non-volcanic / non-fjord"); + + let profile = derive_profile_for_body(&kallast_body_params()); + + // Not volcanic — Kallast is a temperate world. + assert_ne!( + profile.tectonic_class, + TectonicClass::Volcanic, + "Kallast: TectonicClass must not be Volcanic (param: active)" + ); + // Not fjord — no glaciation at temperate K-star orbit. + assert_ne!( + profile.morphology_zone, + MorphologyZone::Fjord, + "Kallast: temperate body must not produce Fjord" + ); + // Not volcanic morphology. + assert_ne!( + profile.morphology_zone, + MorphologyZone::Volcanic, + "Kallast: temperate body must not produce Volcanic" + ); + // Plausible terrestrial/coastal zone for a temperate body with ocean hydro. + let plausible = matches!( + profile.morphology_zone, + MorphologyZone::AlluvialPlain + | MorphologyZone::MeanderReach + | MorphologyZone::RiverBank + | MorphologyZone::Delta + | MorphologyZone::Estuarine + | MorphologyZone::TidalFlat + | MorphologyZone::Lake + | MorphologyZone::OpenOcean + | MorphologyZone::Wetland + ); + assert!( + plausible, + "Kallast: expected alluvial/coastal zone, got {:?}. \ + If wrong, fix body params — not the derivation (D-239 §1).", + profile.morphology_zone + ); + // Temperature plausible for K-star temperate world. + if let Some(t) = profile.temperature_c { + assert!( + (-20.0..=50.0).contains(&t), + "Kallast: temperature {t}°C outside plausible range [-20, 50]" + ); + } + + eprintln!( + "[validation] Kallast: zone={:?} temp={:?}°C glaciation={:?} tectonic={:?} — PASS", + profile.morphology_zone, + profile.temperature_c, + profile.glaciation_grade, + profile.tectonic_class + ); +} + +#[test] +fn validation_gloedberg_volcanic_immature_drainage() { + // D-239 §1: "Cygni B = volcanic immature drainage." + // No body named "Cygni B" exists in the current wiki. GJ581c (Glødberg) is + // the closest volcanic match. NOTE: update this test if a "Cygni B" body is + // added to the wiki in the future. + eprintln!( + "[validation] Glødberg (GJ581c): volcanic proxy — checking LavaField + immature drainage" + ); + + let profile = derive_profile_for_body(&gloedberg_body_params()); + + // Assert volcanic tectonic class. + assert_eq!( + profile.tectonic_class, + TectonicClass::Volcanic, + "Glødberg: volcanic body must have TectonicClass::Volcanic" + ); + // Assert Volcanic morphology (LavaField classifier fires first — §5 gate order). + assert_eq!( + profile.morphology_zone, + MorphologyZone::Volcanic, + "Glødberg: volcanic body must produce Volcanic zone (LavaField family)" + ); + // No glaciation on a hot close-orbit volcanic world. + assert_eq!( + profile.glaciation_grade, + GlaciationGrade::None, + "Glødberg: hot volcanic body at 12-day orbit must have no glaciation" + ); + + // Derive a voxel and check TerrainMaterial::Lava (§8 lithology law). + let chunk = derive_chunk_context(42, "GJ581c", &profile, (0, 0)); + let col = derive_voxel_column(42, "GJ581c", &profile, &chunk, 100, 100); + assert_eq!( + col.terrain, + TerrainMaterial::Lava, + "Glødberg: voxel must be Lava (§8 lava law)" + ); + // Immature drainage: no active channels on lava fields (D-239 §8). + // The LavaField generator explicitly ignores has_active_channel. + assert_eq!( + col.water, + Water::Dry, + "Glødberg: volcanic immature drainage — voxel at (100,100) must be Dry \ + (LavaField ignores active channel per §8 immature drainage law)" + ); + + eprintln!( + "[validation] Glødberg: zone={:?} temp={:?}°C terrain={:?} — PASS", + profile.morphology_zone, profile.temperature_c, col.terrain + ); +} + +#[test] +fn validation_marevna_ocean_island() { + // D-239 §1: "Ross 128 = ocean island." GJ-447 is Ross 128; GJ447c = Marevna. + eprintln!( + "[validation] Marevna (GJ447c / Ross 128): ocean island — checking coastal/ocean zone" + ); + + let profile = derive_profile_for_body(&marevna_body_params()); + + // Not volcanic. + assert_ne!( + profile.morphology_zone, + MorphologyZone::Volcanic, + "Marevna: oceanic world must not produce Volcanic" + ); + // Not fjord — warm M-star body, no glaciation expected. + assert_ne!( + profile.morphology_zone, + MorphologyZone::Fjord, + "Marevna: warm oceanic world must not produce Fjord (no glaciation)" + ); + // Plausible zone for an oceanic high-moisture world on a flat dry-land test terrain. + // moisture_q = 80 (ocean hydro + standard atmo) + slope_q ≈ 0 → Wetland is the + // dominant output from the derivation (§8 Wetland ≤5° flats). AlluvialPlain and + // coastal zones are also plausible depending on ocean_fraction_q signal. + let plausible = matches!( + profile.morphology_zone, + MorphologyZone::OpenOcean + | MorphologyZone::Lake + | MorphologyZone::Delta + | MorphologyZone::Estuarine + | MorphologyZone::TidalFlat + | MorphologyZone::MeanderReach + | MorphologyZone::AlluvialPlain + | MorphologyZone::RiverBank + | MorphologyZone::Wetland // Expected on flat high-moisture terrain (moisture_q=80, slope≈0) + ); + assert!( + plausible, + "Marevna: oceanic world expected coastal/wetland zone, got {:?}. \ + If wrong, fix body params — not the derivation (D-239 §1).", + profile.morphology_zone + ); + // Temperature is deterministically derived from the planet_class envelope (D-240), + // not orbit/star data: oceanic band [-12, 28]°C, maritime factor 0.6 for an "ocean" + // hydrosphere. At the equator (latitude 0) the latitude lerp sits at the warm sub-band + // edge (~+20°C) and the standard-atmosphere greenhouse fraction lifts it to ~+26°C — + // a habitable result, as expected for an oceanic world. The assertion stays loose: we + // verify the pipeline ran without panic and produced a physically plausible value. + if let Some(t) = profile.temperature_c { + assert!( + t > -90.0 && t < 90.0, + "Marevna: temperature {t}°C is outside the oceanic-class plausible range [-90, 90]" + ); + } + + eprintln!( + "[validation] Marevna: zone={:?} temp={:?}°C moisture_q={} — PASS \ + (oceanic class-envelope temp at equator per D-240)", + profile.morphology_zone, profile.temperature_c, profile.moisture_q + ); +} + +#[test] +fn validation_velen_skipped_not_in_wiki() { + // D-239 §1: "Velen = tidal-flat/dune coast." + // D-239 §1 caveat: "tidal flats require a moon param for the D-228 tidal + // term — verify Velen's params before treating its coast as a contract." + // + // STATUS: Body named "Velen" does NOT exist in the current wiki. + // Searched wiki/star-systems/**/*.md for `name: Velen` → NOT FOUND. + // The tidal/moon param cannot be verified (no body to inspect). + // This test is a deliberate skip with documented reason. + // Re-enable when Velen is created in the wiki and systems.db. + eprintln!( + "[validation] SKIPPED Velen (tidal-flat/dune coast):\n \ + Body 'Velen' not found in wiki/star-systems/**/*.md. Lore anchor not\n \ + yet materialised as a named body in systems.db. D-239 §1 caveat:\n \ + moon/tidal param not verifiable. Re-enable when Velen is created." + ); +} + +#[test] +fn validation_gruenfeld_skipped_not_in_wiki() { + // D-239 §1: "Gruenfeld = marginal Gravel/Scrub." + // + // STATUS: Body named "Gruenfeld" does NOT exist in the current wiki. + // Searched wiki/star-systems/**/*.md for `name: Gruenfeld` → NOT FOUND. + // Re-enable when Gruenfeld is created in the wiki and systems.db. + eprintln!( + "[validation] SKIPPED Gruenfeld (marginal Gravel/Scrub):\n \ + Body 'Gruenfeld' not found in wiki/star-systems/**/*.md. Lore anchor\n \ + not yet materialised. Re-enable when Gruenfeld is created." + ); +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Construct a `RegionProfile` directly from parameters, deriving the +/// dependent fields (precipitation_class, river_threshold) consistently. +fn make_region( + zone: MorphologyZone, + tectonic: TectonicClass, + glaciation: GlaciationGrade, + slope_q: i32, + elev_q: i32, + ocean_fraction_q: i32, + moisture_q: i32, + temperature_c: Option, + vegetation_class: VegetationClass, +) -> RegionProfile { + let precip = derive_precipitation_class_from_climate(temperature_c, moisture_q); + RegionProfile { + morphology_zone: zone, + tectonic_class: tectonic, + glaciation_grade: glaciation, + precipitation_class: precip, + slope_q, + elev_q, + ocean_fraction_q, + river_threshold: derive_river_threshold(tectonic, precip), + temperature_c, + moisture_q, + vegetation_class, + } +} + +/// Build a flat all-dry `TerrainAnalysis` for validation body derivation. +/// +/// All cells have elevation = 0.5 with sea_level = 0.3 → ocean_fraction_q = 0 at +/// every region position. This prevents the Tier-0 ocean short-circuit in +/// `derive_morphology_zone` (ocean_fraction_q ≥ 60) from overriding the tectonic +/// and glaciation gates that we're testing. +/// +/// Uses a gentle constant slope (no variation) so slope_q ~ 0 and elev_q ~ 75, +/// which places the morphology classifier in the AlluvialPlain fallback unless the +/// tectonic/glaciation gates fire first. That is the correct pre-condition for +/// lore body tests. +fn make_dry_terrain_analysis() -> TerrainAnalysis { + let (w, h) = (64u32, 32u32); + let n = (w * h) as usize; + // Constant 0.5: all above sea_level=0.3 → zero ocean cells. + let data: Vec = vec![0.5_f32; n]; + let hm = BodyHeightmap { + body_id: "dry_test".into(), + width: w, + height: h, + data, + sea_level: 0.3, + }; + let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level); + TerrainAnalysis::analyze(&hm, &dr) +} diff --git a/server/tests/golden/derivation_harness.json b/server/tests/golden/derivation_harness.json new file mode 100644 index 000000000..97685400c --- /dev/null +++ b/server/tests/golden/derivation_harness.json @@ -0,0 +1,38 @@ +[ + { + "label": "alluvial_forest_active_channel", + "seed": 16045690984503098046, + "body_id": "GJ144d", + "tile_x": 640, + "tile_y": 1280, + "terrain": 0, + "vegetation": 1, + "water": 0, + "elevation_m": 11, + "cover": 0 + }, + { + "label": "lava_field_barren", + "seed": 1311768467294899695, + "body_id": "GJ581c", + "tile_x": 320, + "tile_y": 448, + "terrain": 5, + "vegetation": 0, + "water": 0, + "elevation_m": 21, + "cover": 0 + }, + { + "label": "fjord_wall_glaciated", + "seed": 18369614217980264670, + "body_id": "GJ447c", + "tile_x": 928, + "tile_y": 544, + "terrain": 3, + "vegetation": 0, + "water": 2, + "elevation_m": 1, + "cover": 1 + } +]