feat(simulation): serve the coarse district/morphology grid through the Atlas proxy (T-1046)
Server half of wiring the derived tier into production. The D-225 proxy (AtlasLayerResponse) carried only Layer1Output, so the Atlas could not show the DistrictProfile tier that the cascade derives. Adds DistrictGridLayer (cols/rows + row-major MorphologyZone discriminants + elev_q for relief shading) and build_district_grid(), populated on a cache hit. Because the response is rmp_serde msgpack end-to-end, the new field reaches the client automatically. This is the planetary-scale coarse grid (the Atlas map view); the on-demand 2km derive_district (T-1077) is for in-world Phase 5, not the map. MorphologyZone gains Copy (fieldless repr(u8) enum; additive). Next: client decode (protocol.gd) + the D-226 generation overlay in the Atlas viewer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,12 +51,64 @@ pub enum AtlasLayerStatus {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// A layer response: the computed `Layer1Output`, or a non-ready status.
|
||||
/// The cascade's coarse district grid, surfaced for the Atlas generation overlay
|
||||
/// (T-1046, D-226). This is the **planetary-scale** view — one cell per coarse
|
||||
/// grid square (`grid_w/cols` heightmap pixels) — not the on-demand 2 km districts
|
||||
/// (those derive only when a player enters a settlement, Phase 5). `morphology`
|
||||
/// and `elev_q` are row-major (`rows × cols`); `morphology[i]` is a `MorphologyZone`
|
||||
/// discriminant (D-239 §6, `repr(u8)`), `elev_q[i]` is 0–100 elevation for relief
|
||||
/// shading. The client maps `cols × rows` onto the displayed heightmap.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DistrictGridLayer {
|
||||
pub cols: u32,
|
||||
pub rows: u32,
|
||||
pub morphology: Vec<u8>,
|
||||
pub elev_q: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A layer response: the computed `Layer1Output` + the coarse district grid
|
||||
/// (D-225, T-1046), or a non-ready status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AtlasLayerResponse {
|
||||
pub body_id: String,
|
||||
pub status: AtlasLayerStatus,
|
||||
pub layer1: Option<Layer1Output>,
|
||||
/// The coarse district/morphology grid for the Atlas overlay (T-1046).
|
||||
/// `Some` on a cache hit once the DistrictProfile layer has run; `None` otherwise.
|
||||
pub district_grid: Option<DistrictGridLayer>,
|
||||
}
|
||||
|
||||
/// Build the coarse [`DistrictGridLayer`] from a body's cached state (T-1046).
|
||||
/// Returns `None` when the DistrictProfile layer has not run (empty `districts`).
|
||||
/// The grid is dense `[0, cols) × [0, rows)` (the cascade tiles the full
|
||||
/// heightmap), so the extent comes from the maximum `DistrictPos`.
|
||||
pub fn build_district_grid(
|
||||
state: &crate::atlas::body_world_state::BodyWorldState,
|
||||
) -> Option<DistrictGridLayer> {
|
||||
if state.districts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cols = state.districts.keys().map(|(x, _)| *x).max().unwrap_or(0) as u32 + 1;
|
||||
let rows = state.districts.keys().map(|(_, y)| *y).max().unwrap_or(0) as u32 + 1;
|
||||
let n = (cols * rows) as usize;
|
||||
let mut morphology = vec![0u8; n];
|
||||
let mut elev_q = vec![0u8; n];
|
||||
for (&(x, y), profile) in &state.districts {
|
||||
if x < 0 || y < 0 {
|
||||
continue;
|
||||
}
|
||||
let i = (y as u32 * cols + x as u32) as usize;
|
||||
if i < n {
|
||||
morphology[i] = profile.morphology_zone as u8;
|
||||
elev_q[i] = profile.elev_q.clamp(0, 100) as u8;
|
||||
}
|
||||
}
|
||||
Some(DistrictGridLayer {
|
||||
cols,
|
||||
rows,
|
||||
morphology,
|
||||
elev_q,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve one layer request (D-225). `current_tick` stamps the cache LRU on hit;
|
||||
@@ -94,10 +146,12 @@ pub fn handle_atlas_request(
|
||||
grid_w: state.heightmap_width,
|
||||
grid_h: state.heightmap_height,
|
||||
};
|
||||
let district_grid = build_district_grid(state);
|
||||
return AtlasLayerResponse {
|
||||
body_id: req.body_id.clone(),
|
||||
status: AtlasLayerStatus::Ready,
|
||||
layer1: Some(layer1),
|
||||
district_grid,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,6 +221,7 @@ pub fn handle_atlas_request(
|
||||
body_id: req.body_id.clone(),
|
||||
status: AtlasLayerStatus::Pending,
|
||||
layer1: None,
|
||||
district_grid: None,
|
||||
}
|
||||
}
|
||||
// Unknown / no terrain → re-requesting won't help.
|
||||
@@ -175,11 +230,13 @@ pub fn handle_atlas_request(
|
||||
body_id: req.body_id.clone(),
|
||||
status: AtlasLayerStatus::NotFound,
|
||||
layer1: None,
|
||||
district_grid: None,
|
||||
},
|
||||
Err(e) => AtlasLayerResponse {
|
||||
body_id: req.body_id.clone(),
|
||||
status: AtlasLayerStatus::Error(e.to_string()),
|
||||
layer1: None,
|
||||
district_grid: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -196,6 +253,62 @@ mod tests {
|
||||
|
||||
static SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
#[test]
|
||||
fn district_grid_built_from_cached_districts() {
|
||||
use crate::atlas::district_profile::{
|
||||
DistrictProfile, GlaciationGrade, PrecipitationClass, TectonicClass, VegetationClass,
|
||||
};
|
||||
use crate::simulation::generator::MorphologyZone;
|
||||
let dp = |zone: MorphologyZone, elev: i32| DistrictProfile {
|
||||
morphology_zone: zone,
|
||||
tectonic_class: TectonicClass::Stable,
|
||||
glaciation_grade: GlaciationGrade::None,
|
||||
precipitation_class: PrecipitationClass::Arid,
|
||||
slope_q: 0,
|
||||
elev_q: elev,
|
||||
ocean_fraction_q: 0,
|
||||
river_threshold: 200,
|
||||
temperature_c: Some(10.0),
|
||||
moisture_q: 50,
|
||||
vegetation_class: VegetationClass::Barren,
|
||||
};
|
||||
let mut state = BodyWorldState {
|
||||
body_id: "GJ1c".into(),
|
||||
heightmap: vec![],
|
||||
heightmap_width: 16,
|
||||
heightmap_height: 8,
|
||||
river_network: RiverNetwork::default(),
|
||||
drainage_basins: vec![],
|
||||
attractors: vec![],
|
||||
placements: vec![],
|
||||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||||
quarters: std::collections::BTreeMap::new(),
|
||||
districts: std::collections::BTreeMap::new(),
|
||||
last_accessed: 0,
|
||||
};
|
||||
// 3×2 grid with two distinct zones at the corners.
|
||||
state
|
||||
.districts
|
||||
.insert((0, 0), dp(MorphologyZone::AlluvialPlain, 10));
|
||||
state
|
||||
.districts
|
||||
.insert((2, 1), dp(MorphologyZone::Alpine, 90));
|
||||
|
||||
let grid = build_district_grid(&state).expect("districts present → Some grid");
|
||||
assert_eq!((grid.cols, grid.rows), (3, 2));
|
||||
assert_eq!(grid.morphology.len(), 6);
|
||||
assert_eq!(grid.morphology[0], MorphologyZone::AlluvialPlain as u8);
|
||||
assert_eq!(
|
||||
grid.morphology[grid.cols as usize + 2],
|
||||
MorphologyZone::Alpine as u8
|
||||
);
|
||||
assert_eq!(grid.elev_q[grid.cols as usize + 2], 90);
|
||||
|
||||
// Empty districts → None (DistrictProfile layer hasn't run).
|
||||
state.districts.clear();
|
||||
assert!(build_district_grid(&state).is_none());
|
||||
}
|
||||
|
||||
fn req(body_id: &str) -> AtlasLayerRequest {
|
||||
AtlasLayerRequest {
|
||||
body_id: body_id.to_string(),
|
||||
|
||||
@@ -79,6 +79,7 @@ fn serve_atlas_requests(
|
||||
body_id: req.body_id.clone(),
|
||||
status: AtlasLayerStatus::Error("no body source resolver".to_string()),
|
||||
layer1: None,
|
||||
district_grid: None,
|
||||
},
|
||||
};
|
||||
responses.0.push(resp);
|
||||
|
||||
@@ -3118,7 +3118,7 @@ mod tests {
|
||||
];
|
||||
for zone in &zones {
|
||||
let district = DistrictProfile {
|
||||
morphology_zone: zone.clone(),
|
||||
morphology_zone: *zone,
|
||||
tectonic_class: TectonicClass::Volcanic,
|
||||
glaciation_grade: GlaciationGrade::Moderate,
|
||||
slope_q: 50,
|
||||
@@ -3153,7 +3153,7 @@ mod tests {
|
||||
];
|
||||
for (zone, expected_terrain) in cases {
|
||||
let district = DistrictProfile {
|
||||
morphology_zone: zone.clone(),
|
||||
morphology_zone: *zone,
|
||||
tectonic_class: TectonicClass::Volcanic,
|
||||
glaciation_grade: GlaciationGrade::Moderate,
|
||||
slope_q: 50,
|
||||
|
||||
@@ -986,7 +986,7 @@ pub struct DoorSpec {
|
||||
///
|
||||
/// Integer-discriminant, D-010 compliant. `repr(u8)` pins values for
|
||||
/// serialisation stability; append-only.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
#[repr(u8)]
|
||||
pub enum MorphologyZone {
|
||||
/// Deep-water ocean — hub-and-spoke; perimeter access priority.
|
||||
|
||||
Reference in New Issue
Block a user