feat(simulation): wire region climate layer into the cascade + proxy (T-1113, D-243)
CascadeLayer::Region appended per the enum's append-only Ord rule (depends only on seed/body_params/heightmap dims — documented); BodyWorldState.regions BTreeMap per the districts precedent; RegionGridLayer dense row-major all-integer encoding (season/weather repr(u8) discriminants, mean_temp deci-degC i16 with i16::MIN airless sentinel, moisture_q u8); build_region_grid mirrors build_district_grid; protocol.gd region_grid passthrough (visual overlay deliberately out of scope); wire fixtures regenerated via make fixtures. atlas:: suite 507 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+147
-1
@@ -28,8 +28,9 @@ use crate::atlas::district_profile::{self, BodyParams, DistrictPos, DistrictProf
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||||
use crate::atlas::layer1::{self, Layer1Output};
|
||||
use crate::atlas::region_profile::{self, RegionProfile};
|
||||
use crate::atlas::road_graph::{self, RoadGraph};
|
||||
use crate::atlas::scale;
|
||||
use crate::atlas::scale::{self, RegionPos};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
|
||||
|
||||
@@ -57,6 +58,16 @@ pub enum CascadeLayer {
|
||||
/// last** to honour the append-only `Ord` rule (it neither needs nor blocks
|
||||
/// the DistrictProfile layer; requesting it runs DistrictProfile first, harmlessly).
|
||||
RoadGraph,
|
||||
/// Region climate layer (~205 km cells, D-243 §3, T-1113). Pure function of
|
||||
/// `(seed, body_params, heightmap dims)` — the region baselines the district
|
||||
/// layer already derives internally (and discards) are RETAINED here as
|
||||
/// their own layer output for the Atlas. Semantically the climate context
|
||||
/// *above* districts, but **appended last** per the append-only `Ord` rule
|
||||
/// (the RoadGraph precedent): it depends on no other layer, so requesting
|
||||
/// it runs the earlier layers first, harmlessly. The cheap double-derive
|
||||
/// (district blend cache + this layer) is deliberate — one layer, one
|
||||
/// concern, no cache plumbing between layers.
|
||||
Region,
|
||||
}
|
||||
|
||||
/// Output of the cascade for one body, up to the requested layer (#952).
|
||||
@@ -81,6 +92,9 @@ pub struct CascadeSnapshot {
|
||||
/// Layer 2 — inter-settlement road/rail graph. `Some` once
|
||||
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
|
||||
pub road_graph: Option<RoadGraph>,
|
||||
/// Region climate layer — ~205 km climate-context cells. `Some` once
|
||||
/// [`CascadeLayer::Region`] has run (D-243 §3, T-1113).
|
||||
pub layer_region: Option<LayerRegionOutput>,
|
||||
/// **Transient** — the `TerrainAnalysis` produced by the Layer-1 drainage
|
||||
/// pass (T-1044). Populated when Layer 1 runs; consumed (and freed) once
|
||||
/// both `DistrictProfile` and `RoadGraph` have consumed it.
|
||||
@@ -98,6 +112,19 @@ pub struct LayerDistrictOutput {
|
||||
pub districts: std::collections::BTreeMap<DistrictPos, DistrictProfile>,
|
||||
}
|
||||
|
||||
/// Region climate layer output (T-1113, D-243 §3): per-region (~205 km) climate
|
||||
/// context covering the body's district grid. Stored in `BodyWorldState.regions`.
|
||||
///
|
||||
/// The set is the **covering grid only** — the regions whose districts tile the
|
||||
/// body, with no ±1 neighbour padding. (The district layer's internal region
|
||||
/// cache pads a neighbour ring because its edge-fuzz blend samples across
|
||||
/// boundaries; that padding is a blend implementation detail, not part of the
|
||||
/// body's own region grid, and a dense Atlas wire encoding wants exact dims.)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LayerRegionOutput {
|
||||
pub regions: std::collections::BTreeMap<RegionPos, RegionProfile>,
|
||||
}
|
||||
|
||||
/// Layer 3 output (#955, D-211): attractor-matched settlement placements for the
|
||||
/// body. Re-derivable from (Layer-1 attractors + settlement records + seed).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -123,6 +150,7 @@ impl CascadeSnapshot {
|
||||
.layer_district
|
||||
.map(|lr| lr.districts)
|
||||
.unwrap_or_default();
|
||||
let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
|
||||
let road_graph = self.road_graph.unwrap_or_default();
|
||||
// terrain_analysis (transient) is intentionally dropped here.
|
||||
let _ = self.terrain_analysis;
|
||||
@@ -138,6 +166,7 @@ impl CascadeSnapshot {
|
||||
road_graph,
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
districts,
|
||||
regions,
|
||||
last_accessed: 0,
|
||||
}
|
||||
}
|
||||
@@ -203,6 +232,7 @@ pub fn run_cascade_from_heightmap(
|
||||
layer3: None,
|
||||
layer_district: None,
|
||||
road_graph: None,
|
||||
layer_region: None,
|
||||
terrain_analysis: None,
|
||||
};
|
||||
|
||||
@@ -330,6 +360,42 @@ pub fn run_cascade_from_heightmap(
|
||||
snapshot.terrain_analysis = None;
|
||||
}
|
||||
|
||||
// Region climate layer (D-243 §3, T-1113) — the ~205 km climate-context
|
||||
// cells the district blend already derives internally, retained as their
|
||||
// own layer output. Pure function of (seed, body_params, heightmap dims):
|
||||
// no TerrainAnalysis needed, so it runs outside the transient-borrow block
|
||||
// above. Gates on body_params like the DistrictProfile layer (no params →
|
||||
// no climate inputs → the layer skips, `regions` stays empty).
|
||||
if up_to >= CascadeLayer::Region {
|
||||
if let Some(params) = body_params {
|
||||
// The covering region grid: the same district dims the district
|
||||
// layer computes (heightmap dims ÷ cells-per-district), mapped up
|
||||
// to region cells — WITHOUT the ±1 neighbour padding the district
|
||||
// blend cache adds (see LayerRegionOutput's doc).
|
||||
let gcpr = scale::HEIGHTMAP_CELLS_PER_DISTRICT.max(1);
|
||||
let district_cols = (snapshot.heightmap.width as usize).div_ceil(gcpr) as i32;
|
||||
let district_rows = (snapshot.heightmap.height as usize).div_ceil(gcpr) as i32;
|
||||
let max_region = scale::district_to_region((
|
||||
district_cols.saturating_sub(1),
|
||||
district_rows.saturating_sub(1),
|
||||
));
|
||||
let mut region_positions: Vec<RegionPos> = Vec::new();
|
||||
for ry in 0..=max_region.1 {
|
||||
for rx in 0..=max_region.0 {
|
||||
region_positions.push((rx, ry));
|
||||
}
|
||||
}
|
||||
let climate = district_profile::ClimateConstants::default();
|
||||
let regions = region_profile::derive_regions_for_body(
|
||||
body_seed,
|
||||
params,
|
||||
&climate,
|
||||
region_positions,
|
||||
);
|
||||
snapshot.layer_region = Some(LayerRegionOutput { regions });
|
||||
}
|
||||
}
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
@@ -461,6 +527,7 @@ mod tests {
|
||||
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
|
||||
assert!(CascadeLayer::Settlement < CascadeLayer::DistrictProfile);
|
||||
assert!(CascadeLayer::DistrictProfile < CascadeLayer::RoadGraph);
|
||||
assert!(CascadeLayer::RoadGraph < CascadeLayer::Region);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -794,4 +861,83 @@ mod tests {
|
||||
// Silence unused-import warning when the filter above changes.
|
||||
let _ = RoadNodeKind::Settlement;
|
||||
}
|
||||
|
||||
/// Region climate layer (T-1113, D-243 §3): runs as the cascade terminal,
|
||||
/// populates `BodyWorldState.regions` with the covering region grid, gates
|
||||
/// on body_params like the DistrictProfile layer, and is deterministic.
|
||||
#[test]
|
||||
fn region_layer_populates_regions_deterministically() {
|
||||
use crate::atlas::district_profile::BodyParams;
|
||||
|
||||
let params = BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let run = || {
|
||||
run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
None,
|
||||
Some(¶ms),
|
||||
CascadeLayer::Region,
|
||||
)
|
||||
};
|
||||
let snap = run();
|
||||
let lr = snap.layer_region.as_ref().expect("Region layer ran");
|
||||
// 64×32 working grid → 8×4 districts → a single covering region at
|
||||
// (0,0) (100 districts per region side — the working grid is far
|
||||
// inside one region cell today; the D-243 elastic seam grows this).
|
||||
assert_eq!(lr.regions.len(), 1, "one covering region on the test grid");
|
||||
let profile = lr.regions.get(&(0, 0)).expect("region (0,0) present");
|
||||
assert!(
|
||||
profile.clock.mean_temp_c.is_some(),
|
||||
"breathable temperate body derives a temperature baseline"
|
||||
);
|
||||
assert!((0..=100).contains(&profile.moisture_q));
|
||||
|
||||
// Determinism: identical inputs → bit-identical region output.
|
||||
let key = |s: &CascadeSnapshot| {
|
||||
s.layer_region
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.regions
|
||||
.iter()
|
||||
.map(|(pos, p)| {
|
||||
(
|
||||
*pos,
|
||||
p.clock.season as u8,
|
||||
p.clock.weather as u8,
|
||||
p.clock.mean_temp_c.map(f32::to_bits),
|
||||
p.moisture_q,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
key(&snap),
|
||||
key(&run()),
|
||||
"region layer must be deterministic"
|
||||
);
|
||||
|
||||
// The regions propagate into the hot-cache BodyWorldState.
|
||||
let state = snap.into_body_world_state();
|
||||
assert_eq!(state.regions.len(), 1);
|
||||
assert!(state.regions.contains_key(&(0, 0)));
|
||||
|
||||
// No body params → the layer skips and regions stays empty (mirrors
|
||||
// the DistrictProfile gate).
|
||||
let no_params = run_cascade_from_heightmap(
|
||||
body_seed(),
|
||||
test_heightmap(),
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
CascadeLayer::Region,
|
||||
);
|
||||
assert!(no_params.layer_region.is_none());
|
||||
assert!(no_params.into_body_world_state().regions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user