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:
@@ -774,11 +774,12 @@ static func decode_atlas_layer_response(bytes: PackedByteArray) -> Variant:
|
|||||||
## road_graph/settlements (T-960): passthrough fields for the L2 road/rail
|
## road_graph/settlements (T-960): passthrough fields for the L2 road/rail
|
||||||
## graph and L3 settlement placements, mirroring the district_grid precedent
|
## graph and L3 settlement placements, mirroring the district_grid precedent
|
||||||
## (T-1046) — raw decoded maps/arrays, no further client-side reshaping.
|
## (T-1046) — raw decoded maps/arrays, no further client-side reshaping.
|
||||||
## Key names "road_graph"/"settlements" are the CONFIRMED wire contract —
|
## region_grid (T-1113): the region climate grid, same passthrough pattern.
|
||||||
## identical to server/src/atlas/layer_proxy.rs AtlasLayerResponse's field
|
## Key names "road_graph"/"settlements"/"region_grid" are the CONFIRMED wire
|
||||||
## names (pinned 2026-07-14; round-tripped by test_atlas_overlays.gd and the
|
## contract — identical to server/src/atlas/layer_proxy.rs AtlasLayerResponse's
|
||||||
## server's msgpack round-trip tests). This remains the one client-side spot
|
## field names (pinned 2026-07-14; round-tripped by test_atlas_overlays.gd and
|
||||||
## to touch if the contract ever changes.
|
## the server's msgpack round-trip tests). This remains the one client-side
|
||||||
|
## spot to touch if the contract ever changes.
|
||||||
static func atlas_response_from_raw(raw: Variant) -> Variant:
|
static func atlas_response_from_raw(raw: Variant) -> Variant:
|
||||||
if not raw is Dictionary or not raw.has("status"):
|
if not raw is Dictionary or not raw.has("status"):
|
||||||
return null
|
return null
|
||||||
@@ -798,6 +799,7 @@ static func atlas_response_from_raw(raw: Variant) -> Variant:
|
|||||||
"district_grid": raw.get("district_grid"),
|
"district_grid": raw.get("district_grid"),
|
||||||
"road_graph": raw.get("road_graph"),
|
"road_graph": raw.get("road_graph"),
|
||||||
"settlements": raw.get("settlements"),
|
"settlements": raw.get("settlements"),
|
||||||
|
"region_grid": raw.get("region_grid"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
†§body_id¥ghost¦status¨NotFound¦layer1Àdistrict_gridÀªroad_graphÀ«settlementsÀ
|
‡§body_id¥ghost¦status¨NotFound¦layer1Àdistrict_gridÀªroad_graphÀ«settlementsÀ«region_gridÀ
|
||||||
@@ -1 +1 @@
|
|||||||
�body_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements�
|
𣇪body_id二J1c存tatus判ending奸ayer1嶺district_grid尷road_graph屨settlements屨region_grid�
|
||||||
Binary file not shown.
@@ -15,7 +15,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::atlas::attractor_matching::CityPlacement;
|
use crate::atlas::attractor_matching::CityPlacement;
|
||||||
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
|
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
|
||||||
|
use crate::atlas::region_profile::RegionProfile;
|
||||||
use crate::atlas::road_graph::RoadGraph;
|
use crate::atlas::road_graph::RoadGraph;
|
||||||
|
use crate::atlas::scale::RegionPos;
|
||||||
use crate::simulation::generator::{
|
use crate::simulation::generator::{
|
||||||
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
|
GeographicAttractor, QuarterId, QuarterWorldState, TerritorialStatus,
|
||||||
};
|
};
|
||||||
@@ -100,6 +102,15 @@ pub struct BodyWorldState {
|
|||||||
/// `BTreeMap` keyed by `DistrictPos` for D-010 determinism.
|
/// `BTreeMap` keyed by `DistrictPos` for D-010 determinism.
|
||||||
/// Empty until the DistrictProfile layer has run.
|
/// Empty until the DistrictProfile layer has run.
|
||||||
pub districts: BTreeMap<DistrictPos, DistrictProfile>,
|
pub districts: BTreeMap<DistrictPos, DistrictProfile>,
|
||||||
|
/// Per-region (~205 km) climate context — season/weather/temperature
|
||||||
|
/// baseline cells (D-243 §3, T-1113).
|
||||||
|
///
|
||||||
|
/// Populated by the background cascade's Region layer; the covering grid
|
||||||
|
/// only (no blend-padding ring — see `cascade::LayerRegionOutput`).
|
||||||
|
/// `BTreeMap` keyed by `RegionPos` for D-010 determinism. Empty until the
|
||||||
|
/// Region layer has run. Footprint is trivial (~195×98 cells at the D-243
|
||||||
|
/// true-scale ceiling; a handful on today's working grids).
|
||||||
|
pub regions: BTreeMap<RegionPos, RegionProfile>,
|
||||||
/// Last sim tick this entry was read. Used for LRU eviction.
|
/// Last sim tick this entry was read. Used for LRU eviction.
|
||||||
pub last_accessed: SimTick,
|
pub last_accessed: SimTick,
|
||||||
}
|
}
|
||||||
@@ -224,6 +235,7 @@ mod tests {
|
|||||||
road_graph: RoadGraph::default(),
|
road_graph: RoadGraph::default(),
|
||||||
quarters: BTreeMap::new(),
|
quarters: BTreeMap::new(),
|
||||||
districts: BTreeMap::new(),
|
districts: BTreeMap::new(),
|
||||||
|
regions: BTreeMap::new(),
|
||||||
last_accessed: tick,
|
last_accessed: tick,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+147
-1
@@ -28,8 +28,9 @@ use crate::atlas::district_profile::{self, BodyParams, DistrictPos, DistrictProf
|
|||||||
use crate::atlas::features::TerrainAnalysis;
|
use crate::atlas::features::TerrainAnalysis;
|
||||||
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
use crate::atlas::heightmap::{self, BodyHeightmap, HeightmapLoadError};
|
||||||
use crate::atlas::layer1::{self, Layer1Output};
|
use crate::atlas::layer1::{self, Layer1Output};
|
||||||
|
use crate::atlas::region_profile::{self, RegionProfile};
|
||||||
use crate::atlas::road_graph::{self, RoadGraph};
|
use crate::atlas::road_graph::{self, RoadGraph};
|
||||||
use crate::atlas::scale;
|
use crate::atlas::scale::{self, RegionPos};
|
||||||
use crate::seed::SeedChain;
|
use crate::seed::SeedChain;
|
||||||
use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor, TerritorialStatus};
|
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
|
/// last** to honour the append-only `Ord` rule (it neither needs nor blocks
|
||||||
/// the DistrictProfile layer; requesting it runs DistrictProfile first, harmlessly).
|
/// the DistrictProfile layer; requesting it runs DistrictProfile first, harmlessly).
|
||||||
RoadGraph,
|
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).
|
/// 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
|
/// Layer 2 — inter-settlement road/rail graph. `Some` once
|
||||||
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
|
/// [`CascadeLayer::RoadGraph`] has run (D-211, T-1038).
|
||||||
pub road_graph: Option<RoadGraph>,
|
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
|
/// **Transient** — the `TerrainAnalysis` produced by the Layer-1 drainage
|
||||||
/// pass (T-1044). Populated when Layer 1 runs; consumed (and freed) once
|
/// pass (T-1044). Populated when Layer 1 runs; consumed (and freed) once
|
||||||
/// both `DistrictProfile` and `RoadGraph` have consumed it.
|
/// both `DistrictProfile` and `RoadGraph` have consumed it.
|
||||||
@@ -98,6 +112,19 @@ pub struct LayerDistrictOutput {
|
|||||||
pub districts: std::collections::BTreeMap<DistrictPos, DistrictProfile>,
|
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
|
/// Layer 3 output (#955, D-211): attractor-matched settlement placements for the
|
||||||
/// body. Re-derivable from (Layer-1 attractors + settlement records + seed).
|
/// body. Re-derivable from (Layer-1 attractors + settlement records + seed).
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -123,6 +150,7 @@ impl CascadeSnapshot {
|
|||||||
.layer_district
|
.layer_district
|
||||||
.map(|lr| lr.districts)
|
.map(|lr| lr.districts)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let regions = self.layer_region.map(|lr| lr.regions).unwrap_or_default();
|
||||||
let road_graph = self.road_graph.unwrap_or_default();
|
let road_graph = self.road_graph.unwrap_or_default();
|
||||||
// terrain_analysis (transient) is intentionally dropped here.
|
// terrain_analysis (transient) is intentionally dropped here.
|
||||||
let _ = self.terrain_analysis;
|
let _ = self.terrain_analysis;
|
||||||
@@ -138,6 +166,7 @@ impl CascadeSnapshot {
|
|||||||
road_graph,
|
road_graph,
|
||||||
quarters: std::collections::BTreeMap::new(),
|
quarters: std::collections::BTreeMap::new(),
|
||||||
districts,
|
districts,
|
||||||
|
regions,
|
||||||
last_accessed: 0,
|
last_accessed: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,6 +232,7 @@ pub fn run_cascade_from_heightmap(
|
|||||||
layer3: None,
|
layer3: None,
|
||||||
layer_district: None,
|
layer_district: None,
|
||||||
road_graph: None,
|
road_graph: None,
|
||||||
|
layer_region: None,
|
||||||
terrain_analysis: None,
|
terrain_analysis: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -330,6 +360,42 @@ pub fn run_cascade_from_heightmap(
|
|||||||
snapshot.terrain_analysis = None;
|
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
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +527,7 @@ mod tests {
|
|||||||
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
|
assert!(CascadeLayer::Topography < CascadeLayer::Settlement);
|
||||||
assert!(CascadeLayer::Settlement < CascadeLayer::DistrictProfile);
|
assert!(CascadeLayer::Settlement < CascadeLayer::DistrictProfile);
|
||||||
assert!(CascadeLayer::DistrictProfile < CascadeLayer::RoadGraph);
|
assert!(CascadeLayer::DistrictProfile < CascadeLayer::RoadGraph);
|
||||||
|
assert!(CascadeLayer::RoadGraph < CascadeLayer::Region);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -794,4 +861,83 @@ mod tests {
|
|||||||
// Silence unused-import warning when the filter above changes.
|
// Silence unused-import warning when the filter above changes.
|
||||||
let _ = RoadNodeKind::Settlement;
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -407,13 +407,14 @@ fn run_work_item(item: &GenWorkItem) -> GenCompletion {
|
|||||||
} else {
|
} else {
|
||||||
hm
|
hm
|
||||||
};
|
};
|
||||||
// Run the full cascade through RoadGraph (Layer 2, T-1038), the
|
// Run the full cascade through Region (T-1113), the terminal
|
||||||
// terminal layer. It subsumes Settlement, DistrictProfile (T-1023),
|
// layer. It subsumes RoadGraph (T-1038), Settlement,
|
||||||
// and all prior layers. DistrictProfile derivation still gates on
|
// DistrictProfile (T-1023), and all prior layers.
|
||||||
// body_params internally (skipped when absent — e.g. a body with no
|
// DistrictProfile and Region derivation both gate on
|
||||||
// params row), but the road graph needs no body params, so it runs
|
// body_params internally (skipped when absent — e.g. a body
|
||||||
// for every analyzed body.
|
// with no params row), but the road graph needs no body
|
||||||
let up_to = CascadeLayer::RoadGraph;
|
// params, so it runs for every analyzed body.
|
||||||
|
let up_to = CascadeLayer::Region;
|
||||||
let snapshot = run_cascade_from_heightmap(
|
let snapshot = run_cascade_from_heightmap(
|
||||||
*body_seed,
|
*body_seed,
|
||||||
working,
|
working,
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ pub struct DistrictGridLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A layer response: the computed `Layer1Output` + the coarse district grid
|
/// 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
|
/// (D-225, T-1046) + the road-graph and settlement overlays (T-960 §1/§2) +
|
||||||
/// a non-ready status.
|
/// the region climate grid (T-1113), or a non-ready status.
|
||||||
///
|
///
|
||||||
/// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer
|
/// Growth ceiling (governance-bounded): the one-`Option`-field-per-layer
|
||||||
/// pattern tops out around six fields — D-226's 2026-07-13 amendment (d)
|
/// 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
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AtlasLayerResponse {
|
pub struct AtlasLayerResponse {
|
||||||
pub body_id: String,
|
pub body_id: String,
|
||||||
@@ -94,6 +94,9 @@ pub struct AtlasLayerResponse {
|
|||||||
/// The settlement-placement overlay (T-960 §2, #955). `Some` on a cache hit
|
/// 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.
|
/// once the Settlement layer has placed at least one city; `None` otherwise.
|
||||||
pub settlements: Option<SettlementLayer>,
|
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).
|
/// 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)
|
// RoadGraphLayer (T-960 §1, T-1038)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -341,6 +421,7 @@ pub fn handle_atlas_request(
|
|||||||
let district_grid = build_district_grid(state);
|
let district_grid = build_district_grid(state);
|
||||||
let road_graph = build_road_graph_layer(state);
|
let road_graph = build_road_graph_layer(state);
|
||||||
let settlements = build_settlement_layer(state);
|
let settlements = build_settlement_layer(state);
|
||||||
|
let region_grid = build_region_grid(state);
|
||||||
return AtlasLayerResponse {
|
return AtlasLayerResponse {
|
||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
status: AtlasLayerStatus::Ready,
|
status: AtlasLayerStatus::Ready,
|
||||||
@@ -348,6 +429,7 @@ pub fn handle_atlas_request(
|
|||||||
district_grid,
|
district_grid,
|
||||||
road_graph,
|
road_graph,
|
||||||
settlements,
|
settlements,
|
||||||
|
region_grid,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +502,7 @@ pub fn handle_atlas_request(
|
|||||||
district_grid: None,
|
district_grid: None,
|
||||||
road_graph: None,
|
road_graph: None,
|
||||||
settlements: None,
|
settlements: None,
|
||||||
|
region_grid: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Unknown / no terrain → re-requesting won't help.
|
// Unknown / no terrain → re-requesting won't help.
|
||||||
@@ -431,6 +514,7 @@ pub fn handle_atlas_request(
|
|||||||
district_grid: None,
|
district_grid: None,
|
||||||
road_graph: None,
|
road_graph: None,
|
||||||
settlements: None,
|
settlements: None,
|
||||||
|
region_grid: None,
|
||||||
},
|
},
|
||||||
Err(e) => AtlasLayerResponse {
|
Err(e) => AtlasLayerResponse {
|
||||||
body_id: req.body_id.clone(),
|
body_id: req.body_id.clone(),
|
||||||
@@ -439,6 +523,7 @@ pub fn handle_atlas_request(
|
|||||||
district_grid: None,
|
district_grid: None,
|
||||||
road_graph: None,
|
road_graph: None,
|
||||||
settlements: None,
|
settlements: None,
|
||||||
|
region_grid: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,6 +572,7 @@ mod tests {
|
|||||||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||||||
quarters: std::collections::BTreeMap::new(),
|
quarters: std::collections::BTreeMap::new(),
|
||||||
districts: std::collections::BTreeMap::new(),
|
districts: std::collections::BTreeMap::new(),
|
||||||
|
regions: std::collections::BTreeMap::new(),
|
||||||
last_accessed: 0,
|
last_accessed: 0,
|
||||||
};
|
};
|
||||||
// 3×2 grid with two distinct zones at the corners.
|
// 3×2 grid with two distinct zones at the corners.
|
||||||
@@ -512,6 +598,64 @@ mod tests {
|
|||||||
assert!(build_district_grid(&state).is_none());
|
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 —
|
/// A blank `BodyWorldState` for tests that only care about one field —
|
||||||
/// callers overwrite `placements`/`road_graph`/etc. as needed.
|
/// callers overwrite `placements`/`road_graph`/etc. as needed.
|
||||||
fn blank_state(body_id: &str) -> BodyWorldState {
|
fn blank_state(body_id: &str) -> BodyWorldState {
|
||||||
@@ -527,6 +671,7 @@ mod tests {
|
|||||||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||||||
quarters: std::collections::BTreeMap::new(),
|
quarters: std::collections::BTreeMap::new(),
|
||||||
districts: std::collections::BTreeMap::new(),
|
districts: std::collections::BTreeMap::new(),
|
||||||
|
regions: std::collections::BTreeMap::new(),
|
||||||
last_accessed: 0,
|
last_accessed: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -728,6 +873,7 @@ mod tests {
|
|||||||
district_grid: None,
|
district_grid: None,
|
||||||
road_graph: build_road_graph_layer(&state),
|
road_graph: build_road_graph_layer(&state),
|
||||||
settlements: build_settlement_layer(&state),
|
settlements: build_settlement_layer(&state),
|
||||||
|
region_grid: build_region_grid(&state),
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
|
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
|
||||||
@@ -829,6 +975,7 @@ mod tests {
|
|||||||
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
road_graph: crate::atlas::road_graph::RoadGraph::default(),
|
||||||
quarters: std::collections::BTreeMap::new(),
|
quarters: std::collections::BTreeMap::new(),
|
||||||
districts: std::collections::BTreeMap::new(),
|
districts: std::collections::BTreeMap::new(),
|
||||||
|
regions: std::collections::BTreeMap::new(),
|
||||||
last_accessed: 0,
|
last_accessed: 0,
|
||||||
});
|
});
|
||||||
let (_db, resolver) = empty_resolver();
|
let (_db, resolver) = empty_resolver();
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ fn serve_atlas_requests(
|
|||||||
district_grid: None,
|
district_grid: None,
|
||||||
road_graph: None,
|
road_graph: None,
|
||||||
settlements: None,
|
settlements: None,
|
||||||
|
region_grid: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
responses.0.push(resp);
|
responses.0.push(resp);
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ fn generate_atlas_layer_response_fixtures() {
|
|||||||
district_grid: None,
|
district_grid: None,
|
||||||
road_graph: Some(road_graph),
|
road_graph: Some(road_graph),
|
||||||
settlements: Some(settlements),
|
settlements: Some(settlements),
|
||||||
|
region_grid: None,
|
||||||
};
|
};
|
||||||
write_fixture(
|
write_fixture(
|
||||||
"atlas_response_ready",
|
"atlas_response_ready",
|
||||||
@@ -637,6 +638,7 @@ fn generate_atlas_layer_response_fixtures() {
|
|||||||
district_grid: None,
|
district_grid: None,
|
||||||
road_graph: None,
|
road_graph: None,
|
||||||
settlements: None,
|
settlements: None,
|
||||||
|
region_grid: None,
|
||||||
};
|
};
|
||||||
write_fixture(
|
write_fixture(
|
||||||
"atlas_response_pending",
|
"atlas_response_pending",
|
||||||
@@ -650,6 +652,7 @@ fn generate_atlas_layer_response_fixtures() {
|
|||||||
district_grid: None,
|
district_grid: None,
|
||||||
road_graph: None,
|
road_graph: None,
|
||||||
settlements: None,
|
settlements: None,
|
||||||
|
region_grid: None,
|
||||||
};
|
};
|
||||||
write_fixture(
|
write_fixture(
|
||||||
"atlas_response_not_found",
|
"atlas_response_not_found",
|
||||||
|
|||||||
Reference in New Issue
Block a user