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:
@@ -70,13 +70,13 @@ pub struct DistrictGridLayer {
|
||||
}
|
||||
|
||||
/// A layer response: the computed `Layer1Output` + the coarse district grid
|
||||
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2), or
|
||||
/// a non-ready status.
|
||||
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2) +
|
||||
/// the region climate grid (T-1113), or a non-ready status.
|
||||
///
|
||||
/// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer
|
||||
/// pattern tops out around six fields — D-226's 2026-07-13 amendment (d)
|
||||
/// rules out any L5/tile Atlas layer ever, leaving T-1112 (quarter
|
||||
/// footprints) and T-1113 (region climate) as the only remaining candidates.
|
||||
/// footprints) as the only remaining candidate.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AtlasLayerResponse {
|
||||
pub body_id: String,
|
||||
@@ -94,6 +94,9 @@ pub struct AtlasLayerResponse {
|
||||
/// The settlement-placement overlay (T-960 §2, #955). `Some` on a cache hit
|
||||
/// once the Settlement layer has placed at least one city; `None` otherwise.
|
||||
pub settlements: Option<SettlementLayer>,
|
||||
/// The region climate grid for the Atlas overlay (D-243 §3, T-1113).
|
||||
/// `Some` on a cache hit once the Region layer has run; `None` otherwise.
|
||||
pub region_grid: Option<RegionGridLayer>,
|
||||
}
|
||||
|
||||
/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046).
|
||||
@@ -129,6 +132,83 @@ pub fn build_district_grid(
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RegionGridLayer (T-1113, D-243 §3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The ~205 km region climate grid for the Atlas overlay (T-1113), dense
|
||||
/// row-major like [`DistrictGridLayer`] (the T-1046 encoding precedent).
|
||||
/// Serves the **mean-state** `RegionClock` fields only — the Q-105 tick-phase
|
||||
/// callbacks are deferred, so what ships is the static climate context.
|
||||
///
|
||||
/// Wire encoding is all-integer (D-010 wire discipline):
|
||||
/// - `season[i]` / `weather[i]` — the `repr(u8)` discriminants of
|
||||
/// `SeasonPhase` / `WeatherState` (pinned, append-only).
|
||||
/// - `mean_temp_dc[i]` — mean-annual temperature baseline in **deci-°C**
|
||||
/// (×10, `round`ed; 0.1 °C is ample for a map overlay). `i16::MIN` is the
|
||||
/// sentinel for "no atmosphere → no temperature" (airless bodies carry
|
||||
/// `mean_temp_c: None`); real values are class-band-clamped far inside
|
||||
/// i16 range.
|
||||
/// - `moisture_q[i]` — the 0–100 region moisture primitive.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RegionGridLayer {
|
||||
pub cols: u32,
|
||||
pub rows: u32,
|
||||
pub season: Vec<u8>,
|
||||
pub weather: Vec<u8>,
|
||||
pub mean_temp_dc: Vec<i16>,
|
||||
pub moisture_q: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Sentinel for "airless body — no temperature baseline" in
|
||||
/// [`RegionGridLayer::mean_temp_dc`].
|
||||
pub const REGION_TEMP_NONE_DC: i16 = i16::MIN;
|
||||
|
||||
/// Build the [`RegionGridLayer`] from a body's cached state (T-1113).
|
||||
/// Returns `None` when the Region layer has not run (empty `regions`).
|
||||
/// The stored region set is the dense covering grid `[0, cols) × [0, rows)`
|
||||
/// (see `cascade::LayerRegionOutput` — no blend-padding ring), so the extent
|
||||
/// comes from the maximum `RegionPos`, mirroring [`build_district_grid`].
|
||||
pub fn build_region_grid(
|
||||
state: &crate::atlas::body_world_state::BodyWorldState,
|
||||
) -> Option<RegionGridLayer> {
|
||||
if state.regions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cols = state.regions.keys().map(|(x, _)| *x).max().unwrap_or(0) as u32 + 1;
|
||||
let rows = state.regions.keys().map(|(_, y)| *y).max().unwrap_or(0) as u32 + 1;
|
||||
let n = (cols * rows) as usize;
|
||||
let mut season = vec![0u8; n];
|
||||
let mut weather = vec![0u8; n];
|
||||
let mut mean_temp_dc = vec![REGION_TEMP_NONE_DC; n];
|
||||
let mut moisture_q = vec![0u8; n];
|
||||
for (&(x, y), profile) in &state.regions {
|
||||
if x < 0 || y < 0 {
|
||||
continue;
|
||||
}
|
||||
let i = (y as u32 * cols + x as u32) as usize;
|
||||
if i < n {
|
||||
season[i] = profile.clock.season as u8;
|
||||
weather[i] = profile.clock.weather as u8;
|
||||
mean_temp_dc[i] = match profile.clock.mean_temp_c {
|
||||
Some(t) => {
|
||||
((t * 10.0).round() as i32).clamp(i16::MIN as i32 + 1, i16::MAX as i32) as i16
|
||||
}
|
||||
None => REGION_TEMP_NONE_DC,
|
||||
};
|
||||
moisture_q[i] = profile.moisture_q.clamp(0, 100) as u8;
|
||||
}
|
||||
}
|
||||
Some(RegionGridLayer {
|
||||
cols,
|
||||
rows,
|
||||
season,
|
||||
weather,
|
||||
mean_temp_dc,
|
||||
moisture_q,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RoadGraphLayer (T-960 §1, T-1038)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -341,6 +421,7 @@ pub fn handle_atlas_request(
|
||||
let district_grid = build_district_grid(state);
|
||||
let road_graph = build_road_graph_layer(state);
|
||||
let settlements = build_settlement_layer(state);
|
||||
let region_grid = build_region_grid(state);
|
||||
return AtlasLayerResponse {
|
||||
body_id: req.body_id.clone(),
|
||||
status: AtlasLayerStatus::Ready,
|
||||
@@ -348,6 +429,7 @@ pub fn handle_atlas_request(
|
||||
district_grid,
|
||||
road_graph,
|
||||
settlements,
|
||||
region_grid,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -420,6 +502,7 @@ pub fn handle_atlas_request(
|
||||
district_grid: None,
|
||||
road_graph: None,
|
||||
settlements: None,
|
||||
region_grid: None,
|
||||
}
|
||||
}
|
||||
// Unknown / no terrain → re-requesting won't help.
|
||||
@@ -431,6 +514,7 @@ pub fn handle_atlas_request(
|
||||
district_grid: None,
|
||||
road_graph: None,
|
||||
settlements: None,
|
||||
region_grid: None,
|
||||
},
|
||||
Err(e) => AtlasLayerResponse {
|
||||
body_id: req.body_id.clone(),
|
||||
@@ -439,6 +523,7 @@ pub fn handle_atlas_request(
|
||||
district_grid: None,
|
||||
road_graph: None,
|
||||
settlements: None,
|
||||
region_grid: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -487,6 +572,7 @@ mod tests {
|
||||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
districts: std::collections::BTreeMap::new(),
|
||||
regions: std::collections::BTreeMap::new(),
|
||||
last_accessed: 0,
|
||||
};
|
||||
// 3×2 grid with two distinct zones at the corners.
|
||||
@@ -512,6 +598,64 @@ mod tests {
|
||||
assert!(build_district_grid(&state).is_none());
|
||||
}
|
||||
|
||||
/// T-1113: the region climate grid mirrors the district-grid encoding —
|
||||
/// `None` when the Region layer hasn't run; dense row-major with the
|
||||
/// integer wire quantization (deci-°C temp, `i16::MIN` airless sentinel)
|
||||
/// when it has.
|
||||
#[test]
|
||||
fn build_region_grid_encodes_dense_quantized_climate() {
|
||||
use crate::atlas::region_profile::{RegionClock, RegionProfile, SeasonPhase, WeatherState};
|
||||
|
||||
let mut state = blank_state("GJ1c");
|
||||
// Empty regions → None (the Region layer hasn't run).
|
||||
assert!(build_region_grid(&state).is_none());
|
||||
|
||||
// A 2×1 covering grid: one temperate region, one airless-style region
|
||||
// (mean_temp_c = None → the sentinel).
|
||||
state.regions.insert(
|
||||
(0, 0),
|
||||
RegionProfile {
|
||||
pos: (0, 0),
|
||||
clock: RegionClock {
|
||||
season: SeasonPhase::Summer,
|
||||
weather: WeatherState::Clear,
|
||||
mean_temp_c: Some(12.34),
|
||||
},
|
||||
latitude_deg: 45.0,
|
||||
moisture_q: 80,
|
||||
},
|
||||
);
|
||||
state.regions.insert(
|
||||
(1, 0),
|
||||
RegionProfile {
|
||||
pos: (1, 0),
|
||||
clock: RegionClock {
|
||||
season: SeasonPhase::Winter,
|
||||
weather: WeatherState::Snow,
|
||||
mean_temp_c: None,
|
||||
},
|
||||
latitude_deg: -10.0,
|
||||
moisture_q: 5,
|
||||
},
|
||||
);
|
||||
|
||||
let grid = build_region_grid(&state).expect("regions present → Some grid");
|
||||
assert_eq!((grid.cols, grid.rows), (2, 1));
|
||||
assert_eq!(grid.season.len(), 2);
|
||||
assert_eq!(grid.season[0], SeasonPhase::Summer as u8);
|
||||
assert_eq!(grid.weather[0], WeatherState::Clear as u8);
|
||||
// 12.34 °C → 123 deci-°C (rounded).
|
||||
assert_eq!(grid.mean_temp_dc[0], 123);
|
||||
assert_eq!(grid.moisture_q[0], 80);
|
||||
assert_eq!(grid.season[1], SeasonPhase::Winter as u8);
|
||||
assert_eq!(grid.weather[1], WeatherState::Snow as u8);
|
||||
assert_eq!(
|
||||
grid.mean_temp_dc[1], REGION_TEMP_NONE_DC,
|
||||
"airless None maps to the sentinel"
|
||||
);
|
||||
assert_eq!(grid.moisture_q[1], 5);
|
||||
}
|
||||
|
||||
/// A blank `BodyWorldState` for tests that only care about one field —
|
||||
/// callers overwrite `placements`/`road_graph`/etc. as needed.
|
||||
fn blank_state(body_id: &str) -> BodyWorldState {
|
||||
@@ -527,6 +671,7 @@ mod tests {
|
||||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
districts: std::collections::BTreeMap::new(),
|
||||
regions: std::collections::BTreeMap::new(),
|
||||
last_accessed: 0,
|
||||
}
|
||||
}
|
||||
@@ -728,6 +873,7 @@ mod tests {
|
||||
district_grid: None,
|
||||
road_graph: build_road_graph_layer(&state),
|
||||
settlements: build_settlement_layer(&state),
|
||||
region_grid: build_region_grid(&state),
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
|
||||
@@ -829,6 +975,7 @@ mod tests {
|
||||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
districts: std::collections::BTreeMap::new(),
|
||||
regions: std::collections::BTreeMap::new(),
|
||||
last_accessed: 0,
|
||||
});
|
||||
let (_db, resolver) = empty_resolver();
|
||||
|
||||
Reference in New Issue
Block a user