Lake edges rendered as hard step-edges while ocean coasts got multi-tone transition bands: every coastal-transition morphology gate keys on ocean_fraction_q, definitionally 0 inside a lake basin (hypothesis (b) of the ticket; (a) disproven first — a shoreline-crossing sweep at 2048/512/128m plus a 10m fine sweep all land on the same continuous crossing, so positional refinement was never broken). New DistrictProfile.lake_margin_q (0-100 settled-hydrology depth band, from the same bilinear filled/elevation pair the lake test already samples; ceiling calibrated just above the observed p90 depth on GJ338Bd's 5,043 flooded cells), threaded through both derive paths onto EncodedStepCanvas (serde-default for shape tolerance) and down the client: protocol decode, terrain-layer plane, colorize shades Lake cells by depth band instead of elev_q (bedrock-under-water, the wrong signal). project.yaml 0.4.0 -> 0.4.1: the new wire field must invalidate the client disk cache via its version tag (T-1183's D-192 mechanism). Acceptance gates green with the new field (lossless round-trip, cache-hit==cache-miss, every rung). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2307 lines
83 KiB
Rust
2307 lines
83 KiB
Rust
//! 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, district, 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 `DistrictProfile` 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::collections::BTreeMap;
|
||
use std::path::PathBuf;
|
||
use std::time::Instant;
|
||
|
||
use settled_reach_server::atlas::chunk_context::{
|
||
derive_chunk_context, district_boundary_blend_weight, BasinDirection, ChunkPos,
|
||
};
|
||
use settled_reach_server::atlas::district_profile::{
|
||
derive_district_profile, derive_morphology_zone, derive_precipitation_class_from_climate,
|
||
derive_river_threshold, derive_vegetation, BodyParams, ClimateConstants, DistrictProfile,
|
||
GlaciationGrade, TectonicClass, VegetationClass,
|
||
};
|
||
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::scale;
|
||
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, district, chunk_pos, tile_pos) tuple.
|
||
fn derive_golden(
|
||
label: &str,
|
||
seed: u64,
|
||
body_id: &str,
|
||
district: &DistrictProfile,
|
||
chunk_pos: (i32, i32),
|
||
tile_x: i32,
|
||
tile_y: i32,
|
||
) -> GoldenEntry {
|
||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||
let col = derive_voxel_column(seed, body_id, district, &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,
|
||
}
|
||
}
|
||
|
||
/// Compute the chunk position + tile coordinates sitting ON the district's
|
||
/// channel anchor at a given along-axis chunk index (T-1040/T-1041): feature
|
||
/// placement is district-anchored, so the golden pins a voxel on the anchor
|
||
/// column. Relocates automatically if the anchor derivation changes — which
|
||
/// flips the golden values anyway.
|
||
///
|
||
/// `along_chunk` must stay within district (0, 0) — i.e. in `0..scale::CHUNKS_PER_DISTRICT` — so the
|
||
/// probed anchor belongs to the same district as the returned chunk.
|
||
fn anchor_golden_pos(
|
||
seed: u64,
|
||
body_id: &str,
|
||
district: &DistrictProfile,
|
||
along_chunk: i32,
|
||
) -> ((i32, i32), i32, i32) {
|
||
assert!(
|
||
(0..scale::CHUNKS_PER_DISTRICT).contains(&along_chunk),
|
||
"along_chunk must stay within district (0, 0)"
|
||
);
|
||
let probe = derive_chunk_context(seed, body_id, district, (0, 0), None);
|
||
let anchor = probe.channel_anchor_m;
|
||
let along_tile = along_chunk * 64 + 32;
|
||
match probe.basin_direction {
|
||
BasinDirection::North | BasinDirection::South => {
|
||
((anchor.div_euclid(64), along_chunk), anchor, along_tile)
|
||
}
|
||
BasinDirection::East | BasinDirection::West => {
|
||
((along_chunk, anchor.div_euclid(64)), along_tile, anchor)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The three fixed golden inputs. Varied families and climate states.
|
||
fn golden_cases() -> Vec<(
|
||
&'static str,
|
||
u64,
|
||
&'static str,
|
||
DistrictProfile,
|
||
(i32, i32),
|
||
i32,
|
||
i32,
|
||
)> {
|
||
// Cases A and C pin voxels on the district channel anchor (T-1040/T-1041):
|
||
// the channel/trough centreline is district-anchored, not at the world
|
||
// origin (A) or the chunk centre (C).
|
||
let alluvial_district = make_region(
|
||
MorphologyZone::AlluvialPlain,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::None,
|
||
6,
|
||
22,
|
||
18,
|
||
68,
|
||
Some(12.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
let (alluvial_chunk_pos, alluvial_tx, alluvial_ty) =
|
||
anchor_golden_pos(0xdeadbeef_cafebabe_u64, "GJ144d", &alluvial_district, 12);
|
||
let fjord_district = make_region(
|
||
MorphologyZone::Fjord,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::Moderate,
|
||
55,
|
||
60,
|
||
28,
|
||
55,
|
||
Some(-8.0),
|
||
VegetationClass::Barren,
|
||
);
|
||
let (fjord_chunk_pos, fjord_tx, fjord_ty) =
|
||
anchor_golden_pos(0xfeedface_0badc0de_u64, "GJ447c", &fjord_district, 5);
|
||
vec![
|
||
// Case A: AlluvialPlain — temperate forest, on the active-channel anchor.
|
||
(
|
||
"alluvial_forest_active_channel",
|
||
0xdeadbeef_cafebabe_u64,
|
||
"GJ144d",
|
||
alluvial_district,
|
||
alluvial_chunk_pos,
|
||
alluvial_tx,
|
||
alluvial_ty,
|
||
),
|
||
// 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; tile on the district-anchored
|
||
// trough centreline (Deep water unless the warp nudges it onto the floor edge).
|
||
(
|
||
"fjord_wall_glaciated",
|
||
0xfeedface_0badc0de_u64,
|
||
"GJ447c",
|
||
fjord_district,
|
||
fjord_chunk_pos,
|
||
fjord_tx,
|
||
fjord_ty,
|
||
),
|
||
]
|
||
}
|
||
|
||
#[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<GoldenEntry> = golden_cases()
|
||
.into_iter()
|
||
.map(|(lbl, seed, body, district, cp, tx, ty)| {
|
||
derive_golden(lbl, seed, body, &district, cp, tx, ty)
|
||
})
|
||
.collect();
|
||
let run2: Vec<GoldenEntry> = golden_cases()
|
||
.into_iter()
|
||
.map(|(lbl, seed, body, district, cp, tx, ty)| {
|
||
derive_golden(lbl, seed, body, &district, 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,
|
||
district: &DistrictProfile,
|
||
chunk_pos: (i32, i32),
|
||
) -> bool {
|
||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||
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, district, &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 district = make_region(
|
||
MorphologyZone::AlluvialPlain,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
5,
|
||
20,
|
||
18,
|
||
60,
|
||
Some(15.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
let mut checked = false;
|
||
// Sweep a spread of district chunks plus the channel-anchor band (T-1040:
|
||
// the channel is district-anchored, so only band chunks carry wet tiles).
|
||
let mut positions = vec![(0, 0), (1, 0), (0, 1), (4, 4), (8, 3)];
|
||
positions.extend(anchor_band_chunks(42, "GJ144d", &district, (0, 0)));
|
||
for pos in positions {
|
||
checked |= assert_drainage_monotonicity(42, "GJ144d", "AlluvialPlain", &district, pos);
|
||
}
|
||
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 district = make_region(
|
||
MorphologyZone::MeanderReach,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
8,
|
||
18,
|
||
20,
|
||
65,
|
||
Some(14.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
let mut checked = false;
|
||
let mut positions = vec![(0, 0), (2, 1), (5, 5)];
|
||
positions.extend(anchor_band_chunks(99, "GJ447c", &district, (0, 0)));
|
||
for pos in positions {
|
||
checked |= assert_drainage_monotonicity(99, "GJ447c", "MeanderReach", &district, pos);
|
||
}
|
||
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 district = make_region(
|
||
MorphologyZone::Fjord,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::Moderate,
|
||
55,
|
||
60,
|
||
28,
|
||
55,
|
||
Some(-8.0),
|
||
VegetationClass::Barren,
|
||
);
|
||
// The fjord trough is district-anchored (T-1041): scan the chunk whose
|
||
// cross-range contains the channel anchor — only that chunk column carries
|
||
// the deep-water trough.
|
||
let probe = derive_chunk_context(42, "fjord_body", &district, (0, 0), None);
|
||
let anchor_idx = probe.channel_anchor_m.div_euclid(64);
|
||
let chunk_pos = match probe.basin_direction {
|
||
BasinDirection::North | BasinDirection::South => (anchor_idx, 0),
|
||
BasinDirection::East | BasinDirection::West => (0, anchor_idx),
|
||
};
|
||
let chunk = derive_chunk_context(42, "fjord_body", &district, chunk_pos, None);
|
||
let (base_x, base_y) = (chunk_pos.0 * 64, chunk_pos.1 * 64);
|
||
let mut deep_elevs: Vec<i32> = vec![];
|
||
let mut dry_elevs: Vec<i32> = vec![];
|
||
|
||
// Scan the full 64×64 chunk, not a single row — the deep-water trough axis is
|
||
// not guaranteed to intersect any fixed row, 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",
|
||
&district,
|
||
&chunk,
|
||
base_x + dx,
|
||
base_y + 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 district = make_region(
|
||
MorphologyZone::Delta,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::None,
|
||
3,
|
||
8,
|
||
25,
|
||
50,
|
||
Some(18.0),
|
||
VegetationClass::Scrub,
|
||
);
|
||
let mut checked = false;
|
||
// The braid belt sits on the district's channel anchor (T-1041) — sweep the
|
||
// anchor band so the law is exercised on real thread tiles.
|
||
let mut positions = vec![(0, 0), (1, 1)];
|
||
positions.extend(anchor_band_chunks(17, "delta_body", &district, (0, 0)));
|
||
for pos in positions {
|
||
checked |= assert_drainage_monotonicity(17, "delta_body", "BraidedDelta", &district, pos);
|
||
}
|
||
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,
|
||
district: &DistrictProfile,
|
||
chunk_pos: (i32, i32),
|
||
expected: TerrainMaterial,
|
||
label: &str,
|
||
) {
|
||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||
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, district, &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 district = make_region(
|
||
MorphologyZone::Volcanic,
|
||
TectonicClass::Volcanic,
|
||
GlaciationGrade::None,
|
||
15,
|
||
35,
|
||
0,
|
||
20,
|
||
Some(40.0),
|
||
VegetationClass::Barren,
|
||
);
|
||
assert_all_terrain_is(
|
||
42,
|
||
"GJ581c",
|
||
&district,
|
||
(3, 3),
|
||
TerrainMaterial::Lava,
|
||
"LavaField",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn law_lithology_fjord_emits_rock() {
|
||
let district = make_region(
|
||
MorphologyZone::Fjord,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::Moderate,
|
||
55,
|
||
60,
|
||
28,
|
||
55,
|
||
Some(-8.0),
|
||
VegetationClass::Barren,
|
||
);
|
||
assert_all_terrain_is(
|
||
42,
|
||
"fjord_body",
|
||
&district,
|
||
(0, 0),
|
||
TerrainMaterial::Rock,
|
||
"FjordWall",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn law_lithology_cliff_coast_emits_rock() {
|
||
let district = make_region(
|
||
MorphologyZone::CliffCoast,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::None,
|
||
60,
|
||
40,
|
||
20,
|
||
30,
|
||
Some(10.0),
|
||
VegetationClass::Scrub,
|
||
);
|
||
assert_all_terrain_is(
|
||
42,
|
||
"cliff_body",
|
||
&district,
|
||
(0, 0),
|
||
TerrainMaterial::Rock,
|
||
"CliffCoast",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn law_lithology_incised_gorge_emits_rock() {
|
||
// D-239 §8: IncisedGorge/MountainPass → TerrainMaterial::Rock.
|
||
let district = make_region(
|
||
MorphologyZone::MountainPass,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::None,
|
||
50,
|
||
65,
|
||
5,
|
||
40,
|
||
Some(5.0),
|
||
VegetationClass::Scrub,
|
||
);
|
||
assert_all_terrain_is(
|
||
42,
|
||
"gorge_body",
|
||
&district,
|
||
(0, 0),
|
||
TerrainMaterial::Rock,
|
||
"IncisedGorge",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn law_lithology_dune_strand_emits_sand() {
|
||
// D-239 §8: DuneStrand → TerrainMaterial::Sand (≤32° angle of repose).
|
||
let district = make_region(
|
||
MorphologyZone::DuneStrand,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
12,
|
||
15,
|
||
20,
|
||
25,
|
||
Some(22.0),
|
||
VegetationClass::Barren,
|
||
);
|
||
assert_all_terrain_is(
|
||
42,
|
||
"dune_body",
|
||
&district,
|
||
(0, 0),
|
||
TerrainMaterial::Sand,
|
||
"DuneStrand",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn law_lithology_braided_delta_emits_gravel() {
|
||
// D-239 §8: BraidedDelta (Gravel→braided channels/fans) → TerrainMaterial::Gravel.
|
||
let district = make_region(
|
||
MorphologyZone::Delta,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::None,
|
||
3,
|
||
8,
|
||
25,
|
||
50,
|
||
Some(18.0),
|
||
VegetationClass::Scrub,
|
||
);
|
||
assert_all_terrain_is(
|
||
42,
|
||
"delta_body",
|
||
&district,
|
||
(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 district = make_region(
|
||
MorphologyZone::AlluvialPlain,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
10,
|
||
25,
|
||
12,
|
||
45,
|
||
Some(16.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
assert_all_terrain_is(
|
||
42,
|
||
"alluvial_body",
|
||
&district,
|
||
(0, 0),
|
||
TerrainMaterial::Soil,
|
||
"AlluvialPlain",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn law_lithology_meander_reach_emits_soil() {
|
||
// D-239 §8: MeanderReach (Soil→rolling/floodplain) → TerrainMaterial::Soil.
|
||
let district = make_region(
|
||
MorphologyZone::MeanderReach,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
8,
|
||
18,
|
||
20,
|
||
55,
|
||
Some(14.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
assert_all_terrain_is(
|
||
42,
|
||
"meander_body",
|
||
&district,
|
||
(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,
|
||
false, // T-1184: no hydrology solve in this synthetic-gate sweep
|
||
);
|
||
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,
|
||
false, // T-1184: no hydrology solve in this synthetic-gate sweep
|
||
);
|
||
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,
|
||
false, // T-1184: no hydrology solve in this synthetic-gate test
|
||
);
|
||
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,
|
||
false, // T-1184: no hydrology solve in this synthetic-gate test
|
||
);
|
||
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<VegetationClass> = 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, 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<VegetationClass> = None;
|
||
for elev_q in (0i32..=100).step_by(5) {
|
||
let vc = derive_vegetation(temp, moisture_q, elev_q, false, 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<Option<f32>> = [
|
||
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<VegetationClass> = None;
|
||
for &temp in &temps_desc {
|
||
let vc = derive_vegetation(temp, moisture_q, elev_q, false, 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, false);
|
||
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, false);
|
||
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, false);
|
||
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, false);
|
||
assert_eq!(
|
||
vc,
|
||
VegetationClass::Absent,
|
||
"§8 vegetation: airless body must produce Absent, got {:?} \
|
||
(moisture_q={moisture_q}, elev_q={elev_q})",
|
||
vc
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// §2b — District-anchored feature placement (T-1040 / T-1041, D-239 §10)
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// T-1040: channel centrelines were anchored to the world x=0/y=0 axis — every
|
||
// chunk of a watered district claimed has_active_channel while channel voxels
|
||
// existed only near the world origin. T-1041: fjord/cliff/gorge/delta folded
|
||
// world coordinates into the 64 m chunk frame — district-scale landforms
|
||
// repeated every chunk. Both are fixed by district-anchored feature axes
|
||
// (`channel_anchor_m` / `coast_anchor_m`); these tests pin the placement
|
||
// contract far from the origin, at the ticket's example chunk (1000, −750).
|
||
|
||
#[test]
|
||
fn channel_present_in_active_chunks_far_from_origin() {
|
||
// T-1040 (a): a has_active_channel chunk at an arbitrary large world
|
||
// offset contains in-channel voxels — and the gate is honest in both
|
||
// directions (wet ⇒ gated, ungated ⇒ dry). Sweeps the 16 cross-columns of
|
||
// the district containing chunk (1000, −750) at that chunk's along index.
|
||
let district = make_region(
|
||
MorphologyZone::AlluvialPlain,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
5,
|
||
20,
|
||
18,
|
||
60,
|
||
Some(15.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
let (seed, body) = (42u64, "GJ144d");
|
||
let ns = basin_is_ns(seed, body, &district, (1000, -750));
|
||
let cross_base = if ns {
|
||
(1000 >> scale::CHUNK_DISTRICT_SHIFT) << scale::CHUNK_DISTRICT_SHIFT
|
||
} else {
|
||
(-750i32 >> scale::CHUNK_DISTRICT_SHIFT) << scale::CHUNK_DISTRICT_SHIFT
|
||
};
|
||
let mut any_gated_wet = false;
|
||
let mut any_gate_off = false;
|
||
let mut total_wet = 0usize;
|
||
for i in 0..scale::CHUNKS_PER_DISTRICT {
|
||
let pos: ChunkPos = if ns {
|
||
(cross_base + i, -750)
|
||
} else {
|
||
(1000, cross_base + i)
|
||
};
|
||
let chunk = derive_chunk_context(seed, body, &district, pos, None);
|
||
let (bx, by) = (pos.0 * 64, pos.1 * 64);
|
||
let mut wet = 0usize;
|
||
for dy in 0..64i32 {
|
||
for dx in 0..64i32 {
|
||
let col = derive_voxel_column(seed, body, &district, &chunk, bx + dx, by + dy);
|
||
if col.water != Water::Dry {
|
||
wet += 1;
|
||
}
|
||
}
|
||
}
|
||
total_wet += wet;
|
||
if chunk.has_active_channel {
|
||
any_gated_wet |= wet > 0;
|
||
} else {
|
||
any_gate_off = true;
|
||
assert_eq!(
|
||
wet, 0,
|
||
"T-1040: chunk {pos:?} has no active channel but contains {wet} wet voxels \
|
||
— the chunk gate and the voxel placement disagree"
|
||
);
|
||
}
|
||
}
|
||
assert!(
|
||
total_wet > 0,
|
||
"T-1040: the district at chunk (1000, −750) must contain channel voxels \
|
||
(pre-fix: zero — channels existed only near the world-origin axis)"
|
||
);
|
||
assert!(
|
||
any_gated_wet,
|
||
"T-1040: at least one has_active_channel chunk must contain in-channel voxels"
|
||
);
|
||
assert!(
|
||
any_gate_off,
|
||
"T-1040: the channel band must not blanket the district — some chunks must gate off"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn channel_continuous_across_chunk_boundary_far_from_origin() {
|
||
// T-1040 (a): channel position is continuous across adjacent chunk pairs.
|
||
// Every tile derives under its PRODUCTION covering chunk; the wet band's
|
||
// midpoint may not jump at a 64 m along-boundary (chunk-frame dependence
|
||
// would jump by up to a chunk width or drop out entirely).
|
||
let district = make_region(
|
||
MorphologyZone::AlluvialPlain,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
5,
|
||
20,
|
||
18,
|
||
60,
|
||
Some(15.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
let (seed, body) = (42u64, "GJ144d");
|
||
let probe = derive_chunk_context(seed, body, &district, (1000, -750), None);
|
||
let ns = matches!(
|
||
probe.basin_direction,
|
||
BasinDirection::North | BasinDirection::South
|
||
);
|
||
let anchor = probe.channel_anchor_m;
|
||
// Wet-band midpoint of one world cross-row, sampling the full swept band.
|
||
let row_mid = |along: i32| -> Option<i32> {
|
||
let wet: Vec<i32> = (anchor - 220..anchor + 220)
|
||
.filter(|&c| {
|
||
derive_at_cross_along(seed, body, &district, ns, c, along).water != Water::Dry
|
||
})
|
||
.collect();
|
||
wet.first().map(|f| (f + wet.last().unwrap()) / 2)
|
||
};
|
||
// An interior along-boundary of the district containing chunk (1000, −750):
|
||
// between along-chunks −744 and −743 (N/S) or 1004 and 1005 (E/W).
|
||
let boundary = if ns { -743 * 64 } else { 1005 * 64 };
|
||
let mut prev: Option<i32> = None;
|
||
for along in boundary - 4..boundary + 4 {
|
||
let mid = row_mid(along).unwrap_or_else(|| {
|
||
panic!(
|
||
"T-1040: row along={along} contains no wet tiles — the channel \
|
||
dropped out at the chunk boundary {boundary}"
|
||
)
|
||
});
|
||
if let Some(p) = prev {
|
||
assert!(
|
||
(mid - p).abs() <= 12,
|
||
"T-1040: wet-band midpoint jumped {} m between adjacent rows \
|
||
{} and {} (boundary {boundary}) — channel is not continuous",
|
||
(mid - p).abs(),
|
||
along - 1,
|
||
along
|
||
);
|
||
}
|
||
prev = Some(mid);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// T-1042 — Cross-district parameter blending at chunk/voxel scale
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Acceptance criteria (from the ticket brief):
|
||
// 1. Elevation step across a district seam ≤ typical step between adjacent
|
||
// interior chunks of the same district (seam is invisible in practice).
|
||
// 2. Morphology family seams remain sharp (no blending of family selection).
|
||
// 3. Golden-seed determinism unchanged for chunks far from any district border.
|
||
//
|
||
// The test constructs two adjacent AlluvialPlain districts with a significant
|
||
// `elev_q` contrast (20 vs 70) and measures:
|
||
// - Average elevation of the last chunk of district A (using blend toward B).
|
||
// - Average elevation of the last+1 chunk, which is the first chunk of district
|
||
// B (no blend — it reads cleanly from district B).
|
||
// - Average elevation of a pure interior chunk in district A (far from any seam).
|
||
// - Average elevation of a pure interior chunk in district B (far from any seam).
|
||
//
|
||
// Pass condition: the seam step (last-of-A vs first-of-B) ≤ typical interior
|
||
// step (interior-A vs interior-B), because the blend reduces the apparent jump.
|
||
// We also confirm that chunks far from any boundary match exact unblended output
|
||
// (golden-seed determinism preserved, T-1042 acceptance criterion 3).
|
||
|
||
#[test]
|
||
fn cross_district_elevation_blend_reduces_seam_step() {
|
||
// Two AlluvialPlain districts with a large elev_q contrast to make the
|
||
// seam measurable. Chose distinct seeds so the morphology family stays
|
||
// AlluvialPlain for both (gates trivially satisfied at grade=0, stable).
|
||
let district_a = make_region(
|
||
MorphologyZone::AlluvialPlain,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
5, // slope_q
|
||
20, // elev_q — low
|
||
15, // ocean_fraction_q (water present for a channel)
|
||
55, // moisture_q
|
||
Some(15.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
let district_b = make_region(
|
||
MorphologyZone::AlluvialPlain,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
5, // slope_q
|
||
70, // elev_q — high (50-unit contrast with A)
|
||
15, // ocean_fraction_q
|
||
55, // moisture_q
|
||
Some(15.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
|
||
let (seed, body) = (42u64, "blend_test_body");
|
||
|
||
// District A occupies chunk columns [0, 31]; district B = [32, 63].
|
||
// CHUNKS_PER_DISTRICT = 32.
|
||
//
|
||
// The LAST chunk of district A: chunk x=31 (within-district index 31 =
|
||
// CHUNKS_PER_DISTRICT-1). `district_boundary_blend_weight` returns
|
||
// (true, 128) for this position — a 50-50 blend with district B.
|
||
//
|
||
// The FIRST chunk of district B: chunk x=32 (within-district index 0).
|
||
// `district_boundary_blend_weight` returns (false, 255) — no blend.
|
||
let last_a_chunk: ChunkPos = (31, 0);
|
||
let first_b_chunk: ChunkPos = (32, 0);
|
||
// Interior: well inside district A and B, far from any district boundary.
|
||
let interior_a_chunk: ChunkPos = (15, 0);
|
||
let interior_b_chunk: ChunkPos = (48, 0);
|
||
|
||
// Derive boundary detection for the last-A chunk.
|
||
let (near_boundary, blend_w) = district_boundary_blend_weight(last_a_chunk);
|
||
assert!(
|
||
near_boundary,
|
||
"T-1042: chunk {:?} must be detected as near a district boundary",
|
||
last_a_chunk
|
||
);
|
||
assert_eq!(
|
||
blend_w, 128,
|
||
"T-1042: boundary blend weight must be 128 (50-50)"
|
||
);
|
||
|
||
// Interior-A context retained for the determinism sub-check (Criterion 3).
|
||
let ctx_interior_a = derive_chunk_context(seed, body, &district_a, interior_a_chunk, None);
|
||
|
||
// Average elevation over a tall multi-wavelength y-transect at a given x-column,
|
||
// deriving a ChunkContext per y-chunk. T-1081 adds a zero-mean voxel-relief term to
|
||
// elevation_m; averaged over ≥4× the coarsest relief wavelength (1024 m) it cancels,
|
||
// leaving the elev_q-derived base — the quantity T-1042's blend actually smooths. A
|
||
// single 64 m row would carry a per-chunk relief offset that swamps the seam signal.
|
||
let avg_elev =
|
||
|x_chunk: i32, blend: Option<(&DistrictProfile, u8)>, dist: &DistrictProfile| -> i64 {
|
||
const Y_CHUNKS: i32 = 128; // 8192 m ≈ 8× the 1024 m coarsest relief octave
|
||
let base_x = x_chunk * scale::CHUNK_M;
|
||
let mut sum = 0i64;
|
||
let mut n = 0i64;
|
||
for yc in 0..Y_CHUNKS {
|
||
let cp: ChunkPos = (x_chunk, yc);
|
||
let ctx = derive_chunk_context(seed, body, dist, cp, blend);
|
||
let base_y = yc * scale::CHUNK_M;
|
||
for dx in (0..scale::VOXELS_PER_CHUNK).step_by(8) {
|
||
for dy in (0..scale::VOXELS_PER_CHUNK).step_by(8) {
|
||
let col =
|
||
derive_voxel_column(seed, body, dist, &ctx, base_x + dx, base_y + dy);
|
||
sum += col.elevation_m as i64;
|
||
n += 1;
|
||
}
|
||
}
|
||
}
|
||
sum / n
|
||
};
|
||
|
||
// Force the 50-50 blend with B across the whole last-A column (x=31); the interiors
|
||
// and first-B column are unblended (per the boundary detection asserted above).
|
||
let elev_last_a = avg_elev(last_a_chunk.0, Some((&district_b, blend_w)), &district_a);
|
||
let elev_first_b = avg_elev(first_b_chunk.0, None, &district_b);
|
||
let elev_interior_a = avg_elev(interior_a_chunk.0, None, &district_a);
|
||
let elev_interior_b = avg_elev(interior_b_chunk.0, None, &district_b);
|
||
|
||
// Seam step = elevation gap between the blended last-A chunk and the clean first-B chunk.
|
||
let seam_step = (elev_last_a - elev_first_b).unsigned_abs() as i64;
|
||
// Unblended step = elevation gap between pure interior chunks.
|
||
let interior_step = (elev_interior_a - elev_interior_b).unsigned_abs() as i64;
|
||
|
||
// Criterion 1: the seam step must be strictly less than the interior step.
|
||
// The blend reduces the apparent jump — if blending were absent the seam
|
||
// step would equal the interior step (both districts differ by 50 elev_q units).
|
||
assert!(
|
||
seam_step < interior_step,
|
||
"T-1042: cross-district seam step ({seam_step} m) must be < unblended \
|
||
interior step ({interior_step} m) — blend is not reducing the seam"
|
||
);
|
||
|
||
// Criterion 3: interior chunks produce IDENTICAL output to an unblended context.
|
||
// `ctx_interior_a` has blend_weight=255, secondary=None — same as the
|
||
// pre-T-1042 path. Derive twice; must match.
|
||
let ctx_interior_a2 = derive_chunk_context(seed, body, &district_a, interior_a_chunk, None);
|
||
for dx in 0..scale::VOXELS_PER_CHUNK {
|
||
let base_x = interior_a_chunk.0 * scale::CHUNK_M;
|
||
let base_y = interior_a_chunk.1 * scale::CHUNK_M;
|
||
let col1 = derive_voxel_column(
|
||
seed,
|
||
body,
|
||
&district_a,
|
||
&ctx_interior_a,
|
||
base_x + dx,
|
||
base_y,
|
||
);
|
||
let col2 = derive_voxel_column(
|
||
seed,
|
||
body,
|
||
&district_a,
|
||
&ctx_interior_a2,
|
||
base_x + dx,
|
||
base_y,
|
||
);
|
||
assert_eq!(
|
||
col1.elevation_m, col2.elevation_m,
|
||
"T-1042: interior chunk elevation must be deterministic across two derivations \
|
||
at voxel offset {dx}"
|
||
);
|
||
assert_eq!(
|
||
col1.terrain, col2.terrain,
|
||
"T-1042: interior chunk terrain must be deterministic at voxel offset {dx}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cross_district_morphology_family_seams_stay_sharp() {
|
||
// T-1042 Criterion 2: morphology family is NEVER blended across a district
|
||
// boundary (D-239 §7). An AlluvialPlain district adjacent to a FjordWall
|
||
// district must produce strictly AlluvialPlain (Soil terrain) in the
|
||
// last chunk of the alluvial district, even at 50-50 blend weight.
|
||
//
|
||
// The secondary district (FjordWall) has Rock terrain; the primary (Alluvial)
|
||
// has Soil. After blending, if family selection were inadvertently reading
|
||
// the secondary's zone, some tiles would switch to Rock — catch that here.
|
||
let district_alluvial = make_region(
|
||
MorphologyZone::AlluvialPlain,
|
||
TectonicClass::Stable,
|
||
GlaciationGrade::None,
|
||
5,
|
||
20,
|
||
0, // no channel — simpler tile layout for a clean terrain check
|
||
40,
|
||
Some(15.0),
|
||
VegetationClass::Forest,
|
||
);
|
||
let district_fjord = make_region(
|
||
MorphologyZone::Fjord,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::Moderate,
|
||
55,
|
||
60,
|
||
28,
|
||
55,
|
||
Some(-8.0),
|
||
VegetationClass::Barren,
|
||
);
|
||
|
||
let (seed, body) = (99u64, "seam_sharp_test");
|
||
// Last chunk of the alluvial district — blend with the fjord at 50-50.
|
||
let boundary_chunk: ChunkPos = (31, 0);
|
||
let (_, blend_w) = district_boundary_blend_weight(boundary_chunk);
|
||
let ctx = derive_chunk_context(
|
||
seed,
|
||
body,
|
||
&district_alluvial,
|
||
boundary_chunk,
|
||
Some((&district_fjord, blend_w)),
|
||
);
|
||
|
||
// Every voxel in this chunk must have Soil terrain (AlluvialPlain primary family).
|
||
// If family dispatch accidentally picked up the secondary (FjordWall → Rock),
|
||
// this assertion fails.
|
||
let base_x = boundary_chunk.0 * scale::CHUNK_M;
|
||
let base_y = boundary_chunk.1 * scale::CHUNK_M;
|
||
for dy in (0..scale::VOXELS_PER_CHUNK).step_by(8) {
|
||
for dx in (0..scale::VOXELS_PER_CHUNK).step_by(8) {
|
||
let col = derive_voxel_column(
|
||
seed,
|
||
body,
|
||
&district_alluvial,
|
||
&ctx,
|
||
base_x + dx,
|
||
base_y + dy,
|
||
);
|
||
assert_eq!(
|
||
col.terrain,
|
||
TerrainMaterial::Soil,
|
||
"T-1042 §7 VIOLATED: cross-district boundary chunk must keep primary \
|
||
morphology (AlluvialPlain→Soil) at voxel ({},{}) — got {:?}",
|
||
base_x + dx,
|
||
base_y + dy,
|
||
col.terrain
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn fjord_district_has_one_valley_spanning_chunks() {
|
||
// T-1041 (b): a FjordWall district contains ONE deep-water trough spanning
|
||
// its chunks — pre-fix a complete fjord cross-section repeated in every
|
||
// 64 m chunk. Representative sweep: two far districts × three along rows.
|
||
let district = make_region(
|
||
MorphologyZone::Fjord,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::Moderate,
|
||
55,
|
||
60,
|
||
28,
|
||
55,
|
||
Some(-8.0),
|
||
VegetationClass::Barren,
|
||
);
|
||
let (seed, body) = (42u64, "fjord_body");
|
||
for district_chunk in [(640, -480), (-336, 992)] {
|
||
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
|
||
let ns = matches!(
|
||
probe.basin_direction,
|
||
BasinDirection::North | BasinDirection::South
|
||
);
|
||
let (cross_chunk, along_chunk) = if ns {
|
||
(district_chunk.0, district_chunk.1)
|
||
} else {
|
||
(district_chunk.1, district_chunk.0)
|
||
};
|
||
let cross_base = (cross_chunk >> scale::CHUNK_DISTRICT_SHIFT) * scale::DISTRICT_M;
|
||
let along_base = (along_chunk >> scale::CHUNK_DISTRICT_SHIFT) * scale::DISTRICT_M;
|
||
let positions: Vec<i32> = (cross_base..cross_base + scale::DISTRICT_M).collect();
|
||
for along in [
|
||
along_base + 32,
|
||
along_base + scale::DISTRICT_M / 2,
|
||
along_base + scale::DISTRICT_M - 32,
|
||
] {
|
||
let clusters = count_feature_clusters(
|
||
&positions,
|
||
|c| derive_at_cross_along(seed, body, &district, ns, c, along).water == Water::Deep,
|
||
16,
|
||
);
|
||
assert_eq!(
|
||
clusters, 1,
|
||
"T-1041: FjordWall district {district_chunk:?} must contain exactly ONE \
|
||
deep-water trough across its 2048 m cross extent at along={along} \
|
||
(got {clusters}; pre-fix: one per 64 m chunk)"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn gorge_district_has_one_valley_spanning_chunks() {
|
||
// T-1041 (b): an IncisedGorge district contains ONE shallow-floor gorge
|
||
// spanning its chunks — not one per chunk.
|
||
let district = make_region(
|
||
MorphologyZone::MountainPass,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::None,
|
||
50,
|
||
65,
|
||
5,
|
||
40,
|
||
Some(5.0),
|
||
VegetationClass::Scrub,
|
||
);
|
||
let (seed, body) = (42u64, "gorge_body");
|
||
for district_chunk in [(640, -480), (-336, 992)] {
|
||
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
|
||
let ns = matches!(
|
||
probe.basin_direction,
|
||
BasinDirection::North | BasinDirection::South
|
||
);
|
||
let (cross_chunk, along_chunk) = if ns {
|
||
(district_chunk.0, district_chunk.1)
|
||
} else {
|
||
(district_chunk.1, district_chunk.0)
|
||
};
|
||
let cross_base = (cross_chunk >> scale::CHUNK_DISTRICT_SHIFT) * scale::DISTRICT_M;
|
||
let along_base = (along_chunk >> scale::CHUNK_DISTRICT_SHIFT) * scale::DISTRICT_M;
|
||
let positions: Vec<i32> = (cross_base..cross_base + scale::DISTRICT_M).collect();
|
||
for along in [
|
||
along_base + 32,
|
||
along_base + scale::DISTRICT_M / 2,
|
||
along_base + scale::DISTRICT_M - 32,
|
||
] {
|
||
let clusters = count_feature_clusters(
|
||
&positions,
|
||
|c| {
|
||
derive_at_cross_along(seed, body, &district, ns, c, along).water
|
||
== Water::Shallow
|
||
},
|
||
16,
|
||
);
|
||
assert_eq!(
|
||
clusters, 1,
|
||
"T-1041: IncisedGorge district {district_chunk:?} must contain exactly ONE \
|
||
shallow gorge floor across its 2048 m cross extent at along={along} \
|
||
(got {clusters}; pre-fix: one per 64 m chunk)"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cliff_coast_one_continuous_coastline_per_region() {
|
||
// T-1041 (b): a CliffCoast district has ONE continuous (warp-displaced)
|
||
// coast line on the district-anchored face — pre-fix the cliff face sat at
|
||
// intra-chunk offset 48–55 in every chunk, sawtoothing the coast at 64 m
|
||
// pitch. Transect runs along the seaward (basin) axis across the district.
|
||
let district = make_region(
|
||
MorphologyZone::CliffCoast,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::None,
|
||
60,
|
||
40,
|
||
20,
|
||
30,
|
||
Some(10.0),
|
||
VegetationClass::Scrub,
|
||
);
|
||
let (seed, body) = (42u64, "cliff_body");
|
||
for district_chunk in [(656, -464), (-256, 768)] {
|
||
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
|
||
let coast = probe.coast_anchor_m;
|
||
let ns = matches!(
|
||
probe.basin_direction,
|
||
BasinDirection::North | BasinDirection::South
|
||
);
|
||
let (cross_chunk, along_chunk) = if ns {
|
||
(district_chunk.0, district_chunk.1)
|
||
} else {
|
||
(district_chunk.1, district_chunk.0)
|
||
};
|
||
let along_base = (along_chunk >> scale::CHUNK_DISTRICT_SHIFT) * scale::DISTRICT_M;
|
||
let cross_fixed = cross_chunk * 64 + 32;
|
||
let positions: Vec<i32> = (along_base..along_base + scale::DISTRICT_M).collect();
|
||
// Exactly one ocean cluster (the seaward side of the one coast line).
|
||
let clusters = count_feature_clusters(
|
||
&positions,
|
||
|a| {
|
||
derive_at_cross_along(seed, body, &district, ns, cross_fixed, a).water
|
||
== Water::Deep
|
||
},
|
||
16,
|
||
);
|
||
assert_eq!(
|
||
clusters, 1,
|
||
"T-1041: CliffCoast district {district_chunk:?} must have exactly ONE ocean \
|
||
side (got {clusters} Deep clusters; pre-fix: one 64 m sawtooth per chunk)"
|
||
);
|
||
// Monotone coast: well inland of the face line → Dry; well seaward →
|
||
// Deep. Margins absorb the ±8 m domain warp (face 0..8, ledge 8..13).
|
||
for &a in &positions {
|
||
// Signed seaward distance — mirrors generate_cliff_coast.
|
||
let d = match probe.basin_direction {
|
||
BasinDirection::North | BasinDirection::West => coast - a,
|
||
BasinDirection::South | BasinDirection::East => a - coast,
|
||
};
|
||
let col = derive_at_cross_along(seed, body, &district, ns, cross_fixed, a);
|
||
if d <= -9 {
|
||
assert_eq!(
|
||
col.water,
|
||
Water::Dry,
|
||
"T-1041: tile {} m inland of the coast line must be Dry (along={a})",
|
||
-d
|
||
);
|
||
} else if d >= 21 {
|
||
assert_eq!(
|
||
col.water,
|
||
Water::Deep,
|
||
"T-1041: tile {d} m seaward of the coast line must be Deep (along={a})"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn braided_threads_confined_to_region_belt() {
|
||
// T-1041: braided threads anastomose across the district-anchored fan belt
|
||
// (anchor ± 32 m + thread width + warp) — pre-fix the same three threads
|
||
// restarted in every 64 m chunk, spreading thread water across the whole
|
||
// district's cross extent.
|
||
let district = make_region(
|
||
MorphologyZone::Delta,
|
||
TectonicClass::Active,
|
||
GlaciationGrade::None,
|
||
3,
|
||
8,
|
||
25,
|
||
50,
|
||
Some(18.0),
|
||
VegetationClass::Scrub,
|
||
);
|
||
let (seed, body) = (17u64, "delta_body");
|
||
for district_chunk in [(800, -592)] {
|
||
let probe = derive_chunk_context(seed, body, &district, district_chunk, None);
|
||
let ns = matches!(
|
||
probe.basin_direction,
|
||
BasinDirection::North | BasinDirection::South
|
||
);
|
||
let anchor = probe.channel_anchor_m;
|
||
let (cross_chunk, along_chunk) = if ns {
|
||
(district_chunk.0, district_chunk.1)
|
||
} else {
|
||
(district_chunk.1, district_chunk.0)
|
||
};
|
||
let cross_base = (cross_chunk >> scale::CHUNK_DISTRICT_SHIFT) * scale::DISTRICT_M;
|
||
let along = along_chunk * 64 + 32;
|
||
let wet: Vec<i32> = (cross_base..cross_base + scale::DISTRICT_M)
|
||
.filter(|&c| {
|
||
derive_at_cross_along(seed, body, &district, ns, c, along).water == Water::Shallow
|
||
})
|
||
.collect();
|
||
assert!(
|
||
!wet.is_empty(),
|
||
"T-1041: BraidedDelta district {district_chunk:?} must contain thread water"
|
||
);
|
||
// Belt confinement: thread centres ∈ anchor ± 32, half-width ≤ 4,
|
||
// warp ≤ 8 → all thread water within anchor ± 44.
|
||
for &c in &wet {
|
||
assert!(
|
||
(c - anchor).abs() <= 44,
|
||
"T-1041: thread water at cross={c} is {} m from the fan axis {anchor} \
|
||
— outside the district belt (pre-fix: threads repeated every chunk)",
|
||
(c - anchor).abs()
|
||
);
|
||
}
|
||
// Braided, not single-thread: 1–3 thread clusters within the belt
|
||
// (three threads, possibly merged where centres overlap; warp-scale
|
||
// gap tolerance — the ±8 m warp punches small holes in a thread).
|
||
let cluster_count = count_feature_clusters(&wet, |_| true, 8);
|
||
assert!(
|
||
(1..=3).contains(&cluster_count),
|
||
"T-1041: expected 1–3 braided thread clusters in the belt, got {cluster_count}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// §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,
|
||
district: &DistrictProfile,
|
||
chunk_pos: (i32, i32),
|
||
) -> (usize, u128) {
|
||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||
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, district, &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, DistrictProfile)> {
|
||
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, district) in budget_families() {
|
||
let (cnt, us) = derive_chunk_timed(seed, body_id, &district, (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 district = 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, &district, chunk_pos, None);
|
||
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, &district, &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, &district, 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()),
|
||
latitude_deg: 0.0,
|
||
elevation_km: 0.0,
|
||
body_radius_km: None,
|
||
}
|
||
}
|
||
|
||
/// 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()),
|
||
latitude_deg: 0.0,
|
||
elevation_km: 0.5,
|
||
body_radius_km: None,
|
||
}
|
||
}
|
||
|
||
/// 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()),
|
||
latitude_deg: 0.0,
|
||
elevation_km: 0.0,
|
||
body_radius_km: None,
|
||
}
|
||
}
|
||
|
||
/// Derive a DistrictProfile for a body at given latitude/elevation,
|
||
/// using the full derive_district_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) -> DistrictProfile {
|
||
let climate = ClimateConstants::default();
|
||
let seed = SeedChain::root(42).derive(SeedDomain::Body, 1);
|
||
let ta = make_dry_terrain_analysis();
|
||
derive_district_profile(
|
||
seed,
|
||
params,
|
||
&ta,
|
||
scale::SurveyCellPos(0, 0),
|
||
8,
|
||
&climate,
|
||
"test_body",
|
||
&BTreeMap::new(),
|
||
settled_reach_server::atlas::scale::BasinDirection::default(),
|
||
None,
|
||
)
|
||
}
|
||
|
||
#[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), None);
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Whether the district's basin runs north/south (cross axis = x). District-scale
|
||
/// property — identical for every chunk of the district containing `district_chunk`.
|
||
fn basin_is_ns(
|
||
seed: u64,
|
||
body_id: &str,
|
||
district: &DistrictProfile,
|
||
district_chunk: ChunkPos,
|
||
) -> bool {
|
||
let probe = derive_chunk_context(seed, body_id, district, district_chunk, None);
|
||
matches!(
|
||
probe.basin_direction,
|
||
BasinDirection::North | BasinDirection::South
|
||
)
|
||
}
|
||
|
||
/// The chunk positions of the channel-anchor band (anchor column ± 1) across
|
||
/// the full along-extent of the district containing `district_chunk` (T-1040).
|
||
///
|
||
/// The channel/landform centreline is district-anchored: it lives in the anchor
|
||
/// chunk column, swinging up to one meander amplitude sideways. The meander
|
||
/// wavelength (≤ ~650 m) fits inside the district's 2048 m along-extent, so the
|
||
/// centreline crosses the anchor column at least once — sweeping this band
|
||
/// guarantees wet tiles are exercised somewhere in it.
|
||
fn anchor_band_chunks(
|
||
seed: u64,
|
||
body_id: &str,
|
||
district: &DistrictProfile,
|
||
district_chunk: ChunkPos,
|
||
) -> Vec<ChunkPos> {
|
||
let probe = derive_chunk_context(seed, body_id, district, district_chunk, None);
|
||
let anchor_idx = probe.channel_anchor_m.div_euclid(64);
|
||
let ns = matches!(
|
||
probe.basin_direction,
|
||
BasinDirection::North | BasinDirection::South
|
||
);
|
||
// District base index on the along axis (16-chunk districts; arithmetic shift
|
||
// = floor division, correct for negative chunks).
|
||
let along_base = if ns {
|
||
(district_chunk.1 >> scale::CHUNK_DISTRICT_SHIFT) << scale::CHUNK_DISTRICT_SHIFT
|
||
} else {
|
||
(district_chunk.0 >> scale::CHUNK_DISTRICT_SHIFT) << scale::CHUNK_DISTRICT_SHIFT
|
||
};
|
||
let mut out = Vec::new();
|
||
for cross in anchor_idx - 1..=anchor_idx + 1 {
|
||
for j in along_base..along_base + 16 {
|
||
out.push(if ns { (cross, j) } else { (j, cross) });
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Derive one voxel at world cross/along coordinates under the PRODUCTION
|
||
/// covering chunk's context (per-tile chunk lookup — exactly what a consumer
|
||
/// streaming the world does). Basis-aware: cross ⊥ basin, along ∥ basin.
|
||
fn derive_at_cross_along(
|
||
seed: u64,
|
||
body_id: &str,
|
||
district: &DistrictProfile,
|
||
ns: bool,
|
||
cross: i32,
|
||
along: i32,
|
||
) -> settled_reach_server::atlas::voxel::VoxelColumn {
|
||
let (tx, ty) = if ns { (cross, along) } else { (along, cross) };
|
||
let chunk_pos = (tx.div_euclid(64), ty.div_euclid(64));
|
||
let chunk = derive_chunk_context(seed, body_id, district, chunk_pos, None);
|
||
derive_voxel_column(seed, body_id, district, &chunk, tx, ty)
|
||
}
|
||
|
||
/// Count clusters of positions where `pred` holds across a cross/along
|
||
/// transect, merging runs separated by gaps ≤ `gap_tolerance` (domain-warp
|
||
/// jitter can fragment a single feature by a few metres; distinct per-chunk
|
||
/// repeats are ≥ ~50 m apart and never merge).
|
||
fn count_feature_clusters(
|
||
positions: &[i32],
|
||
hits: impl Fn(i32) -> bool,
|
||
gap_tolerance: i32,
|
||
) -> usize {
|
||
let mut clusters = 0usize;
|
||
let mut last_hit: Option<i32> = None;
|
||
for &p in positions {
|
||
if hits(p) {
|
||
match last_hit {
|
||
Some(prev) if p - prev <= gap_tolerance => {}
|
||
_ => clusters += 1,
|
||
}
|
||
last_hit = Some(p);
|
||
}
|
||
}
|
||
clusters
|
||
}
|
||
|
||
/// Construct a `DistrictProfile` 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<f32>,
|
||
vegetation_class: VegetationClass,
|
||
) -> DistrictProfile {
|
||
let precip = derive_precipitation_class_from_climate(temperature_c, moisture_q);
|
||
DistrictProfile {
|
||
morphology_zone: zone,
|
||
tectonic_class: tectonic,
|
||
glaciation_grade: glaciation,
|
||
precipitation_class: precip,
|
||
slope_q,
|
||
elev_q,
|
||
ocean_fraction_q,
|
||
lake_margin_q: 0,
|
||
river_threshold: derive_river_threshold(tectonic, precip),
|
||
temperature_c,
|
||
moisture_q,
|
||
vegetation_class,
|
||
basin_direction: settled_reach_server::atlas::scale::BasinDirection::default(),
|
||
}
|
||
}
|
||
|
||
/// 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 district 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<f32> = 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)
|
||
}
|